source: Klonkt/src/services/ThumbnailService.js@ 1a0b007

main
Last change on this file since 1a0b007 was 8308c9ca, checked in by Robin Genis <roboburr@…>, 2 months ago

fix(images): full-width timeline media at 1280px (was 640, blurry on mobile retina)

The News-feed media is full-width — on a phone that's ~430px CSS x 3 DPR ~= 1290 physical px,
so the 640px thumbnail was upscaled and looked low-quality. Serve it at 1280px (crisp on
mobile/desktop retina; capped at the original width). Grid/list covers stay small (their
display is small; a bigger thumb would over-downscale line-art on desktop).

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