@rmdes/indiekit-endpoint-activitypub 3.7.3 → 3.7.5

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
@@ -721,19 +721,13 @@ export default class ActivityPubEndpoint {
721
721
  );
722
722
 
723
723
  // Resolve the remote actor to get their inbox
724
- // Try authenticated document loader first (for Authorized Fetch servers),
725
- // fall back to unsigned if that fails (some servers reject signed GETs)
724
+ // lookupWithSecurity handles signed→unsigned fallback automatically
726
725
  const documentLoader = await ctx.getDocumentLoader({
727
726
  identifier: handle,
728
727
  });
729
- let remoteActor = await lookupWithSecurity(ctx, actorUrl, {
728
+ const remoteActor = await lookupWithSecurity(ctx, actorUrl, {
730
729
  documentLoader,
731
730
  });
732
- if (!remoteActor) {
733
- // Retry without authentication — some servers (e.g., tags.pub)
734
- // may reject or mishandle signed GET requests
735
- remoteActor = await lookupWithSecurity(ctx, actorUrl);
736
- }
737
731
  if (!remoteActor) {
738
732
  return { ok: false, error: "Could not resolve remote actor" };
739
733
  }
@@ -41,12 +41,16 @@ export function federationMgmtController(mountPath, plugin) {
41
41
 
42
42
  const redisUrl = plugin.options.redisUrl || "";
43
43
 
44
- // Parallel: collection stats + posts + recent activities
45
- const [collectionStats, postsResult, recentActivities] =
44
+ // Parallel: collection stats + posts + recent activities + moderation data
45
+ const pluginCollections = plugin._collections || {};
46
+ const [collectionStats, postsResult, recentActivities, blockedServers, blockedAccounts, mutedAccounts] =
46
47
  await Promise.all([
47
48
  getCollectionStats(collections, { redisUrl }),
48
49
  getPaginatedPosts(collections, request.query.page),
49
50
  getRecentActivities(collections),
51
+ pluginCollections.ap_blocked_servers?.find({}).sort({ blockedAt: -1 }).toArray() || [],
52
+ pluginCollections.ap_blocked?.find({}).sort({ blockedAt: -1 }).toArray() || [],
53
+ pluginCollections.ap_muted?.find({}).sort({ mutedAt: -1 }).toArray() || [],
50
54
  ]);
51
55
 
52
56
  const csrfToken = getToken(request.session);
@@ -62,6 +66,9 @@ export function federationMgmtController(mountPath, plugin) {
62
66
  posts: postsResult.posts,
63
67
  cursor: postsResult.cursor,
64
68
  recentActivities,
69
+ blockedServers: blockedServers || [],
70
+ blockedAccounts: blockedAccounts || [],
71
+ mutedAccounts: mutedAccounts || [],
65
72
  csrfToken,
66
73
  mountPath,
67
74
  publicationUrl: plugin._publicationUrl,
@@ -60,7 +60,8 @@ export function resolveController(mountPath, plugin) {
60
60
  let object;
61
61
 
62
62
  try {
63
- object = await lookupWithSecurity(ctx,lookupInput, { documentLoader });
63
+ // lookupWithSecurity handles signed→unsigned fallback automatically
64
+ object = await lookupWithSecurity(ctx, lookupInput, { documentLoader });
64
65
  } catch (error) {
65
66
  console.warn(
66
67
  `[resolve] lookupObject failed for "${query}":`,
@@ -14,14 +14,36 @@
14
14
  * Using `crossOrigin: "ignore"` tells Fedify to silently discard objects
15
15
  * whose id doesn't match the fetch origin, rather than throwing.
16
16
  *
17
+ * When an authenticated document loader is provided (for Authorized Fetch
18
+ * compatibility), the lookup is tried with it first. If it fails (some
19
+ * servers like tags.pub return 400 for signed GETs), a fallback to the
20
+ * default unsigned loader is attempted automatically.
21
+ *
17
22
  * @param {object} ctx - Fedify Context
18
23
  * @param {string|URL} input - URL or handle to look up
19
24
  * @param {object} [options] - Additional options passed to lookupObject
20
25
  * @returns {Promise<object|null>} Resolved object or null
21
26
  */
22
- export function lookupWithSecurity(ctx, input, options = {}) {
23
- return ctx.lookupObject(input, {
24
- crossOrigin: "ignore",
25
- ...options,
26
- });
27
+ export async function lookupWithSecurity(ctx, input, options = {}) {
28
+ const baseOptions = { crossOrigin: "ignore", ...options };
29
+
30
+ let result = null;
31
+ try {
32
+ result = await ctx.lookupObject(input, baseOptions);
33
+ } catch {
34
+ // signed lookup threw — fall through to unsigned
35
+ }
36
+
37
+ // If signed lookup failed and we used a custom documentLoader,
38
+ // retry without it (unsigned GET)
39
+ if (!result && options.documentLoader) {
40
+ try {
41
+ const { documentLoader: _, ...unsignedOptions } = baseOptions;
42
+ result = await ctx.lookupObject(input, unsignedOptions);
43
+ } catch {
44
+ // unsigned also failed — return null
45
+ }
46
+ }
47
+
48
+ return result;
27
49
  }
@@ -170,11 +170,12 @@ router.get("/api/v1/accounts/relationships", async (req, res, next) => {
170
170
 
171
171
  const collections = req.app.locals.mastodonCollections;
172
172
 
173
- const [followers, following, blocked, muted] = await Promise.all([
173
+ const [followers, following, blocked, muted, blockedServers] = await Promise.all([
174
174
  collections.ap_followers.find({}).toArray(),
175
175
  collections.ap_following.find({}).toArray(),
176
176
  collections.ap_blocked.find({}).toArray(),
177
177
  collections.ap_muted.find({}).toArray(),
178
+ collections.ap_blocked_servers?.find({}).toArray() || [],
178
179
  ]);
179
180
 
180
181
  const followerIds = new Set(followers.map((f) => remoteActorId(f.actorUrl)));
@@ -182,6 +183,21 @@ router.get("/api/v1/accounts/relationships", async (req, res, next) => {
182
183
  const blockedIds = new Set(blocked.map((b) => remoteActorId(b.url)));
183
184
  const mutedIds = new Set(muted.filter((m) => m.url).map((m) => remoteActorId(m.url)));
184
185
 
186
+ // Build domain-blocked actor ID set by checking known actors against blocked server hostnames
187
+ const blockedDomains = new Set(blockedServers.map((s) => s.hostname).filter(Boolean));
188
+ const domainBlockedIds = new Set();
189
+ if (blockedDomains.size > 0) {
190
+ const allActors = [...followers, ...following];
191
+ for (const actor of allActors) {
192
+ try {
193
+ const domain = new URL(actor.actorUrl).hostname;
194
+ if (blockedDomains.has(domain)) {
195
+ domainBlockedIds.add(remoteActorId(actor.actorUrl));
196
+ }
197
+ } catch { /* skip invalid URLs */ }
198
+ }
199
+ }
200
+
185
201
  const relationships = ids.map((id) => ({
186
202
  id,
187
203
  following: followingIds.has(id),
@@ -195,7 +211,7 @@ router.get("/api/v1/accounts/relationships", async (req, res, next) => {
195
211
  muting_notifications: mutedIds.has(id),
196
212
  requested: false,
197
213
  requested_by: false,
198
- domain_blocking: false,
214
+ domain_blocking: domainBlockedIds.has(id),
199
215
  endorsed: false,
200
216
  note: "",
201
217
  }));
@@ -314,8 +314,15 @@ router.get("/api/v1/conversations", (req, res) => {
314
314
 
315
315
  // ─── Domain blocks ──────────────────────────────────────────────────────────
316
316
 
317
- router.get("/api/v1/domain_blocks", (req, res) => {
318
- res.json([]);
317
+ router.get("/api/v1/domain_blocks", async (req, res) => {
318
+ try {
319
+ const collections = req.app.locals.mastodonCollections;
320
+ if (!collections?.ap_blocked_servers) return res.json([]);
321
+ const docs = await collections.ap_blocked_servers.find({}).toArray();
322
+ res.json(docs.map((d) => d.hostname).filter(Boolean));
323
+ } catch {
324
+ res.json([]);
325
+ }
319
326
  });
320
327
 
321
328
  // ─── Endorsements ───────────────────────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-activitypub",
3
- "version": "3.7.3",
3
+ "version": "3.7.5",
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",
@@ -116,6 +116,53 @@
116
116
  {% endif %}
117
117
  </section>
118
118
 
119
+ {# --- Moderation Overview --- #}
120
+ <section class="ap-federation__section">
121
+ <h2>Moderation</h2>
122
+ {% if blockedServers.length > 0 %}
123
+ <h3>Blocked servers ({{ blockedServers.length }})</h3>
124
+ <div class="ap-federation__stats-grid">
125
+ {% for server in blockedServers %}
126
+ <div class="ap-federation__stat-card">
127
+ <span class="ap-federation__stat-label">🚫 {{ server.hostname }}</span>
128
+ {% if server.blockedAt %}
129
+ <span class="ap-federation__stat-count" style="font-size: 0.75rem; opacity: 0.7">{{ server.blockedAt | date("PPp") }}</span>
130
+ {% endif %}
131
+ </div>
132
+ {% endfor %}
133
+ </div>
134
+ {% else %}
135
+ {{ prose({ text: "No servers blocked." }) }}
136
+ {% endif %}
137
+
138
+ {% if blockedAccounts.length > 0 %}
139
+ <h3>Blocked accounts ({{ blockedAccounts.length }})</h3>
140
+ <div class="ap-federation__stats-grid">
141
+ {% for account in blockedAccounts %}
142
+ <div class="ap-federation__stat-card">
143
+ <span class="ap-federation__stat-label">🚫 {{ account.url or account.handle or "Unknown" }}</span>
144
+ {% if account.blockedAt %}
145
+ <span class="ap-federation__stat-count" style="font-size: 0.75rem; opacity: 0.7">{{ account.blockedAt | date("PPp") }}</span>
146
+ {% endif %}
147
+ </div>
148
+ {% endfor %}
149
+ </div>
150
+ {% else %}
151
+ {{ prose({ text: "No accounts blocked." }) }}
152
+ {% endif %}
153
+
154
+ {% if mutedAccounts.length > 0 %}
155
+ <h3>Muted ({{ mutedAccounts.length }})</h3>
156
+ <div class="ap-federation__stats-grid">
157
+ {% for muted in mutedAccounts %}
158
+ <div class="ap-federation__stat-card">
159
+ <span class="ap-federation__stat-label">🔇 {{ muted.url or muted.keyword or "Unknown" }}</span>
160
+ </div>
161
+ {% endfor %}
162
+ </div>
163
+ {% endif %}
164
+ </section>
165
+
119
166
  {# --- JSON Modal --- #}
120
167
  <div class="ap-federation__modal-overlay" x-show="jsonModalOpen" x-cloak
121
168
  @click.self="jsonModalOpen = false" @keydown.escape.window="jsonModalOpen = false">