@rmdes/indiekit-endpoint-activitypub 2.13.0 → 2.15.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 +68 -0
- package/lib/controllers/moderation.js +80 -1
- package/lib/federation-setup.js +57 -46
- package/lib/inbox-handlers.js +1102 -0
- package/lib/inbox-listeners.js +188 -704
- package/lib/inbox-queue.js +99 -0
- package/lib/key-refresh.js +138 -0
- package/lib/outbox-failure.js +139 -0
- package/lib/redis-cache.js +16 -0
- package/lib/storage/server-blocks.js +121 -0
- package/package.json +1 -1
- package/views/activitypub-moderation.njk +77 -0
package/lib/inbox-listeners.js
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Inbox listener registrations for the Fedify Federation instance.
|
|
3
3
|
*
|
|
4
|
-
* Each listener
|
|
5
|
-
*
|
|
4
|
+
* Each listener is a thin shim that:
|
|
5
|
+
* 1. Checks server-level blocks (Redis, O(1))
|
|
6
|
+
* 2. Updates key freshness tracking
|
|
7
|
+
* 3. Performs synchronous-only work (Follow Accept, Block follower removal)
|
|
8
|
+
* 4. Enqueues the activity for async processing
|
|
6
9
|
*/
|
|
7
10
|
|
|
8
11
|
import {
|
|
9
12
|
Accept,
|
|
10
13
|
Add,
|
|
11
14
|
Announce,
|
|
12
|
-
Article,
|
|
13
15
|
Block,
|
|
14
16
|
Create,
|
|
15
17
|
Delete,
|
|
@@ -17,20 +19,18 @@ import {
|
|
|
17
19
|
Follow,
|
|
18
20
|
Like,
|
|
19
21
|
Move,
|
|
20
|
-
Note,
|
|
21
22
|
Reject,
|
|
22
23
|
Remove,
|
|
23
24
|
Undo,
|
|
24
25
|
Update,
|
|
25
26
|
} from "@fedify/fedify/vocab";
|
|
26
27
|
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
29
|
-
import {
|
|
28
|
+
import { isServerBlocked } from "./storage/server-blocks.js";
|
|
29
|
+
import { touchKeyFreshness } from "./key-refresh.js";
|
|
30
|
+
import { resetDeliveryStrikes } from "./outbox-failure.js";
|
|
31
|
+
import { enqueueActivity } from "./inbox-queue.js";
|
|
32
|
+
import { extractActorInfo } from "./timeline-store.js";
|
|
30
33
|
import { addNotification } from "./storage/notifications.js";
|
|
31
|
-
import { addMessage } from "./storage/messages.js";
|
|
32
|
-
import { fetchAndStorePreviews, fetchAndStoreQuote } from "./og-unfurl.js";
|
|
33
|
-
import { getFollowedTags } from "./storage/followed-tags.js";
|
|
34
34
|
|
|
35
35
|
/**
|
|
36
36
|
* Register all inbox listeners on a federation's inbox chain.
|
|
@@ -41,54 +41,21 @@ import { getFollowedTags } from "./storage/followed-tags.js";
|
|
|
41
41
|
* @param {string} options.handle - Actor handle
|
|
42
42
|
* @param {boolean} options.storeRawActivities - Whether to store raw JSON
|
|
43
43
|
*/
|
|
44
|
-
/** @type {string} ActivityStreams Public Collection constant */
|
|
45
|
-
const PUBLIC = "https://www.w3.org/ns/activitystreams#Public";
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* Determine if an object is a direct message (DM).
|
|
49
|
-
* A DM is addressed only to specific actors — no PUBLIC_COLLECTION,
|
|
50
|
-
* no followers collection, and includes our actor URL.
|
|
51
|
-
*
|
|
52
|
-
* @param {object} object - Fedify object (Note, Article, etc.)
|
|
53
|
-
* @param {string} ourActorUrl - Our actor's URL
|
|
54
|
-
* @param {string} followersUrl - Our followers collection URL
|
|
55
|
-
* @returns {boolean}
|
|
56
|
-
*/
|
|
57
|
-
function isDirectMessage(object, ourActorUrl, followersUrl) {
|
|
58
|
-
const allAddressed = [
|
|
59
|
-
...object.toIds.map((u) => u.href),
|
|
60
|
-
...object.ccIds.map((u) => u.href),
|
|
61
|
-
...object.btoIds.map((u) => u.href),
|
|
62
|
-
...object.bccIds.map((u) => u.href),
|
|
63
|
-
];
|
|
64
|
-
|
|
65
|
-
// Must be addressed to us
|
|
66
|
-
if (!allAddressed.includes(ourActorUrl)) return false;
|
|
67
|
-
|
|
68
|
-
// Must NOT include public collection
|
|
69
|
-
if (allAddressed.some((u) => u === PUBLIC || u === "as:Public")) return false;
|
|
70
|
-
|
|
71
|
-
// Must NOT include our followers collection
|
|
72
|
-
if (followersUrl && allAddressed.includes(followersUrl)) return false;
|
|
73
|
-
|
|
74
|
-
return true;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
44
|
export function registerInboxListeners(inboxChain, options) {
|
|
78
|
-
const { collections, handle
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* Get an authenticated DocumentLoader that signs outbound fetches with
|
|
82
|
-
* our actor's key. This allows .getActor()/.getObject() to succeed
|
|
83
|
-
* against Authorized Fetch (Secure Mode) servers like hachyderm.io.
|
|
84
|
-
*
|
|
85
|
-
* @param {import("@fedify/fedify").Context} ctx - Fedify context
|
|
86
|
-
* @returns {Promise<import("@fedify/fedify").DocumentLoader>}
|
|
87
|
-
*/
|
|
45
|
+
const { collections, handle } = options;
|
|
46
|
+
|
|
88
47
|
const getAuthLoader = (ctx) => ctx.getDocumentLoader({ identifier: handle });
|
|
89
48
|
|
|
90
49
|
inboxChain
|
|
50
|
+
// ── Follow ──────────────────────────────────────────────────────
|
|
51
|
+
// Synchronous: Accept/Reject + follower storage (federation requirement)
|
|
52
|
+
// Async: notification + activity log
|
|
91
53
|
.on(Follow, async (ctx, follow) => {
|
|
54
|
+
const actorUrl = follow.actorId?.href || "";
|
|
55
|
+
if (await isServerBlocked(actorUrl, collections)) return;
|
|
56
|
+
await touchKeyFreshness(collections, actorUrl);
|
|
57
|
+
await resetDeliveryStrikes(collections, actorUrl);
|
|
58
|
+
|
|
92
59
|
const authLoader = await getAuthLoader(ctx);
|
|
93
60
|
const followerActor = await follow.getActor({ documentLoader: authLoader });
|
|
94
61
|
if (!followerActor?.id) return;
|
|
@@ -99,7 +66,6 @@ export function registerInboxListeners(inboxChain, options) {
|
|
|
99
66
|
followerActor.preferredUsername?.toString() ||
|
|
100
67
|
followerUrl;
|
|
101
68
|
|
|
102
|
-
// Build common follower data
|
|
103
69
|
const followerData = {
|
|
104
70
|
actorUrl: followerUrl,
|
|
105
71
|
handle: followerActor.preferredUsername?.toString() || "",
|
|
@@ -111,12 +77,10 @@ export function registerInboxListeners(inboxChain, options) {
|
|
|
111
77
|
sharedInbox: followerActor.endpoints?.sharedInbox?.href || "",
|
|
112
78
|
};
|
|
113
79
|
|
|
114
|
-
// Check if manual approval is enabled
|
|
115
80
|
const profile = await collections.ap_profile.findOne({});
|
|
116
81
|
const manualApproval = profile?.manuallyApprovesFollowers || false;
|
|
117
82
|
|
|
118
83
|
if (manualApproval && collections.ap_pending_follows) {
|
|
119
|
-
// Store as pending — do NOT send Accept yet
|
|
120
84
|
await collections.ap_pending_follows.updateOne(
|
|
121
85
|
{ actorUrl: followerUrl },
|
|
122
86
|
{
|
|
@@ -129,15 +93,7 @@ export function registerInboxListeners(inboxChain, options) {
|
|
|
129
93
|
{ upsert: true },
|
|
130
94
|
);
|
|
131
95
|
|
|
132
|
-
|
|
133
|
-
direction: "inbound",
|
|
134
|
-
type: "Follow",
|
|
135
|
-
actorUrl: followerUrl,
|
|
136
|
-
actorName: followerName,
|
|
137
|
-
summary: `${followerName} requested to follow you`,
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
// Notification with type "follow_request"
|
|
96
|
+
// Notification for follow request (synchronous — needed for UI)
|
|
141
97
|
const followerInfo = await extractActorInfo(followerActor, { documentLoader: authLoader });
|
|
142
98
|
await addNotification(collections, {
|
|
143
99
|
uid: follow.id?.href || `follow_request:${followerUrl}`,
|
|
@@ -150,7 +106,6 @@ export function registerInboxListeners(inboxChain, options) {
|
|
|
150
106
|
createdAt: new Date().toISOString(),
|
|
151
107
|
});
|
|
152
108
|
} else {
|
|
153
|
-
// Auto-accept: store follower + send Accept back
|
|
154
109
|
await collections.ap_followers.updateOne(
|
|
155
110
|
{ actorUrl: followerUrl },
|
|
156
111
|
{
|
|
@@ -172,15 +127,7 @@ export function registerInboxListeners(inboxChain, options) {
|
|
|
172
127
|
{ orderingKey: followerUrl },
|
|
173
128
|
);
|
|
174
129
|
|
|
175
|
-
|
|
176
|
-
direction: "inbound",
|
|
177
|
-
type: "Follow",
|
|
178
|
-
actorUrl: followerUrl,
|
|
179
|
-
actorName: followerName,
|
|
180
|
-
summary: `${followerName} followed you`,
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
// Store notification
|
|
130
|
+
// Notification for follow (synchronous — needed for UI)
|
|
184
131
|
const followerInfo = await extractActorInfo(followerActor, { documentLoader: authLoader });
|
|
185
132
|
await addNotification(collections, {
|
|
186
133
|
uid: follow.id?.href || `follow:${followerUrl}`,
|
|
@@ -193,680 +140,217 @@ export function registerInboxListeners(inboxChain, options) {
|
|
|
193
140
|
createdAt: new Date().toISOString(),
|
|
194
141
|
});
|
|
195
142
|
}
|
|
143
|
+
|
|
144
|
+
// Enqueue async portion (activity log)
|
|
145
|
+
await enqueueActivity(collections, {
|
|
146
|
+
activityType: "Follow",
|
|
147
|
+
actorUrl,
|
|
148
|
+
rawJson: await follow.toJsonLd(),
|
|
149
|
+
});
|
|
196
150
|
})
|
|
151
|
+
|
|
152
|
+
// ── Undo ────────────────────────────────────────────────────────
|
|
197
153
|
.on(Undo, async (ctx, undo) => {
|
|
198
154
|
const actorUrl = undo.actorId?.href || "";
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
inner = await undo.getObject({ documentLoader: authLoader });
|
|
203
|
-
} catch {
|
|
204
|
-
// Inner activity not dereferenceable — can't determine what was undone
|
|
205
|
-
return;
|
|
206
|
-
}
|
|
155
|
+
if (await isServerBlocked(actorUrl, collections)) return;
|
|
156
|
+
await touchKeyFreshness(collections, actorUrl);
|
|
157
|
+
await resetDeliveryStrikes(collections, actorUrl);
|
|
207
158
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
actorUrl,
|
|
214
|
-
summary: `${actorUrl} unfollowed you`,
|
|
215
|
-
});
|
|
216
|
-
} else if (inner instanceof Like) {
|
|
217
|
-
const objectId = inner.objectId?.href || "";
|
|
218
|
-
await collections.ap_activities.deleteOne({
|
|
219
|
-
type: "Like",
|
|
220
|
-
actorUrl,
|
|
221
|
-
objectUrl: objectId,
|
|
222
|
-
});
|
|
223
|
-
} else if (inner instanceof Announce) {
|
|
224
|
-
const objectId = inner.objectId?.href || "";
|
|
225
|
-
await collections.ap_activities.deleteOne({
|
|
226
|
-
type: "Announce",
|
|
227
|
-
actorUrl,
|
|
228
|
-
objectUrl: objectId,
|
|
229
|
-
});
|
|
230
|
-
} else {
|
|
231
|
-
const typeName = inner?.constructor?.name || "unknown";
|
|
232
|
-
await logActivity(collections, storeRawActivities, {
|
|
233
|
-
direction: "inbound",
|
|
234
|
-
type: `Undo(${typeName})`,
|
|
235
|
-
actorUrl,
|
|
236
|
-
summary: `${actorUrl} undid ${typeName}`,
|
|
237
|
-
});
|
|
238
|
-
}
|
|
159
|
+
await enqueueActivity(collections, {
|
|
160
|
+
activityType: "Undo",
|
|
161
|
+
actorUrl,
|
|
162
|
+
rawJson: await undo.toJsonLd(),
|
|
163
|
+
});
|
|
239
164
|
})
|
|
165
|
+
|
|
166
|
+
// ── Accept ──────────────────────────────────────────────────────
|
|
240
167
|
.on(Accept, async (ctx, accept) => {
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
const result = await collections.ap_following.findOneAndUpdate(
|
|
252
|
-
{
|
|
253
|
-
actorUrl,
|
|
254
|
-
source: { $in: ["refollow:sent", "reader", "microsub-reader"] },
|
|
255
|
-
},
|
|
256
|
-
{
|
|
257
|
-
$set: {
|
|
258
|
-
source: "federation",
|
|
259
|
-
acceptedAt: new Date().toISOString(),
|
|
260
|
-
},
|
|
261
|
-
$unset: {
|
|
262
|
-
refollowAttempts: "",
|
|
263
|
-
refollowLastAttempt: "",
|
|
264
|
-
refollowError: "",
|
|
265
|
-
},
|
|
266
|
-
},
|
|
267
|
-
{ returnDocument: "after" },
|
|
268
|
-
);
|
|
269
|
-
|
|
270
|
-
if (result) {
|
|
271
|
-
const actorName =
|
|
272
|
-
result.name || result.handle || actorUrl;
|
|
273
|
-
await logActivity(collections, storeRawActivities, {
|
|
274
|
-
direction: "inbound",
|
|
275
|
-
type: "Accept(Follow)",
|
|
276
|
-
actorUrl,
|
|
277
|
-
actorName,
|
|
278
|
-
summary: `${actorName} accepted our Follow`,
|
|
279
|
-
});
|
|
280
|
-
}
|
|
168
|
+
const actorUrl = accept.actorId?.href || "";
|
|
169
|
+
if (await isServerBlocked(actorUrl, collections)) return;
|
|
170
|
+
await touchKeyFreshness(collections, actorUrl);
|
|
171
|
+
await resetDeliveryStrikes(collections, actorUrl);
|
|
172
|
+
|
|
173
|
+
await enqueueActivity(collections, {
|
|
174
|
+
activityType: "Accept",
|
|
175
|
+
actorUrl,
|
|
176
|
+
rawJson: await accept.toJsonLd(),
|
|
177
|
+
});
|
|
281
178
|
})
|
|
179
|
+
|
|
180
|
+
// ── Reject ──────────────────────────────────────────────────────
|
|
282
181
|
.on(Reject, async (ctx, reject) => {
|
|
283
|
-
const
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
// Mark rejected follow in ap_following
|
|
289
|
-
const result = await collections.ap_following.findOneAndUpdate(
|
|
290
|
-
{
|
|
291
|
-
actorUrl,
|
|
292
|
-
source: { $in: ["refollow:sent", "reader", "microsub-reader"] },
|
|
293
|
-
},
|
|
294
|
-
{
|
|
295
|
-
$set: {
|
|
296
|
-
source: "rejected",
|
|
297
|
-
rejectedAt: new Date().toISOString(),
|
|
298
|
-
},
|
|
299
|
-
},
|
|
300
|
-
{ returnDocument: "after" },
|
|
301
|
-
);
|
|
302
|
-
|
|
303
|
-
if (result) {
|
|
304
|
-
const actorName = result.name || result.handle || actorUrl;
|
|
305
|
-
await logActivity(collections, storeRawActivities, {
|
|
306
|
-
direction: "inbound",
|
|
307
|
-
type: "Reject(Follow)",
|
|
308
|
-
actorUrl,
|
|
309
|
-
actorName,
|
|
310
|
-
summary: `${actorName} rejected our Follow`,
|
|
311
|
-
});
|
|
312
|
-
}
|
|
313
|
-
})
|
|
314
|
-
.on(Like, async (ctx, like) => {
|
|
315
|
-
// Use .objectId (non-fetching) for the liked URL — we only need the
|
|
316
|
-
// URL to filter and log, not the full remote object.
|
|
317
|
-
const objectId = like.objectId?.href || "";
|
|
182
|
+
const actorUrl = reject.actorId?.href || "";
|
|
183
|
+
if (await isServerBlocked(actorUrl, collections)) return;
|
|
184
|
+
await touchKeyFreshness(collections, actorUrl);
|
|
185
|
+
await resetDeliveryStrikes(collections, actorUrl);
|
|
318
186
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
187
|
+
await enqueueActivity(collections, {
|
|
188
|
+
activityType: "Reject",
|
|
189
|
+
actorUrl,
|
|
190
|
+
rawJson: await reject.toJsonLd(),
|
|
191
|
+
});
|
|
192
|
+
})
|
|
322
193
|
|
|
323
|
-
|
|
194
|
+
// ── Like ────────────────────────────────────────────────────────
|
|
195
|
+
.on(Like, async (ctx, like) => {
|
|
324
196
|
const actorUrl = like.actorId?.href || "";
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
} catch {
|
|
329
|
-
actorObj = null;
|
|
330
|
-
}
|
|
197
|
+
if (await isServerBlocked(actorUrl, collections)) return;
|
|
198
|
+
await touchKeyFreshness(collections, actorUrl);
|
|
199
|
+
await resetDeliveryStrikes(collections, actorUrl);
|
|
331
200
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
actorObj?.preferredUsername?.toString() ||
|
|
335
|
-
actorUrl;
|
|
336
|
-
|
|
337
|
-
// Extract actor info (including avatar) before logging so we can store it
|
|
338
|
-
const actorInfo = await extractActorInfo(actorObj, { documentLoader: authLoader });
|
|
339
|
-
|
|
340
|
-
await logActivity(collections, storeRawActivities, {
|
|
341
|
-
direction: "inbound",
|
|
342
|
-
type: "Like",
|
|
201
|
+
await enqueueActivity(collections, {
|
|
202
|
+
activityType: "Like",
|
|
343
203
|
actorUrl,
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
objectUrl: objectId,
|
|
347
|
-
summary: `${actorName} liked ${objectId}`,
|
|
348
|
-
});
|
|
349
|
-
|
|
350
|
-
// Store notification
|
|
351
|
-
await addNotification(collections, {
|
|
352
|
-
uid: like.id?.href || `like:${actorUrl}:${objectId}`,
|
|
353
|
-
type: "like",
|
|
354
|
-
actorUrl: actorInfo.url,
|
|
355
|
-
actorName: actorInfo.name,
|
|
356
|
-
actorPhoto: actorInfo.photo,
|
|
357
|
-
actorHandle: actorInfo.handle,
|
|
358
|
-
targetUrl: objectId,
|
|
359
|
-
targetName: "", // Could fetch post title, but not critical
|
|
360
|
-
published: like.published ? String(like.published) : new Date().toISOString(),
|
|
361
|
-
createdAt: new Date().toISOString(),
|
|
204
|
+
objectUrl: like.objectId?.href || "",
|
|
205
|
+
rawJson: await like.toJsonLd(),
|
|
362
206
|
});
|
|
363
207
|
})
|
|
364
|
-
.on(Announce, async (ctx, announce) => {
|
|
365
|
-
const objectId = announce.objectId?.href || "";
|
|
366
|
-
if (!objectId) return;
|
|
367
208
|
|
|
368
|
-
|
|
209
|
+
// ── Announce ────────────────────────────────────────────────────
|
|
210
|
+
.on(Announce, async (ctx, announce) => {
|
|
369
211
|
const actorUrl = announce.actorId?.href || "";
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
212
|
+
if (await isServerBlocked(actorUrl, collections)) return;
|
|
213
|
+
await touchKeyFreshness(collections, actorUrl);
|
|
214
|
+
await resetDeliveryStrikes(collections, actorUrl);
|
|
373
215
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
actorObj = null;
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
const actorName =
|
|
384
|
-
actorObj?.name?.toString() ||
|
|
385
|
-
actorObj?.preferredUsername?.toString() ||
|
|
386
|
-
actorUrl;
|
|
387
|
-
|
|
388
|
-
// Extract actor info (including avatar) before logging so we can store it
|
|
389
|
-
const actorInfo = await extractActorInfo(actorObj, { documentLoader: authLoader });
|
|
390
|
-
|
|
391
|
-
// Log the boost activity
|
|
392
|
-
await logActivity(collections, storeRawActivities, {
|
|
393
|
-
direction: "inbound",
|
|
394
|
-
type: "Announce",
|
|
395
|
-
actorUrl,
|
|
396
|
-
actorName,
|
|
397
|
-
actorAvatar: actorInfo.photo || "",
|
|
398
|
-
objectUrl: objectId,
|
|
399
|
-
summary: `${actorName} boosted ${objectId}`,
|
|
400
|
-
});
|
|
401
|
-
|
|
402
|
-
// Create notification
|
|
403
|
-
await addNotification(collections, {
|
|
404
|
-
uid: announce.id?.href || `${actorUrl}#boost-${objectId}`,
|
|
405
|
-
type: "boost",
|
|
406
|
-
actorUrl: actorInfo.url,
|
|
407
|
-
actorName: actorInfo.name,
|
|
408
|
-
actorPhoto: actorInfo.photo,
|
|
409
|
-
actorHandle: actorInfo.handle,
|
|
410
|
-
targetUrl: objectId,
|
|
411
|
-
targetName: "", // Could fetch post title, but not critical
|
|
412
|
-
published: announce.published ? String(announce.published) : new Date().toISOString(),
|
|
413
|
-
createdAt: new Date().toISOString(),
|
|
414
|
-
});
|
|
415
|
-
|
|
416
|
-
// Don't return — fall through to check if actor is also followed
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
// PATH 2: Boost from someone we follow → Timeline (store original post)
|
|
420
|
-
const following = await collections.ap_following.findOne({ actorUrl });
|
|
421
|
-
if (following) {
|
|
422
|
-
try {
|
|
423
|
-
// Fetch the original object being boosted (authenticated for Secure Mode servers)
|
|
424
|
-
const object = await announce.getObject({ documentLoader: authLoader });
|
|
425
|
-
if (!object) return;
|
|
426
|
-
|
|
427
|
-
// Skip non-content objects (Lemmy/PieFed like/create activities
|
|
428
|
-
// that resolve to activity IDs instead of actual Note/Article posts)
|
|
429
|
-
const hasContent = object.content?.toString() || object.name?.toString();
|
|
430
|
-
if (!hasContent) return;
|
|
431
|
-
|
|
432
|
-
// Get booster actor info
|
|
433
|
-
const boosterActor = await announce.getActor({ documentLoader: authLoader });
|
|
434
|
-
const boosterInfo = await extractActorInfo(boosterActor, { documentLoader: authLoader });
|
|
435
|
-
|
|
436
|
-
// Extract and store with boost metadata
|
|
437
|
-
const timelineItem = await extractObjectData(object, {
|
|
438
|
-
boostedBy: boosterInfo,
|
|
439
|
-
boostedAt: announce.published ? String(announce.published) : new Date().toISOString(),
|
|
440
|
-
documentLoader: authLoader,
|
|
441
|
-
});
|
|
442
|
-
|
|
443
|
-
await addTimelineItem(collections, timelineItem);
|
|
444
|
-
|
|
445
|
-
// Fire-and-forget quote enrichment for boosted posts
|
|
446
|
-
if (timelineItem.quoteUrl) {
|
|
447
|
-
fetchAndStoreQuote(collections, timelineItem.uid, timelineItem.quoteUrl, ctx, authLoader)
|
|
448
|
-
.catch((error) => {
|
|
449
|
-
console.error(`[inbox] Quote fetch failed for ${timelineItem.uid}:`, error.message);
|
|
450
|
-
});
|
|
451
|
-
}
|
|
452
|
-
} catch (error) {
|
|
453
|
-
// Remote object unreachable (timeout, Authorized Fetch, deleted, etc.) — skip
|
|
454
|
-
const cause = error?.cause?.code || error?.message || "unknown";
|
|
455
|
-
console.warn(`[AP] Skipped boost from ${actorUrl}: ${cause}`);
|
|
456
|
-
}
|
|
457
|
-
}
|
|
216
|
+
await enqueueActivity(collections, {
|
|
217
|
+
activityType: "Announce",
|
|
218
|
+
actorUrl,
|
|
219
|
+
objectUrl: announce.objectId?.href || "",
|
|
220
|
+
rawJson: await announce.toJsonLd(),
|
|
221
|
+
});
|
|
458
222
|
})
|
|
459
|
-
.on(Create, async (ctx, create) => {
|
|
460
|
-
const authLoader = await getAuthLoader(ctx);
|
|
461
|
-
let object;
|
|
462
|
-
try {
|
|
463
|
-
object = await create.getObject({ documentLoader: authLoader });
|
|
464
|
-
} catch {
|
|
465
|
-
// Remote object not dereferenceable (deleted, etc.)
|
|
466
|
-
return;
|
|
467
|
-
}
|
|
468
|
-
if (!object) return;
|
|
469
223
|
|
|
224
|
+
// ── Create ──────────────────────────────────────────────────────
|
|
225
|
+
.on(Create, async (ctx, create) => {
|
|
470
226
|
const actorUrl = create.actorId?.href || "";
|
|
471
|
-
|
|
227
|
+
if (await isServerBlocked(actorUrl, collections)) return;
|
|
228
|
+
await touchKeyFreshness(collections, actorUrl);
|
|
229
|
+
await resetDeliveryStrikes(collections, actorUrl);
|
|
230
|
+
|
|
231
|
+
// Forward public replies to our posts to our followers.
|
|
232
|
+
// Must happen here (not in async handler) because forwardActivity
|
|
233
|
+
// is only available on InboxContext, not base Context.
|
|
234
|
+
const objectUrl = create.objectId?.href || "";
|
|
472
235
|
try {
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
const published = object.published ? String(object.published) : new Date().toISOString();
|
|
495
|
-
const inReplyToDM = object.replyTargetId?.href || null;
|
|
496
|
-
|
|
497
|
-
// Store as message
|
|
498
|
-
await addMessage(collections, {
|
|
499
|
-
uid: object.id?.href || `dm:${actorUrl}:${Date.now()}`,
|
|
500
|
-
actorUrl: actorInfo.url,
|
|
501
|
-
actorName: actorInfo.name,
|
|
502
|
-
actorPhoto: actorInfo.photo,
|
|
503
|
-
actorHandle: actorInfo.handle,
|
|
504
|
-
content: {
|
|
505
|
-
text: contentText,
|
|
506
|
-
html: contentHtml,
|
|
507
|
-
},
|
|
508
|
-
inReplyTo: inReplyToDM,
|
|
509
|
-
conversationId: actorInfo.url,
|
|
510
|
-
direction: "inbound",
|
|
511
|
-
published,
|
|
512
|
-
createdAt: new Date().toISOString(),
|
|
513
|
-
});
|
|
514
|
-
|
|
515
|
-
// Also create a notification so DMs appear in the notification tab
|
|
516
|
-
await addNotification(collections, {
|
|
517
|
-
uid: `dm:${object.id?.href || `${actorUrl}:${Date.now()}`}`,
|
|
518
|
-
url: object.url?.href || object.id?.href || "",
|
|
519
|
-
type: "dm",
|
|
520
|
-
actorUrl: actorInfo.url,
|
|
521
|
-
actorName: actorInfo.name,
|
|
522
|
-
actorPhoto: actorInfo.photo,
|
|
523
|
-
actorHandle: actorInfo.handle,
|
|
524
|
-
content: {
|
|
525
|
-
text: contentText,
|
|
526
|
-
html: contentHtml,
|
|
527
|
-
},
|
|
528
|
-
published,
|
|
529
|
-
createdAt: new Date().toISOString(),
|
|
530
|
-
});
|
|
531
|
-
|
|
532
|
-
await logActivity(collections, storeRawActivities, {
|
|
533
|
-
direction: "inbound",
|
|
534
|
-
type: "DirectMessage",
|
|
535
|
-
actorUrl,
|
|
536
|
-
actorName,
|
|
537
|
-
actorAvatar: actorInfo.photo || "",
|
|
538
|
-
objectUrl: object.id?.href || "",
|
|
539
|
-
content: contentText.substring(0, 100),
|
|
540
|
-
summary: `${actorName} sent a direct message`,
|
|
541
|
-
});
|
|
542
|
-
|
|
543
|
-
return; // Don't process DMs as timeline/mention/reply
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
// Use replyTargetId (non-fetching) for the inReplyTo URL
|
|
547
|
-
const inReplyTo = object.replyTargetId?.href || null;
|
|
548
|
-
|
|
549
|
-
// Log replies to our posts (existing behavior for conversations)
|
|
550
|
-
const pubUrl = collections._publicationUrl;
|
|
551
|
-
if (inReplyTo) {
|
|
552
|
-
const content = object.content?.toString() || "";
|
|
553
|
-
|
|
554
|
-
// Extract actor info (including avatar) before logging so we can store it
|
|
555
|
-
const actorInfo = await extractActorInfo(actorObj, { documentLoader: authLoader });
|
|
556
|
-
|
|
557
|
-
await logActivity(collections, storeRawActivities, {
|
|
558
|
-
direction: "inbound",
|
|
559
|
-
type: "Reply",
|
|
560
|
-
actorUrl,
|
|
561
|
-
actorName,
|
|
562
|
-
actorAvatar: actorInfo.photo || "",
|
|
563
|
-
objectUrl: object.id?.href || "",
|
|
564
|
-
targetUrl: inReplyTo,
|
|
565
|
-
content,
|
|
566
|
-
summary: `${actorName} replied to ${inReplyTo}`,
|
|
567
|
-
});
|
|
568
|
-
|
|
569
|
-
// Create notification if reply is to one of OUR posts
|
|
570
|
-
if (pubUrl && inReplyTo.startsWith(pubUrl)) {
|
|
571
|
-
const rawHtml = object.content?.toString() || "";
|
|
572
|
-
const contentHtml = sanitizeContent(rawHtml);
|
|
573
|
-
const contentText = rawHtml.replace(/<[^>]*>/g, "").substring(0, 200);
|
|
574
|
-
|
|
575
|
-
await addNotification(collections, {
|
|
576
|
-
uid: object.id?.href || `reply:${actorUrl}:${inReplyTo}`,
|
|
577
|
-
url: object.url?.href || object.id?.href || "",
|
|
578
|
-
type: "reply",
|
|
579
|
-
actorUrl: actorInfo.url,
|
|
580
|
-
actorName: actorInfo.name,
|
|
581
|
-
actorPhoto: actorInfo.photo,
|
|
582
|
-
actorHandle: actorInfo.handle,
|
|
583
|
-
targetUrl: inReplyTo,
|
|
584
|
-
targetName: "",
|
|
585
|
-
content: {
|
|
586
|
-
text: contentText,
|
|
587
|
-
html: contentHtml,
|
|
588
|
-
},
|
|
589
|
-
published: object.published ? String(object.published) : new Date().toISOString(),
|
|
590
|
-
createdAt: new Date().toISOString(),
|
|
591
|
-
});
|
|
592
|
-
}
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
// Check for mentions of our actor
|
|
596
|
-
if (object.tag) {
|
|
597
|
-
const tags = Array.isArray(object.tag) ? object.tag : [object.tag];
|
|
598
|
-
const ourActorUrl = ctx.getActorUri(handle).href;
|
|
599
|
-
|
|
600
|
-
for (const tag of tags) {
|
|
601
|
-
if (tag.type === "Mention" && tag.href?.href === ourActorUrl) {
|
|
602
|
-
const actorInfo = await extractActorInfo(actorObj, { documentLoader: authLoader });
|
|
603
|
-
const rawMentionHtml = object.content?.toString() || "";
|
|
604
|
-
const mentionHtml = sanitizeContent(rawMentionHtml);
|
|
605
|
-
const contentText = rawMentionHtml.replace(/<[^>]*>/g, "").substring(0, 200);
|
|
606
|
-
|
|
607
|
-
await addNotification(collections, {
|
|
608
|
-
uid: object.id?.href || `mention:${actorUrl}:${object.id?.href}`,
|
|
609
|
-
url: object.url?.href || object.id?.href || "",
|
|
610
|
-
type: "mention",
|
|
611
|
-
actorUrl: actorInfo.url,
|
|
612
|
-
actorName: actorInfo.name,
|
|
613
|
-
actorPhoto: actorInfo.photo,
|
|
614
|
-
actorHandle: actorInfo.handle,
|
|
615
|
-
content: {
|
|
616
|
-
text: contentText,
|
|
617
|
-
html: mentionHtml,
|
|
236
|
+
const obj = await create.getObject();
|
|
237
|
+
const inReplyTo = obj?.replyTargetId?.href || "";
|
|
238
|
+
if (
|
|
239
|
+
inReplyTo &&
|
|
240
|
+
collections._publicationUrl &&
|
|
241
|
+
inReplyTo.startsWith(collections._publicationUrl)
|
|
242
|
+
) {
|
|
243
|
+
// Check if the reply is public (to/cc includes PUBLIC collection)
|
|
244
|
+
const toUrls = (obj.toIds || []).map((u) => u.href);
|
|
245
|
+
const ccUrls = (obj.ccIds || []).map((u) => u.href);
|
|
246
|
+
const isPublic = [...toUrls, ...ccUrls].includes(
|
|
247
|
+
"https://www.w3.org/ns/activitystreams#Public",
|
|
248
|
+
);
|
|
249
|
+
if (isPublic) {
|
|
250
|
+
await ctx.forwardActivity(
|
|
251
|
+
{ identifier: handle },
|
|
252
|
+
"followers",
|
|
253
|
+
{
|
|
254
|
+
skipIfUnsigned: true,
|
|
255
|
+
preferSharedInbox: true,
|
|
256
|
+
excludeBaseUris: [new URL(ctx.origin)],
|
|
618
257
|
},
|
|
619
|
-
|
|
620
|
-
createdAt: new Date().toISOString(),
|
|
621
|
-
});
|
|
622
|
-
|
|
623
|
-
break; // Only create one mention notification per post
|
|
258
|
+
);
|
|
624
259
|
}
|
|
625
260
|
}
|
|
261
|
+
} catch (error) {
|
|
262
|
+
// Non-critical — forwarding failure shouldn't block processing
|
|
263
|
+
console.warn("[inbox-listeners] Reply forwarding failed:", error.message);
|
|
626
264
|
}
|
|
627
265
|
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
documentLoader: authLoader,
|
|
635
|
-
});
|
|
636
|
-
await addTimelineItem(collections, timelineItem);
|
|
637
|
-
|
|
638
|
-
// Fire-and-forget OG unfurling for notes and articles (not boosts)
|
|
639
|
-
if (timelineItem.type === "note" || timelineItem.type === "article") {
|
|
640
|
-
fetchAndStorePreviews(collections, timelineItem.uid, timelineItem.content.html)
|
|
641
|
-
.catch((error) => {
|
|
642
|
-
console.error(`[inbox] OG unfurl failed for ${timelineItem.uid}:`, error);
|
|
643
|
-
});
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
// Fire-and-forget quote enrichment
|
|
647
|
-
if (timelineItem.quoteUrl) {
|
|
648
|
-
fetchAndStoreQuote(collections, timelineItem.uid, timelineItem.quoteUrl, ctx, authLoader)
|
|
649
|
-
.catch((error) => {
|
|
650
|
-
console.error(`[inbox] Quote fetch failed for ${timelineItem.uid}:`, error.message);
|
|
651
|
-
});
|
|
652
|
-
}
|
|
653
|
-
} catch (error) {
|
|
654
|
-
// Log extraction errors but don't fail the entire handler
|
|
655
|
-
console.error("Failed to store timeline item:", error);
|
|
656
|
-
}
|
|
657
|
-
} else if (collections.ap_followed_tags) {
|
|
658
|
-
// Not a followed account — check if the post's hashtags match any followed tags
|
|
659
|
-
// so tagged posts from across the fediverse appear in the timeline
|
|
660
|
-
try {
|
|
661
|
-
const objectTags = Array.isArray(object.tag) ? object.tag : (object.tag ? [object.tag] : []);
|
|
662
|
-
const postHashtags = objectTags
|
|
663
|
-
.filter((t) => t.type === "Hashtag" && t.name)
|
|
664
|
-
.map((t) => t.name.toString().replace(/^#/, "").toLowerCase());
|
|
665
|
-
|
|
666
|
-
if (postHashtags.length > 0) {
|
|
667
|
-
const followedTags = await getFollowedTags(collections);
|
|
668
|
-
const followedSet = new Set(followedTags.map((t) => t.toLowerCase()));
|
|
669
|
-
const hasMatchingTag = postHashtags.some((tag) => followedSet.has(tag));
|
|
670
|
-
|
|
671
|
-
if (hasMatchingTag) {
|
|
672
|
-
const timelineItem = await extractObjectData(object, {
|
|
673
|
-
actorFallback: actorObj,
|
|
674
|
-
documentLoader: authLoader,
|
|
675
|
-
});
|
|
676
|
-
await addTimelineItem(collections, timelineItem);
|
|
677
|
-
}
|
|
678
|
-
}
|
|
679
|
-
} catch (error) {
|
|
680
|
-
// Non-critical — don't fail the handler
|
|
681
|
-
console.error("[inbox] Followed tag check failed:", error.message);
|
|
682
|
-
}
|
|
683
|
-
}
|
|
684
|
-
|
|
266
|
+
await enqueueActivity(collections, {
|
|
267
|
+
activityType: "Create",
|
|
268
|
+
actorUrl,
|
|
269
|
+
objectUrl,
|
|
270
|
+
rawJson: await create.toJsonLd(),
|
|
271
|
+
});
|
|
685
272
|
})
|
|
273
|
+
|
|
274
|
+
// ── Delete ──────────────────────────────────────────────────────
|
|
686
275
|
.on(Delete, async (ctx, del) => {
|
|
687
|
-
const
|
|
688
|
-
if (
|
|
689
|
-
|
|
690
|
-
|
|
276
|
+
const actorUrl = del.actorId?.href || "";
|
|
277
|
+
if (await isServerBlocked(actorUrl, collections)) return;
|
|
278
|
+
await touchKeyFreshness(collections, actorUrl);
|
|
279
|
+
await resetDeliveryStrikes(collections, actorUrl);
|
|
691
280
|
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
281
|
+
await enqueueActivity(collections, {
|
|
282
|
+
activityType: "Delete",
|
|
283
|
+
actorUrl,
|
|
284
|
+
objectUrl: del.objectId?.href || "",
|
|
285
|
+
rawJson: await del.toJsonLd(),
|
|
286
|
+
});
|
|
695
287
|
})
|
|
696
|
-
.on(Move, async (ctx, move) => {
|
|
697
|
-
const authLoader = await getAuthLoader(ctx);
|
|
698
|
-
const oldActorObj = await move.getActor({ documentLoader: authLoader });
|
|
699
|
-
const oldActorUrl = oldActorObj?.id?.href || "";
|
|
700
|
-
const target = await move.getTarget({ documentLoader: authLoader });
|
|
701
|
-
const newActorUrl = target?.id?.href || "";
|
|
702
288
|
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
289
|
+
// ── Move ────────────────────────────────────────────────────────
|
|
290
|
+
.on(Move, async (ctx, move) => {
|
|
291
|
+
const actorUrl = move.actorId?.href || "";
|
|
292
|
+
if (await isServerBlocked(actorUrl, collections)) return;
|
|
293
|
+
await touchKeyFreshness(collections, actorUrl);
|
|
294
|
+
await resetDeliveryStrikes(collections, actorUrl);
|
|
709
295
|
|
|
710
|
-
await
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
objectUrl: newActorUrl,
|
|
715
|
-
summary: `${oldActorUrl} moved to ${newActorUrl}`,
|
|
296
|
+
await enqueueActivity(collections, {
|
|
297
|
+
activityType: "Move",
|
|
298
|
+
actorUrl,
|
|
299
|
+
rawJson: await move.toJsonLd(),
|
|
716
300
|
});
|
|
717
301
|
})
|
|
718
|
-
.on(Update, async (ctx, update) => {
|
|
719
|
-
// Update can be for a profile OR for a post (edited content)
|
|
720
|
-
const authLoader = await getAuthLoader(ctx);
|
|
721
302
|
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
}
|
|
729
|
-
|
|
730
|
-
// PATH 1: If object is a Note/Article → Update timeline item content
|
|
731
|
-
if (object && (object instanceof Note || object instanceof Article)) {
|
|
732
|
-
const objectUrl = object.id?.href || "";
|
|
733
|
-
if (objectUrl) {
|
|
734
|
-
try {
|
|
735
|
-
// Extract updated content
|
|
736
|
-
const contentHtml = object.content?.toString() || "";
|
|
737
|
-
const contentText = object.source?.content?.toString() || contentHtml.replace(/<[^>]*>/g, "");
|
|
738
|
-
|
|
739
|
-
const updates = {
|
|
740
|
-
content: {
|
|
741
|
-
text: contentText,
|
|
742
|
-
html: contentHtml,
|
|
743
|
-
},
|
|
744
|
-
name: object.name?.toString() || "",
|
|
745
|
-
summary: object.summary?.toString() || "",
|
|
746
|
-
sensitive: object.sensitive || false,
|
|
747
|
-
};
|
|
748
|
-
|
|
749
|
-
await updateTimelineItem(collections, objectUrl, updates);
|
|
750
|
-
} catch (error) {
|
|
751
|
-
console.error("Failed to update timeline item:", error);
|
|
752
|
-
}
|
|
753
|
-
}
|
|
754
|
-
return;
|
|
755
|
-
}
|
|
756
|
-
|
|
757
|
-
// PATH 2: Otherwise, assume profile update — refresh stored follower data
|
|
758
|
-
const actorObj = await update.getActor({ documentLoader: authLoader });
|
|
759
|
-
const actorUrl = actorObj?.id?.href || "";
|
|
760
|
-
if (!actorUrl) return;
|
|
303
|
+
// ── Update ──────────────────────────────────────────────────────
|
|
304
|
+
.on(Update, async (ctx, update) => {
|
|
305
|
+
const actorUrl = update.actorId?.href || "";
|
|
306
|
+
if (await isServerBlocked(actorUrl, collections)) return;
|
|
307
|
+
await touchKeyFreshness(collections, actorUrl);
|
|
308
|
+
await resetDeliveryStrikes(collections, actorUrl);
|
|
761
309
|
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
$set: {
|
|
768
|
-
name:
|
|
769
|
-
actorObj.name?.toString() ||
|
|
770
|
-
actorObj.preferredUsername?.toString() ||
|
|
771
|
-
actorUrl,
|
|
772
|
-
handle: actorObj.preferredUsername?.toString() || "",
|
|
773
|
-
avatar: actorObj.icon
|
|
774
|
-
? (await actorObj.icon)?.url?.href || ""
|
|
775
|
-
: "",
|
|
776
|
-
updatedAt: new Date().toISOString(),
|
|
777
|
-
},
|
|
778
|
-
},
|
|
779
|
-
);
|
|
780
|
-
}
|
|
310
|
+
await enqueueActivity(collections, {
|
|
311
|
+
activityType: "Update",
|
|
312
|
+
actorUrl,
|
|
313
|
+
rawJson: await update.toJsonLd(),
|
|
314
|
+
});
|
|
781
315
|
})
|
|
316
|
+
|
|
317
|
+
// ── Block ───────────────────────────────────────────────────────
|
|
318
|
+
// Synchronous: remove from followers (immediate)
|
|
319
|
+
// Async: activity log
|
|
782
320
|
.on(Block, async (ctx, block) => {
|
|
783
|
-
|
|
321
|
+
const actorUrl = block.actorId?.href || "";
|
|
322
|
+
if (await isServerBlocked(actorUrl, collections)) return;
|
|
323
|
+
|
|
324
|
+
// Synchronous: remove from followers immediately
|
|
784
325
|
const authLoader = await getAuthLoader(ctx);
|
|
785
326
|
const actorObj = await block.getActor({ documentLoader: authLoader });
|
|
786
|
-
const
|
|
787
|
-
if (
|
|
788
|
-
await collections.ap_followers.deleteOne({ actorUrl });
|
|
327
|
+
const resolvedUrl = actorObj?.id?.href || "";
|
|
328
|
+
if (resolvedUrl) {
|
|
329
|
+
await collections.ap_followers.deleteOne({ actorUrl: resolvedUrl });
|
|
789
330
|
}
|
|
790
|
-
})
|
|
791
|
-
.on(Add, async () => {
|
|
792
|
-
// Mastodon uses Add for pinning posts to featured collections — safe to ignore
|
|
793
|
-
})
|
|
794
|
-
.on(Remove, async () => {
|
|
795
|
-
// Mastodon uses Remove for unpinning posts from featured collections — safe to ignore
|
|
796
|
-
})
|
|
797
|
-
// ── Flag (Report) ──────────────────────────────────────────────
|
|
798
|
-
.on(Flag, async (ctx, flag) => {
|
|
799
|
-
try {
|
|
800
|
-
const authLoader = await getAuthLoader(ctx);
|
|
801
|
-
const actorObj = await flag.getActor({ documentLoader: authLoader }).catch(() => null);
|
|
802
|
-
|
|
803
|
-
const reporterUrl = actorObj?.id?.href || flag.actorId?.href || "";
|
|
804
|
-
const reporterName = actorObj?.name?.toString() || actorObj?.preferredUsername?.toString() || reporterUrl;
|
|
805
|
-
|
|
806
|
-
// Extract reported objects — Flag can report actors or posts
|
|
807
|
-
const reportedIds = flag.objectIds?.map((u) => u.href) || [];
|
|
808
|
-
const reason = flag.content?.toString() || flag.summary?.toString() || "";
|
|
809
|
-
|
|
810
|
-
if (reportedIds.length === 0 && !reason) {
|
|
811
|
-
console.info("[ActivityPub] Ignoring empty Flag from", reporterUrl);
|
|
812
|
-
return;
|
|
813
|
-
}
|
|
814
331
|
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
reason,
|
|
822
|
-
createdAt: new Date().toISOString(),
|
|
823
|
-
read: false,
|
|
824
|
-
});
|
|
825
|
-
}
|
|
332
|
+
await enqueueActivity(collections, {
|
|
333
|
+
activityType: "Block",
|
|
334
|
+
actorUrl: resolvedUrl || actorUrl,
|
|
335
|
+
rawJson: await block.toJsonLd(),
|
|
336
|
+
});
|
|
337
|
+
})
|
|
826
338
|
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
uid: `flag:${reporterUrl}:${Date.now()}`,
|
|
831
|
-
type: "report",
|
|
832
|
-
actorUrl: reporterUrl,
|
|
833
|
-
actorName: reporterName,
|
|
834
|
-
actorPhoto: actorObj?.iconUrl?.href || actorObj?.icon?.url?.href || "",
|
|
835
|
-
actorHandle: actorObj?.preferredUsername
|
|
836
|
-
? `@${actorObj.preferredUsername}@${new URL(reporterUrl).hostname}`
|
|
837
|
-
: reporterUrl,
|
|
838
|
-
objectUrl: reportedIds[0] || "",
|
|
839
|
-
summary: reason ? reason.slice(0, 200) : "Report received",
|
|
840
|
-
published: new Date().toISOString(),
|
|
841
|
-
createdAt: new Date().toISOString(),
|
|
842
|
-
});
|
|
843
|
-
}
|
|
339
|
+
// ── Add / Remove (no-ops) ───────────────────────────────────────
|
|
340
|
+
.on(Add, async () => {})
|
|
341
|
+
.on(Remove, async () => {})
|
|
844
342
|
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
});
|
|
343
|
+
// ── Flag ────────────────────────────────────────────────────────
|
|
344
|
+
.on(Flag, async (ctx, flag) => {
|
|
345
|
+
const actorUrl = flag.actorId?.href || "";
|
|
346
|
+
if (await isServerBlocked(actorUrl, collections)) return;
|
|
347
|
+
await touchKeyFreshness(collections, actorUrl);
|
|
348
|
+
await resetDeliveryStrikes(collections, actorUrl);
|
|
852
349
|
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
350
|
+
await enqueueActivity(collections, {
|
|
351
|
+
activityType: "Flag",
|
|
352
|
+
actorUrl,
|
|
353
|
+
rawJson: await flag.toJsonLd(),
|
|
354
|
+
});
|
|
857
355
|
});
|
|
858
356
|
}
|
|
859
|
-
|
|
860
|
-
/**
|
|
861
|
-
* Log an activity to the ap_activities collection.
|
|
862
|
-
* Wrapper around the shared utility that accepts the (collections, storeRaw, record) signature
|
|
863
|
-
* used throughout this file.
|
|
864
|
-
*/
|
|
865
|
-
async function logActivity(collections, storeRaw, record, rawJson) {
|
|
866
|
-
await logActivityShared(
|
|
867
|
-
collections.ap_activities,
|
|
868
|
-
record,
|
|
869
|
-
storeRaw && rawJson ? { rawJson } : {},
|
|
870
|
-
);
|
|
871
|
-
}
|
|
872
|
-
|