@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.
- 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 +8 -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 +28 -1
- 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 +84 -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-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,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
|
+
}
|