@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.
- package/assets/reader.css +884 -0
- package/index.js +172 -15
- package/lib/controllers/compose.js +323 -0
- package/lib/controllers/featured-tags.js +12 -2
- package/lib/controllers/featured.js +12 -2
- package/lib/controllers/interactions-boost.js +208 -0
- package/lib/controllers/interactions-like.js +231 -0
- package/lib/controllers/interactions.js +7 -0
- package/lib/controllers/moderation.js +294 -0
- package/lib/controllers/profile.js +27 -1
- package/lib/controllers/profile.remote.js +218 -0
- package/lib/controllers/reader.js +187 -0
- package/lib/csrf.js +49 -0
- package/lib/federation-setup.js +33 -2
- package/lib/inbox-listeners.js +217 -213
- package/lib/storage/moderation.js +180 -0
- package/lib/storage/notifications.js +132 -0
- package/lib/storage/timeline.js +210 -0
- package/lib/timeline-cleanup.js +88 -0
- package/lib/timeline-store.js +207 -0
- package/locales/en.json +92 -1
- package/package.json +3 -2
- package/views/activitypub-compose.njk +94 -0
- package/views/activitypub-moderation.njk +118 -0
- package/views/activitypub-notifications.njk +31 -0
- package/views/activitypub-profile.njk +98 -0
- package/views/activitypub-reader.njk +61 -0
- package/views/activitypub-remote-profile.njk +117 -0
- package/views/layouts/reader.njk +9 -0
- package/views/partials/ap-item-card.njk +157 -0
- package/views/partials/ap-item-media.njk +37 -0
- package/views/partials/ap-notification-card.njk +58 -0
|
@@ -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
|
+
}
|
package/lib/federation-setup.js
CHANGED
|
@@ -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) =>
|
|
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
|
-
|
|
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, "&")
|
|
710
|
+
.replace(/</g, "<")
|
|
711
|
+
.replace(/>/g, ">")
|
|
712
|
+
.replace(/"/g, """);
|
|
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 = {
|