/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 @@
67 "host-validation-middleware": "^0.1.1",67 "host-validation-middleware": "^0.1.1",
68 "html-entities": "^2.6.0",68 "html-entities": "^2.6.0",
69 "iconv-lite": "^0.6.3",69 "iconv-lite": "^0.6.3",
70 "image-size": "^2.0.2",
70 "ip-matching": "^2.1.2",71 "ip-matching": "^2.1.2",
71 "ip-regex": "^5.0.0",72 "ip-regex": "^5.0.0",
72 "ipaddr.js": "^2.2.0",73 "ipaddr.js": "^2.2.0",
@@ -5836,6 +5837,18 @@
5836 "integrity": "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g==",5837 "integrity": "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g==",
5837 "license": "MIT"5838 "license": "MIT"
5838 },5839 },
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 },
5839 "node_modules/immediate": {5852 "node_modules/immediate": {
5840 "version": "3.0.6",5853 "version": "3.0.6",
5841 "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",5854 "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
@@ -6732,6 +6745,33 @@
6732 "node": ">=10.12.0"6745 "node": ">=10.12.0"
6733 }6746 }
6734 },6747 },
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 },
6735 "node_modules/node-releases": {6775 "node_modules/node-releases": {
6736 "version": "2.0.18",6776 "version": "2.0.18",
6737 "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz",6777 "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz",
@@ -6941,10 +6981,27 @@
6941 "node": ">=8"6981 "node": ">=8"
6942 }6982 }
6943 },6983 },
6944 "node_modules/p-limit": {6984 "node_modules/p-locate": {
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": {
6945 "version": "3.1.0",7001 "version": "3.1.0",
6946 "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",7002 "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
6947 "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",7003 "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
7004 "dev": true,
6948 "license": "MIT",7005 "license": "MIT",
6949 "dependencies": {7006 "dependencies": {
6950 "yocto-queue": "^0.1.0"7007 "yocto-queue": "^0.1.0"
@@ -6956,15 +7013,12 @@
6956 "url": "https://github.com/sponsors/sindresorhus"7013 "url": "https://github.com/sponsors/sindresorhus"
6957 }7014 }
6958 },7015 },
6959 "node_modules/p-locate": {7016 "node_modules/p-locate/node_modules/yocto-queue": {
6960 "version": "5.0.0",7017 "version": "0.1.0",
6961 "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",7018 "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
6962 "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",7019 "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
6963 "dev": true,7020 "dev": true,
6964 "license": "MIT",7021 "license": "MIT",
6965 "dependencies": {
6966 "p-limit": "^3.0.2"
6967 },
6968 "engines": {7022 "engines": {
6969 "node": ">=10"7023 "node": ">=10"
6970 },7024 },
@@ -9236,18 +9290,6 @@
9236 "node": ">=12"9290 "node": ">=12"
9237 }9291 }
9238 },9292 },
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 },
9251 "node_modules/zip-stream": {9293 "node_modules/zip-stream": {
9252 "version": "6.0.1",9294 "version": "6.0.1",
9253 "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz",9295 "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz",
package.json+1 -0
@@ -57,6 +57,7 @@
57 "host-validation-middleware": "^0.1.1",57 "host-validation-middleware": "^0.1.1",
58 "html-entities": "^2.6.0",58 "html-entities": "^2.6.0",
59 "iconv-lite": "^0.6.3",59 "iconv-lite": "^0.6.3",
60 "image-size": "^2.0.2",
60 "ip-matching": "^2.1.2",61 "ip-matching": "^2.1.2",
61 "ip-regex": "^5.0.0",62 "ip-regex": "^5.0.0",
62 "ipaddr.js": "^2.2.0",63 "ipaddr.js": "^2.2.0",
src/endpoints/backgrounds.js+3 -2
@@ -4,7 +4,8 @@ import path from 'node:path';
4import express from 'express';4import express from 'express';
5import sanitize from 'sanitize-filename';5import sanitize from 'sanitize-filename';
66
7import { dimensions, invalidateThumbnail } from './thumbnails.js';7import { invalidateThumbnail } from './thumbnails.js';
8import { thumbnailDimensions } from './image-metadata.js';
8import { getImages } from '../util.js';9import { getImages } from '../util.js';
9import { getFileNameValidationFunction } from '../middleware/validateFileName.js';10import { getFileNameValidationFunction } from '../middleware/validateFileName.js';
1011
@@ -12,7 +13,7 @@ export const router = express.Router();
1213
13router.post('/all', function (request, response) {14router.post('/all', function (request, response) {
14 const images = getImages(request.user.directories.backgrounds);15 const images = getImages(request.user.directories.backgrounds);
15 const config = { width: dimensions.bg[0], height: dimensions.bg[1] };16 const config = { width: thumbnailDimensions.bg[0], height: thumbnailDimensions.bg[1] };
16 response.json({ images, config });17 response.json({ images, config });
17});18});
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
6import * as fs from 'node:fs/promises';
7import path from 'node:path';
8import crypto from 'node:crypto';
9import { imageSize } from 'image-size';
10import writeFileAtomic from 'write-file-atomic';
11import express from 'express';
12import { Jimp } from '../jimp.js';
13import { getConfigValue, isPathUnderParent } from '../util.js';
14
15export 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[]>} */
41export 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 */
52export 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 */
65function 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 */
74export 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 */
85async 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 */
109export 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 */
164export 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 */
179export 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 */
193export 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 */
251export 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 */
267export 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 */
290export 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
320export const router = express.Router();
321
322/**
323 * POST /api/image-metadata
324 * Get metadata for image(s) by path.
325 */
326router.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 */
408router.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 @@
1import fs from 'node:fs';1import fs from 'node:fs';
2import { promises as fsPromises } from 'node:fs';
3import path from 'node:path';2import path from 'node:path';
43
5import mime from 'mime-types';
6import express from 'express';4import express from 'express';
7import sanitize from 'sanitize-filename';5import sanitize from 'sanitize-filename';
8import { Jimp, JimpMime } from '../jimp.js';6import { Jimp, JimpMime } from '../jimp.js';
9import { sync as writeFileAtomicSync } from 'write-file-atomic';7import { sync as writeFileAtomicSync } from 'write-file-atomic';
8import { imageSize as sizeOf } from 'image-size';
109
11import { getConfigValue, invalidateFirefoxCache } from '../util.js';10import { getConfigValue, invalidateFirefoxCache } from '../util.js';
11import { getThumbnailResolution, isAnimatedWebP, 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']);
1219
13const thumbnailsEnabled = !!getConfigValue('thumbnails.enabled', true, 'boolean');20const thumbnailsEnabled = !!getConfigValue('thumbnails.enabled', true, 'boolean');
14const quality = Math.min(100, Math.max(1, parseInt(getConfigValue('thumbnails.quality', 95, 'number'))));21const 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
18 * @typedef {'bg' | 'avatar' | 'persona'} ThumbnailType25 * @typedef {'bg' | 'avatar' | 'persona'} ThumbnailType
19 */26 */
2027
21/** @type {Record<string, number[]>} */
22export 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
28/**29/**
29 * Gets a path to thumbnail folder based on the type.30 * Gets a path to thumbnail folder based on the type.
@@ -91,162 +92,212 @@ export function invalidateThumbnail(directories, type, file) {
91}92}
9293
93/**94/**
94 * Generates a thumbnail for the given file.95 * Generates or retrieves a thumbnail for a given file.
95 * @param {import('../users.js').UserDirectoryList} directories User directories96 * @param {import('../users.js').UserDirectoryList} directories - User's directory configuration.
96 * @param {ThumbnailType} type Type of the thumbnail97 * @param {ThumbnailType} type - Type of thumbnail ('bg', 'avatar', 'persona').
97 * @param {string} file Name of the file98 * @param {string} file - The filename of the image.
98 * @returns99 * @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.
99 */102 */
100async function generateThumbnail(directories, type, file) {103export 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);
103 if (thumbnailFolder === undefined || originalFolder === undefined) throw new Error('Invalid thumbnail type');111 if (thumbnailFolder === undefined || originalFolder === undefined) throw new Error('Invalid thumbnail type');
104 const pathToCachedFile = path.join(thumbnailFolder, file);112 const pathToCachedFile = path.join(thumbnailFolder, file);
105 const pathToOriginalFile = path.join(originalFolder, file);
106113
107 const cachedFileExists = fs.existsSync(pathToCachedFile);114 try {
108 const originalFileExists = fs.existsSync(pathToOriginalFile);115 const pathToOriginalFile = path.join(originalFolder, file);
109116
110 // to handle cases when original image was updated after thumb creation117 // Check if thumbnail already exists and return it if not forcing regeneration
111 let shouldRegenerate = false;118 if (!forceGenerate && fs.existsSync(pathToCachedFile)) {
112119 try {
113 if (cachedFileExists && originalFileExists) {120 // Check if original image was updated after thumbnail creation
114 const originalStat = fs.statSync(pathToOriginalFile);121 const originalFileExists = fs.existsSync(pathToOriginalFile);
115 const cachedStat = fs.statSync(pathToCachedFile);122 if (originalFileExists) {
116123 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 };
120 }147 }
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.
133153 if (fileExtension === '.webp' && isKnownAnimated !== false) {
134 try {154 const buffer = fs.readFileSync(pathToOriginalFile);
135 const size = dimensions[type];155 const isAnimated = 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' });
143 }160 }
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 };
147 }164 }
148165
149 writeFileAtomicSync(pathToCachedFile, buffer);166 // Process the image to generate thumbnail
150 }167 const result = await processSingleImage(file, originalFolder, thumbnailFolder, type);
151 catch (outer) {168 if (result.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 };
153 }177 }
154
155 return pathToCachedFile;
156}178}
157179
158/**180/**
159 * Ensures that the thumbnail cache for backgrounds is valid.181 * Processes a single image to generate its thumbnail.
160 * @param {import('../users.js').UserDirectoryList[]} directoriesList User directories182 * @param {string} file - The filename of the image.
161 * @returns {Promise<void>} Promise that resolves when the cache is validated183 * @param {string} originalFolder - Path to the original image folder.
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.
162 */187 */
163export async function ensureThumbnailCache(directoriesList) {188async function processSingleImage(file, originalFolder, thumbnailFolder, type) {
164 for (const directories of directoriesList) {189 const pathToOriginalFile = path.join(originalFolder, file);
165 const cacheFiles = fs.readdirSync(directories.thumbnailsBg);190 const pathToCachedFile = path.join(thumbnailFolder, file);
166191
167 // files exist, all ok192 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 });
170 }219 }
171220
172 console.info('Generating thumbnails cache. Please wait...');221 const buffer = pngFormat
173222 ? 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 };
183 }231 }
184}232}
185233
186export const router = express.Router();234/**
187235 * 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.
189router.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') {239publicRouter.get('/', async function (request, response) {
192 return response.sendStatus(400);240 try {
193 }241 const { file: rawFile, type, animated } = request.query;
194242 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
202 if (!(type === 'bg' || type === 'avatar' || type === 'persona')) {243 if (!(type === 'bg' || type === 'avatar' || type === 'persona')) {
203 return response.sendStatus(400);244 return response.sendStatus(400);
204 }245 }
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
211 if (!thumbnailsEnabled) {250 const serveOriginal = () => {
212 const folder = getOriginalFolder(request.user.directories, type);251 const folder = getOriginalFolder(request.user.directories, type);
213252 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
226 invalidateFirefoxCache(pathToOriginalFile, request, response);254 invalidateFirefoxCache(pathToOriginalFile, request, response);
255 return response.sendFile(pathToOriginalFile);
256 };
227257
228 return response.send(originalFile);258 if (!thumbnailsEnabled) {
259 return serveOriginal();
229 }260 }
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();
235 }269 }
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
237 if (!fs.existsSync(pathToCachedFile)) {279 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 }
239 }285 }
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);
248 } catch (error) {295 } catch (error) {
249 console.error('Failed getting thumbnail', error);296 console.error('Failed getting thumbnail', error);
250 return response.sendStatus(500);297 return response.sendStatus(500);
251 }298 }
252});299});
300
301export const router = express.Router();
302router.use(publicRouter);
303router.use(apiRouter);
src/server-main.js+0 -2
@@ -59,7 +59,6 @@ import {
59 getConfigValue,59 getConfigValue,
60} from './util.js';60} from './util.js';
61import { UPLOADS_DIRECTORY } from './constants.js';61import { UPLOADS_DIRECTORY } from './constants.js';
62import { ensureThumbnailCache } from './endpoints/thumbnails.js';
6362
64// Routers63// Routers
65import { router as usersPublicRouter } from './endpoints/users-public.js';64import { router as usersPublicRouter } from './endpoints/users-public.js';
@@ -269,7 +268,6 @@ async function preSetupTasks() {
269 const directories = await getUserDirectoriesList();268 const directories = await getUserDirectoriesList();
270 await migrateGroupChatsMetadataFormat(directories);269 await migrateGroupChatsMetadataFormat(directories);
271 await checkForNewContent(directories);270 await checkForNewContent(directories);
272 await ensureThumbnailCache(directories);
273 await diskCache.verify(directories);271 await diskCache.verify(directories);
274 migrateFlatSecrets(directories);272 migrateFlatSecrets(directories);
275 cleanUploads();273 cleanUploads();
src/server-startup.js+2 -0
@@ -48,6 +48,7 @@ import { router as azureRouter } from './endpoints/azure.js';
48import { router as minimaxRouter } from './endpoints/minimax.js';48import { router as minimaxRouter } from './endpoints/minimax.js';
49import { router as dataMaidRouter } from './endpoints/data-maid.js';49import { router as dataMaidRouter } from './endpoints/data-maid.js';
50import { router as backupsRouter } from './endpoints/backups.js';50import { router as backupsRouter } from './endpoints/backups.js';
51import { router as imageMetadataRouter } from './endpoints/image-metadata.js';
51import { router as volcengineRouter } from './endpoints/volcengine.js';52import { router as volcengineRouter } from './endpoints/volcengine.js';
5253
53/**54/**
@@ -179,6 +180,7 @@ export function setupPrivateEndpoints(app) {
179 app.use('/api/minimax', minimaxRouter);180 app.use('/api/minimax', minimaxRouter);
180 app.use('/api/data-maid', dataMaidRouter);181 app.use('/api/data-maid', dataMaidRouter);
181 app.use('/api/backups', backupsRouter);182 app.use('/api/backups', backupsRouter);
183 app.use('/api/image-metadata', imageMetadataRouter);
182}184}
183185
184/**186/**