@rmdes/indiekit-endpoint-activitypub 3.9.2 → 3.9.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -2
- package/index.js +20 -0
- package/lib/mastodon/routes/statuses.js +59 -124
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -88,13 +88,17 @@ ActivityPub federation endpoint for [Indiekit](https://getindiekit.com), built o
|
|
|
88
88
|
- URL auto-linkification and @mention extraction in posted content
|
|
89
89
|
- Thread context (ancestors + descendants)
|
|
90
90
|
- Remote profile resolution via Fedify WebFinger with follower/following/post counts from AP collections
|
|
91
|
-
- Account stats enrichment —
|
|
91
|
+
- Account stats enrichment — cached account data applied immediately; uncached accounts resolved in background
|
|
92
92
|
- Favourite, boost, bookmark interactions federated via Fedify AP activities
|
|
93
93
|
- Notifications with type filtering
|
|
94
94
|
- Search across accounts, statuses, and hashtags with remote resolution
|
|
95
95
|
- Domain blocks API
|
|
96
96
|
- Timeline backfill from posts collection on startup (bookmarks, likes, reposts get synthesized content)
|
|
97
97
|
- In-memory account stats cache (500 entries, 1h TTL) for performance
|
|
98
|
+
- OAuth2 scope enforcement — read/write scope validation on all API routes
|
|
99
|
+
- Rate limiting — configurable limits on API, auth, and app registration endpoints
|
|
100
|
+
- Access token expiry (1 hour) with refresh token rotation (90 days)
|
|
101
|
+
- PKCE (S256) and CSRF protection on authorization flow
|
|
98
102
|
|
|
99
103
|
**Admin UI**
|
|
100
104
|
- Dashboard with follower/following counts and recent activity
|
|
@@ -318,6 +322,9 @@ The plugin creates these collections automatically:
|
|
|
318
322
|
| `ap_blocked_servers` | Blocked server domains (instance-level blocks) |
|
|
319
323
|
| `ap_key_freshness` | Tracks when remote actor keys were last verified |
|
|
320
324
|
| `ap_inbox_queue` | Persistent async inbox processing queue |
|
|
325
|
+
| `ap_oauth_apps` | Mastodon API client app registrations |
|
|
326
|
+
| `ap_oauth_tokens` | OAuth2 authorization codes and access tokens |
|
|
327
|
+
| `ap_markers` | Read position markers for Mastodon API clients |
|
|
321
328
|
|
|
322
329
|
## Supported Post Types
|
|
323
330
|
|
|
@@ -411,7 +418,6 @@ This is not a bug — Fedify requires explicit opt-in for signed fetches. But it
|
|
|
411
418
|
- **Single actor** — One fediverse identity per Indiekit instance
|
|
412
419
|
- **No Authorized Fetch enforcement** — `.authorize()` disabled on actor dispatcher (see workarounds above)
|
|
413
420
|
- **No image upload in reader** — Compose form is text-only
|
|
414
|
-
- **No custom emoji rendering** — Custom emoji shortcodes display as text
|
|
415
421
|
- **In-process queue without Redis** — Activities may be lost on restart
|
|
416
422
|
|
|
417
423
|
## Acknowledgements
|
package/index.js
CHANGED
|
@@ -180,6 +180,26 @@ export default class ActivityPubEndpoint {
|
|
|
180
180
|
text: "activitypub.reader.title",
|
|
181
181
|
requiresDatabase: true,
|
|
182
182
|
},
|
|
183
|
+
{
|
|
184
|
+
href: `${this.options.mountPath}/admin/reader/notifications`,
|
|
185
|
+
text: "activitypub.notifications.title",
|
|
186
|
+
requiresDatabase: true,
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
href: `${this.options.mountPath}/admin/reader/messages`,
|
|
190
|
+
text: "activitypub.messages.title",
|
|
191
|
+
requiresDatabase: true,
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
href: `${this.options.mountPath}/admin/reader/moderation`,
|
|
195
|
+
text: "activitypub.moderation.title",
|
|
196
|
+
requiresDatabase: true,
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
href: `${this.options.mountPath}/admin/my-profile`,
|
|
200
|
+
text: "activitypub.myProfile.title",
|
|
201
|
+
requiresDatabase: true,
|
|
202
|
+
},
|
|
183
203
|
{
|
|
184
204
|
href: `${this.options.mountPath}/admin/federation`,
|
|
185
205
|
text: "activitypub.federationMgmt.title",
|
|
@@ -21,7 +21,6 @@ import {
|
|
|
21
21
|
boostPost, unboostPost,
|
|
22
22
|
bookmarkPost, unbookmarkPost,
|
|
23
23
|
} from "../helpers/interactions.js";
|
|
24
|
-
import { addTimelineItem } from "../../storage/timeline.js";
|
|
25
24
|
import { tokenRequired } from "../middleware/token-required.js";
|
|
26
25
|
import { scopeRequired } from "../middleware/scope-required.js";
|
|
27
26
|
|
|
@@ -166,130 +165,105 @@ router.post("/api/v1/statuses", tokenRequired, scopeRequired("write", "write:sta
|
|
|
166
165
|
}
|
|
167
166
|
}
|
|
168
167
|
|
|
169
|
-
// Build JF2 properties for the Micropub pipeline
|
|
168
|
+
// Build JF2 properties for the Micropub pipeline.
|
|
169
|
+
// Provide both text and html — linkify URLs since Micropub's markdown-it
|
|
170
|
+
// doesn't have linkify enabled. Mentions are preserved as plain text;
|
|
171
|
+
// the AP syndicator resolves them via WebFinger for federation delivery.
|
|
172
|
+
const contentText = statusText || "";
|
|
173
|
+
const contentHtml = contentText
|
|
174
|
+
.replace(/&/g, "&")
|
|
175
|
+
.replace(/</g, "<")
|
|
176
|
+
.replace(/>/g, ">")
|
|
177
|
+
.replace(/(https?:\/\/[^\s<>&"')\]]+)/g, '<a href="$1">$1</a>')
|
|
178
|
+
.replace(/\n/g, "<br>");
|
|
179
|
+
|
|
170
180
|
const jf2 = {
|
|
171
181
|
type: "entry",
|
|
172
|
-
content:
|
|
182
|
+
content: { text: contentText, html: `<p>${contentHtml}</p>` },
|
|
173
183
|
};
|
|
174
184
|
|
|
175
185
|
if (inReplyTo) {
|
|
176
186
|
jf2["in-reply-to"] = inReplyTo;
|
|
177
187
|
}
|
|
178
188
|
|
|
179
|
-
if (
|
|
180
|
-
jf2.
|
|
189
|
+
if (visibility && visibility !== "public") {
|
|
190
|
+
jf2.visibility = visibility;
|
|
181
191
|
}
|
|
182
192
|
|
|
183
|
-
|
|
193
|
+
// Use content-warning (not summary) to match native reader behavior
|
|
194
|
+
if (spoilerText) {
|
|
195
|
+
jf2["content-warning"] = spoilerText;
|
|
184
196
|
jf2.sensitive = "true";
|
|
185
197
|
}
|
|
186
198
|
|
|
187
|
-
if (visibility && visibility !== "public") {
|
|
188
|
-
jf2.visibility = visibility;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
199
|
if (language) {
|
|
192
200
|
jf2["mp-language"] = language;
|
|
193
201
|
}
|
|
194
202
|
|
|
195
|
-
// Syndicate to AP
|
|
196
|
-
// Never cross-post to Bluesky (conversations stay in their protocol).
|
|
197
|
-
// The publication URL is the AP syndicator's uid.
|
|
203
|
+
// Syndicate to AP — posts from Mastodon clients belong to the fediverse
|
|
198
204
|
const publicationUrl = pluginOptions.publicationUrl || baseUrl;
|
|
199
205
|
jf2["mp-syndicate-to"] = [publicationUrl.replace(/\/$/, "") + "/"];
|
|
200
206
|
|
|
201
|
-
// Create post via Micropub pipeline (same functions
|
|
202
|
-
// postData.create() handles: normalization, post type detection, path rendering,
|
|
203
|
-
// mp-syndicate-to validated against configured syndicators, MongoDB posts collection
|
|
207
|
+
// Create post via Micropub pipeline (same internal functions)
|
|
204
208
|
const { postData } = await import("@indiekit/endpoint-micropub/lib/post-data.js");
|
|
205
209
|
const { postContent } = await import("@indiekit/endpoint-micropub/lib/post-content.js");
|
|
206
210
|
|
|
207
211
|
const data = await postData.create(application, publication, jf2);
|
|
208
|
-
// postContent.create() handles: template rendering, file creation in store
|
|
209
212
|
await postContent.create(publication, data);
|
|
210
213
|
|
|
211
214
|
const postUrl = data.properties.url;
|
|
212
215
|
console.info(`[Mastodon API] Created post via Micropub: ${postUrl}`);
|
|
213
216
|
|
|
214
|
-
//
|
|
217
|
+
// Return a minimal status to the Mastodon client.
|
|
218
|
+
// No timeline entry is created here — the post will appear in the timeline
|
|
219
|
+
// after the normal flow: Eleventy rebuild → syndication webhook → AP delivery.
|
|
215
220
|
const profile = await collections.ap_profile.findOne({});
|
|
216
221
|
const handle = pluginOptions.handle || "user";
|
|
217
|
-
const actorUrl = profile?.url || `${publicationUrl}/users/${handle}`;
|
|
218
|
-
|
|
219
|
-
// Extract hashtags from status text and merge with any Micropub categories
|
|
220
|
-
const categories = data.properties.category || [];
|
|
221
|
-
const inlineHashtags = (statusText || "").match(/(?:^|\s)#([a-zA-Z_]\w*)/g);
|
|
222
|
-
if (inlineHashtags) {
|
|
223
|
-
const existing = new Set(categories.map((c) => c.toLowerCase()));
|
|
224
|
-
for (const match of inlineHashtags) {
|
|
225
|
-
const tag = match.trim().slice(1).toLowerCase();
|
|
226
|
-
if (!existing.has(tag)) {
|
|
227
|
-
existing.add(tag);
|
|
228
|
-
categories.push(tag);
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
// Resolve relative media URLs to absolute
|
|
234
|
-
const resolveMedia = (items) => {
|
|
235
|
-
if (!items || !items.length) return [];
|
|
236
|
-
return items.map((item) => {
|
|
237
|
-
if (typeof item === "string") {
|
|
238
|
-
return item.startsWith("http") ? item : `${publicationUrl.replace(/\/$/, "")}/${item.replace(/^\//, "")}`;
|
|
239
|
-
}
|
|
240
|
-
if (item?.url && !item.url.startsWith("http")) {
|
|
241
|
-
return { ...item, url: `${publicationUrl.replace(/\/$/, "")}/${item.url.replace(/^\//, "")}` };
|
|
242
|
-
}
|
|
243
|
-
return item;
|
|
244
|
-
});
|
|
245
|
-
};
|
|
246
|
-
|
|
247
|
-
// Process content: linkify URLs and extract @mentions
|
|
248
|
-
const rawContent = data.properties.content || { text: statusText || "", html: "" };
|
|
249
|
-
const processedContent = processStatusContent(rawContent, statusText || "");
|
|
250
|
-
const mentions = extractMentions(statusText || "");
|
|
251
222
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
223
|
+
res.json({
|
|
224
|
+
id: String(Date.now()),
|
|
225
|
+
created_at: new Date().toISOString(),
|
|
226
|
+
content: `<p>${contentHtml}</p>`,
|
|
255
227
|
url: postUrl,
|
|
256
|
-
|
|
257
|
-
content: processedContent,
|
|
258
|
-
summary: spoilerText || "",
|
|
259
|
-
sensitive: sensitive === true || sensitive === "true",
|
|
228
|
+
uri: postUrl,
|
|
260
229
|
visibility: visibility || "public",
|
|
230
|
+
sensitive: sensitive === true || sensitive === "true",
|
|
231
|
+
spoiler_text: spoilerText || "",
|
|
232
|
+
in_reply_to_id: inReplyToId || null,
|
|
233
|
+
in_reply_to_account_id: null,
|
|
261
234
|
language: language || null,
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
235
|
+
replies_count: 0,
|
|
236
|
+
reblogs_count: 0,
|
|
237
|
+
favourites_count: 0,
|
|
238
|
+
favourited: false,
|
|
239
|
+
reblogged: false,
|
|
240
|
+
bookmarked: false,
|
|
241
|
+
account: {
|
|
242
|
+
id: "owner",
|
|
243
|
+
username: handle,
|
|
244
|
+
acct: handle,
|
|
245
|
+
display_name: profile?.name || handle,
|
|
267
246
|
url: profile?.url || publicationUrl,
|
|
268
|
-
|
|
269
|
-
|
|
247
|
+
avatar: profile?.icon || "",
|
|
248
|
+
avatar_static: profile?.icon || "",
|
|
249
|
+
header: "",
|
|
250
|
+
header_static: "",
|
|
251
|
+
followers_count: 0,
|
|
252
|
+
following_count: 0,
|
|
253
|
+
statuses_count: 0,
|
|
270
254
|
emojis: [],
|
|
271
|
-
|
|
255
|
+
fields: [],
|
|
272
256
|
},
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
257
|
+
media_attachments: [],
|
|
258
|
+
mentions: extractMentions(contentText).map(m => ({
|
|
259
|
+
id: "0",
|
|
260
|
+
username: m.name.split("@")[1] || m.name,
|
|
261
|
+
acct: m.name.replace(/^@/, ""),
|
|
262
|
+
url: m.url,
|
|
263
|
+
})),
|
|
264
|
+
tags: [],
|
|
280
265
|
emojis: [],
|
|
281
266
|
});
|
|
282
|
-
|
|
283
|
-
// Serialize and return
|
|
284
|
-
const serialized = serializeStatus(timelineItem, {
|
|
285
|
-
baseUrl,
|
|
286
|
-
favouritedIds: new Set(),
|
|
287
|
-
rebloggedIds: new Set(),
|
|
288
|
-
bookmarkedIds: new Set(),
|
|
289
|
-
pinnedIds: new Set(),
|
|
290
|
-
});
|
|
291
|
-
|
|
292
|
-
res.json(serialized);
|
|
293
267
|
} catch (error) {
|
|
294
268
|
next(error);
|
|
295
269
|
}
|
|
@@ -604,45 +578,6 @@ async function loadItemInteractions(collections, item) {
|
|
|
604
578
|
return { favouritedIds, rebloggedIds, bookmarkedIds };
|
|
605
579
|
}
|
|
606
580
|
|
|
607
|
-
/**
|
|
608
|
-
* Process status content: linkify bare URLs and convert @mentions to links.
|
|
609
|
-
*
|
|
610
|
-
* Mastodon clients send plain text — the server is responsible for
|
|
611
|
-
* converting URLs and mentions into HTML links.
|
|
612
|
-
*
|
|
613
|
-
* @param {object} content - { text, html } from Micropub pipeline
|
|
614
|
-
* @param {string} rawText - Original status text from client
|
|
615
|
-
* @returns {object} { text, html } with linkified content
|
|
616
|
-
*/
|
|
617
|
-
function processStatusContent(content, rawText) {
|
|
618
|
-
let html = content.html || content.text || rawText || "";
|
|
619
|
-
|
|
620
|
-
// If the HTML is just plain text wrapped in <p>, process it
|
|
621
|
-
// Don't touch HTML that already has links (from Micropub rendering)
|
|
622
|
-
if (!html.includes("<a ")) {
|
|
623
|
-
// Linkify bare URLs (http/https)
|
|
624
|
-
html = html.replace(
|
|
625
|
-
/(https?:\/\/[^\s<>"')\]]+)/g,
|
|
626
|
-
'<a href="$1" rel="nofollow noopener noreferrer" target="_blank">$1</a>',
|
|
627
|
-
);
|
|
628
|
-
|
|
629
|
-
// Convert @user@domain mentions to profile links
|
|
630
|
-
html = html.replace(
|
|
631
|
-
/(?:^|\s)(@([a-zA-Z0-9_]+)@([a-zA-Z0-9.-]+\.[a-zA-Z]{2,}))/g,
|
|
632
|
-
(match, full, username, domain) =>
|
|
633
|
-
match.replace(
|
|
634
|
-
full,
|
|
635
|
-
`<span class="h-card"><a href="https://${domain}/@${username}" class="u-url mention" rel="nofollow noopener noreferrer" target="_blank">@${username}@${domain}</a></span>`,
|
|
636
|
-
),
|
|
637
|
-
);
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
return {
|
|
641
|
-
text: content.text || rawText || "",
|
|
642
|
-
html,
|
|
643
|
-
};
|
|
644
|
-
}
|
|
645
|
-
|
|
646
581
|
/**
|
|
647
582
|
* Extract @user@domain mentions from text into mention objects.
|
|
648
583
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rmdes/indiekit-endpoint-activitypub",
|
|
3
|
-
"version": "3.9.
|
|
3
|
+
"version": "3.9.4",
|
|
4
4
|
"description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"indiekit",
|