@rmdes/indiekit-endpoint-activitypub 2.4.0 → 2.4.2
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/index.js +21 -12
- package/lib/controllers/api-timeline.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
package/index.js
CHANGED
|
@@ -1000,18 +1000,27 @@ export default class ActivityPubEndpoint {
|
|
|
1000
1000
|
);
|
|
1001
1001
|
}
|
|
1002
1002
|
|
|
1003
|
-
//
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1003
|
+
// Muted collection — sparse unique indexes (allow multiple null values)
|
|
1004
|
+
this._collections.ap_muted
|
|
1005
|
+
.dropIndex("url_1")
|
|
1006
|
+
.catch(() => {})
|
|
1007
|
+
.then(() =>
|
|
1008
|
+
this._collections.ap_muted.createIndex(
|
|
1009
|
+
{ url: 1 },
|
|
1010
|
+
{ unique: true, sparse: true, background: true },
|
|
1011
|
+
),
|
|
1012
|
+
)
|
|
1013
|
+
.catch(() => {});
|
|
1014
|
+
this._collections.ap_muted
|
|
1015
|
+
.dropIndex("keyword_1")
|
|
1016
|
+
.catch(() => {})
|
|
1017
|
+
.then(() =>
|
|
1018
|
+
this._collections.ap_muted.createIndex(
|
|
1019
|
+
{ keyword: 1 },
|
|
1020
|
+
{ unique: true, sparse: true, background: true },
|
|
1021
|
+
),
|
|
1022
|
+
)
|
|
1023
|
+
.catch(() => {});
|
|
1015
1024
|
|
|
1016
1025
|
this._collections.ap_blocked.createIndex(
|
|
1017
1026
|
{ url: 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
|
|
@@ -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.2",
|
|
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",
|