@rmdes/indiekit-endpoint-activitypub 1.0.29 → 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
+ }
@@ -29,7 +29,7 @@ export function profileGetController(mountPath) {
29
29
  };
30
30
  }
31
31
 
32
- export function profilePostController(mountPath) {
32
+ export function profilePostController(mountPath, plugin) {
33
33
  return async (request, response, next) => {
34
34
  try {
35
35
  const { application } = request.app.locals;
@@ -79,6 +79,13 @@ export function profilePostController(mountPath) {
79
79
 
80
80
  await profileCollection.updateOne({}, update, { upsert: true });
81
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
+
82
89
  const profile = await profileCollection.findOne({});
83
90
 
84
91
  response.render("activitypub-profile", {
@@ -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
+ }
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Reader controller — shows timeline of posts from followed accounts.
3
+ */
4
+
5
+ import { getTimelineItems } from "../storage/timeline.js";
6
+ import {
7
+ getNotifications,
8
+ getUnreadNotificationCount,
9
+ markAllNotificationsRead,
10
+ } from "../storage/notifications.js";
11
+ import { getToken } from "../csrf.js";
12
+ import {
13
+ getMutedUrls,
14
+ getMutedKeywords,
15
+ getBlockedUrls,
16
+ } from "../storage/moderation.js";
17
+
18
+ // Re-export controllers from split modules for backward compatibility
19
+ export {
20
+ composeController,
21
+ submitComposeController,
22
+ } from "./compose.js";
23
+ export {
24
+ remoteProfileController,
25
+ followController,
26
+ unfollowController,
27
+ } from "./profile.remote.js";
28
+
29
+ export function readerController(mountPath) {
30
+ return async (request, response, next) => {
31
+ try {
32
+ const { application } = request.app.locals;
33
+ const collections = {
34
+ ap_timeline: application?.collections?.get("ap_timeline"),
35
+ ap_notifications: application?.collections?.get("ap_notifications"),
36
+ };
37
+
38
+ // Query parameters
39
+ const tab = request.query.tab || "all";
40
+ const before = request.query.before;
41
+ const after = request.query.after;
42
+ const limit = Number.parseInt(request.query.limit || "20", 10);
43
+
44
+ // Build query options
45
+ const options = { before, after, limit };
46
+
47
+ // Tab filtering
48
+ if (tab === "notes") {
49
+ options.type = "note";
50
+ } else if (tab === "articles") {
51
+ options.type = "article";
52
+ } else if (tab === "boosts") {
53
+ options.type = "boost";
54
+ }
55
+
56
+ // Get timeline items
57
+ const result = await getTimelineItems(collections, options);
58
+
59
+ // Apply client-side filtering for tabs not supported by storage layer
60
+ let items = result.items;
61
+ if (tab === "replies") {
62
+ items = items.filter((item) => item.inReplyTo);
63
+ } else if (tab === "media") {
64
+ items = items.filter(
65
+ (item) =>
66
+ (item.photo && item.photo.length > 0) ||
67
+ (item.video && item.video.length > 0) ||
68
+ (item.audio && item.audio.length > 0),
69
+ );
70
+ }
71
+
72
+ // Apply moderation filters (muted actors, keywords, blocked actors)
73
+ const modCollections = {
74
+ ap_muted: application?.collections?.get("ap_muted"),
75
+ ap_blocked: application?.collections?.get("ap_blocked"),
76
+ };
77
+ const [mutedUrls, mutedKeywords, blockedUrls] = await Promise.all([
78
+ getMutedUrls(modCollections),
79
+ getMutedKeywords(modCollections),
80
+ getBlockedUrls(modCollections),
81
+ ]);
82
+ const hiddenUrls = new Set([...mutedUrls, ...blockedUrls]);
83
+
84
+ if (hiddenUrls.size > 0 || mutedKeywords.length > 0) {
85
+ items = items.filter((item) => {
86
+ // Filter by author URL
87
+ if (item.author?.url && hiddenUrls.has(item.author.url)) {
88
+ return false;
89
+ }
90
+
91
+ // Filter by muted keywords in content
92
+ if (mutedKeywords.length > 0 && item.content?.text) {
93
+ const lower = item.content.text.toLowerCase();
94
+
95
+ if (
96
+ mutedKeywords.some((kw) => lower.includes(kw.toLowerCase()))
97
+ ) {
98
+ return false;
99
+ }
100
+ }
101
+
102
+ return true;
103
+ });
104
+ }
105
+
106
+ // Get unread notification count for badge
107
+ const unreadCount = await getUnreadNotificationCount(collections);
108
+
109
+ // Get interaction state for liked/boosted indicators
110
+ const interactionsCol =
111
+ application?.collections?.get("ap_interactions");
112
+ const interactionMap = {};
113
+
114
+ if (interactionsCol) {
115
+ const itemUrls = items
116
+ .map((item) => item.url || item.originalUrl)
117
+ .filter(Boolean);
118
+
119
+ if (itemUrls.length > 0) {
120
+ const interactions = await interactionsCol
121
+ .find({ objectUrl: { $in: itemUrls } })
122
+ .toArray();
123
+
124
+ for (const interaction of interactions) {
125
+ if (!interactionMap[interaction.objectUrl]) {
126
+ interactionMap[interaction.objectUrl] = {};
127
+ }
128
+
129
+ interactionMap[interaction.objectUrl][interaction.type] = true;
130
+ }
131
+ }
132
+ }
133
+
134
+ // CSRF token for interaction forms
135
+ const csrfToken = getToken(request.session);
136
+
137
+ response.render("activitypub-reader", {
138
+ title: response.locals.__("activitypub.reader.title"),
139
+ items,
140
+ tab,
141
+ before: result.before,
142
+ after: result.after,
143
+ unreadCount,
144
+ interactionMap,
145
+ csrfToken,
146
+ mountPath,
147
+ });
148
+ } catch (error) {
149
+ next(error);
150
+ }
151
+ };
152
+ }
153
+
154
+ export function notificationsController(mountPath) {
155
+ return async (request, response, next) => {
156
+ try {
157
+ const { application } = request.app.locals;
158
+ const collections = {
159
+ ap_notifications: application?.collections?.get("ap_notifications"),
160
+ };
161
+
162
+ const before = request.query.before;
163
+ const limit = Number.parseInt(request.query.limit || "20", 10);
164
+
165
+ // Get notifications
166
+ const result = await getNotifications(collections, { before, limit });
167
+
168
+ // Get unread count before marking as read
169
+ const unreadCount = await getUnreadNotificationCount(collections);
170
+
171
+ // Mark all as read when page loads
172
+ if (result.items.length > 0) {
173
+ await markAllNotificationsRead(collections);
174
+ }
175
+
176
+ response.render("activitypub-notifications", {
177
+ title: response.locals.__("activitypub.notifications.title"),
178
+ items: result.items,
179
+ before: result.before,
180
+ unreadCount,
181
+ mountPath,
182
+ });
183
+ } catch (error) {
184
+ next(error);
185
+ }
186
+ };
187
+ }