@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/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
+ }
@@ -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) => new PropertyValue({ name: att.name, value: att.value }),
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, "&amp;")
710
+ .replace(/</g, "&lt;")
711
+ .replace(/>/g, "&gt;")
712
+ .replace(/"/g, "&quot;");
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 = {
@@ -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 actorName = actorUrl;
229
+ let actorObj;
214
230
  try {
215
- const actorObj = await like.getActor();
216
- actorName =
217
- actorObj?.name?.toString() ||
218
- actorObj?.preferredUsername?.toString() ||
219
- actorUrl;
231
+ actorObj = await like.getActor();
220
232
  } catch {
221
- /* actor not dereferenceable — use URL */
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
- // Only log boosts of our own content
269
+ const actorUrl = announce.actorId?.href || "";
238
270
  const pubUrl = collections._publicationUrl;
239
- if (!objectId || (pubUrl && !objectId.startsWith(pubUrl))) return;
240
271
 
241
- const actorUrl = announce.actorId?.href || "";
242
- let actorName = actorUrl;
243
- try {
244
- const actorObj = await announce.getActor();
245
- actorName =
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
- } catch {
250
- /* actor not dereferenceable — use URL */
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
- await logActivity(collections, storeRawActivities, {
254
- direction: "inbound",
255
- type: "Announce",
256
- actorUrl,
257
- actorName,
258
- objectUrl: objectId,
259
- summary: `${actorName} boosted ${objectId}`,
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
- // Remote actor updated their profile refresh stored follower data
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
- }