Changeset dd568e7 in Klonkt for src/services/ActivityPubService.js
- Timestamp:
- 07/19/2026 03:15:18 AM (7 weeks ago)
- Branches:
- main
- Children:
- bf72108
- Parents:
- d49b60b
- git-author:
- Robin <roboburr@…> (07/19/2026 03:14:01 AM)
- git-committer:
- Robin <roboburr@…> (07/19/2026 03:15:18 AM)
- File:
-
- 1 edited
-
src/services/ActivityPubService.js (modified) (2 diffs)
Legend:
- Unmodified
- Added
- Removed
-
src/services/ActivityPubService.js
rd49b60b rdd568e7 1750 1750 } 1751 1751 1752 // ── ActivityPub Client-to-Server: ingest an activity POSTed to the outbox ── 1753 // The C2S counterpart of handleInbox: a native/web client (Shaer) posts an 1754 // activity here and we translate it onto the SAME delivery machinery the web UI 1755 // uses (deliverReply / sendInteraction / followActor / deliverCreate). Returns 1756 // { status, id?, url?, error? }. Auth + site-ownership are checked by the route. 1757 const c2sIdOf = (x) => (typeof x === 'string' ? x : (x && (x.id || x.href))) || null; 1758 1759 export async function ingestOutboxActivity(site, user, activity) { 1760 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, ''); 1761 if (!base || !site || !activity || typeof activity !== 'object') return { status: 400, error: 'invalid_activity' }; 1762 1763 // AP §6: a client MAY POST a bare object; the server wraps it in a Create. 1764 let type = activity.type; 1765 let object = activity.object; 1766 if (type === 'Note' || type === 'Article') { object = activity; type = 'Create'; } 1767 if (Array.isArray(type)) type = type.find((t) => typeof t === 'string'); 1768 1769 try { 1770 switch (type) { 1771 case 'Create': { 1772 if (!object || typeof object !== 'object') return { status: 400, error: 'missing_object' }; 1773 // Client sends `source` (plain/markdown) + `content` (HTML). deliverReply 1774 // re-escapes, so it needs plain text; a top-level post keeps sanitized HTML. 1775 const plain = (object.source && object.source.content) || HtmlSanitizerService.toPlainText(object.content || ''); 1776 if (!plain.trim() && !object.content) return { status: 400, error: 'empty_note' }; 1777 if (object.inReplyTo) { 1778 const parent = await resolveRemoteNote(c2sIdOf(object.inReplyTo)).catch(() => null); 1779 if (!parent) return { status: 502, error: 'cannot_resolve_inReplyTo' }; 1780 const r = await deliverReply(site, { postId: parent.localPostId || '', postSlug: null, parent, text: plain }); 1781 if (!r || !r.id) return { status: 502, error: 'reply_failed' }; 1782 return { status: 201, id: r.id, url: `${base}/ap/notes/${r.id}` }; 1783 } 1784 return await c2sCreatePost(base, site, user, object); 1785 } 1786 case 'Like': 1787 case 'Announce': { 1788 const targetUri = c2sIdOf(object); 1789 if (!targetUri) return { status: 400, error: 'missing_object' }; 1790 const note = await resolveRemoteNote(targetUri).catch(() => null); 1791 const objUri = (note && note.object_uri) || targetUri; 1792 const authorUri = note && note.actor_uri; 1793 const kind = type === 'Announce' ? 'boost' : 'like'; 1794 await sendInteraction(site, kind, objUri, authorUri); 1795 setMyReaction(site.slug, targetUri, kind, true); 1796 if (type === 'Announce' && note) { try { upsertBoostedNote(site.slug, note); } catch { /* non-fatal */ } } 1797 return { status: 202, url: objUri }; 1798 } 1799 case 'Follow': { 1800 const actorUri = c2sIdOf(object); 1801 if (!actorUri) return { status: 400, error: 'missing_object' }; 1802 await followActor(site, actorUri); 1803 return { status: 202, url: actorUri }; 1804 } 1805 case 'Undo': { 1806 const inner = object && typeof object === 'object' ? object : null; 1807 let innerType = inner && inner.type; 1808 if (Array.isArray(innerType)) innerType = innerType.find((t) => typeof t === 'string'); 1809 const innerTarget = c2sIdOf(inner && inner.object); 1810 if (innerType === 'Follow') { await unfollowActor(site, innerTarget); return { status: 202, url: innerTarget }; } 1811 if (innerType === 'Like' || innerType === 'Announce') { 1812 const kind = innerType === 'Announce' ? 'unboost' : 'unlike'; 1813 const note = await resolveRemoteNote(innerTarget).catch(() => null); 1814 const objUri = (note && note.object_uri) || innerTarget; 1815 await sendInteraction(site, kind, objUri, note && note.actor_uri); 1816 setMyReaction(site.slug, innerTarget, innerType === 'Announce' ? 'boost' : 'like', false); 1817 if (innerType === 'Announce') { try { unmarkBoosted(site.slug, objUri); } catch { /* non-fatal */ } } 1818 return { status: 202, url: objUri }; 1819 } 1820 return { status: 400, error: 'unsupported_undo' }; 1821 } 1822 // Delete/Update of arbitrary objects need the post-edit pipeline; tracked 1823 // separately (klonkt-demo-c2s-del). Reject clearly rather than half-doing it. 1824 default: 1825 return { status: 400, error: 'unsupported_type', detail: String(type || 'none') }; 1826 } 1827 } catch (e) { 1828 console.warn('[AP] C2S ingest failed:', e && e.message); 1829 return { status: 500, error: 'ingest_error' }; 1830 } 1831 } 1832 1833 // Create a top-level microblog post from a C2S Note and federate it. Minimal 1834 // sibling of the /posts/create route: sanitized HTML content, no title/cover. 1835 async function c2sCreatePost(base, site, user, object) { 1836 const html = HtmlSanitizerService.sanitize(object.content || (object.source && object.source.content) || ''); 1837 if (!html.trim()) return { status: 400, error: 'empty_note' }; 1838 const postId = crypto.randomUUID(); 1839 const slug = 'n-' + postId.slice(0, 8); 1840 const now = new Date().toISOString(); 1841 db.prepare(`INSERT INTO posts (id, site_id, slug, author_id, title, content, excerpt, status, type, language, created_at, updated_at, published_at) 1842 VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`) 1843 .run(postId, site.id, slug, user.id, '', html, '', 'published', 'post', object.language || 'nl', now, now, now); 1844 try { db.prepare('UPDATE posts SET content_rendered = ? WHERE id = ?').run(bakePostContent(html), postId); } catch { /* render fallback covers it */ } 1845 bakePostContentWithMentions(html).then((h) => { try { db.prepare('UPDATE posts SET content_rendered = ? WHERE id = ?').run(h, postId); } catch { /* keep sync bake */ } }).catch(() => {}); 1846 try { db.prepare('INSERT INTO posts_fts(content, title, author, post_id) VALUES (?,?,?,?)').run(HtmlSanitizerService.toPlainText(html), '', user.username || '', postId); } catch { /* FTS non-fatal */ } 1847 deliverCreate(site, { id: postId, slug, title: '', content: html, published_at: now, created_at: now }).catch(() => { /* best-effort */ }); 1848 return { status: 201, id: postId, url: `${base}/ap/notes/${postId}` }; 1849 } 1850 1752 1851 // Send a reply FROM this site to a remote actor (in reply to their inbound reply). 1753 1852 // `parent` = an ap_interactions row (actor_uri, actor_url, actor_handle, object_uri). … … 2659 2758 linkifyBody, bakePostContent, bakePostContentWithMentions, listFollowers, removeFollower, listConnections, 2660 2759 noteVisibility, isRejectedObject, rejectInteraction, interactionReportTarget, 2661 getMessages, notificationsSeenAt, 2760 getMessages, notificationsSeenAt, ingestOutboxActivity, 2662 2761 };
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)