Changeset 74c5abc in Klonkt for src/services/ThumbnailService.js
- Timestamp:
- 06/28/2026 04:46:38 PM (2 months ago)
- Branches:
- main
- Children:
- 99989f9
- Parents:
- f79a471
- File:
-
- 1 edited
-
src/services/ThumbnailService.js (modified) (2 diffs)
Legend:
- Unmodified
- Added
- Removed
-
src/services/ThumbnailService.js
rf79a471 r74c5abc 17 17 import path from 'path'; 18 18 import fs from 'fs'; 19 import crypto from 'crypto'; 19 20 import { promisify } from 'util'; 21 import { safeFetch } from './ActivityPubService.js'; 20 22 21 23 const execFileP = promisify(execFile); 22 24 23 // Allowed widths (whitelist → no arbitrary-size abuse). 480 ≈ 2× a grid tile (retina).24 export const THUMB_SIZES = new Set([ 320, 480, 640]);25 // Allowed widths (whitelist → no arbitrary-size abuse). 128 = avatars; 480 ≈ 2× a grid tile. 26 export const THUMB_SIZES = new Set([128, 320, 480, 640]); 25 27 26 28 let _seq = 0; … … 75 77 } 76 78 } 79 80 // ── Signed remote-image proxy ───────────────────────────────────── 81 // Remote avatars/images (fediverse) live on OTHER servers, so we fetch them once 82 // (SSRF-safe via safeFetch), downscale them identically, and cache. The proxy URL is 83 // HMAC-signed so it can't be abused as an open image-resizer: only URLs that Klonkt 84 // itself rendered are accepted. 85 86 let _key; 87 function imgKey() { 88 if (_key) return _key; 89 _key = process.env.SESSION_SECRET || ''; 90 if (!_key) { 91 try { 92 const dataDir = path.dirname(path.resolve(process.env.DATABASE_PATH || './storage/database.sqlite')); 93 _key = fs.readFileSync(path.join(dataDir, '.session-secret'), 'utf8').trim(); 94 } catch { _key = 'klonkt-img-proxy'; } 95 } 96 return _key; 97 } 98 99 function sign(url, w) { 100 return crypto.createHmac('sha256', imgKey()).update(`${w}:${url}`).digest('hex').slice(0, 24); 101 } 102 103 // Signed proxy URL for a remote image (used by the avatar() view helper). 104 export function imgProxyUrl(url, width) { 105 return `/img/a/${width}?u=${encodeURIComponent(url)}&s=${sign(url, width)}`; 106 } 107 108 export function verifyImg(url, width, sig) { 109 if (!sig || !url) return false; 110 let want; 111 try { want = sign(url, width); } catch { return false; } 112 try { return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(want)); } catch { return false; } 113 } 114 115 /** 116 * Fetch a remote image (SSRF-safe), downscale to `width` (lanczos → WebP), cache it. 117 * @returns {Promise<string|null>} cached path, or null. 118 */ 119 export async function getRemoteThumbnail(url, width) { 120 if (!THUMB_SIZES.has(width) || !ffmpegPath || !url) return null; 121 const root = mediaRoot(); 122 const hash = crypto.createHash('sha256').update(url).digest('hex'); 123 // Remote filenames are content-hashed (Mastodon/Klonkt) → URL-keyed cache never stales. 124 const cached = path.join(root, '.thumbs', 'remote', String(width), `${hash}.webp`); 125 if (fs.existsSync(cached)) return cached; 126 127 let buf; 128 try { 129 const r = await safeFetch(url); 130 if (!r.ok) return null; 131 if (!(r.headers.get('content-type') || '').startsWith('image/')) return null; 132 if (parseInt(r.headers.get('content-length') || '0', 10) > 12 * 1024 * 1024) return null; 133 buf = Buffer.from(await r.arrayBuffer()); 134 } catch (e) { 135 console.warn('[thumb-remote] fetch failed for', url, '-', e.message); 136 return null; 137 } 138 if (buf.length > 12 * 1024 * 1024) return null; 139 140 await fs.promises.mkdir(path.dirname(cached), { recursive: true }); 141 const tmpIn = `${cached}.in-${process.pid}-${_seq++}`; 142 const tmpOut = `${cached}.out-${process.pid}-${_seq++}`; 143 try { 144 await fs.promises.writeFile(tmpIn, buf); 145 await execFileP(ffmpegPath, [ 146 '-hide_banner', '-loglevel', 'error', '-y', 147 '-i', tmpIn, 148 '-vf', `scale='min(${width},iw)':-2:flags=lanczos`, 149 '-frames:v', '1', 150 '-c:v', 'libwebp', '-q:v', '82', '-f', 'webp', 151 tmpOut, 152 ], { timeout: 20000 }); 153 await fs.promises.rename(tmpOut, cached); 154 return cached; 155 } catch (e) { 156 console.warn('[thumb-remote] downscale failed for', url, '-', e.message); 157 return null; 158 } finally { 159 fs.promises.unlink(tmpIn).catch(() => {}); 160 fs.promises.unlink(tmpOut).catch(() => {}); 161 } 162 }
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)