@rmdes/indiekit-endpoint-activitypub 1.0.29 → 1.1.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/assets/reader.css +884 -0
- package/index.js +172 -15
- package/lib/controllers/compose.js +323 -0
- package/lib/controllers/featured-tags.js +12 -2
- package/lib/controllers/featured.js +12 -2
- package/lib/controllers/interactions-boost.js +208 -0
- package/lib/controllers/interactions-like.js +231 -0
- package/lib/controllers/interactions.js +7 -0
- package/lib/controllers/moderation.js +294 -0
- package/lib/controllers/profile.js +8 -1
- package/lib/controllers/profile.remote.js +218 -0
- package/lib/controllers/reader.js +187 -0
- package/lib/csrf.js +49 -0
- package/lib/federation-setup.js +28 -1
- package/lib/inbox-listeners.js +217 -213
- package/lib/storage/moderation.js +180 -0
- package/lib/storage/notifications.js +132 -0
- package/lib/storage/timeline.js +210 -0
- package/lib/timeline-cleanup.js +88 -0
- package/lib/timeline-store.js +207 -0
- package/locales/en.json +84 -1
- package/package.json +3 -2
- package/views/activitypub-compose.njk +94 -0
- package/views/activitypub-moderation.njk +118 -0
- package/views/activitypub-notifications.njk +31 -0
- package/views/activitypub-reader.njk +61 -0
- package/views/activitypub-remote-profile.njk +117 -0
- package/views/layouts/reader.njk +9 -0
- package/views/partials/ap-item-card.njk +157 -0
- package/views/partials/ap-item-media.njk +37 -0
- package/views/partials/ap-notification-card.njk +58 -0
package/lib/csrf.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simple CSRF token generation and validation.
|
|
3
|
+
* Tokens are stored in the Express session.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Get or generate a CSRF token for the current session.
|
|
10
|
+
* @param {object} session - Express session object
|
|
11
|
+
* @returns {string} CSRF token
|
|
12
|
+
*/
|
|
13
|
+
export function getToken(session) {
|
|
14
|
+
if (!session._csrfToken) {
|
|
15
|
+
session._csrfToken = randomBytes(32).toString("hex");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return session._csrfToken;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Validate a CSRF token from a request.
|
|
23
|
+
* Checks both the request body `_csrf` field and the `X-CSRF-Token` header.
|
|
24
|
+
* @param {object} request - Express request object
|
|
25
|
+
* @returns {boolean} Whether the token is valid
|
|
26
|
+
*/
|
|
27
|
+
export function validateToken(request) {
|
|
28
|
+
const sessionToken = request.session?._csrfToken;
|
|
29
|
+
|
|
30
|
+
if (!sessionToken) {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const requestToken =
|
|
35
|
+
request.body?._csrf || request.headers["x-csrf-token"];
|
|
36
|
+
|
|
37
|
+
if (!requestToken) {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (sessionToken.length !== requestToken.length) {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return timingSafeEqual(
|
|
46
|
+
Buffer.from(sessionToken),
|
|
47
|
+
Buffer.from(requestToken),
|
|
48
|
+
);
|
|
49
|
+
}
|
package/lib/federation-setup.js
CHANGED
|
@@ -183,7 +183,11 @@ export function setupFederation(options) {
|
|
|
183
183
|
|
|
184
184
|
if (profile.attachments?.length > 0) {
|
|
185
185
|
personOptions.attachments = profile.attachments.map(
|
|
186
|
-
(att) =>
|
|
186
|
+
(att) =>
|
|
187
|
+
new PropertyValue({
|
|
188
|
+
name: att.name,
|
|
189
|
+
value: formatAttachmentValue(att.value),
|
|
190
|
+
}),
|
|
187
191
|
);
|
|
188
192
|
}
|
|
189
193
|
|
|
@@ -689,6 +693,29 @@ async function importPkcs8Pem(pem) {
|
|
|
689
693
|
);
|
|
690
694
|
}
|
|
691
695
|
|
|
696
|
+
/**
|
|
697
|
+
* Format an attachment value for ActivityPub PropertyValue.
|
|
698
|
+
* If the value looks like a URL, wrap it in an HTML anchor tag with rel="me"
|
|
699
|
+
* so Mastodon can verify profile link ownership. Plain text values pass through.
|
|
700
|
+
*/
|
|
701
|
+
function formatAttachmentValue(value) {
|
|
702
|
+
if (!value) return "";
|
|
703
|
+
const trimmed = value.trim();
|
|
704
|
+
// Already contains HTML — pass through
|
|
705
|
+
if (trimmed.startsWith("<")) return trimmed;
|
|
706
|
+
// URL — wrap in anchor with rel="me"
|
|
707
|
+
if (/^https?:\/\//i.test(trimmed)) {
|
|
708
|
+
const escaped = trimmed
|
|
709
|
+
.replace(/&/g, "&")
|
|
710
|
+
.replace(/</g, "<")
|
|
711
|
+
.replace(/>/g, ">")
|
|
712
|
+
.replace(/"/g, """);
|
|
713
|
+
return `<a href="${escaped}" rel="me">${escaped}</a>`;
|
|
714
|
+
}
|
|
715
|
+
// Plain text (e.g. pronouns) — return as-is
|
|
716
|
+
return trimmed;
|
|
717
|
+
}
|
|
718
|
+
|
|
692
719
|
function guessImageMediaType(url) {
|
|
693
720
|
const ext = url.split(".").pop()?.toLowerCase();
|
|
694
721
|
const types = {
|
package/lib/inbox-listeners.js
CHANGED
|
@@ -23,6 +23,9 @@ import {
|
|
|
23
23
|
} from "@fedify/fedify";
|
|
24
24
|
|
|
25
25
|
import { logActivity as logActivityShared } from "./activity-log.js";
|
|
26
|
+
import { sanitizeContent, extractActorInfo, extractObjectData } from "./timeline-store.js";
|
|
27
|
+
import { addTimelineItem, deleteTimelineItem, updateTimelineItem } from "./storage/timeline.js";
|
|
28
|
+
import { addNotification } from "./storage/notifications.js";
|
|
26
29
|
|
|
27
30
|
/**
|
|
28
31
|
* Register all inbox listeners on a federation's inbox chain.
|
|
@@ -83,6 +86,19 @@ export function registerInboxListeners(inboxChain, options) {
|
|
|
83
86
|
actorName: followerName,
|
|
84
87
|
summary: `${followerName} followed you`,
|
|
85
88
|
});
|
|
89
|
+
|
|
90
|
+
// Store notification
|
|
91
|
+
const followerInfo = await extractActorInfo(followerActor);
|
|
92
|
+
await addNotification(collections, {
|
|
93
|
+
uid: follow.id?.href || `follow:${followerUrl}`,
|
|
94
|
+
type: "follow",
|
|
95
|
+
actorUrl: followerInfo.url,
|
|
96
|
+
actorName: followerInfo.name,
|
|
97
|
+
actorPhoto: followerInfo.photo,
|
|
98
|
+
actorHandle: followerInfo.handle,
|
|
99
|
+
published: follow.published ? String(follow.published) : new Date().toISOString(),
|
|
100
|
+
createdAt: new Date().toISOString(),
|
|
101
|
+
});
|
|
86
102
|
})
|
|
87
103
|
.on(Undo, async (ctx, undo) => {
|
|
88
104
|
const actorUrl = undo.actorId?.href || "";
|
|
@@ -139,7 +155,7 @@ export function registerInboxListeners(inboxChain, options) {
|
|
|
139
155
|
const result = await collections.ap_following.findOneAndUpdate(
|
|
140
156
|
{
|
|
141
157
|
actorUrl,
|
|
142
|
-
source: { $in: ["refollow:sent", "microsub-reader"] },
|
|
158
|
+
source: { $in: ["refollow:sent", "reader", "microsub-reader"] },
|
|
143
159
|
},
|
|
144
160
|
{
|
|
145
161
|
$set: {
|
|
@@ -176,7 +192,7 @@ export function registerInboxListeners(inboxChain, options) {
|
|
|
176
192
|
const result = await collections.ap_following.findOneAndUpdate(
|
|
177
193
|
{
|
|
178
194
|
actorUrl,
|
|
179
|
-
source: { $in: ["refollow:sent", "microsub-reader"] },
|
|
195
|
+
source: { $in: ["refollow:sent", "reader", "microsub-reader"] },
|
|
180
196
|
},
|
|
181
197
|
{
|
|
182
198
|
$set: {
|
|
@@ -210,17 +226,18 @@ export function registerInboxListeners(inboxChain, options) {
|
|
|
210
226
|
if (!objectId || (pubUrl && !objectId.startsWith(pubUrl))) return;
|
|
211
227
|
|
|
212
228
|
const actorUrl = like.actorId?.href || "";
|
|
213
|
-
let
|
|
229
|
+
let actorObj;
|
|
214
230
|
try {
|
|
215
|
-
|
|
216
|
-
actorName =
|
|
217
|
-
actorObj?.name?.toString() ||
|
|
218
|
-
actorObj?.preferredUsername?.toString() ||
|
|
219
|
-
actorUrl;
|
|
231
|
+
actorObj = await like.getActor();
|
|
220
232
|
} catch {
|
|
221
|
-
|
|
233
|
+
actorObj = null;
|
|
222
234
|
}
|
|
223
235
|
|
|
236
|
+
const actorName =
|
|
237
|
+
actorObj?.name?.toString() ||
|
|
238
|
+
actorObj?.preferredUsername?.toString() ||
|
|
239
|
+
actorUrl;
|
|
240
|
+
|
|
224
241
|
await logActivity(collections, storeRawActivities, {
|
|
225
242
|
direction: "inbound",
|
|
226
243
|
type: "Like",
|
|
@@ -229,35 +246,96 @@ export function registerInboxListeners(inboxChain, options) {
|
|
|
229
246
|
objectUrl: objectId,
|
|
230
247
|
summary: `${actorName} liked ${objectId}`,
|
|
231
248
|
});
|
|
249
|
+
|
|
250
|
+
// Store notification
|
|
251
|
+
const actorInfo = await extractActorInfo(actorObj);
|
|
252
|
+
await addNotification(collections, {
|
|
253
|
+
uid: like.id?.href || `like:${actorUrl}:${objectId}`,
|
|
254
|
+
type: "like",
|
|
255
|
+
actorUrl: actorInfo.url,
|
|
256
|
+
actorName: actorInfo.name,
|
|
257
|
+
actorPhoto: actorInfo.photo,
|
|
258
|
+
actorHandle: actorInfo.handle,
|
|
259
|
+
targetUrl: objectId,
|
|
260
|
+
targetName: "", // Could fetch post title, but not critical
|
|
261
|
+
published: like.published ? String(like.published) : new Date().toISOString(),
|
|
262
|
+
createdAt: new Date().toISOString(),
|
|
263
|
+
});
|
|
232
264
|
})
|
|
233
265
|
.on(Announce, async (ctx, announce) => {
|
|
234
|
-
// Use .objectId — no remote fetch needed (see Like handler comment)
|
|
235
266
|
const objectId = announce.objectId?.href || "";
|
|
267
|
+
if (!objectId) return;
|
|
236
268
|
|
|
237
|
-
|
|
269
|
+
const actorUrl = announce.actorId?.href || "";
|
|
238
270
|
const pubUrl = collections._publicationUrl;
|
|
239
|
-
if (!objectId || (pubUrl && !objectId.startsWith(pubUrl))) return;
|
|
240
271
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
272
|
+
// Dual path logic: Notification vs Timeline
|
|
273
|
+
|
|
274
|
+
// PATH 1: Boost of OUR content → Notification
|
|
275
|
+
if (pubUrl && objectId.startsWith(pubUrl)) {
|
|
276
|
+
let actorObj;
|
|
277
|
+
try {
|
|
278
|
+
actorObj = await announce.getActor();
|
|
279
|
+
} catch {
|
|
280
|
+
actorObj = null;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const actorName =
|
|
246
284
|
actorObj?.name?.toString() ||
|
|
247
285
|
actorObj?.preferredUsername?.toString() ||
|
|
248
286
|
actorUrl;
|
|
249
|
-
|
|
250
|
-
|
|
287
|
+
|
|
288
|
+
// Log the boost activity
|
|
289
|
+
await logActivity(collections, storeRawActivities, {
|
|
290
|
+
direction: "inbound",
|
|
291
|
+
type: "Announce",
|
|
292
|
+
actorUrl,
|
|
293
|
+
actorName,
|
|
294
|
+
objectUrl: objectId,
|
|
295
|
+
summary: `${actorName} boosted ${objectId}`,
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// Create notification
|
|
299
|
+
const actorInfo = await extractActorInfo(actorObj);
|
|
300
|
+
await addNotification(collections, {
|
|
301
|
+
uid: announce.id?.href || `${actorUrl}#boost-${objectId}`,
|
|
302
|
+
type: "boost",
|
|
303
|
+
actorUrl: actorInfo.url,
|
|
304
|
+
actorName: actorInfo.name,
|
|
305
|
+
actorPhoto: actorInfo.photo,
|
|
306
|
+
actorHandle: actorInfo.handle,
|
|
307
|
+
targetUrl: objectId,
|
|
308
|
+
targetName: "", // Could fetch post title, but not critical
|
|
309
|
+
published: announce.published ? String(announce.published) : new Date().toISOString(),
|
|
310
|
+
createdAt: new Date().toISOString(),
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// Don't return — fall through to check if actor is also followed
|
|
251
314
|
}
|
|
252
315
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
316
|
+
// PATH 2: Boost from someone we follow → Timeline (store original post)
|
|
317
|
+
const following = await collections.ap_following.findOne({ actorUrl });
|
|
318
|
+
if (following) {
|
|
319
|
+
try {
|
|
320
|
+
// Fetch the original object being boosted
|
|
321
|
+
const object = await announce.getObject();
|
|
322
|
+
if (!object) return;
|
|
323
|
+
|
|
324
|
+
// Get booster actor info
|
|
325
|
+
const boosterActor = await announce.getActor();
|
|
326
|
+
const boosterInfo = await extractActorInfo(boosterActor);
|
|
327
|
+
|
|
328
|
+
// Extract and store with boost metadata
|
|
329
|
+
const timelineItem = await extractObjectData(object, {
|
|
330
|
+
boostedBy: boosterInfo,
|
|
331
|
+
boostedAt: announce.published ? String(announce.published) : new Date().toISOString(),
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
await addTimelineItem(collections, timelineItem);
|
|
335
|
+
} catch (error) {
|
|
336
|
+
console.error("Failed to store boosted timeline item:", error);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
261
339
|
})
|
|
262
340
|
.on(Create, async (ctx, create) => {
|
|
263
341
|
let object;
|
|
@@ -292,6 +370,7 @@ export function registerInboxListeners(inboxChain, options) {
|
|
|
292
370
|
}
|
|
293
371
|
|
|
294
372
|
// Log replies to our posts (existing behavior for conversations)
|
|
373
|
+
const pubUrl = collections._publicationUrl;
|
|
295
374
|
if (inReplyTo) {
|
|
296
375
|
const content = object.content?.toString() || "";
|
|
297
376
|
await logActivity(collections, storeRawActivities, {
|
|
@@ -304,21 +383,86 @@ export function registerInboxListeners(inboxChain, options) {
|
|
|
304
383
|
content,
|
|
305
384
|
summary: `${actorName} replied to ${inReplyTo}`,
|
|
306
385
|
});
|
|
386
|
+
|
|
387
|
+
// Create notification if reply is to one of OUR posts
|
|
388
|
+
if (pubUrl && inReplyTo.startsWith(pubUrl)) {
|
|
389
|
+
const actorInfo = await extractActorInfo(actorObj);
|
|
390
|
+
const rawHtml = object.content?.toString() || "";
|
|
391
|
+
const contentHtml = sanitizeContent(rawHtml);
|
|
392
|
+
const contentText = rawHtml.replace(/<[^>]*>/g, "").substring(0, 200);
|
|
393
|
+
|
|
394
|
+
await addNotification(collections, {
|
|
395
|
+
uid: object.id?.href || `reply:${actorUrl}:${inReplyTo}`,
|
|
396
|
+
type: "reply",
|
|
397
|
+
actorUrl: actorInfo.url,
|
|
398
|
+
actorName: actorInfo.name,
|
|
399
|
+
actorPhoto: actorInfo.photo,
|
|
400
|
+
actorHandle: actorInfo.handle,
|
|
401
|
+
targetUrl: inReplyTo,
|
|
402
|
+
targetName: "",
|
|
403
|
+
content: {
|
|
404
|
+
text: contentText,
|
|
405
|
+
html: contentHtml,
|
|
406
|
+
},
|
|
407
|
+
published: object.published ? String(object.published) : new Date().toISOString(),
|
|
408
|
+
createdAt: new Date().toISOString(),
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Check for mentions of our actor
|
|
414
|
+
if (object.tag) {
|
|
415
|
+
const tags = Array.isArray(object.tag) ? object.tag : [object.tag];
|
|
416
|
+
const ourActorUrl = ctx.getActorUri(handle).href;
|
|
417
|
+
|
|
418
|
+
for (const tag of tags) {
|
|
419
|
+
if (tag.type === "Mention" && tag.href?.href === ourActorUrl) {
|
|
420
|
+
const actorInfo = await extractActorInfo(actorObj);
|
|
421
|
+
const rawMentionHtml = object.content?.toString() || "";
|
|
422
|
+
const mentionHtml = sanitizeContent(rawMentionHtml);
|
|
423
|
+
const contentText = rawMentionHtml.replace(/<[^>]*>/g, "").substring(0, 200);
|
|
424
|
+
|
|
425
|
+
await addNotification(collections, {
|
|
426
|
+
uid: object.id?.href || `mention:${actorUrl}:${object.id?.href}`,
|
|
427
|
+
type: "mention",
|
|
428
|
+
actorUrl: actorInfo.url,
|
|
429
|
+
actorName: actorInfo.name,
|
|
430
|
+
actorPhoto: actorInfo.photo,
|
|
431
|
+
actorHandle: actorInfo.handle,
|
|
432
|
+
content: {
|
|
433
|
+
text: contentText,
|
|
434
|
+
html: mentionHtml,
|
|
435
|
+
},
|
|
436
|
+
published: object.published ? String(object.published) : new Date().toISOString(),
|
|
437
|
+
createdAt: new Date().toISOString(),
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
break; // Only create one mention notification per post
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Store timeline items from accounts we follow (native storage)
|
|
446
|
+
const following = await collections.ap_following.findOne({ actorUrl });
|
|
447
|
+
if (following) {
|
|
448
|
+
try {
|
|
449
|
+
const timelineItem = await extractObjectData(object);
|
|
450
|
+
await addTimelineItem(collections, timelineItem);
|
|
451
|
+
} catch (error) {
|
|
452
|
+
// Log extraction errors but don't fail the entire handler
|
|
453
|
+
console.error("Failed to store timeline item:", error);
|
|
454
|
+
}
|
|
307
455
|
}
|
|
308
456
|
|
|
309
|
-
// Store timeline items from accounts we follow
|
|
310
|
-
await storeTimelineItem(collections, {
|
|
311
|
-
actorUrl,
|
|
312
|
-
actorName,
|
|
313
|
-
actorObj,
|
|
314
|
-
object,
|
|
315
|
-
inReplyTo,
|
|
316
|
-
});
|
|
317
457
|
})
|
|
318
458
|
.on(Delete, async (ctx, del) => {
|
|
319
459
|
const objectId = del.objectId?.href || "";
|
|
320
460
|
if (objectId) {
|
|
461
|
+
// Remove from activity log
|
|
321
462
|
await collections.ap_activities.deleteMany({ objectUrl: objectId });
|
|
463
|
+
|
|
464
|
+
// Remove from timeline
|
|
465
|
+
await deleteTimelineItem(collections, objectId);
|
|
322
466
|
}
|
|
323
467
|
})
|
|
324
468
|
.on(Move, async (ctx, move) => {
|
|
@@ -343,7 +487,44 @@ export function registerInboxListeners(inboxChain, options) {
|
|
|
343
487
|
});
|
|
344
488
|
})
|
|
345
489
|
.on(Update, async (ctx, update) => {
|
|
346
|
-
//
|
|
490
|
+
// Update can be for a profile OR for a post (edited content)
|
|
491
|
+
|
|
492
|
+
// Try to get the object being updated
|
|
493
|
+
let object;
|
|
494
|
+
try {
|
|
495
|
+
object = await update.getObject();
|
|
496
|
+
} catch {
|
|
497
|
+
object = null;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// PATH 1: If object is a Note/Article → Update timeline item content
|
|
501
|
+
if (object && (object instanceof Note || object.type === "Article")) {
|
|
502
|
+
const objectUrl = object.id?.href || "";
|
|
503
|
+
if (objectUrl) {
|
|
504
|
+
try {
|
|
505
|
+
// Extract updated content
|
|
506
|
+
const contentHtml = object.content?.toString() || "";
|
|
507
|
+
const contentText = object.source?.content?.toString() || contentHtml.replace(/<[^>]*>/g, "");
|
|
508
|
+
|
|
509
|
+
const updates = {
|
|
510
|
+
content: {
|
|
511
|
+
text: contentText,
|
|
512
|
+
html: contentHtml,
|
|
513
|
+
},
|
|
514
|
+
name: object.name?.toString() || "",
|
|
515
|
+
summary: object.summary?.toString() || "",
|
|
516
|
+
sensitive: object.sensitive || false,
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
await updateTimelineItem(collections, objectUrl, updates);
|
|
520
|
+
} catch (error) {
|
|
521
|
+
console.error("Failed to update timeline item:", error);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// PATH 2: Otherwise, assume profile update — refresh stored follower data
|
|
347
528
|
const actorObj = await update.getActor();
|
|
348
529
|
const actorUrl = actorObj?.id?.href || "";
|
|
349
530
|
if (!actorUrl) return;
|
|
@@ -397,180 +578,3 @@ async function logActivity(collections, storeRaw, record, rawJson) {
|
|
|
397
578
|
);
|
|
398
579
|
}
|
|
399
580
|
|
|
400
|
-
// Cached ActivityPub channel ObjectId
|
|
401
|
-
let _apChannelId = null;
|
|
402
|
-
|
|
403
|
-
/**
|
|
404
|
-
* Look up (or auto-create) the ActivityPub channel's ObjectId.
|
|
405
|
-
* Cached after first successful call.
|
|
406
|
-
*
|
|
407
|
-
* The channel is created with `userId: "default"` so it appears in the
|
|
408
|
-
* Microsub reader UI alongside user-created channels.
|
|
409
|
-
*
|
|
410
|
-
* @param {object} collections - MongoDB collections
|
|
411
|
-
* @returns {Promise<import("mongodb").ObjectId|null>}
|
|
412
|
-
*/
|
|
413
|
-
async function getApChannelId(collections) {
|
|
414
|
-
if (_apChannelId) return _apChannelId;
|
|
415
|
-
if (!collections.microsub_channels) return null;
|
|
416
|
-
|
|
417
|
-
let channel = await collections.microsub_channels.findOne({
|
|
418
|
-
uid: "activitypub",
|
|
419
|
-
});
|
|
420
|
-
|
|
421
|
-
if (!channel) {
|
|
422
|
-
// Auto-create the channel with the same fields the Microsub plugin uses
|
|
423
|
-
const maxOrderDoc = await collections.microsub_channels
|
|
424
|
-
.find({ userId: "default" })
|
|
425
|
-
.sort({ order: -1 })
|
|
426
|
-
.limit(1)
|
|
427
|
-
.toArray();
|
|
428
|
-
const order = maxOrderDoc.length > 0 ? maxOrderDoc[0].order + 1 : 0;
|
|
429
|
-
|
|
430
|
-
const doc = {
|
|
431
|
-
uid: "activitypub",
|
|
432
|
-
name: "Fediverse",
|
|
433
|
-
userId: "default",
|
|
434
|
-
order,
|
|
435
|
-
settings: { excludeTypes: [], excludeRegex: null },
|
|
436
|
-
createdAt: new Date().toISOString(),
|
|
437
|
-
updatedAt: new Date().toISOString(),
|
|
438
|
-
};
|
|
439
|
-
await collections.microsub_channels.insertOne(doc);
|
|
440
|
-
channel = doc;
|
|
441
|
-
console.info("[ActivityPub] Auto-created Microsub channel 'Fediverse'");
|
|
442
|
-
} else if (!channel.userId) {
|
|
443
|
-
// Fix existing channel missing userId (created by earlier version)
|
|
444
|
-
await collections.microsub_channels.updateOne(
|
|
445
|
-
{ _id: channel._id },
|
|
446
|
-
{ $set: { userId: "default" } },
|
|
447
|
-
);
|
|
448
|
-
console.info("[ActivityPub] Fixed Microsub channel: set userId to 'default'");
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
_apChannelId = channel._id;
|
|
452
|
-
return _apChannelId;
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
/**
|
|
456
|
-
* Store a Create activity as a Microsub timeline item if the actor
|
|
457
|
-
* is someone we follow. Skips gracefully if the Microsub plugin
|
|
458
|
-
* isn't loaded or the AP channel doesn't exist yet.
|
|
459
|
-
*
|
|
460
|
-
* @param {object} collections - MongoDB collections
|
|
461
|
-
* @param {object} params
|
|
462
|
-
* @param {string} params.actorUrl - Actor URL
|
|
463
|
-
* @param {string} params.actorName - Actor display name
|
|
464
|
-
* @param {object} params.actorObj - Fedify actor object
|
|
465
|
-
* @param {object} params.object - Fedify Note/Article object
|
|
466
|
-
* @param {string|null} params.inReplyTo - URL this is a reply to (if any)
|
|
467
|
-
*/
|
|
468
|
-
async function storeTimelineItem(
|
|
469
|
-
collections,
|
|
470
|
-
{ actorUrl, actorName, actorObj, object, inReplyTo },
|
|
471
|
-
) {
|
|
472
|
-
// Skip if Microsub plugin not loaded
|
|
473
|
-
if (!collections.microsub_items || !collections.microsub_channels) return;
|
|
474
|
-
|
|
475
|
-
// Only store posts from accounts we follow
|
|
476
|
-
const following = await collections.ap_following.findOne({ actorUrl });
|
|
477
|
-
if (!following) return;
|
|
478
|
-
|
|
479
|
-
const channelId = await getApChannelId(collections);
|
|
480
|
-
if (!channelId) return;
|
|
481
|
-
|
|
482
|
-
const objectUrl = object.id?.href || "";
|
|
483
|
-
if (!objectUrl) return;
|
|
484
|
-
|
|
485
|
-
// Extract content
|
|
486
|
-
const contentHtml = object.content?.toString() || "";
|
|
487
|
-
const contentText = contentHtml.replace(/<[^>]*>/g, "").trim();
|
|
488
|
-
|
|
489
|
-
// Name (usually only on Article, not Note)
|
|
490
|
-
const name = object.name?.toString() || undefined;
|
|
491
|
-
const summary = object.summary?.toString() || undefined;
|
|
492
|
-
|
|
493
|
-
// Published date — Fedify returns Temporal.Instant
|
|
494
|
-
let published;
|
|
495
|
-
if (object.published) {
|
|
496
|
-
try {
|
|
497
|
-
published = new Date(Number(object.published.epochMilliseconds));
|
|
498
|
-
} catch {
|
|
499
|
-
published = new Date();
|
|
500
|
-
}
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
// Author avatar
|
|
504
|
-
let authorPhoto = "";
|
|
505
|
-
try {
|
|
506
|
-
if (actorObj.icon) {
|
|
507
|
-
const iconObj = await actorObj.icon;
|
|
508
|
-
authorPhoto = iconObj?.url?.href || "";
|
|
509
|
-
}
|
|
510
|
-
} catch {
|
|
511
|
-
/* remote fetch may fail */
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
// Tags / categories
|
|
515
|
-
const category = [];
|
|
516
|
-
try {
|
|
517
|
-
for await (const tag of object.getTags()) {
|
|
518
|
-
const tagName = tag.name?.toString();
|
|
519
|
-
if (tagName) category.push(tagName.replace(/^#/, ""));
|
|
520
|
-
}
|
|
521
|
-
} catch {
|
|
522
|
-
/* ignore */
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
// Attachments (photos, videos, audio)
|
|
526
|
-
const photo = [];
|
|
527
|
-
const video = [];
|
|
528
|
-
const audio = [];
|
|
529
|
-
try {
|
|
530
|
-
for await (const att of object.getAttachments()) {
|
|
531
|
-
const mediaType = att.mediaType?.toString() || "";
|
|
532
|
-
const url = att.url?.href || att.id?.href || "";
|
|
533
|
-
if (!url) continue;
|
|
534
|
-
if (mediaType.startsWith("image/")) photo.push(url);
|
|
535
|
-
else if (mediaType.startsWith("video/")) video.push(url);
|
|
536
|
-
else if (mediaType.startsWith("audio/")) audio.push(url);
|
|
537
|
-
}
|
|
538
|
-
} catch {
|
|
539
|
-
/* ignore */
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
const item = {
|
|
543
|
-
channelId,
|
|
544
|
-
feedId: null,
|
|
545
|
-
uid: objectUrl,
|
|
546
|
-
type: "entry",
|
|
547
|
-
url: objectUrl,
|
|
548
|
-
name,
|
|
549
|
-
content: contentHtml ? { text: contentText, html: contentHtml } : undefined,
|
|
550
|
-
summary,
|
|
551
|
-
published: published || new Date(),
|
|
552
|
-
author: {
|
|
553
|
-
name: actorName,
|
|
554
|
-
url: actorUrl,
|
|
555
|
-
photo: authorPhoto,
|
|
556
|
-
},
|
|
557
|
-
category,
|
|
558
|
-
photo,
|
|
559
|
-
video,
|
|
560
|
-
audio,
|
|
561
|
-
inReplyTo: inReplyTo ? [inReplyTo] : [],
|
|
562
|
-
source: {
|
|
563
|
-
type: "activitypub",
|
|
564
|
-
actorUrl,
|
|
565
|
-
},
|
|
566
|
-
readBy: [],
|
|
567
|
-
createdAt: new Date().toISOString(),
|
|
568
|
-
};
|
|
569
|
-
|
|
570
|
-
// Atomic upsert — prevents duplicates without a separate check+insert
|
|
571
|
-
await collections.microsub_items.updateOne(
|
|
572
|
-
{ channelId, uid: objectUrl },
|
|
573
|
-
{ $setOnInsert: item },
|
|
574
|
-
{ upsert: true },
|
|
575
|
-
);
|
|
576
|
-
}
|