@rmdes/indiekit-endpoint-activitypub 1.1.16 → 1.1.18

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/README.md CHANGED
@@ -93,6 +93,7 @@ export default {
93
93
  | `redisUrl` | string | `""` | Redis connection URL for delivery queue |
94
94
  | `parallelWorkers` | number | `5` | Number of parallel delivery workers (requires Redis) |
95
95
  | `actorType` | string | `"Person"` | Actor type: `Person`, `Service`, `Organization`, or `Group` |
96
+ | `logLevel` | string | `"warning"` | Fedify log level: `"debug"`, `"info"`, `"warning"`, `"error"`, `"fatal"` |
96
97
  | `timelineRetention` | number | `1000` | Maximum timeline items to keep (0 = unlimited) |
97
98
 
98
99
  ### Redis (Recommended for Production)
package/assets/reader.css CHANGED
@@ -4,6 +4,54 @@
4
4
  * Uses Indiekit CSS custom properties for automatic dark mode support
5
5
  */
6
6
 
7
+ /* ==========================================================================
8
+ Fediverse Lookup
9
+ ========================================================================== */
10
+
11
+ .ap-lookup {
12
+ display: flex;
13
+ gap: var(--space-xs);
14
+ margin-bottom: var(--space-m);
15
+ }
16
+
17
+ .ap-lookup__input {
18
+ flex: 1;
19
+ padding: var(--space-s) var(--space-m);
20
+ border: var(--border-width-thin) solid var(--color-outline);
21
+ border-radius: var(--border-radius-small);
22
+ background: var(--color-offset);
23
+ color: var(--color-on-background);
24
+ font-size: var(--font-size-m);
25
+ font-family: inherit;
26
+ }
27
+
28
+ .ap-lookup__input::placeholder {
29
+ color: var(--color-on-offset);
30
+ }
31
+
32
+ .ap-lookup__input:focus {
33
+ outline: 2px solid var(--color-primary);
34
+ outline-offset: -1px;
35
+ border-color: var(--color-primary);
36
+ }
37
+
38
+ .ap-lookup__btn {
39
+ padding: var(--space-s) var(--space-m);
40
+ border: var(--border-width-thin) solid var(--color-primary);
41
+ border-radius: var(--border-radius-small);
42
+ background: var(--color-primary);
43
+ color: var(--color-on-primary);
44
+ font-size: var(--font-size-m);
45
+ font-family: inherit;
46
+ font-weight: 600;
47
+ cursor: pointer;
48
+ white-space: nowrap;
49
+ }
50
+
51
+ .ap-lookup__btn:hover {
52
+ opacity: 0.9;
53
+ }
54
+
7
55
  /* ==========================================================================
8
56
  Tab Navigation
9
57
  ========================================================================== */
package/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import express from "express";
2
2
 
3
- import { setupFederation } from "./lib/federation-setup.js";
3
+ import { setupFederation, buildPersonActor } from "./lib/federation-setup.js";
4
4
  import {
5
5
  createFedifyMiddleware,
6
6
  } from "./lib/federation-bridge.js";
@@ -57,6 +57,7 @@ import {
57
57
  featuredTagsAddController,
58
58
  featuredTagsRemoveController,
59
59
  } from "./lib/controllers/featured-tags.js";
60
+ import { resolveController } from "./lib/controllers/resolve.js";
60
61
  import {
61
62
  refollowPauseController,
62
63
  refollowResumeController,
@@ -81,6 +82,7 @@ const defaults = {
81
82
  redisUrl: "",
82
83
  parallelWorkers: 5,
83
84
  actorType: "Person",
85
+ logLevel: "warning",
84
86
  timelineRetention: 1000,
85
87
  notificationRetentionDays: 30,
86
88
  };
@@ -202,6 +204,7 @@ export default class ActivityPubEndpoint {
202
204
  router.post("/admin/reader/unlike", unlikeController(mp, this));
203
205
  router.post("/admin/reader/boost", boostController(mp, this));
204
206
  router.post("/admin/reader/unboost", unboostController(mp, this));
207
+ router.get("/admin/reader/resolve", resolveController(mp, this));
205
208
  router.get("/admin/reader/profile", remoteProfileController(mp, this));
206
209
  router.get("/admin/reader/post", postDetailController(mp, this));
207
210
  router.post("/admin/reader/follow", followController(mp, this));
@@ -687,9 +690,15 @@ export default class ActivityPubEndpoint {
687
690
  { handle, publicationUrl: this._publicationUrl },
688
691
  );
689
692
 
690
- // Retrieve the full actor from the dispatcher (same object remote
691
- // servers will get when they re-fetch the actor URL)
692
- const actor = await ctx.getActor(handle);
693
+ // Build the full actor object (same as what the dispatcher serves).
694
+ // Note: ctx.getActor() only exists on RequestContext, not the base
695
+ // Context returned by createContext(), so we use the shared helper.
696
+ const actor = await buildPersonActor(
697
+ ctx,
698
+ handle,
699
+ this._collections,
700
+ this.options.actorType,
701
+ );
693
702
  if (!actor) {
694
703
  console.warn("[ActivityPub] broadcastActorUpdate: could not build actor");
695
704
  return;
@@ -889,6 +898,7 @@ export default class ActivityPubEndpoint {
889
898
  publicationUrl: this._publicationUrl,
890
899
  parallelWorkers: this.options.parallelWorkers,
891
900
  actorType: this.options.actorType,
901
+ logLevel: this.options.logLevel,
892
902
  });
893
903
 
894
904
  this._federation = federation;
@@ -32,7 +32,7 @@ export function followersController(mountPath) {
32
32
  const cursor = buildCursor(page, totalPages, mountPath + "/admin/followers");
33
33
 
34
34
  response.render("activitypub-followers", {
35
- title: response.locals.__("activitypub.followers"),
35
+ title: `${totalCount} ${response.locals.__("activitypub.followers")}`,
36
36
  followers,
37
37
  followerCount: totalCount,
38
38
  mountPath,
@@ -32,7 +32,7 @@ export function followingController(mountPath) {
32
32
  const cursor = buildCursor(page, totalPages, mountPath + "/admin/following");
33
33
 
34
34
  response.render("activitypub-following", {
35
- title: response.locals.__("activitypub.following"),
35
+ title: `${totalCount} ${response.locals.__("activitypub.following")}`,
36
36
  following,
37
37
  followingCount: totalCount,
38
38
  mountPath,
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Resolve controller — accepts any fediverse URL or handle, resolves it
3
+ * via lookupObject(), and redirects to the appropriate internal view.
4
+ */
5
+ import {
6
+ Article,
7
+ Note,
8
+ Person,
9
+ Service,
10
+ Application,
11
+ Organization,
12
+ Group,
13
+ } from "@fedify/fedify";
14
+
15
+ /**
16
+ * GET /admin/reader/resolve?q=<url-or-handle>
17
+ * Resolves a fediverse URL or @user@domain handle and redirects to
18
+ * the post detail or remote profile view.
19
+ */
20
+ export function resolveController(mountPath, plugin) {
21
+ return async (request, response, next) => {
22
+ try {
23
+ const query = (request.query.q || "").trim();
24
+
25
+ if (!query) {
26
+ return response.redirect(`${mountPath}/admin/reader`);
27
+ }
28
+
29
+ if (!plugin._federation) {
30
+ return response.status(503).render("error", {
31
+ title: "Error",
32
+ content: "Federation not initialized",
33
+ });
34
+ }
35
+
36
+ const handle = plugin.options.actor.handle;
37
+ const ctx = plugin._federation.createContext(
38
+ new URL(plugin._publicationUrl),
39
+ { handle, publicationUrl: plugin._publicationUrl },
40
+ );
41
+
42
+ const documentLoader = await ctx.getDocumentLoader({
43
+ identifier: handle,
44
+ });
45
+
46
+ // Determine if input is a URL or a handle
47
+ // lookupObject accepts: URLs, @user@domain, user@domain, acct:user@domain
48
+ let lookupInput;
49
+
50
+ try {
51
+ // If it parses as a URL, pass as URL object
52
+ const parsed = new URL(query);
53
+ lookupInput = parsed;
54
+ } catch {
55
+ // Not a URL — treat as handle (strip leading @ if present)
56
+ lookupInput = query;
57
+ }
58
+
59
+ let object;
60
+
61
+ try {
62
+ object = await ctx.lookupObject(lookupInput, { documentLoader });
63
+ } catch (error) {
64
+ console.warn(
65
+ `[resolve] lookupObject failed for "${query}":`,
66
+ error.message,
67
+ );
68
+ }
69
+
70
+ if (!object) {
71
+ return response.status(404).render("error", {
72
+ title: response.locals.__("activitypub.reader.resolve.notFoundTitle"),
73
+ content: response.locals.__(
74
+ "activitypub.reader.resolve.notFound",
75
+ ),
76
+ });
77
+ }
78
+
79
+ // Determine object type and redirect accordingly
80
+ const objectUrl =
81
+ object.id?.href || object.url?.href || query;
82
+
83
+ if (
84
+ object instanceof Person ||
85
+ object instanceof Service ||
86
+ object instanceof Application ||
87
+ object instanceof Organization ||
88
+ object instanceof Group
89
+ ) {
90
+ return response.redirect(
91
+ `${mountPath}/admin/reader/profile?url=${encodeURIComponent(objectUrl)}`,
92
+ );
93
+ }
94
+
95
+ if (object instanceof Note || object instanceof Article) {
96
+ return response.redirect(
97
+ `${mountPath}/admin/reader/post?url=${encodeURIComponent(objectUrl)}`,
98
+ );
99
+ }
100
+
101
+ // Unknown type — try post detail as fallback
102
+ return response.redirect(
103
+ `${mountPath}/admin/reader/post?url=${encodeURIComponent(objectUrl)}`,
104
+ );
105
+ } catch (error) {
106
+ next(error);
107
+ }
108
+ };
109
+ }
@@ -60,6 +60,7 @@ export function setupFederation(options) {
60
60
  publicationUrl = "",
61
61
  parallelWorkers = 5,
62
62
  actorType = "Person",
63
+ logLevel = "warning",
63
64
  } = options;
64
65
 
65
66
  // Map config string to Fedify actor class
@@ -67,6 +68,9 @@ export function setupFederation(options) {
67
68
  const ActorClass = actorTypeMap[actorType] || Person;
68
69
 
69
70
  // Configure LogTape for Fedify delivery logging (once per process)
71
+ // Valid levels: "debug" | "info" | "warning" | "error" | "fatal"
72
+ const validLevels = ["debug", "info", "warning", "error", "fatal"];
73
+ const resolvedLevel = validLevels.includes(logLevel) ? logLevel : "warning";
70
74
  if (!_logtapeConfigured) {
71
75
  _logtapeConfigured = true;
72
76
  configure({
@@ -79,7 +83,7 @@ export function setupFederation(options) {
79
83
  // All Fedify logs — federation, vocab, delivery, HTTP signatures
80
84
  category: ["fedify"],
81
85
  sinks: ["console"],
82
- lowestLevel: "info",
86
+ lowestLevel: resolvedLevel,
83
87
  },
84
88
  ],
85
89
  }).catch((error) => {
@@ -138,74 +142,7 @@ export function setupFederation(options) {
138
142
 
139
143
  if (identifier !== handle) return null;
140
144
 
141
- const profile = await getProfile(collections);
142
- const keyPairs = await ctx.getActorKeyPairs(identifier);
143
-
144
- const personOptions = {
145
- id: ctx.getActorUri(identifier),
146
- preferredUsername: identifier,
147
- name: profile.name || identifier,
148
- url: profile.url ? new URL(profile.url) : null,
149
- inbox: ctx.getInboxUri(identifier),
150
- outbox: ctx.getOutboxUri(identifier),
151
- followers: ctx.getFollowersUri(identifier),
152
- following: ctx.getFollowingUri(identifier),
153
- liked: ctx.getLikedUri(identifier),
154
- featured: ctx.getFeaturedUri(identifier),
155
- featuredTags: ctx.getFeaturedTagsUri(identifier),
156
- endpoints: new Endpoints({ sharedInbox: ctx.getInboxUri() }),
157
- manuallyApprovesFollowers:
158
- profile.manuallyApprovesFollowers || false,
159
- };
160
-
161
- if (profile.summary) {
162
- personOptions.summary = profile.summary;
163
- }
164
-
165
- if (profile.icon) {
166
- personOptions.icon = new Image({
167
- url: new URL(profile.icon),
168
- mediaType: guessImageMediaType(profile.icon),
169
- });
170
- }
171
-
172
- if (profile.image) {
173
- personOptions.image = new Image({
174
- url: new URL(profile.image),
175
- mediaType: guessImageMediaType(profile.image),
176
- });
177
- }
178
-
179
- if (keyPairs.length > 0) {
180
- personOptions.publicKey = keyPairs[0].cryptographicKey;
181
- personOptions.assertionMethods = keyPairs.map((k) => k.multikey);
182
- }
183
-
184
- if (profile.attachments?.length > 0) {
185
- personOptions.attachments = profile.attachments.map(
186
- (att) =>
187
- new PropertyValue({
188
- name: att.name,
189
- value: formatAttachmentValue(att.value),
190
- }),
191
- );
192
- }
193
-
194
- if (profile.alsoKnownAs?.length > 0) {
195
- personOptions.alsoKnownAs = profile.alsoKnownAs.map(
196
- (u) => new URL(u),
197
- );
198
- }
199
-
200
- if (profile.createdAt) {
201
- personOptions.published = Temporal.Instant.from(profile.createdAt);
202
- }
203
-
204
- // Actor type from profile overrides config default
205
- const profileActorType = profile.actorType || actorType;
206
- const ResolvedActorClass = actorTypeMap[profileActorType] || ActorClass;
207
-
208
- return new ResolvedActorClass(personOptions);
145
+ return buildPersonActor(ctx, identifier, collections, actorType);
209
146
  },
210
147
  )
211
148
  .mapHandle((_ctx, username) => {
@@ -674,6 +611,90 @@ async function getProfile(collections) {
674
611
  return doc || {};
675
612
  }
676
613
 
614
+ /**
615
+ * Build the Person/Service/Organization actor object from the stored profile.
616
+ * Used by both the actor dispatcher (for serving the actor to federation
617
+ * requests) and broadcastActorUpdate() (for sending Update activities).
618
+ *
619
+ * @param {object} ctx - Fedify context (base Context or RequestContext)
620
+ * @param {string} identifier - Actor handle (e.g. "rick")
621
+ * @param {object} collections - MongoDB collections
622
+ * @param {string} [defaultActorType="Person"] - Fallback actor type
623
+ * @returns {Promise<import("@fedify/fedify").Actor|null>}
624
+ */
625
+ export async function buildPersonActor(
626
+ ctx,
627
+ identifier,
628
+ collections,
629
+ defaultActorType = "Person",
630
+ ) {
631
+ const actorTypeMap = { Person, Service, Application, Organization, Group };
632
+ const profile = await getProfile(collections);
633
+ const keyPairs = await ctx.getActorKeyPairs(identifier);
634
+
635
+ const personOptions = {
636
+ id: ctx.getActorUri(identifier),
637
+ preferredUsername: identifier,
638
+ name: profile.name || identifier,
639
+ url: profile.url ? new URL(profile.url) : null,
640
+ inbox: ctx.getInboxUri(identifier),
641
+ outbox: ctx.getOutboxUri(identifier),
642
+ followers: ctx.getFollowersUri(identifier),
643
+ following: ctx.getFollowingUri(identifier),
644
+ liked: ctx.getLikedUri(identifier),
645
+ featured: ctx.getFeaturedUri(identifier),
646
+ featuredTags: ctx.getFeaturedTagsUri(identifier),
647
+ endpoints: new Endpoints({ sharedInbox: ctx.getInboxUri() }),
648
+ manuallyApprovesFollowers: profile.manuallyApprovesFollowers || false,
649
+ };
650
+
651
+ if (profile.summary) {
652
+ personOptions.summary = profile.summary;
653
+ }
654
+
655
+ if (profile.icon) {
656
+ personOptions.icon = new Image({
657
+ url: new URL(profile.icon),
658
+ mediaType: guessImageMediaType(profile.icon),
659
+ });
660
+ }
661
+
662
+ if (profile.image) {
663
+ personOptions.image = new Image({
664
+ url: new URL(profile.image),
665
+ mediaType: guessImageMediaType(profile.image),
666
+ });
667
+ }
668
+
669
+ if (keyPairs.length > 0) {
670
+ personOptions.publicKey = keyPairs[0].cryptographicKey;
671
+ personOptions.assertionMethods = keyPairs.map((k) => k.multikey);
672
+ }
673
+
674
+ if (profile.attachments?.length > 0) {
675
+ personOptions.attachments = profile.attachments.map(
676
+ (att) =>
677
+ new PropertyValue({
678
+ name: att.name,
679
+ value: formatAttachmentValue(att.value),
680
+ }),
681
+ );
682
+ }
683
+
684
+ if (profile.alsoKnownAs?.length > 0) {
685
+ personOptions.alsoKnownAs = profile.alsoKnownAs.map((u) => new URL(u));
686
+ }
687
+
688
+ if (profile.createdAt) {
689
+ personOptions.published = Temporal.Instant.from(profile.createdAt);
690
+ }
691
+
692
+ const profileActorType = profile.actorType || defaultActorType;
693
+ const ResolvedActorClass = actorTypeMap[profileActorType] || Person;
694
+
695
+ return new ResolvedActorClass(personOptions);
696
+ }
697
+
677
698
  /**
678
699
  * Import a PKCS#8 PEM private key using Web Crypto API.
679
700
  * Fedify's importPem only handles PKCS#1, but Node.js crypto generates PKCS#8.
package/locales/en.json CHANGED
@@ -191,6 +191,13 @@
191
191
  "loadingThread": "Loading thread...",
192
192
  "threadError": "Could not load full thread"
193
193
  },
194
+ "resolve": {
195
+ "placeholder": "Paste a fediverse URL or @user@domain handle…",
196
+ "label": "Look up a fediverse post or account",
197
+ "button": "Look up",
198
+ "notFoundTitle": "Not found",
199
+ "notFound": "Could not find this post or account. The URL may be invalid, the server may be unavailable, or the content may have been deleted."
200
+ },
194
201
  "linkPreview": {
195
202
  "label": "Link preview"
196
203
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-activitypub",
3
- "version": "1.1.16",
3
+ "version": "1.1.18",
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",
@@ -7,8 +7,6 @@
7
7
  {% from "pagination/macro.njk" import pagination with context %}
8
8
 
9
9
  {% block content %}
10
- {{ heading({ text: __("activitypub.activities"), level: 1 }) }}
11
-
12
10
  {% if activities.length > 0 %}
13
11
  {% for activity in activities %}
14
12
  {{ card({
@@ -3,8 +3,6 @@
3
3
  {% from "heading/macro.njk" import heading with context %}
4
4
 
5
5
  {% block readercontent %}
6
- {{ heading({ text: title, level: 1 }) }}
7
-
8
6
  {# Reply context — show the post being replied to #}
9
7
  {% if replyContext %}
10
8
  <div class="ap-compose__context">
@@ -7,8 +7,6 @@
7
7
  {% from "badge/macro.njk" import badge with context %}
8
8
 
9
9
  {% block content %}
10
- {{ heading({ text: title, level: 1 }) }}
11
-
12
10
  {{ cardGrid({ cardSize: "16rem", items: [
13
11
  {
14
12
  title: followerCount + " " + __("activitypub.followers"),
@@ -4,8 +4,6 @@
4
4
  {% from "prose/macro.njk" import prose with context %}
5
5
 
6
6
  {% block content %}
7
- {{ heading({ text: title, level: 1 }) }}
8
-
9
7
  {% if tags.length > 0 %}
10
8
  <table class="table">
11
9
  <thead>
@@ -4,8 +4,6 @@
4
4
  {% from "prose/macro.njk" import prose with context %}
5
5
 
6
6
  {% block content %}
7
- {{ heading({ text: title, level: 1 }) }}
8
-
9
7
  {% if pinned.length > 0 %}
10
8
  <table class="table">
11
9
  <thead>
@@ -6,8 +6,6 @@
6
6
  {% from "pagination/macro.njk" import pagination with context %}
7
7
 
8
8
  {% block content %}
9
- {{ heading({ text: followerCount + " " + __("activitypub.followers"), level: 1 }) }}
10
-
11
9
  {% if followers.length > 0 %}
12
10
  {% for follower in followers %}
13
11
  {{ card({
@@ -7,8 +7,6 @@
7
7
  {% from "pagination/macro.njk" import pagination with context %}
8
8
 
9
9
  {% block content %}
10
- {{ heading({ text: followingCount + " " + __("activitypub.following"), level: 1 }) }}
11
-
12
10
  {% if following.length > 0 %}
13
11
  {% for account in following %}
14
12
  {% if account.source === "import" %}
@@ -8,8 +8,6 @@
8
8
  {% from "prose/macro.njk" import prose with context %}
9
9
 
10
10
  {% block content %}
11
- {{ heading({ text: title, level: 1 }) }}
12
-
13
11
  {% if result %}
14
12
  {{ notificationBanner({ type: result.type, text: result.text }) }}
15
13
  {% endif %}
@@ -4,8 +4,6 @@
4
4
  {% from "prose/macro.njk" import prose with context %}
5
5
 
6
6
  {% block readercontent %}
7
- {{ heading({ text: title, level: 1 }) }}
8
-
9
7
  {# Blocked actors #}
10
8
  <section class="ap-moderation__section">
11
9
  <h2>{{ __("activitypub.moderation.blockedTitle") }}</h2>
@@ -4,8 +4,6 @@
4
4
  {% from "prose/macro.njk" import prose with context %}
5
5
 
6
6
  {% block readercontent %}
7
- {{ heading({ text: __("activitypub.notifications.title"), level: 1 }) }}
8
-
9
7
  {% if items.length > 0 %}
10
8
  <div class="ap-notifications__toolbar">
11
9
  {% if unreadCount > 0 %}
@@ -3,8 +3,6 @@
3
3
  {% from "heading/macro.njk" import heading with context %}
4
4
 
5
5
  {% block readercontent %}
6
- {{ heading({ text: title, level: 1 }) }}
7
-
8
6
  <div class="ap-post-detail" data-mount-path="{{ mountPath }}">
9
7
  {# Back button #}
10
8
  <div class="ap-post-detail__back">
@@ -10,8 +10,6 @@
10
10
  {% from "prose/macro.njk" import prose with context %}
11
11
 
12
12
  {% block content %}
13
- {{ heading({ text: title, level: 1 }) }}
14
-
15
13
  {% if result %}
16
14
  {{ notificationBanner({ type: result.type, text: result.text }) }}
17
15
  {% endif %}
@@ -4,7 +4,13 @@
4
4
  {% from "prose/macro.njk" import prose with context %}
5
5
 
6
6
  {% block readercontent %}
7
- {{ heading({ text: __("activitypub.reader.title"), level: 1 }) }}
7
+ {# Fediverse lookup #}
8
+ <form action="{{ mountPath }}/admin/reader/resolve" method="get" class="ap-lookup">
9
+ <input type="text" name="q" class="ap-lookup__input"
10
+ placeholder="{{ __('activitypub.reader.resolve.placeholder') }}"
11
+ aria-label="{{ __('activitypub.reader.resolve.label') }}">
12
+ <button type="submit" class="ap-lookup__btn">{{ __("activitypub.reader.resolve.button") }}</button>
13
+ </form>
8
14
 
9
15
  {# Tab navigation #}
10
16
  <nav class="ap-tabs" role="tablist">
@@ -4,8 +4,6 @@
4
4
  {% from "prose/macro.njk" import prose with context %}
5
5
 
6
6
  {% block readercontent %}
7
- {{ heading({ text: title, level: 1 }) }}
8
-
9
7
  <div class="ap-profile"
10
8
  x-data="{
11
9
  following: {{ 'true' if isFollowing else 'false' }},