@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 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 — embedded account data in timeline responses includes real counts
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, "&lt;")
176
+ .replace(/>/g, "&gt;")
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: statusText || "",
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 (spoilerText) {
180
- jf2.summary = spoilerText;
189
+ if (visibility && visibility !== "public") {
190
+ jf2.visibility = visibility;
181
191
  }
182
192
 
183
- if (sensitive === true || sensitive === "true") {
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 only — posts from Mastodon clients belong to the fediverse.
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 the Micropub endpoint uses)
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
- // Add to ap_timeline so the post is visible in the Mastodon Client API
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
- const now = new Date().toISOString();
253
- const timelineItem = await addTimelineItem(collections, {
254
- uid: postUrl,
223
+ res.json({
224
+ id: String(Date.now()),
225
+ created_at: new Date().toISOString(),
226
+ content: `<p>${contentHtml}</p>`,
255
227
  url: postUrl,
256
- type: data.properties["post-type"] || "note",
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
- inReplyTo,
263
- published: data.properties.published || now,
264
- createdAt: now,
265
- author: {
266
- name: profile?.name || handle,
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
- photo: profile?.icon || "",
269
- handle: `@${handle}`,
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
- bot: false,
255
+ fields: [],
272
256
  },
273
- photo: resolveMedia(data.properties.photo || []),
274
- video: resolveMedia(data.properties.video || []),
275
- audio: resolveMedia(data.properties.audio || []),
276
- category: categories,
277
- counts: { replies: 0, boosts: 0, likes: 0 },
278
- linkPreviews: [],
279
- mentions,
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.2",
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",