@rmdes/indiekit-endpoint-activitypub 1.0.28 → 1.1.1

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,294 @@
1
+ /**
2
+ * Moderation controllers — Mute, Unmute, Block, Unblock.
3
+ */
4
+
5
+ import { validateToken, getToken } from "../csrf.js";
6
+ import {
7
+ addMuted,
8
+ removeMuted,
9
+ addBlocked,
10
+ removeBlocked,
11
+ getAllMuted,
12
+ getAllBlocked,
13
+ } from "../storage/moderation.js";
14
+
15
+ /**
16
+ * Helper to get moderation collections from request.
17
+ */
18
+ function getModerationCollections(request) {
19
+ const { application } = request.app.locals;
20
+ return {
21
+ ap_muted: application?.collections?.get("ap_muted"),
22
+ ap_blocked: application?.collections?.get("ap_blocked"),
23
+ ap_timeline: application?.collections?.get("ap_timeline"),
24
+ };
25
+ }
26
+
27
+ /**
28
+ * POST /admin/reader/mute — Mute an actor or keyword.
29
+ */
30
+ export function muteController(mountPath, plugin) {
31
+ return async (request, response, next) => {
32
+ try {
33
+ if (!validateToken(request)) {
34
+ return response.status(403).json({
35
+ success: false,
36
+ error: "Invalid CSRF token",
37
+ });
38
+ }
39
+
40
+ const { url, keyword } = request.body;
41
+
42
+ if (!url && !keyword) {
43
+ return response.status(400).json({
44
+ success: false,
45
+ error: "Provide url or keyword to mute",
46
+ });
47
+ }
48
+
49
+ const collections = getModerationCollections(request);
50
+ await addMuted(collections, { url: url || undefined, keyword: keyword || undefined });
51
+
52
+ console.info(
53
+ `[ActivityPub] Muted ${url ? `actor: ${url}` : `keyword: ${keyword}`}`,
54
+ );
55
+
56
+ return response.json({
57
+ success: true,
58
+ type: "mute",
59
+ url: url || undefined,
60
+ keyword: keyword || undefined,
61
+ });
62
+ } catch (error) {
63
+ console.error("[ActivityPub] Mute failed:", error.message);
64
+ return response.status(500).json({
65
+ success: false,
66
+ error: "Operation failed. Please try again later.",
67
+ });
68
+ }
69
+ };
70
+ }
71
+
72
+ /**
73
+ * POST /admin/reader/unmute — Unmute an actor or keyword.
74
+ */
75
+ export function unmuteController(mountPath, plugin) {
76
+ return async (request, response, next) => {
77
+ try {
78
+ if (!validateToken(request)) {
79
+ return response.status(403).json({
80
+ success: false,
81
+ error: "Invalid CSRF token",
82
+ });
83
+ }
84
+
85
+ const { url, keyword } = request.body;
86
+
87
+ if (!url && !keyword) {
88
+ return response.status(400).json({
89
+ success: false,
90
+ error: "Provide url or keyword to unmute",
91
+ });
92
+ }
93
+
94
+ const collections = getModerationCollections(request);
95
+ await removeMuted(collections, { url: url || undefined, keyword: keyword || undefined });
96
+
97
+ return response.json({
98
+ success: true,
99
+ type: "unmute",
100
+ url: url || undefined,
101
+ keyword: keyword || undefined,
102
+ });
103
+ } catch (error) {
104
+ return response.status(500).json({
105
+ success: false,
106
+ error: "Operation failed. Please try again later.",
107
+ });
108
+ }
109
+ };
110
+ }
111
+
112
+ /**
113
+ * POST /admin/reader/block — Block an actor (sends Block activity + removes timeline items).
114
+ */
115
+ export function blockController(mountPath, plugin) {
116
+ return async (request, response, next) => {
117
+ try {
118
+ if (!validateToken(request)) {
119
+ return response.status(403).json({
120
+ success: false,
121
+ error: "Invalid CSRF token",
122
+ });
123
+ }
124
+
125
+ const { url } = request.body;
126
+
127
+ if (!url) {
128
+ return response.status(400).json({
129
+ success: false,
130
+ error: "Missing actor URL",
131
+ });
132
+ }
133
+
134
+ const collections = getModerationCollections(request);
135
+
136
+ // Store the block
137
+ await addBlocked(collections, url);
138
+
139
+ // Remove timeline items from this actor
140
+ if (collections.ap_timeline) {
141
+ await collections.ap_timeline.deleteMany({ "author.url": url });
142
+ }
143
+
144
+ // Send Block activity via federation
145
+ if (plugin._federation) {
146
+ try {
147
+ const { Block } = await import("@fedify/fedify");
148
+ const handle = plugin.options.actor.handle;
149
+ const ctx = plugin._federation.createContext(
150
+ new URL(plugin._publicationUrl),
151
+ { handle, publicationUrl: plugin._publicationUrl },
152
+ );
153
+
154
+ const remoteActor = await ctx.lookupObject(new URL(url));
155
+
156
+ if (remoteActor) {
157
+ const block = new Block({
158
+ actor: ctx.getActorUri(handle),
159
+ object: new URL(url),
160
+ });
161
+
162
+ await ctx.sendActivity(
163
+ { identifier: handle },
164
+ remoteActor,
165
+ block,
166
+ { orderingKey: url },
167
+ );
168
+ }
169
+ } catch (error) {
170
+ console.warn(
171
+ `[ActivityPub] Could not send Block to ${url}: ${error.message}`,
172
+ );
173
+ }
174
+ }
175
+
176
+ console.info(`[ActivityPub] Blocked actor: ${url}`);
177
+
178
+ return response.json({
179
+ success: true,
180
+ type: "block",
181
+ url,
182
+ });
183
+ } catch (error) {
184
+ console.error("[ActivityPub] Block failed:", error.message);
185
+ return response.status(500).json({
186
+ success: false,
187
+ error: "Operation failed. Please try again later.",
188
+ });
189
+ }
190
+ };
191
+ }
192
+
193
+ /**
194
+ * POST /admin/reader/unblock — Unblock an actor (sends Undo(Block)).
195
+ */
196
+ export function unblockController(mountPath, plugin) {
197
+ return async (request, response, next) => {
198
+ try {
199
+ if (!validateToken(request)) {
200
+ return response.status(403).json({
201
+ success: false,
202
+ error: "Invalid CSRF token",
203
+ });
204
+ }
205
+
206
+ const { url } = request.body;
207
+
208
+ if (!url) {
209
+ return response.status(400).json({
210
+ success: false,
211
+ error: "Missing actor URL",
212
+ });
213
+ }
214
+
215
+ const collections = getModerationCollections(request);
216
+ await removeBlocked(collections, url);
217
+
218
+ // Send Undo(Block) via federation
219
+ if (plugin._federation) {
220
+ try {
221
+ const { Block, Undo } = await import("@fedify/fedify");
222
+ const handle = plugin.options.actor.handle;
223
+ const ctx = plugin._federation.createContext(
224
+ new URL(plugin._publicationUrl),
225
+ { handle, publicationUrl: plugin._publicationUrl },
226
+ );
227
+
228
+ const remoteActor = await ctx.lookupObject(new URL(url));
229
+
230
+ if (remoteActor) {
231
+ const block = new Block({
232
+ actor: ctx.getActorUri(handle),
233
+ object: new URL(url),
234
+ });
235
+
236
+ const undo = new Undo({
237
+ actor: ctx.getActorUri(handle),
238
+ object: block,
239
+ });
240
+
241
+ await ctx.sendActivity(
242
+ { identifier: handle },
243
+ remoteActor,
244
+ undo,
245
+ { orderingKey: url },
246
+ );
247
+ }
248
+ } catch (error) {
249
+ console.warn(
250
+ `[ActivityPub] Could not send Undo(Block) to ${url}: ${error.message}`,
251
+ );
252
+ }
253
+ }
254
+
255
+ console.info(`[ActivityPub] Unblocked actor: ${url}`);
256
+
257
+ return response.json({
258
+ success: true,
259
+ type: "unblock",
260
+ url,
261
+ });
262
+ } catch (error) {
263
+ return response.status(500).json({
264
+ success: false,
265
+ error: "Operation failed. Please try again later.",
266
+ });
267
+ }
268
+ };
269
+ }
270
+
271
+ /**
272
+ * GET /admin/reader/moderation — View muted/blocked lists.
273
+ */
274
+ export function moderationController(mountPath) {
275
+ return async (request, response, next) => {
276
+ try {
277
+ const collections = getModerationCollections(request);
278
+ const csrfToken = getToken(request.session);
279
+
280
+ const muted = await getAllMuted(collections);
281
+ const blocked = await getAllBlocked(collections);
282
+
283
+ response.render("activitypub-moderation", {
284
+ title: response.locals.__("activitypub.moderation.title"),
285
+ muted,
286
+ blocked,
287
+ csrfToken,
288
+ mountPath,
289
+ });
290
+ } catch (error) {
291
+ next(error);
292
+ }
293
+ };
294
+ }
@@ -5,6 +5,8 @@
5
5
  * POST: saves updated profile fields back to ap_profile
6
6
  */
7
7
 
8
+ const ACTOR_TYPES = ["Person", "Service", "Organization"];
9
+
8
10
  export function profileGetController(mountPath) {
9
11
  return async (request, response, next) => {
10
12
  try {
@@ -18,6 +20,7 @@ export function profileGetController(mountPath) {
18
20
  title: response.locals.__("activitypub.profile.title"),
19
21
  mountPath,
20
22
  profile,
23
+ actorTypes: ACTOR_TYPES,
21
24
  result: null,
22
25
  });
23
26
  } catch (error) {
@@ -26,7 +29,7 @@ export function profileGetController(mountPath) {
26
29
  };
27
30
  }
28
31
 
29
- export function profilePostController(mountPath) {
32
+ export function profilePostController(mountPath, plugin) {
30
33
  return async (request, response, next) => {
31
34
  try {
32
35
  const { application } = request.app.locals;
@@ -42,10 +45,23 @@ export function profilePostController(mountPath) {
42
45
  url,
43
46
  icon,
44
47
  image,
48
+ actorType,
45
49
  manuallyApprovesFollowers,
46
50
  authorizedFetch,
47
51
  } = request.body;
48
52
 
53
+ // Parse profile links (attachments) from form arrays
54
+ const linkNames = [].concat(request.body["link_name[]"] || []);
55
+ const linkValues = [].concat(request.body["link_value[]"] || []);
56
+ const attachments = [];
57
+ for (let i = 0; i < linkNames.length; i++) {
58
+ const n = linkNames[i]?.trim();
59
+ const v = linkValues[i]?.trim();
60
+ if (n && v) {
61
+ attachments.push({ name: n, value: v });
62
+ }
63
+ }
64
+
49
65
  const update = {
50
66
  $set: {
51
67
  name: name?.trim() || "",
@@ -53,20 +69,30 @@ export function profilePostController(mountPath) {
53
69
  url: url?.trim() || "",
54
70
  icon: icon?.trim() || "",
55
71
  image: image?.trim() || "",
72
+ actorType: ACTOR_TYPES.includes(actorType) ? actorType : "Person",
56
73
  manuallyApprovesFollowers: manuallyApprovesFollowers === "true",
57
74
  authorizedFetch: authorizedFetch === "true",
75
+ attachments,
58
76
  updatedAt: new Date().toISOString(),
59
77
  },
60
78
  };
61
79
 
62
80
  await profileCollection.updateOne({}, update, { upsert: true });
63
81
 
82
+ // Send Update(Person) to followers so remote servers re-fetch the actor
83
+ if (plugin?.broadcastActorUpdate) {
84
+ plugin.broadcastActorUpdate().catch((error) => {
85
+ console.warn("[ActivityPub] Profile update broadcast failed:", error.message);
86
+ });
87
+ }
88
+
64
89
  const profile = await profileCollection.findOne({});
65
90
 
66
91
  response.render("activitypub-profile", {
67
92
  title: response.locals.__("activitypub.profile.title"),
68
93
  mountPath,
69
94
  profile,
95
+ actorTypes: ACTOR_TYPES,
70
96
  result: {
71
97
  type: "success",
72
98
  text: response.locals.__("activitypub.profile.saved"),
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Remote profile controllers — view remote actors and follow/unfollow.
3
+ */
4
+
5
+ import { getToken, validateToken } from "../csrf.js";
6
+ import { sanitizeContent } from "../timeline-store.js";
7
+
8
+ /**
9
+ * GET /admin/reader/profile — Show remote actor profile.
10
+ * @param {string} mountPath - Plugin mount path
11
+ * @param {object} plugin - ActivityPub plugin instance
12
+ */
13
+ export function remoteProfileController(mountPath, plugin) {
14
+ return async (request, response, next) => {
15
+ try {
16
+ const { application } = request.app.locals;
17
+ const actorUrl = request.query.url || request.query.handle;
18
+
19
+ if (!actorUrl) {
20
+ return response.status(400).render("error", {
21
+ title: "Error",
22
+ content: "Missing actor URL or handle",
23
+ });
24
+ }
25
+
26
+ if (!plugin._federation) {
27
+ return response.status(503).render("error", {
28
+ title: "Error",
29
+ content: "Federation not initialized",
30
+ });
31
+ }
32
+
33
+ const handle = plugin.options.actor.handle;
34
+ const ctx = plugin._federation.createContext(
35
+ new URL(plugin._publicationUrl),
36
+ { handle, publicationUrl: plugin._publicationUrl },
37
+ );
38
+
39
+ // Look up the remote actor
40
+ let actor;
41
+
42
+ try {
43
+ actor = await ctx.lookupObject(new URL(actorUrl));
44
+ } catch {
45
+ return response.status(404).render("error", {
46
+ title: "Error",
47
+ content: response.locals.__("activitypub.profile.remote.notFound"),
48
+ });
49
+ }
50
+
51
+ if (!actor) {
52
+ return response.status(404).render("error", {
53
+ title: "Error",
54
+ content: response.locals.__("activitypub.profile.remote.notFound"),
55
+ });
56
+ }
57
+
58
+ // Extract actor info
59
+ const name =
60
+ actor.name?.toString() ||
61
+ actor.preferredUsername?.toString() ||
62
+ actorUrl;
63
+ const actorHandle = actor.preferredUsername?.toString() || "";
64
+ const summary = sanitizeContent(actor.summary?.toString() || "");
65
+ let icon = "";
66
+ let image = "";
67
+
68
+ try {
69
+ const iconObj = await actor.getIcon();
70
+ icon = iconObj?.url?.href || "";
71
+ } catch {
72
+ // No icon
73
+ }
74
+
75
+ try {
76
+ const imageObj = await actor.getImage();
77
+ image = imageObj?.url?.href || "";
78
+ } catch {
79
+ // No header image
80
+ }
81
+
82
+ // Extract host for "View on {instance}"
83
+ let instanceHost = "";
84
+
85
+ try {
86
+ instanceHost = new URL(actorUrl).hostname;
87
+ } catch {
88
+ // Invalid URL
89
+ }
90
+
91
+ // Check if we're following this actor
92
+ const followingCol = application?.collections?.get("ap_following");
93
+ const isFollowing = followingCol
94
+ ? !!(await followingCol.findOne({ actorUrl }))
95
+ : false;
96
+
97
+ // Get their posts from our timeline (only if following)
98
+ let posts = [];
99
+
100
+ if (isFollowing) {
101
+ const timelineCol = application?.collections?.get("ap_timeline");
102
+
103
+ if (timelineCol) {
104
+ posts = await timelineCol
105
+ .find({ "author.url": actorUrl })
106
+ .sort({ published: -1 })
107
+ .limit(20)
108
+ .toArray();
109
+ }
110
+ }
111
+
112
+ // Check mute/block state
113
+ const mutedCol = application?.collections?.get("ap_muted");
114
+ const blockedCol = application?.collections?.get("ap_blocked");
115
+ const isMuted = mutedCol
116
+ ? !!(await mutedCol.findOne({ url: actorUrl }))
117
+ : false;
118
+ const isBlocked = blockedCol
119
+ ? !!(await blockedCol.findOne({ url: actorUrl }))
120
+ : false;
121
+
122
+ const csrfToken = getToken(request.session);
123
+
124
+ response.render("activitypub-remote-profile", {
125
+ title: name,
126
+ actorUrl,
127
+ name,
128
+ actorHandle,
129
+ summary,
130
+ icon,
131
+ image,
132
+ instanceHost,
133
+ isFollowing,
134
+ isMuted,
135
+ isBlocked,
136
+ posts,
137
+ csrfToken,
138
+ mountPath,
139
+ });
140
+ } catch (error) {
141
+ next(error);
142
+ }
143
+ };
144
+ }
145
+
146
+ /**
147
+ * POST /admin/reader/follow — Follow a remote actor.
148
+ */
149
+ export function followController(mountPath, plugin) {
150
+ return async (request, response, next) => {
151
+ try {
152
+ if (!validateToken(request)) {
153
+ return response.status(403).json({
154
+ success: false,
155
+ error: "Invalid CSRF token",
156
+ });
157
+ }
158
+
159
+ const { url } = request.body;
160
+
161
+ if (!url) {
162
+ return response.status(400).json({
163
+ success: false,
164
+ error: "Missing actor URL",
165
+ });
166
+ }
167
+
168
+ const result = await plugin.followActor(url);
169
+
170
+ return response.json({
171
+ success: result.ok,
172
+ error: result.error || undefined,
173
+ });
174
+ } catch (error) {
175
+ return response.status(500).json({
176
+ success: false,
177
+ error: "Operation failed. Please try again later.",
178
+ });
179
+ }
180
+ };
181
+ }
182
+
183
+ /**
184
+ * POST /admin/reader/unfollow — Unfollow a remote actor.
185
+ */
186
+ export function unfollowController(mountPath, plugin) {
187
+ return async (request, response, next) => {
188
+ try {
189
+ if (!validateToken(request)) {
190
+ return response.status(403).json({
191
+ success: false,
192
+ error: "Invalid CSRF token",
193
+ });
194
+ }
195
+
196
+ const { url } = request.body;
197
+
198
+ if (!url) {
199
+ return response.status(400).json({
200
+ success: false,
201
+ error: "Missing actor URL",
202
+ });
203
+ }
204
+
205
+ const result = await plugin.unfollowActor(url);
206
+
207
+ return response.json({
208
+ success: result.ok,
209
+ error: result.error || undefined,
210
+ });
211
+ } catch (error) {
212
+ return response.status(500).json({
213
+ success: false,
214
+ error: "Operation failed. Please try again later.",
215
+ });
216
+ }
217
+ };
218
+ }