Changeset 5a6a457 in Klonkt


Ignore:
Timestamp:
06/25/2026 05:15:48 AM (3 months ago)
Author:
Robin Genis <roboburr@…>
Branches:
main
Children:
d7526bd
Parents:
99234c3
Message:

feat(fediverse): federate on save+scheduler, delivery retry-queue, music as listen-link

#1 Posts now federate to followers not only on create, but also when a draft/

scheduled post becomes published (editor save) and when the Scheduler flips a
scheduled post live — previously those silently didn't reach followers.

#2 Delivery retry-queue (ap_delivery): a failed delivery (down server/timeout) is

queued and retried with backoff (1/5/15/60/180/360 min, 6 tries) by a worker,
instead of fire-and-forget. Signing key re-derived from the slug, never stored.

#3 Music posts: audio shortcodes federate as a '🎵 listen on the site' link to the

post (protected player) instead of the raw mp3 — keeps Klonkt's audio friction
intact (no downloadable file handed to Mastodon).

Co-Authored-By: Claude <noreply@…>

Location:
src
Files:
5 edited

Legend:

Unmodified
Added
Removed
  • src/config/database.js

    r99234c3 r5a6a457  
    389389    );
    390390    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);
    391401  `);
    392402}
  • src/routes/posts.js

    r99234c3 r5a6a457  
    353353    }
    354354  } 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  }
    355365
    356366  res.redirect(`${res.locals.siteUrlBase || ''}/${finalSlug}`);
  • src/server.js

    r99234c3 r5a6a457  
    6161import ogRoutes from './routes/og.js';
    6262import apRoutes from './routes/activitypub.js';
    63 import { apWants } from './services/ActivityPubService.js';
     63import { apWants, startDeliveryWorker } from './services/ActivityPubService.js';
    6464
    6565// SESSION_SECRET: use the env var if set. Otherwise auto-generate a strong one
     
    163163initializeDatabase();
    164164startScheduler(); // release planning: publish scheduled posts when publish_at is reached
     165startDeliveryWorker(); // retry failed fediverse deliveries with backoff
    165166
    166167// Safety net: guarantee that there is always a primary site (solo/hub/circle).
  • src/services/ActivityPubService.js

    r99234c3 r5a6a457  
    121121  for (const m of body.matchAll(/<img\b[^>]*\bsrc="([^"]+)"[^>]*>/gi)) urls.push(abs(m[1]));
    122122  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) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[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);
    125135  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  }
    126140  const seen = new Set();
    127141  const attachment = urls.filter(Boolean)
     
    328342    return await r.json();
    329343  } 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.
     351const DELIVERY_MAX_ATTEMPTS = 6;
     352const DELIVERY_BACKOFF_MIN = [1, 5, 15, 60, 180, 360];
     353let _insDeliv, _dueDeliv, _delDeliv, _bumpDeliv;
     354function 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}
     363export 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.
     368export 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}
     373export 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}
     392let _delivTimer = null;
     393export function startDeliveryWorker() {
     394  if (_delivTimer) return;
     395  _delivTimer = setInterval(() => { processDeliveryQueue().catch(() => {}); }, 60 * 1000);
     396  if (_delivTimer.unref) _delivTimer.unref();
    330397}
    331398
     
    479546  const keyId = `${actorId(base, site.slug)}#main-key`;
    480547  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);
    482549}
    483550
     
    500567    object: { id: nid, type: 'Tombstone' },
    501568  };
    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);
    503570}
    504571
     
    838905  webfingerResolve, followActor, unfollowActor, listFollowing, getTimeline, sendInteraction,
    839906  getNotifications, listBlocks, isBlockedAny, blockTarget, unblock,
     907  deliverWithRetry, enqueueDelivery, processDeliveryQueue, startDeliveryWorker,
    840908};
  • src/services/Scheduler.js

    r99234c3 r5a6a457  
    1010import db from '../config/database.js';
    1111import HtmlSanitizerService from './HtmlSanitizerService.js';
     12import ActivityPubService from './ActivityPubService.js';
    1213
    1314export function flipScheduledPosts() {
    1415  try {
    1516    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
    1719      FROM posts p JOIN users u ON u.id = p.author_id
    1820      WHERE p.status = 'scheduled' AND p.publish_at IS NOT NULL AND datetime(p.publish_at) <= datetime('now')
     
    2325    );
    2426    const fts = db.prepare('INSERT INTO posts_fts(content, title, author, post_id) VALUES (?, ?, ?, ?)');
     27    const siteStmt = db.prepare('SELECT * FROM sites WHERE id = ?');
    2528    for (const p of due) {
    2629      upd.run(p.id);
    2730      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      }
    2844    }
    2945    return due.length;
Note: See TracChangeset for help on using the changeset viewer.