@rmdes/indiekit-endpoint-activitypub 0.1.0
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/icon.svg +12 -0
- package/index.js +376 -0
- package/lib/actor.js +75 -0
- package/lib/controllers/activities.js +56 -0
- package/lib/controllers/dashboard.js +39 -0
- package/lib/controllers/followers.js +58 -0
- package/lib/controllers/following.js +58 -0
- package/lib/controllers/migrate.js +121 -0
- package/lib/federation.js +410 -0
- package/lib/inbox.js +291 -0
- package/lib/jf2-to-as2.js +191 -0
- package/lib/keys.js +39 -0
- package/lib/migration.js +184 -0
- package/lib/webfinger.js +43 -0
- package/locales/en.json +41 -0
- package/package.json +51 -0
- package/views/activities.njk +29 -0
- package/views/dashboard.njk +45 -0
- package/views/followers.njk +26 -0
- package/views/following.njk +27 -0
- package/views/migrate.njk +67 -0
package/assets/icon.svg
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
2
|
+
<circle cx="12" cy="12" r="10"/>
|
|
3
|
+
<circle cx="12" cy="12" r="3"/>
|
|
4
|
+
<line x1="12" y1="2" x2="12" y2="5"/>
|
|
5
|
+
<line x1="12" y1="19" x2="12" y2="22"/>
|
|
6
|
+
<line x1="2" y1="12" x2="5" y2="12"/>
|
|
7
|
+
<line x1="19" y1="12" x2="22" y2="12"/>
|
|
8
|
+
<line x1="4.93" y1="4.93" x2="6.76" y2="6.76"/>
|
|
9
|
+
<line x1="17.24" y1="17.24" x2="19.07" y2="19.07"/>
|
|
10
|
+
<line x1="4.93" y1="19.07" x2="6.76" y2="17.24"/>
|
|
11
|
+
<line x1="17.24" y1="6.76" x2="19.07" y2="4.93"/>
|
|
12
|
+
</svg>
|
package/index.js
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
import express from "express";
|
|
4
|
+
|
|
5
|
+
import { handleWebFinger } from "./lib/webfinger.js";
|
|
6
|
+
import { buildActorDocument } from "./lib/actor.js";
|
|
7
|
+
import { getOrCreateKeyPair } from "./lib/keys.js";
|
|
8
|
+
import { jf2ToActivityStreams, resolvePostUrl } from "./lib/jf2-to-as2.js";
|
|
9
|
+
import { createFederationHandler } from "./lib/federation.js";
|
|
10
|
+
import { dashboardController } from "./lib/controllers/dashboard.js";
|
|
11
|
+
import { followersController } from "./lib/controllers/followers.js";
|
|
12
|
+
import { followingController } from "./lib/controllers/following.js";
|
|
13
|
+
import { activitiesController } from "./lib/controllers/activities.js";
|
|
14
|
+
import { migrateGetController, migratePostController } from "./lib/controllers/migrate.js";
|
|
15
|
+
|
|
16
|
+
const defaults = {
|
|
17
|
+
mountPath: "/activitypub",
|
|
18
|
+
actor: {
|
|
19
|
+
handle: "rick",
|
|
20
|
+
name: "",
|
|
21
|
+
summary: "",
|
|
22
|
+
icon: "",
|
|
23
|
+
},
|
|
24
|
+
checked: true,
|
|
25
|
+
alsoKnownAs: "",
|
|
26
|
+
activityRetentionDays: 90, // Auto-delete activities older than this (0 = keep forever)
|
|
27
|
+
storeRawActivities: false, // Store full incoming JSON (enables debugging, costs storage)
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export default class ActivityPubEndpoint {
|
|
31
|
+
name = "ActivityPub endpoint";
|
|
32
|
+
|
|
33
|
+
constructor(options = {}) {
|
|
34
|
+
this.options = { ...defaults, ...options };
|
|
35
|
+
this.options.actor = { ...defaults.actor, ...options.actor };
|
|
36
|
+
this.mountPath = this.options.mountPath;
|
|
37
|
+
|
|
38
|
+
// Set at init time when we have access to Indiekit
|
|
39
|
+
this._publicationUrl = "";
|
|
40
|
+
this._actorUrl = "";
|
|
41
|
+
this._collections = {};
|
|
42
|
+
this._federationHandler = null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
get navigationItems() {
|
|
46
|
+
return {
|
|
47
|
+
href: this.options.mountPath,
|
|
48
|
+
text: "activitypub.title",
|
|
49
|
+
requiresDatabase: true,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
get filePath() {
|
|
54
|
+
return path.dirname(new URL(import.meta.url).pathname);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* WebFinger routes — mounted at /.well-known/
|
|
59
|
+
*/
|
|
60
|
+
get routesWellKnown() {
|
|
61
|
+
const router = express.Router(); // eslint-disable-line new-cap
|
|
62
|
+
const options = this.options;
|
|
63
|
+
const self = this;
|
|
64
|
+
|
|
65
|
+
router.get("/webfinger", (request, response) => {
|
|
66
|
+
const resource = request.query.resource;
|
|
67
|
+
if (!resource) {
|
|
68
|
+
return response.status(400).json({ error: "Missing resource parameter" });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const result = handleWebFinger(resource, {
|
|
72
|
+
handle: options.actor.handle,
|
|
73
|
+
hostname: new URL(self._publicationUrl).hostname,
|
|
74
|
+
actorUrl: self._actorUrl,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
if (!result) {
|
|
78
|
+
return response.status(404).json({ error: "Resource not found" });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
response.set("Content-Type", "application/jrd+json");
|
|
82
|
+
return response.json(result);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
return router;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Public federation routes — mounted at mountPath, unauthenticated
|
|
90
|
+
*/
|
|
91
|
+
get routesPublic() {
|
|
92
|
+
const router = express.Router(); // eslint-disable-line new-cap
|
|
93
|
+
const self = this;
|
|
94
|
+
|
|
95
|
+
// Actor document (fallback — primary is content negotiation on /)
|
|
96
|
+
router.get("/actor", async (request, response) => {
|
|
97
|
+
const actor = await self._getActorDocument();
|
|
98
|
+
if (!actor) {
|
|
99
|
+
return response.status(500).json({ error: "Actor not configured" });
|
|
100
|
+
}
|
|
101
|
+
response.set("Content-Type", "application/activity+json");
|
|
102
|
+
return response.json(actor);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// Inbox — receive incoming activities
|
|
106
|
+
router.post("/inbox", express.raw({ type: ["application/activity+json", "application/ld+json", "application/json"] }), async (request, response, next) => {
|
|
107
|
+
try {
|
|
108
|
+
if (self._federationHandler) {
|
|
109
|
+
return await self._federationHandler.handleInbox(request, response);
|
|
110
|
+
}
|
|
111
|
+
return response.status(202).json({ status: "accepted" });
|
|
112
|
+
} catch (error) {
|
|
113
|
+
next(error);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// Outbox — serve published posts as ActivityStreams
|
|
118
|
+
router.get("/outbox", async (request, response, next) => {
|
|
119
|
+
try {
|
|
120
|
+
if (self._federationHandler) {
|
|
121
|
+
return await self._federationHandler.handleOutbox(request, response);
|
|
122
|
+
}
|
|
123
|
+
response.set("Content-Type", "application/activity+json");
|
|
124
|
+
return response.json({
|
|
125
|
+
"@context": "https://www.w3.org/ns/activitystreams",
|
|
126
|
+
type: "OrderedCollection",
|
|
127
|
+
totalItems: 0,
|
|
128
|
+
orderedItems: [],
|
|
129
|
+
});
|
|
130
|
+
} catch (error) {
|
|
131
|
+
next(error);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// Followers collection
|
|
136
|
+
router.get("/followers", async (request, response, next) => {
|
|
137
|
+
try {
|
|
138
|
+
if (self._federationHandler) {
|
|
139
|
+
return await self._federationHandler.handleFollowers(request, response);
|
|
140
|
+
}
|
|
141
|
+
response.set("Content-Type", "application/activity+json");
|
|
142
|
+
return response.json({
|
|
143
|
+
"@context": "https://www.w3.org/ns/activitystreams",
|
|
144
|
+
type: "OrderedCollection",
|
|
145
|
+
totalItems: 0,
|
|
146
|
+
orderedItems: [],
|
|
147
|
+
});
|
|
148
|
+
} catch (error) {
|
|
149
|
+
next(error);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// Following collection
|
|
154
|
+
router.get("/following", async (request, response, next) => {
|
|
155
|
+
try {
|
|
156
|
+
if (self._federationHandler) {
|
|
157
|
+
return await self._federationHandler.handleFollowing(request, response);
|
|
158
|
+
}
|
|
159
|
+
response.set("Content-Type", "application/activity+json");
|
|
160
|
+
return response.json({
|
|
161
|
+
"@context": "https://www.w3.org/ns/activitystreams",
|
|
162
|
+
type: "OrderedCollection",
|
|
163
|
+
totalItems: 0,
|
|
164
|
+
orderedItems: [],
|
|
165
|
+
});
|
|
166
|
+
} catch (error) {
|
|
167
|
+
next(error);
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
return router;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Authenticated admin routes — mounted at mountPath, behind IndieAuth
|
|
176
|
+
*/
|
|
177
|
+
get routes() {
|
|
178
|
+
const router = express.Router(); // eslint-disable-line new-cap
|
|
179
|
+
const mp = this.options.mountPath;
|
|
180
|
+
|
|
181
|
+
router.get("/", dashboardController(mp));
|
|
182
|
+
router.get("/admin/followers", followersController(mp));
|
|
183
|
+
router.get("/admin/following", followingController(mp));
|
|
184
|
+
router.get("/admin/activities", activitiesController(mp));
|
|
185
|
+
router.get("/admin/migrate", migrateGetController(mp));
|
|
186
|
+
router.post("/admin/migrate", migratePostController(mp, this.options));
|
|
187
|
+
|
|
188
|
+
return router;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Content negotiation handler — serves AS2 JSON for ActivityPub clients
|
|
193
|
+
* Registered as a separate endpoint with mountPath "/"
|
|
194
|
+
*/
|
|
195
|
+
get contentNegotiationRoutes() {
|
|
196
|
+
const router = express.Router(); // eslint-disable-line new-cap
|
|
197
|
+
const self = this;
|
|
198
|
+
|
|
199
|
+
router.get("*", async (request, response, next) => {
|
|
200
|
+
const accept = request.headers.accept || "";
|
|
201
|
+
const isActivityPub =
|
|
202
|
+
accept.includes("application/activity+json") ||
|
|
203
|
+
accept.includes("application/ld+json");
|
|
204
|
+
|
|
205
|
+
if (!isActivityPub) {
|
|
206
|
+
return next();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
// Root URL — serve actor document
|
|
211
|
+
if (request.path === "/") {
|
|
212
|
+
const actor = await self._getActorDocument();
|
|
213
|
+
if (!actor) {
|
|
214
|
+
return next();
|
|
215
|
+
}
|
|
216
|
+
response.set("Content-Type", "application/activity+json");
|
|
217
|
+
return response.json(actor);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Post URLs — look up in database and convert to AS2
|
|
221
|
+
const { application } = request.app.locals;
|
|
222
|
+
const postsCollection = application?.collections?.get("posts");
|
|
223
|
+
if (!postsCollection) {
|
|
224
|
+
return next();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Try to find a post matching this URL path
|
|
228
|
+
const requestUrl = `${self._publicationUrl}${request.path.slice(1)}`;
|
|
229
|
+
const post = await postsCollection.findOne({
|
|
230
|
+
"properties.url": requestUrl,
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
if (!post) {
|
|
234
|
+
return next();
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const activity = jf2ToActivityStreams(
|
|
238
|
+
post.properties,
|
|
239
|
+
self._actorUrl,
|
|
240
|
+
self._publicationUrl,
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
// Return the object, not the wrapping Create activity
|
|
244
|
+
const object = activity.object || activity;
|
|
245
|
+
response.set("Content-Type", "application/activity+json");
|
|
246
|
+
return response.json({
|
|
247
|
+
"@context": [
|
|
248
|
+
"https://www.w3.org/ns/activitystreams",
|
|
249
|
+
"https://w3id.org/security/v1",
|
|
250
|
+
],
|
|
251
|
+
...object,
|
|
252
|
+
});
|
|
253
|
+
} catch {
|
|
254
|
+
return next();
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
return router;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Build and cache the actor document
|
|
263
|
+
*/
|
|
264
|
+
async _getActorDocument() {
|
|
265
|
+
const keysCollection = this._collections.ap_keys;
|
|
266
|
+
if (!keysCollection) {
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const keyPair = await getOrCreateKeyPair(keysCollection, this._actorUrl);
|
|
271
|
+
return buildActorDocument({
|
|
272
|
+
actorUrl: this._actorUrl,
|
|
273
|
+
publicationUrl: this._publicationUrl,
|
|
274
|
+
mountPath: this.options.mountPath,
|
|
275
|
+
handle: this.options.actor.handle,
|
|
276
|
+
name: this.options.actor.name,
|
|
277
|
+
summary: this.options.actor.summary,
|
|
278
|
+
icon: this.options.actor.icon,
|
|
279
|
+
alsoKnownAs: this.options.alsoKnownAs,
|
|
280
|
+
publicKeyPem: keyPair.publicKeyPem,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Syndicator — delivers posts to ActivityPub followers
|
|
286
|
+
*/
|
|
287
|
+
get syndicator() {
|
|
288
|
+
const self = this;
|
|
289
|
+
return {
|
|
290
|
+
name: "ActivityPub syndicator",
|
|
291
|
+
|
|
292
|
+
get info() {
|
|
293
|
+
const hostname = self._publicationUrl
|
|
294
|
+
? new URL(self._publicationUrl).hostname
|
|
295
|
+
: "example.com";
|
|
296
|
+
return {
|
|
297
|
+
checked: self.options.checked,
|
|
298
|
+
name: `@${self.options.actor.handle}@${hostname}`,
|
|
299
|
+
uid: self._publicationUrl || "https://example.com/",
|
|
300
|
+
service: {
|
|
301
|
+
name: "ActivityPub (Fediverse)",
|
|
302
|
+
photo: "/assets/@rmdes-indiekit-endpoint-activitypub/icon.svg",
|
|
303
|
+
url: self._publicationUrl || "https://example.com/",
|
|
304
|
+
},
|
|
305
|
+
};
|
|
306
|
+
},
|
|
307
|
+
|
|
308
|
+
async syndicate(properties, publication) {
|
|
309
|
+
if (!self._federationHandler) {
|
|
310
|
+
return undefined;
|
|
311
|
+
}
|
|
312
|
+
return self._federationHandler.deliverToFollowers(
|
|
313
|
+
properties,
|
|
314
|
+
publication,
|
|
315
|
+
);
|
|
316
|
+
},
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
init(Indiekit) {
|
|
321
|
+
// Store publication URL for later use
|
|
322
|
+
this._publicationUrl = Indiekit.publication?.me
|
|
323
|
+
? Indiekit.publication.me.endsWith("/")
|
|
324
|
+
? Indiekit.publication.me
|
|
325
|
+
: `${Indiekit.publication.me}/`
|
|
326
|
+
: "";
|
|
327
|
+
this._actorUrl = this._publicationUrl;
|
|
328
|
+
|
|
329
|
+
// Register MongoDB collections
|
|
330
|
+
Indiekit.addCollection("ap_followers");
|
|
331
|
+
Indiekit.addCollection("ap_following");
|
|
332
|
+
Indiekit.addCollection("ap_activities");
|
|
333
|
+
Indiekit.addCollection("ap_keys");
|
|
334
|
+
|
|
335
|
+
// Store collection references for later use
|
|
336
|
+
this._collections = {
|
|
337
|
+
ap_followers: Indiekit.collections.get("ap_followers"),
|
|
338
|
+
ap_following: Indiekit.collections.get("ap_following"),
|
|
339
|
+
ap_activities: Indiekit.collections.get("ap_activities"),
|
|
340
|
+
ap_keys: Indiekit.collections.get("ap_keys"),
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
// Set up TTL index so ap_activities self-cleans (MongoDB handles expiry)
|
|
344
|
+
const retentionDays = this.options.activityRetentionDays;
|
|
345
|
+
if (retentionDays > 0) {
|
|
346
|
+
this._collections.ap_activities.createIndex(
|
|
347
|
+
{ receivedAt: 1 },
|
|
348
|
+
{ expireAfterSeconds: retentionDays * 86_400 },
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Initialize federation handler
|
|
353
|
+
this._federationHandler = createFederationHandler({
|
|
354
|
+
actorUrl: this._actorUrl,
|
|
355
|
+
publicationUrl: this._publicationUrl,
|
|
356
|
+
mountPath: this.options.mountPath,
|
|
357
|
+
actorConfig: this.options.actor,
|
|
358
|
+
alsoKnownAs: this.options.alsoKnownAs,
|
|
359
|
+
collections: this._collections,
|
|
360
|
+
storeRawActivities: this.options.storeRawActivities,
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
// Register as endpoint (adds routes)
|
|
364
|
+
Indiekit.addEndpoint(this);
|
|
365
|
+
|
|
366
|
+
// Register content negotiation handler as a virtual endpoint
|
|
367
|
+
Indiekit.addEndpoint({
|
|
368
|
+
name: "ActivityPub content negotiation",
|
|
369
|
+
mountPath: "/",
|
|
370
|
+
routesPublic: this.contentNegotiationRoutes,
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
// Register as syndicator (appears in post UI)
|
|
374
|
+
Indiekit.addSyndicator(this.syndicator);
|
|
375
|
+
}
|
|
376
|
+
}
|
package/lib/actor.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build an ActivityPub Person actor document.
|
|
3
|
+
*
|
|
4
|
+
* This is the identity document that remote servers fetch to learn about
|
|
5
|
+
* this actor — it contains the profile, endpoints, and the public key
|
|
6
|
+
* used to verify HTTP Signatures on outbound activities.
|
|
7
|
+
*
|
|
8
|
+
* @param {object} options
|
|
9
|
+
* @param {string} options.actorUrl - Actor URL (also the Person id)
|
|
10
|
+
* @param {string} options.publicationUrl - Publication base URL (trailing slash)
|
|
11
|
+
* @param {string} options.mountPath - Plugin mount path (e.g. "/activitypub")
|
|
12
|
+
* @param {string} options.handle - Preferred username (e.g. "rick")
|
|
13
|
+
* @param {string} options.name - Display name
|
|
14
|
+
* @param {string} options.summary - Bio / profile summary
|
|
15
|
+
* @param {string} options.icon - Avatar URL or path
|
|
16
|
+
* @param {string} options.alsoKnownAs - Previous account URL (for Mastodon migration)
|
|
17
|
+
* @param {string} options.publicKeyPem - PEM-encoded RSA public key
|
|
18
|
+
* @returns {object} ActivityStreams Person document
|
|
19
|
+
*/
|
|
20
|
+
export function buildActorDocument(options) {
|
|
21
|
+
const {
|
|
22
|
+
actorUrl,
|
|
23
|
+
publicationUrl,
|
|
24
|
+
mountPath,
|
|
25
|
+
handle,
|
|
26
|
+
name,
|
|
27
|
+
summary,
|
|
28
|
+
icon,
|
|
29
|
+
alsoKnownAs,
|
|
30
|
+
publicKeyPem,
|
|
31
|
+
} = options;
|
|
32
|
+
|
|
33
|
+
const baseUrl = publicationUrl.replace(/\/$/, "");
|
|
34
|
+
|
|
35
|
+
const actor = {
|
|
36
|
+
"@context": [
|
|
37
|
+
"https://www.w3.org/ns/activitystreams",
|
|
38
|
+
"https://w3id.org/security/v1",
|
|
39
|
+
],
|
|
40
|
+
type: "Person",
|
|
41
|
+
id: actorUrl,
|
|
42
|
+
preferredUsername: handle,
|
|
43
|
+
name: name || handle,
|
|
44
|
+
url: actorUrl,
|
|
45
|
+
inbox: `${baseUrl}${mountPath}/inbox`,
|
|
46
|
+
outbox: `${baseUrl}${mountPath}/outbox`,
|
|
47
|
+
followers: `${baseUrl}${mountPath}/followers`,
|
|
48
|
+
following: `${baseUrl}${mountPath}/following`,
|
|
49
|
+
publicKey: {
|
|
50
|
+
id: `${actorUrl}#main-key`,
|
|
51
|
+
owner: actorUrl,
|
|
52
|
+
publicKeyPem,
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
if (summary) {
|
|
57
|
+
actor.summary = summary;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (icon) {
|
|
61
|
+
const iconUrl = icon.startsWith("http") ? icon : `${baseUrl}${icon.startsWith("/") ? "" : "/"}${icon}`;
|
|
62
|
+
actor.icon = {
|
|
63
|
+
type: "Image",
|
|
64
|
+
url: iconUrl,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (alsoKnownAs) {
|
|
69
|
+
actor.alsoKnownAs = Array.isArray(alsoKnownAs)
|
|
70
|
+
? alsoKnownAs
|
|
71
|
+
: [alsoKnownAs];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return actor;
|
|
75
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Activity log controller — paginated list of inbound/outbound activities.
|
|
3
|
+
*/
|
|
4
|
+
const PAGE_SIZE = 20;
|
|
5
|
+
|
|
6
|
+
export function activitiesController(mountPath) {
|
|
7
|
+
return async (request, response, next) => {
|
|
8
|
+
try {
|
|
9
|
+
const { application } = request.app.locals;
|
|
10
|
+
const collection = application?.collections?.get("ap_activities");
|
|
11
|
+
|
|
12
|
+
if (!collection) {
|
|
13
|
+
return response.render("activities", {
|
|
14
|
+
title: response.locals.__("activitypub.activities"),
|
|
15
|
+
activities: [],
|
|
16
|
+
mountPath,
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const page = Math.max(1, Number.parseInt(request.query.page, 10) || 1);
|
|
21
|
+
const totalCount = await collection.countDocuments();
|
|
22
|
+
const totalPages = Math.ceil(totalCount / PAGE_SIZE);
|
|
23
|
+
|
|
24
|
+
const activities = await collection
|
|
25
|
+
.find()
|
|
26
|
+
.sort({ receivedAt: -1 })
|
|
27
|
+
.skip((page - 1) * PAGE_SIZE)
|
|
28
|
+
.limit(PAGE_SIZE)
|
|
29
|
+
.toArray();
|
|
30
|
+
|
|
31
|
+
const cursor = buildCursor(page, totalPages, mountPath + "/admin/activities");
|
|
32
|
+
|
|
33
|
+
response.render("activities", {
|
|
34
|
+
title: response.locals.__("activitypub.activities"),
|
|
35
|
+
activities,
|
|
36
|
+
mountPath,
|
|
37
|
+
cursor,
|
|
38
|
+
});
|
|
39
|
+
} catch (error) {
|
|
40
|
+
next(error);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function buildCursor(page, totalPages, basePath) {
|
|
46
|
+
if (totalPages <= 1) return null;
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
previous: page > 1
|
|
50
|
+
? { href: `${basePath}?page=${page - 1}` }
|
|
51
|
+
: undefined,
|
|
52
|
+
next: page < totalPages
|
|
53
|
+
? { href: `${basePath}?page=${page + 1}` }
|
|
54
|
+
: undefined,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dashboard controller — shows follower/following counts and recent activity.
|
|
3
|
+
*/
|
|
4
|
+
export function dashboardController(mountPath) {
|
|
5
|
+
return async (request, response, next) => {
|
|
6
|
+
try {
|
|
7
|
+
const { application } = request.app.locals;
|
|
8
|
+
const followersCollection = application?.collections?.get("ap_followers");
|
|
9
|
+
const followingCollection = application?.collections?.get("ap_following");
|
|
10
|
+
const activitiesCollection =
|
|
11
|
+
application?.collections?.get("ap_activities");
|
|
12
|
+
|
|
13
|
+
const followerCount = followersCollection
|
|
14
|
+
? await followersCollection.countDocuments()
|
|
15
|
+
: 0;
|
|
16
|
+
const followingCount = followingCollection
|
|
17
|
+
? await followingCollection.countDocuments()
|
|
18
|
+
: 0;
|
|
19
|
+
|
|
20
|
+
const recentActivities = activitiesCollection
|
|
21
|
+
? await activitiesCollection
|
|
22
|
+
.find()
|
|
23
|
+
.sort({ receivedAt: -1 })
|
|
24
|
+
.limit(10)
|
|
25
|
+
.toArray()
|
|
26
|
+
: [];
|
|
27
|
+
|
|
28
|
+
response.render("dashboard", {
|
|
29
|
+
title: response.locals.__("activitypub.title"),
|
|
30
|
+
followerCount,
|
|
31
|
+
followingCount,
|
|
32
|
+
recentActivities,
|
|
33
|
+
mountPath,
|
|
34
|
+
});
|
|
35
|
+
} catch (error) {
|
|
36
|
+
next(error);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Followers list controller — paginated list of accounts following this actor.
|
|
3
|
+
*/
|
|
4
|
+
const PAGE_SIZE = 20;
|
|
5
|
+
|
|
6
|
+
export function followersController(mountPath) {
|
|
7
|
+
return async (request, response, next) => {
|
|
8
|
+
try {
|
|
9
|
+
const { application } = request.app.locals;
|
|
10
|
+
const collection = application?.collections?.get("ap_followers");
|
|
11
|
+
|
|
12
|
+
if (!collection) {
|
|
13
|
+
return response.render("followers", {
|
|
14
|
+
title: response.locals.__("activitypub.followers"),
|
|
15
|
+
followers: [],
|
|
16
|
+
followerCount: 0,
|
|
17
|
+
mountPath,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const page = Math.max(1, Number.parseInt(request.query.page, 10) || 1);
|
|
22
|
+
const totalCount = await collection.countDocuments();
|
|
23
|
+
const totalPages = Math.ceil(totalCount / PAGE_SIZE);
|
|
24
|
+
|
|
25
|
+
const followers = await collection
|
|
26
|
+
.find()
|
|
27
|
+
.sort({ followedAt: -1 })
|
|
28
|
+
.skip((page - 1) * PAGE_SIZE)
|
|
29
|
+
.limit(PAGE_SIZE)
|
|
30
|
+
.toArray();
|
|
31
|
+
|
|
32
|
+
const cursor = buildCursor(page, totalPages, mountPath + "/admin/followers");
|
|
33
|
+
|
|
34
|
+
response.render("followers", {
|
|
35
|
+
title: response.locals.__("activitypub.followers"),
|
|
36
|
+
followers,
|
|
37
|
+
followerCount: totalCount,
|
|
38
|
+
mountPath,
|
|
39
|
+
cursor,
|
|
40
|
+
});
|
|
41
|
+
} catch (error) {
|
|
42
|
+
next(error);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function buildCursor(page, totalPages, basePath) {
|
|
48
|
+
if (totalPages <= 1) return null;
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
previous: page > 1
|
|
52
|
+
? { href: `${basePath}?page=${page - 1}` }
|
|
53
|
+
: undefined,
|
|
54
|
+
next: page < totalPages
|
|
55
|
+
? { href: `${basePath}?page=${page + 1}` }
|
|
56
|
+
: undefined,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Following list controller — paginated list of accounts this actor follows.
|
|
3
|
+
*/
|
|
4
|
+
const PAGE_SIZE = 20;
|
|
5
|
+
|
|
6
|
+
export function followingController(mountPath) {
|
|
7
|
+
return async (request, response, next) => {
|
|
8
|
+
try {
|
|
9
|
+
const { application } = request.app.locals;
|
|
10
|
+
const collection = application?.collections?.get("ap_following");
|
|
11
|
+
|
|
12
|
+
if (!collection) {
|
|
13
|
+
return response.render("following", {
|
|
14
|
+
title: response.locals.__("activitypub.following"),
|
|
15
|
+
following: [],
|
|
16
|
+
followingCount: 0,
|
|
17
|
+
mountPath,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const page = Math.max(1, Number.parseInt(request.query.page, 10) || 1);
|
|
22
|
+
const totalCount = await collection.countDocuments();
|
|
23
|
+
const totalPages = Math.ceil(totalCount / PAGE_SIZE);
|
|
24
|
+
|
|
25
|
+
const following = await collection
|
|
26
|
+
.find()
|
|
27
|
+
.sort({ followedAt: -1 })
|
|
28
|
+
.skip((page - 1) * PAGE_SIZE)
|
|
29
|
+
.limit(PAGE_SIZE)
|
|
30
|
+
.toArray();
|
|
31
|
+
|
|
32
|
+
const cursor = buildCursor(page, totalPages, mountPath + "/admin/following");
|
|
33
|
+
|
|
34
|
+
response.render("following", {
|
|
35
|
+
title: response.locals.__("activitypub.following"),
|
|
36
|
+
following,
|
|
37
|
+
followingCount: totalCount,
|
|
38
|
+
mountPath,
|
|
39
|
+
cursor,
|
|
40
|
+
});
|
|
41
|
+
} catch (error) {
|
|
42
|
+
next(error);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function buildCursor(page, totalPages, basePath) {
|
|
48
|
+
if (totalPages <= 1) return null;
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
previous: page > 1
|
|
52
|
+
? { href: `${basePath}?page=${page - 1}` }
|
|
53
|
+
: undefined,
|
|
54
|
+
next: page < totalPages
|
|
55
|
+
? { href: `${basePath}?page=${page + 1}` }
|
|
56
|
+
: undefined,
|
|
57
|
+
};
|
|
58
|
+
}
|