Blame Raw
Cohee · e3f41666 · · 312 lines (13.0 KB)
1 contributor
1import fs from 'node:fs';
2import path from 'node:path';
3
4import express from 'express';
5import sanitize from 'sanitize-filename';
6import { Jimp, JimpMime } from '../jimp.js';
7import { sync as writeFileAtomicSync } from 'write-file-atomic';
8import { imageSize as sizeOf } from 'image-size';
9
10import { getConfigValue, invalidateFirefoxCache } from '../util.js';
11import { getThumbnailResolution, isAnimatedWebP, isAnimatedApng, thumbnailDimensions as dimensions } from './image-metadata.js';
12import { ResizeStrategy } from '@jimp/plugin-resize';
13
14export const publicRouter = express.Router();
15export const apiRouter = express.Router();
16
17export const SKIPPED_EXTENSIONS = new Set(['.apng', '.mp4', '.webm', '.avi', '.mkv', '.flv', '.gif']);
18export const ALLOWED_IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.tif', '.tiff', '.apng']);
19
20const thumbnailsEnabled = !!getConfigValue('thumbnails.enabled', true, 'boolean');
21const quality = Math.min(100, Math.max(1, parseInt(getConfigValue('thumbnails.quality', 95, 'number'))));
22const 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 */
35function 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 */
59function 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 */
83export 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 */
103export 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 */
198async 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 */
249publicRouter.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
310export const router = express.Router();
311router.use(publicRouter);
312router.use(apiRouter);