@rmdes/indiekit-endpoint-activitypub 3.6.1 → 3.6.3

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
+ }
@@ -310,7 +310,7 @@ router.post("/oauth/authorize", async (req, res, next) => {
310
310
  "error_description",
311
311
  "The resource owner denied the request",
312
312
  );
313
- return res.redirect(url.toString());
313
+ return redirectToUri(res, redirect_uri, url.toString());
314
314
  }
315
315
  return res.status(403).json({
316
316
  error: "access_denied",
@@ -362,7 +362,7 @@ router.post("/oauth/authorize", async (req, res, next) => {
362
362
  // Redirect with code
363
363
  const url = new URL(redirect_uri);
364
364
  url.searchParams.set("code", code);
365
- res.redirect(url.toString());
365
+ redirectToUri(res, redirect_uri, url.toString());
366
366
  } catch (error) {
367
367
  next(error);
368
368
  }
@@ -600,4 +600,36 @@ function extractClientCredentials(req) {
600
600
  };
601
601
  }
602
602
 
603
+ /**
604
+ * Redirect to a URI, handling custom schemes for native apps.
605
+ *
606
+ * HTTP(S) redirect URIs use a standard 302 redirect (web clients).
607
+ * Custom scheme URIs (fedilab://, moshidon-android-auth://) use an
608
+ * HTML page with JavaScript + meta refresh. Android Chrome Custom Tabs
609
+ * block 302 redirects to non-HTTP schemes but allow client-side navigation.
610
+ *
611
+ * @param {object} res - Express response
612
+ * @param {string} originalUri - The registered redirect_uri (to detect scheme)
613
+ * @param {string} fullUrl - The complete redirect URL with query params
614
+ */
615
+ function redirectToUri(res, originalUri, fullUrl) {
616
+ if (originalUri.startsWith("http://") || originalUri.startsWith("https://")) {
617
+ return res.redirect(fullUrl);
618
+ }
619
+
620
+ // Native app — HTML page with JS redirect + meta refresh fallback
621
+ res.type("html").send(`<!DOCTYPE html>
622
+ <html lang="en">
623
+ <head>
624
+ <meta charset="utf-8">
625
+ <meta http-equiv="refresh" content="0;url=${fullUrl}">
626
+ <title>Redirecting…</title>
627
+ </head>
628
+ <body>
629
+ <p>Redirecting to application…</p>
630
+ <script>window.location.href = ${JSON.stringify(fullUrl)};</script>
631
+ </body>
632
+ </html>`);
633
+ }
634
+
603
635
  export default router;
@@ -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.1",
3
+ "version": "3.6.3",
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",