source: Klonkt/src/services/ThumbnailService.js@ 492fc20

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

fix(images): 256px size for list/cirkel covers (match the crisp grid ratio)

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