@rmdes/indiekit-endpoint-activitypub 1.0.21 → 1.0.22

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 CHANGED
@@ -21,6 +21,16 @@ import {
21
21
  profileGetController,
22
22
  profilePostController,
23
23
  } from "./lib/controllers/profile.js";
24
+ import {
25
+ featuredGetController,
26
+ featuredPinController,
27
+ featuredUnpinController,
28
+ } from "./lib/controllers/featured.js";
29
+ import {
30
+ featuredTagsGetController,
31
+ featuredTagsAddController,
32
+ featuredTagsRemoveController,
33
+ } from "./lib/controllers/featured-tags.js";
24
34
  import {
25
35
  refollowPauseController,
26
36
  refollowResumeController,
@@ -42,6 +52,8 @@ const defaults = {
42
52
  activityRetentionDays: 90,
43
53
  storeRawActivities: false,
44
54
  redisUrl: "",
55
+ parallelWorkers: 5,
56
+ actorType: "Person",
45
57
  };
46
58
 
47
59
  export default class ActivityPubEndpoint {
@@ -136,6 +148,12 @@ export default class ActivityPubEndpoint {
136
148
  router.get("/admin/followers", followersController(mp));
137
149
  router.get("/admin/following", followingController(mp));
138
150
  router.get("/admin/activities", activitiesController(mp));
151
+ router.get("/admin/featured", featuredGetController(mp));
152
+ router.post("/admin/featured/pin", featuredPinController());
153
+ router.post("/admin/featured/unpin", featuredUnpinController());
154
+ router.get("/admin/tags", featuredTagsGetController(mp));
155
+ router.post("/admin/tags/add", featuredTagsAddController());
156
+ router.post("/admin/tags/remove", featuredTagsRemoveController());
139
157
  router.get("/admin/profile", profileGetController(mp));
140
158
  router.post("/admin/profile", profilePostController(mp));
141
159
  router.get("/admin/migrate", migrateGetController(mp, this.options));
@@ -266,7 +284,7 @@ export default class ActivityPubEndpoint {
266
284
 
267
285
  const ctx = self._federation.createContext(
268
286
  new URL(self._publicationUrl),
269
- {},
287
+ { handle, publicationUrl: self._publicationUrl },
270
288
  );
271
289
 
272
290
  // For replies, resolve the original post author for proper
@@ -327,11 +345,16 @@ export default class ActivityPubEndpoint {
327
345
  `[ActivityPub] Sending ${activity.constructor?.name || "activity"} for ${properties.url} to ${followerCount} followers`,
328
346
  );
329
347
 
330
- // Send to followers
348
+ // Send to followers via shared inboxes with collection sync (FEP-8fcf)
331
349
  await ctx.sendActivity(
332
350
  { identifier: handle },
333
351
  "followers",
334
352
  activity,
353
+ {
354
+ preferSharedInbox: true,
355
+ syncCollection: true,
356
+ orderingKey: properties.url,
357
+ },
335
358
  );
336
359
 
337
360
  // For replies, also deliver to the original post author's inbox
@@ -342,6 +365,7 @@ export default class ActivityPubEndpoint {
342
365
  { identifier: handle },
343
366
  replyToActor.recipient,
344
367
  activity,
368
+ { orderingKey: properties.url },
345
369
  );
346
370
  console.info(
347
371
  `[ActivityPub] Reply delivered to author: ${replyToActor.url}`,
@@ -408,7 +432,7 @@ export default class ActivityPubEndpoint {
408
432
  const handle = this.options.actor.handle;
409
433
  const ctx = this._federation.createContext(
410
434
  new URL(this._publicationUrl),
411
- {},
435
+ { handle, publicationUrl: this._publicationUrl },
412
436
  );
413
437
 
414
438
  // Resolve the remote actor to get their inbox
@@ -423,7 +447,9 @@ export default class ActivityPubEndpoint {
423
447
  object: new URL(actorUrl),
424
448
  });
425
449
 
426
- await ctx.sendActivity({ identifier: handle }, remoteActor, follow);
450
+ await ctx.sendActivity({ identifier: handle }, remoteActor, follow, {
451
+ orderingKey: actorUrl,
452
+ });
427
453
 
428
454
  // Store in ap_following
429
455
  const name =
@@ -502,7 +528,7 @@ export default class ActivityPubEndpoint {
502
528
  const handle = this.options.actor.handle;
503
529
  const ctx = this._federation.createContext(
504
530
  new URL(this._publicationUrl),
505
- {},
531
+ { handle, publicationUrl: this._publicationUrl },
506
532
  );
507
533
 
508
534
  const remoteActor = await ctx.lookupObject(actorUrl);
@@ -531,7 +557,9 @@ export default class ActivityPubEndpoint {
531
557
  object: follow,
532
558
  });
533
559
 
534
- await ctx.sendActivity({ identifier: handle }, remoteActor, undo);
560
+ await ctx.sendActivity({ identifier: handle }, remoteActor, undo, {
561
+ orderingKey: actorUrl,
562
+ });
535
563
  await this._collections.ap_following.deleteOne({ actorUrl });
536
564
 
537
565
  console.info(`[ActivityPub] Sent Undo(Follow) to ${actorUrl}`);
@@ -586,6 +614,8 @@ export default class ActivityPubEndpoint {
586
614
  Indiekit.addCollection("ap_keys");
587
615
  Indiekit.addCollection("ap_kv");
588
616
  Indiekit.addCollection("ap_profile");
617
+ Indiekit.addCollection("ap_featured");
618
+ Indiekit.addCollection("ap_featured_tags");
589
619
 
590
620
  // Store collection references (posts resolved lazily)
591
621
  const indiekitCollections = Indiekit.collections;
@@ -596,6 +626,8 @@ export default class ActivityPubEndpoint {
596
626
  ap_keys: indiekitCollections.get("ap_keys"),
597
627
  ap_kv: indiekitCollections.get("ap_kv"),
598
628
  ap_profile: indiekitCollections.get("ap_profile"),
629
+ ap_featured: indiekitCollections.get("ap_featured"),
630
+ ap_featured_tags: indiekitCollections.get("ap_featured_tags"),
599
631
  get posts() {
600
632
  return indiekitCollections.get("posts");
601
633
  },
@@ -652,6 +684,9 @@ export default class ActivityPubEndpoint {
652
684
  handle: this.options.actor.handle,
653
685
  storeRawActivities: this.options.storeRawActivities,
654
686
  redisUrl: this.options.redisUrl,
687
+ publicationUrl: this._publicationUrl,
688
+ parallelWorkers: this.options.parallelWorkers,
689
+ actorType: this.options.actorType,
655
690
  });
656
691
 
657
692
  this._federation = federation;
@@ -225,7 +225,7 @@ async function processOneFollow(options, entry) {
225
225
  const { federation, collections, handle, publicationUrl } = options;
226
226
 
227
227
  try {
228
- const ctx = federation.createContext(new URL(publicationUrl), {});
228
+ const ctx = federation.createContext(new URL(publicationUrl), { handle, publicationUrl });
229
229
 
230
230
  // Resolve the remote actor
231
231
  const remoteActor = await ctx.lookupObject(entry.actorUrl);
@@ -242,7 +242,9 @@ async function processOneFollow(options, entry) {
242
242
  object: new URL(canonicalUrl),
243
243
  });
244
244
 
245
- await ctx.sendActivity({ identifier: handle }, remoteActor, follow);
245
+ await ctx.sendActivity({ identifier: handle }, remoteActor, follow, {
246
+ orderingKey: canonicalUrl,
247
+ });
246
248
 
247
249
  // Mark as sent — update actorUrl to canonical form so Accept handler
248
250
  // can match when the remote server responds
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Featured tags controller — list, add, and remove featured hashtags.
3
+ */
4
+
5
+ export function featuredTagsGetController(mountPath) {
6
+ return async (request, response, next) => {
7
+ try {
8
+ const { application } = request.app.locals;
9
+ const collection = application?.collections?.get("ap_featured_tags");
10
+
11
+ const tags = collection
12
+ ? await collection.find().sort({ addedAt: -1 }).toArray()
13
+ : [];
14
+
15
+ response.render("activitypub-featured-tags", {
16
+ title:
17
+ response.locals.__("activitypub.featuredTags") || "Featured Tags",
18
+ tags,
19
+ mountPath,
20
+ });
21
+ } catch (error) {
22
+ next(error);
23
+ }
24
+ };
25
+ }
26
+
27
+ export function featuredTagsAddController() {
28
+ return async (request, response, next) => {
29
+ try {
30
+ const { application } = request.app.locals;
31
+ const collection = application?.collections?.get("ap_featured_tags");
32
+ if (!collection) return response.status(500).send("No collection");
33
+
34
+ let { tag } = request.body;
35
+ if (!tag) return response.status(400).send("Missing tag");
36
+
37
+ // Normalize: strip leading # and lowercase
38
+ tag = tag.replace(/^#/, "").toLowerCase().trim();
39
+ if (!tag) return response.status(400).send("Invalid tag");
40
+
41
+ await collection.updateOne(
42
+ { tag },
43
+ { $set: { tag, addedAt: new Date().toISOString() } },
44
+ { upsert: true },
45
+ );
46
+
47
+ response.redirect("back");
48
+ } catch (error) {
49
+ next(error);
50
+ }
51
+ };
52
+ }
53
+
54
+ export function featuredTagsRemoveController() {
55
+ return async (request, response, next) => {
56
+ try {
57
+ const { application } = request.app.locals;
58
+ const collection = application?.collections?.get("ap_featured_tags");
59
+ if (!collection) return response.status(500).send("No collection");
60
+
61
+ const { tag } = request.body;
62
+ if (!tag) return response.status(400).send("Missing tag");
63
+
64
+ await collection.deleteOne({ tag });
65
+
66
+ response.redirect("back");
67
+ } catch (error) {
68
+ next(error);
69
+ }
70
+ };
71
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Featured (pinned) posts controller — list, pin, and unpin posts.
3
+ */
4
+ const MAX_PINS = 5;
5
+
6
+ export function featuredGetController(mountPath) {
7
+ return async (request, response, next) => {
8
+ try {
9
+ const { application } = request.app.locals;
10
+ const featuredCollection = application?.collections?.get("ap_featured");
11
+ const postsCollection = application?.collections?.get("posts");
12
+
13
+ const pinnedDocs = featuredCollection
14
+ ? await featuredCollection.find().sort({ pinnedAt: -1 }).toArray()
15
+ : [];
16
+
17
+ // Enrich pinned posts with title/type from posts collection
18
+ const pinned = [];
19
+ for (const doc of pinnedDocs) {
20
+ let title = doc.postUrl;
21
+ let postType = "note";
22
+ if (postsCollection) {
23
+ const post = await postsCollection.findOne({
24
+ "properties.url": doc.postUrl,
25
+ });
26
+ if (post?.properties) {
27
+ title =
28
+ post.properties.name ||
29
+ post.properties.content?.text?.slice(0, 80) ||
30
+ doc.postUrl;
31
+ postType = post.properties["post-type"] || "note";
32
+ }
33
+ }
34
+ pinned.push({ ...doc, title, postType });
35
+ }
36
+
37
+ // Get recent posts for the "pin" dropdown
38
+ const recentPosts = postsCollection
39
+ ? await postsCollection
40
+ .find()
41
+ .sort({ "properties.published": -1 })
42
+ .limit(20)
43
+ .toArray()
44
+ : [];
45
+
46
+ const pinnedUrls = new Set(pinnedDocs.map((d) => d.postUrl));
47
+ const availablePosts = recentPosts
48
+ .filter((p) => p.properties?.url && !pinnedUrls.has(p.properties.url))
49
+ .map((p) => ({
50
+ url: p.properties.url,
51
+ title:
52
+ p.properties.name ||
53
+ p.properties.content?.text?.slice(0, 80) ||
54
+ p.properties.url,
55
+ postType: p.properties["post-type"] || "note",
56
+ }));
57
+
58
+ response.render("activitypub-featured", {
59
+ title: response.locals.__("activitypub.featured") || "Pinned Posts",
60
+ pinned,
61
+ availablePosts,
62
+ maxPins: MAX_PINS,
63
+ canPin: pinned.length < MAX_PINS,
64
+ mountPath,
65
+ });
66
+ } catch (error) {
67
+ next(error);
68
+ }
69
+ };
70
+ }
71
+
72
+ export function featuredPinController() {
73
+ return async (request, response, next) => {
74
+ try {
75
+ const { application } = request.app.locals;
76
+ const collection = application?.collections?.get("ap_featured");
77
+ if (!collection) return response.status(500).send("No collection");
78
+
79
+ const { postUrl } = request.body;
80
+ if (!postUrl) return response.status(400).send("Missing postUrl");
81
+
82
+ const count = await collection.countDocuments();
83
+ if (count >= MAX_PINS) {
84
+ return response.status(400).send("Maximum pins reached");
85
+ }
86
+
87
+ await collection.updateOne(
88
+ { postUrl },
89
+ { $set: { postUrl, pinnedAt: new Date().toISOString() } },
90
+ { upsert: true },
91
+ );
92
+
93
+ response.redirect("back");
94
+ } catch (error) {
95
+ next(error);
96
+ }
97
+ };
98
+ }
99
+
100
+ export function featuredUnpinController() {
101
+ return async (request, response, next) => {
102
+ try {
103
+ const { application } = request.app.locals;
104
+ const collection = application?.collections?.get("ap_featured");
105
+ if (!collection) return response.status(500).send("No collection");
106
+
107
+ const { postUrl } = request.body;
108
+ if (!postUrl) return response.status(400).send("Missing postUrl");
109
+
110
+ await collection.deleteOne({ postUrl });
111
+
112
+ response.redirect("back");
113
+ } catch (error) {
114
+ next(error);
115
+ }
116
+ };
117
+ }
@@ -36,8 +36,15 @@ export function profilePostController(mountPath) {
36
36
  return next(new Error("ap_profile collection not available"));
37
37
  }
38
38
 
39
- const { name, summary, url, icon, image, manuallyApprovesFollowers } =
40
- request.body;
39
+ const {
40
+ name,
41
+ summary,
42
+ url,
43
+ icon,
44
+ image,
45
+ manuallyApprovesFollowers,
46
+ authorizedFetch,
47
+ } = request.body;
41
48
 
42
49
  const update = {
43
50
  $set: {
@@ -47,6 +54,7 @@ export function profilePostController(mountPath) {
47
54
  icon: icon?.trim() || "",
48
55
  image: image?.trim() || "",
49
56
  manuallyApprovesFollowers: manuallyApprovesFollowers === "true",
57
+ authorizedFetch: authorizedFetch === "true",
50
58
  updatedAt: new Date().toISOString(),
51
59
  },
52
60
  };
@@ -7,13 +7,23 @@
7
7
  */
8
8
 
9
9
  import { AsyncLocalStorage } from "node:async_hooks";
10
+ import { createRequire } from "node:module";
10
11
  import { Temporal } from "@js-temporal/polyfill";
11
12
  import {
13
+ Application,
14
+ Article,
15
+ Create,
12
16
  Endpoints,
17
+ Group,
18
+ Hashtag,
13
19
  Image,
14
20
  InProcessMessageQueue,
21
+ Note,
22
+ Organization,
23
+ ParallelMessageQueue,
15
24
  Person,
16
25
  PropertyValue,
26
+ Service,
17
27
  createFederation,
18
28
  exportJwk,
19
29
  generateCryptoKeyPair,
@@ -25,6 +35,7 @@ import { RedisMessageQueue } from "@fedify/redis";
25
35
  import Redis from "ioredis";
26
36
  import { MongoKvStore } from "./kv-store.js";
27
37
  import { registerInboxListeners } from "./inbox-listeners.js";
38
+ import { jf2ToAS2Activity, resolvePostUrl } from "./jf2-to-as2.js";
28
39
 
29
40
  /**
30
41
  * Create and configure a Fedify Federation instance.
@@ -46,8 +57,15 @@ export function setupFederation(options) {
46
57
  handle,
47
58
  storeRawActivities = false,
48
59
  redisUrl = "",
60
+ publicationUrl = "",
61
+ parallelWorkers = 5,
62
+ actorType = "Person",
49
63
  } = options;
50
64
 
65
+ // Map config string to Fedify actor class
66
+ const actorTypeMap = { Person, Service, Application, Organization, Group };
67
+ const ActorClass = actorTypeMap[actorType] || Person;
68
+
51
69
  // Configure LogTape for Fedify delivery logging (once per process)
52
70
  if (!_logtapeConfigured) {
53
71
  _logtapeConfigured = true;
@@ -71,8 +89,16 @@ export function setupFederation(options) {
71
89
 
72
90
  let queue;
73
91
  if (redisUrl) {
74
- queue = new RedisMessageQueue(() => new Redis(redisUrl));
75
- console.info("[ActivityPub] Using Redis message queue");
92
+ const redisQueue = new RedisMessageQueue(() => new Redis(redisUrl));
93
+ if (parallelWorkers > 1) {
94
+ queue = new ParallelMessageQueue(redisQueue, parallelWorkers);
95
+ console.info(
96
+ `[ActivityPub] Using Redis message queue with ${parallelWorkers} parallel workers`,
97
+ );
98
+ } else {
99
+ queue = redisQueue;
100
+ console.info("[ActivityPub] Using Redis message queue (single worker)");
101
+ }
76
102
  } else {
77
103
  queue = new InProcessMessageQueue();
78
104
  console.warn(
@@ -90,6 +116,26 @@ export function setupFederation(options) {
90
116
  .setActorDispatcher(
91
117
  `${mountPath}/users/{identifier}`,
92
118
  async (ctx, identifier) => {
119
+ // Instance actor: Application-type actor for the domain itself
120
+ // Required for authorized fetch to avoid infinite loops
121
+ const hostname = ctx.url?.hostname || "";
122
+ if (identifier === hostname) {
123
+ const keyPairs = await ctx.getActorKeyPairs(identifier);
124
+ const appOptions = {
125
+ id: ctx.getActorUri(identifier),
126
+ preferredUsername: hostname,
127
+ name: hostname,
128
+ inbox: ctx.getInboxUri(identifier),
129
+ outbox: ctx.getOutboxUri(identifier),
130
+ endpoints: new Endpoints({ sharedInbox: ctx.getInboxUri() }),
131
+ };
132
+ if (keyPairs.length > 0) {
133
+ appOptions.publicKey = keyPairs[0].cryptographicKey;
134
+ appOptions.assertionMethods = keyPairs.map((k) => k.multikey);
135
+ }
136
+ return new Application(appOptions);
137
+ }
138
+
93
139
  if (identifier !== handle) return null;
94
140
 
95
141
  const profile = await getProfile(collections);
@@ -104,6 +150,9 @@ export function setupFederation(options) {
104
150
  outbox: ctx.getOutboxUri(identifier),
105
151
  followers: ctx.getFollowersUri(identifier),
106
152
  following: ctx.getFollowingUri(identifier),
153
+ liked: ctx.getLikedUri(identifier),
154
+ featured: ctx.getFeaturedUri(identifier),
155
+ featuredTags: ctx.getFeaturedTagsUri(identifier),
107
156
  endpoints: new Endpoints({ sharedInbox: ctx.getInboxUri() }),
108
157
  manuallyApprovesFollowers:
109
158
  profile.manuallyApprovesFollowers || false,
@@ -148,12 +197,35 @@ export function setupFederation(options) {
148
197
  personOptions.published = Temporal.Instant.from(profile.createdAt);
149
198
  }
150
199
 
151
- return new Person(personOptions);
200
+ return new ActorClass(personOptions);
152
201
  },
153
202
  )
154
- .mapHandle((_ctx, username) => (username === handle ? handle : null))
203
+ .mapHandle((_ctx, username) => {
204
+ if (username === handle) return handle;
205
+ // Accept hostname as valid identifier for instance actor
206
+ if (publicationUrl) {
207
+ try {
208
+ const hostname = new URL(publicationUrl).hostname;
209
+ if (username === hostname) return hostname;
210
+ } catch { /* ignore */ }
211
+ }
212
+ return null;
213
+ })
214
+ .mapAlias((_ctx, alias) => {
215
+ // Resolve profile URL and /@handle patterns via WebFinger
216
+ if (!publicationUrl) return null;
217
+ try {
218
+ const pub = new URL(publicationUrl);
219
+ if (alias.hostname !== pub.hostname) return null;
220
+ const path = alias.pathname.replace(/\/$/, "");
221
+ if (path === "" || path === `/@${handle}`) return handle;
222
+ } catch { /* ignore */ }
223
+ return null;
224
+ })
155
225
  .setKeyPairsDispatcher(async (ctx, identifier) => {
156
- if (identifier !== handle) return [];
226
+ // Allow key pairs for both the main actor and instance actor
227
+ const hostname = ctx.url?.hostname || "";
228
+ if (identifier !== handle && identifier !== hostname) return [];
157
229
 
158
230
  const keyPairs = [];
159
231
 
@@ -224,6 +296,16 @@ export function setupFederation(options) {
224
296
  }
225
297
 
226
298
  return keyPairs;
299
+ })
300
+ .authorize(async (ctx, identifier, signedKey, _signedKeyOwner) => {
301
+ // Instance actor is always publicly accessible (prevents infinite loops)
302
+ const hostname = ctx.url?.hostname || "";
303
+ if (identifier === hostname) return true;
304
+ // Check if authorized fetch is enabled
305
+ const profile = await getProfile(collections);
306
+ if (!profile.authorizedFetch) return true;
307
+ // When enabled, require a valid HTTP Signature
308
+ return signedKey != null;
227
309
  });
228
310
 
229
311
  // --- Inbox listeners ---
@@ -248,8 +330,22 @@ export function setupFederation(options) {
248
330
  setupFollowers(federation, mountPath, handle, collections);
249
331
  setupFollowing(federation, mountPath, handle, collections);
250
332
  setupOutbox(federation, mountPath, handle, collections);
333
+ setupLiked(federation, mountPath, handle, collections);
334
+ setupFeatured(federation, mountPath, handle, collections, publicationUrl);
335
+ setupFeaturedTags(federation, mountPath, handle, collections, publicationUrl);
336
+
337
+ // --- Object dispatchers (make posts dereferenceable) ---
338
+ setupObjectDispatchers(federation, mountPath, handle, collections, publicationUrl);
251
339
 
252
340
  // --- NodeInfo ---
341
+ let softwareVersion = { major: 1, minor: 0, patch: 0 };
342
+ try {
343
+ const require = createRequire(import.meta.url);
344
+ const pkg = require("@indiekit/indiekit/package.json");
345
+ const [major, minor, patch] = pkg.version.split(/[.-]/).map(Number);
346
+ if (!Number.isNaN(major)) softwareVersion = { major, minor: minor || 0, patch: patch || 0 };
347
+ } catch { /* fallback to 1.0.0 */ }
348
+
253
349
  federation.setNodeInfoDispatcher("/nodeinfo/2.1", async () => {
254
350
  const postsCount = collections.posts
255
351
  ? await collections.posts.countDocuments()
@@ -258,7 +354,7 @@ export function setupFederation(options) {
258
354
  return {
259
355
  software: {
260
356
  name: "indiekit",
261
- version: { major: 1, minor: 0, patch: 0 },
357
+ version: softwareVersion,
262
358
  },
263
359
  protocols: ["activitypub"],
264
360
  usage: {
@@ -288,8 +384,29 @@ function setupFollowers(federation, mountPath, handle, collections) {
288
384
  `${mountPath}/users/{identifier}/followers`,
289
385
  async (ctx, identifier, cursor) => {
290
386
  if (identifier !== handle) return null;
387
+
388
+ // One-shot collection: when cursor is null, return ALL followers
389
+ // as Recipient objects so sendActivity("followers") can deliver.
390
+ // See: https://fedify.dev/manual/collections#one-shot-followers-collection-for-gathering-recipients
391
+ if (cursor == null) {
392
+ const docs = await collections.ap_followers
393
+ .find()
394
+ .sort({ followedAt: -1 })
395
+ .toArray();
396
+ return {
397
+ items: docs.map((f) => ({
398
+ id: new URL(f.actorUrl),
399
+ inboxId: f.inbox ? new URL(f.inbox) : null,
400
+ endpoints: f.sharedInbox
401
+ ? { sharedInbox: new URL(f.sharedInbox) }
402
+ : null,
403
+ })),
404
+ };
405
+ }
406
+
407
+ // Paginated collection: for remote browsing of /followers endpoint
291
408
  const pageSize = 20;
292
- const skip = cursor ? Number.parseInt(cursor, 10) : 0;
409
+ const skip = Number.parseInt(cursor, 10);
293
410
  const docs = await collections.ap_followers
294
411
  .find()
295
412
  .sort({ followedAt: -1 })
@@ -342,6 +459,118 @@ function setupFollowing(federation, mountPath, handle, collections) {
342
459
  .setFirstCursor(async () => "0");
343
460
  }
344
461
 
462
+ function setupLiked(federation, mountPath, handle, collections) {
463
+ federation
464
+ .setLikedDispatcher(
465
+ `${mountPath}/users/{identifier}/liked`,
466
+ async (ctx, identifier, cursor) => {
467
+ if (identifier !== handle) return null;
468
+ if (!collections.posts) return { items: [] };
469
+
470
+ const pageSize = 20;
471
+ const skip = cursor ? Number.parseInt(cursor, 10) : 0;
472
+ const query = { "properties.post-type": "like" };
473
+ const docs = await collections.posts
474
+ .find(query)
475
+ .sort({ "properties.published": -1 })
476
+ .skip(skip)
477
+ .limit(pageSize)
478
+ .toArray();
479
+ const total = await collections.posts.countDocuments(query);
480
+
481
+ const items = docs
482
+ .map((d) => {
483
+ const likeOf = d.properties?.["like-of"];
484
+ return likeOf ? new URL(likeOf) : null;
485
+ })
486
+ .filter(Boolean);
487
+
488
+ return {
489
+ items,
490
+ nextCursor:
491
+ skip + pageSize < total ? String(skip + pageSize) : null,
492
+ };
493
+ },
494
+ )
495
+ .setCounter(async (ctx, identifier) => {
496
+ if (identifier !== handle) return 0;
497
+ if (!collections.posts) return 0;
498
+ return await collections.posts.countDocuments({
499
+ "properties.post-type": "like",
500
+ });
501
+ })
502
+ .setFirstCursor(async () => "0");
503
+ }
504
+
505
+ function setupFeatured(federation, mountPath, handle, collections, publicationUrl) {
506
+ federation.setFeaturedDispatcher(
507
+ `${mountPath}/users/{identifier}/featured`,
508
+ async (ctx, identifier) => {
509
+ if (identifier !== handle) return null;
510
+ if (!collections.ap_featured) return { items: [] };
511
+
512
+ const docs = await collections.ap_featured
513
+ .find()
514
+ .sort({ pinnedAt: -1 })
515
+ .toArray();
516
+
517
+ // Convert pinned post URLs to Fedify Note/Article objects
518
+ const items = [];
519
+ for (const doc of docs) {
520
+ if (!collections.posts) continue;
521
+ const post = await collections.posts.findOne({
522
+ "properties.url": doc.postUrl,
523
+ });
524
+ if (!post) continue;
525
+ const actorUrl = ctx.getActorUri(identifier).href;
526
+ const activity = jf2ToAS2Activity(
527
+ post.properties,
528
+ actorUrl,
529
+ publicationUrl,
530
+ );
531
+ if (activity instanceof Create) {
532
+ const obj = await activity.getObject();
533
+ if (obj) items.push(obj);
534
+ }
535
+ }
536
+
537
+ return { items };
538
+ },
539
+ );
540
+ }
541
+
542
+ function setupFeaturedTags(federation, mountPath, handle, collections, publicationUrl) {
543
+ federation.setFeaturedTagsDispatcher(
544
+ `${mountPath}/users/{identifier}/tags`,
545
+ async (ctx, identifier) => {
546
+ if (identifier !== handle) return null;
547
+ if (!collections.ap_featured_tags) return { items: [] };
548
+
549
+ const docs = await collections.ap_featured_tags
550
+ .find()
551
+ .sort({ addedAt: -1 })
552
+ .toArray();
553
+
554
+ const baseUrl = publicationUrl
555
+ ? publicationUrl.replace(/\/$/, "")
556
+ : ctx.url.origin;
557
+
558
+ const items = docs.map(
559
+ (doc) =>
560
+ new Hashtag({
561
+ name: `#${doc.tag}`,
562
+ href: new URL(
563
+ `/categories/${encodeURIComponent(doc.tag)}`,
564
+ baseUrl,
565
+ ),
566
+ }),
567
+ );
568
+
569
+ return { items };
570
+ },
571
+ );
572
+ }
573
+
345
574
  function setupOutbox(federation, mountPath, handle, collections) {
346
575
  federation
347
576
  .setOutboxDispatcher(
@@ -394,6 +623,41 @@ function setupOutbox(federation, mountPath, handle, collections) {
394
623
  .setFirstCursor(async () => "0");
395
624
  }
396
625
 
626
+ function setupObjectDispatchers(federation, mountPath, handle, collections, publicationUrl) {
627
+ // Shared lookup: find post by URL path, convert to Fedify Note/Article
628
+ async function resolvePost(ctx, id) {
629
+ if (!collections.posts || !publicationUrl) return null;
630
+ const postUrl = `${publicationUrl.replace(/\/$/, "")}/${id}`;
631
+ const post = await collections.posts.findOne({ "properties.url": postUrl });
632
+ if (!post) return null;
633
+ const actorUrl = ctx.getActorUri(handle).href;
634
+ const activity = jf2ToAS2Activity(post.properties, actorUrl, publicationUrl);
635
+ // Only Create activities wrap Note/Article objects
636
+ if (!(activity instanceof Create)) return null;
637
+ return await activity.getObject();
638
+ }
639
+
640
+ // Note dispatcher — handles note, reply, bookmark, jam, rsvp, checkin
641
+ federation.setObjectDispatcher(
642
+ Note,
643
+ `${mountPath}/objects/note/{+id}`,
644
+ async (ctx, { id }) => {
645
+ const obj = await resolvePost(ctx, id);
646
+ return obj instanceof Note ? obj : null;
647
+ },
648
+ );
649
+
650
+ // Article dispatcher
651
+ federation.setObjectDispatcher(
652
+ Article,
653
+ `${mountPath}/objects/article/{+id}`,
654
+ async (ctx, { id }) => {
655
+ const obj = await resolvePost(ctx, id);
656
+ return obj instanceof Article ? obj : null;
657
+ },
658
+ );
659
+ }
660
+
397
661
  // --- Helpers ---
398
662
 
399
663
  async function getProfile(collections) {
@@ -73,6 +73,7 @@ export function registerInboxListeners(inboxChain, options) {
73
73
  actor: ctx.getActorUri(handle),
74
74
  object: follow,
75
75
  }),
76
+ { orderingKey: followerUrl },
76
77
  );
77
78
 
78
79
  await logActivity(collections, storeRawActivities, {
package/locales/en.json CHANGED
@@ -4,6 +4,8 @@
4
4
  "followers": "Followers",
5
5
  "following": "Following",
6
6
  "activities": "Activity log",
7
+ "featured": "Pinned Posts",
8
+ "featuredTags": "Featured Tags",
7
9
  "recentActivity": "Recent activity",
8
10
  "noActivity": "No activity yet. Once your actor is federated, interactions will appear here.",
9
11
  "noFollowers": "No followers yet.",
@@ -36,6 +38,8 @@
36
38
  "imageHint": "URL to a banner image shown at the top of your profile",
37
39
  "manualApprovalLabel": "Manually approve followers",
38
40
  "manualApprovalHint": "When enabled, follow requests require your approval before they take effect",
41
+ "authorizedFetchLabel": "Require authorized fetch (secure mode)",
42
+ "authorizedFetchHint": "When enabled, only servers with valid HTTP Signatures can fetch your actor and collections. This improves privacy but may reduce compatibility with some clients.",
39
43
  "save": "Save profile",
40
44
  "saved": "Profile saved. Changes are now visible to the fediverse."
41
45
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-activitypub",
3
- "version": "1.0.21",
3
+ "version": "1.0.22",
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",
@@ -37,8 +37,8 @@
37
37
  "url": "https://github.com/rmdes/indiekit-endpoint-activitypub/issues"
38
38
  },
39
39
  "dependencies": {
40
- "@fedify/express": "^1.9.0",
41
- "@fedify/fedify": "^1.10.0",
40
+ "@fedify/express": "^1.10.3",
41
+ "@fedify/fedify": "^1.10.3",
42
42
  "@fedify/redis": "^1.10.3",
43
43
  "@js-temporal/polyfill": "^0.5.0",
44
44
  "express": "^5.0.0",
@@ -0,0 +1,43 @@
1
+ {% extends "document.njk" %}
2
+
3
+ {% from "heading/macro.njk" import heading with context %}
4
+ {% from "prose/macro.njk" import prose with context %}
5
+
6
+ {% block content %}
7
+ {{ heading({ text: title, level: 1, parent: { text: __("activitypub.title"), href: mountPath } }) }}
8
+
9
+ {% if tags.length > 0 %}
10
+ <table class="table">
11
+ <thead>
12
+ <tr>
13
+ <th>Tag</th>
14
+ <th>Added</th>
15
+ <th></th>
16
+ </tr>
17
+ </thead>
18
+ <tbody>
19
+ {% for item in tags %}
20
+ <tr>
21
+ <td>#{{ item.tag }}</td>
22
+ <td>{% if item.addedAt %}{{ item.addedAt | date("PPp") }}{% endif %}</td>
23
+ <td>
24
+ <form method="post" action="{{ mountPath }}/admin/tags/remove">
25
+ <input type="hidden" name="tag" value="{{ item.tag }}">
26
+ <button type="submit" class="button button--small">Remove</button>
27
+ </form>
28
+ </td>
29
+ </tr>
30
+ {% endfor %}
31
+ </tbody>
32
+ </table>
33
+ {% else %}
34
+ {{ prose({ text: "No featured tags yet. Add a hashtag to help others discover your content." }) }}
35
+ {% endif %}
36
+
37
+ <h2>Add a featured tag</h2>
38
+ <form method="post" action="{{ mountPath }}/admin/tags/add">
39
+ <label for="tag">Hashtag</label>
40
+ <input type="text" id="tag" name="tag" placeholder="indieweb" required>
41
+ <button type="submit" class="button">Add tag</button>
42
+ </form>
43
+ {% endblock %}
@@ -0,0 +1,52 @@
1
+ {% extends "document.njk" %}
2
+
3
+ {% from "heading/macro.njk" import heading with context %}
4
+ {% from "prose/macro.njk" import prose with context %}
5
+
6
+ {% block content %}
7
+ {{ heading({ text: title, level: 1, parent: { text: __("activitypub.title"), href: mountPath } }) }}
8
+
9
+ {% if pinned.length > 0 %}
10
+ <table class="table">
11
+ <thead>
12
+ <tr>
13
+ <th>Post</th>
14
+ <th>Type</th>
15
+ <th>Pinned</th>
16
+ <th></th>
17
+ </tr>
18
+ </thead>
19
+ <tbody>
20
+ {% for item in pinned %}
21
+ <tr>
22
+ <td><a href="{{ item.postUrl }}">{{ item.title }}</a></td>
23
+ <td>{{ item.postType }}</td>
24
+ <td>{% if item.pinnedAt %}{{ item.pinnedAt | date("PPp") }}{% endif %}</td>
25
+ <td>
26
+ <form method="post" action="{{ mountPath }}/admin/featured/unpin">
27
+ <input type="hidden" name="postUrl" value="{{ item.postUrl }}">
28
+ <button type="submit" class="button button--small">Unpin</button>
29
+ </form>
30
+ </td>
31
+ </tr>
32
+ {% endfor %}
33
+ </tbody>
34
+ </table>
35
+ {% else %}
36
+ {{ prose({ text: "No pinned posts yet." }) }}
37
+ {% endif %}
38
+
39
+ {% if canPin and availablePosts.length > 0 %}
40
+ <h2>Pin a post ({{ pinned.length }}/{{ maxPins }})</h2>
41
+ <form method="post" action="{{ mountPath }}/admin/featured/pin">
42
+ <select name="postUrl">
43
+ {% for post in availablePosts %}
44
+ <option value="{{ post.url }}">{{ post.title }} ({{ post.postType }})</option>
45
+ {% endfor %}
46
+ </select>
47
+ <button type="submit" class="button">Pin</button>
48
+ </form>
49
+ {% elif not canPin %}
50
+ {{ prose({ text: "Maximum of " + maxPins + " pinned posts reached. Unpin one to add another." }) }}
51
+ {% endif %}
52
+ {% endblock %}
@@ -69,6 +69,18 @@
69
69
  values: ["true"] if profile.manuallyApprovesFollowers else []
70
70
  }) }}
71
71
 
72
+ {{ checkboxes({
73
+ name: "authorizedFetch",
74
+ items: [
75
+ {
76
+ label: __("activitypub.profile.authorizedFetchLabel"),
77
+ value: "true",
78
+ hint: __("activitypub.profile.authorizedFetchHint")
79
+ }
80
+ ],
81
+ values: ["true"] if profile.authorizedFetch else []
82
+ }) }}
83
+
72
84
  {{ button({ text: __("activitypub.profile.save") }) }}
73
85
  </form>
74
86
  {% endblock %}