@rmdes/indiekit-endpoint-activitypub 3.6.3 → 3.6.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.
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Resolve a remote account via WebFinger + ActivityPub actor fetch.
3
+ * Uses the Fedify federation instance to perform discovery.
4
+ *
5
+ * Shared by accounts.js (lookup) and search.js (resolve=true).
6
+ */
7
+ import { serializeAccount } from "../entities/account.js";
8
+
9
+ /**
10
+ * @param {string} acct - Account identifier (user@domain or URL)
11
+ * @param {object} pluginOptions - Plugin options with federation, handle, publicationUrl
12
+ * @param {string} baseUrl - Server base URL
13
+ * @returns {Promise<object|null>} Serialized Mastodon Account or null
14
+ */
15
+ export async function resolveRemoteAccount(acct, pluginOptions, baseUrl) {
16
+ const { federation, handle, publicationUrl } = pluginOptions;
17
+ if (!federation) return null;
18
+
19
+ try {
20
+ const ctx = federation.createContext(
21
+ new URL(publicationUrl),
22
+ { handle, publicationUrl },
23
+ );
24
+
25
+ // Determine lookup URI
26
+ let actorUri;
27
+ if (acct.includes("@")) {
28
+ const parts = acct.replace(/^@/, "").split("@");
29
+ const username = parts[0];
30
+ const domain = parts[1];
31
+ if (!username || !domain) return null;
32
+ actorUri = `acct:${username}@${domain}`;
33
+ } else if (acct.startsWith("http")) {
34
+ actorUri = acct;
35
+ } else {
36
+ return null;
37
+ }
38
+
39
+ const actor = await ctx.lookupObject(actorUri);
40
+ if (!actor) return null;
41
+
42
+ // Extract data from the Fedify actor object
43
+ const name = actor.name?.toString() || actor.preferredUsername?.toString() || "";
44
+ const actorUrl = actor.id?.href || "";
45
+ const username = actor.preferredUsername?.toString() || "";
46
+ const domain = actorUrl ? new URL(actorUrl).hostname : "";
47
+ const summary = actor.summary?.toString() || "";
48
+
49
+ // Get avatar
50
+ let avatarUrl = "";
51
+ try {
52
+ const icon = await actor.getIcon();
53
+ avatarUrl = icon?.url?.href || "";
54
+ } catch { /* ignore */ }
55
+
56
+ // Get header image
57
+ let headerUrl = "";
58
+ try {
59
+ const image = await actor.getImage();
60
+ headerUrl = image?.url?.href || "";
61
+ } catch { /* ignore */ }
62
+
63
+ // Get collection counts (followers, following, outbox)
64
+ let followersCount = 0;
65
+ let followingCount = 0;
66
+ let statusesCount = 0;
67
+ try {
68
+ const followers = await actor.getFollowers();
69
+ if (followers?.totalItems != null) followersCount = followers.totalItems;
70
+ } catch { /* ignore */ }
71
+ try {
72
+ const following = await actor.getFollowing();
73
+ if (following?.totalItems != null) followingCount = following.totalItems;
74
+ } catch { /* ignore */ }
75
+ try {
76
+ const outbox = await actor.getOutbox();
77
+ if (outbox?.totalItems != null) statusesCount = outbox.totalItems;
78
+ } catch { /* ignore */ }
79
+
80
+ // Get published/created date
81
+ const published = actor.published
82
+ ? String(actor.published)
83
+ : null;
84
+
85
+ // Profile fields from attachments
86
+ const fields = [];
87
+ try {
88
+ for await (const attachment of actor.getAttachments()) {
89
+ if (attachment?.name) {
90
+ fields.push({
91
+ name: attachment.name?.toString() || "",
92
+ value: attachment.value?.toString() || "",
93
+ });
94
+ }
95
+ }
96
+ } catch { /* ignore */ }
97
+
98
+ const account = serializeAccount(
99
+ {
100
+ name,
101
+ url: actorUrl,
102
+ photo: avatarUrl,
103
+ handle: `@${username}@${domain}`,
104
+ summary,
105
+ image: headerUrl,
106
+ bot: actor.constructor?.name === "Service" || actor.constructor?.name === "Application",
107
+ attachments: fields.length > 0 ? fields : undefined,
108
+ createdAt: published || undefined,
109
+ },
110
+ { baseUrl },
111
+ );
112
+
113
+ // Override counts with real data from AP collections
114
+ account.followers_count = followersCount;
115
+ account.following_count = followingCount;
116
+ account.statuses_count = statusesCount;
117
+
118
+ return account;
119
+ } catch (error) {
120
+ console.warn(`[Mastodon API] Remote account resolution failed for ${acct}:`, error.message);
121
+ return null;
122
+ }
123
+ }
@@ -9,6 +9,7 @@ import { serializeCredentialAccount, serializeAccount } from "../entities/accoun
9
9
  import { serializeStatus } from "../entities/status.js";
10
10
  import { accountId, remoteActorId } from "../helpers/id-mapping.js";
11
11
  import { buildPaginationQuery, parseLimit, setPaginationHeaders } from "../helpers/pagination.js";
12
+ import { resolveRemoteAccount } from "../helpers/resolve-account.js";
12
13
 
13
14
  const router = express.Router(); // eslint-disable-line new-cap
14
15
 
@@ -100,7 +101,7 @@ router.get("/api/v1/accounts/lookup", async (req, res, next) => {
100
101
  }
101
102
  }
102
103
 
103
- // Check followers/following for known remote actors
104
+ // Check followers for known remote actors
104
105
  const follower = await collections.ap_followers.findOne({
105
106
  $or: [
106
107
  { handle: `@${bareAcct}` },
@@ -116,6 +117,38 @@ router.get("/api/v1/accounts/lookup", async (req, res, next) => {
116
117
  );
117
118
  }
118
119
 
120
+ // Check following
121
+ const following = await collections.ap_following.findOne({
122
+ $or: [
123
+ { handle: `@${bareAcct}` },
124
+ { handle: bareAcct },
125
+ ],
126
+ });
127
+ if (following) {
128
+ return res.json(
129
+ serializeAccount(
130
+ { name: following.name, url: following.actorUrl, photo: following.avatar, handle: following.handle },
131
+ { baseUrl },
132
+ ),
133
+ );
134
+ }
135
+
136
+ // Check timeline authors (people whose posts are in our timeline)
137
+ const timelineAuthor = await collections.ap_timeline.findOne({
138
+ "author.handle": { $in: [`@${bareAcct}`, bareAcct] },
139
+ });
140
+ if (timelineAuthor?.author) {
141
+ return res.json(
142
+ serializeAccount(timelineAuthor.author, { baseUrl }),
143
+ );
144
+ }
145
+
146
+ // Resolve remotely via federation (WebFinger + actor fetch)
147
+ const resolved = await resolveRemoteAccount(bareAcct, pluginOptions, baseUrl);
148
+ if (resolved) {
149
+ return res.json(resolved);
150
+ }
151
+
119
152
  return res.status(404).json({ error: "Record not found" });
120
153
  } catch (error) {
121
154
  next(error);
@@ -7,6 +7,7 @@ import express from "express";
7
7
  import { serializeStatus } from "../entities/status.js";
8
8
  import { serializeAccount } from "../entities/account.js";
9
9
  import { parseLimit } from "../helpers/pagination.js";
10
+ import { resolveRemoteAccount } from "../helpers/resolve-account.js";
10
11
 
11
12
  const router = express.Router(); // eslint-disable-line new-cap
12
13
 
@@ -21,6 +22,9 @@ router.get("/api/v2/search", async (req, res, next) => {
21
22
  const limit = parseLimit(req.query.limit);
22
23
  const offset = Math.max(0, Number.parseInt(req.query.offset, 10) || 0);
23
24
 
25
+ const resolve = req.query.resolve === "true";
26
+ const pluginOptions = req.app.locals.mastodonPluginOptions || {};
27
+
24
28
  if (!query) {
25
29
  return res.json({ accounts: [], statuses: [], hashtags: [] });
26
30
  }
@@ -75,6 +79,14 @@ router.get("/api/v2/search", async (req, res, next) => {
75
79
  }
76
80
  if (results.accounts.length >= limit) break;
77
81
  }
82
+
83
+ // If no local results and resolve=true, try remote lookup
84
+ if (results.accounts.length === 0 && resolve && query.includes("@")) {
85
+ const resolved = await resolveRemoteAccount(query, pluginOptions, baseUrl);
86
+ if (resolved) {
87
+ results.accounts.push(resolved);
88
+ }
89
+ }
78
90
  }
79
91
 
80
92
  // ─── Status search ───────────────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-activitypub",
3
- "version": "3.6.3",
3
+ "version": "3.6.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",