| 1 | import fs from 'node:fs'; |
| 2 | import path from 'node:path'; |
| 3 | |
| 4 | import express from 'express'; |
| 5 | import sanitize from 'sanitize-filename'; |
| 6 | import { Jimp, JimpMime } from '../jimp.js'; |
| 7 | import { sync as writeFileAtomicSync } from 'write-file-atomic'; |
| 8 | import { imageSize as sizeOf } from 'image-size'; |
| 9 | |
| 10 | import { getConfigValue, invalidateFirefoxCache } from '../util.js'; |
| 11 | import { getThumbnailResolution, isAnimatedWebP, isAnimatedApng, thumbnailDimensions as dimensions } from './image-metadata.js'; |
| 12 | import { ResizeStrategy } from '@jimp/plugin-resize'; |
| 13 | |
| 14 | export const publicRouter = express.Router(); |
| 15 | export const apiRouter = express.Router(); |
| 16 | |
| 17 | export const SKIPPED_EXTENSIONS = new Set(['.apng', '.mp4', '.webm', '.avi', '.mkv', '.flv', '.gif']); |
| 18 | export const ALLOWED_IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.tif', '.tiff', '.apng']); |
| 19 | |
| 20 | const thumbnailsEnabled = !!getConfigValue('thumbnails.enabled', true, 'boolean'); |
| 21 | const quality = Math.min(100, Math.max(1, parseInt(getConfigValue('thumbnails.quality', 95, 'number')))); |
| 22 | const pngFormat = String(getConfigValue('thumbnails.format', 'jpg')).toLowerCase().trim() === 'png'; |
| 23 | |
| 24 | /** |
| 25 | * @typedef {'bg' | 'avatar' | 'persona'} ThumbnailType |
| 26 | */ |
| 27 | |
| 28 | |
| 29 | /** |
| 30 | * Gets a path to thumbnail folder based on the type. |
| 31 | * @param {import('../users.js').UserDirectoryList} directories User directories |
| 32 | * @param {ThumbnailType} type Thumbnail type |
| 33 | * @returns {string} Path to the thumbnails folder |
| 34 | */ |
| 35 | function getThumbnailFolder(directories, type) { |
| 36 | let thumbnailFolder; |
| 37 | |
| 38 | switch (type) { |
| 39 | case 'bg': |
| 40 | thumbnailFolder = directories.thumbnailsBg; |
| 41 | break; |
| 42 | case 'avatar': |
| 43 | thumbnailFolder = directories.thumbnailsAvatar; |
| 44 | break; |
| 45 | case 'persona': |
| 46 | thumbnailFolder = directories.thumbnailsPersona; |
| 47 | break; |
| 48 | } |
| 49 | |
| 50 | return thumbnailFolder; |
| 51 | } |
| 52 | |
| 53 | /** |
| 54 | * Gets a path to the original images folder based on the type. |
| 55 | * @param {import('../users.js').UserDirectoryList} directories User directories |
| 56 | * @param {ThumbnailType} type Thumbnail type |
| 57 | * @returns {string} Path to the original images folder |
| 58 | */ |
| 59 | function getOriginalFolder(directories, type) { |
| 60 | let originalFolder; |
| 61 | |
| 62 | switch (type) { |
| 63 | case 'bg': |
| 64 | originalFolder = directories.backgrounds; |
| 65 | break; |
| 66 | case 'avatar': |
| 67 | originalFolder = directories.characters; |
| 68 | break; |
| 69 | case 'persona': |
| 70 | originalFolder = directories.avatars; |
| 71 | break; |
| 72 | } |
| 73 | |
| 74 | return originalFolder; |
| 75 | } |
| 76 | |
| 77 | /** |
| 78 | * Removes the generated thumbnail from the disk. |
| 79 | * @param {import('../users.js').UserDirectoryList} directories User directories |
| 80 | * @param {ThumbnailType} type Type of the thumbnail |
| 81 | * @param {string} file Name of the file |
| 82 | */ |
| 83 | export function invalidateThumbnail(directories, type, file) { |
| 84 | const folder = getThumbnailFolder(directories, type); |
| 85 | if (folder === undefined) throw new Error('Invalid thumbnail type'); |
| 86 | |
| 87 | const pathToThumbnail = path.join(folder, sanitize(file)); |
| 88 | |
| 89 | if (fs.existsSync(pathToThumbnail)) { |
| 90 | fs.unlinkSync(pathToThumbnail); |
| 91 | } |
| 92 | } |
| 93 | |
| 94 | /** |
| 95 | * Generates or retrieves a thumbnail for a given file. |
| 96 | * @param {import('../users.js').UserDirectoryList} directories - User's directory configuration. |
| 97 | * @param {ThumbnailType} type - Type of thumbnail ('bg', 'avatar', 'persona'). |
| 98 | * @param {string} file - The filename of the image. |
| 99 | * @param {boolean} [forceGenerate=false] - Whether to force generation even if a thumbnail exists. |
| 100 | * @param {boolean|null} [isKnownAnimated=null] - If true, skips generation. If false, assumes static. If null, checks. |
| 101 | * @returns {Promise<{path: string|null, aspectRatio: number|null, resolution: number|null}>} Path to thumbnail, its aspect ratio, and resolution. |
| 102 | */ |
| 103 | export async function generateThumbnail(directories, type, file, forceGenerate = false, isKnownAnimated = null) { |
| 104 | // If the caller has already determined the file is animated, skip processing. |
| 105 | if (isKnownAnimated) { |
| 106 | return { path: null, aspectRatio: null, resolution: null }; |
| 107 | } |
| 108 | |
| 109 | const thumbnailFolder = getThumbnailFolder(directories, type); |
| 110 | const originalFolder = getOriginalFolder(directories, type); |
| 111 | if (thumbnailFolder === undefined || originalFolder === undefined) throw new Error('Invalid thumbnail type'); |
| 112 | const pathToCachedFile = path.join(thumbnailFolder, file); |
| 113 | |
| 114 | try { |
| 115 | const pathToOriginalFile = path.join(originalFolder, file); |
| 116 | |
| 117 | // Check if thumbnail already exists and return it if not forcing regeneration |
| 118 | if (!forceGenerate && fs.existsSync(pathToCachedFile)) { |
| 119 | try { |
| 120 | // Check if original image was updated after thumbnail creation |
| 121 | const originalFileExists = fs.existsSync(pathToOriginalFile); |
| 122 | if (originalFileExists) { |
| 123 | const originalStat = fs.statSync(pathToOriginalFile); |
| 124 | const cachedStat = fs.statSync(pathToCachedFile); |
| 125 | |
| 126 | if (originalStat.mtimeMs > cachedStat.ctimeMs) { |
| 127 | // Original file changed, regenerate thumbnail |
| 128 | forceGenerate = true; |
| 129 | } |
| 130 | } |
| 131 | |
| 132 | if (!forceGenerate) { |
| 133 | const buffer = fs.readFileSync(pathToCachedFile); |
| 134 | const fileDimensions = sizeOf(buffer); |
| 135 | const ratio = (fileDimensions.height > 0) ? (fileDimensions.width / fileDimensions.height) : 1.0; |
| 136 | // When a thumbnail exists, return the current resolution from config so the JSON can be updated. |
| 137 | const resolution = getThumbnailResolution(type); |
| 138 | return { path: pathToCachedFile, aspectRatio: ratio, resolution }; |
| 139 | } |
| 140 | } catch (e) { |
| 141 | forceGenerate = true; |
| 142 | } |
| 143 | } |
| 144 | if (!fs.existsSync(pathToOriginalFile)) { |
| 145 | console.error(`[generateThumbnail] Cannot generate thumbnail, original file not found: ${pathToOriginalFile}`); |
| 146 | return { path: null, aspectRatio: null, resolution: null }; |
| 147 | } |
| 148 | |
| 149 | const fileExtension = path.extname(file).toLowerCase(); |
| 150 | |
| 151 | // For WebP files, we must check if they are animated, as Jimp cannot process them. |
| 152 | // If isKnownAnimated is false, we assume the caller knows it is static and skip this check. |
| 153 | if (fileExtension === '.webp' && isKnownAnimated !== false) { |
| 154 | const buffer = fs.readFileSync(pathToOriginalFile); |
| 155 | const isAnimated = isAnimatedWebP(buffer); |
| 156 | if (isAnimated) { |
| 157 | // The client is expected to handle it. |
| 158 | return { path: null, aspectRatio: null, resolution: null }; |
| 159 | } |
| 160 | } |
| 161 | |
| 162 | // For PNG files, check if they are actually APNGs. |
| 163 | if (fileExtension === '.png' && isKnownAnimated !== false) { |
| 164 | const buffer = fs.readFileSync(pathToOriginalFile); |
| 165 | const isAnimated = isAnimatedApng(buffer); |
| 166 | if (isAnimated) { |
| 167 | // The client is expected to handle it. |
| 168 | return { path: null, aspectRatio: null, resolution: null }; |
| 169 | } |
| 170 | } |
| 171 | |
| 172 | if (SKIPPED_EXTENSIONS.has(fileExtension)) { |
| 173 | return { path: null, aspectRatio: null, resolution: null }; |
| 174 | } |
| 175 | |
| 176 | // Process the image to generate thumbnail |
| 177 | const result = await processSingleImage(file, originalFolder, thumbnailFolder, type); |
| 178 | if (result.success) { |
| 179 | return { path: pathToCachedFile, aspectRatio: result.aspectRatio ?? null, resolution: result.resolution ?? null }; |
| 180 | } else { |
| 181 | console.error(`[generateThumbnail] Failed to process image ${file}:`, result.error); |
| 182 | return { path: null, aspectRatio: null, resolution: null }; |
| 183 | } |
| 184 | } catch (error) { |
| 185 | console.error(`[generateThumbnail] Unexpected error processing ${file}:`, error); |
| 186 | return { path: null, aspectRatio: null, resolution: null }; |
| 187 | } |
| 188 | } |
| 189 | |
| 190 | /** |
| 191 | * Processes a single image to generate its thumbnail. |
| 192 | * @param {string} file - The filename of the image. |
| 193 | * @param {string} originalFolder - Path to the original image folder. |
| 194 | * @param {string} thumbnailFolder - Path to the thumbnail output folder. |
| 195 | * @param {ThumbnailType} type - The type of thumbnail to generate. |
| 196 | * @returns {Promise<{success: boolean, filename?: string, error?: string, aspectRatio?: number, resolution?: number}>} Result of the processing. |
| 197 | */ |
| 198 | async function processSingleImage(file, originalFolder, thumbnailFolder, type) { |
| 199 | const pathToOriginalFile = path.join(originalFolder, file); |
| 200 | const pathToCachedFile = path.join(thumbnailFolder, file); |
| 201 | |
| 202 | try { |
| 203 | const fileBuffer = fs.readFileSync(pathToOriginalFile); |
| 204 | const image = await Jimp.read(fileBuffer); |
| 205 | |
| 206 | // Calculate aspect ratio from original image dimensions |
| 207 | const originalWidth = image.bitmap.width; |
| 208 | const originalHeight = image.bitmap.height; |
| 209 | const aspectRatio = (originalHeight > 0) ? (originalWidth / originalHeight) : 1.0; |
| 210 | |
| 211 | const thumbImage = image.clone(); |
| 212 | const thumbnailResolution = getThumbnailResolution(type); |
| 213 | |
| 214 | if (type === 'bg') { |
| 215 | const [configWidth, configHeight] = dimensions[type]; |
| 216 | const targetPixelArea = configWidth * configHeight; |
| 217 | |
| 218 | // Calculate thumbnail dimensions to maintain target pixel area while preserving aspect ratio |
| 219 | // For aspect ratio w:h, if area = w*h and ratio = w/h, then: |
| 220 | // w = sqrt(area * ratio) and h = sqrt(area / ratio) |
| 221 | const thumbWidth = Math.round(Math.sqrt(targetPixelArea * aspectRatio)); |
| 222 | const thumbHeight = Math.round(Math.sqrt(targetPixelArea / aspectRatio)); |
| 223 | |
| 224 | thumbImage.resize({ w: thumbWidth, h: thumbHeight, mode: ResizeStrategy.BILINEAR }); |
| 225 | } else if (type === 'avatar' || type === 'persona') { |
| 226 | // Crop and resize to fixed dimensions |
| 227 | const [configWidth, configHeight] = dimensions[type]; |
| 228 | thumbImage.cover({ w: configWidth, h: configHeight }); |
| 229 | } |
| 230 | |
| 231 | const buffer = pngFormat |
| 232 | ? await thumbImage.getBuffer(JimpMime.png) |
| 233 | : await thumbImage.getBuffer(JimpMime.jpeg, { quality: quality, jpegColorSpace: 'ycbcr' }); |
| 234 | |
| 235 | writeFileAtomicSync(pathToCachedFile, buffer); |
| 236 | |
| 237 | return { success: true, aspectRatio, resolution: thumbnailResolution }; |
| 238 | } catch (error) { |
| 239 | console.warn(`[Thumbnails] Failed to process image ${file}:`, error); |
| 240 | return { success: false, filename: file, error: error.message }; |
| 241 | } |
| 242 | } |
| 243 | |
| 244 | /** |
| 245 | * Public endpoint for serving thumbnails. |
| 246 | * @param {express.Request} request - The Express request object. |
| 247 | * @param {express.Response} response - The Express response object. |
| 248 | */ |
| 249 | publicRouter.get('/', async function (request, response) { |
| 250 | try { |
| 251 | const { file: rawFile, type, animated } = request.query; |
| 252 | if (typeof rawFile !== 'string' || typeof type !== 'string') return response.sendStatus(400); |
| 253 | if (!(type === 'bg' || type === 'avatar' || type === 'persona')) { |
| 254 | return response.sendStatus(400); |
| 255 | } |
| 256 | |
| 257 | const file = sanitize(rawFile); |
| 258 | if (file !== rawFile) return response.sendStatus(403); |
| 259 | |
| 260 | const serveOriginal = () => { |
| 261 | const folder = getOriginalFolder(request.user.directories, type); |
| 262 | const pathToOriginalFile = path.resolve(path.join(folder, file)); |
| 263 | if (!fs.existsSync(pathToOriginalFile)) return response.sendStatus(404); |
| 264 | invalidateFirefoxCache(pathToOriginalFile, request, response); |
| 265 | return response.sendFile(pathToOriginalFile); |
| 266 | }; |
| 267 | |
| 268 | if (!thumbnailsEnabled) { |
| 269 | return serveOriginal(); |
| 270 | } |
| 271 | |
| 272 | const animatedEnabled = animated === 'true'; |
| 273 | const fileExtension = path.extname(file).toLowerCase(); |
| 274 | const isAnimatedFormat = SKIPPED_EXTENSIONS.has(fileExtension); |
| 275 | |
| 276 | // Serve original for animated formats or GIFs |
| 277 | if (animatedEnabled && isAnimatedFormat) { |
| 278 | return serveOriginal(); |
| 279 | } |
| 280 | |
| 281 | if (fileExtension === '.gif') { |
| 282 | return serveOriginal(); |
| 283 | } |
| 284 | |
| 285 | const thumbnailFolder = getThumbnailFolder(request.user.directories, type); |
| 286 | const pathToCachedFile = path.join(thumbnailFolder, file); |
| 287 | |
| 288 | // Try to generate thumbnail if it doesn't exist |
| 289 | if (!fs.existsSync(pathToCachedFile)) { |
| 290 | const thumbResult = await generateThumbnail(request.user.directories, type, file, false); |
| 291 | // If generation failed (path is null), serve the original file |
| 292 | if (!thumbResult.path) { |
| 293 | return serveOriginal(); |
| 294 | } |
| 295 | } |
| 296 | |
| 297 | if (fs.existsSync(pathToCachedFile)) { |
| 298 | invalidateFirefoxCache(pathToCachedFile, request, response); |
| 299 | return response.sendFile(file, { root: thumbnailFolder, dotfiles: 'allow' }); |
| 300 | } |
| 301 | |
| 302 | // Send a 404 so the frontend can display a placeholder |
| 303 | return response.sendStatus(404); |
| 304 | } catch (error) { |
| 305 | console.error('Failed getting thumbnail', error); |
| 306 | return response.sendStatus(500); |
| 307 | } |
| 308 | }); |
| 309 | |
| 310 | export const router = express.Router(); |
| 311 | router.use(publicRouter); |
| 312 | router.use(apiRouter); |