/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 | "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", |
| @@ -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", |
| @@ -4,7 +4,8 @@ import path from 'node:path'; | |||
| 4 | import express from 'express'; | 4 | import express from 'express'; |
| 5 | import sanitize from 'sanitize-filename'; | 5 | import sanitize from 'sanitize-filename'; |
| 6 | 6 | ||
| 7 | import { dimensions, invalidateThumbnail } from './thumbnails.js'; | 7 | import { invalidateThumbnail } from './thumbnails.js'; |
| 8 | import { thumbnailDimensions } from './image-metadata.js'; | ||
| 8 | import { getImages } from '../util.js'; | 9 | import { getImages } from '../util.js'; |
| 9 | import { getFileNameValidationFunction } from '../middleware/validateFileName.js'; | 10 | import { getFileNameValidationFunction } from '../middleware/validateFileName.js'; |
| 10 | 11 | ||
| @@ -12,7 +13,7 @@ export const router = express.Router(); | |||
| 12 | 13 | ||
| 13 | router.post('/all', function (request, response) { | 14 | router.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 | }); |
| 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 | import fs from 'node:fs'; | 1 | import fs from 'node:fs'; |
| 2 | import { promises as fsPromises } from 'node:fs'; | ||
| 3 | import path from 'node:path'; | 2 | import path from 'node:path'; |
| 4 | 3 | ||
| 5 | import mime from 'mime-types'; | ||
| 6 | import express from 'express'; | 4 | import express from 'express'; |
| 7 | import sanitize from 'sanitize-filename'; | 5 | import sanitize from 'sanitize-filename'; |
| 8 | import { Jimp, JimpMime } from '../jimp.js'; | 6 | import { Jimp, JimpMime } from '../jimp.js'; |
| 9 | import { sync as writeFileAtomicSync } from 'write-file-atomic'; | 7 | import { sync as writeFileAtomicSync } from 'write-file-atomic'; |
| 8 | import { imageSize as sizeOf } from 'image-size'; | ||
| 10 | 9 | ||
| 11 | import { getConfigValue, invalidateFirefoxCache } from '../util.js'; | 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 | const thumbnailsEnabled = !!getConfigValue('thumbnails.enabled', true, 'boolean'); | 20 | const thumbnailsEnabled = !!getConfigValue('thumbnails.enabled', true, 'boolean'); |
| 14 | const quality = Math.min(100, Math.max(1, parseInt(getConfigValue('thumbnails.quality', 95, 'number')))); | 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 | * @typedef {'bg' | 'avatar' | 'persona'} ThumbnailType | 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 | * 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 | } |
| 92 | 93 | ||
| 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 directories | 96 | * @param {import('../users.js').UserDirectoryList} directories - User's directory configuration. |
| 96 | * @param {ThumbnailType} type Type of the thumbnail | 97 | * @param {ThumbnailType} type - Type of thumbnail ('bg', 'avatar', 'persona'). |
| 97 | * @param {string} file Name of the file | 98 | * @param {string} file - The filename of the image. |
| 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 | async function generateThumbnail(directories, type, file) { | 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 | 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); |
| 113 | |||
| 114 | try { | ||
| 105 | const pathToOriginalFile = path.join(originalFolder, file); | 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 | const originalFileExists = fs.existsSync(pathToOriginalFile); | 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 | const originalStat = fs.statSync(pathToOriginalFile); | 123 | const originalStat = fs.statSync(pathToOriginalFile); |
| 115 | const cachedStat = fs.statSync(pathToCachedFile); | 124 | const cachedStat = fs.statSync(pathToCachedFile); |
| 116 | 125 | ||
| 117 | if (originalStat.mtimeMs > cachedStat.ctimeMs) { | 126 | if (originalStat.mtimeMs > cachedStat.ctimeMs) { |
| 118 | //console.warn('Original file changed. Regenerating thumbnail...'); | 127 | // Original file changed, regenerate thumbnail |
| 119 | shouldRegenerate = true; | 128 | forceGenerate = true; |
| 120 | } | 129 | } |
| 121 | } | 130 | } |
| 122 | 131 | ||
| 123 | if (cachedFileExists && !shouldRegenerate) { | 132 | if (!forceGenerate) { |
| 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 | * 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 directories | 182 | * @param {string} file - The filename of the image. |
| 161 | * @returns {Promise<void>} Promise that resolves when the cache is validated | 183 | * @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 | */ |
| 163 | export async function ensureThumbnailCache(directoriesList) { | 188 | async 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); |
| 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 | try { | 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 | 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 | } |
| 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 | if (!thumbnailsEnabled) { | 250 | const serveOriginal = () => { |
| 212 | const folder = getOriginalFolder(request.user.directories, type); | 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 | if (folder === undefined) { | 258 | if (!thumbnailsEnabled) { |
| 215 | return response.sendStatus(400); | 259 | return serveOriginal(); |
| 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 | 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 | } |
| 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 | } 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 | |||
| 301 | export const router = express.Router(); | ||
| 302 | router.use(publicRouter); | ||
| 303 | router.use(apiRouter); | ||
| @@ -59,7 +59,6 @@ import { | |||
| 59 | getConfigValue, | 59 | getConfigValue, |
| 60 | } from './util.js'; | 60 | } from './util.js'; |
| 61 | import { UPLOADS_DIRECTORY } from './constants.js'; | 61 | import { UPLOADS_DIRECTORY } from './constants.js'; |
| 62 | import { ensureThumbnailCache } from './endpoints/thumbnails.js'; | ||
| 63 | 62 | ||
| 64 | // Routers | 63 | // Routers |
| 65 | import { router as usersPublicRouter } from './endpoints/users-public.js'; | 64 | import { 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(); |
| @@ -48,6 +48,7 @@ import { router as azureRouter } from './endpoints/azure.js'; | |||
| 48 | import { router as minimaxRouter } from './endpoints/minimax.js'; | 48 | import { router as minimaxRouter } from './endpoints/minimax.js'; |
| 49 | import { router as dataMaidRouter } from './endpoints/data-maid.js'; | 49 | import { router as dataMaidRouter } from './endpoints/data-maid.js'; |
| 50 | import { router as backupsRouter } from './endpoints/backups.js'; | 50 | import { router as backupsRouter } from './endpoints/backups.js'; |
| 51 | import { router as imageMetadataRouter } from './endpoints/image-metadata.js'; | ||
| 51 | import { router as volcengineRouter } from './endpoints/volcengine.js'; | 52 | import { router as volcengineRouter } from './endpoints/volcengine.js'; |
| 52 | 53 | ||
| 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 | } |
| 183 | 185 | ||
| 184 | /** | 186 | /** |