@rmdes/indiekit-endpoint-activitypub 2.4.1 → 2.4.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.
@@ -10,6 +10,7 @@ import {
10
10
  getBlockedUrls,
11
11
  getFilterMode,
12
12
  } from "../storage/moderation.js";
13
+ import { stripQuoteReferenceHtml } from "../og-unfurl.js";
13
14
 
14
15
  export function apiTimelineController(mountPath) {
15
16
  return async (request, response, next) => {
@@ -134,6 +135,14 @@ export function apiTimelineController(mountPath) {
134
135
  }
135
136
  }
136
137
 
138
+ // Strip "RE:" paragraphs from items that have quote embeds
139
+ for (const item of items) {
140
+ const quoteRef = item.quoteUrl || item.quote?.url || item.quote?.uid;
141
+ if (item.quote && quoteRef && item.content?.html) {
142
+ item.content.html = stripQuoteReferenceHtml(item.content.html, quoteRef);
143
+ }
144
+ }
145
+
137
146
  const csrfToken = getToken(request.session);
138
147
 
139
148
  // Render each card server-side using the same Nunjucks template
@@ -92,7 +92,7 @@ export function mapMastodonStatusToItem(status, instance) {
92
92
  }
93
93
  }
94
94
 
95
- return {
95
+ const item = {
96
96
  uid: status.url || status.uri || "",
97
97
  url: status.url || status.uri || "",
98
98
  type: "note",
@@ -116,7 +116,43 @@ export function mapMastodonStatusToItem(status, instance) {
116
116
  video,
117
117
  audio,
118
118
  inReplyTo: status.in_reply_to_id ? `https://${instance}/web/statuses/${status.in_reply_to_id}` : "",
119
+ quoteUrl: status.quote?.url || status.quote?.uri || "",
119
120
  createdAt: new Date().toISOString(),
120
121
  _explore: true,
121
122
  };
123
+
124
+ // Map quoted post data if present (Mastodon 4.3+ quote support)
125
+ if (status.quote) {
126
+ const q = status.quote;
127
+ const qAccount = q.account || {};
128
+ const qAcct = qAccount.acct || "";
129
+ const qHandle = qAcct.includes("@") ? `@${qAcct}` : `@${qAcct}@${instance}`;
130
+ const qPhoto = [];
131
+ for (const att of q.media_attachments || []) {
132
+ const attUrl = att.url || att.remote_url || "";
133
+ if (attUrl && (att.type === "image" || att.type === "gifv")) {
134
+ qPhoto.push(attUrl);
135
+ }
136
+ }
137
+
138
+ item.quote = {
139
+ url: q.url || q.uri || "",
140
+ uid: q.uri || q.url || "",
141
+ author: {
142
+ name: sanitizeHtml(qAccount.display_name || qAccount.username || "Unknown", { allowedTags: [], allowedAttributes: {} }),
143
+ url: qAccount.url || "",
144
+ photo: qAccount.avatar || qAccount.avatar_static || "",
145
+ handle: qHandle,
146
+ },
147
+ content: {
148
+ text: (q.content || "").replace(/<[^>]*>/g, ""),
149
+ html: sanitizeContent(q.content || ""),
150
+ },
151
+ published: q.created_at || "",
152
+ name: "",
153
+ photo: qPhoto.slice(0, 1),
154
+ };
155
+ }
156
+
157
+ return item;
122
158
  }
@@ -8,6 +8,7 @@
8
8
  import { searchInstances, checkInstanceTimeline, getPopularAccounts } from "../fedidb.js";
9
9
  import { getToken } from "../csrf.js";
10
10
  import { validateInstance, validateHashtag, mapMastodonStatusToItem } from "./explore-utils.js";
11
+ import { stripQuoteReferenceHtml } from "../og-unfurl.js";
11
12
 
12
13
  const FETCH_TIMEOUT_MS = 10_000;
13
14
  const MAX_RESULTS = 20;
@@ -98,6 +99,14 @@ export function exploreController(mountPath) {
98
99
 
99
100
  items = statuses.map((s) => mapMastodonStatusToItem(s, instance));
100
101
 
102
+ // Strip "RE:" paragraphs from items that have quote embeds
103
+ for (const item of items) {
104
+ const quoteRef = item.quoteUrl || item.quote?.url || item.quote?.uid;
105
+ if (item.quote && quoteRef && item.content?.html) {
106
+ item.content.html = stripQuoteReferenceHtml(item.content.html, quoteRef);
107
+ }
108
+ }
109
+
101
110
  // Get next max_id from last item for pagination
102
111
  if (statuses.length === MAX_RESULTS && statuses.length > 0) {
103
112
  const last = statuses[statuses.length - 1];
@@ -181,6 +190,14 @@ export function exploreApiController(mountPath) {
181
190
 
182
191
  const items = statuses.map((s) => mapMastodonStatusToItem(s, instance));
183
192
 
193
+ // Strip "RE:" paragraphs from items that have quote embeds
194
+ for (const item of items) {
195
+ const quoteRef = item.quoteUrl || item.quote?.url || item.quote?.uid;
196
+ if (item.quote && quoteRef && item.content?.html) {
197
+ item.content.html = stripQuoteReferenceHtml(item.content.html, quoteRef);
198
+ }
199
+ }
200
+
184
201
  let nextMaxId = null;
185
202
  if (statuses.length === MAX_RESULTS && statuses.length > 0) {
186
203
  const last = statuses[statuses.length - 1];
@@ -18,6 +18,7 @@
18
18
 
19
19
  import { validateHashtag, mapMastodonStatusToItem } from "./explore-utils.js";
20
20
  import { getToken } from "../csrf.js";
21
+ import { stripQuoteReferenceHtml } from "../og-unfurl.js";
21
22
 
22
23
  const FETCH_TIMEOUT_MS = 10_000;
23
24
  const PAGE_SIZE = 20;
@@ -183,6 +184,14 @@ export function hashtagExploreApiController(mountPath) {
183
184
  mapMastodonStatusToItem(status, domain)
184
185
  );
185
186
 
187
+ // Strip "RE:" paragraphs from items that have quote embeds
188
+ for (const item of items) {
189
+ const quoteRef = item.quoteUrl || item.quote?.url || item.quote?.uid;
190
+ if (item.quote && quoteRef && item.content?.html) {
191
+ item.content.html = stripQuoteReferenceHtml(item.content.html, quoteRef);
192
+ }
193
+ }
194
+
186
195
  // Render HTML AFTER merge/dedup/paginate (don't waste CPU on discarded items)
187
196
  const csrfToken = getToken(request.session);
188
197
  const templateData = {
@@ -1,9 +1,9 @@
1
1
  // Post detail controller — view individual AP posts/notes/articles
2
2
  import { Article, Note, Person, Service, Application } from "@fedify/fedify/vocab";
3
3
  import { getToken } from "../csrf.js";
4
- import { extractObjectData } from "../timeline-store.js";
4
+ import { extractObjectData, extractActorInfo } from "../timeline-store.js";
5
5
  import { getCached, setCache } from "../lookup-cache.js";
6
- import { fetchAndStoreQuote } from "../og-unfurl.js";
6
+ import { fetchAndStoreQuote, stripQuoteReferenceHtml } from "../og-unfurl.js";
7
7
 
8
8
  // Load parent posts (inReplyTo chain) up to maxDepth levels
9
9
  async function loadParentChain(ctx, documentLoader, timelineCol, parentUrl, maxDepth = 5) {
@@ -332,6 +332,20 @@ export function postDetailController(mountPath, plugin) {
332
332
 
333
333
  if (quoteObject) {
334
334
  const quoteData = await extractObjectData(quoteObject, { documentLoader: qLoader });
335
+
336
+ // If author photo is empty, try fetching the actor directly
337
+ if (!quoteData.author.photo && quoteData.author.url) {
338
+ try {
339
+ const actor = await qCtx.lookupObject(new URL(quoteData.author.url), { documentLoader: qLoader });
340
+ if (actor) {
341
+ const actorInfo = await extractActorInfo(actor, { documentLoader: qLoader });
342
+ if (actorInfo.photo) quoteData.author.photo = actorInfo.photo;
343
+ }
344
+ } catch {
345
+ // Actor fetch failed — keep existing author data
346
+ }
347
+ }
348
+
335
349
  timelineItem.quote = {
336
350
  url: quoteData.url || quoteData.uid,
337
351
  uid: quoteData.uid,
@@ -342,11 +356,24 @@ export function postDetailController(mountPath, plugin) {
342
356
  photo: quoteData.photo?.slice(0, 1) || [],
343
357
  };
344
358
 
359
+ // Strip RE: paragraph from parent content
360
+ const quoteRef = timelineItem.quoteUrl || timelineItem.quote.url || timelineItem.quote.uid;
361
+ if (timelineItem.content?.html && quoteRef) {
362
+ timelineItem.content.html = stripQuoteReferenceHtml(
363
+ timelineItem.content.html,
364
+ quoteRef,
365
+ );
366
+ }
367
+
345
368
  // Persist for future requests (fire-and-forget)
346
369
  if (timelineCol) {
370
+ const persistUpdate = { $set: { quote: timelineItem.quote } };
371
+ if (timelineItem.content?.html) {
372
+ persistUpdate.$set["content.html"] = timelineItem.content.html;
373
+ }
347
374
  timelineCol.updateOne(
348
375
  { $or: [{ uid: objectUrl }, { url: objectUrl }] },
349
- { $set: { quote: timelineItem.quote } },
376
+ persistUpdate,
350
377
  ).catch(() => {});
351
378
  }
352
379
  }
@@ -355,6 +382,14 @@ export function postDetailController(mountPath, plugin) {
355
382
  }
356
383
  }
357
384
 
385
+ // Strip RE: paragraph for items with existing quote data (render-time cleanup)
386
+ if (timelineItem.quote && timelineItem.content?.html) {
387
+ const quoteRef = timelineItem.quoteUrl || timelineItem.quote.url || timelineItem.quote.uid;
388
+ if (quoteRef) {
389
+ timelineItem.content.html = stripQuoteReferenceHtml(timelineItem.content.html, quoteRef);
390
+ }
391
+ }
392
+
358
393
  const csrfToken = getToken(request.session);
359
394
 
360
395
  response.render("activitypub-post-detail", {
@@ -19,6 +19,7 @@ import {
19
19
  getFilterMode,
20
20
  } from "../storage/moderation.js";
21
21
  import { getFollowedTags } from "../storage/followed-tags.js";
22
+ import { stripQuoteReferenceHtml } from "../og-unfurl.js";
22
23
 
23
24
  // Re-export controllers from split modules for backward compatibility
24
25
  export {
@@ -196,6 +197,14 @@ export function readerController(mountPath) {
196
197
  }
197
198
  }
198
199
 
200
+ // Strip "RE:" paragraphs from items that have quote embeds
201
+ for (const item of items) {
202
+ const quoteRef = item.quoteUrl || item.quote?.url || item.quote?.uid;
203
+ if (item.quote && quoteRef && item.content?.html) {
204
+ item.content.html = stripQuoteReferenceHtml(item.content.html, quoteRef);
205
+ }
206
+ }
207
+
199
208
  // CSRF token for interaction forms
200
209
  const csrfToken = getToken(request.session);
201
210
 
package/lib/og-unfurl.js CHANGED
@@ -267,6 +267,22 @@ export async function fetchAndStoreQuote(collections, uid, quoteUrl, ctx, docume
267
267
 
268
268
  const quoteData = await extractObjectData(object, { documentLoader });
269
269
 
270
+ // If author photo is empty, try fetching the actor directly
271
+ if (!quoteData.author.photo && quoteData.author.url) {
272
+ try {
273
+ const actor = await ctx.lookupObject(new URL(quoteData.author.url), { documentLoader });
274
+ if (actor) {
275
+ const { extractActorInfo } = await import("./timeline-store.js");
276
+ const actorInfo = await extractActorInfo(actor, { documentLoader });
277
+ if (actorInfo.photo) {
278
+ quoteData.author.photo = actorInfo.photo;
279
+ }
280
+ }
281
+ } catch {
282
+ // Actor fetch failed — keep existing author data
283
+ }
284
+ }
285
+
270
286
  const quote = {
271
287
  url: quoteData.url || quoteData.uid,
272
288
  uid: quoteData.uid,
@@ -277,11 +293,46 @@ export async function fetchAndStoreQuote(collections, uid, quoteUrl, ctx, docume
277
293
  photo: quoteData.photo?.slice(0, 1) || [],
278
294
  };
279
295
 
280
- await collections.ap_timeline.updateOne(
281
- { uid },
282
- { $set: { quote } },
283
- );
296
+ // Strip the "RE: <link>" paragraph from the parent post's content
297
+ // Mastodon adds this as: <p>RE: <a href="QUOTE_URL">...</a></p>
298
+ const update = { $set: { quote } };
299
+ const parentItem = await collections.ap_timeline.findOne({ uid });
300
+ if (parentItem?.content?.html) {
301
+ const cleaned = stripQuoteReferenceHtml(parentItem.content.html, quoteUrl);
302
+ if (cleaned !== parentItem.content.html) {
303
+ update.$set["content.html"] = cleaned;
304
+ }
305
+ }
306
+
307
+ await collections.ap_timeline.updateOne({ uid }, update);
284
308
  } catch (error) {
285
309
  console.error(`[og-unfurl] Failed to fetch quote for ${uid}: ${error.message}`);
286
310
  }
287
311
  }
312
+
313
+ /**
314
+ * Strip the "RE: <link>" paragraph that Mastodon adds for quoted posts.
315
+ * Removes <p> elements containing "RE:" followed by a link to the quote URL.
316
+ * @param {string} html - Content HTML
317
+ * @param {string} quoteUrl - URL of the quoted post
318
+ * @returns {string} Cleaned HTML
319
+ */
320
+ export function stripQuoteReferenceHtml(html, quoteUrl) {
321
+ if (!html || !quoteUrl) return html;
322
+ // Match <p> containing "RE:" and a link whose href contains the quote domain+path
323
+ // Mastodon uses both /users/X/statuses/Y and /@X/Y URL formats
324
+ try {
325
+ const quoteUrlObj = new URL(quoteUrl);
326
+ const quoteDomain = quoteUrlObj.hostname;
327
+ // Escape special regex chars in domain
328
+ const domainEscaped = quoteDomain.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
329
+ // Match <p>RE: <a href="...DOMAIN...">...</a></p> (with optional whitespace)
330
+ const re = new RegExp(
331
+ `<p>\\s*RE:\\s*<a\\s[^>]*href="[^"]*${domainEscaped}[^"]*"[^>]*>.*?</a>\\s*</p>`,
332
+ "i",
333
+ );
334
+ return html.replace(re, "").trim();
335
+ } catch {
336
+ return html;
337
+ }
338
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-activitypub",
3
- "version": "2.4.1",
3
+ "version": "2.4.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",