@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,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
+ }
package/lib/csrf.js ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Simple CSRF token generation and validation.
3
+ * Tokens are stored in the Express session.
4
+ */
5
+
6
+ import { randomBytes, timingSafeEqual } from "node:crypto";
7
+
8
+ /**
9
+ * Get or generate a CSRF token for the current session.
10
+ * @param {object} session - Express session object
11
+ * @returns {string} CSRF token
12
+ */
13
+ export function getToken(session) {
14
+ if (!session._csrfToken) {
15
+ session._csrfToken = randomBytes(32).toString("hex");
16
+ }
17
+
18
+ return session._csrfToken;
19
+ }
20
+
21
+ /**
22
+ * Validate a CSRF token from a request.
23
+ * Checks both the request body `_csrf` field and the `X-CSRF-Token` header.
24
+ * @param {object} request - Express request object
25
+ * @returns {boolean} Whether the token is valid
26
+ */
27
+ export function validateToken(request) {
28
+ const sessionToken = request.session?._csrfToken;
29
+
30
+ if (!sessionToken) {
31
+ return false;
32
+ }
33
+
34
+ const requestToken =
35
+ request.body?._csrf || request.headers["x-csrf-token"];
36
+
37
+ if (!requestToken) {
38
+ return false;
39
+ }
40
+
41
+ if (sessionToken.length !== requestToken.length) {
42
+ return false;
43
+ }
44
+
45
+ return timingSafeEqual(
46
+ Buffer.from(sessionToken),
47
+ Buffer.from(requestToken),
48
+ );
49
+ }
@@ -183,7 +183,11 @@ export function setupFederation(options) {
183
183
 
184
184
  if (profile.attachments?.length > 0) {
185
185
  personOptions.attachments = profile.attachments.map(
186
- (att) => new PropertyValue({ name: att.name, value: att.value }),
186
+ (att) =>
187
+ new PropertyValue({
188
+ name: att.name,
189
+ value: formatAttachmentValue(att.value),
190
+ }),
187
191
  );
188
192
  }
189
193
 
@@ -197,7 +201,11 @@ export function setupFederation(options) {
197
201
  personOptions.published = Temporal.Instant.from(profile.createdAt);
198
202
  }
199
203
 
200
- return new ActorClass(personOptions);
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);
201
209
  },
202
210
  )
203
211
  .mapHandle((_ctx, username) => {
@@ -685,6 +693,29 @@ async function importPkcs8Pem(pem) {
685
693
  );
686
694
  }
687
695
 
696
+ /**
697
+ * Format an attachment value for ActivityPub PropertyValue.
698
+ * If the value looks like a URL, wrap it in an HTML anchor tag with rel="me"
699
+ * so Mastodon can verify profile link ownership. Plain text values pass through.
700
+ */
701
+ function formatAttachmentValue(value) {
702
+ if (!value) return "";
703
+ const trimmed = value.trim();
704
+ // Already contains HTML — pass through
705
+ if (trimmed.startsWith("<")) return trimmed;
706
+ // URL — wrap in anchor with rel="me"
707
+ if (/^https?:\/\//i.test(trimmed)) {
708
+ const escaped = trimmed
709
+ .replace(/&/g, "&amp;")
710
+ .replace(/</g, "&lt;")
711
+ .replace(/>/g, "&gt;")
712
+ .replace(/"/g, "&quot;");
713
+ return `<a href="${escaped}" rel="me">${escaped}</a>`;
714
+ }
715
+ // Plain text (e.g. pronouns) — return as-is
716
+ return trimmed;
717
+ }
718
+
688
719
  function guessImageMediaType(url) {
689
720
  const ext = url.split(".").pop()?.toLowerCase();
690
721
  const types = {