Changeset dd568e7 in Klonkt
- 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)
- Files:
-
- 1 added
- 5 edited
-
CHANGELOG.de.md (modified) (1 diff)
-
CHANGELOG.md (modified) (1 diff)
-
CHANGELOG.nl.md (modified) (1 diff)
-
src/routes/activitypub.js (modified) (2 diffs)
-
src/services/ActivityPubService.js (modified) (2 diffs)
-
test/c2s-outbox.test.js (added)
Legend:
- Unmodified
- Added
- Removed
-
CHANGELOG.de.md
rd49b60b rdd568e7 16 16 `/.well-known/oauth-authorization-server` (RFC 8414) liefert die Metadaten, 17 17 sodass Clients alles entdecken statt Pfade festzuschreiben. Nur öffentliche 18 Clients + PKCE, keine Client-Secrets. Die Token-annehmende Outbox (POST) ist 19 die nächste Phase. 18 Clients + PKCE, keine Client-Secrets. 19 - **Die Outbox nimmt Beiträge von Apps an (C2S, Phase 1 komplett).** Ein 20 `POST` mit Bearer-Token an `/ap/users/:slug/outbox` steuert jetzt dein Konto 21 aus einer App: einen Beitrag veröffentlichen, antworten, liken, teilen, folgen 22 und all das rückgängig machen. Aktivitäten laufen über dieselbe 23 Zustell-Maschinerie wie die Web-UI; eine nackte Note wird laut Spezifikation in 24 ein Create verpackt; Inhalt wird bereinigt; das Token ist an eine Seite 25 gebunden. Hinweis: Das ist ActivityPub C2S, das die Shaer-Apps sprechen. 26 Mastodon-Clients (Ivory usw.) nutzen Mastodons eigene API und werden hier nicht 27 unterstützt. 20 28 21 29 ### Behoben -
CHANGELOG.md
rd49b60b rdd568e7 15 15 `/.well-known/oauth-authorization-server` (RFC 8414) exposes the metadata, so 16 16 clients discover everything instead of hardcoding paths. Public clients + PKCE 17 only, no client secrets. The token-accepting outbox (POST) is the next phase. 17 only, no client secrets. 18 - **The outbox accepts posts from apps (C2S, phase 1 complete).** A 19 bearer-authenticated `POST` to `/ap/users/:slug/outbox` now drives your account 20 from a client: publish a note, reply, like, boost, follow, and undo any of 21 those. Activities are translated onto the same delivery machinery the web UI 22 uses; a bare Note is wrapped in a Create per the spec; content is sanitized; 23 the token is scoped to one site so it can't post as another. Note: this is 24 ActivityPub C2S, which the Shaer apps speak. Mastodon clients (Ivory etc.) use 25 Mastodon's own API and are not supported by this. 18 26 19 27 ### Fixed -
CHANGELOG.nl.md
rd49b60b rdd568e7 15 15 uploadMedia-endpoints en `/.well-known/oauth-authorization-server` (RFC 8414) 16 16 geeft de metadata, dus apps ontdekken alles in plaats van paden vast te 17 spijkeren. Alleen publieke clients + PKCE, geen client-secrets. De outbox die 18 de tokens accepteert (POST) is de volgende fase. 17 spijkeren. Alleen publieke clients + PKCE, geen client-secrets. 18 - **De outbox accepteert posts van apps (C2S, fase 1 compleet).** Een 19 `POST` met bearer-token naar `/ap/users/:slug/outbox` bestuurt nu je account 20 vanuit een app: een bericht plaatsen, reageren, liken, boosten, volgen en dat 21 allemaal ongedaan maken. Activities gaan via dezelfde bezorg-machinerie als de 22 web-UI; een kale Note wordt in een Create verpakt (spec); content wordt 23 gesanitized; het token is aan één site gebonden dus kan niet namens een andere 24 posten. Let op: dit is ActivityPub C2S, wat de Shaer-apps spreken. 25 Mastodon-clients (Ivory e.d.) gebruiken Mastodons eigen API en worden hier niet 26 ondersteund. 19 27 20 28 ### Opgelost -
src/routes/activitypub.js
rd49b60b rdd568e7 18 18 import { apReadLimiter, apInboxLimiter } from '../middleware/rate-limit.js'; 19 19 import { apEnabled } from '../services/SettingsService.js'; 20 import OAuth from '../services/OAuthService.js'; 20 21 21 22 const router = express.Router(); … … 193 194 }); 194 195 196 // ── Outbox POST: ActivityPub Client-to-Server ───────────────────── 197 // A bearer-authenticated client (Shaer) POSTs an activity; we translate it onto 198 // the normal delivery machinery. The token is scoped to one user+site (OAuth 199 // consent), so it must match the slug in the URL. (Declared after apJson, which 200 // this shares with the inbox handler.) 201 router.post('/ap/users/:slug/outbox', apInboxLimiter, apJson, async (req, res) => { 202 const auth = OAuth.verifyBearer(req.headers.authorization); 203 if (!auth) { res.set('WWW-Authenticate', 'Bearer'); return res.status(401).json({ error: 'invalid_token' }); } 204 if (auth.site.slug !== req.params.slug) return res.status(403).json({ error: 'wrong_site', detail: 'token is scoped to a different site' }); 205 if (auth.user.readonly) return res.status(403).json({ error: 'read_only_account' }); 206 207 const out = await AP.ingestOutboxActivity(auth.site, auth.user, req.body); 208 if (out.error) return res.status(out.status || 400).json({ error: out.error, detail: out.detail }); 209 // 201 Created → Location header (AP spec); 202 Accepted for side-effect verbs. 210 if (out.status === 201 && out.url) res.set('Location', out.url); 211 return res.status(out.status || 202).json({ ok: true, id: out.id, url: out.url }); 212 }); 213 195 214 export default router; -
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)