@rmdes/indiekit-endpoint-activitypub 3.6.2 → 3.6.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.
@@ -21,6 +21,7 @@ export async function backfillTimeline(collections) {
21
21
 
22
22
  // Get local profile for author info
23
23
  const profile = await ap_profile?.findOne({});
24
+ const siteUrl = profile?.url?.replace(/\/$/, "") || "";
24
25
  const author = profile
25
26
  ? {
26
27
  name: profile.name || "",
@@ -58,24 +59,30 @@ export async function backfillTimeline(collections) {
58
59
  continue;
59
60
  }
60
61
 
61
- // Map JF2 properties to timeline item format
62
- const content = normalizeContent(props.content);
62
+ // Build content interaction types (bookmark, like, repost) may not have
63
+ // body content, so synthesize it from the interaction target URL
64
+ const content = buildContent(props);
63
65
  const type = mapPostType(props["post-type"]);
64
66
 
67
+ // Extract categories + inline hashtags from content
68
+ const categories = normalizeArray(props.category);
69
+ const inlineHashtags = extractHashtags(content.text + " " + (content.html || ""));
70
+ const mergedCategories = mergeCategories(categories, inlineHashtags);
71
+
65
72
  const timelineItem = {
66
73
  uid,
67
74
  url: uid,
68
75
  type,
69
- content,
76
+ content: rewriteHashtagLinks(content, siteUrl),
70
77
  author,
71
78
  published: props.published || props.date || new Date().toISOString(),
72
79
  createdAt: props.published || props.date || new Date().toISOString(),
73
80
  visibility: "public",
74
81
  sensitive: false,
75
- category: normalizeArray(props.category),
76
- photo: normalizeMediaArray(props.photo),
77
- video: normalizeMediaArray(props.video),
78
- audio: normalizeMediaArray(props.audio),
82
+ category: mergedCategories,
83
+ photo: normalizeMediaArray(props.photo, siteUrl),
84
+ video: normalizeMediaArray(props.video, siteUrl),
85
+ audio: normalizeMediaArray(props.audio, siteUrl),
79
86
  readBy: [],
80
87
  };
81
88
 
@@ -107,6 +114,55 @@ export async function backfillTimeline(collections) {
107
114
  return { total: allPosts.length, inserted, skipped };
108
115
  }
109
116
 
117
+ // ─── Content Building ─────────────────────────────────────────────────────────
118
+
119
+ /**
120
+ * Build content from JF2 properties, synthesizing content for interaction types.
121
+ * Bookmarks, likes, and reposts often have no body text — show the target URL.
122
+ */
123
+ function buildContent(props) {
124
+ const raw = normalizeContent(props.content);
125
+
126
+ // If there's already content, use it
127
+ if (raw.text || raw.html) return raw;
128
+
129
+ // Synthesize content for interaction types
130
+ const bookmarkOf = props["bookmark-of"];
131
+ const likeOf = props["like-of"];
132
+ const repostOf = props["repost-of"];
133
+ const name = props.name;
134
+
135
+ if (bookmarkOf) {
136
+ const label = name || bookmarkOf;
137
+ return {
138
+ text: `Bookmarked: ${label}`,
139
+ html: `<p>Bookmarked: <a href="${escapeHtml(bookmarkOf)}">${escapeHtml(label)}</a></p>`,
140
+ };
141
+ }
142
+
143
+ if (likeOf) {
144
+ return {
145
+ text: `Liked: ${likeOf}`,
146
+ html: `<p>Liked: <a href="${escapeHtml(likeOf)}">${escapeHtml(likeOf)}</a></p>`,
147
+ };
148
+ }
149
+
150
+ if (repostOf) {
151
+ const label = name || repostOf;
152
+ return {
153
+ text: `Reposted: ${label}`,
154
+ html: `<p>Reposted: <a href="${escapeHtml(repostOf)}">${escapeHtml(label)}</a></p>`,
155
+ };
156
+ }
157
+
158
+ // Article with title but no body
159
+ if (name) {
160
+ return { text: name, html: `<p>${escapeHtml(name)}</p>` };
161
+ }
162
+
163
+ return raw;
164
+ }
165
+
110
166
  /**
111
167
  * Normalize content from JF2 properties to { text, html } format.
112
168
  */
@@ -122,6 +178,58 @@ function normalizeContent(content) {
122
178
  return { text: "", html: "" };
123
179
  }
124
180
 
181
+ // ─── Hashtag Handling ─────────────────────────────────────────────────────────
182
+
183
+ /**
184
+ * Extract hashtags from text content.
185
+ * Matches #word patterns, returns lowercase tag names without the # prefix.
186
+ */
187
+ function extractHashtags(text) {
188
+ if (!text) return [];
189
+ const matches = text.match(/(?:^|\s)#([a-zA-Z_]\w*)/g);
190
+ if (!matches) return [];
191
+ return [...new Set(matches.map((m) => m.trim().slice(1).toLowerCase()))];
192
+ }
193
+
194
+ /**
195
+ * Merge explicit categories with inline hashtags (deduplicated, case-insensitive).
196
+ */
197
+ function mergeCategories(categories, hashtags) {
198
+ const seen = new Set(categories.map((c) => c.toLowerCase()));
199
+ const result = [...categories];
200
+ for (const tag of hashtags) {
201
+ if (!seen.has(tag)) {
202
+ seen.add(tag);
203
+ result.push(tag);
204
+ }
205
+ }
206
+ return result;
207
+ }
208
+
209
+ /**
210
+ * Rewrite hashtag links in HTML from site-internal (/categories/tag/) to
211
+ * Mastodon-compatible format. Mastodon clients use the tag objects, not
212
+ * inline links, but having correct href helps with link following.
213
+ */
214
+ function rewriteHashtagLinks(content, siteUrl) {
215
+ if (!content.html) return content;
216
+ // Rewrite /categories/tag/ links to /tags/tag (Mastodon convention)
217
+ let html = content.html.replace(
218
+ /href="\/categories\/([^/"]+)\/?"/g,
219
+ (_, tag) => `href="${siteUrl}/tags/${tag}" class="hashtag" rel="tag"`,
220
+ );
221
+ // Also rewrite absolute site category links
222
+ if (siteUrl) {
223
+ html = html.replace(
224
+ new RegExp(`href="${escapeRegex(siteUrl)}/categories/([^/"]+)/?"`, "g"),
225
+ (_, tag) => `href="${siteUrl}/tags/${tag}" class="hashtag" rel="tag"`,
226
+ );
227
+ }
228
+ return { ...content, html };
229
+ }
230
+
231
+ // ─── Post Type Mapping ────────────────────────────────────────────────────────
232
+
125
233
  /**
126
234
  * Map Micropub post-type to timeline type.
127
235
  */
@@ -138,12 +246,15 @@ function mapPostType(postType) {
138
246
  case "repost":
139
247
  return "boost";
140
248
  case "like":
249
+ case "bookmark":
141
250
  return "note";
142
251
  default:
143
252
  return "note";
144
253
  }
145
254
  }
146
255
 
256
+ // ─── Normalization Helpers ────────────────────────────────────────────────────
257
+
147
258
  /**
148
259
  * Normalize a value to an array of strings.
149
260
  */
@@ -154,14 +265,47 @@ function normalizeArray(value) {
154
265
  }
155
266
 
156
267
  /**
157
- * Normalize media values (can be strings or objects with url property).
268
+ * Normalize media values resolves relative URLs to absolute.
269
+ *
270
+ * @param {*} value - String, object with url, or array thereof
271
+ * @param {string} siteUrl - Base site URL for resolving relative paths
158
272
  */
159
- function normalizeMediaArray(value) {
273
+ function normalizeMediaArray(value, siteUrl) {
160
274
  if (!value) return [];
161
275
  const arr = Array.isArray(value) ? value : [value];
162
276
  return arr.map((item) => {
163
- if (typeof item === "string") return item;
164
- if (typeof item === "object" && item.url) return item;
277
+ if (typeof item === "string") return resolveUrl(item, siteUrl);
278
+ if (typeof item === "object" && item.url) {
279
+ return { ...item, url: resolveUrl(item.url, siteUrl) };
280
+ }
165
281
  return null;
166
282
  }).filter(Boolean);
167
283
  }
284
+
285
+ /**
286
+ * Resolve a URL — if relative, prepend the site URL.
287
+ */
288
+ function resolveUrl(url, siteUrl) {
289
+ if (!url) return url;
290
+ if (url.startsWith("http://") || url.startsWith("https://")) return url;
291
+ if (url.startsWith("/")) return `${siteUrl}${url}`;
292
+ return `${siteUrl}/${url}`;
293
+ }
294
+
295
+ /**
296
+ * Escape HTML entities.
297
+ */
298
+ function escapeHtml(str) {
299
+ return str
300
+ .replace(/&/g, "&amp;")
301
+ .replace(/</g, "&lt;")
302
+ .replace(/>/g, "&gt;")
303
+ .replace(/"/g, "&quot;");
304
+ }
305
+
306
+ /**
307
+ * Escape regex special characters.
308
+ */
309
+ function escapeRegex(str) {
310
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
311
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Resolve a remote account via WebFinger + ActivityPub actor fetch.
3
+ * Uses the Fedify federation instance to perform discovery.
4
+ *
5
+ * Shared by accounts.js (lookup) and search.js (resolve=true).
6
+ */
7
+ import { serializeAccount } from "../entities/account.js";
8
+
9
+ /**
10
+ * @param {string} acct - Account identifier (user@domain or URL)
11
+ * @param {object} pluginOptions - Plugin options with federation, handle, publicationUrl
12
+ * @param {string} baseUrl - Server base URL
13
+ * @returns {Promise<object|null>} Serialized Mastodon Account or null
14
+ */
15
+ export async function resolveRemoteAccount(acct, pluginOptions, baseUrl) {
16
+ const { federation, handle, publicationUrl } = pluginOptions;
17
+ if (!federation) return null;
18
+
19
+ try {
20
+ const ctx = federation.createContext(
21
+ new URL(publicationUrl),
22
+ { handle, publicationUrl },
23
+ );
24
+
25
+ // Determine lookup URI
26
+ let actorUri;
27
+ if (acct.includes("@")) {
28
+ const parts = acct.replace(/^@/, "").split("@");
29
+ const username = parts[0];
30
+ const domain = parts[1];
31
+ if (!username || !domain) return null;
32
+ actorUri = `acct:${username}@${domain}`;
33
+ } else if (acct.startsWith("http")) {
34
+ actorUri = acct;
35
+ } else {
36
+ return null;
37
+ }
38
+
39
+ const actor = await ctx.lookupObject(actorUri);
40
+ if (!actor) return null;
41
+
42
+ // Extract data from the Fedify actor object
43
+ const name = actor.name?.toString() || actor.preferredUsername?.toString() || "";
44
+ const actorUrl = actor.id?.href || "";
45
+ const username = actor.preferredUsername?.toString() || "";
46
+ const domain = actorUrl ? new URL(actorUrl).hostname : "";
47
+ const summary = actor.summary?.toString() || "";
48
+
49
+ // Get avatar
50
+ let avatarUrl = "";
51
+ try {
52
+ const icon = await actor.getIcon();
53
+ avatarUrl = icon?.url?.href || "";
54
+ } catch { /* ignore */ }
55
+
56
+ // Get header image
57
+ let headerUrl = "";
58
+ try {
59
+ const image = await actor.getImage();
60
+ headerUrl = image?.url?.href || "";
61
+ } catch { /* ignore */ }
62
+
63
+ return serializeAccount(
64
+ {
65
+ name,
66
+ url: actorUrl,
67
+ photo: avatarUrl,
68
+ handle: `@${username}@${domain}`,
69
+ summary,
70
+ image: headerUrl,
71
+ bot: actor.constructor?.name === "Service" || actor.constructor?.name === "Application",
72
+ },
73
+ { baseUrl },
74
+ );
75
+ } catch (error) {
76
+ console.warn(`[Mastodon API] Remote account resolution failed for ${acct}:`, error.message);
77
+ return null;
78
+ }
79
+ }
@@ -9,6 +9,7 @@ import { serializeCredentialAccount, serializeAccount } from "../entities/accoun
9
9
  import { serializeStatus } from "../entities/status.js";
10
10
  import { accountId, remoteActorId } from "../helpers/id-mapping.js";
11
11
  import { buildPaginationQuery, parseLimit, setPaginationHeaders } from "../helpers/pagination.js";
12
+ import { resolveRemoteAccount } from "../helpers/resolve-account.js";
12
13
 
13
14
  const router = express.Router(); // eslint-disable-line new-cap
14
15
 
@@ -100,7 +101,7 @@ router.get("/api/v1/accounts/lookup", async (req, res, next) => {
100
101
  }
101
102
  }
102
103
 
103
- // Check followers/following for known remote actors
104
+ // Check followers for known remote actors
104
105
  const follower = await collections.ap_followers.findOne({
105
106
  $or: [
106
107
  { handle: `@${bareAcct}` },
@@ -116,6 +117,38 @@ router.get("/api/v1/accounts/lookup", async (req, res, next) => {
116
117
  );
117
118
  }
118
119
 
120
+ // Check following
121
+ const following = await collections.ap_following.findOne({
122
+ $or: [
123
+ { handle: `@${bareAcct}` },
124
+ { handle: bareAcct },
125
+ ],
126
+ });
127
+ if (following) {
128
+ return res.json(
129
+ serializeAccount(
130
+ { name: following.name, url: following.actorUrl, photo: following.avatar, handle: following.handle },
131
+ { baseUrl },
132
+ ),
133
+ );
134
+ }
135
+
136
+ // Check timeline authors (people whose posts are in our timeline)
137
+ const timelineAuthor = await collections.ap_timeline.findOne({
138
+ "author.handle": { $in: [`@${bareAcct}`, bareAcct] },
139
+ });
140
+ if (timelineAuthor?.author) {
141
+ return res.json(
142
+ serializeAccount(timelineAuthor.author, { baseUrl }),
143
+ );
144
+ }
145
+
146
+ // Resolve remotely via federation (WebFinger + actor fetch)
147
+ const resolved = await resolveRemoteAccount(bareAcct, pluginOptions, baseUrl);
148
+ if (resolved) {
149
+ return res.json(resolved);
150
+ }
151
+
119
152
  return res.status(404).json({ error: "Record not found" });
120
153
  } catch (error) {
121
154
  next(error);
@@ -7,6 +7,7 @@ import express from "express";
7
7
  import { serializeStatus } from "../entities/status.js";
8
8
  import { serializeAccount } from "../entities/account.js";
9
9
  import { parseLimit } from "../helpers/pagination.js";
10
+ import { resolveRemoteAccount } from "../helpers/resolve-account.js";
10
11
 
11
12
  const router = express.Router(); // eslint-disable-line new-cap
12
13
 
@@ -21,6 +22,9 @@ router.get("/api/v2/search", async (req, res, next) => {
21
22
  const limit = parseLimit(req.query.limit);
22
23
  const offset = Math.max(0, Number.parseInt(req.query.offset, 10) || 0);
23
24
 
25
+ const resolve = req.query.resolve === "true";
26
+ const pluginOptions = req.app.locals.mastodonPluginOptions || {};
27
+
24
28
  if (!query) {
25
29
  return res.json({ accounts: [], statuses: [], hashtags: [] });
26
30
  }
@@ -75,6 +79,14 @@ router.get("/api/v2/search", async (req, res, next) => {
75
79
  }
76
80
  if (results.accounts.length >= limit) break;
77
81
  }
82
+
83
+ // If no local results and resolve=true, try remote lookup
84
+ if (results.accounts.length === 0 && resolve && query.includes("@")) {
85
+ const resolved = await resolveRemoteAccount(query, pluginOptions, baseUrl);
86
+ if (resolved) {
87
+ results.accounts.push(resolved);
88
+ }
89
+ }
78
90
  }
79
91
 
80
92
  // ─── Status search ───────────────────────────────────────────────────
@@ -219,6 +219,34 @@ router.post("/api/v1/statuses", async (req, res, next) => {
219
219
  const handle = pluginOptions.handle || "user";
220
220
  const actorUrl = profile?.url || `${publicationUrl}/users/${handle}`;
221
221
 
222
+ // Extract hashtags from status text and merge with any Micropub categories
223
+ const categories = data.properties.category || [];
224
+ const inlineHashtags = (statusText || "").match(/(?:^|\s)#([a-zA-Z_]\w*)/g);
225
+ if (inlineHashtags) {
226
+ const existing = new Set(categories.map((c) => c.toLowerCase()));
227
+ for (const match of inlineHashtags) {
228
+ const tag = match.trim().slice(1).toLowerCase();
229
+ if (!existing.has(tag)) {
230
+ existing.add(tag);
231
+ categories.push(tag);
232
+ }
233
+ }
234
+ }
235
+
236
+ // Resolve relative media URLs to absolute
237
+ const resolveMedia = (items) => {
238
+ if (!items || !items.length) return [];
239
+ return items.map((item) => {
240
+ if (typeof item === "string") {
241
+ return item.startsWith("http") ? item : `${publicationUrl.replace(/\/$/, "")}/${item.replace(/^\//, "")}`;
242
+ }
243
+ if (item?.url && !item.url.startsWith("http")) {
244
+ return { ...item, url: `${publicationUrl.replace(/\/$/, "")}/${item.url.replace(/^\//, "")}` };
245
+ }
246
+ return item;
247
+ });
248
+ };
249
+
222
250
  const now = new Date().toISOString();
223
251
  const timelineItem = await addTimelineItem(collections, {
224
252
  uid: postUrl,
@@ -234,16 +262,16 @@ router.post("/api/v1/statuses", async (req, res, next) => {
234
262
  createdAt: now,
235
263
  author: {
236
264
  name: profile?.name || handle,
237
- url: actorUrl,
265
+ url: profile?.url || publicationUrl,
238
266
  photo: profile?.icon || "",
239
267
  handle: `@${handle}`,
240
268
  emojis: [],
241
269
  bot: false,
242
270
  },
243
- photo: data.properties.photo || [],
244
- video: data.properties.video || [],
245
- audio: data.properties.audio || [],
246
- category: data.properties.category || [],
271
+ photo: resolveMedia(data.properties.photo || []),
272
+ video: resolveMedia(data.properties.video || []),
273
+ audio: resolveMedia(data.properties.audio || []),
274
+ category: categories,
247
275
  counts: { replies: 0, boosts: 0, likes: 0 },
248
276
  linkPreviews: [],
249
277
  mentions: [],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-activitypub",
3
- "version": "3.6.2",
3
+ "version": "3.6.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",