source: Klonkt/src/services/ThumbnailService.js@ 201ec06

main
Last change on this file since 201ec06 was 201ec06, checked in by Robin Genis <roboburr@…>, 2 months ago

fix(images): cap concurrent ffmpeg (thumbnails) to avoid CPU saturation

A cold-cache image-heavy page fired one ffmpeg per thumbnail at once (a thundering herd),
saturating CPU and making the whole instance slow. Cap concurrent ffmpeg spawns to 3; excess
requests wait briefly for a slot. Applies to local + remote thumbnail generation.

  • services/ThumbnailService.js — acquireSlot/releaseSlot semaphore + runFfmpeg wrapper
  • Property mode set to 100644
File size: 6.9 KB
Line 
1/**
2 * On-demand cover thumbnails.
3 *
4 * High-res covers (especially line-art) look jagged because the BROWSER downscales
5 * them to the small grid/list size. We instead downscale the stored original
6 * server-side with ffmpeg's lanczos filter to a small WebP and cache it on disk, so
7 * the browser receives a near-1:1 image → crisp lines.
8 *
9 * No re-upload / backfill: the original file is only READ, never modified, and
10 * thumbnails are generated lazily on first request. Cover filenames are content-
11 * hashed/UUID, so a cached thumbnail can never go stale (a new cover = a new name).
12 *
13 * Uses the bundled `ffmpeg-static` (always present); cwebp is not required here.
14 */
15import { execFile } from 'child_process';
16import ffmpegPath from 'ffmpeg-static';
17import path from 'path';
18import fs from 'fs';
19import crypto from 'crypto';
20import { promisify } from 'util';
21import { safeFetch } from './ActivityPubService.js';
22
23const execFileP = promisify(execFile);
24
25// Allowed widths (whitelist → no arbitrary-size abuse). 128 = avatars; 480 ≈ 2× a grid tile.
26export const THUMB_SIZES = new Set([128, 320, 480, 640]);
27
28let _seq = 0;
29
30// Limit concurrent ffmpeg spawns. A cold-cache, image-heavy page fires many thumbnail
31// requests at once; without a cap each spawns its own ffmpeg → CPU saturation makes the
32// WHOLE instance slow (the thundering herd). With the cap, excess requests wait briefly
33// for a slot → bounded CPU, the page still loads (images just appear progressively).
34const MAX_CONCURRENT = 3;
35let _active = 0;
36const _waiters = [];
37function acquireSlot() {
38 if (_active < MAX_CONCURRENT) { _active++; return Promise.resolve(); }
39 return new Promise((resolve) => _waiters.push(resolve));
40}
41function releaseSlot() {
42 const next = _waiters.shift();
43 if (next) next(); // transfer the slot directly to the next waiter (_active unchanged)
44 else _active--;
45}
46async function runFfmpeg(args) {
47 await acquireSlot();
48 try { await execFileP(ffmpegPath, args, { timeout: 20000 }); }
49 finally { releaseSlot(); }
50}
51
52function mediaRoot() {
53 return path.resolve(process.env.MEDIA_PATH || './storage/media');
54}
55
56// Resolve a safe absolute path for a relative media path; null on traversal attempts.
57function safeOriginal(rel) {
58 const root = mediaRoot();
59 const orig = path.resolve(root, rel);
60 if (orig !== root && !orig.startsWith(root + path.sep)) return null;
61 return orig;
62}
63
64/**
65 * Return the on-disk path of the cached thumbnail, generating it if needed.
66 * @returns {Promise<string|null>} absolute path, or null if it can't be produced.
67 */
68export async function getThumbnail(rel, width) {
69 if (!THUMB_SIZES.has(width) || !ffmpegPath || !rel) return null;
70 const orig = safeOriginal(rel);
71 if (!orig || !fs.existsSync(orig)) return null;
72
73 const root = mediaRoot();
74 // Cache under <media>/.thumbs/<w>/<rel>.webp (dotted dir → never collides with media).
75 const cached = path.join(root, '.thumbs', String(width), rel) + '.webp';
76 if (fs.existsSync(cached)) return cached;
77
78 await fs.promises.mkdir(path.dirname(cached), { recursive: true });
79 const tmp = `${cached}.tmp-${process.pid}-${_seq++}`;
80 try {
81 await runFfmpeg([
82 '-hide_banner', '-loglevel', 'error', '-y',
83 '-i', orig,
84 // Downscale to `width` (never upscale past the original) with lanczos; even height.
85 '-vf', `scale='min(${width},iw)':-2:flags=lanczos`,
86 '-frames:v', '1',
87 '-c:v', 'libwebp', '-q:v', '82',
88 // Force the WebP muxer: the tmp filename has no .webp extension, so ffmpeg
89 // can't infer the output format from it.
90 '-f', 'webp',
91 tmp,
92 ]);
93 await fs.promises.rename(tmp, cached);
94 return cached;
95 } catch (e) {
96 try { await fs.promises.unlink(tmp); } catch {}
97 console.warn('[thumb] generation failed for', rel, '-', e.message);
98 return null;
99 }
100}
101
102// ── Signed remote-image proxy ─────────────────────────────────────
103// Remote avatars/images (fediverse) live on OTHER servers, so we fetch them once
104// (SSRF-safe via safeFetch), downscale them identically, and cache. The proxy URL is
105// HMAC-signed so it can't be abused as an open image-resizer: only URLs that Klonkt
106// itself rendered are accepted.
107
108let _key;
109function imgKey() {
110 if (_key) return _key;
111 _key = process.env.SESSION_SECRET || '';
112 if (!_key) {
113 try {
114 const dataDir = path.dirname(path.resolve(process.env.DATABASE_PATH || './storage/database.sqlite'));
115 _key = fs.readFileSync(path.join(dataDir, '.session-secret'), 'utf8').trim();
116 } catch { _key = 'klonkt-img-proxy'; }
117 }
118 return _key;
119}
120
121function sign(url, w) {
122 return crypto.createHmac('sha256', imgKey()).update(`${w}:${url}`).digest('hex').slice(0, 24);
123}
124
125// Signed proxy URL for a remote image (used by the avatar() view helper).
126export function imgProxyUrl(url, width) {
127 return `/img/a/${width}?u=${encodeURIComponent(url)}&s=${sign(url, width)}`;
128}
129
130export function verifyImg(url, width, sig) {
131 if (!sig || !url) return false;
132 let want;
133 try { want = sign(url, width); } catch { return false; }
134 try { return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(want)); } catch { return false; }
135}
136
137/**
138 * Fetch a remote image (SSRF-safe), downscale to `width` (lanczos → WebP), cache it.
139 * @returns {Promise<string|null>} cached path, or null.
140 */
141export async function getRemoteThumbnail(url, width) {
142 if (!THUMB_SIZES.has(width) || !ffmpegPath || !url) return null;
143 const root = mediaRoot();
144 const hash = crypto.createHash('sha256').update(url).digest('hex');
145 // Remote filenames are content-hashed (Mastodon/Klonkt) → URL-keyed cache never stales.
146 const cached = path.join(root, '.thumbs', 'remote', String(width), `${hash}.webp`);
147 if (fs.existsSync(cached)) return cached;
148
149 let buf;
150 try {
151 const r = await safeFetch(url);
152 if (!r.ok) return null;
153 if (!(r.headers.get('content-type') || '').startsWith('image/')) return null;
154 if (parseInt(r.headers.get('content-length') || '0', 10) > 12 * 1024 * 1024) return null;
155 buf = Buffer.from(await r.arrayBuffer());
156 } catch (e) {
157 console.warn('[thumb-remote] fetch failed for', url, '-', e.message);
158 return null;
159 }
160 if (buf.length > 12 * 1024 * 1024) return null;
161
162 await fs.promises.mkdir(path.dirname(cached), { recursive: true });
163 const tmpIn = `${cached}.in-${process.pid}-${_seq++}`;
164 const tmpOut = `${cached}.out-${process.pid}-${_seq++}`;
165 try {
166 await fs.promises.writeFile(tmpIn, buf);
167 await runFfmpeg([
168 '-hide_banner', '-loglevel', 'error', '-y',
169 '-i', tmpIn,
170 '-vf', `scale='min(${width},iw)':-2:flags=lanczos`,
171 '-frames:v', '1',
172 '-c:v', 'libwebp', '-q:v', '82', '-f', 'webp',
173 tmpOut,
174 ]);
175 await fs.promises.rename(tmpOut, cached);
176 return cached;
177 } catch (e) {
178 console.warn('[thumb-remote] downscale failed for', url, '-', e.message);
179 return null;
180 } finally {
181 fs.promises.unlink(tmpIn).catch(() => {});
182 fs.promises.unlink(tmpOut).catch(() => {});
183 }
184}
Note: See TracBrowser for help on using the repository browser.