Changeset 55bc7f9 in Klonkt
- Timestamp:
- 06/24/2026 12:09:08 PM (3 months ago)
- Branches:
- main
- Children:
- 3a2c9d3
- Parents:
- 47a0d29
- Location:
- src
- Files:
-
- 8 edited
-
assets/css/style.css (modified) (1 diff)
-
config/database.js (modified) (1 diff)
-
routes/activitypub.js (modified) (1 diff)
-
routes/posts.js (modified) (4 diffs)
-
services/ActivityPubService.js (modified) (4 diffs)
-
services/i18n.js (modified) (3 diffs)
-
views/pages/post.ejs (modified) (1 diff)
-
views/shell.ejs (modified) (1 diff)
Legend:
- Unmodified
- Added
- Removed
-
src/assets/css/style.css
r47a0d29 r55bc7f9 4434 4434 .post-fediverse .fedi-text p:first-child { margin-top: 0; } 4435 4435 .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 332 332 ); 333 333 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); 334 346 `); 335 347 } -
src/routes/activitypub.js
r47a0d29 r55bc7f9 74 74 "SELECT * FROM posts WHERE id = ? AND status = 'published' AND (fan_only IS NULL OR fan_only = 0)" 75 75 ).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 } 77 82 const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(post.site_id); 78 83 if (!site) return res.status(404).end(); -
src/routes/posts.js
r47a0d29 r55bc7f9 7 7 import ejs from 'ejs'; 8 8 import db from '../config/database.js'; 9 import { requireAuth } from '../middleware/auth.js';9 import { requireAuth, requireSiteManager } from '../middleware/auth.js'; 10 10 import { renderPage } from '../middleware/render.js'; 11 11 import { recordPageview, recordPostView } from '../services/StatsService.js'; … … 749 749 750 750 // 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 }; 752 752 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)); 753 755 754 756 renderPage(req, res, 'pages/post', { … … 760 762 totalComments, 761 763 fediverse, 764 canManageSite, 762 765 likeCount, 763 766 likedByMe, … … 769 772 }); 770 773 774 // ── Reply back to a fediverse interaction (site owner/admin only) ── 775 router.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 771 790 export default router; 772 791 export { postNeighbors }; -
src/services/ActivityPubService.js
r47a0d29 r55bc7f9 191 191 export function followerCount(slug) { return fStmts().cnt.get(slug).n; } 192 192 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 ── 194 let _insI, _delLA, _delReply, _listI, _getI, _insO, _listO, _getO; 195 195 function iStmts() { 196 196 if (!_insI) { … … 198 198 _delLA = db.prepare('DELETE FROM ap_interactions WHERE kind = ? AND post_id = ? AND actor_uri = ?'); 199 199 _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 209 export function getInteractionById(id) { return iStmts().getI.get(id); } 204 210 205 211 const localPostExists = (id) => { try { return !!db.prepare('SELECT 1 FROM posts WHERE id = ?').get(id); } catch { return false; } }; … … 226 232 } 227 233 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. 229 235 export 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 })); 231 242 return { 232 243 replies: rows.filter((r) => r.kind === 'reply'), 244 outReplies, 233 245 likeCount: rows.filter((r) => r.kind === 'like').length, 234 246 announceCount: rows.filter((r) => r.kind === 'announce').length, 235 total: rows.length ,247 total: rows.length + outReplies.length, 236 248 }; 237 249 } … … 399 411 } 400 412 413 // ── outbound replies (Klonkt → fediverse) ───────────────────────── 414 const escHtml = (s) => String(s || '').replace(/[<>&]/g, (c) => ({ '<': '<', '>': '>', '&': '&' }[c])); 415 const 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. 418 export 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). 435 export 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). 445 export 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 401 479 export default { 402 480 getOrCreateKeys, apWants, sendAP, actorId, noteId, 403 481 buildActor, buildNote, buildCreate, buildOutbox, buildFollowers, 404 482 followerCount, deliver, fetchActor, verifyRequest, handleInbox, deliverCreate, deliverDelete, 405 getInteractions, 483 getInteractions, getInteractionById, buildReplyNote, getOutboxNote, deliverReply, 406 484 }; -
src/services/i18n.js
r47a0d29 r55bc7f9 108 108 'comments.empty': 'Nog geen reacties.', 109 109 '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:', 110 111 'comments.to_start': 'om de conversatie te starten.', 111 112 'comments.reply': 'Reageer', 'comments.delete': 'Verwijder', 'comments.cancel': 'Annuleren', … … 1101 1102 'comments.empty': 'No comments yet.', 1102 1103 '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:', 1103 1105 'comments.to_start': 'to start the conversation.', 1104 1106 'comments.reply': 'Reply', 'comments.delete': 'Delete', 'comments.cancel': 'Cancel', … … 2092 2094 'comments.empty': 'Noch keine Kommentare.', 2093 2095 '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:', 2094 2097 'comments.to_start': 'um das Gespräch zu starten.', 2095 2098 'comments.reply': 'Antworten', 'comments.delete': 'Löschen', 'comments.cancel': 'Abbrechen', -
src/views/pages/post.ejs
r47a0d29 r55bc7f9 96 96 </div> 97 97 <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 <% } %> 98 112 </div> 99 113 </li> -
src/views/shell.ejs
r47a0d29 r55bc7f9 175 175 176 176 <!-- v9 stylesheet (full palette system) --> 177 <link rel="stylesheet" href="/assets/css/style.css?v=3 6">177 <link rel="stylesheet" href="/assets/css/style.css?v=37"> 178 178 179 179 <!-- Audio player styles: loaded on every page so the mini-player works
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)