/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>
Signed| @@ -67,6 +67,7 @@ | ||
| 67 | 67 | "host-validation-middleware": "^0.1.1", |
| 68 | 68 | "html-entities": "^2.6.0", |
| 69 | 69 | "iconv-lite": "^0.6.3", |
| 70 | + "image-size": "^2.0.2", | |
| 70 | 71 | "ip-matching": "^2.1.2", |
| 71 | 72 | "ip-regex": "^5.0.0", |
| 72 | 73 | "ipaddr.js": "^2.2.0", |
| @@ -5836,6 +5837,18 @@ | ||
| 5836 | 5837 | "integrity": "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g==", |
| 5837 | 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 | 5852 | "node_modules/immediate": { |
| 5840 | 5853 | "version": "3.0.6", |
| 5841 | 5854 | "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", |
| @@ -6732,6 +6745,33 @@ | ||
| 6732 | 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 | 6775 | "node_modules/node-releases": { |
| 6736 | 6776 | "version": "2.0.18", |
| 6737 | 6777 | "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz", |
| @@ -6941,10 +6981,27 @@ | ||
| 6941 | 6981 | "node": ">=8" |
| 6942 | 6982 | } |
| 6943 | 6983 | }, |
| 6944 | 6984 | "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": { | |
| 6945 | 7001 | "version": "3.1.0", |
| 6946 | 7002 | "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", |
| 6947 | 7003 | "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", |
| 7004 | + "dev": true, | |
| 6948 | 7005 | "license": "MIT", |
| 6949 | 7006 | "dependencies": { |
| 6950 | 7007 | "yocto-queue": "^0.1.0" |
| @@ -6956,15 +7013,12 @@ | ||
| 6956 | 7013 | "url": "https://github.com/sponsors/sindresorhus" |
| 6957 | 7014 | } |
| 6958 | 7015 | }, |
| 6959 | 7016 | "node_modules/p-locate/node_modules/yocto-queue": { |
| 6960 | 7017 | "version": "5.0.1.0", |
| 6961 | 7018 | "resolved": "https://registry.npmjs.org/pyocto-locatequeue/-/pyocto-locatequeue-5.0.1.0.tgz", |
| 6962 | 7019 | "integrity": "sha512-LaNjtRWUBY++zB5nErVksvsnNCdJ/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7XohGc6xgPwyN8eheCxsiLM8mxuE/tlt/QYq3TIeE6nxHppbo2LGymrG5PwmOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", |
| 6963 | 7020 | "dev": true, |
| 6964 | 7021 | "license": "MIT", |
| 6965 | - "dependencies": { | |
| 6966 | - "p-limit": "^3.0.2" | |
| 6967 | - }, | |
| 6968 | 7022 | "engines": { |
| 6969 | 7023 | "node": ">=10" |
| 6970 | 7024 | }, |
| @@ -9236,18 +9290,6 @@ | ||
| 9236 | 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 | 9293 | "node_modules/zip-stream": { |
| 9252 | 9294 | "version": "6.0.1", |
| 9253 | 9295 | "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz", |
| @@ -57,6 +57,7 @@ | ||
| 57 | 57 | "host-validation-middleware": "^0.1.1", |
| 58 | 58 | "html-entities": "^2.6.0", |
| 59 | 59 | "iconv-lite": "^0.6.3", |
| 60 | + "image-size": "^2.0.2", | |
| 60 | 61 | "ip-matching": "^2.1.2", |
| 61 | 62 | "ip-regex": "^5.0.0", |
| 62 | 63 | "ipaddr.js": "^2.2.0", |
| @@ -4,7 +4,8 @@ import path from 'node:path'; | ||
| 4 | 4 | import express from 'express'; |
| 5 | 5 | import sanitize from 'sanitize-filename'; |
| 6 | 6 | |
| 7 | 7 | import { dimensions, invalidateThumbnail } from './thumbnails.js'; |
| 8 | +import { thumbnailDimensions } from './image-metadata.js'; | |
| 8 | 9 | import { getImages } from '../util.js'; |
| 9 | 10 | import { getFileNameValidationFunction } from '../middleware/validateFileName.js'; |
| 10 | 11 | |
| @@ -12,7 +13,7 @@ export const router = express.Router(); | ||
| 12 | 13 | |
| 13 | 14 | router.post('/all', function (request, response) { |
| 14 | 15 | const images = getImages(request.user.directories.backgrounds); |
| 15 | 16 | const config = { width: dimensionsthumbnailDimensions.bg[0], height: dimensionsthumbnailDimensions.bg[1] }; |
| 16 | 17 | response.json({ images, config }); |
| 17 | 18 | }); |
| 18 | 19 | |
| @@ -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 | +}); | |
| @@ -1,14 +1,21 @@ | ||
| 1 | 1 | import fs from 'node:fs'; |
| 2 | -import { promises as fsPromises } from 'node:fs'; | |
| 3 | 2 | import path from 'node:path'; |
| 4 | 3 | |
| 5 | -import mime from 'mime-types'; | |
| 6 | 4 | import express from 'express'; |
| 7 | 5 | import sanitize from 'sanitize-filename'; |
| 8 | 6 | import { Jimp, JimpMime } from '../jimp.js'; |
| 9 | 7 | import { sync as writeFileAtomicSync } from 'write-file-atomic'; |
| 8 | +import { imageSize as sizeOf } from 'image-size'; | |
| 10 | 9 | |
| 11 | 10 | import { 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']); | |
| 12 | 19 | |
| 13 | 20 | const thumbnailsEnabled = !!getConfigValue('thumbnails.enabled', true, 'boolean'); |
| 14 | 21 | const 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 | 25 | * @typedef {'bg' | 'avatar' | 'persona'} ThumbnailType |
| 19 | 26 | */ |
| 20 | 27 | |
| 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 | -}; | |
| 27 | 28 | |
| 28 | 29 | /** |
| 29 | 30 | * Gets a path to thumbnail folder based on the type. |
| @@ -91,162 +92,212 @@ export function invalidateThumbnail(directories, type, file) { | ||
| 91 | 92 | } |
| 92 | 93 | |
| 93 | 94 | /** |
| 94 | 95 | * Generates or retrieves a thumbnail for thea given file. |
| 95 | 96 | * @param {import('../users.js').UserDirectoryList} directories - User's directoriesdirectory configuration. |
| 96 | 97 | * @param {ThumbnailType} type - Type of the thumbnail ('bg', 'avatar', 'persona'). |
| 97 | 98 | * @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. | |
| 99 | 102 | */ |
| 100 | 103 | export 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 | 111 | if (thumbnailFolder === undefined || originalFolder === undefined) throw new Error('Invalid thumbnail type'); |
| 104 | 112 | const pathToCachedFile = path.join(thumbnailFolder, file); |
| 113 | + | |
| 114 | + try { | |
| 105 | 115 | const pathToOriginalFile = path.join(originalFolder, file); |
| 106 | 116 | |
| 107 | - const cachedFileExists = fs.existsSync(pathToCachedFile); | |
| 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 | |
| 108 | 121 | const originalFileExists = fs.existsSync(pathToOriginalFile); |
| 109 | - | |
| 122 | + if (originalFileExists) { | |
| 110 | - // to handle cases when original image was updated after thumb creation | |
| 111 | - let shouldRegenerate = false; | |
| 112 | - | |
| 113 | - if (cachedFileExists && originalFileExists) { | |
| 114 | 123 | const originalStat = fs.statSync(pathToOriginalFile); |
| 115 | 124 | const cachedStat = fs.statSync(pathToCachedFile); |
| 116 | 125 | |
| 117 | 126 | if (originalStat.mtimeMs > cachedStat.ctimeMs) { |
| 118 | 127 | //console.warn(' Original file changed., Regeneratingregenerate thumbnail...'); |
| 119 | 128 | shouldRegenerate forceGenerate = true; |
| 120 | 129 | } |
| 121 | 130 | } |
| 122 | 131 | |
| 123 | 132 | if (cachedFileExists && !shouldRegenerateforceGenerate) { |
| 124 | - return pathToCachedFile; | |
| 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 }; | |
| 125 | 139 | } |
| 126 | - | |
| 140 | + } catch (e) { | |
| 127 | - if (!originalFileExists) { | |
| 141 | + forceGenerate = true; | |
| 128 | - return null; | |
| 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 }; | |
| 129 | 147 | } |
| 130 | 148 | |
| 131 | - try { | |
| 149 | + const fileExtension = path.extname(file).toLowerCase(); | |
| 132 | - let buffer; | |
| 133 | 150 | |
| 134 | - try { | |
| 151 | + // For WebP files, we must check if they are animated, as Jimp cannot process them. | |
| 135 | - const size = dimensions[type]; | |
| 152 | + // If isKnownAnimated is false, we assume the caller knows it is static and skip this check. | |
| 136 | - const image = await Jimp.read(pathToOriginalFile); | |
| 153 | + if (fileExtension === '.webp' && isKnownAnimated !== false) { | |
| 137 | - const width = !isNaN(size?.[0]) && size?.[0] > 0 ? size[0] : image.bitmap.width; | |
| 154 | + const buffer = fs.readFileSync(pathToOriginalFile); | |
| 138 | - const height = !isNaN(size?.[1]) && size?.[1] > 0 ? size[1] : image.bitmap.height; | |
| 155 | + const isAnimated = isAnimatedWebP(buffer); | |
| 139 | - image.cover({ w: width, h: height }); | |
| 156 | + if (isAnimated) { | |
| 140 | - buffer = pngFormat | |
| 157 | + // The client is expected to handle it. | |
| 141 | - ? await image.getBuffer(JimpMime.png) | |
| 158 | + return { path: null, aspectRatio: null, resolution: null }; | |
| 142 | - : await image.getBuffer(JimpMime.jpeg, { quality: quality, jpegColorSpace: 'ycbcr' }); | |
| 143 | 159 | } |
| 144 | - catch (inner) { | |
| 145 | - console.warn(`Thumbnailer can not process the image: ${pathToOriginalFile}. Using original size`, inner); | |
| 146 | - buffer = fs.readFileSync(pathToOriginalFile); | |
| 147 | 160 | } |
| 148 | 161 | |
| 149 | - writeFileAtomicSync(pathToCachedFile, buffer); | |
| 162 | + if (SKIPPED_EXTENSIONS.has(fileExtension)) { | |
| 150 | - } | |
| 163 | + return { path: null, aspectRatio: null, resolution: null }; | |
| 151 | - catch (outer) { | |
| 152 | - return null; | |
| 153 | 164 | } |
| 154 | 165 | |
| 155 | - return pathToCachedFile; | |
| 166 | + // Process the image to generate thumbnail | |
| 167 | + const result = await processSingleImage(file, originalFolder, thumbnailFolder, type); | |
| 168 | + if (result.success) { | |
| 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 }; | |
| 177 | + } | |
| 156 | 178 | } |
| 157 | 179 | |
| 158 | 180 | /** |
| 159 | 181 | * 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. | |
| 161 | 183 | * @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. | |
| 162 | 187 | */ |
| 163 | 188 | export async function ensureThumbnailCacheprocessSingleImage(directoriesListfile, originalFolder, thumbnailFolder, type) { |
| 164 | - for (const directories of directoriesList) { | |
| 189 | + const pathToOriginalFile = path.join(originalFolder, file); | |
| 165 | 190 | const cacheFilespathToCachedFile = fspath.readdirSyncjoin(directories.thumbnailsBgthumbnailFolder, file); |
| 166 | 191 | |
| 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 }); | |
| 170 | 219 | } |
| 171 | 220 | |
| 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 = []; | |
| 176 | 224 | |
| 177 | - for (const file of bgFiles) { | |
| 225 | + writeFileAtomicSync(pathToCachedFile, buffer); | |
| 178 | - tasks.push(generateThumbnail(directories, 'bg', file)); | |
| 179 | - } | |
| 180 | 226 | |
| 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 | } |
| 185 | 233 | |
| 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. | |
| 238 | + */ | |
| 239 | +publicRouter.get('/', async function (request, response) { | |
| 190 | 240 | try { |
| 191 | - if (typeof request.query.file !== 'string' || typeof request.query.type !== 'string') { | |
| 241 | + const { file: rawFile, type, animated } = request.query; | |
| 192 | - return response.sendStatus(400); | |
| 242 | + if (typeof rawFile !== 'string' || typeof type !== 'string') return response.sendStatus(400); | |
| 193 | - } | |
| 194 | - | |
| 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 | 243 | if (!(type === 'bg' || type === 'avatar' || type === 'persona')) { |
| 203 | 244 | return response.sendStatus(400); |
| 204 | 245 | } |
| 205 | 246 | |
| 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 | - } | |
| 210 | 249 | |
| 211 | 250 | ifconst serveOriginal = (!thumbnailsEnabled) => { |
| 212 | 251 | const folder = getOriginalFolder(request.user.directories, type); |
| 252 | + const pathToOriginalFile = path.resolve(path.join(folder, file)); | |
| 253 | + if (!fs.existsSync(pathToOriginalFile)) return response.sendStatus(404); | |
| 254 | + invalidateFirefoxCache(pathToOriginalFile, request, response); | |
| 255 | + return response.sendFile(pathToOriginalFile); | |
| 256 | + }; | |
| 213 | 257 | |
| 214 | 258 | if (folder === undefined!thumbnailsEnabled) { |
| 215 | 259 | return response.sendStatusserveOriginal(400); |
| 216 | - } | |
| 217 | - | |
| 218 | - const pathToOriginalFile = path.join(folder, file); | |
| 219 | - if (!fs.existsSync(pathToOriginalFile)) { | |
| 220 | - return response.sendStatus(404); | |
| 221 | 260 | } |
| 222 | - const contentType = mime.lookup(pathToOriginalFile) || 'image/png'; | |
| 223 | - const originalFile = await fsPromises.readFile(pathToOriginalFile); | |
| 224 | - response.setHeader('Content-Type', contentType); | |
| 225 | 261 | |
| 226 | - invalidateFirefoxCache(pathToOriginalFile, request, response); | |
| 262 | + const animatedEnabled = animated === 'true'; | |
| 263 | + const fileExtension = path.extname(file).toLowerCase(); | |
| 264 | + const isAnimatedFormat = SKIPPED_EXTENSIONS.has(fileExtension); | |
| 227 | 265 | |
| 228 | - return response.send(originalFile); | |
| 266 | + // Serve original for animated formats or GIFs | |
| 267 | + if (animatedEnabled && isAnimatedFormat) { | |
| 268 | + return serveOriginal(); | |
| 229 | 269 | } |
| 230 | 270 | |
| 231 | - const pathToCachedFile = await generateThumbnail(request.user.directories, type, file); | |
| 271 | + if (fileExtension === '.gif') { | |
| 232 | - | |
| 272 | + return serveOriginal(); | |
| 233 | - if (!pathToCachedFile) { | |
| 234 | - return response.sendStatus(404); | |
| 235 | 273 | } |
| 236 | 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 | 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 | } |
| 240 | 286 | |
| 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 | + } | |
| 244 | 291 | |
| 245 | - invalidateFirefoxCache(file, request, response); | |
| 292 | + // Send a 404 so the frontend can display a placeholder | |
| 293 | + return response.sendStatus(404); | |
| 246 | 294 | |
| 247 | - return response.send(cachedFile); | |
| 248 | 295 | } catch (error) { |
| 249 | 296 | console.error('Failed getting thumbnail', error); |
| 250 | 297 | return response.sendStatus(500); |
| 251 | 298 | } |
| 252 | 299 | }); |
| 300 | + | |
| 301 | +export const router = express.Router(); | |
| 302 | +router.use(publicRouter); | |
| 303 | +router.use(apiRouter); | |
| @@ -59,7 +59,6 @@ import { | ||
| 59 | 59 | getConfigValue, |
| 60 | 60 | } from './util.js'; |
| 61 | 61 | import { UPLOADS_DIRECTORY } from './constants.js'; |
| 62 | -import { ensureThumbnailCache } from './endpoints/thumbnails.js'; | |
| 63 | 62 | |
| 64 | 63 | // Routers |
| 65 | 64 | import { router as usersPublicRouter } from './endpoints/users-public.js'; |
| @@ -269,7 +268,6 @@ async function preSetupTasks() { | ||
| 269 | 268 | const directories = await getUserDirectoriesList(); |
| 270 | 269 | await migrateGroupChatsMetadataFormat(directories); |
| 271 | 270 | await checkForNewContent(directories); |
| 272 | - await ensureThumbnailCache(directories); | |
| 273 | 271 | await diskCache.verify(directories); |
| 274 | 272 | migrateFlatSecrets(directories); |
| 275 | 273 | cleanUploads(); |
| @@ -48,6 +48,7 @@ import { router as azureRouter } from './endpoints/azure.js'; | ||
| 48 | 48 | import { router as minimaxRouter } from './endpoints/minimax.js'; |
| 49 | 49 | import { router as dataMaidRouter } from './endpoints/data-maid.js'; |
| 50 | 50 | import { router as backupsRouter } from './endpoints/backups.js'; |
| 51 | +import { router as imageMetadataRouter } from './endpoints/image-metadata.js'; | |
| 51 | 52 | import { router as volcengineRouter } from './endpoints/volcengine.js'; |
| 52 | 53 | |
| 53 | 54 | /** |
| @@ -179,6 +180,7 @@ export function setupPrivateEndpoints(app) { | ||
| 179 | 180 | app.use('/api/minimax', minimaxRouter); |
| 180 | 181 | app.use('/api/data-maid', dataMaidRouter); |
| 181 | 182 | app.use('/api/backups', backupsRouter); |
| 183 | + app.use('/api/image-metadata', imageMetadataRouter); | |
| 182 | 184 | } |
| 183 | 185 | |
| 184 | 186 | /** |