@rmdes/indiekit-endpoint-activitypub 0.1.10 → 1.0.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/index.js +155 -174
- package/lib/controllers/migrate.js +23 -5
- package/lib/controllers/profile.js +71 -0
- package/lib/federation-bridge.js +119 -0
- package/lib/federation-setup.js +321 -0
- package/lib/inbox-listeners.js +215 -0
- package/lib/jf2-to-as2.js +262 -63
- package/lib/kv-store.js +55 -0
- package/locales/en.json +18 -0
- package/package.json +2 -1
- package/views/activitypub-dashboard.njk +4 -0
- package/views/activitypub-profile.njk +74 -0
- package/lib/actor.js +0 -75
- package/lib/federation.js +0 -410
- package/lib/inbox.js +0 -291
- package/lib/keys.js +0 -39
- package/lib/webfinger.js +0 -43
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Express ↔ Fedify bridge.
|
|
3
|
+
*
|
|
4
|
+
* Converts Express requests to standard Request objects and delegates
|
|
5
|
+
* to federation.fetch(). We can't use @fedify/express's integrateFederation()
|
|
6
|
+
* because Indiekit plugins mount routes at a sub-path (e.g. /activitypub),
|
|
7
|
+
* which causes req.url to lose the mount prefix. Instead, we use
|
|
8
|
+
* req.originalUrl to preserve the full path that Fedify's URI templates expect.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { Readable } from "node:stream";
|
|
12
|
+
import { Buffer } from "node:buffer";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Convert an Express request to a standard Request with the full URL.
|
|
16
|
+
*
|
|
17
|
+
* @param {import("express").Request} req - Express request
|
|
18
|
+
* @returns {Request} Standard Request object
|
|
19
|
+
*/
|
|
20
|
+
export function fromExpressRequest(req) {
|
|
21
|
+
const url = `${req.protocol}://${req.get("host")}${req.originalUrl}`;
|
|
22
|
+
const headers = new Headers();
|
|
23
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
24
|
+
if (Array.isArray(value)) {
|
|
25
|
+
for (const v of value) headers.append(key, v);
|
|
26
|
+
} else if (typeof value === "string") {
|
|
27
|
+
headers.append(key, value);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return new Request(url, {
|
|
32
|
+
method: req.method,
|
|
33
|
+
headers,
|
|
34
|
+
duplex: "half",
|
|
35
|
+
body:
|
|
36
|
+
req.method === "GET" || req.method === "HEAD"
|
|
37
|
+
? undefined
|
|
38
|
+
: Readable.toWeb(req),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Send a standard Response back through Express.
|
|
44
|
+
*
|
|
45
|
+
* @param {import("express").Response} res - Express response
|
|
46
|
+
* @param {Response} response - Standard Response from federation.fetch()
|
|
47
|
+
*/
|
|
48
|
+
async function sendFedifyResponse(res, response) {
|
|
49
|
+
res.status(response.status);
|
|
50
|
+
response.headers.forEach((value, key) => {
|
|
51
|
+
res.setHeader(key, value);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
if (!response.body) {
|
|
55
|
+
res.end();
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const reader = response.body.getReader();
|
|
60
|
+
await new Promise((resolve) => {
|
|
61
|
+
function read({ done, value }) {
|
|
62
|
+
if (done) {
|
|
63
|
+
reader.releaseLock();
|
|
64
|
+
resolve();
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
res.write(Buffer.from(value));
|
|
68
|
+
reader.read().then(read);
|
|
69
|
+
}
|
|
70
|
+
reader.read().then(read);
|
|
71
|
+
});
|
|
72
|
+
res.end();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Create Express middleware that delegates to Fedify's federation.fetch().
|
|
77
|
+
*
|
|
78
|
+
* On 404 (Fedify didn't match), calls next().
|
|
79
|
+
* On 406 (not acceptable), calls next() so Express can try other handlers.
|
|
80
|
+
* Otherwise, sends the Fedify response directly.
|
|
81
|
+
*
|
|
82
|
+
* @param {import("@fedify/fedify").Federation} federation
|
|
83
|
+
* @param {Function} contextDataFactory - (req) => contextData
|
|
84
|
+
* @returns {import("express").RequestHandler}
|
|
85
|
+
*/
|
|
86
|
+
export function createFedifyMiddleware(federation, contextDataFactory) {
|
|
87
|
+
return async (req, res, next) => {
|
|
88
|
+
try {
|
|
89
|
+
const request = fromExpressRequest(req);
|
|
90
|
+
const contextData = await Promise.resolve(contextDataFactory(req));
|
|
91
|
+
|
|
92
|
+
let notFound = false;
|
|
93
|
+
let notAcceptable = false;
|
|
94
|
+
|
|
95
|
+
const response = await federation.fetch(request, {
|
|
96
|
+
contextData,
|
|
97
|
+
onNotFound: () => {
|
|
98
|
+
notFound = true;
|
|
99
|
+
return new Response("Not found", { status: 404 });
|
|
100
|
+
},
|
|
101
|
+
onNotAcceptable: () => {
|
|
102
|
+
notAcceptable = true;
|
|
103
|
+
return new Response("Not acceptable", {
|
|
104
|
+
status: 406,
|
|
105
|
+
headers: { "Content-Type": "text/plain", Vary: "Accept" },
|
|
106
|
+
});
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
if (notFound || notAcceptable) {
|
|
111
|
+
return next();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
await sendFedifyResponse(res, response);
|
|
115
|
+
} catch (error) {
|
|
116
|
+
next(error);
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
}
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fedify Federation setup — configures the Federation instance with all
|
|
3
|
+
* dispatchers, inbox listeners, and collection handlers.
|
|
4
|
+
*
|
|
5
|
+
* This replaces the hand-rolled federation.js, actor.js, keys.js, webfinger.js,
|
|
6
|
+
* and inbox.js with Fedify's battle-tested implementations.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Temporal } from "@js-temporal/polyfill";
|
|
10
|
+
import {
|
|
11
|
+
Endpoints,
|
|
12
|
+
Image,
|
|
13
|
+
InProcessMessageQueue,
|
|
14
|
+
Person,
|
|
15
|
+
PropertyValue,
|
|
16
|
+
createFederation,
|
|
17
|
+
importSpki,
|
|
18
|
+
} from "@fedify/fedify";
|
|
19
|
+
import { MongoKvStore } from "./kv-store.js";
|
|
20
|
+
import { registerInboxListeners } from "./inbox-listeners.js";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Create and configure a Fedify Federation instance.
|
|
24
|
+
*
|
|
25
|
+
* @param {object} options
|
|
26
|
+
* @param {object} options.collections - MongoDB collections
|
|
27
|
+
* @param {string} options.mountPath - Plugin mount path (e.g. "/activitypub")
|
|
28
|
+
* @param {string} options.handle - Actor handle (e.g. "rick")
|
|
29
|
+
* @param {boolean} options.storeRawActivities - Whether to store full raw JSON
|
|
30
|
+
* @returns {{ federation: import("@fedify/fedify").Federation }}
|
|
31
|
+
*/
|
|
32
|
+
export function setupFederation(options) {
|
|
33
|
+
const {
|
|
34
|
+
collections,
|
|
35
|
+
mountPath,
|
|
36
|
+
handle,
|
|
37
|
+
storeRawActivities = false,
|
|
38
|
+
} = options;
|
|
39
|
+
|
|
40
|
+
const federation = createFederation({
|
|
41
|
+
kv: new MongoKvStore(collections.ap_kv),
|
|
42
|
+
queue: new InProcessMessageQueue(),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// --- Actor dispatcher ---
|
|
46
|
+
federation
|
|
47
|
+
.setActorDispatcher(
|
|
48
|
+
`${mountPath}/users/{identifier}`,
|
|
49
|
+
async (ctx, identifier) => {
|
|
50
|
+
if (identifier !== handle) return null;
|
|
51
|
+
|
|
52
|
+
const profile = await getProfile(collections);
|
|
53
|
+
const keyPairs = await ctx.getActorKeyPairs(identifier);
|
|
54
|
+
|
|
55
|
+
const personOptions = {
|
|
56
|
+
id: ctx.getActorUri(identifier),
|
|
57
|
+
preferredUsername: identifier,
|
|
58
|
+
name: profile.name || identifier,
|
|
59
|
+
url: profile.url ? new URL(profile.url) : null,
|
|
60
|
+
inbox: ctx.getInboxUri(identifier),
|
|
61
|
+
outbox: ctx.getOutboxUri(identifier),
|
|
62
|
+
followers: ctx.getFollowersUri(identifier),
|
|
63
|
+
following: ctx.getFollowingUri(identifier),
|
|
64
|
+
endpoints: new Endpoints({ sharedInbox: ctx.getInboxUri() }),
|
|
65
|
+
manuallyApprovesFollowers:
|
|
66
|
+
profile.manuallyApprovesFollowers || false,
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
if (profile.summary) {
|
|
70
|
+
personOptions.summary = profile.summary;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (profile.icon) {
|
|
74
|
+
personOptions.icon = new Image({
|
|
75
|
+
url: new URL(profile.icon),
|
|
76
|
+
mediaType: guessImageMediaType(profile.icon),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (profile.image) {
|
|
81
|
+
personOptions.image = new Image({
|
|
82
|
+
url: new URL(profile.image),
|
|
83
|
+
mediaType: guessImageMediaType(profile.image),
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (keyPairs.length > 0) {
|
|
88
|
+
personOptions.publicKey = keyPairs[0].cryptographicKey;
|
|
89
|
+
personOptions.assertionMethod = keyPairs[0].multikey;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (profile.attachments?.length > 0) {
|
|
93
|
+
personOptions.attachments = profile.attachments.map(
|
|
94
|
+
(att) => new PropertyValue({ name: att.name, value: att.value }),
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (profile.alsoKnownAs?.length > 0) {
|
|
99
|
+
personOptions.alsoKnownAs = profile.alsoKnownAs.map(
|
|
100
|
+
(u) => new URL(u),
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (profile.createdAt) {
|
|
105
|
+
personOptions.published = Temporal.Instant.from(profile.createdAt);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return new Person(personOptions);
|
|
109
|
+
},
|
|
110
|
+
)
|
|
111
|
+
.setKeyPairsDispatcher(async (ctx, identifier) => {
|
|
112
|
+
if (identifier !== handle) return [];
|
|
113
|
+
|
|
114
|
+
const legacyKey = await collections.ap_keys.findOne({});
|
|
115
|
+
if (legacyKey?.publicKeyPem && legacyKey?.privateKeyPem) {
|
|
116
|
+
try {
|
|
117
|
+
const publicKey = await importSpki(legacyKey.publicKeyPem, "RSA");
|
|
118
|
+
const privateKey = await importPkcs8Pem(legacyKey.privateKeyPem);
|
|
119
|
+
return [{ publicKey, privateKey }];
|
|
120
|
+
} catch {
|
|
121
|
+
console.warn(
|
|
122
|
+
"[ActivityPub] Could not import legacy RSA keys, generating new key pairs",
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return [];
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// --- Inbox listeners ---
|
|
131
|
+
const inboxChain = federation.setInboxListeners(
|
|
132
|
+
`${mountPath}/users/{identifier}/inbox`,
|
|
133
|
+
`${mountPath}/inbox`,
|
|
134
|
+
);
|
|
135
|
+
registerInboxListeners(inboxChain, {
|
|
136
|
+
collections,
|
|
137
|
+
handle,
|
|
138
|
+
storeRawActivities,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// --- Collection dispatchers ---
|
|
142
|
+
setupFollowers(federation, mountPath, handle, collections);
|
|
143
|
+
setupFollowing(federation, mountPath, handle, collections);
|
|
144
|
+
setupOutbox(federation, mountPath, handle, collections);
|
|
145
|
+
|
|
146
|
+
// --- NodeInfo ---
|
|
147
|
+
federation.setNodeInfoDispatcher("/nodeinfo/2.1", async () => {
|
|
148
|
+
const postsCount = collections.posts
|
|
149
|
+
? await collections.posts.countDocuments()
|
|
150
|
+
: 0;
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
software: {
|
|
154
|
+
name: "indiekit",
|
|
155
|
+
version: { major: 1, minor: 0, patch: 0 },
|
|
156
|
+
},
|
|
157
|
+
protocols: ["activitypub"],
|
|
158
|
+
usage: {
|
|
159
|
+
users: { total: 1, activeMonth: 1, activeHalfyear: 1 },
|
|
160
|
+
localPosts: postsCount,
|
|
161
|
+
localComments: 0,
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
return { federation };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// --- Collection setup helpers ---
|
|
170
|
+
|
|
171
|
+
function setupFollowers(federation, mountPath, handle, collections) {
|
|
172
|
+
federation
|
|
173
|
+
.setFollowersDispatcher(
|
|
174
|
+
`${mountPath}/users/{identifier}/followers`,
|
|
175
|
+
async (ctx, identifier, cursor) => {
|
|
176
|
+
if (identifier !== handle) return null;
|
|
177
|
+
const pageSize = 20;
|
|
178
|
+
const skip = cursor ? Number.parseInt(cursor, 10) : 0;
|
|
179
|
+
const docs = await collections.ap_followers
|
|
180
|
+
.find()
|
|
181
|
+
.sort({ followedAt: -1 })
|
|
182
|
+
.skip(skip)
|
|
183
|
+
.limit(pageSize)
|
|
184
|
+
.toArray();
|
|
185
|
+
const total = await collections.ap_followers.countDocuments();
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
items: docs.map((f) => new URL(f.actorUrl)),
|
|
189
|
+
nextCursor:
|
|
190
|
+
skip + pageSize < total ? String(skip + pageSize) : null,
|
|
191
|
+
};
|
|
192
|
+
},
|
|
193
|
+
)
|
|
194
|
+
.setCounter(async (ctx, identifier) => {
|
|
195
|
+
if (identifier !== handle) return 0;
|
|
196
|
+
return await collections.ap_followers.countDocuments();
|
|
197
|
+
})
|
|
198
|
+
.setFirstCursor(async () => "0");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function setupFollowing(federation, mountPath, handle, collections) {
|
|
202
|
+
federation
|
|
203
|
+
.setFollowingDispatcher(
|
|
204
|
+
`${mountPath}/users/{identifier}/following`,
|
|
205
|
+
async (ctx, identifier, cursor) => {
|
|
206
|
+
if (identifier !== handle) return null;
|
|
207
|
+
const pageSize = 20;
|
|
208
|
+
const skip = cursor ? Number.parseInt(cursor, 10) : 0;
|
|
209
|
+
const docs = await collections.ap_following
|
|
210
|
+
.find()
|
|
211
|
+
.sort({ followedAt: -1 })
|
|
212
|
+
.skip(skip)
|
|
213
|
+
.limit(pageSize)
|
|
214
|
+
.toArray();
|
|
215
|
+
const total = await collections.ap_following.countDocuments();
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
items: docs.map((f) => new URL(f.actorUrl)),
|
|
219
|
+
nextCursor:
|
|
220
|
+
skip + pageSize < total ? String(skip + pageSize) : null,
|
|
221
|
+
};
|
|
222
|
+
},
|
|
223
|
+
)
|
|
224
|
+
.setCounter(async (ctx, identifier) => {
|
|
225
|
+
if (identifier !== handle) return 0;
|
|
226
|
+
return await collections.ap_following.countDocuments();
|
|
227
|
+
})
|
|
228
|
+
.setFirstCursor(async () => "0");
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function setupOutbox(federation, mountPath, handle, collections) {
|
|
232
|
+
federation
|
|
233
|
+
.setOutboxDispatcher(
|
|
234
|
+
`${mountPath}/users/{identifier}/outbox`,
|
|
235
|
+
async (ctx, identifier, cursor) => {
|
|
236
|
+
if (identifier !== handle) return null;
|
|
237
|
+
|
|
238
|
+
const postsCollection = collections.posts;
|
|
239
|
+
if (!postsCollection) return { items: [] };
|
|
240
|
+
|
|
241
|
+
const pageSize = 20;
|
|
242
|
+
const skip = cursor ? Number.parseInt(cursor, 10) : 0;
|
|
243
|
+
const total = await postsCollection.countDocuments();
|
|
244
|
+
|
|
245
|
+
const posts = await postsCollection
|
|
246
|
+
.find()
|
|
247
|
+
.sort({ "properties.published": -1 })
|
|
248
|
+
.skip(skip)
|
|
249
|
+
.limit(pageSize)
|
|
250
|
+
.toArray();
|
|
251
|
+
|
|
252
|
+
const { jf2ToAS2Activity } = await import("./jf2-to-as2.js");
|
|
253
|
+
const items = posts
|
|
254
|
+
.map((post) => {
|
|
255
|
+
try {
|
|
256
|
+
return jf2ToAS2Activity(
|
|
257
|
+
post.properties,
|
|
258
|
+
ctx.getActorUri(identifier).href,
|
|
259
|
+
collections._publicationUrl,
|
|
260
|
+
);
|
|
261
|
+
} catch {
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
})
|
|
265
|
+
.filter(Boolean);
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
items,
|
|
269
|
+
nextCursor:
|
|
270
|
+
skip + pageSize < total ? String(skip + pageSize) : null,
|
|
271
|
+
};
|
|
272
|
+
},
|
|
273
|
+
)
|
|
274
|
+
.setCounter(async (ctx, identifier) => {
|
|
275
|
+
if (identifier !== handle) return 0;
|
|
276
|
+
const postsCollection = collections.posts;
|
|
277
|
+
if (!postsCollection) return 0;
|
|
278
|
+
return await postsCollection.countDocuments();
|
|
279
|
+
})
|
|
280
|
+
.setFirstCursor(async () => "0");
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// --- Helpers ---
|
|
284
|
+
|
|
285
|
+
async function getProfile(collections) {
|
|
286
|
+
const doc = await collections.ap_profile.findOne({});
|
|
287
|
+
return doc || {};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Import a PKCS#8 PEM private key using Web Crypto API.
|
|
292
|
+
* Fedify's importPem only handles PKCS#1, but Node.js crypto generates PKCS#8.
|
|
293
|
+
*/
|
|
294
|
+
async function importPkcs8Pem(pem) {
|
|
295
|
+
const lines = pem
|
|
296
|
+
.replace("-----BEGIN PRIVATE KEY-----", "")
|
|
297
|
+
.replace("-----END PRIVATE KEY-----", "")
|
|
298
|
+
.replace(/\s/g, "");
|
|
299
|
+
const der = Uint8Array.from(atob(lines), (c) => c.charCodeAt(0));
|
|
300
|
+
return crypto.subtle.importKey(
|
|
301
|
+
"pkcs8",
|
|
302
|
+
der,
|
|
303
|
+
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
|
|
304
|
+
true,
|
|
305
|
+
["sign"],
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function guessImageMediaType(url) {
|
|
310
|
+
const ext = url.split(".").pop()?.toLowerCase();
|
|
311
|
+
const types = {
|
|
312
|
+
jpg: "image/jpeg",
|
|
313
|
+
jpeg: "image/jpeg",
|
|
314
|
+
png: "image/png",
|
|
315
|
+
gif: "image/gif",
|
|
316
|
+
webp: "image/webp",
|
|
317
|
+
svg: "image/svg+xml",
|
|
318
|
+
avif: "image/avif",
|
|
319
|
+
};
|
|
320
|
+
return types[ext] || "image/jpeg";
|
|
321
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inbox listener registrations for the Fedify Federation instance.
|
|
3
|
+
*
|
|
4
|
+
* Each listener handles a specific ActivityPub activity type received
|
|
5
|
+
* in the actor's inbox (Follow, Undo, Like, Announce, Create, Delete, Move).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
Accept,
|
|
10
|
+
Announce,
|
|
11
|
+
Create,
|
|
12
|
+
Delete,
|
|
13
|
+
Follow,
|
|
14
|
+
Like,
|
|
15
|
+
Move,
|
|
16
|
+
Note,
|
|
17
|
+
Undo,
|
|
18
|
+
} from "@fedify/fedify";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Register all inbox listeners on a federation's inbox chain.
|
|
22
|
+
*
|
|
23
|
+
* @param {object} inboxChain - Return value of federation.setInboxListeners()
|
|
24
|
+
* @param {object} options
|
|
25
|
+
* @param {object} options.collections - MongoDB collections
|
|
26
|
+
* @param {string} options.handle - Actor handle
|
|
27
|
+
* @param {boolean} options.storeRawActivities - Whether to store raw JSON
|
|
28
|
+
*/
|
|
29
|
+
export function registerInboxListeners(inboxChain, options) {
|
|
30
|
+
const { collections, handle, storeRawActivities } = options;
|
|
31
|
+
|
|
32
|
+
inboxChain
|
|
33
|
+
.on(Follow, async (ctx, follow) => {
|
|
34
|
+
const followerActor = await follow.getActor();
|
|
35
|
+
if (!followerActor?.id) return;
|
|
36
|
+
|
|
37
|
+
const followerUrl = followerActor.id.href;
|
|
38
|
+
const followerName =
|
|
39
|
+
followerActor.name?.toString() ||
|
|
40
|
+
followerActor.preferredUsername?.toString() ||
|
|
41
|
+
followerUrl;
|
|
42
|
+
|
|
43
|
+
await collections.ap_followers.updateOne(
|
|
44
|
+
{ actorUrl: followerUrl },
|
|
45
|
+
{
|
|
46
|
+
$set: {
|
|
47
|
+
actorUrl: followerUrl,
|
|
48
|
+
handle: followerActor.preferredUsername?.toString() || "",
|
|
49
|
+
name: followerName,
|
|
50
|
+
avatar: followerActor.icon
|
|
51
|
+
? (await followerActor.icon)?.url?.href || ""
|
|
52
|
+
: "",
|
|
53
|
+
inbox: followerActor.inbox?.id?.href || "",
|
|
54
|
+
sharedInbox: followerActor.endpoints?.sharedInbox?.href || "",
|
|
55
|
+
followedAt: new Date().toISOString(),
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
{ upsert: true },
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
// Auto-accept: send Accept back
|
|
62
|
+
await ctx.sendActivity(
|
|
63
|
+
{ identifier: handle },
|
|
64
|
+
followerActor,
|
|
65
|
+
new Accept({
|
|
66
|
+
actor: ctx.getActorUri(handle),
|
|
67
|
+
object: follow,
|
|
68
|
+
}),
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
await logActivity(collections, storeRawActivities, {
|
|
72
|
+
direction: "inbound",
|
|
73
|
+
type: "Follow",
|
|
74
|
+
actorUrl: followerUrl,
|
|
75
|
+
actorName: followerName,
|
|
76
|
+
summary: `${followerName} followed you`,
|
|
77
|
+
});
|
|
78
|
+
})
|
|
79
|
+
.on(Undo, async (ctx, undo) => {
|
|
80
|
+
const actorObj = await undo.getActor();
|
|
81
|
+
const actorUrl = actorObj?.id?.href || "";
|
|
82
|
+
const inner = await undo.getObject();
|
|
83
|
+
|
|
84
|
+
if (inner instanceof Follow) {
|
|
85
|
+
await collections.ap_followers.deleteOne({ actorUrl });
|
|
86
|
+
await logActivity(collections, storeRawActivities, {
|
|
87
|
+
direction: "inbound",
|
|
88
|
+
type: "Undo(Follow)",
|
|
89
|
+
actorUrl,
|
|
90
|
+
summary: `${actorUrl} unfollowed you`,
|
|
91
|
+
});
|
|
92
|
+
} else if (inner instanceof Like) {
|
|
93
|
+
const objectId = (await inner.getObject())?.id?.href || "";
|
|
94
|
+
await collections.ap_activities.deleteOne({
|
|
95
|
+
type: "Like",
|
|
96
|
+
actorUrl,
|
|
97
|
+
objectUrl: objectId,
|
|
98
|
+
});
|
|
99
|
+
} else if (inner instanceof Announce) {
|
|
100
|
+
const objectId = (await inner.getObject())?.id?.href || "";
|
|
101
|
+
await collections.ap_activities.deleteOne({
|
|
102
|
+
type: "Announce",
|
|
103
|
+
actorUrl,
|
|
104
|
+
objectUrl: objectId,
|
|
105
|
+
});
|
|
106
|
+
} else {
|
|
107
|
+
const typeName = inner?.constructor?.name || "unknown";
|
|
108
|
+
await logActivity(collections, storeRawActivities, {
|
|
109
|
+
direction: "inbound",
|
|
110
|
+
type: `Undo(${typeName})`,
|
|
111
|
+
actorUrl,
|
|
112
|
+
summary: `${actorUrl} undid ${typeName}`,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
})
|
|
116
|
+
.on(Like, async (ctx, like) => {
|
|
117
|
+
const actorObj = await like.getActor();
|
|
118
|
+
const actorUrl = actorObj?.id?.href || "";
|
|
119
|
+
const actorName =
|
|
120
|
+
actorObj?.name?.toString() ||
|
|
121
|
+
actorObj?.preferredUsername?.toString() ||
|
|
122
|
+
actorUrl;
|
|
123
|
+
const objectId = (await like.getObject())?.id?.href || "";
|
|
124
|
+
|
|
125
|
+
await logActivity(collections, storeRawActivities, {
|
|
126
|
+
direction: "inbound",
|
|
127
|
+
type: "Like",
|
|
128
|
+
actorUrl,
|
|
129
|
+
actorName,
|
|
130
|
+
objectUrl: objectId,
|
|
131
|
+
summary: `${actorName} liked ${objectId}`,
|
|
132
|
+
});
|
|
133
|
+
})
|
|
134
|
+
.on(Announce, async (ctx, announce) => {
|
|
135
|
+
const actorObj = await announce.getActor();
|
|
136
|
+
const actorUrl = actorObj?.id?.href || "";
|
|
137
|
+
const actorName =
|
|
138
|
+
actorObj?.name?.toString() ||
|
|
139
|
+
actorObj?.preferredUsername?.toString() ||
|
|
140
|
+
actorUrl;
|
|
141
|
+
const objectId = (await announce.getObject())?.id?.href || "";
|
|
142
|
+
|
|
143
|
+
await logActivity(collections, storeRawActivities, {
|
|
144
|
+
direction: "inbound",
|
|
145
|
+
type: "Announce",
|
|
146
|
+
actorUrl,
|
|
147
|
+
actorName,
|
|
148
|
+
objectUrl: objectId,
|
|
149
|
+
summary: `${actorName} boosted ${objectId}`,
|
|
150
|
+
});
|
|
151
|
+
})
|
|
152
|
+
.on(Create, async (ctx, create) => {
|
|
153
|
+
const object = await create.getObject();
|
|
154
|
+
if (!object) return;
|
|
155
|
+
|
|
156
|
+
const inReplyTo =
|
|
157
|
+
object instanceof Note
|
|
158
|
+
? (await object.getInReplyTo())?.id?.href
|
|
159
|
+
: null;
|
|
160
|
+
if (!inReplyTo) return;
|
|
161
|
+
|
|
162
|
+
const actorObj = await create.getActor();
|
|
163
|
+
const actorUrl = actorObj?.id?.href || "";
|
|
164
|
+
const actorName =
|
|
165
|
+
actorObj?.name?.toString() ||
|
|
166
|
+
actorObj?.preferredUsername?.toString() ||
|
|
167
|
+
actorUrl;
|
|
168
|
+
|
|
169
|
+
await logActivity(collections, storeRawActivities, {
|
|
170
|
+
direction: "inbound",
|
|
171
|
+
type: "Reply",
|
|
172
|
+
actorUrl,
|
|
173
|
+
actorName,
|
|
174
|
+
objectUrl: object.id?.href || "",
|
|
175
|
+
summary: `${actorName} replied to ${inReplyTo}`,
|
|
176
|
+
});
|
|
177
|
+
})
|
|
178
|
+
.on(Delete, async (ctx, del) => {
|
|
179
|
+
const objectId = (await del.getObject())?.id?.href || "";
|
|
180
|
+
if (objectId) {
|
|
181
|
+
await collections.ap_activities.deleteMany({ objectUrl: objectId });
|
|
182
|
+
}
|
|
183
|
+
})
|
|
184
|
+
.on(Move, async (ctx, move) => {
|
|
185
|
+
const oldActorObj = await move.getActor();
|
|
186
|
+
const oldActorUrl = oldActorObj?.id?.href || "";
|
|
187
|
+
const target = await move.getTarget();
|
|
188
|
+
const newActorUrl = target?.id?.href || "";
|
|
189
|
+
|
|
190
|
+
if (oldActorUrl && newActorUrl) {
|
|
191
|
+
await collections.ap_followers.updateOne(
|
|
192
|
+
{ actorUrl: oldActorUrl },
|
|
193
|
+
{ $set: { actorUrl: newActorUrl, movedFrom: oldActorUrl } },
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
await logActivity(collections, storeRawActivities, {
|
|
198
|
+
direction: "inbound",
|
|
199
|
+
type: "Move",
|
|
200
|
+
actorUrl: oldActorUrl,
|
|
201
|
+
objectUrl: newActorUrl,
|
|
202
|
+
summary: `${oldActorUrl} moved to ${newActorUrl}`,
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Log an activity to the ap_activities collection.
|
|
209
|
+
*/
|
|
210
|
+
async function logActivity(collections, storeRaw, record) {
|
|
211
|
+
await collections.ap_activities.insertOne({
|
|
212
|
+
...record,
|
|
213
|
+
receivedAt: new Date().toISOString(),
|
|
214
|
+
});
|
|
215
|
+
}
|