source: Klonkt/src/services/ThumbnailService.js@ 5bc67b4

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

fix(images): 96px feed/comment/follow avatars (2x of the 44px display)

A 128px avatar thumbnail shown at 44px = ~2.9x browser downscale → still jagged for
line-art. Serve the small fediverse avatars at 96px (~2.2x, the same crisp ratio as the grid).

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