@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.
- package/lib/controllers/api-timeline.js +9 -0
- package/lib/controllers/explore-utils.js +37 -1
- package/lib/controllers/explore.js +17 -0
- package/lib/controllers/hashtag-explore.js +9 -0
- package/lib/controllers/post-detail.js +38 -3
- package/lib/controllers/reader.js +9 -0
- package/lib/og-unfurl.js +55 -4
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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.
|
|
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",
|