@rmdes/indiekit-endpoint-activitypub 3.11.3 → 3.11.6

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.
@@ -44,7 +44,7 @@ export function setLocalIdentity(publicationUrl, handle) {
44
44
  * @param {Set<string>} [options.pinnedIds] - UIDs the user has pinned
45
45
  * @returns {object} Mastodon Status entity
46
46
  */
47
- export function serializeStatus(item, { baseUrl, favouritedIds, rebloggedIds, bookmarkedIds, pinnedIds }) {
47
+ export function serializeStatus(item, { baseUrl, favouritedIds, rebloggedIds, bookmarkedIds, pinnedIds, replyIdMap } = {}) {
48
48
  if (!item) return null;
49
49
 
50
50
  // Use published-based cursor as the status ID so pagination cursors
@@ -204,8 +204,8 @@ export function serializeStatus(item, { baseUrl, favouritedIds, rebloggedIds, bo
204
204
  return {
205
205
  id,
206
206
  created_at: published,
207
- in_reply_to_id: item.inReplyTo ? null : null, // TODO: resolve to local ID
208
- in_reply_to_account_id: null, // TODO: resolve
207
+ in_reply_to_id: replyIdMap?.get(item.inReplyTo) ?? null,
208
+ in_reply_to_account_id: null,
209
209
  sensitive,
210
210
  spoiler_text: spoilerText,
211
211
  visibility,
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Batch-resolve inReplyTo URLs to Mastodon cursor IDs.
3
+ *
4
+ * Looks up parent posts in ap_timeline by uid/url and returns a Map
5
+ * of inReplyTo URL → cursor ID (milliseconds since epoch as string).
6
+ * Used by route handlers before calling serializeStatus().
7
+ *
8
+ * @param {object} collection - ap_timeline MongoDB collection
9
+ * @param {Array<object>} items - Timeline items with optional inReplyTo
10
+ * @returns {Promise<Map<string, string>>} Map of URL → cursor ID
11
+ */
12
+ import { encodeCursor } from "./pagination.js";
13
+
14
+ export async function resolveReplyIds(collection, items) {
15
+ const map = new Map();
16
+ if (!collection || !items?.length) return map;
17
+
18
+ // Collect unique inReplyTo URLs
19
+ const urls = [
20
+ ...new Set(
21
+ items
22
+ .map((item) => item.inReplyTo)
23
+ .filter(Boolean),
24
+ ),
25
+ ];
26
+ if (urls.length === 0) return map;
27
+
28
+ // Batch lookup parents by uid or url
29
+ const parents = await collection
30
+ .find({ $or: [{ uid: { $in: urls } }, { url: { $in: urls } }] })
31
+ .project({ uid: 1, url: 1, published: 1 })
32
+ .toArray();
33
+
34
+ for (const parent of parents) {
35
+ const cursorId = encodeCursor(parent.published);
36
+ if (cursorId && cursorId !== "0") {
37
+ // Map both uid and url to the cursor ID
38
+ if (parent.uid) map.set(parent.uid, cursorId);
39
+ if (parent.url && parent.url !== parent.uid) map.set(parent.url, cursorId);
40
+ }
41
+ }
42
+
43
+ return map;
44
+ }
@@ -98,8 +98,13 @@ router.post(
98
98
  try {
99
99
  const { application } = req.app.locals;
100
100
  const collections = req.app.locals.mastodonCollections;
101
+ // Use IndieAuth token stored during OAuth authorization, falling back
102
+ // to session token (native reader) or Mastodon token (won't work for
103
+ // Micropub media endpoint but covers direct internal calls).
101
104
  const token =
102
- req.session?.access_token || req.mastodonToken?.accessToken;
105
+ req.session?.access_token ||
106
+ req.mastodonToken?.indieauthToken ||
107
+ req.mastodonToken?.accessToken;
103
108
 
104
109
  const file = req.files?.file;
105
110
  if (!file) {
@@ -142,8 +147,13 @@ router.post(
142
147
  try {
143
148
  const { application } = req.app.locals;
144
149
  const collections = req.app.locals.mastodonCollections;
150
+ // Use IndieAuth token stored during OAuth authorization, falling back
151
+ // to session token (native reader) or Mastodon token (won't work for
152
+ // Micropub media endpoint but covers direct internal calls).
145
153
  const token =
146
- req.session?.access_token || req.mastodonToken?.accessToken;
154
+ req.session?.access_token ||
155
+ req.mastodonToken?.indieauthToken ||
156
+ req.mastodonToken?.accessToken;
147
157
 
148
158
  const file = req.files?.file;
149
159
  if (!file) {
@@ -388,6 +388,9 @@ router.post("/oauth/authorize", async (req, res, next) => {
388
388
  redirectUri: redirect_uri,
389
389
  codeChallenge: code_challenge || null,
390
390
  codeChallengeMethod: code_challenge_method || null,
391
+ // Store the IndieAuth session token so Mastodon API routes can call
392
+ // Micropub/media endpoints on behalf of this user (single-user server).
393
+ indieauthToken: req.session?.access_token || null,
391
394
  createdAt: new Date(),
392
395
  expiresAt: new Date(Date.now() + 10 * 60 * 1000), // 10 minutes
393
396
  });
@@ -18,6 +18,7 @@ import express from "express";
18
18
  import { ObjectId } from "mongodb";
19
19
  import { serializeStatus } from "../entities/status.js";
20
20
  import { decodeCursor } from "../helpers/pagination.js";
21
+ import { resolveReplyIds } from "../helpers/resolve-reply-ids.js";
21
22
  import {
22
23
  likePost, unlikePost,
23
24
  boostPost, unboostPost,
@@ -43,11 +44,13 @@ router.get("/api/v1/statuses/:id", tokenRequired, scopeRequired("read", "read:st
43
44
 
44
45
  // Load interaction state if authenticated
45
46
  const interactionState = await loadItemInteractions(collections, item);
47
+ const replyIdMap = await resolveReplyIds(collections.ap_timeline, [item]);
46
48
 
47
49
  const status = serializeStatus(item, {
48
50
  baseUrl,
49
51
  ...interactionState,
50
52
  pinnedIds: new Set(),
53
+ replyIdMap,
51
54
  });
52
55
 
53
56
  res.json(status);
@@ -122,7 +125,9 @@ router.get("/api/v1/statuses/:id/context", tokenRequired, scopeRequired("read",
122
125
  pinnedIds: new Set(),
123
126
  };
124
127
 
125
- const serializeOpts = { baseUrl, ...emptyInteractions };
128
+ const allItems = [...ancestors, ...descendants];
129
+ const replyIdMap = await resolveReplyIds(collections.ap_timeline, allItems);
130
+ const serializeOpts = { baseUrl, ...emptyInteractions, replyIdMap };
126
131
 
127
132
  res.json({
128
133
  ancestors: ancestors.map((a) => serializeStatus(a, serializeOpts)),
@@ -245,7 +250,7 @@ router.post("/api/v1/statuses", tokenRequired, scopeRequired("write", "write:sta
245
250
  for (const m of mediaUrls) {
246
251
  if (m.type?.startsWith("image/")) {
247
252
  if (!jf2.photo) jf2.photo = [];
248
- jf2.photo.push({ value: m.url, alt: m.alt });
253
+ jf2.photo.push({ url: m.url, alt: m.alt });
249
254
  } else if (m.type?.startsWith("video/")) {
250
255
  if (!jf2.video) jf2.video = [];
251
256
  jf2.video.push(m.url);
@@ -8,6 +8,7 @@
8
8
  import express from "express";
9
9
  import { serializeStatus } from "../entities/status.js";
10
10
  import { buildPaginationQuery, parseLimit, setPaginationHeaders } from "../helpers/pagination.js";
11
+ import { resolveReplyIds } from "../helpers/resolve-reply-ids.js";
11
12
  import { loadModerationData, applyModerationFilters } from "../../item-processing.js";
12
13
  import { enrichAccountStats } from "../helpers/enrich-accounts.js";
13
14
  import { tokenRequired } from "../middleware/token-required.js";
@@ -63,6 +64,9 @@ router.get("/api/v1/timelines/home", tokenRequired, scopeRequired("read", "read:
63
64
  items,
64
65
  );
65
66
 
67
+ // Resolve reply parent IDs for threading
68
+ const replyIdMap = await resolveReplyIds(collections.ap_timeline, items);
69
+
66
70
  // Serialize to Mastodon Status entities
67
71
  const statuses = items.map((item) =>
68
72
  serializeStatus(item, {
@@ -71,6 +75,7 @@ router.get("/api/v1/timelines/home", tokenRequired, scopeRequired("read", "read:
71
75
  rebloggedIds,
72
76
  bookmarkedIds,
73
77
  pinnedIds: new Set(),
78
+ replyIdMap,
74
79
  }),
75
80
  );
76
81
 
@@ -163,6 +168,8 @@ router.get("/api/v1/timelines/public", async (req, res, next) => {
163
168
  ));
164
169
  }
165
170
 
171
+ const replyIdMap = await resolveReplyIds(collections.ap_timeline, items);
172
+
166
173
  const statuses = items.map((item) =>
167
174
  serializeStatus(item, {
168
175
  baseUrl,
@@ -170,6 +177,7 @@ router.get("/api/v1/timelines/public", async (req, res, next) => {
170
177
  rebloggedIds,
171
178
  bookmarkedIds,
172
179
  pinnedIds: new Set(),
180
+ replyIdMap,
173
181
  }),
174
182
  );
175
183
 
@@ -226,6 +234,8 @@ router.get("/api/v1/timelines/tag/:hashtag", async (req, res, next) => {
226
234
  ));
227
235
  }
228
236
 
237
+ const replyIdMap = await resolveReplyIds(collections.ap_timeline, items);
238
+
229
239
  const statuses = items.map((item) =>
230
240
  serializeStatus(item, {
231
241
  baseUrl,
@@ -233,6 +243,7 @@ router.get("/api/v1/timelines/tag/:hashtag", async (req, res, next) => {
233
243
  rebloggedIds,
234
244
  bookmarkedIds,
235
245
  pinnedIds: new Set(),
246
+ replyIdMap,
236
247
  }),
237
248
  );
238
249
 
package/lib/syndicator.js CHANGED
@@ -344,8 +344,19 @@ function buildTimelineContent(properties) {
344
344
  };
345
345
  }
346
346
 
347
- // Regular post — return body content as-is
347
+ // Regular post — append permalink to match federated AS2 content.
348
+ // Without this, the Mastodon API timeline entry lacks the link back
349
+ // to the source post that fediverse users see via federation.
348
350
  if (bodyText || bodyHtml) {
351
+ const postUrl = properties.url;
352
+ if (postUrl) {
353
+ const linkText = `\n\n\u{1F517} ${postUrl}`;
354
+ const linkHtml = `<p>\u{1F517} <a href="${esc(postUrl)}">${esc(postUrl)}</a></p>`;
355
+ return {
356
+ text: `${bodyText}${linkText}`,
357
+ html: `${bodyHtml}\n${linkHtml}`,
358
+ };
359
+ }
349
360
  return { text: bodyText, html: bodyHtml };
350
361
  }
351
362
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-activitypub",
3
- "version": "3.11.3",
3
+ "version": "3.11.6",
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",