Changeset 74c5abc in Klonkt
- Timestamp:
- 06/28/2026 04:46:38 PM (2 months ago)
- Branches:
- main
- Children:
- 99989f9
- Parents:
- f79a471
- Location:
- src
- Files:
-
- 7 edited
-
middleware/render.js (modified) (2 diffs)
-
server.js (modified) (2 diffs)
-
services/ActivityPubService.js (modified) (1 diff)
-
services/ThumbnailService.js (modified) (2 diffs)
-
views/pages/following.ejs (modified) (1 diff)
-
views/pages/news.ejs (modified) (1 diff)
-
views/partials/fedi-node.ejs (modified) (1 diff)
Legend:
- Unmodified
- Added
- Removed
-
src/middleware/render.js
rf79a471 r74c5abc 20 20 import { isPremium as isPremiumInstance, premiumEnabled, premiumUnlocked } from '../services/PatreonService.js'; 21 21 import ActivityPubService from '../services/ActivityPubService.js'; 22 import { imgProxyUrl } from '../services/ThumbnailService.js'; 22 23 import { audioEnabled as audioFeatureEnabled } from '../config/features.js'; 23 24 … … 165 166 ? `/media/thumb/${w || 480}/${url.slice(7)}` 166 167 : 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 }, 167 177 pageTitle: data.pageTitle || (data.site && data.site.title) || 'Klonkt', 168 178 appVersion: APP_VERSION, -
src/server.js
rf79a471 r74c5abc 20 20 import { SqliteSessionStore } from './services/SqliteSessionStore.js'; 21 21 import { ensurePrimarySite } from './services/ensurePrimarySite.js'; 22 import { getThumbnail, THUMB_SIZES } from './services/ThumbnailService.js';22 import { getThumbnail, getRemoteThumbnail, verifyImg, THUMB_SIZES } from './services/ThumbnailService.js'; 23 23 import { resolveSite, loadAudioTracks, loadTheme } from './middleware/site.js'; 24 24 import { isViewer } from './middleware/auth.js'; … … 234 234 }); 235 235 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. 239 app.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 236 253 app.use('/media', express.static(process.env.MEDIA_PATH || './storage/media', { 237 254 // Public media (post covers, avatars) must be cross-origin embeddable by other -
src/services/ActivityPubService.js
rf79a471 r74c5abc 60 60 if (!addrs.length || addrs.some((a) => isBlockedIp(a.address))) throw new Error('ssrf-blocked-host'); 61 61 } 62 async function safeFetch(url, opts = {}, maxRedirects = 3) {62 export async function safeFetch(url, opts = {}, maxRedirects = 3) { 63 63 let target = url; 64 64 for (let hop = 0; ; hop++) { -
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 } -
src/views/pages/following.ejs
rf79a471 r74c5abc 20 20 <% following.forEach(function(f){ %> 21 21 <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> 23 23 <span class="tl-foll-meta"> 24 24 <a href="<%= f.url || f.actor_uri %>" target="_blank" rel="nofollow noopener"><%= f.name || f.handle %></a> -
src/views/pages/news.ejs
rf79a471 r74c5abc 93 93 <% 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><% } %> 94 94 <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> 96 96 <span class="tl-id"> 97 97 <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 1 1 <%# Renders one fediverse thread node (n). Expects: n, t, canManageSite, _base, siteAvatar, formatDateTime, postSlug %> 2 2 <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"> 4 4 <% } else { %><span><%= (n.actor_name || '?').charAt(0).toUpperCase() %></span><% } %> 5 5 </div>
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)