Changeset 74c5abc in Klonkt


Ignore:
Timestamp:
06/28/2026 04:46:38 PM (2 months ago)
Author:
Robin Genis <roboburr@…>
Branches:
main
Children:
99989f9
Parents:
f79a471
Message:

feat(images): downscale remote (fediverse) avatars via a signed proxy

Remote avatars live on other servers, so the browser shrank full-res line-art to ~44px
(jagged). Fetch them once (SSRF-safe via safeFetch), downscale identically (lanczos -> WebP),
cache, serve. HMAC-signed proxy URLs → not an open resizer.

  • services/ActivityPubService.js — export safeFetch
  • services/ThumbnailService.js — getRemoteThumbnail + signed imgProxyUrl/verifyImg; +128px size
  • server.js — GET /img/a/:w signed proxy route
  • middleware/render.js — avatar(url,w) helper (local thumb / remote proxy)
  • views: news.ejs, fedi-node.ejs, following.ejs avatars -> avatar()
Location:
src
Files:
7 edited

Legend:

Unmodified
Added
Removed
  • src/middleware/render.js

    rf79a471 r74c5abc  
    2020import { isPremium as isPremiumInstance, premiumEnabled, premiumUnlocked } from '../services/PatreonService.js';
    2121import ActivityPubService from '../services/ActivityPubService.js';
     22import { imgProxyUrl } from '../services/ThumbnailService.js';
    2223import { audioEnabled as audioFeatureEnabled } from '../config/features.js';
    2324
     
    165166      ? `/media/thumb/${w || 480}/${url.slice(7)}`
    166167      : url,
     168    // Crisp avatars: a local /media avatar goes through the local thumb route; a REMOTE
     169    // (fediverse) avatar through the signed downscaling proxy. Same downscale, the remote
     170    // one is just fetched first. Default 128px (covers feed 44px → profile ~120px).
     171    avatar: (url, w) => {
     172      if (!url || typeof url !== 'string') return url;
     173      if (url.startsWith('/media/') && !url.startsWith('/media/thumb/')) return `/media/thumb/${w || 128}/${url.slice(7)}`;
     174      if (/^https?:\/\//i.test(url)) return imgProxyUrl(url, w || 128);
     175      return url;
     176    },
    167177    pageTitle: data.pageTitle || (data.site && data.site.title) || 'Klonkt',
    168178    appVersion: APP_VERSION,
  • src/server.js

    rf79a471 r74c5abc  
    2020import { SqliteSessionStore } from './services/SqliteSessionStore.js';
    2121import { ensurePrimarySite } from './services/ensurePrimarySite.js';
    22 import { getThumbnail, THUMB_SIZES } from './services/ThumbnailService.js';
     22import { getThumbnail, getRemoteThumbnail, verifyImg, THUMB_SIZES } from './services/ThumbnailService.js';
    2323import { resolveSite, loadAudioTracks, loadTheme } from './middleware/site.js';
    2424import { isViewer } from './middleware/auth.js';
     
    234234});
    235235
     236// Signed remote-image proxy: downscale a REMOTE avatar/image (SSRF-safe via safeFetch)
     237// to a cached WebP, so line-art fediverse avatars don't render jagged. Only HMAC-signed
     238// URLs (produced by the avatar() view helper) are accepted — not an open resizer.
     239app.get('/img/a/:w', async (req, res) => {
     240  const w = parseInt(req.params.w, 10);
     241  const url = typeof req.query.u === 'string' ? req.query.u : '';
     242  const sig = typeof req.query.s === 'string' ? req.query.s : '';
     243  if (!THUMB_SIZES.has(w) || !verifyImg(url, w, sig)) return res.status(400).end();
     244  let file = null;
     245  try { file = await getRemoteThumbnail(url, w); } catch { /* fall through to original */ }
     246  if (!file) return res.redirect(302, url); // fetch/downscale failed → let the browser load the remote original
     247  res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
     248  res.setHeader('Cache-Control', isDev ? 'no-cache' : 'public, max-age=604800');
     249  res.type('webp');
     250  res.sendFile(file);
     251});
     252
    236253app.use('/media', express.static(process.env.MEDIA_PATH || './storage/media', {
    237254  // Public media (post covers, avatars) must be cross-origin embeddable by other
  • src/services/ActivityPubService.js

    rf79a471 r74c5abc  
    6060  if (!addrs.length || addrs.some((a) => isBlockedIp(a.address))) throw new Error('ssrf-blocked-host');
    6161}
    62 async function safeFetch(url, opts = {}, maxRedirects = 3) {
     62export async function safeFetch(url, opts = {}, maxRedirects = 3) {
    6363  let target = url;
    6464  for (let hop = 0; ; hop++) {
  • src/services/ThumbnailService.js

    rf79a471 r74c5abc  
    1717import path from 'path';
    1818import fs from 'fs';
     19import crypto from 'crypto';
    1920import { promisify } from 'util';
     21import { safeFetch } from './ActivityPubService.js';
    2022
    2123const execFileP = promisify(execFile);
    2224
    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.
     26export const THUMB_SIZES = new Set([128, 320, 480, 640]);
    2527
    2628let _seq = 0;
     
    7577  }
    7678}
     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
     86let _key;
     87function 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
     99function 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).
     104export function imgProxyUrl(url, width) {
     105  return `/img/a/${width}?u=${encodeURIComponent(url)}&s=${sign(url, width)}`;
     106}
     107
     108export 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 */
     119export 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}
  • src/views/pages/following.ejs

    rf79a471 r74c5abc  
    2020      <% following.forEach(function(f){ %>
    2121        <li class="tl-foll">
    22           <span class="tl-foll-av"><% if (f.icon) { %><img src="<%= f.icon %>" alt=""><% } else { %><%= (f.name || '?').charAt(0).toUpperCase() %><% } %></span>
     22          <span class="tl-foll-av"><% if (f.icon) { %><img src="<%= avatar(f.icon, 128) %>" alt=""><% } else { %><%= (f.name || '?').charAt(0).toUpperCase() %><% } %></span>
    2323          <span class="tl-foll-meta">
    2424            <a href="<%= f.url || f.actor_uri %>" target="_blank" rel="nofollow noopener"><%= f.name || f.handle %></a>
  • src/views/pages/news.ejs

    rf79a471 r74c5abc  
    9393          <% if (p.reblog_name) { %><div class="tl-boost-by"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg> <strong><%= p.reblog_name %></strong> <%= t('tl.boosted') %></div><% } %>
    9494          <div class="tl-head">
    95             <span class="tl-avatar"><% if (p.author_icon) { %><img src="<%= p.author_icon %>" alt="" loading="lazy"><% } else { %><%= (p.author_name || '?').charAt(0).toUpperCase() %><% } %></span>
     95            <span class="tl-avatar"><% if (p.author_icon) { %><img src="<%= avatar(p.author_icon, 128) %>" alt="" loading="lazy"><% } else { %><%= (p.author_name || '?').charAt(0).toUpperCase() %><% } %></span>
    9696            <span class="tl-id">
    9797              <a class="tl-author" href="<%= p.author_url || p.author_uri %>" target="_blank" rel="nofollow noopener"><%= p.author_name %></a>
  • src/views/partials/fedi-node.ejs

    rf79a471 r74c5abc  
    11<%# Renders one fediverse thread node (n). Expects: n, t, canManageSite, _base, siteAvatar, formatDateTime, postSlug %>
    22<div class="comment-avatar">
    3   <% if (n.actor_icon) { %><img src="<%= n.actor_icon %>" alt="" loading="lazy">
     3  <% if (n.actor_icon) { %><img src="<%= avatar(n.actor_icon, 128) %>" alt="" loading="lazy">
    44  <% } else { %><span><%= (n.actor_name || '?').charAt(0).toUpperCase() %></span><% } %>
    55</div>
Note: See TracChangeset for help on using the changeset viewer.