Changeset 55bc7f9 in Klonkt


Ignore:
Timestamp:
06/24/2026 12:09:08 PM (3 months ago)
Author:
Robin Genis <roboburr@…>
Branches:
main
Children:
3a2c9d3
Parents:
47a0d29
Message:

feat(activitypub): reply back to the fediverse (Phase 4 outbound)

Site owner can reply to an inbound fediverse interaction from the post page. The
reply is sent as a signed Create(Note) with inReplyTo + @Mention to the remote
actor's inbox + our followers, stored in ap_outbox, shown in the thread, and
resolvable at /ap/notes/<id>.

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

Location:
src
Files:
8 edited

Legend:

Unmodified
Added
Removed
  • src/assets/css/style.css

    r47a0d29 r55bc7f9  
    44344434.post-fediverse .fedi-text p:first-child { margin-top: 0; }
    44354435.post-fediverse .fedi-text a { color: var(--accent, #06c); }
     4436.post-fediverse .fedi-ours {
     4437  margin: .6rem 0 0; padding: .55rem .75rem; border-radius: 12px;
     4438  border-left: 3px solid var(--accent, #888);
     4439  background: color-mix(in srgb, var(--accent, #888) 8%, transparent);
     4440  font-size: .95rem;
     4441}
     4442.post-fediverse .fedi-ours-label { font-weight: 600; color: var(--accent, #555); margin-right: .25rem; }
     4443.post-fediverse .fedi-ours-body p { margin: 0; display: inline; }
     4444.post-fediverse .fedi-replybox { margin-top: .55rem; }
     4445.post-fediverse .fedi-replybox summary { cursor: pointer; font-size: .85rem; color: var(--ink-soft, #888); width: fit-content; }
     4446.post-fediverse .fedi-replybox form { display: flex; flex-direction: column; gap: .5rem; margin-top: .5rem; }
     4447.post-fediverse .fedi-replybox textarea {
     4448  width: 100%; box-sizing: border-box; resize: vertical; padding: .55rem .7rem;
     4449  border-radius: 10px; border: 1px solid color-mix(in srgb, var(--ink, #000) 18%, transparent);
     4450  background: var(--paper, #fff); color: var(--ink, #000); font: inherit;
     4451}
     4452.post-fediverse .fedi-replybox button { align-self: flex-end; }
  • src/config/database.js

    r47a0d29 r55bc7f9  
    332332    );
    333333    CREATE INDEX IF NOT EXISTS idx_ap_inter_post ON ap_interactions(post_id, kind);
     334    CREATE TABLE IF NOT EXISTS ap_outbox (
     335      id TEXT PRIMARY KEY,            -- note path segment (uuid) → /ap/notes/<id>
     336      site_slug TEXT NOT NULL,
     337      post_id TEXT NOT NULL,
     338      post_slug TEXT,
     339      in_reply_to TEXT,               -- remote status uri we reply to
     340      to_actor TEXT,                  -- remote actor uri (mentioned)
     341      to_handle TEXT,
     342      content TEXT NOT NULL,          -- sanitized HTML of our reply
     343      created_at DATETIME DEFAULT CURRENT_TIMESTAMP
     344    );
     345    CREATE INDEX IF NOT EXISTS idx_ap_outbox_post ON ap_outbox(post_id);
    334346  `);
    335347}
  • src/routes/activitypub.js

    r47a0d29 r55bc7f9  
    7474    "SELECT * FROM posts WHERE id = ? AND status = 'published' AND (fan_only IS NULL OR fan_only = 0)"
    7575  ).get(req.params.id);
    76   if (!post) return res.status(404).end();
     76  if (!post) {
     77    // Could be one of OUR outbound replies (ap_outbox), not a post.
     78    const note = AP.getOutboxNote(baseUrl(req), req.params.id);
     79    if (note) return AP.sendAP(res, { '@context': 'https://www.w3.org/ns/activitystreams', ...note });
     80    return res.status(404).end();
     81  }
    7782  const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(post.site_id);
    7883  if (!site) return res.status(404).end();
  • src/routes/posts.js

    r47a0d29 r55bc7f9  
    77import ejs from 'ejs';
    88import db from '../config/database.js';
    9 import { requireAuth } from '../middleware/auth.js';
     9import { requireAuth, requireSiteManager } from '../middleware/auth.js';
    1010import { renderPage } from '../middleware/render.js';
    1111import { recordPageview, recordPostView } from '../services/StatsService.js';
     
    749749
    750750  // Inbound fediverse activity (replies/likes/boosts) for this post.
    751   let fediverse = { replies: [], likeCount: 0, announceCount: 0, total: 0 };
     751  let fediverse = { replies: [], outReplies: [], likeCount: 0, announceCount: 0, total: 0 };
    752752  try { fediverse = ActivityPubService.getInteractions(post.id); } catch { /* non-fatal */ }
     753  // Owner/admin of this site may reply back to a fediverse interaction.
     754  const canManageSite = !!(req.session?.user && PermissionsService.canAdminSite(req.session.user, site));
    753755
    754756  renderPage(req, res, 'pages/post', {
     
    760762    totalComments,
    761763    fediverse,
     764    canManageSite,
    762765    likeCount,
    763766    likedByMe,
     
    769772});
    770773
     774// ── Reply back to a fediverse interaction (site owner/admin only) ──
     775router.post('/posts/:slug/fedi-reply', requireSiteManager, async (req, res) => {
     776  const site = res.locals.site;
     777  if (!site) return res.status(404).send('Site required');
     778  const post = db.prepare('SELECT id, slug FROM posts WHERE site_id = ? AND slug = ?').get(site.id, req.params.slug);
     779  if (!post) return res.status(404).send('Not found');
     780  const parent = ActivityPubService.getInteractionById(req.body.interaction_id);
     781  const text = (req.body.text || '').toString();
     782  if (parent && parent.post_id === post.id && text.trim()) {
     783    try {
     784      await ActivityPubService.deliverReply(site, { postId: post.id, postSlug: post.slug, parent, text });
     785    } catch (e) { console.warn('[AP] reply send failed:', e.message); }
     786  }
     787  res.redirect(`${res.locals.siteUrlBase || ''}/${post.slug}#fediverse`);
     788});
     789
    771790export default router;
    772791export { postNeighbors };
  • src/services/ActivityPubService.js

    r47a0d29 r55bc7f9  
    191191export function followerCount(slug) { return fStmts().cnt.get(slug).n; }
    192192
    193 // ── inbound interactions store (replies / likes / boosts), lazy stmts ──
    194 let _insI, _delLA, _delReply, _listI;
     193// ── inbound interactions store (replies / likes / boosts) + our outbound replies ──
     194let _insI, _delLA, _delReply, _listI, _getI, _insO, _listO, _getO;
    195195function iStmts() {
    196196  if (!_insI) {
     
    198198    _delLA = db.prepare('DELETE FROM ap_interactions WHERE kind = ? AND post_id = ? AND actor_uri = ?');
    199199    _delReply = db.prepare("DELETE FROM ap_interactions WHERE kind = 'reply' AND object_uri = ?");
    200     _listI = db.prepare('SELECT kind, actor_uri, actor_name, actor_handle, actor_url, actor_icon, content, published, created_at FROM ap_interactions WHERE post_id = ? ORDER BY created_at ASC');
    201   }
    202   return { ins: _insI, delLA: _delLA, delReply: _delReply, list: _listI };
    203 }
     200    _listI = db.prepare('SELECT id, kind, object_uri, actor_uri, actor_name, actor_handle, actor_url, actor_icon, content, published, created_at FROM ap_interactions WHERE post_id = ? ORDER BY created_at ASC');
     201    _getI = db.prepare('SELECT * FROM ap_interactions WHERE id = ?');
     202    _insO = db.prepare('INSERT INTO ap_outbox (id, site_slug, post_id, post_slug, in_reply_to, to_actor, to_handle, content, created_at) VALUES (?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)');
     203    _listO = db.prepare('SELECT * FROM ap_outbox WHERE post_id = ? ORDER BY created_at ASC');
     204    _getO = db.prepare('SELECT * FROM ap_outbox WHERE id = ?');
     205  }
     206  return { ins: _insI, delLA: _delLA, delReply: _delReply, list: _listI, getI: _getI, insO: _insO, listO: _listO, getO: _getO };
     207}
     208
     209export function getInteractionById(id) { return iStmts().getI.get(id); }
    204210
    205211const localPostExists = (id) => { try { return !!db.prepare('SELECT 1 FROM posts WHERE id = ?').get(id); } catch { return false; } };
     
    226232}
    227233
    228 // Stored, view-ready summary of a post's inbound fediverse activity.
     234// Stored, view-ready summary of a post's inbound fediverse activity + our replies.
    229235export function getInteractions(postId) {
    230   const rows = iStmts().list.all(postId);
     236  const s = iStmts();
     237  const rows = s.list.all(postId);
     238  const outReplies = s.listO.all(postId).map((o) => ({
     239    id: o.id, content: o.content, in_reply_to: o.in_reply_to, to_handle: o.to_handle,
     240    created_at: o.created_at, mine: true,
     241  }));
    231242  return {
    232243    replies: rows.filter((r) => r.kind === 'reply'),
     244    outReplies,
    233245    likeCount: rows.filter((r) => r.kind === 'like').length,
    234246    announceCount: rows.filter((r) => r.kind === 'announce').length,
    235     total: rows.length,
     247    total: rows.length + outReplies.length,
    236248  };
    237249}
     
    399411}
    400412
     413// ── outbound replies (Klonkt → fediverse) ─────────────────────────
     414const escHtml = (s) => String(s || '').replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
     415const toISO = (v) => { if (!v) return new Date().toISOString(); const s = String(v); const d = new Date(/[TZ]/.test(s) ? s : s.replace(' ', 'T') + 'Z'); return isNaN(d) ? new Date().toISOString() : d.toISOString(); };
     416
     417// Build one of OUR outbound reply Notes from an ap_outbox row.
     418export function buildReplyNote(base, site, row) {
     419  const me = actorId(base, site.slug);
     420  return {
     421    id: noteId(base, row.id),
     422    type: 'Note',
     423    attributedTo: me,
     424    inReplyTo: row.in_reply_to || undefined,
     425    content: row.content,
     426    url: row.post_slug ? `${base}/${encodeURIComponent(row.post_slug)}` : undefined,
     427    published: toISO(row.created_at),
     428    to: row.to_actor ? [row.to_actor] : [PUBLIC],
     429    cc: [PUBLIC, `${me}/followers`],
     430    tag: row.to_actor ? [{ type: 'Mention', href: row.to_actor, name: row.to_handle }] : [],
     431  };
     432}
     433
     434// Resolve one of our outbound reply Notes by id (for /ap/notes/:id fallback).
     435export function getOutboxNote(base, id) {
     436  const row = iStmts().getO.get(id);
     437  if (!row) return null;
     438  const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(row.site_slug);
     439  if (!site) return null;
     440  return buildReplyNote(base, site, row);
     441}
     442
     443// Send a reply FROM this site to a remote actor (in reply to their inbound reply).
     444// `parent` = an ap_interactions row (actor_uri, actor_url, actor_handle, object_uri).
     445export async function deliverReply(site, { postId, postSlug, parent, text }) {
     446  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
     447  if (!base || !site || !site.slug || !parent || !String(text || '').trim()) return null;
     448  const me = actorId(base, site.slug);
     449  const handle = parent.actor_handle || deriveHandle(parent.actor_uri);
     450  const body = escHtml(String(text).trim()).replace(/\r?\n/g, '<br>');
     451  const mention = parent.actor_uri
     452    ? `<a href="${escHtml(parent.actor_url || parent.actor_uri)}" class="u-url mention">${escHtml(handle)}</a> ` : '';
     453  const content = `<p>${mention}${body}</p>`;
     454  const id = crypto.randomUUID();
     455  iStmts().insO.run(id, site.slug, postId, postSlug || null, parent.object_uri || null, parent.actor_uri || null, handle, content);
     456  const row = iStmts().getO.get(id);
     457  const note = buildReplyNote(base, site, row);
     458  const create = {
     459    '@context': 'https://www.w3.org/ns/activitystreams',
     460    id: note.id + '#create', type: 'Create', actor: me,
     461    published: note.published, to: note.to, cc: note.cc, object: note,
     462  };
     463  const keys = getOrCreateKeys(site.slug);
     464  const keyId = `${me}#main-key`;
     465  const inboxes = new Set();
     466  if (parent.actor_uri) {
     467    const a = await fetchActor(parent.actor_uri).catch(() => null);
     468    if (a) inboxes.add((a.endpoints && a.endpoints.sharedInbox) || a.inbox);
     469  }
     470  for (const f of fStmts().list.all(site.slug)) inboxes.add(f.shared_inbox || f.inbox);
     471  let delivered = 0;
     472  for (const inbox of [...inboxes].filter(Boolean)) {
     473    try { const st = await deliver(inbox, create, keyId, keys.private_pem); if (st >= 200 && st < 300) delivered++; } catch { /* best-effort */ }
     474  }
     475  console.log('[AP] outreply', site.slug, '→', parent.actor_uri, 'delivered', delivered);
     476  return { id, content, delivered };
     477}
     478
    401479export default {
    402480  getOrCreateKeys, apWants, sendAP, actorId, noteId,
    403481  buildActor, buildNote, buildCreate, buildOutbox, buildFollowers,
    404482  followerCount, deliver, fetchActor, verifyRequest, handleInbox, deliverCreate, deliverDelete,
    405   getInteractions,
     483  getInteractions, getInteractionById, buildReplyNote, getOutboxNote, deliverReply,
    406484};
  • src/services/i18n.js

    r47a0d29 r55bc7f9  
    108108    'comments.empty': 'Nog geen reacties.',
    109109    'fedi.heading': 'Vanuit de fediverse', 'fedi.likes': 'sterren', 'fedi.boosts': 'boosts', 'fedi.replies': 'Reacties uit de fediverse',
     110    'fedi.reply': 'Reageer', 'fedi.reply_ph': 'Je antwoord aan de fediverse…', 'fedi.send': 'Versturen', 'fedi.you': 'Jij:',
    110111    'comments.to_start': 'om de conversatie te starten.',
    111112    'comments.reply': 'Reageer', 'comments.delete': 'Verwijder', 'comments.cancel': 'Annuleren',
     
    11011102    'comments.empty': 'No comments yet.',
    11021103    'fedi.heading': 'From the fediverse', 'fedi.likes': 'favourites', 'fedi.boosts': 'boosts', 'fedi.replies': 'Replies from the fediverse',
     1104    'fedi.reply': 'Reply', 'fedi.reply_ph': 'Your reply to the fediverse…', 'fedi.send': 'Send', 'fedi.you': 'You:',
    11031105    'comments.to_start': 'to start the conversation.',
    11041106    'comments.reply': 'Reply', 'comments.delete': 'Delete', 'comments.cancel': 'Cancel',
     
    20922094    'comments.empty': 'Noch keine Kommentare.',
    20932095    'fedi.heading': 'Aus dem Fediverse', 'fedi.likes': 'Favoriten', 'fedi.boosts': 'Boosts', 'fedi.replies': 'Antworten aus dem Fediverse',
     2096    'fedi.reply': 'Antworten', 'fedi.reply_ph': 'Deine Antwort an das Fediverse…', 'fedi.send': 'Senden', 'fedi.you': 'Du:',
    20942097    'comments.to_start': 'um das Gespräch zu starten.',
    20952098    'comments.reply': 'Antworten', 'comments.delete': 'Löschen', 'comments.cancel': 'Abbrechen',
  • src/views/pages/post.ejs

    r47a0d29 r55bc7f9  
    9696                </div>
    9797                <div class="fedi-text"><%- r.content %></div>
     98                <% var mine = (typeof fediverse.outReplies !== 'undefined' ? fediverse.outReplies : []).filter(function(o){ return o.in_reply_to && o.in_reply_to === r.object_uri; }); %>
     99                <% mine.forEach(function(o){ %>
     100                  <div class="fedi-ours"><span class="fedi-ours-label"><%= t('fedi.you') %></span> <span class="fedi-ours-body"><%- o.content %></span></div>
     101                <% }); %>
     102                <% if (typeof canManageSite !== 'undefined' && canManageSite) { %>
     103                  <details class="fedi-replybox">
     104                    <summary><%= t('fedi.reply') %></summary>
     105                    <form method="post" action="<%= _base %>/posts/<%= post.slug %>/fedi-reply">
     106                      <input type="hidden" name="interaction_id" value="<%= r.id %>">
     107                      <textarea name="text" rows="2" required placeholder="<%= t('fedi.reply_ph') %>"></textarea>
     108                      <button type="submit" class="btn btn-primary"><%= t('fedi.send') %></button>
     109                    </form>
     110                  </details>
     111                <% } %>
    98112              </div>
    99113            </li>
  • src/views/shell.ejs

    r47a0d29 r55bc7f9  
    175175
    176176<!-- v9 stylesheet (full palette system) -->
    177 <link rel="stylesheet" href="/assets/css/style.css?v=36">
     177<link rel="stylesheet" href="/assets/css/style.css?v=37">
    178178
    179179<!-- Audio player styles: loaded on every page so the mini-player works
Note: See TracChangeset for help on using the changeset viewer.