Changeset 5a6a457 in Klonkt
- Timestamp:
- 06/25/2026 05:15:48 AM (3 months ago)
- Branches:
- main
- Children:
- d7526bd
- Parents:
- 99234c3
- Location:
- src
- Files:
-
- 5 edited
-
config/database.js (modified) (1 diff)
-
routes/posts.js (modified) (1 diff)
-
server.js (modified) (2 diffs)
-
services/ActivityPubService.js (modified) (5 diffs)
-
services/Scheduler.js (modified) (2 diffs)
Legend:
- Unmodified
- Added
- Removed
-
src/config/database.js
r99234c3 r5a6a457 389 389 ); 390 390 CREATE INDEX IF NOT EXISTS idx_ap_blocks_target ON ap_blocks(target); 391 CREATE TABLE IF NOT EXISTS ap_delivery ( 392 id INTEGER PRIMARY KEY AUTOINCREMENT, 393 slug TEXT NOT NULL, -- our site/actor that signs the delivery 394 inbox TEXT NOT NULL, -- recipient inbox URL 395 body TEXT NOT NULL, -- the activity JSON to POST 396 attempts INTEGER NOT NULL DEFAULT 0, 397 next_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 398 created_at DATETIME DEFAULT CURRENT_TIMESTAMP 399 ); 400 CREATE INDEX IF NOT EXISTS idx_ap_delivery_due ON ap_delivery(next_at); 391 401 `); 392 402 } -
src/routes/posts.js
r99234c3 r5a6a457 353 353 } 354 354 } catch (e) { /* FTS issues non-fatal */ } 355 356 // ActivityPub: federate when a post BECOMES published (draft/scheduled → published 357 // via the editor). A brand-new published post is handled in the create route. 358 if (finalStatus === 'published' && post.status !== 'published' && !fanOnly) { 359 ActivityPubService.deliverCreate(site, { 360 id: post.id, slug: finalSlug, title: title || finalSlug, 361 content: cleanContent, cover_image_url: cover_image_url || null, 362 published_at: publishedAt, created_at: post.created_at, 363 }).catch(() => { /* best-effort */ }); 364 } 355 365 356 366 res.redirect(`${res.locals.siteUrlBase || ''}/${finalSlug}`); -
src/server.js
r99234c3 r5a6a457 61 61 import ogRoutes from './routes/og.js'; 62 62 import apRoutes from './routes/activitypub.js'; 63 import { apWants } from './services/ActivityPubService.js';63 import { apWants, startDeliveryWorker } from './services/ActivityPubService.js'; 64 64 65 65 // SESSION_SECRET: use the env var if set. Otherwise auto-generate a strong one … … 163 163 initializeDatabase(); 164 164 startScheduler(); // release planning: publish scheduled posts when publish_at is reached 165 startDeliveryWorker(); // retry failed fediverse deliveries with backoff 165 166 166 167 // Safety net: guarantee that there is always a primary site (solo/hub/circle). -
src/services/ActivityPubService.js
r99234c3 r5a6a457 121 121 for (const m of body.matchAll(/<img\b[^>]*\bsrc="([^"]+)"[^>]*>/gi)) urls.push(abs(m[1])); 122 122 body = body.replace(/<img\b[^>]*>/gi, ''); 123 // Strip Klonkt audio shortcodes ([[track:…]] etc.) — they'd federate raw as 124 // ugly text (audio federation itself is a later phase). 123 // Audio shortcodes: do NOT federate the raw audio file — Klonkt deliberately 124 // gates audio (the /audio/stream URL has friction), and shipping it as an AP 125 // audio attachment would hand Mastodon a plain, downloadable mp3 URL. Instead, 126 // replace the shortcodes with a "🎵 listen on the site" link so the post invites 127 // a click-through to the protected player (discovery without leaking the file). 128 const esc = (s) => String(s == null ? '' : s).replace(/[<>&]/g, (c) => ({ '<': '<', '>': '>', '&': '&' }[c])); 129 const audioLabels = []; 130 try { 131 for (const m of body.matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)) { const r = db.prepare('SELECT title FROM audio_tracks WHERE id = ?').get(m[1]); if (r && r.title) audioLabels.push(r.title); } 132 for (const m of body.matchAll(/\[\[album:([^\]]+)\]\]/g)) audioLabels.push(m[1].trim()); 133 } catch { /* non-fatal */ } 134 const hadAudio = /\[\[(track|album|playlist):/i.test(body); 125 135 body = body.replace(/\[\[(track|album|playlist):[^\]]+\]\]/gi, ''); 136 if (hadAudio) { 137 const lbl = audioLabels.length ? esc(audioLabels.slice(0, 4).join(', ')) : ''; 138 body += `<p>🎵 ${lbl ? `<strong>${lbl}</strong> — ` : ''}<a href="${human}">listen on ${esc(site.title || 'the site')}</a></p>`; 139 } 126 140 const seen = new Set(); 127 141 const attachment = urls.filter(Boolean) … … 328 342 return await r.json(); 329 343 } catch { return null; } 344 } 345 346 // ── Delivery queue with retries ─────────────────────────────────── 347 // Outbound deliveries are tried immediately; on failure (down server, timeout, 348 // non-2xx) they're queued and retried with backoff so a briefly-offline follower 349 // doesn't silently miss the post. The signing key is NOT stored — the worker 350 // re-derives it from the actor slug at send time. 351 const DELIVERY_MAX_ATTEMPTS = 6; 352 const DELIVERY_BACKOFF_MIN = [1, 5, 15, 60, 180, 360]; 353 let _insDeliv, _dueDeliv, _delDeliv, _bumpDeliv; 354 function deliveryStmts() { 355 if (!_insDeliv) { 356 _insDeliv = db.prepare('INSERT INTO ap_delivery (slug, inbox, body, attempts, next_at) VALUES (?,?,?,0,CURRENT_TIMESTAMP)'); 357 _dueDeliv = db.prepare("SELECT * FROM ap_delivery WHERE datetime(next_at) <= datetime('now') ORDER BY next_at LIMIT 30"); 358 _delDeliv = db.prepare('DELETE FROM ap_delivery WHERE id = ?'); 359 _bumpDeliv = db.prepare('UPDATE ap_delivery SET attempts = ?, next_at = ? WHERE id = ?'); 360 } 361 return { ins: _insDeliv, due: _dueDeliv, del: _delDeliv, bump: _bumpDeliv }; 362 } 363 export function enqueueDelivery(slug, inbox, activity) { 364 if (!slug || !inbox || !activity) return; 365 try { deliveryStmts().ins.run(slug, inbox, JSON.stringify(activity)); } catch { /* ignore */ } 366 } 367 // Deliver now; queue for retry if it fails. 368 export async function deliverWithRetry(slug, inbox, activity, keyId, privPem) { 369 if (!inbox) return; 370 try { const st = await deliver(inbox, activity, keyId, privPem); if (st >= 200 && st < 300) return; } catch { /* queue below */ } 371 enqueueDelivery(slug, inbox, activity); 372 } 373 export async function processDeliveryQueue() { 374 let rows; 375 try { rows = deliveryStmts().due.all(); } catch { return; } 376 if (!rows || !rows.length) return; 377 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, ''); 378 for (const row of rows) { 379 let ok = false; 380 try { 381 const keys = getOrCreateKeys(row.slug); 382 const st = await deliver(row.inbox, JSON.parse(row.body), `${actorId(base, row.slug)}#main-key`, keys.private_pem); 383 ok = st >= 200 && st < 300; 384 } catch { ok = false; } 385 if (ok) { deliveryStmts().del.run(row.id); continue; } 386 const attempts = row.attempts + 1; 387 if (attempts >= DELIVERY_MAX_ATTEMPTS) { deliveryStmts().del.run(row.id); console.warn('[AP] delivery gave up after', attempts, 'tries →', row.inbox); continue; } 388 const mins = DELIVERY_BACKOFF_MIN[Math.min(attempts, DELIVERY_BACKOFF_MIN.length - 1)]; 389 deliveryStmts().bump.run(attempts, new Date(Date.now() + mins * 60000).toISOString(), row.id); 390 } 391 } 392 let _delivTimer = null; 393 export function startDeliveryWorker() { 394 if (_delivTimer) return; 395 _delivTimer = setInterval(() => { processDeliveryQueue().catch(() => {}); }, 60 * 1000); 396 if (_delivTimer.unref) _delivTimer.unref(); 330 397 } 331 398 … … 479 546 const keyId = `${actorId(base, site.slug)}#main-key`; 480 547 const create = buildCreate(base, site, post); 481 for (const inbox of inboxes) deliver (inbox, create, keyId, keys.private_pem).catch(() => { /* best-effort */ });548 for (const inbox of inboxes) deliverWithRetry(site.slug, inbox, create, keyId, keys.private_pem); 482 549 } 483 550 … … 500 567 object: { id: nid, type: 'Tombstone' }, 501 568 }; 502 for (const inbox of inboxes) deliver (inbox, del, `${me}#main-key`, keys.private_pem).catch(() => { /* best-effort */ });569 for (const inbox of inboxes) deliverWithRetry(site.slug, inbox, del, `${me}#main-key`, keys.private_pem); 503 570 } 504 571 … … 838 905 webfingerResolve, followActor, unfollowActor, listFollowing, getTimeline, sendInteraction, 839 906 getNotifications, listBlocks, isBlockedAny, blockTarget, unblock, 907 deliverWithRetry, enqueueDelivery, processDeliveryQueue, startDeliveryWorker, 840 908 }; -
src/services/Scheduler.js
r99234c3 r5a6a457 10 10 import db from '../config/database.js'; 11 11 import HtmlSanitizerService from './HtmlSanitizerService.js'; 12 import ActivityPubService from './ActivityPubService.js'; 12 13 13 14 export function flipScheduledPosts() { 14 15 try { 15 16 const due = db.prepare(` 16 SELECT p.id, p.title, p.content, u.username 17 SELECT p.id, p.site_id, p.slug, p.title, p.content, p.cover_image_url, p.fan_only, 18 p.published_at, p.publish_at, p.created_at, u.username 17 19 FROM posts p JOIN users u ON u.id = p.author_id 18 20 WHERE p.status = 'scheduled' AND p.publish_at IS NOT NULL AND datetime(p.publish_at) <= datetime('now') … … 23 25 ); 24 26 const fts = db.prepare('INSERT INTO posts_fts(content, title, author, post_id) VALUES (?, ?, ?, ?)'); 27 const siteStmt = db.prepare('SELECT * FROM sites WHERE id = ?'); 25 28 for (const p of due) { 26 29 upd.run(p.id); 27 30 try { fts.run(HtmlSanitizerService.toPlainText(p.content || ''), p.title || '', p.username || '', p.id); } catch { /* FTS failure is non-fatal */ } 31 // ActivityPub: federate the now-published post to followers. 32 if (!p.fan_only) { 33 try { 34 const site = siteStmt.get(p.site_id); 35 if (site) { 36 ActivityPubService.deliverCreate(site, { 37 id: p.id, slug: p.slug, title: p.title || p.slug, 38 content: p.content, cover_image_url: p.cover_image_url || null, 39 published_at: p.published_at || p.publish_at, created_at: p.created_at, 40 }).catch(() => { /* best-effort */ }); 41 } 42 } catch { /* non-fatal */ } 43 } 28 44 } 29 45 return due.length;
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)