@rmdes/indiekit-endpoint-activitypub 1.0.28 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/reader.css +884 -0
- package/index.js +172 -15
- package/lib/controllers/compose.js +323 -0
- package/lib/controllers/featured-tags.js +12 -2
- package/lib/controllers/featured.js +12 -2
- package/lib/controllers/interactions-boost.js +208 -0
- package/lib/controllers/interactions-like.js +231 -0
- package/lib/controllers/interactions.js +7 -0
- package/lib/controllers/moderation.js +294 -0
- package/lib/controllers/profile.js +27 -1
- package/lib/controllers/profile.remote.js +218 -0
- package/lib/controllers/reader.js +187 -0
- package/lib/csrf.js +49 -0
- package/lib/federation-setup.js +33 -2
- package/lib/inbox-listeners.js +217 -213
- package/lib/storage/moderation.js +180 -0
- package/lib/storage/notifications.js +132 -0
- package/lib/storage/timeline.js +210 -0
- package/lib/timeline-cleanup.js +88 -0
- package/lib/timeline-store.js +207 -0
- package/locales/en.json +92 -1
- package/package.json +3 -2
- package/views/activitypub-compose.njk +94 -0
- package/views/activitypub-moderation.njk +118 -0
- package/views/activitypub-notifications.njk +31 -0
- package/views/activitypub-profile.njk +98 -0
- package/views/activitypub-reader.njk +61 -0
- package/views/activitypub-remote-profile.njk +117 -0
- package/views/layouts/reader.njk +9 -0
- package/views/partials/ap-item-card.njk +157 -0
- package/views/partials/ap-item-media.njk +37 -0
- package/views/partials/ap-notification-card.njk +58 -0
package/lib/inbox-listeners.js
CHANGED
|
@@ -23,6 +23,9 @@ import {
|
|
|
23
23
|
} from "@fedify/fedify";
|
|
24
24
|
|
|
25
25
|
import { logActivity as logActivityShared } from "./activity-log.js";
|
|
26
|
+
import { sanitizeContent, extractActorInfo, extractObjectData } from "./timeline-store.js";
|
|
27
|
+
import { addTimelineItem, deleteTimelineItem, updateTimelineItem } from "./storage/timeline.js";
|
|
28
|
+
import { addNotification } from "./storage/notifications.js";
|
|
26
29
|
|
|
27
30
|
/**
|
|
28
31
|
* Register all inbox listeners on a federation's inbox chain.
|
|
@@ -83,6 +86,19 @@ export function registerInboxListeners(inboxChain, options) {
|
|
|
83
86
|
actorName: followerName,
|
|
84
87
|
summary: `${followerName} followed you`,
|
|
85
88
|
});
|
|
89
|
+
|
|
90
|
+
// Store notification
|
|
91
|
+
const followerInfo = await extractActorInfo(followerActor);
|
|
92
|
+
await addNotification(collections, {
|
|
93
|
+
uid: follow.id?.href || `follow:${followerUrl}`,
|
|
94
|
+
type: "follow",
|
|
95
|
+
actorUrl: followerInfo.url,
|
|
96
|
+
actorName: followerInfo.name,
|
|
97
|
+
actorPhoto: followerInfo.photo,
|
|
98
|
+
actorHandle: followerInfo.handle,
|
|
99
|
+
published: follow.published ? new Date(follow.published) : new Date(),
|
|
100
|
+
createdAt: new Date().toISOString(),
|
|
101
|
+
});
|
|
86
102
|
})
|
|
87
103
|
.on(Undo, async (ctx, undo) => {
|
|
88
104
|
const actorUrl = undo.actorId?.href || "";
|
|
@@ -139,7 +155,7 @@ export function registerInboxListeners(inboxChain, options) {
|
|
|
139
155
|
const result = await collections.ap_following.findOneAndUpdate(
|
|
140
156
|
{
|
|
141
157
|
actorUrl,
|
|
142
|
-
source: { $in: ["refollow:sent", "microsub-reader"] },
|
|
158
|
+
source: { $in: ["refollow:sent", "reader", "microsub-reader"] },
|
|
143
159
|
},
|
|
144
160
|
{
|
|
145
161
|
$set: {
|
|
@@ -176,7 +192,7 @@ export function registerInboxListeners(inboxChain, options) {
|
|
|
176
192
|
const result = await collections.ap_following.findOneAndUpdate(
|
|
177
193
|
{
|
|
178
194
|
actorUrl,
|
|
179
|
-
source: { $in: ["refollow:sent", "microsub-reader"] },
|
|
195
|
+
source: { $in: ["refollow:sent", "reader", "microsub-reader"] },
|
|
180
196
|
},
|
|
181
197
|
{
|
|
182
198
|
$set: {
|
|
@@ -210,17 +226,18 @@ export function registerInboxListeners(inboxChain, options) {
|
|
|
210
226
|
if (!objectId || (pubUrl && !objectId.startsWith(pubUrl))) return;
|
|
211
227
|
|
|
212
228
|
const actorUrl = like.actorId?.href || "";
|
|
213
|
-
let
|
|
229
|
+
let actorObj;
|
|
214
230
|
try {
|
|
215
|
-
|
|
216
|
-
actorName =
|
|
217
|
-
actorObj?.name?.toString() ||
|
|
218
|
-
actorObj?.preferredUsername?.toString() ||
|
|
219
|
-
actorUrl;
|
|
231
|
+
actorObj = await like.getActor();
|
|
220
232
|
} catch {
|
|
221
|
-
|
|
233
|
+
actorObj = null;
|
|
222
234
|
}
|
|
223
235
|
|
|
236
|
+
const actorName =
|
|
237
|
+
actorObj?.name?.toString() ||
|
|
238
|
+
actorObj?.preferredUsername?.toString() ||
|
|
239
|
+
actorUrl;
|
|
240
|
+
|
|
224
241
|
await logActivity(collections, storeRawActivities, {
|
|
225
242
|
direction: "inbound",
|
|
226
243
|
type: "Like",
|
|
@@ -229,35 +246,96 @@ export function registerInboxListeners(inboxChain, options) {
|
|
|
229
246
|
objectUrl: objectId,
|
|
230
247
|
summary: `${actorName} liked ${objectId}`,
|
|
231
248
|
});
|
|
249
|
+
|
|
250
|
+
// Store notification
|
|
251
|
+
const actorInfo = await extractActorInfo(actorObj);
|
|
252
|
+
await addNotification(collections, {
|
|
253
|
+
uid: like.id?.href || `like:${actorUrl}:${objectId}`,
|
|
254
|
+
type: "like",
|
|
255
|
+
actorUrl: actorInfo.url,
|
|
256
|
+
actorName: actorInfo.name,
|
|
257
|
+
actorPhoto: actorInfo.photo,
|
|
258
|
+
actorHandle: actorInfo.handle,
|
|
259
|
+
targetUrl: objectId,
|
|
260
|
+
targetName: "", // Could fetch post title, but not critical
|
|
261
|
+
published: like.published ? new Date(like.published) : new Date(),
|
|
262
|
+
createdAt: new Date().toISOString(),
|
|
263
|
+
});
|
|
232
264
|
})
|
|
233
265
|
.on(Announce, async (ctx, announce) => {
|
|
234
|
-
// Use .objectId — no remote fetch needed (see Like handler comment)
|
|
235
266
|
const objectId = announce.objectId?.href || "";
|
|
267
|
+
if (!objectId) return;
|
|
236
268
|
|
|
237
|
-
|
|
269
|
+
const actorUrl = announce.actorId?.href || "";
|
|
238
270
|
const pubUrl = collections._publicationUrl;
|
|
239
|
-
if (!objectId || (pubUrl && !objectId.startsWith(pubUrl))) return;
|
|
240
271
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
272
|
+
// Dual path logic: Notification vs Timeline
|
|
273
|
+
|
|
274
|
+
// PATH 1: Boost of OUR content → Notification
|
|
275
|
+
if (pubUrl && objectId.startsWith(pubUrl)) {
|
|
276
|
+
let actorObj;
|
|
277
|
+
try {
|
|
278
|
+
actorObj = await announce.getActor();
|
|
279
|
+
} catch {
|
|
280
|
+
actorObj = null;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const actorName =
|
|
246
284
|
actorObj?.name?.toString() ||
|
|
247
285
|
actorObj?.preferredUsername?.toString() ||
|
|
248
286
|
actorUrl;
|
|
249
|
-
|
|
250
|
-
|
|
287
|
+
|
|
288
|
+
// Log the boost activity
|
|
289
|
+
await logActivity(collections, storeRawActivities, {
|
|
290
|
+
direction: "inbound",
|
|
291
|
+
type: "Announce",
|
|
292
|
+
actorUrl,
|
|
293
|
+
actorName,
|
|
294
|
+
objectUrl: objectId,
|
|
295
|
+
summary: `${actorName} boosted ${objectId}`,
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// Create notification
|
|
299
|
+
const actorInfo = await extractActorInfo(actorObj);
|
|
300
|
+
await addNotification(collections, {
|
|
301
|
+
uid: announce.id?.href || `${actorUrl}#boost-${objectId}`,
|
|
302
|
+
type: "boost",
|
|
303
|
+
actorUrl: actorInfo.url,
|
|
304
|
+
actorName: actorInfo.name,
|
|
305
|
+
actorPhoto: actorInfo.photo,
|
|
306
|
+
actorHandle: actorInfo.handle,
|
|
307
|
+
targetUrl: objectId,
|
|
308
|
+
targetName: "", // Could fetch post title, but not critical
|
|
309
|
+
published: announce.published ? new Date(announce.published).toISOString() : new Date().toISOString(),
|
|
310
|
+
createdAt: new Date().toISOString(),
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// Don't return — fall through to check if actor is also followed
|
|
251
314
|
}
|
|
252
315
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
316
|
+
// PATH 2: Boost from someone we follow → Timeline (store original post)
|
|
317
|
+
const following = await collections.ap_following.findOne({ actorUrl });
|
|
318
|
+
if (following) {
|
|
319
|
+
try {
|
|
320
|
+
// Fetch the original object being boosted
|
|
321
|
+
const object = await announce.getObject();
|
|
322
|
+
if (!object) return;
|
|
323
|
+
|
|
324
|
+
// Get booster actor info
|
|
325
|
+
const boosterActor = await announce.getActor();
|
|
326
|
+
const boosterInfo = await extractActorInfo(boosterActor);
|
|
327
|
+
|
|
328
|
+
// Extract and store with boost metadata
|
|
329
|
+
const timelineItem = await extractObjectData(object, {
|
|
330
|
+
boostedBy: boosterInfo,
|
|
331
|
+
boostedAt: announce.published ? new Date(announce.published).toISOString() : new Date().toISOString(),
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
await addTimelineItem(collections, timelineItem);
|
|
335
|
+
} catch (error) {
|
|
336
|
+
console.error("Failed to store boosted timeline item:", error);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
261
339
|
})
|
|
262
340
|
.on(Create, async (ctx, create) => {
|
|
263
341
|
let object;
|
|
@@ -292,6 +370,7 @@ export function registerInboxListeners(inboxChain, options) {
|
|
|
292
370
|
}
|
|
293
371
|
|
|
294
372
|
// Log replies to our posts (existing behavior for conversations)
|
|
373
|
+
const pubUrl = collections._publicationUrl;
|
|
295
374
|
if (inReplyTo) {
|
|
296
375
|
const content = object.content?.toString() || "";
|
|
297
376
|
await logActivity(collections, storeRawActivities, {
|
|
@@ -304,21 +383,86 @@ export function registerInboxListeners(inboxChain, options) {
|
|
|
304
383
|
content,
|
|
305
384
|
summary: `${actorName} replied to ${inReplyTo}`,
|
|
306
385
|
});
|
|
386
|
+
|
|
387
|
+
// Create notification if reply is to one of OUR posts
|
|
388
|
+
if (pubUrl && inReplyTo.startsWith(pubUrl)) {
|
|
389
|
+
const actorInfo = await extractActorInfo(actorObj);
|
|
390
|
+
const rawHtml = object.content?.toString() || "";
|
|
391
|
+
const contentHtml = sanitizeContent(rawHtml);
|
|
392
|
+
const contentText = rawHtml.replace(/<[^>]*>/g, "").substring(0, 200);
|
|
393
|
+
|
|
394
|
+
await addNotification(collections, {
|
|
395
|
+
uid: object.id?.href || `reply:${actorUrl}:${inReplyTo}`,
|
|
396
|
+
type: "reply",
|
|
397
|
+
actorUrl: actorInfo.url,
|
|
398
|
+
actorName: actorInfo.name,
|
|
399
|
+
actorPhoto: actorInfo.photo,
|
|
400
|
+
actorHandle: actorInfo.handle,
|
|
401
|
+
targetUrl: inReplyTo,
|
|
402
|
+
targetName: "",
|
|
403
|
+
content: {
|
|
404
|
+
text: contentText,
|
|
405
|
+
html: contentHtml,
|
|
406
|
+
},
|
|
407
|
+
published: object.published ? new Date(object.published).toISOString() : new Date().toISOString(),
|
|
408
|
+
createdAt: new Date().toISOString(),
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Check for mentions of our actor
|
|
414
|
+
if (object.tag) {
|
|
415
|
+
const tags = Array.isArray(object.tag) ? object.tag : [object.tag];
|
|
416
|
+
const ourActorUrl = ctx.getActorUri(handle).href;
|
|
417
|
+
|
|
418
|
+
for (const tag of tags) {
|
|
419
|
+
if (tag.type === "Mention" && tag.href?.href === ourActorUrl) {
|
|
420
|
+
const actorInfo = await extractActorInfo(actorObj);
|
|
421
|
+
const rawMentionHtml = object.content?.toString() || "";
|
|
422
|
+
const mentionHtml = sanitizeContent(rawMentionHtml);
|
|
423
|
+
const contentText = rawMentionHtml.replace(/<[^>]*>/g, "").substring(0, 200);
|
|
424
|
+
|
|
425
|
+
await addNotification(collections, {
|
|
426
|
+
uid: object.id?.href || `mention:${actorUrl}:${object.id?.href}`,
|
|
427
|
+
type: "mention",
|
|
428
|
+
actorUrl: actorInfo.url,
|
|
429
|
+
actorName: actorInfo.name,
|
|
430
|
+
actorPhoto: actorInfo.photo,
|
|
431
|
+
actorHandle: actorInfo.handle,
|
|
432
|
+
content: {
|
|
433
|
+
text: contentText,
|
|
434
|
+
html: mentionHtml,
|
|
435
|
+
},
|
|
436
|
+
published: object.published ? new Date(object.published).toISOString() : new Date().toISOString(),
|
|
437
|
+
createdAt: new Date().toISOString(),
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
break; // Only create one mention notification per post
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Store timeline items from accounts we follow (native storage)
|
|
446
|
+
const following = await collections.ap_following.findOne({ actorUrl });
|
|
447
|
+
if (following) {
|
|
448
|
+
try {
|
|
449
|
+
const timelineItem = await extractObjectData(object);
|
|
450
|
+
await addTimelineItem(collections, timelineItem);
|
|
451
|
+
} catch (error) {
|
|
452
|
+
// Log extraction errors but don't fail the entire handler
|
|
453
|
+
console.error("Failed to store timeline item:", error);
|
|
454
|
+
}
|
|
307
455
|
}
|
|
308
456
|
|
|
309
|
-
// Store timeline items from accounts we follow
|
|
310
|
-
await storeTimelineItem(collections, {
|
|
311
|
-
actorUrl,
|
|
312
|
-
actorName,
|
|
313
|
-
actorObj,
|
|
314
|
-
object,
|
|
315
|
-
inReplyTo,
|
|
316
|
-
});
|
|
317
457
|
})
|
|
318
458
|
.on(Delete, async (ctx, del) => {
|
|
319
459
|
const objectId = del.objectId?.href || "";
|
|
320
460
|
if (objectId) {
|
|
461
|
+
// Remove from activity log
|
|
321
462
|
await collections.ap_activities.deleteMany({ objectUrl: objectId });
|
|
463
|
+
|
|
464
|
+
// Remove from timeline
|
|
465
|
+
await deleteTimelineItem(collections, objectId);
|
|
322
466
|
}
|
|
323
467
|
})
|
|
324
468
|
.on(Move, async (ctx, move) => {
|
|
@@ -343,7 +487,44 @@ export function registerInboxListeners(inboxChain, options) {
|
|
|
343
487
|
});
|
|
344
488
|
})
|
|
345
489
|
.on(Update, async (ctx, update) => {
|
|
346
|
-
//
|
|
490
|
+
// Update can be for a profile OR for a post (edited content)
|
|
491
|
+
|
|
492
|
+
// Try to get the object being updated
|
|
493
|
+
let object;
|
|
494
|
+
try {
|
|
495
|
+
object = await update.getObject();
|
|
496
|
+
} catch {
|
|
497
|
+
object = null;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// PATH 1: If object is a Note/Article → Update timeline item content
|
|
501
|
+
if (object && (object instanceof Note || object.type === "Article")) {
|
|
502
|
+
const objectUrl = object.id?.href || "";
|
|
503
|
+
if (objectUrl) {
|
|
504
|
+
try {
|
|
505
|
+
// Extract updated content
|
|
506
|
+
const contentHtml = object.content?.toString() || "";
|
|
507
|
+
const contentText = object.source?.content?.toString() || contentHtml.replace(/<[^>]*>/g, "");
|
|
508
|
+
|
|
509
|
+
const updates = {
|
|
510
|
+
content: {
|
|
511
|
+
text: contentText,
|
|
512
|
+
html: contentHtml,
|
|
513
|
+
},
|
|
514
|
+
name: object.name?.toString() || "",
|
|
515
|
+
summary: object.summary?.toString() || "",
|
|
516
|
+
sensitive: object.sensitive || false,
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
await updateTimelineItem(collections, objectUrl, updates);
|
|
520
|
+
} catch (error) {
|
|
521
|
+
console.error("Failed to update timeline item:", error);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// PATH 2: Otherwise, assume profile update — refresh stored follower data
|
|
347
528
|
const actorObj = await update.getActor();
|
|
348
529
|
const actorUrl = actorObj?.id?.href || "";
|
|
349
530
|
if (!actorUrl) return;
|
|
@@ -397,180 +578,3 @@ async function logActivity(collections, storeRaw, record, rawJson) {
|
|
|
397
578
|
);
|
|
398
579
|
}
|
|
399
580
|
|
|
400
|
-
// Cached ActivityPub channel ObjectId
|
|
401
|
-
let _apChannelId = null;
|
|
402
|
-
|
|
403
|
-
/**
|
|
404
|
-
* Look up (or auto-create) the ActivityPub channel's ObjectId.
|
|
405
|
-
* Cached after first successful call.
|
|
406
|
-
*
|
|
407
|
-
* The channel is created with `userId: "default"` so it appears in the
|
|
408
|
-
* Microsub reader UI alongside user-created channels.
|
|
409
|
-
*
|
|
410
|
-
* @param {object} collections - MongoDB collections
|
|
411
|
-
* @returns {Promise<import("mongodb").ObjectId|null>}
|
|
412
|
-
*/
|
|
413
|
-
async function getApChannelId(collections) {
|
|
414
|
-
if (_apChannelId) return _apChannelId;
|
|
415
|
-
if (!collections.microsub_channels) return null;
|
|
416
|
-
|
|
417
|
-
let channel = await collections.microsub_channels.findOne({
|
|
418
|
-
uid: "activitypub",
|
|
419
|
-
});
|
|
420
|
-
|
|
421
|
-
if (!channel) {
|
|
422
|
-
// Auto-create the channel with the same fields the Microsub plugin uses
|
|
423
|
-
const maxOrderDoc = await collections.microsub_channels
|
|
424
|
-
.find({ userId: "default" })
|
|
425
|
-
.sort({ order: -1 })
|
|
426
|
-
.limit(1)
|
|
427
|
-
.toArray();
|
|
428
|
-
const order = maxOrderDoc.length > 0 ? maxOrderDoc[0].order + 1 : 0;
|
|
429
|
-
|
|
430
|
-
const doc = {
|
|
431
|
-
uid: "activitypub",
|
|
432
|
-
name: "Fediverse",
|
|
433
|
-
userId: "default",
|
|
434
|
-
order,
|
|
435
|
-
settings: { excludeTypes: [], excludeRegex: null },
|
|
436
|
-
createdAt: new Date().toISOString(),
|
|
437
|
-
updatedAt: new Date().toISOString(),
|
|
438
|
-
};
|
|
439
|
-
await collections.microsub_channels.insertOne(doc);
|
|
440
|
-
channel = doc;
|
|
441
|
-
console.info("[ActivityPub] Auto-created Microsub channel 'Fediverse'");
|
|
442
|
-
} else if (!channel.userId) {
|
|
443
|
-
// Fix existing channel missing userId (created by earlier version)
|
|
444
|
-
await collections.microsub_channels.updateOne(
|
|
445
|
-
{ _id: channel._id },
|
|
446
|
-
{ $set: { userId: "default" } },
|
|
447
|
-
);
|
|
448
|
-
console.info("[ActivityPub] Fixed Microsub channel: set userId to 'default'");
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
_apChannelId = channel._id;
|
|
452
|
-
return _apChannelId;
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
/**
|
|
456
|
-
* Store a Create activity as a Microsub timeline item if the actor
|
|
457
|
-
* is someone we follow. Skips gracefully if the Microsub plugin
|
|
458
|
-
* isn't loaded or the AP channel doesn't exist yet.
|
|
459
|
-
*
|
|
460
|
-
* @param {object} collections - MongoDB collections
|
|
461
|
-
* @param {object} params
|
|
462
|
-
* @param {string} params.actorUrl - Actor URL
|
|
463
|
-
* @param {string} params.actorName - Actor display name
|
|
464
|
-
* @param {object} params.actorObj - Fedify actor object
|
|
465
|
-
* @param {object} params.object - Fedify Note/Article object
|
|
466
|
-
* @param {string|null} params.inReplyTo - URL this is a reply to (if any)
|
|
467
|
-
*/
|
|
468
|
-
async function storeTimelineItem(
|
|
469
|
-
collections,
|
|
470
|
-
{ actorUrl, actorName, actorObj, object, inReplyTo },
|
|
471
|
-
) {
|
|
472
|
-
// Skip if Microsub plugin not loaded
|
|
473
|
-
if (!collections.microsub_items || !collections.microsub_channels) return;
|
|
474
|
-
|
|
475
|
-
// Only store posts from accounts we follow
|
|
476
|
-
const following = await collections.ap_following.findOne({ actorUrl });
|
|
477
|
-
if (!following) return;
|
|
478
|
-
|
|
479
|
-
const channelId = await getApChannelId(collections);
|
|
480
|
-
if (!channelId) return;
|
|
481
|
-
|
|
482
|
-
const objectUrl = object.id?.href || "";
|
|
483
|
-
if (!objectUrl) return;
|
|
484
|
-
|
|
485
|
-
// Extract content
|
|
486
|
-
const contentHtml = object.content?.toString() || "";
|
|
487
|
-
const contentText = contentHtml.replace(/<[^>]*>/g, "").trim();
|
|
488
|
-
|
|
489
|
-
// Name (usually only on Article, not Note)
|
|
490
|
-
const name = object.name?.toString() || undefined;
|
|
491
|
-
const summary = object.summary?.toString() || undefined;
|
|
492
|
-
|
|
493
|
-
// Published date — Fedify returns Temporal.Instant
|
|
494
|
-
let published;
|
|
495
|
-
if (object.published) {
|
|
496
|
-
try {
|
|
497
|
-
published = new Date(Number(object.published.epochMilliseconds));
|
|
498
|
-
} catch {
|
|
499
|
-
published = new Date();
|
|
500
|
-
}
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
// Author avatar
|
|
504
|
-
let authorPhoto = "";
|
|
505
|
-
try {
|
|
506
|
-
if (actorObj.icon) {
|
|
507
|
-
const iconObj = await actorObj.icon;
|
|
508
|
-
authorPhoto = iconObj?.url?.href || "";
|
|
509
|
-
}
|
|
510
|
-
} catch {
|
|
511
|
-
/* remote fetch may fail */
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
// Tags / categories
|
|
515
|
-
const category = [];
|
|
516
|
-
try {
|
|
517
|
-
for await (const tag of object.getTags()) {
|
|
518
|
-
const tagName = tag.name?.toString();
|
|
519
|
-
if (tagName) category.push(tagName.replace(/^#/, ""));
|
|
520
|
-
}
|
|
521
|
-
} catch {
|
|
522
|
-
/* ignore */
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
// Attachments (photos, videos, audio)
|
|
526
|
-
const photo = [];
|
|
527
|
-
const video = [];
|
|
528
|
-
const audio = [];
|
|
529
|
-
try {
|
|
530
|
-
for await (const att of object.getAttachments()) {
|
|
531
|
-
const mediaType = att.mediaType?.toString() || "";
|
|
532
|
-
const url = att.url?.href || att.id?.href || "";
|
|
533
|
-
if (!url) continue;
|
|
534
|
-
if (mediaType.startsWith("image/")) photo.push(url);
|
|
535
|
-
else if (mediaType.startsWith("video/")) video.push(url);
|
|
536
|
-
else if (mediaType.startsWith("audio/")) audio.push(url);
|
|
537
|
-
}
|
|
538
|
-
} catch {
|
|
539
|
-
/* ignore */
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
const item = {
|
|
543
|
-
channelId,
|
|
544
|
-
feedId: null,
|
|
545
|
-
uid: objectUrl,
|
|
546
|
-
type: "entry",
|
|
547
|
-
url: objectUrl,
|
|
548
|
-
name,
|
|
549
|
-
content: contentHtml ? { text: contentText, html: contentHtml } : undefined,
|
|
550
|
-
summary,
|
|
551
|
-
published: published || new Date(),
|
|
552
|
-
author: {
|
|
553
|
-
name: actorName,
|
|
554
|
-
url: actorUrl,
|
|
555
|
-
photo: authorPhoto,
|
|
556
|
-
},
|
|
557
|
-
category,
|
|
558
|
-
photo,
|
|
559
|
-
video,
|
|
560
|
-
audio,
|
|
561
|
-
inReplyTo: inReplyTo ? [inReplyTo] : [],
|
|
562
|
-
source: {
|
|
563
|
-
type: "activitypub",
|
|
564
|
-
actorUrl,
|
|
565
|
-
},
|
|
566
|
-
readBy: [],
|
|
567
|
-
createdAt: new Date().toISOString(),
|
|
568
|
-
};
|
|
569
|
-
|
|
570
|
-
// Atomic upsert — prevents duplicates without a separate check+insert
|
|
571
|
-
await collections.microsub_items.updateOne(
|
|
572
|
-
{ channelId, uid: objectUrl },
|
|
573
|
-
{ $setOnInsert: item },
|
|
574
|
-
{ upsert: true },
|
|
575
|
-
);
|
|
576
|
-
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Moderation storage operations (mute/block)
|
|
3
|
+
* @module storage/moderation
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Add a muted URL or keyword
|
|
8
|
+
* @param {object} collections - MongoDB collections
|
|
9
|
+
* @param {object} data - Mute data
|
|
10
|
+
* @param {string} [data.url] - Actor URL to mute (mutually exclusive with keyword)
|
|
11
|
+
* @param {string} [data.keyword] - Keyword to mute (mutually exclusive with url)
|
|
12
|
+
* @returns {Promise<object>} Created mute entry
|
|
13
|
+
*/
|
|
14
|
+
export async function addMuted(collections, { url, keyword }) {
|
|
15
|
+
const { ap_muted } = collections;
|
|
16
|
+
|
|
17
|
+
if (!url && !keyword) {
|
|
18
|
+
throw new Error("Either url or keyword must be provided");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (url && keyword) {
|
|
22
|
+
throw new Error("Cannot mute both url and keyword in same entry");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const entry = {
|
|
26
|
+
url: url || null,
|
|
27
|
+
keyword: keyword || null,
|
|
28
|
+
mutedAt: new Date().toISOString(),
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// Upsert to avoid duplicates
|
|
32
|
+
const filter = url ? { url } : { keyword };
|
|
33
|
+
await ap_muted.updateOne(filter, { $setOnInsert: entry }, { upsert: true });
|
|
34
|
+
|
|
35
|
+
return await ap_muted.findOne(filter);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Remove a muted URL or keyword
|
|
40
|
+
* @param {object} collections - MongoDB collections
|
|
41
|
+
* @param {object} data - Mute identifier
|
|
42
|
+
* @param {string} [data.url] - Actor URL to unmute
|
|
43
|
+
* @param {string} [data.keyword] - Keyword to unmute
|
|
44
|
+
* @returns {Promise<object>} Delete result
|
|
45
|
+
*/
|
|
46
|
+
export async function removeMuted(collections, { url, keyword }) {
|
|
47
|
+
const { ap_muted } = collections;
|
|
48
|
+
|
|
49
|
+
const filter = {};
|
|
50
|
+
if (url) {
|
|
51
|
+
filter.url = url;
|
|
52
|
+
} else if (keyword) {
|
|
53
|
+
filter.keyword = keyword;
|
|
54
|
+
} else {
|
|
55
|
+
throw new Error("Either url or keyword must be provided");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return await ap_muted.deleteOne(filter);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Get all muted URLs
|
|
63
|
+
* @param {object} collections - MongoDB collections
|
|
64
|
+
* @returns {Promise<string[]>} Array of muted URLs
|
|
65
|
+
*/
|
|
66
|
+
export async function getMutedUrls(collections) {
|
|
67
|
+
const { ap_muted } = collections;
|
|
68
|
+
const entries = await ap_muted.find({ url: { $ne: null } }).toArray();
|
|
69
|
+
return entries.map((entry) => entry.url);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Get all muted keywords
|
|
74
|
+
* @param {object} collections - MongoDB collections
|
|
75
|
+
* @returns {Promise<string[]>} Array of muted keywords
|
|
76
|
+
*/
|
|
77
|
+
export async function getMutedKeywords(collections) {
|
|
78
|
+
const { ap_muted } = collections;
|
|
79
|
+
const entries = await ap_muted.find({ keyword: { $ne: null } }).toArray();
|
|
80
|
+
return entries.map((entry) => entry.keyword);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Check if a URL is muted
|
|
85
|
+
* @param {object} collections - MongoDB collections
|
|
86
|
+
* @param {string} url - URL to check
|
|
87
|
+
* @returns {Promise<boolean>} True if muted
|
|
88
|
+
*/
|
|
89
|
+
export async function isUrlMuted(collections, url) {
|
|
90
|
+
const { ap_muted } = collections;
|
|
91
|
+
const entry = await ap_muted.findOne({ url });
|
|
92
|
+
return !!entry;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Check if content contains muted keywords
|
|
97
|
+
* @param {object} collections - MongoDB collections
|
|
98
|
+
* @param {string} content - Content text to check
|
|
99
|
+
* @returns {Promise<boolean>} True if contains muted keyword
|
|
100
|
+
*/
|
|
101
|
+
export async function containsMutedKeyword(collections, content) {
|
|
102
|
+
const keywords = await getMutedKeywords(collections);
|
|
103
|
+
const lowerContent = content.toLowerCase();
|
|
104
|
+
|
|
105
|
+
return keywords.some((keyword) => lowerContent.includes(keyword.toLowerCase()));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Add a blocked actor URL
|
|
110
|
+
* @param {object} collections - MongoDB collections
|
|
111
|
+
* @param {string} url - Actor URL to block
|
|
112
|
+
* @returns {Promise<object>} Created block entry
|
|
113
|
+
*/
|
|
114
|
+
export async function addBlocked(collections, url) {
|
|
115
|
+
const { ap_blocked } = collections;
|
|
116
|
+
|
|
117
|
+
const entry = {
|
|
118
|
+
url,
|
|
119
|
+
blockedAt: new Date().toISOString(),
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
// Upsert to avoid duplicates
|
|
123
|
+
await ap_blocked.updateOne({ url }, { $setOnInsert: entry }, { upsert: true });
|
|
124
|
+
|
|
125
|
+
return await ap_blocked.findOne({ url });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Remove a blocked actor URL
|
|
130
|
+
* @param {object} collections - MongoDB collections
|
|
131
|
+
* @param {string} url - Actor URL to unblock
|
|
132
|
+
* @returns {Promise<object>} Delete result
|
|
133
|
+
*/
|
|
134
|
+
export async function removeBlocked(collections, url) {
|
|
135
|
+
const { ap_blocked } = collections;
|
|
136
|
+
return await ap_blocked.deleteOne({ url });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Get all blocked URLs
|
|
141
|
+
* @param {object} collections - MongoDB collections
|
|
142
|
+
* @returns {Promise<string[]>} Array of blocked URLs
|
|
143
|
+
*/
|
|
144
|
+
export async function getBlockedUrls(collections) {
|
|
145
|
+
const { ap_blocked } = collections;
|
|
146
|
+
const entries = await ap_blocked.find({}).toArray();
|
|
147
|
+
return entries.map((entry) => entry.url);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Check if a URL is blocked
|
|
152
|
+
* @param {object} collections - MongoDB collections
|
|
153
|
+
* @param {string} url - URL to check
|
|
154
|
+
* @returns {Promise<boolean>} True if blocked
|
|
155
|
+
*/
|
|
156
|
+
export async function isUrlBlocked(collections, url) {
|
|
157
|
+
const { ap_blocked } = collections;
|
|
158
|
+
const entry = await ap_blocked.findOne({ url });
|
|
159
|
+
return !!entry;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Get list of all muted entries (URLs and keywords)
|
|
164
|
+
* @param {object} collections - MongoDB collections
|
|
165
|
+
* @returns {Promise<object[]>} Array of mute entries
|
|
166
|
+
*/
|
|
167
|
+
export async function getAllMuted(collections) {
|
|
168
|
+
const { ap_muted } = collections;
|
|
169
|
+
return await ap_muted.find({}).toArray();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Get list of all blocked entries
|
|
174
|
+
* @param {object} collections - MongoDB collections
|
|
175
|
+
* @returns {Promise<object[]>} Array of block entries
|
|
176
|
+
*/
|
|
177
|
+
export async function getAllBlocked(collections) {
|
|
178
|
+
const { ap_blocked } = collections;
|
|
179
|
+
return await ap_blocked.find({}).toArray();
|
|
180
|
+
}
|