/image-metadata (#4788) * git push --force Implement background management in json lint upload multer weirdness, fix for timestamp * remove duplicated logic to generate backgrounds.json * remove duplicate logic to create backgrounds.js * remove excessive enodent eror handling * simplify shared extension constants * move backgrounds.json to backgrounds/index.json * use plimit * move user directory iteration to syncBackgroundsMetadata insetad of server main * create and move getBackgroundThumbnailResolution * nitpick * typdef * simplify checks * use read method * error -> regenerate * use fileExists * type error fix (maybe) * add object to jsdoc returns * typo * use getUniqueName from util.js * remove duplicated multer something or other * make skipped_extensions a set * remove strict comparison Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com> * create isAnimatedWebP and use it * use file creation date instead of server date * actually type BackgroundImageMetadata * use fileExists instead of try catch * response.sendStatus(500); * sync json to allow adding images to the folder without restart * lint * Generic image metadata endpoint. Remove tags and starred. No startup sync. * lint * Descriptive json and move to util.js * remove folders routes * It's a POST * Revert file name to index.json * Type errors fixing * Remove p-limit * Why? * apply codex suggestions * see comment * remove unused functions * remove path normalization for da path, not needed * Fix dimensions shadowing issue, circular imports, and apply bg res to only bg * move thumbnailsEnabled to the top level * Metadata now stored at {userDataRoot}/image-metadata.json instead of per-folder index.json Uses relative paths as keys (e.g., backgrounds/sunset.png) for easy file existence checking Added cleanupOrphanedMetadata() function and /api/image-metadata/cleanup endpoint for data-maid integration Simplified the API endpoint by removing folder-grouping logic * lint * update backgrounds.js imports * security check, .replaceAll * Remove useless function, pass type to cache invalidate and seperate file types * fix: normalize file paths to use POSIX separators in metadata functions * serve original --------- Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com>

f5b5350cf4ea20d6a6e1c869edabf65bf6815ab7

L <123923688+Vibecoder9000@users.noreply.github.com>

Signed
7 files changed, +657 -145Ignore whitespace
package-lock.json+62 -20
@@ -67,6 +67,7 @@
6767 "host-validation-middleware": "^0.1.1",
6868 "html-entities": "^2.6.0",
6969 "iconv-lite": "^0.6.3",
70+ "image-size": "^2.0.2",
7071 "ip-matching": "^2.1.2",
7172 "ip-regex": "^5.0.0",
7273 "ipaddr.js": "^2.2.0",
@@ -5836,6 +5837,18 @@
58365837 "integrity": "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g==",
58375838 "license": "MIT"
58385839 },
5840+ "node_modules/image-size": {
5841+ "version": "2.0.2",
5842+ "resolved": "https://registry.npmjs.org/image-size/-/image-size-2.0.2.tgz",
5843+ "integrity": "sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w==",
5844+ "license": "MIT",
5845+ "bin": {
5846+ "image-size": "bin/image-size.js"
5847+ },
5848+ "engines": {
5849+ "node": ">=16.x"
5850+ }
5851+ },
58395852 "node_modules/immediate": {
58405853 "version": "3.0.6",
58415854 "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
@@ -6732,6 +6745,33 @@
67326745 "node": ">=10.12.0"
67336746 }
67346747 },
6748+ "node_modules/node-persist/node_modules/p-limit": {
6749+ "version": "3.1.0",
6750+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
6751+ "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
6752+ "license": "MIT",
6753+ "dependencies": {
6754+ "yocto-queue": "^0.1.0"
6755+ },
6756+ "engines": {
6757+ "node": ">=10"
6758+ },
6759+ "funding": {
6760+ "url": "https://github.com/sponsors/sindresorhus"
6761+ }
6762+ },
6763+ "node_modules/node-persist/node_modules/yocto-queue": {
6764+ "version": "0.1.0",
6765+ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
6766+ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
6767+ "license": "MIT",
6768+ "engines": {
6769+ "node": ">=10"
6770+ },
6771+ "funding": {
6772+ "url": "https://github.com/sponsors/sindresorhus"
6773+ }
6774+ },
67356775 "node_modules/node-releases": {
67366776 "version": "2.0.18",
67376777 "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz",
@@ -6941,10 +6981,27 @@
69416981 "node": ">=8"
69426982 }
69436983 },
69446984 "node_modules/p-limitlocate": {
6985+ "version": "5.0.0",
6986+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
6987+ "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
6988+ "dev": true,
6989+ "license": "MIT",
6990+ "dependencies": {
6991+ "p-limit": "^3.0.2"
6992+ },
6993+ "engines": {
6994+ "node": ">=10"
6995+ },
6996+ "funding": {
6997+ "url": "https://github.com/sponsors/sindresorhus"
6998+ }
6999+ },
7000+ "node_modules/p-locate/node_modules/p-limit": {
69457001 "version": "3.1.0",
69467002 "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
69477003 "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
7004+ "dev": true,
69487005 "license": "MIT",
69497006 "dependencies": {
69507007 "yocto-queue": "^0.1.0"
@@ -6956,15 +7013,12 @@
69567013 "url": "https://github.com/sponsors/sindresorhus"
69577014 }
69587015 },
69597016 "node_modules/p-locate/node_modules/yocto-queue": {
69607017 "version": "5.0.1.0",
69617018 "resolved": "https://registry.npmjs.org/pyocto-locatequeue/-/pyocto-locatequeue-5.0.1.0.tgz",
69627019 "integrity": "sha512-LaNjtRWUBY++zB5nErVksvsnNCdJ/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7XohGc6xgPwyN8eheCxsiLM8mxuE/tlt/QYq3TIeE6nxHppbo2LGymrG5PwmOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
69637020 "dev": true,
69647021 "license": "MIT",
6965- "dependencies": {
6966- "p-limit": "^3.0.2"
6967- },
69687022 "engines": {
69697023 "node": ">=10"
69707024 },
@@ -9236,18 +9290,6 @@
92369290 "node": ">=12"
92379291 }
92389292 },
9239- "node_modules/yocto-queue": {
9240- "version": "0.1.0",
9241- "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
9242- "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
9243- "license": "MIT",
9244- "engines": {
9245- "node": ">=10"
9246- },
9247- "funding": {
9248- "url": "https://github.com/sponsors/sindresorhus"
9249- }
9250- },
92519293 "node_modules/zip-stream": {
92529294 "version": "6.0.1",
92539295 "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz",
package.json+1 -0
@@ -57,6 +57,7 @@
5757 "host-validation-middleware": "^0.1.1",
5858 "html-entities": "^2.6.0",
5959 "iconv-lite": "^0.6.3",
60+ "image-size": "^2.0.2",
6061 "ip-matching": "^2.1.2",
6162 "ip-regex": "^5.0.0",
6263 "ipaddr.js": "^2.2.0",
src/endpoints/backgrounds.js+3 -2
@@ -4,7 +4,8 @@ import path from 'node:path';
44import express from 'express';
55import sanitize from 'sanitize-filename';
66
77import { dimensions, invalidateThumbnail } from './thumbnails.js';
8+import { thumbnailDimensions } from './image-metadata.js';
89import { getImages } from '../util.js';
910import { getFileNameValidationFunction } from '../middleware/validateFileName.js';
1011
@@ -12,7 +13,7 @@ export const router = express.Router();
1213
1314router.post('/all', function (request, response) {
1415 const images = getImages(request.user.directories.backgrounds);
1516 const config = { width: dimensionsthumbnailDimensions.bg[0], height: dimensionsthumbnailDimensions.bg[1] };
1617 response.json({ images, config });
1718});
1819
src/endpoints/image-metadata.js+417 -0
@@ -0,0 +1,417 @@
1+/**
2+ * Generic image metadata service.
3+ * Provides on-demand metadata generation with file mtime-based caching.
4+ */
5+
6+import * as fs from 'node:fs/promises';
7+import path from 'node:path';
8+import crypto from 'node:crypto';
9+import { imageSize } from 'image-size';
10+import writeFileAtomic from 'write-file-atomic';
11+import express from 'express';
12+import { Jimp } from '../jimp.js';
13+import { getConfigValue, isPathUnderParent } from '../util.js';
14+
15+export const METADATA_FILE = 'image-metadata.json';
16+
17+/**
18+ * @typedef {Object} ImageMetadata
19+ * @property {string} hash - SHA-256 hash of the image file.
20+ * @property {number} aspectRatio - Aspect ratio (width / height) of the image.
21+ * @property {boolean} isAnimated - Whether the image is animated.
22+ * @property {string} dominantColor - Dominant color in hex format (e.g., '#RRGGBB').
23+ * @property {string[]} folderIds - Array of virtual folder IDs the image belongs to.
24+ * @property {number} addedTimestamp - Timestamp when the image was added.
25+ * @property {number} thumbnailResolution - Thumbnail resolution (width * height) for cache invalidation.
26+ * @property {number} [mtime] - File modification time for cache invalidation (internal use).
27+ */
28+
29+/**
30+ * @typedef {Object} MetadataIndex
31+ * @property {number} version - Metadata version.
32+ * @property {Object.<string, ImageMetadata>} images - Mapping of relative paths to their metadata.
33+ * @property {Array<{id: string, name: string, thumbnailFile: string}>} folders - Virtual folders.
34+ */
35+
36+/**
37+ * @typedef {'bg' | 'avatar' | 'persona'} ThumbnailType
38+ */
39+
40+/** @type {Record<string, number[]>} */
41+export const thumbnailDimensions = {
42+ 'bg': getConfigValue('thumbnails.dimensions.bg', [160, 90]),
43+ 'avatar': getConfigValue('thumbnails.dimensions.avatar', [96, 144]),
44+ 'persona': getConfigValue('thumbnails.dimensions.persona', [96, 144]),
45+};
46+
47+/**
48+ * Gets the configured resolution for a given thumbnail type.
49+ * @param {ThumbnailType} type Thumbnail type
50+ * @returns {number} Resolution (width * height)
51+ */
52+export function getThumbnailResolution(type) {
53+ const dims = thumbnailDimensions[type];
54+ if (Array.isArray(dims) && dims.length >= 2) {
55+ return Number(dims[0]) * Number(dims[1]);
56+ }
57+ return 0;
58+}
59+
60+/**
61+ * Checks if a buffer contains an animated PNG (APNG) by looking for the 'acTL' chunk.
62+ * @param {Buffer} buffer The file buffer.
63+ * @returns {boolean}
64+ */
65+function isAnimatedApng(buffer) {
66+ return buffer.subarray(0, 200).includes('acTL');
67+}
68+
69+/**
70+ * Checks if a WebP buffer is animated by looking for 'ANIM' or 'ANMF' chunks.
71+ * @param {Buffer} buffer The WebP file buffer (can be full file or header)
72+ * @returns {boolean} True if the WebP is animated
73+ */
74+export function isAnimatedWebP(buffer) {
75+ const headerBuffer = buffer.length > 200 ? buffer.subarray(0, 200) : buffer;
76+ return headerBuffer.includes('ANIM') || headerBuffer.includes('ANMF');
77+}
78+
79+/**
80+ * Calculate average color using Jimp.
81+ * Resizes the image to 1x1 to efficiently get the average color.
82+ * @param {Buffer} buffer The image buffer.
83+ * @returns {Promise<string>} The average color as a hex string (e.g., '#RRGGBB').
84+ */
85+async function getAverageColorWithJimp(buffer) {
86+ try {
87+ const image = await Jimp.read(buffer);
88+ image.resize({ w: 1, h: 1 });
89+
90+ const colorInt = image.getPixelColor(0, 0);
91+ const r = (colorInt >> 24) & 255;
92+ const g = (colorInt >> 16) & 255;
93+ const b = (colorInt >> 8) & 255;
94+
95+ const toHex = (c) => c.toString(16).padStart(2, '0');
96+ return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
97+ } catch (error) {
98+ console.warn('[Jimp] Failed to calculate average color:', error.message);
99+ return '#808080';
100+ }
101+}
102+
103+/**
104+ * Generates metadata for a single image file.
105+ * @param {string} filePath - The full path to the image file.
106+ * @param {ThumbnailType} type - The thumbnail type for resolution calculation.
107+ * @returns {Promise<ImageMetadata>} A metadata object. Throws an error if processing fails.
108+ */
109+export async function generateImageMetadata(filePath, type) {
110+ const buffer = await fs.readFile(filePath);
111+ const hash = crypto.createHash('sha256').update(buffer).digest('hex');
112+ const dimensions = imageSize(buffer);
113+
114+ if (!dimensions || !dimensions.width || !dimensions.height) {
115+ throw new Error('Could not determine image dimensions.');
116+ }
117+
118+ const aspectRatio = dimensions.width / dimensions.height;
119+ let isAnimated = false;
120+
121+ switch (dimensions.type) {
122+ case 'gif':
123+ isAnimated = true;
124+ break;
125+ case 'png':
126+ isAnimated = isAnimatedApng(buffer);
127+ break;
128+ case 'webp':
129+ isAnimated = isAnimatedWebP(buffer);
130+ break;
131+ }
132+
133+ let dominantColor;
134+ if (isAnimated) {
135+ dominantColor = '#808080';
136+ } else {
137+ dominantColor = await getAverageColorWithJimp(buffer);
138+ }
139+
140+ let addedTimestamp;
141+ try {
142+ const stats = await fs.stat(filePath);
143+ addedTimestamp = Math.floor(stats.birthtimeMs || stats.mtimeMs);
144+ } catch {
145+ addedTimestamp = Date.now();
146+ }
147+
148+ return {
149+ hash,
150+ aspectRatio: parseFloat(aspectRatio.toFixed(4)),
151+ isAnimated,
152+ dominantColor,
153+ folderIds: [],
154+ addedTimestamp,
155+ thumbnailResolution: getThumbnailResolution(type),
156+ };
157+}
158+
159+/**
160+ * Reads the centralized metadata index from the user data root.
161+ * @param {string} userDataRoot - Path to the user data directory root
162+ * @returns {Promise<MetadataIndex>} The metadata index
163+ */
164+export async function readMetadataIndex(userDataRoot) {
165+ const indexPath = path.join(userDataRoot, METADATA_FILE);
166+ try {
167+ const rawData = await fs.readFile(indexPath, 'utf8');
168+ return JSON.parse(rawData);
169+ } catch {
170+ return { version: 1, images: {}, folders: [] };
171+ }
172+}
173+
174+/**
175+ * Writes the centralized metadata index to the user data root.
176+ * @param {string} userDataRoot - Path to the user data directory root
177+ * @param {MetadataIndex} metadata - The metadata to write
178+ */
179+export async function writeMetadataIndex(userDataRoot, metadata) {
180+ const indexPath = path.join(userDataRoot, METADATA_FILE);
181+ const jsonString = JSON.stringify(metadata, null, 4);
182+ await writeFileAtomic(indexPath, jsonString, 'utf8');
183+}
184+
185+/**
186+ * Gets metadata for multiple images, generating on-demand as needed.
187+ * Uses relative paths from the user data root as keys in the centralized index.
188+ * @param {string} userDataRoot - Path to the user data directory root
189+ * @param {string[]} relativePaths - Array of relative paths from userDataRoot
190+ * @param {ThumbnailType} type - The thumbnail type for resolution calculation.
191+ * @returns {Promise<Object.<string, ImageMetadata>>} Map of relativePath to metadata
192+ */
193+export async function getOrGenerateMetadataBatch(userDataRoot, relativePaths, type) {
194+ /** @type {Object.<string, ImageMetadata>} */
195+ const results = {};
196+ const index = await readMetadataIndex(userDataRoot);
197+ let indexModified = false;
198+
199+ for (const relativePath of relativePaths) {
200+ // Normalize the path to use forward slashes for consistent keys
201+ const posixPath = relativePath.replaceAll(path.sep, path.posix.sep);
202+ const fullPath = path.join(userDataRoot, relativePath);
203+
204+ let stats;
205+ try {
206+ stats = await fs.stat(fullPath);
207+ } catch {
208+ continue; // File doesn't exist, skip
209+ }
210+
211+ const currentMtime = stats.mtimeMs;
212+ const cached = index.images[posixPath];
213+
214+ // If cached and not modified, use cached
215+ if (cached && cached.mtime === currentMtime) {
216+ results[relativePath] = cached;
217+ continue;
218+ }
219+
220+ // Generate new metadata
221+ try {
222+ const metadata = await generateImageMetadata(fullPath, type);
223+ metadata.mtime = currentMtime;
224+
225+ // Preserve folderIds if they existed
226+ if (cached?.folderIds) {
227+ metadata.folderIds = cached.folderIds;
228+ }
229+
230+ index.images[posixPath] = metadata;
231+ results[relativePath] = metadata;
232+ indexModified = true;
233+ } catch (error) {
234+ console.warn(`[ImageMetadata] Failed to generate metadata for ${relativePath}:`, error.message);
235+ }
236+ }
237+
238+ // Write index if modified
239+ if (indexModified) {
240+ await writeMetadataIndex(userDataRoot, index);
241+ }
242+
243+ return results;
244+}
245+
246+/**
247+ * Removes metadata for an image from the centralized index.
248+ * @param {string} userDataRoot - Path to the user data directory root
249+ * @param {string} relativePath - The relative path to remove
250+ */
251+export async function removeMetadata(userDataRoot, relativePath) {
252+ const posixPath = relativePath.replaceAll(path.sep, path.posix.sep);
253+ const index = await readMetadataIndex(userDataRoot);
254+ if (index.images[posixPath]) {
255+ delete index.images[posixPath];
256+ await writeMetadataIndex(userDataRoot, index);
257+ }
258+}
259+
260+/**
261+ * Updates metadata for an image (e.g., after rename).
262+ * @param {string} userDataRoot - Path to the user data directory root
263+ * @param {string} oldRelativePath - The old relative path
264+ * @param {string} newRelativePath - The new relative path
265+ * @returns {Promise<ImageMetadata|null>} The updated metadata
266+ */
267+export async function renameMetadata(userDataRoot, oldRelativePath, newRelativePath) {
268+ const posixOldPath = oldRelativePath.replaceAll(path.sep, path.posix.sep);
269+ const posixNewPath = newRelativePath.replaceAll(path.sep, path.posix.sep);
270+ const index = await readMetadataIndex(userDataRoot);
271+ const data = index.images[posixOldPath];
272+
273+ if (!data) {
274+ throw new Error(`Image '${oldRelativePath}' not found in metadata.`);
275+ }
276+
277+ delete index.images[posixOldPath];
278+ index.images[posixNewPath] = data;
279+ await writeMetadataIndex(userDataRoot, index);
280+
281+ return data;
282+}
283+
284+/**
285+ * Cleans up orphaned entries from the metadata index.
286+ * Iterates over all entries and removes those whose files no longer exist.
287+ * @param {string} userDataRoot - Path to the user data directory root
288+ * @returns {Promise<string[]>} Array of removed paths
289+ */
290+export async function cleanupOrphanedMetadata(userDataRoot) {
291+ const index = await readMetadataIndex(userDataRoot);
292+ const orphanedPaths = [];
293+
294+ for (const relativePath of Object.keys(index.images)) {
295+ const fullPath = path.resolve(userDataRoot, relativePath);
296+
297+ if (!isPathUnderParent(userDataRoot, fullPath)) {
298+ orphanedPaths.push(relativePath);
299+ delete index.images[relativePath];
300+ continue;
301+ }
302+
303+ try {
304+ await fs.access(fullPath);
305+ } catch {
306+ // File doesn't exist, mark for removal
307+ orphanedPaths.push(relativePath);
308+ delete index.images[relativePath];
309+ }
310+ }
311+
312+ if (orphanedPaths.length > 0) {
313+ await writeMetadataIndex(userDataRoot, index);
314+ console.log(`[ImageMetadata] Cleaned up ${orphanedPaths.length} orphaned metadata entries`);
315+ }
316+
317+ return orphanedPaths;
318+}
319+
320+export const router = express.Router();
321+
322+/**
323+ * POST /api/image-metadata
324+ * Get metadata for image(s) by path.
325+ */
326+router.post('/', async function (request, response) {
327+ try {
328+ const { path: singlePath, paths, type } = request.body;
329+
330+ if (!singlePath && !paths) {
331+ return response.status(400).json({ error: 'Either "path" or "paths" is required.' });
332+ }
333+
334+ const userDataRoot = request.user.directories.root;
335+
336+ // Helper to validate a path is under user data directory
337+ const validatePath = (relativePath) => {
338+ const fullPath = path.resolve(userDataRoot, relativePath);
339+ if (!isPathUnderParent(userDataRoot, fullPath)) {
340+ throw new Error(`Path "${relativePath}" is outside the user data directory.`);
341+ }
342+ return relativePath;
343+ };
344+
345+ // Handle single path
346+ if (singlePath && !paths) {
347+ const relativePath = validatePath(singlePath);
348+ const fullPath = path.join(userDataRoot, relativePath);
349+
350+ try {
351+ await fs.access(fullPath);
352+ } catch {
353+ return response.status(404).json({ error: 'File not found.' });
354+ }
355+
356+ const metadataResults = await getOrGenerateMetadataBatch(userDataRoot, [relativePath], type);
357+ const metadata = metadataResults[relativePath];
358+
359+ if (!metadata) {
360+ return response.status(404).json({ error: 'Could not generate metadata for file.' });
361+ }
362+
363+ return response.json(metadata);
364+ }
365+
366+ // Handle multiple paths
367+ if (paths && Array.isArray(paths)) {
368+ /** @type {Object.<string, ImageMetadata|{error: string}>} */
369+ const results = {};
370+ const validPaths = [];
371+
372+ // Validate all paths first
373+ for (const relativePath of paths) {
374+ try {
375+ validatePath(relativePath);
376+ validPaths.push(relativePath);
377+ } catch (error) {
378+ results[relativePath] = { error: error.message };
379+ }
380+ }
381+
382+ // Process all valid paths in a single batch
383+ const batchMetadata = await getOrGenerateMetadataBatch(userDataRoot, validPaths, type);
384+
385+ for (const relativePath of validPaths) {
386+ if (batchMetadata[relativePath]) {
387+ results[relativePath] = batchMetadata[relativePath];
388+ } else {
389+ results[relativePath] = { error: 'File not found or could not process.' };
390+ }
391+ }
392+
393+ return response.json(results);
394+ }
395+
396+ return response.status(400).json({ error: 'Invalid request format.' });
397+
398+ } catch (error) {
399+ console.error('[ImageMetadata] API error:', error);
400+ return response.status(500).json({ error: 'Internal server error.' });
401+ }
402+});
403+
404+/**
405+ * POST /api/image-metadata/cleanup
406+ * Clean up orphaned metadata entries (files that no longer exist).
407+ */
408+router.post('/cleanup', async function (request, response) {
409+ try {
410+ const userDataRoot = request.user.directories.root;
411+ const removed = await cleanupOrphanedMetadata(userDataRoot);
412+ return response.json({ removed, count: removed.length });
413+ } catch (error) {
414+ console.error('[ImageMetadata] Cleanup error:', error);
415+ return response.status(500).json({ error: 'Internal server error.' });
416+ }
417+});
src/endpoints/thumbnails.js+172 -121
@@ -1,14 +1,21 @@
11import fs from 'node:fs';
2-import { promises as fsPromises } from 'node:fs';
32import path from 'node:path';
43
5-import mime from 'mime-types';
64import express from 'express';
75import sanitize from 'sanitize-filename';
86import { Jimp, JimpMime } from '../jimp.js';
97import { sync as writeFileAtomicSync } from 'write-file-atomic';
8+import { imageSize as sizeOf } from 'image-size';
109
1110import { getConfigValue, invalidateFirefoxCache } from '../util.js';
11+import { getThumbnailResolution, isAnimatedWebP, 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']);
1219
1320const thumbnailsEnabled = !!getConfigValue('thumbnails.enabled', true, 'boolean');
1421const quality = Math.min(100, Math.max(1, parseInt(getConfigValue('thumbnails.quality', 95, 'number'))));
@@ -18,12 +25,6 @@ const pngFormat = String(getConfigValue('thumbnails.format', 'jpg')).toLowerCase
1825 * @typedef {'bg' | 'avatar' | 'persona'} ThumbnailType
1926 */
2027
21-/** @type {Record<string, number[]>} */
22-export const dimensions = {
23- 'bg': getConfigValue('thumbnails.dimensions.bg', [160, 90]),
24- 'avatar': getConfigValue('thumbnails.dimensions.avatar', [96, 144]),
25- 'persona': getConfigValue('thumbnails.dimensions.persona', [96, 144]),
26-};
2728
2829/**
2930 * Gets a path to thumbnail folder based on the type.
@@ -91,162 +92,212 @@ export function invalidateThumbnail(directories, type, file) {
9192}
9293
9394/**
9495 * Generates or retrieves a thumbnail for thea given file.
9596 * @param {import('../users.js').UserDirectoryList} directories - User's directoriesdirectory configuration.
9697 * @param {ThumbnailType} type - Type of the thumbnail ('bg', 'avatar', 'persona').
9798 * @param {string} file Name- The filename of the fileimage.
98- * @returns
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.
99102 */
100103export async function generateThumbnail(directories, type, file, forceGenerate = false, isKnownAnimated = null) {
101- let thumbnailFolder = getThumbnailFolder(directories, type);
104+ // If the caller has already determined the file is animated, skip processing.
102- let originalFolder = getOriginalFolder(directories, type);
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);
103111 if (thumbnailFolder === undefined || originalFolder === undefined) throw new Error('Invalid thumbnail type');
104112 const pathToCachedFile = path.join(thumbnailFolder, file);
105- const pathToOriginalFile = path.join(originalFolder, file);
106113
107- const cachedFileExists = fs.existsSync(pathToCachedFile);
114+ try {
108115 const originalFileExistspathToOriginalFile = fspath.existsSyncjoin(pathToOriginalFileoriginalFolder, file);
109116
110117 // toCheck handleif casesthumbnail whenalready originalexists imageand wasreturn updatedit afterif thumbnot creationforcing regeneration
111- let shouldRegenerate = false;
118+ if (!forceGenerate && fs.existsSync(pathToCachedFile)) {
112-
119+ try {
113- if (cachedFileExists && originalFileExists) {
120+ // Check if original image was updated after thumbnail creation
114121 const originalStatoriginalFileExists = fs.statSyncexistsSync(pathToOriginalFile);
115- const cachedStat = fs.statSync(pathToCachedFile);
122+ if (originalFileExists) {
116-
123+ const originalStat = fs.statSync(pathToOriginalFile);
117- if (originalStat.mtimeMs > cachedStat.ctimeMs) {
124+ const cachedStat = fs.statSync(pathToCachedFile);
118- //console.warn('Original file changed. Regenerating thumbnail...');
125+
119- shouldRegenerate = true;
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 };
120147 }
121- }
122-
123- if (cachedFileExists && !shouldRegenerate) {
124- return pathToCachedFile;
125- }
126148
127- if (!originalFileExists) {
149+ const fileExtension = path.extname(file).toLowerCase();
128- return null;
129- }
130150
131- try {
151+ // For WebP files, we must check if they are animated, as Jimp cannot process them.
132- let buffer;
152+ // If isKnownAnimated is false, we assume the caller knows it is static and skip this check.
133-
153+ if (fileExtension === '.webp' && isKnownAnimated !== false) {
134- try {
154+ const buffer = fs.readFileSync(pathToOriginalFile);
135155 const sizeisAnimated = dimensions[type]isAnimatedWebP(buffer);
136- const image = await Jimp.read(pathToOriginalFile);
156+ if (isAnimated) {
137- const width = !isNaN(size?.[0]) && size?.[0] > 0 ? size[0] : image.bitmap.width;
157+ // The client is expected to handle it.
138- const height = !isNaN(size?.[1]) && size?.[1] > 0 ? size[1] : image.bitmap.height;
158+ return { path: null, aspectRatio: null, resolution: null };
139- image.cover({ w: width, h: height });
159+ }
140- buffer = pngFormat
141- ? await image.getBuffer(JimpMime.png)
142- : await image.getBuffer(JimpMime.jpeg, { quality: quality, jpegColorSpace: 'ycbcr' });
143160 }
144- catch (inner) {
161+
145- console.warn(`Thumbnailer can not process the image: ${pathToOriginalFile}. Using original size`, inner);
162+ if (SKIPPED_EXTENSIONS.has(fileExtension)) {
146- buffer = fs.readFileSync(pathToOriginalFile);
163+ return { path: null, aspectRatio: null, resolution: null };
147164 }
148165
149- writeFileAtomicSync(pathToCachedFile, buffer);
166+ // Process the image to generate thumbnail
150- }
167+ const result = await processSingleImage(file, originalFolder, thumbnailFolder, type);
151168 catch if (outerresult.success) {
152- return null;
169+ return { path: pathToCachedFile, aspectRatio: result.aspectRatio ?? null, resolution: result.resolution ?? null };
170+ } else {
171+ console.error(`[generateThumbnail] Failed to process image ${file}:`, result.error);
172+ return { path: null, aspectRatio: null, resolution: null };
173+ }
174+ } catch (error) {
175+ console.error(`[generateThumbnail] Unexpected error processing ${file}:`, error);
176+ return { path: null, aspectRatio: null, resolution: null };
153177 }
154-
155- return pathToCachedFile;
156178}
157179
158180/**
159181 * Ensures thatProcesses thea thumbnailsingle cacheimage forto backgroundsgenerate isits validthumbnail.
160- * @param {import('../users.js').UserDirectoryList[]} directoriesList User directories
182+ * @param {string} file - The filename of the image.
161183 * @returnsparam {Promise<void>string} PromiseoriginalFolder that- resolvesPath whento the cacheoriginal isimage validatedfolder.
184+ * @param {string} thumbnailFolder - Path to the thumbnail output folder.
185+ * @param {ThumbnailType} type - The type of thumbnail to generate.
186+ * @returns {Promise<{success: boolean, filename?: string, error?: string, aspectRatio?: number, resolution?: number}>} Result of the processing.
162187 */
163188export async function ensureThumbnailCacheprocessSingleImage(directoriesListfile, originalFolder, thumbnailFolder, type) {
164- for (const directories of directoriesList) {
189+ const pathToOriginalFile = path.join(originalFolder, file);
165190 const cacheFilespathToCachedFile = fspath.readdirSyncjoin(directories.thumbnailsBgthumbnailFolder, file);
166191
167- // files exist, all ok
192+ try {
168- if (cacheFiles.length) {
193+ const fileBuffer = fs.readFileSync(pathToOriginalFile);
169- continue;
194+ const image = await Jimp.read(fileBuffer);
195+
196+ // Calculate aspect ratio from original image dimensions
197+ const originalWidth = image.bitmap.width;
198+ const originalHeight = image.bitmap.height;
199+ const aspectRatio = (originalHeight > 0) ? (originalWidth / originalHeight) : 1.0;
200+
201+ const thumbImage = image.clone();
202+ const thumbnailResolution = getThumbnailResolution(type);
203+
204+ if (type === 'bg') {
205+ const [configWidth, configHeight] = dimensions[type];
206+ const targetPixelArea = configWidth * configHeight;
207+
208+ // Calculate thumbnail dimensions to maintain target pixel area while preserving aspect ratio
209+ // For aspect ratio w:h, if area = w*h and ratio = w/h, then:
210+ // w = sqrt(area * ratio) and h = sqrt(area / ratio)
211+ const thumbWidth = Math.round(Math.sqrt(targetPixelArea * aspectRatio));
212+ const thumbHeight = Math.round(Math.sqrt(targetPixelArea / aspectRatio));
213+
214+ thumbImage.resize({ w: thumbWidth, h: thumbHeight, mode: ResizeStrategy.BILINEAR });
215+ } else if (type === 'avatar' || type === 'persona') {
216+ // Crop and resize to fixed dimensions
217+ const [configWidth, configHeight] = dimensions[type];
218+ thumbImage.cover({ w: configWidth, h: configHeight });
170219 }
171220
172- console.info('Generating thumbnails cache. Please wait...');
221+ const buffer = pngFormat
173-
222+ ? await thumbImage.getBuffer(JimpMime.png)
174- const bgFiles = fs.readdirSync(directories.backgrounds);
223+ : await thumbImage.getBuffer(JimpMime.jpeg, { quality: quality, jpegColorSpace: 'ycbcr' });
175- const tasks = [];
176224
177- for (const file of bgFiles) {
225+ writeFileAtomicSync(pathToCachedFile, buffer);
178- tasks.push(generateThumbnail(directories, 'bg', file));
179- }
180226
181- await Promise.all(tasks);
227+ return { success: true, aspectRatio, resolution: thumbnailResolution };
182- console.info(`Done! Generated: ${bgFiles.length} preview images`);
228+ } catch (error) {
229+ console.warn(`[Thumbnails] Failed to process image ${file}:`, error);
230+ return { success: false, filename: file, error: error.message };
183231 }
184232}
185233
186-export const router = express.Router();
234+/**
187-
235+ * Public endpoint for serving thumbnails.
188-// Important: This route must be mounted as '/thumbnail'. It is used in the client code and saved to chat files.
236+ * @param {express.Request} request - The Express request object.
189-router.get('/', async function (request, response) {
237+ * @param {express.Response} response - The Express response object.
190- try{
238+ */
191- if (typeof request.query.file !== 'string' || typeof request.query.type !== 'string') {
239+publicRouter.get('/', async function (request, response) {
192- return response.sendStatus(400);
240+ try {
193- }
241+ const { file: rawFile, type, animated } = request.query;
194-
242+ if (typeof rawFile !== 'string' || typeof type !== 'string') return response.sendStatus(400);
195- const type = request.query.type;
196- const file = sanitize(request.query.file);
197-
198- if (!type || !file) {
199- return response.sendStatus(400);
200- }
201-
202243 if (!(type === 'bg' || type === 'avatar' || type === 'persona')) {
203244 return response.sendStatus(400);
204245 }
205246
206- if (sanitize(file) !== file) {
247+ const file = sanitize(rawFile);
207- console.error('Malicious filename prevented');
248+ if (file !== rawFile) return response.sendStatus(403);
208- return response.sendStatus(403);
209- }
210249
211250 ifconst serveOriginal = (!thumbnailsEnabled) => {
212251 const folder = getOriginalFolder(request.user.directories, type);
213-
252+ const pathToOriginalFile = path.resolve(path.join(folder, file));
214- if (folder === undefined) {
253+ if (!fs.existsSync(pathToOriginalFile)) return response.sendStatus(404);
215- return response.sendStatus(400);
216- }
217-
218- const pathToOriginalFile = path.join(folder, file);
219- if (!fs.existsSync(pathToOriginalFile)) {
220- return response.sendStatus(404);
221- }
222- const contentType = mime.lookup(pathToOriginalFile) || 'image/png';
223- const originalFile = await fsPromises.readFile(pathToOriginalFile);
224- response.setHeader('Content-Type', contentType);
225-
226254 invalidateFirefoxCache(pathToOriginalFile, request, response);
255+ return response.sendFile(pathToOriginalFile);
256+ };
227257
228- return response.send(originalFile);
258+ if (!thumbnailsEnabled) {
259+ return serveOriginal();
229260 }
230261
231- const pathToCachedFile = await generateThumbnail(request.user.directories, type, file);
262+ const animatedEnabled = animated === 'true';
263+ const fileExtension = path.extname(file).toLowerCase();
264+ const isAnimatedFormat = SKIPPED_EXTENSIONS.has(fileExtension);
232265
233- if (!pathToCachedFile) {
266+ // Serve original for animated formats or GIFs
234- return response.sendStatus(404);
267+ if (animatedEnabled && isAnimatedFormat) {
268+ return serveOriginal();
235269 }
236270
271+ if (fileExtension === '.gif') {
272+ return serveOriginal();
273+ }
274+
275+ const thumbnailFolder = getThumbnailFolder(request.user.directories, type);
276+ const pathToCachedFile = path.join(thumbnailFolder, file);
277+
278+ // Try to generate thumbnail if it doesn't exist
237279 if (!fs.existsSync(pathToCachedFile)) {
238- return response.sendStatus(404);
280+ const thumbResult = await generateThumbnail(request.user.directories, type, file, false);
281+ // If generation failed (path is null), serve the original file
282+ if (!thumbResult.path) {
283+ return serveOriginal();
284+ }
239285 }
240286
241- const contentType = mime.lookup(pathToCachedFile) || 'image/jpeg';
287+ if (fs.existsSync(pathToCachedFile)) {
242- const cachedFile = await fsPromises.readFile(pathToCachedFile);
288+ invalidateFirefoxCache(pathToCachedFile, request, response);
243- response.setHeader('Content-Type', contentType);
289+ return response.sendFile(path.resolve(pathToCachedFile));
290+ }
244291
245- invalidateFirefoxCache(file, request, response);
292+ // Send a 404 so the frontend can display a placeholder
293+ return response.sendStatus(404);
246294
247- return response.send(cachedFile);
248295 } catch (error) {
249296 console.error('Failed getting thumbnail', error);
250297 return response.sendStatus(500);
251298 }
252299});
300+
301+export const router = express.Router();
302+router.use(publicRouter);
303+router.use(apiRouter);
src/server-main.js+0 -2
@@ -59,7 +59,6 @@ import {
5959 getConfigValue,
6060} from './util.js';
6161import { UPLOADS_DIRECTORY } from './constants.js';
62-import { ensureThumbnailCache } from './endpoints/thumbnails.js';
6362
6463// Routers
6564import { router as usersPublicRouter } from './endpoints/users-public.js';
@@ -269,7 +268,6 @@ async function preSetupTasks() {
269268 const directories = await getUserDirectoriesList();
270269 await migrateGroupChatsMetadataFormat(directories);
271270 await checkForNewContent(directories);
272- await ensureThumbnailCache(directories);
273271 await diskCache.verify(directories);
274272 migrateFlatSecrets(directories);
275273 cleanUploads();
src/server-startup.js+2 -0
@@ -48,6 +48,7 @@ import { router as azureRouter } from './endpoints/azure.js';
4848import { router as minimaxRouter } from './endpoints/minimax.js';
4949import { router as dataMaidRouter } from './endpoints/data-maid.js';
5050import { router as backupsRouter } from './endpoints/backups.js';
51+import { router as imageMetadataRouter } from './endpoints/image-metadata.js';
5152import { router as volcengineRouter } from './endpoints/volcengine.js';
5253
5354/**
@@ -179,6 +180,7 @@ export function setupPrivateEndpoints(app) {
179180 app.use('/api/minimax', minimaxRouter);
180181 app.use('/api/data-maid', dataMaidRouter);
181182 app.use('/api/backups', backupsRouter);
183+ app.use('/api/image-metadata', imageMetadataRouter);
182184}
183185
184186/**