@rmdes/indiekit-endpoint-microsub 1.0.60 → 1.0.63
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 +36 -30
- package/lib/controllers/reader/channel.js +41 -2
- package/lib/controllers/reader/index.js +0 -15
- package/lib/storage/channels.js +1 -37
- package/lib/storage/items-retention.js +207 -96
- package/lib/utils/validation.js +25 -0
- package/locales/en.json +8 -0
- package/package.json +2 -1
- package/views/partials/item-card.njk +0 -6
- package/views/settings.njk +37 -0
- package/lib/activitypub/outbox-fetcher.js +0 -267
- package/lib/controllers/reader/actor.js +0 -142
- package/views/actor.njk +0 -188
package/index.js
CHANGED
|
@@ -3,6 +3,7 @@ import { fileURLToPath } from "node:url";
|
|
|
3
3
|
|
|
4
4
|
import express from "express";
|
|
5
5
|
import rateLimit from "express-rate-limit";
|
|
6
|
+
import { waitForReady } from "@rmdes/indiekit-startup-gate";
|
|
6
7
|
|
|
7
8
|
import { microsubController } from "./lib/controllers/microsub.js";
|
|
8
9
|
import { opmlController } from "./lib/controllers/opml.js";
|
|
@@ -11,11 +12,11 @@ import { asyncHandler } from "./lib/utils/async-handler.js";
|
|
|
11
12
|
import { handleMediaProxy } from "./lib/media/proxy.js";
|
|
12
13
|
import { csrfToken, csrfValidate } from "./lib/utils/csrf.js";
|
|
13
14
|
import { startScheduler, stopScheduler } from "./lib/polling/scheduler.js";
|
|
14
|
-
import { ensureActivityPubChannel } from "./lib/storage/channels.js";
|
|
15
15
|
import { createIndexes } from "./lib/storage/items.js";
|
|
16
16
|
import {
|
|
17
17
|
cleanupAllReadItems,
|
|
18
18
|
cleanupStaleItems,
|
|
19
|
+
removeActivityPubData,
|
|
19
20
|
} from "./lib/storage/items-retention.js";
|
|
20
21
|
import { webmentionReceiver } from "./lib/webmention/receiver.js";
|
|
21
22
|
import { websubHandler } from "./lib/websub/handler.js";
|
|
@@ -132,9 +133,6 @@ export default class MicrosubEndpoint {
|
|
|
132
133
|
readerRouter.get("/search", asyncHandler(readerController.searchPage));
|
|
133
134
|
readerRouter.post("/search", asyncHandler(readerController.searchFeeds));
|
|
134
135
|
readerRouter.post("/subscribe", asyncHandler(readerController.subscribe));
|
|
135
|
-
readerRouter.get("/actor", asyncHandler(readerController.actorProfile));
|
|
136
|
-
readerRouter.post("/actor/follow", asyncHandler(readerController.followActorAction));
|
|
137
|
-
readerRouter.post("/actor/unfollow", asyncHandler(readerController.unfollowActorAction));
|
|
138
136
|
readerRouter.post("/api/mark-read", asyncHandler(readerController.markAllRead));
|
|
139
137
|
readerRouter.post("/api/mark-view-read", asyncHandler(readerController.markViewRead));
|
|
140
138
|
readerRouter.get("/opml", opmlController.exportOpml);
|
|
@@ -202,38 +200,45 @@ export default class MicrosubEndpoint {
|
|
|
202
200
|
// Start feed polling scheduler when server starts
|
|
203
201
|
// This will be called after the server is ready
|
|
204
202
|
if (indiekit.database) {
|
|
205
|
-
|
|
206
|
-
startScheduler(indiekit);
|
|
207
|
-
|
|
208
|
-
// Ensure system channels exist
|
|
209
|
-
ensureActivityPubChannel(indiekit).catch((error) => {
|
|
210
|
-
console.warn(
|
|
211
|
-
"[Microsub] ActivityPub channel creation failed:",
|
|
212
|
-
error.message,
|
|
213
|
-
);
|
|
214
|
-
});
|
|
215
|
-
|
|
216
|
-
// Create indexes for optimal performance (runs in background)
|
|
203
|
+
// Indexes are cheap and idempotent — create immediately
|
|
217
204
|
createIndexes(indiekit).catch((error) => {
|
|
218
205
|
console.warn("[Microsub] Index creation failed:", error.message);
|
|
219
206
|
});
|
|
220
207
|
|
|
221
|
-
//
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
208
|
+
// Defer heavy tasks until host is ready
|
|
209
|
+
this._stopGate = waitForReady(
|
|
210
|
+
() => {
|
|
211
|
+
console.info("[Microsub] Starting scheduler and maintenance tasks");
|
|
212
|
+
startScheduler(indiekit);
|
|
225
213
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
214
|
+
// One-time migration: drop the abandoned "Fediverse" channel and its
|
|
215
|
+
// items. Idempotent — does nothing once the channel is gone.
|
|
216
|
+
removeActivityPubData(indiekit).catch((error) => {
|
|
217
|
+
console.warn(
|
|
218
|
+
"[Microsub] ActivityPub data removal failed:",
|
|
219
|
+
error.message,
|
|
220
|
+
);
|
|
221
|
+
});
|
|
230
222
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
223
|
+
// Cleanup old read items on startup
|
|
224
|
+
cleanupAllReadItems(indiekit).catch((error) => {
|
|
225
|
+
console.warn("[Microsub] Startup cleanup failed:", error.message);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// Delete stale items (stripped skeletons + unread older than 30 days)
|
|
229
|
+
cleanupStaleItems(indiekit).catch((error) => {
|
|
230
|
+
console.warn("[Microsub] Stale cleanup failed:", error.message);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// Schedule daily stale cleanup (items accumulate between restarts)
|
|
234
|
+
setInterval(() => {
|
|
235
|
+
cleanupStaleItems(indiekit).catch((error) => {
|
|
236
|
+
console.warn("[Microsub] Scheduled stale cleanup failed:", error.message);
|
|
237
|
+
});
|
|
238
|
+
}, 24 * 60 * 60 * 1000);
|
|
239
|
+
},
|
|
240
|
+
{ label: "Microsub" },
|
|
241
|
+
);
|
|
237
242
|
} else {
|
|
238
243
|
console.warn(
|
|
239
244
|
"[Microsub] Database not available at init, scheduler not started",
|
|
@@ -245,6 +250,7 @@ export default class MicrosubEndpoint {
|
|
|
245
250
|
* Cleanup on shutdown
|
|
246
251
|
*/
|
|
247
252
|
destroy() {
|
|
253
|
+
this._stopGate?.();
|
|
248
254
|
stopScheduler();
|
|
249
255
|
}
|
|
250
256
|
}
|
|
@@ -18,7 +18,13 @@ import {
|
|
|
18
18
|
validateChannelName,
|
|
19
19
|
validateExcludeTypes,
|
|
20
20
|
validateExcludeRegex,
|
|
21
|
+
validateRetentionSetting,
|
|
21
22
|
} from "../../utils/validation.js";
|
|
23
|
+
import {
|
|
24
|
+
DEFAULT_MAX_ITEMS,
|
|
25
|
+
DEFAULT_MAX_ITEMS_PER_FEED,
|
|
26
|
+
DEFAULT_MAX_UNREAD_AGE_DAYS,
|
|
27
|
+
} from "../../storage/items-retention.js";
|
|
22
28
|
import { proxyItemImages } from "../../media/proxy.js";
|
|
23
29
|
|
|
24
30
|
/**
|
|
@@ -212,6 +218,14 @@ export async function channelHtml(request, response) {
|
|
|
212
218
|
* @param {object} response - Express response
|
|
213
219
|
* @returns {Promise<void>}
|
|
214
220
|
*/
|
|
221
|
+
// Defaults exposed to the settings template so placeholders show what the
|
|
222
|
+
// channel falls back to when a field is left blank.
|
|
223
|
+
const RETENTION_DEFAULTS = {
|
|
224
|
+
maxItems: DEFAULT_MAX_ITEMS,
|
|
225
|
+
maxItemsPerFeed: DEFAULT_MAX_ITEMS_PER_FEED,
|
|
226
|
+
maxUnreadAgeDays: DEFAULT_MAX_UNREAD_AGE_DAYS,
|
|
227
|
+
};
|
|
228
|
+
|
|
215
229
|
export async function settings(request, response) {
|
|
216
230
|
const { application } = request.app.locals;
|
|
217
231
|
const userId = getUserId(request);
|
|
@@ -236,6 +250,7 @@ export async function settings(request, response) {
|
|
|
236
250
|
{ text: channelDocument.name, href: `${request.baseUrl}/channels/${uid}` },
|
|
237
251
|
{ text: "Settings" },
|
|
238
252
|
],
|
|
253
|
+
retentionDefaults: RETENTION_DEFAULTS,
|
|
239
254
|
});
|
|
240
255
|
}
|
|
241
256
|
|
|
@@ -249,7 +264,13 @@ export async function updateSettings(request, response) {
|
|
|
249
264
|
const { application } = request.app.locals;
|
|
250
265
|
const userId = getUserId(request);
|
|
251
266
|
const { uid } = request.params;
|
|
252
|
-
const {
|
|
267
|
+
const {
|
|
268
|
+
excludeTypes,
|
|
269
|
+
excludeRegex,
|
|
270
|
+
maxItems,
|
|
271
|
+
maxItemsPerFeed,
|
|
272
|
+
maxUnreadAgeDays,
|
|
273
|
+
} = request.body;
|
|
253
274
|
|
|
254
275
|
const channelDocument = await getChannel(application, uid, userId);
|
|
255
276
|
if (!channelDocument) {
|
|
@@ -261,12 +282,30 @@ export async function updateSettings(request, response) {
|
|
|
261
282
|
);
|
|
262
283
|
const validatedRegex = validateExcludeRegex(excludeRegex);
|
|
263
284
|
|
|
285
|
+
// Retention overrides. Empty/invalid input falls back to the global default
|
|
286
|
+
// (stored as undefined so future global default changes apply automatically).
|
|
287
|
+
const validatedMaxItems = validateRetentionSetting(maxItems, {
|
|
288
|
+
min: 10,
|
|
289
|
+
max: 100_000,
|
|
290
|
+
});
|
|
291
|
+
const validatedMaxItemsPerFeed = validateRetentionSetting(maxItemsPerFeed, {
|
|
292
|
+
min: 1,
|
|
293
|
+
max: 10_000,
|
|
294
|
+
});
|
|
295
|
+
const validatedMaxUnreadAgeDays = validateRetentionSetting(
|
|
296
|
+
maxUnreadAgeDays,
|
|
297
|
+
{ min: 1, max: 3650 },
|
|
298
|
+
);
|
|
299
|
+
|
|
264
300
|
await updateChannelSettings(
|
|
265
301
|
application,
|
|
266
302
|
uid,
|
|
267
303
|
{
|
|
268
304
|
excludeTypes: validatedTypes,
|
|
269
305
|
excludeRegex: validatedRegex,
|
|
306
|
+
maxItems: validatedMaxItems,
|
|
307
|
+
maxItemsPerFeed: validatedMaxItemsPerFeed,
|
|
308
|
+
maxUnreadAgeDays: validatedMaxUnreadAgeDays,
|
|
270
309
|
},
|
|
271
310
|
userId,
|
|
272
311
|
);
|
|
@@ -286,7 +325,7 @@ export async function deleteChannelAction(request, response) {
|
|
|
286
325
|
const { uid } = request.params;
|
|
287
326
|
|
|
288
327
|
// Don't allow deleting system channels
|
|
289
|
-
if (uid === "notifications"
|
|
328
|
+
if (uid === "notifications") {
|
|
290
329
|
return response.redirect(`${request.baseUrl}/channels`);
|
|
291
330
|
}
|
|
292
331
|
|
|
@@ -38,12 +38,6 @@ export { compose, submitCompose } from "./compose.js";
|
|
|
38
38
|
|
|
39
39
|
export { searchPage, searchFeeds, subscribe } from "./search.js";
|
|
40
40
|
|
|
41
|
-
export {
|
|
42
|
-
actorProfile,
|
|
43
|
-
followActorAction,
|
|
44
|
-
unfollowActorAction,
|
|
45
|
-
} from "./actor.js";
|
|
46
|
-
|
|
47
41
|
export { deck, deckSettings, saveDeckSettings } from "./deck.js";
|
|
48
42
|
|
|
49
43
|
import {
|
|
@@ -81,12 +75,6 @@ import { compose, submitCompose } from "./compose.js";
|
|
|
81
75
|
|
|
82
76
|
import { searchPage, searchFeeds, subscribe } from "./search.js";
|
|
83
77
|
|
|
84
|
-
import {
|
|
85
|
-
actorProfile,
|
|
86
|
-
followActorAction,
|
|
87
|
-
unfollowActorAction,
|
|
88
|
-
} from "./actor.js";
|
|
89
|
-
|
|
90
78
|
import { deck, deckSettings, saveDeckSettings } from "./deck.js";
|
|
91
79
|
|
|
92
80
|
export const readerController = {
|
|
@@ -115,9 +103,6 @@ export const readerController = {
|
|
|
115
103
|
searchPage,
|
|
116
104
|
searchFeeds,
|
|
117
105
|
subscribe,
|
|
118
|
-
actorProfile,
|
|
119
|
-
followActorAction,
|
|
120
|
-
unfollowActorAction,
|
|
121
106
|
timeline,
|
|
122
107
|
timelineHtml,
|
|
123
108
|
deck,
|
package/lib/storage/channels.js
CHANGED
|
@@ -298,7 +298,7 @@ export async function deleteChannel(application, uid, userId) {
|
|
|
298
298
|
if (userId) query.userId = userId;
|
|
299
299
|
|
|
300
300
|
// Don't allow deleting system channels
|
|
301
|
-
if (uid === "notifications"
|
|
301
|
+
if (uid === "notifications") {
|
|
302
302
|
return false;
|
|
303
303
|
}
|
|
304
304
|
|
|
@@ -396,39 +396,3 @@ export async function ensureNotificationsChannel(application, userId) {
|
|
|
396
396
|
await collection.insertOne(channel);
|
|
397
397
|
return channel;
|
|
398
398
|
}
|
|
399
|
-
|
|
400
|
-
/**
|
|
401
|
-
* Ensure ActivityPub channel exists
|
|
402
|
-
* @param {object} application - Indiekit application
|
|
403
|
-
* @param {string} [userId] - User ID
|
|
404
|
-
* @returns {Promise<object>} ActivityPub channel
|
|
405
|
-
*/
|
|
406
|
-
export async function ensureActivityPubChannel(application, userId) {
|
|
407
|
-
const collection = getCollection(application);
|
|
408
|
-
|
|
409
|
-
const existing = await collection.findOne({
|
|
410
|
-
uid: "activitypub",
|
|
411
|
-
...(userId && { userId }),
|
|
412
|
-
});
|
|
413
|
-
|
|
414
|
-
if (existing) {
|
|
415
|
-
return existing;
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
const channel = {
|
|
419
|
-
uid: "activitypub",
|
|
420
|
-
name: "Fediverse",
|
|
421
|
-
userId,
|
|
422
|
-
source: "activitypub",
|
|
423
|
-
order: -0.5, // After notifications (-1), before user channels (0+)
|
|
424
|
-
settings: {
|
|
425
|
-
excludeTypes: [],
|
|
426
|
-
excludeRegex: undefined,
|
|
427
|
-
},
|
|
428
|
-
createdAt: new Date().toISOString(),
|
|
429
|
-
updatedAt: new Date().toISOString(),
|
|
430
|
-
};
|
|
431
|
-
|
|
432
|
-
await collection.insertOne(channel);
|
|
433
|
-
return channel;
|
|
434
|
-
}
|
|
@@ -8,13 +8,18 @@ import { getCollection } from "./items.js";
|
|
|
8
8
|
// Maximum number of full read items to keep per channel before stripping content.
|
|
9
9
|
const MAX_FULL_READ_ITEMS = 200;
|
|
10
10
|
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
|
|
11
|
+
// Global retention defaults. Each can be overridden per channel via
|
|
12
|
+
// channel.settings.{maxItems,maxItemsPerFeed,maxUnreadAgeDays}. The "notifications"
|
|
13
|
+
// channel is exempt from these caps entirely — webmentions are high-signal and
|
|
14
|
+
// users may want long history there.
|
|
15
|
+
export const DEFAULT_MAX_ITEMS = 1000;
|
|
16
|
+
export const DEFAULT_MAX_ITEMS_PER_FEED = 50;
|
|
17
|
+
export const DEFAULT_MAX_UNREAD_AGE_DAYS = 30;
|
|
14
18
|
|
|
15
19
|
/**
|
|
16
20
|
* Cleanup all read items across all channels (startup cleanup).
|
|
17
|
-
*
|
|
21
|
+
* Read items beyond MAX_FULL_READ_ITEMS are stripped to skeletons (kept for
|
|
22
|
+
* dedup, content removed).
|
|
18
23
|
* @param {object} application - Indiekit application
|
|
19
24
|
* @returns {Promise<number>} Total number of items cleaned up
|
|
20
25
|
*/
|
|
@@ -49,59 +54,36 @@ export async function cleanupAllReadItems(application) {
|
|
|
49
54
|
})
|
|
50
55
|
.sort({ published: -1, _id: -1 })
|
|
51
56
|
.skip(MAX_FULL_READ_ITEMS)
|
|
52
|
-
.project({ _id: 1
|
|
57
|
+
.project({ _id: 1 })
|
|
53
58
|
.toArray();
|
|
54
59
|
|
|
55
60
|
if (itemsToCleanup.length > 0) {
|
|
56
|
-
const
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// Strip RSS items to skeletons
|
|
78
|
-
if (rssItemIds.length > 0) {
|
|
79
|
-
const stripped = await collection.updateMany(
|
|
80
|
-
{ _id: { $in: rssItemIds } },
|
|
81
|
-
{
|
|
82
|
-
$set: { _stripped: true },
|
|
83
|
-
$unset: {
|
|
84
|
-
name: "",
|
|
85
|
-
content: "",
|
|
86
|
-
summary: "",
|
|
87
|
-
author: "",
|
|
88
|
-
category: "",
|
|
89
|
-
photo: "",
|
|
90
|
-
video: "",
|
|
91
|
-
audio: "",
|
|
92
|
-
likeOf: "",
|
|
93
|
-
repostOf: "",
|
|
94
|
-
bookmarkOf: "",
|
|
95
|
-
inReplyTo: "",
|
|
96
|
-
source: "",
|
|
97
|
-
},
|
|
61
|
+
const ids = itemsToCleanup.map((item) => item._id);
|
|
62
|
+
const stripped = await collection.updateMany(
|
|
63
|
+
{ _id: { $in: ids } },
|
|
64
|
+
{
|
|
65
|
+
$set: { _stripped: true },
|
|
66
|
+
$unset: {
|
|
67
|
+
name: "",
|
|
68
|
+
content: "",
|
|
69
|
+
summary: "",
|
|
70
|
+
author: "",
|
|
71
|
+
category: "",
|
|
72
|
+
photo: "",
|
|
73
|
+
video: "",
|
|
74
|
+
audio: "",
|
|
75
|
+
likeOf: "",
|
|
76
|
+
repostOf: "",
|
|
77
|
+
bookmarkOf: "",
|
|
78
|
+
inReplyTo: "",
|
|
79
|
+
source: "",
|
|
98
80
|
},
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
81
|
+
},
|
|
82
|
+
);
|
|
83
|
+
totalCleaned += stripped.modifiedCount;
|
|
84
|
+
console.info(
|
|
85
|
+
`[Microsub] Startup cleanup: stripped ${stripped.modifiedCount} items from channel "${channel.name}"`,
|
|
86
|
+
);
|
|
105
87
|
}
|
|
106
88
|
}
|
|
107
89
|
}
|
|
@@ -117,58 +99,187 @@ export async function cleanupAllReadItems(application) {
|
|
|
117
99
|
}
|
|
118
100
|
|
|
119
101
|
/**
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
102
|
+
* Per-channel retention cleanup. For each channel (excluding `notifications`):
|
|
103
|
+
* 1. Drop unread items + stripped skeletons older than `maxUnreadAgeDays`.
|
|
104
|
+
* 2. Per-feed cap: keep most recent `maxItemsPerFeed` items per feed, drop the rest.
|
|
105
|
+
* 3. Channel-wide cap: keep most recent `maxItems` items total, drop the rest.
|
|
106
|
+
*
|
|
107
|
+
* Each channel uses its own `channel.settings.{maxItems,maxItemsPerFeed,maxUnreadAgeDays}`
|
|
108
|
+
* when present; otherwise the module-level defaults apply. This makes the policy
|
|
109
|
+
* configurable per channel — a noisy aggregator channel can set tight caps while
|
|
110
|
+
* a low-volume curated channel keeps a long tail.
|
|
111
|
+
*
|
|
112
|
+
* The order matters: per-feed cap runs before channel cap so a single prolific
|
|
113
|
+
* feed cannot starve other feeds in the channel of representation after the
|
|
114
|
+
* channel-wide trim.
|
|
115
|
+
*
|
|
123
116
|
* @param {object} application - Indiekit application
|
|
124
|
-
* @returns {Promise<number>} Total number of items deleted
|
|
117
|
+
* @returns {Promise<number>} Total number of items deleted across all channels
|
|
125
118
|
*/
|
|
126
119
|
export async function cleanupStaleItems(application) {
|
|
127
|
-
const
|
|
128
|
-
const
|
|
129
|
-
cutoff.setDate(cutoff.getDate() - MAX_ITEM_AGE_DAYS);
|
|
130
|
-
|
|
131
|
-
// Delete stripped skeletons older than cutoff
|
|
132
|
-
const strippedResult = await collection.deleteMany({
|
|
133
|
-
_stripped: true,
|
|
134
|
-
$or: [
|
|
135
|
-
{ published: { $lt: cutoff } },
|
|
136
|
-
{ published: { $exists: false }, createdAt: { $lt: cutoff.toISOString() } },
|
|
137
|
-
],
|
|
138
|
-
});
|
|
120
|
+
const itemsCollection = getCollection(application);
|
|
121
|
+
const channelsCollection = application.collections.get("microsub_channels");
|
|
139
122
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
readBy: { $in: [null, []] },
|
|
143
|
-
_stripped: { $ne: true },
|
|
144
|
-
$or: [
|
|
145
|
-
{ published: { $lt: cutoff } },
|
|
146
|
-
{ published: { $exists: false }, createdAt: { $lt: cutoff.toISOString() } },
|
|
147
|
-
],
|
|
148
|
-
});
|
|
123
|
+
const channels = await channelsCollection.find({}).toArray();
|
|
124
|
+
let totalDeleted = 0;
|
|
149
125
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
126
|
+
for (const channel of channels) {
|
|
127
|
+
// Notifications channel (webmentions) is exempt — high-signal, kept indefinitely.
|
|
128
|
+
if (channel.uid === "notifications") continue;
|
|
129
|
+
|
|
130
|
+
const settings = channel.settings || {};
|
|
131
|
+
const maxItems = settings.maxItems ?? DEFAULT_MAX_ITEMS;
|
|
132
|
+
const maxItemsPerFeed =
|
|
133
|
+
settings.maxItemsPerFeed ?? DEFAULT_MAX_ITEMS_PER_FEED;
|
|
134
|
+
const maxUnreadAgeDays =
|
|
135
|
+
settings.maxUnreadAgeDays ?? DEFAULT_MAX_UNREAD_AGE_DAYS;
|
|
136
|
+
|
|
137
|
+
const cutoff = new Date();
|
|
138
|
+
cutoff.setDate(cutoff.getDate() - maxUnreadAgeDays);
|
|
139
|
+
const cutoffIso = cutoff.toISOString();
|
|
140
|
+
let channelDeleted = 0;
|
|
141
|
+
let staleDeleted = 0;
|
|
142
|
+
let perFeedDeleted = 0;
|
|
143
|
+
let channelCapDeleted = 0;
|
|
144
|
+
|
|
145
|
+
// 1. Drop stripped skeletons older than cutoff (served their dedup purpose).
|
|
146
|
+
const strippedResult = await itemsCollection.deleteMany({
|
|
147
|
+
channelId: channel._id,
|
|
148
|
+
_stripped: true,
|
|
149
|
+
$or: [
|
|
150
|
+
{ published: { $lt: cutoff } },
|
|
151
|
+
{
|
|
152
|
+
published: { $exists: false },
|
|
153
|
+
createdAt: { $lt: cutoffIso },
|
|
154
|
+
},
|
|
155
|
+
],
|
|
156
|
+
});
|
|
157
|
+
staleDeleted += strippedResult.deletedCount;
|
|
158
|
+
|
|
159
|
+
// 1b. Drop unread (or never-read) items older than cutoff.
|
|
160
|
+
const unreadAgeResult = await itemsCollection.deleteMany({
|
|
161
|
+
channelId: channel._id,
|
|
162
|
+
_stripped: { $ne: true },
|
|
163
|
+
$and: [
|
|
164
|
+
{
|
|
165
|
+
$or: [
|
|
166
|
+
{ readBy: { $exists: false } },
|
|
167
|
+
{ readBy: { $size: 0 } },
|
|
168
|
+
{ readBy: null },
|
|
169
|
+
],
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
$or: [
|
|
173
|
+
{ published: { $lt: cutoff } },
|
|
174
|
+
{
|
|
175
|
+
published: { $exists: false },
|
|
176
|
+
createdAt: { $lt: cutoffIso },
|
|
177
|
+
},
|
|
178
|
+
],
|
|
179
|
+
},
|
|
180
|
+
],
|
|
181
|
+
});
|
|
182
|
+
staleDeleted += unreadAgeResult.deletedCount;
|
|
183
|
+
channelDeleted += staleDeleted;
|
|
184
|
+
|
|
185
|
+
// 2. Per-feed cap. Iterate feeds in the channel; for each, delete oldest
|
|
186
|
+
// items beyond maxItemsPerFeed regardless of read state.
|
|
187
|
+
const feedIds = await itemsCollection.distinct("feedId", {
|
|
188
|
+
channelId: channel._id,
|
|
189
|
+
feedId: { $exists: true, $ne: null },
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
for (const feedId of feedIds) {
|
|
193
|
+
const excess = await itemsCollection
|
|
194
|
+
.find({ channelId: channel._id, feedId })
|
|
195
|
+
.sort({ published: -1, _id: -1 })
|
|
196
|
+
.skip(maxItemsPerFeed)
|
|
197
|
+
.project({ _id: 1 })
|
|
198
|
+
.toArray();
|
|
199
|
+
|
|
200
|
+
if (excess.length > 0) {
|
|
201
|
+
const ids = excess.map((item) => item._id);
|
|
202
|
+
const result = await itemsCollection.deleteMany({
|
|
203
|
+
_id: { $in: ids },
|
|
204
|
+
});
|
|
205
|
+
perFeedDeleted += result.deletedCount;
|
|
206
|
+
channelDeleted += result.deletedCount;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
159
209
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
210
|
+
// 3. Channel-wide cap. Catches items without feedId plus anything still over
|
|
211
|
+
// the per-channel ceiling after the per-feed pass.
|
|
212
|
+
const excessChannel = await itemsCollection
|
|
213
|
+
.find({ channelId: channel._id })
|
|
214
|
+
.sort({ published: -1, _id: -1 })
|
|
215
|
+
.skip(maxItems)
|
|
216
|
+
.project({ _id: 1 })
|
|
217
|
+
.toArray();
|
|
164
218
|
|
|
165
|
-
|
|
219
|
+
if (excessChannel.length > 0) {
|
|
220
|
+
const ids = excessChannel.map((item) => item._id);
|
|
221
|
+
const result = await itemsCollection.deleteMany({ _id: { $in: ids } });
|
|
222
|
+
channelCapDeleted += result.deletedCount;
|
|
223
|
+
channelDeleted += result.deletedCount;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (channelDeleted > 0) {
|
|
227
|
+
console.info(
|
|
228
|
+
`[Microsub] Retention cleanup "${channel.name}": deleted ${channelDeleted} items ` +
|
|
229
|
+
`(stale: ${staleDeleted}, per-feed: ${perFeedDeleted}, channel-cap: ${channelCapDeleted}; ` +
|
|
230
|
+
`maxItems=${maxItems}, maxItemsPerFeed=${maxItemsPerFeed}, maxUnreadAgeDays=${maxUnreadAgeDays})`,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
totalDeleted += channelDeleted;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (totalDeleted > 0) {
|
|
166
237
|
console.info(
|
|
167
|
-
`[Microsub]
|
|
168
|
-
`${unreadResult.deletedCount + noReadByResult.deletedCount} stale unread items ` +
|
|
169
|
-
`(cutoff: ${MAX_ITEM_AGE_DAYS} days)`,
|
|
238
|
+
`[Microsub] Retention cleanup complete: ${totalDeleted} total items deleted across ${channels.length} channels`,
|
|
170
239
|
);
|
|
171
240
|
}
|
|
172
241
|
|
|
173
|
-
return
|
|
242
|
+
return totalDeleted;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* One-time migration: remove the abandoned "Fediverse" channel and its items.
|
|
247
|
+
* The microsub reader briefly tried to ingest ActivityPub outboxes into a
|
|
248
|
+
* dedicated channel (uid: "activitypub"). That feature was abandoned — fediverse
|
|
249
|
+
* federation lives entirely in the separate `indiekit-endpoint-activitypub`
|
|
250
|
+
* plugin now. This migration cleans up the leftover channel and items.
|
|
251
|
+
* Idempotent — safe to run on every startup.
|
|
252
|
+
* @param {object} application - Indiekit application
|
|
253
|
+
* @returns {Promise<{ channelsRemoved: number, itemsRemoved: number }>}
|
|
254
|
+
*/
|
|
255
|
+
export async function removeActivityPubData(application) {
|
|
256
|
+
const itemsCollection = getCollection(application);
|
|
257
|
+
const channelsCollection = application.collections.get("microsub_channels");
|
|
258
|
+
|
|
259
|
+
const apChannels = await channelsCollection
|
|
260
|
+
.find({ uid: "activitypub" })
|
|
261
|
+
.toArray();
|
|
262
|
+
|
|
263
|
+
if (apChannels.length === 0) {
|
|
264
|
+
return { channelsRemoved: 0, itemsRemoved: 0 };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const channelIds = apChannels.map((c) => c._id);
|
|
268
|
+
|
|
269
|
+
const itemsResult = await itemsCollection.deleteMany({
|
|
270
|
+
channelId: { $in: channelIds },
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
const channelsResult = await channelsCollection.deleteMany({
|
|
274
|
+
_id: { $in: channelIds },
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
console.info(
|
|
278
|
+
`[Microsub] Removed abandoned Fediverse channel: ${channelsResult.deletedCount} channel(s), ${itemsResult.deletedCount} item(s)`,
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
return {
|
|
282
|
+
channelsRemoved: channelsResult.deletedCount,
|
|
283
|
+
itemsRemoved: itemsResult.deletedCount,
|
|
284
|
+
};
|
|
174
285
|
}
|
package/lib/utils/validation.js
CHANGED
|
@@ -163,6 +163,31 @@ export function validateExcludeTypes(types) {
|
|
|
163
163
|
return types.filter((type) => VALID_EXCLUDE_TYPES.includes(type));
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
+
/**
|
|
167
|
+
* Validate a per-channel retention numeric setting.
|
|
168
|
+
* Accepts a positive integer; returns undefined for empty/invalid input so the
|
|
169
|
+
* caller can fall back to the global default.
|
|
170
|
+
* @param {string|number|undefined} value - Raw form value
|
|
171
|
+
* @param {object} [options]
|
|
172
|
+
* @param {number} [options.min] - Minimum allowed value (inclusive). Default 1.
|
|
173
|
+
* @param {number} [options.max] - Maximum allowed value (inclusive). Default 1_000_000.
|
|
174
|
+
* @returns {number|undefined} Validated integer, or undefined if empty/invalid
|
|
175
|
+
*/
|
|
176
|
+
export function validateRetentionSetting(value, options = {}) {
|
|
177
|
+
const { min = 1, max = 1_000_000 } = options;
|
|
178
|
+
|
|
179
|
+
if (value === undefined || value === null || value === "") {
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const parsed = Number.parseInt(String(value), 10);
|
|
184
|
+
if (!Number.isFinite(parsed) || parsed < min || parsed > max) {
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return parsed;
|
|
189
|
+
}
|
|
190
|
+
|
|
166
191
|
/**
|
|
167
192
|
* Validate regex pattern
|
|
168
193
|
* @param {string} pattern - Regex pattern to validate
|
package/locales/en.json
CHANGED
|
@@ -75,6 +75,14 @@
|
|
|
75
75
|
"excludeRegex": "Exclude pattern",
|
|
76
76
|
"excludeRegexHelp": "Regular expression to filter out matching content",
|
|
77
77
|
"save": "Save settings",
|
|
78
|
+
"retentionTitle": "Retention",
|
|
79
|
+
"retentionHelp": "Control how many items this channel keeps in MongoDB. Useful for high-volume aggregator channels that would otherwise grow without bound. Leave any field blank to use the global default.",
|
|
80
|
+
"maxItems": "Maximum items in channel",
|
|
81
|
+
"maxItemsHelp": "Keep at most this many items total. Oldest items are deleted regardless of read state. Default: {{default}}.",
|
|
82
|
+
"maxItemsPerFeed": "Maximum items per feed",
|
|
83
|
+
"maxItemsPerFeedHelp": "Keep at most this many items per feed inside this channel. Prevents one prolific feed from monopolising the channel cap. Default: {{default}}.",
|
|
84
|
+
"maxUnreadAgeDays": "Drop unread items older than (days)",
|
|
85
|
+
"maxUnreadAgeDaysHelp": "Unread items older than this are deleted, even if the channel is below its item cap. Default: {{default}}.",
|
|
78
86
|
"dangerZone": "Danger zone",
|
|
79
87
|
"deleteWarning": "Deleting this channel will permanently remove all feeds and items. This action cannot be undone.",
|
|
80
88
|
"deleteConfirm": "Are you sure you want to delete this channel and all its content?",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rmdes/indiekit-endpoint-microsub",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.63",
|
|
4
4
|
"description": "Microsub endpoint for Indiekit. Enables subscribing to feeds and reading content using the Microsub protocol.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"indiekit",
|
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
39
|
"@indiekit/error": "^1.0.0-beta.25",
|
|
40
|
+
"@rmdes/indiekit-startup-gate": "^1.0.0",
|
|
40
41
|
"@indiekit/frontend": "^1.0.0-beta.25",
|
|
41
42
|
"@indiekit/util": "^1.0.0-beta.25",
|
|
42
43
|
"debug": "^4.3.2",
|
|
@@ -153,12 +153,6 @@
|
|
|
153
153
|
|
|
154
154
|
{# Inline actions (Aperture pattern) #}
|
|
155
155
|
<div class="ms-item-actions">
|
|
156
|
-
{% if item._source and item._source.type === "activitypub" and item.author and item.author.url %}
|
|
157
|
-
<a href="{{ baseUrl }}/actor?url={{ item.author.url | urlencode }}" class="ms-item-actions__button" title="View actor profile">
|
|
158
|
-
{{ icon("mention") }}
|
|
159
|
-
<span class="-!-visually-hidden">Actor profile</span>
|
|
160
|
-
</a>
|
|
161
|
-
{% endif %}
|
|
162
156
|
{% if item.url %}
|
|
163
157
|
<a href="{{ item.url }}" class="ms-item-actions__button" target="_blank" rel="noopener" title="View original">
|
|
164
158
|
{{ icon("syndicate") }}
|
package/views/settings.njk
CHANGED
|
@@ -49,6 +49,43 @@
|
|
|
49
49
|
value: channel.settings.excludeRegex
|
|
50
50
|
}) }}
|
|
51
51
|
|
|
52
|
+
{% if channel.uid !== "notifications" %}
|
|
53
|
+
<fieldset class="ms-retention-settings">
|
|
54
|
+
<legend>{{ __("microsub.settings.retentionTitle") }}</legend>
|
|
55
|
+
<p class="hint">{{ __("microsub.settings.retentionHelp") }}</p>
|
|
56
|
+
|
|
57
|
+
{{ input({
|
|
58
|
+
id: "maxItems",
|
|
59
|
+
name: "maxItems",
|
|
60
|
+
type: "number",
|
|
61
|
+
label: __("microsub.settings.maxItems"),
|
|
62
|
+
hint: __("microsub.settings.maxItemsHelp", { default: retentionDefaults.maxItems }),
|
|
63
|
+
attributes: { min: 10, max: 100000, placeholder: retentionDefaults.maxItems },
|
|
64
|
+
value: channel.settings.maxItems
|
|
65
|
+
}) }}
|
|
66
|
+
|
|
67
|
+
{{ input({
|
|
68
|
+
id: "maxItemsPerFeed",
|
|
69
|
+
name: "maxItemsPerFeed",
|
|
70
|
+
type: "number",
|
|
71
|
+
label: __("microsub.settings.maxItemsPerFeed"),
|
|
72
|
+
hint: __("microsub.settings.maxItemsPerFeedHelp", { default: retentionDefaults.maxItemsPerFeed }),
|
|
73
|
+
attributes: { min: 1, max: 10000, placeholder: retentionDefaults.maxItemsPerFeed },
|
|
74
|
+
value: channel.settings.maxItemsPerFeed
|
|
75
|
+
}) }}
|
|
76
|
+
|
|
77
|
+
{{ input({
|
|
78
|
+
id: "maxUnreadAgeDays",
|
|
79
|
+
name: "maxUnreadAgeDays",
|
|
80
|
+
type: "number",
|
|
81
|
+
label: __("microsub.settings.maxUnreadAgeDays"),
|
|
82
|
+
hint: __("microsub.settings.maxUnreadAgeDaysHelp", { default: retentionDefaults.maxUnreadAgeDays }),
|
|
83
|
+
attributes: { min: 1, max: 3650, placeholder: retentionDefaults.maxUnreadAgeDays },
|
|
84
|
+
value: channel.settings.maxUnreadAgeDays
|
|
85
|
+
}) }}
|
|
86
|
+
</fieldset>
|
|
87
|
+
{% endif %}
|
|
88
|
+
|
|
52
89
|
<div class="button-group">
|
|
53
90
|
{{ button({
|
|
54
91
|
text: __("microsub.settings.save")
|
|
@@ -1,267 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Fetch a remote ActivityPub actor's outbox for on-demand reading.
|
|
3
|
-
* Returns ephemeral jf2 items — nothing is stored in MongoDB.
|
|
4
|
-
*
|
|
5
|
-
* @module activitypub/outbox-fetcher
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import sanitizeHtml from "sanitize-html";
|
|
9
|
-
|
|
10
|
-
import { isPrivateUrl } from "../media/proxy.js";
|
|
11
|
-
import { SANITIZE_OPTIONS } from "../utils/sanitize.js";
|
|
12
|
-
|
|
13
|
-
const AP_ACCEPT =
|
|
14
|
-
'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"';
|
|
15
|
-
const FETCH_TIMEOUT = 10_000;
|
|
16
|
-
const USER_AGENT = "Indiekit/1.0 (Microsub reader)";
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Fetch a remote actor's profile and recent posts from their outbox.
|
|
20
|
-
*
|
|
21
|
-
* @param {string} actorUrl - Full URL of the AP actor
|
|
22
|
-
* @param {object} [options]
|
|
23
|
-
* @param {number} [options.limit=20] - Max items to return
|
|
24
|
-
* @returns {Promise<{ actor: object, items: Array }>}
|
|
25
|
-
*/
|
|
26
|
-
export async function fetchActorOutbox(actorUrl, options = {}) {
|
|
27
|
-
const limit = options.limit || 20;
|
|
28
|
-
|
|
29
|
-
// 1. Fetch actor profile
|
|
30
|
-
const actor = await fetchJson(actorUrl);
|
|
31
|
-
if (!actor || !actor.outbox) {
|
|
32
|
-
throw new Error("Could not resolve actor or outbox URL");
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
const actorInfo = {
|
|
36
|
-
name:
|
|
37
|
-
actor.name ||
|
|
38
|
-
actor.preferredUsername ||
|
|
39
|
-
new URL(actorUrl).pathname.split("/").pop(),
|
|
40
|
-
url: actor.url || actor.id || actorUrl,
|
|
41
|
-
photo: actor.icon?.url || actor.icon || "",
|
|
42
|
-
summary: stripHtml(actor.summary || ""),
|
|
43
|
-
handle: actor.preferredUsername || "",
|
|
44
|
-
followersCount: 0,
|
|
45
|
-
followingCount: 0,
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
// Resolve follower/following counts if available
|
|
49
|
-
if (typeof actor.followers === "string") {
|
|
50
|
-
try {
|
|
51
|
-
const followersCollection = await fetchJson(actor.followers);
|
|
52
|
-
actorInfo.followersCount = followersCollection?.totalItems || 0;
|
|
53
|
-
} catch {
|
|
54
|
-
/* ignore */
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
if (typeof actor.following === "string") {
|
|
58
|
-
try {
|
|
59
|
-
const followingCollection = await fetchJson(actor.following);
|
|
60
|
-
actorInfo.followingCount = followingCollection?.totalItems || 0;
|
|
61
|
-
} catch {
|
|
62
|
-
/* ignore */
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// 2. Fetch outbox (OrderedCollection)
|
|
67
|
-
const outboxUrl =
|
|
68
|
-
typeof actor.outbox === "string" ? actor.outbox : actor.outbox?.id;
|
|
69
|
-
const outbox = await fetchJson(outboxUrl);
|
|
70
|
-
if (!outbox) {
|
|
71
|
-
return { actor: actorInfo, items: [] };
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// 3. Get items — may be inline or on a first page
|
|
75
|
-
let activities = [];
|
|
76
|
-
|
|
77
|
-
if (outbox.orderedItems?.length > 0) {
|
|
78
|
-
activities = outbox.orderedItems;
|
|
79
|
-
} else if (outbox.first) {
|
|
80
|
-
const firstPageUrl =
|
|
81
|
-
typeof outbox.first === "string" ? outbox.first : outbox.first?.id;
|
|
82
|
-
if (firstPageUrl) {
|
|
83
|
-
const firstPage = await fetchJson(firstPageUrl);
|
|
84
|
-
activities = firstPage?.orderedItems || firstPage?.items || [];
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
// 4. Convert Create activities to jf2 items
|
|
89
|
-
const items = [];
|
|
90
|
-
for (const activity of activities) {
|
|
91
|
-
if (items.length >= limit) break;
|
|
92
|
-
|
|
93
|
-
const item = activityToJf2(activity, actorInfo);
|
|
94
|
-
if (item) items.push(item);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
return { actor: actorInfo, items };
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* Convert a single AP activity (or bare object) to jf2 format.
|
|
102
|
-
* @param {object} activity - AP activity or object
|
|
103
|
-
* @param {object} actorInfo - Actor profile info
|
|
104
|
-
* @returns {object|null} jf2 item or null if not displayable
|
|
105
|
-
*/
|
|
106
|
-
function activityToJf2(activity, actorInfo) {
|
|
107
|
-
// Unwrap Create/Announce — the displayable content is the inner object
|
|
108
|
-
let object = activity;
|
|
109
|
-
const activityType = activity.type;
|
|
110
|
-
|
|
111
|
-
if (activityType === "Create" || activityType === "Announce") {
|
|
112
|
-
object = activity.object;
|
|
113
|
-
if (!object || typeof object === "string") return null; // Unresolved reference
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// Skip non-content types (Follow, Like, etc.)
|
|
117
|
-
const contentTypes = new Set([
|
|
118
|
-
"Note",
|
|
119
|
-
"Article",
|
|
120
|
-
"Page",
|
|
121
|
-
"Video",
|
|
122
|
-
"Audio",
|
|
123
|
-
"Image",
|
|
124
|
-
"Event",
|
|
125
|
-
"Question",
|
|
126
|
-
]);
|
|
127
|
-
if (!contentTypes.has(object.type)) return null;
|
|
128
|
-
|
|
129
|
-
const rawHtml = object.content || "";
|
|
130
|
-
const contentHtml = rawHtml ? sanitizeHtml(rawHtml, SANITIZE_OPTIONS) : "";
|
|
131
|
-
const contentText = stripHtml(rawHtml);
|
|
132
|
-
|
|
133
|
-
const jf2 = {
|
|
134
|
-
type: "entry",
|
|
135
|
-
url: object.url || object.id || "",
|
|
136
|
-
uid: object.id || object.url || "",
|
|
137
|
-
name: object.name || undefined,
|
|
138
|
-
content: contentHtml ? { text: contentText, html: contentHtml } : undefined,
|
|
139
|
-
summary: object.summary ? stripHtml(object.summary) : undefined,
|
|
140
|
-
published: object.published || activity.published || undefined,
|
|
141
|
-
author: {
|
|
142
|
-
name: actorInfo.name,
|
|
143
|
-
url: actorInfo.url,
|
|
144
|
-
photo: actorInfo.photo,
|
|
145
|
-
},
|
|
146
|
-
category: extractTags(object.tag),
|
|
147
|
-
photo: extractMedia(object.attachment, "image"),
|
|
148
|
-
video: extractMedia(object.attachment, "video"),
|
|
149
|
-
audio: extractMedia(object.attachment, "audio"),
|
|
150
|
-
_source: { type: "activitypub", actorUrl: actorInfo.url },
|
|
151
|
-
};
|
|
152
|
-
|
|
153
|
-
// Boost attribution
|
|
154
|
-
if (activityType === "Announce" && activity.actor) {
|
|
155
|
-
jf2._boostedBy = actorInfo;
|
|
156
|
-
// The inner object may have its own author
|
|
157
|
-
if (object.attributedTo) {
|
|
158
|
-
const attributedUrl =
|
|
159
|
-
typeof object.attributedTo === "string"
|
|
160
|
-
? object.attributedTo
|
|
161
|
-
: object.attributedTo?.id || object.attributedTo?.url;
|
|
162
|
-
if (attributedUrl) {
|
|
163
|
-
jf2.author = {
|
|
164
|
-
name:
|
|
165
|
-
object.attributedTo?.name ||
|
|
166
|
-
object.attributedTo?.preferredUsername ||
|
|
167
|
-
attributedUrl,
|
|
168
|
-
url: attributedUrl,
|
|
169
|
-
photo: object.attributedTo?.icon?.url || "",
|
|
170
|
-
};
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
if (object.inReplyTo) {
|
|
176
|
-
const replyUrl =
|
|
177
|
-
typeof object.inReplyTo === "string"
|
|
178
|
-
? object.inReplyTo
|
|
179
|
-
: object.inReplyTo?.id;
|
|
180
|
-
if (replyUrl) jf2["in-reply-to"] = [replyUrl];
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
return jf2;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
/**
|
|
187
|
-
* Extract hashtags from AP tag array.
|
|
188
|
-
* @param {Array} tags - AP tag objects
|
|
189
|
-
* @returns {Array<string>}
|
|
190
|
-
*/
|
|
191
|
-
function extractTags(tags) {
|
|
192
|
-
if (!Array.isArray(tags)) return [];
|
|
193
|
-
return tags
|
|
194
|
-
.filter((t) => t.type === "Hashtag" || t.type === "Tag")
|
|
195
|
-
.map((t) => (t.name || "").replace(/^#/, ""))
|
|
196
|
-
.filter(Boolean);
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
/**
|
|
200
|
-
* Extract media URLs from AP attachment array.
|
|
201
|
-
* @param {Array} attachments - AP attachment objects
|
|
202
|
-
* @param {string} mediaPrefix - "image", "video", or "audio"
|
|
203
|
-
* @returns {Array<string>}
|
|
204
|
-
*/
|
|
205
|
-
function extractMedia(attachments, mediaPrefix) {
|
|
206
|
-
if (!Array.isArray(attachments)) return [];
|
|
207
|
-
return attachments
|
|
208
|
-
.filter((a) => (a.mediaType || "").startsWith(`${mediaPrefix}/`))
|
|
209
|
-
.map((a) => a.url || a.href || "")
|
|
210
|
-
.filter(Boolean);
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
/**
|
|
214
|
-
* Fetch a URL as ActivityPub JSON.
|
|
215
|
-
* @param {string} url
|
|
216
|
-
* @returns {Promise<object|null>}
|
|
217
|
-
*/
|
|
218
|
-
async function fetchJson(url) {
|
|
219
|
-
if (!url) return null;
|
|
220
|
-
|
|
221
|
-
// SSRF protection — block private/internal IPs (including DNS rebinding)
|
|
222
|
-
if (await isPrivateUrl(url)) {
|
|
223
|
-
console.warn(`[Microsub] AP fetch blocked private URL: ${url}`);
|
|
224
|
-
return null;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
const controller = new AbortController();
|
|
228
|
-
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT);
|
|
229
|
-
|
|
230
|
-
try {
|
|
231
|
-
const response = await fetch(url, {
|
|
232
|
-
headers: {
|
|
233
|
-
Accept: AP_ACCEPT,
|
|
234
|
-
"User-Agent": USER_AGENT,
|
|
235
|
-
},
|
|
236
|
-
signal: controller.signal,
|
|
237
|
-
redirect: "follow",
|
|
238
|
-
});
|
|
239
|
-
|
|
240
|
-
if (!response.ok) {
|
|
241
|
-
console.warn(
|
|
242
|
-
`[Microsub] AP fetch failed: ${response.status} for ${url}`,
|
|
243
|
-
);
|
|
244
|
-
return null;
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
return await response.json();
|
|
248
|
-
} catch (error) {
|
|
249
|
-
if (error.name === "AbortError") {
|
|
250
|
-
console.warn(`[Microsub] AP fetch timeout for ${url}`);
|
|
251
|
-
} else {
|
|
252
|
-
console.warn(`[Microsub] AP fetch error for ${url}: ${error.message}`);
|
|
253
|
-
}
|
|
254
|
-
return null;
|
|
255
|
-
} finally {
|
|
256
|
-
clearTimeout(timeout);
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
/**
|
|
261
|
-
* Strip HTML tags for plain text.
|
|
262
|
-
* @param {string} html
|
|
263
|
-
* @returns {string}
|
|
264
|
-
*/
|
|
265
|
-
function stripHtml(html) {
|
|
266
|
-
return (html || "").replace(/<[^>]*>/g, "").trim();
|
|
267
|
-
}
|
|
@@ -1,142 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ActivityPub actor profiles
|
|
3
|
-
* @module controllers/reader/actor
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { fetchActorOutbox } from "../../activitypub/outbox-fetcher.js";
|
|
7
|
-
|
|
8
|
-
const ACTOR_OUTBOX_LIMIT = 30;
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Find the ActivityPub plugin instance from installed plugins.
|
|
12
|
-
* @param {object} request - Express request
|
|
13
|
-
* @returns {object|undefined} The AP plugin instance
|
|
14
|
-
*/
|
|
15
|
-
function getApPlugin(request) {
|
|
16
|
-
const installedPlugins = request.app.locals.installedPlugins;
|
|
17
|
-
if (!installedPlugins) return undefined;
|
|
18
|
-
return [...installedPlugins].find(
|
|
19
|
-
(p) => p.name === "ActivityPub endpoint",
|
|
20
|
-
);
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Actor profile — fetch and display a remote AP actor's recent posts
|
|
25
|
-
* @param {object} request - Express request
|
|
26
|
-
* @param {object} response - Express response
|
|
27
|
-
*/
|
|
28
|
-
export async function actorProfile(request, response) {
|
|
29
|
-
const actorUrl = request.query.url;
|
|
30
|
-
if (!actorUrl) {
|
|
31
|
-
return response.status(400).render("404");
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// Check if we already follow this actor
|
|
35
|
-
const { application } = request.app.locals;
|
|
36
|
-
const apFollowing = application?.collections?.get("ap_following");
|
|
37
|
-
let isFollowing = false;
|
|
38
|
-
if (apFollowing) {
|
|
39
|
-
const existing = await apFollowing.findOne({ actorUrl });
|
|
40
|
-
isFollowing = !!existing;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
// Check if AP plugin is available (for follow button visibility)
|
|
44
|
-
const apPlugin = getApPlugin(request);
|
|
45
|
-
const canFollow = !!apPlugin;
|
|
46
|
-
|
|
47
|
-
try {
|
|
48
|
-
const { actor, items } = await fetchActorOutbox(actorUrl, { limit: ACTOR_OUTBOX_LIMIT });
|
|
49
|
-
|
|
50
|
-
response.render("actor", {
|
|
51
|
-
title: actor.name || "Actor",
|
|
52
|
-
actor,
|
|
53
|
-
items,
|
|
54
|
-
actorUrl,
|
|
55
|
-
isFollowing,
|
|
56
|
-
canFollow,
|
|
57
|
-
baseUrl: request.baseUrl,
|
|
58
|
-
readerBaseUrl: request.baseUrl,
|
|
59
|
-
activeView: "channels",
|
|
60
|
-
breadcrumbs: [
|
|
61
|
-
{ text: "Reader", href: request.baseUrl },
|
|
62
|
-
{ text: actor.name || "Actor" },
|
|
63
|
-
],
|
|
64
|
-
});
|
|
65
|
-
} catch (error) {
|
|
66
|
-
console.error(`[Microsub] Actor profile fetch failed: ${error.message}`);
|
|
67
|
-
response.render("actor", {
|
|
68
|
-
title: "Actor",
|
|
69
|
-
actor: { name: actorUrl, url: actorUrl, photo: "", summary: "" },
|
|
70
|
-
items: [],
|
|
71
|
-
actorUrl,
|
|
72
|
-
isFollowing,
|
|
73
|
-
canFollow,
|
|
74
|
-
baseUrl: request.baseUrl,
|
|
75
|
-
readerBaseUrl: request.baseUrl,
|
|
76
|
-
activeView: "channels",
|
|
77
|
-
error: "Could not fetch this actor's profile. They may have restricted access.",
|
|
78
|
-
breadcrumbs: [
|
|
79
|
-
{ text: "Reader", href: request.baseUrl },
|
|
80
|
-
{ text: "Actor" },
|
|
81
|
-
],
|
|
82
|
-
});
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Follow an ActivityPub actor
|
|
88
|
-
* @param {object} request - Express request
|
|
89
|
-
* @param {object} response - Express response
|
|
90
|
-
*/
|
|
91
|
-
export async function followActorAction(request, response) {
|
|
92
|
-
const { actorUrl, actorName } = request.body;
|
|
93
|
-
if (!actorUrl) {
|
|
94
|
-
return response.status(400).redirect(request.baseUrl + "/channels/activitypub");
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
const apPlugin = getApPlugin(request);
|
|
98
|
-
if (!apPlugin) {
|
|
99
|
-
console.error("[Microsub] Cannot follow: ActivityPub plugin not installed");
|
|
100
|
-
return response.redirect(
|
|
101
|
-
`${request.baseUrl}/actor?url=${encodeURIComponent(actorUrl)}`,
|
|
102
|
-
);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
const result = await apPlugin.followActor(actorUrl, { name: actorName });
|
|
106
|
-
if (!result.ok) {
|
|
107
|
-
console.error(`[Microsub] Follow via AP plugin failed: ${result.error}`);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
return response.redirect(
|
|
111
|
-
`${request.baseUrl}/actor?url=${encodeURIComponent(actorUrl)}`,
|
|
112
|
-
);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/**
|
|
116
|
-
* Unfollow an ActivityPub actor
|
|
117
|
-
* @param {object} request - Express request
|
|
118
|
-
* @param {object} response - Express response
|
|
119
|
-
*/
|
|
120
|
-
export async function unfollowActorAction(request, response) {
|
|
121
|
-
const { actorUrl } = request.body;
|
|
122
|
-
if (!actorUrl) {
|
|
123
|
-
return response.status(400).redirect(request.baseUrl + "/channels/activitypub");
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
const apPlugin = getApPlugin(request);
|
|
127
|
-
if (!apPlugin) {
|
|
128
|
-
console.error("[Microsub] Cannot unfollow: ActivityPub plugin not installed");
|
|
129
|
-
return response.redirect(
|
|
130
|
-
`${request.baseUrl}/actor?url=${encodeURIComponent(actorUrl)}`,
|
|
131
|
-
);
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
const result = await apPlugin.unfollowActor(actorUrl);
|
|
135
|
-
if (!result.ok) {
|
|
136
|
-
console.error(`[Microsub] Unfollow via AP plugin failed: ${result.error}`);
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
return response.redirect(
|
|
140
|
-
`${request.baseUrl}/actor?url=${encodeURIComponent(actorUrl)}`,
|
|
141
|
-
);
|
|
142
|
-
}
|
package/views/actor.njk
DELETED
|
@@ -1,188 +0,0 @@
|
|
|
1
|
-
{% extends "layouts/reader.njk" %}
|
|
2
|
-
|
|
3
|
-
{% block reader %}
|
|
4
|
-
<div class="ms-channel">
|
|
5
|
-
<header class="ms-channel__header">
|
|
6
|
-
<a href="{{ baseUrl }}/channels/activitypub" class="back-link">
|
|
7
|
-
{{ icon("previous") }} Fediverse
|
|
8
|
-
</a>
|
|
9
|
-
</header>
|
|
10
|
-
|
|
11
|
-
{# Actor profile card #}
|
|
12
|
-
<div class="ms-actor-profile">
|
|
13
|
-
<div class="ms-actor-profile__header">
|
|
14
|
-
<div class="ms-actor-profile__avatar-wrap" data-avatar-fallback>
|
|
15
|
-
{% if actor.photo %}
|
|
16
|
-
<img src="{{ actor.photo }}"
|
|
17
|
-
alt=""
|
|
18
|
-
class="ms-actor-profile__avatar"
|
|
19
|
-
width="80"
|
|
20
|
-
height="80">
|
|
21
|
-
{% endif %}
|
|
22
|
-
<span class="ms-actor-profile__avatar ms-actor-profile__avatar--default" aria-hidden="true">{{ actor.name[0] | upper if actor.name else "?" }}</span>
|
|
23
|
-
</div>
|
|
24
|
-
<div class="ms-actor-profile__info">
|
|
25
|
-
<h2 class="ms-actor-profile__name">{{ actor.name }}</h2>
|
|
26
|
-
{% if actor.handle %}
|
|
27
|
-
<span class="ms-actor-profile__handle">@{{ actor.handle }}</span>
|
|
28
|
-
{% endif %}
|
|
29
|
-
{% if actor.summary %}
|
|
30
|
-
<p class="ms-actor-profile__summary">{{ actor.summary }}</p>
|
|
31
|
-
{% endif %}
|
|
32
|
-
<div class="ms-actor-profile__stats">
|
|
33
|
-
{% if actor.followersCount %}
|
|
34
|
-
<span>{{ actor.followersCount }} followers</span>
|
|
35
|
-
{% endif %}
|
|
36
|
-
{% if actor.followingCount %}
|
|
37
|
-
<span>{{ actor.followingCount }} following</span>
|
|
38
|
-
{% endif %}
|
|
39
|
-
</div>
|
|
40
|
-
</div>
|
|
41
|
-
</div>
|
|
42
|
-
<div class="ms-actor-profile__actions">
|
|
43
|
-
<a href="{{ actor.url }}" class="button button--secondary button--small" target="_blank" rel="noopener">
|
|
44
|
-
{{ icon("syndicate") }} View profile
|
|
45
|
-
</a>
|
|
46
|
-
{% if canFollow %}
|
|
47
|
-
{% if isFollowing %}
|
|
48
|
-
<form action="{{ baseUrl }}/actor/unfollow" method="POST" style="display: inline;">
|
|
49
|
-
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
|
|
50
|
-
<input type="hidden" name="actorUrl" value="{{ actorUrl }}">
|
|
51
|
-
<button type="submit" class="button button--secondary button--small">
|
|
52
|
-
{{ icon("tick") }} Following
|
|
53
|
-
</button>
|
|
54
|
-
</form>
|
|
55
|
-
{% else %}
|
|
56
|
-
<form action="{{ baseUrl }}/actor/follow" method="POST" style="display: inline;">
|
|
57
|
-
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
|
|
58
|
-
<input type="hidden" name="actorUrl" value="{{ actorUrl }}">
|
|
59
|
-
<input type="hidden" name="actorName" value="{{ actor.name }}">
|
|
60
|
-
<button type="submit" class="button button--primary button--small">
|
|
61
|
-
{{ icon("syndicate") }} Follow
|
|
62
|
-
</button>
|
|
63
|
-
</form>
|
|
64
|
-
{% endif %}
|
|
65
|
-
{% endif %}
|
|
66
|
-
</div>
|
|
67
|
-
</div>
|
|
68
|
-
|
|
69
|
-
{% if error %}
|
|
70
|
-
<div class="ms-reader__empty">
|
|
71
|
-
{{ icon("warning") }}
|
|
72
|
-
<p>{{ error }}</p>
|
|
73
|
-
</div>
|
|
74
|
-
{% elif items.length > 0 %}
|
|
75
|
-
<div class="ms-timeline" id="timeline">
|
|
76
|
-
{% for item in items %}
|
|
77
|
-
<article class="ms-item-card">
|
|
78
|
-
{# Author #}
|
|
79
|
-
{% if item.author %}
|
|
80
|
-
<div class="ms-item-card__author" style="padding: 12px 16px 0;">
|
|
81
|
-
<div class="ms-item-card__avatar-wrap" data-avatar-fallback>
|
|
82
|
-
{% if item.author.photo %}
|
|
83
|
-
<img src="{{ item.author.photo }}"
|
|
84
|
-
alt=""
|
|
85
|
-
class="ms-item-card__author-photo"
|
|
86
|
-
width="40"
|
|
87
|
-
height="40"
|
|
88
|
-
loading="lazy">
|
|
89
|
-
{% endif %}
|
|
90
|
-
<span class="ms-item-card__author-photo ms-item-card__author-photo--default" aria-hidden="true">{{ item.author.name[0] | upper if item.author.name else "?" }}</span>
|
|
91
|
-
</div>
|
|
92
|
-
<div class="ms-item-card__author-info">
|
|
93
|
-
<span class="ms-item-card__author-name">{{ item.author.name or "Unknown" }}</span>
|
|
94
|
-
{% if item.author.url %}
|
|
95
|
-
<span class="ms-item-card__source">{{ item.author.url | replace("https://", "") | replace("http://", "") }}</span>
|
|
96
|
-
{% endif %}
|
|
97
|
-
</div>
|
|
98
|
-
</div>
|
|
99
|
-
{% endif %}
|
|
100
|
-
|
|
101
|
-
<a href="{{ item.url }}" class="ms-item-card__link" target="_blank" rel="noopener">
|
|
102
|
-
{# Reply context #}
|
|
103
|
-
{% if item["in-reply-to"] and item["in-reply-to"].length > 0 %}
|
|
104
|
-
<div class="ms-item-card__context">
|
|
105
|
-
{{ icon("reply") }}
|
|
106
|
-
<span>Reply to</span>
|
|
107
|
-
<span>{{ item["in-reply-to"][0] | replace("https://", "") | replace("http://", "") | truncate(50) }}</span>
|
|
108
|
-
</div>
|
|
109
|
-
{% endif %}
|
|
110
|
-
|
|
111
|
-
{# Title #}
|
|
112
|
-
{% if item.name %}
|
|
113
|
-
<h3 class="ms-item-card__title">{{ item.name }}</h3>
|
|
114
|
-
{% endif %}
|
|
115
|
-
|
|
116
|
-
{# Content #}
|
|
117
|
-
{% if item.content %}
|
|
118
|
-
<div class="ms-item-card__content{% if (item.content.text or '') | length > 300 %} ms-item-card__content--truncated{% endif %}">
|
|
119
|
-
{% if item.content.html %}
|
|
120
|
-
{{ item.content.html | safe | striptags | truncate(400) }}
|
|
121
|
-
{% elif item.content.text %}
|
|
122
|
-
{{ item.content.text | truncate(400) }}
|
|
123
|
-
{% endif %}
|
|
124
|
-
</div>
|
|
125
|
-
{% endif %}
|
|
126
|
-
|
|
127
|
-
{# Tags #}
|
|
128
|
-
{% if item.category and item.category.length > 0 %}
|
|
129
|
-
<div class="ms-item-card__categories">
|
|
130
|
-
{% for cat in item.category %}
|
|
131
|
-
{% if loop.index0 < 5 %}
|
|
132
|
-
<span class="ms-item-card__category">#{{ cat }}</span>
|
|
133
|
-
{% endif %}
|
|
134
|
-
{% endfor %}
|
|
135
|
-
</div>
|
|
136
|
-
{% endif %}
|
|
137
|
-
|
|
138
|
-
{# Photos #}
|
|
139
|
-
{% if item.photo and item.photo.length > 0 %}
|
|
140
|
-
{% set photoCount = item.photo.length if item.photo.length <= 4 else 4 %}
|
|
141
|
-
<div class="ms-item-card__photos ms-item-card__photos--{{ photoCount }}">
|
|
142
|
-
{% for photo in item.photo %}
|
|
143
|
-
{% if loop.index0 < 4 %}
|
|
144
|
-
<img src="{{ photo }}" alt="" class="ms-item-card__photo" loading="lazy">
|
|
145
|
-
{% endif %}
|
|
146
|
-
{% endfor %}
|
|
147
|
-
</div>
|
|
148
|
-
{% endif %}
|
|
149
|
-
|
|
150
|
-
{# Footer #}
|
|
151
|
-
<footer class="ms-item-card__footer">
|
|
152
|
-
{% if item.published %}
|
|
153
|
-
<time datetime="{{ item.published }}" class="ms-item-card__date">
|
|
154
|
-
{{ item.published | date("PP", { locale: locale, timeZone: application.timeZone }) }}
|
|
155
|
-
</time>
|
|
156
|
-
{% endif %}
|
|
157
|
-
</footer>
|
|
158
|
-
</a>
|
|
159
|
-
|
|
160
|
-
{# Actions #}
|
|
161
|
-
<div class="ms-item-actions">
|
|
162
|
-
<a href="{{ item.url }}" class="ms-item-actions__button" target="_blank" rel="noopener" title="View original">
|
|
163
|
-
{{ icon("syndicate") }}
|
|
164
|
-
</a>
|
|
165
|
-
<a href="{{ baseUrl }}/compose?reply={{ item.url | urlencode }}" class="ms-item-actions__button" title="Reply">
|
|
166
|
-
{{ icon("reply") }}
|
|
167
|
-
</a>
|
|
168
|
-
<a href="{{ baseUrl }}/compose?like={{ item.url | urlencode }}" class="ms-item-actions__button" title="Like">
|
|
169
|
-
{{ icon("like") }}
|
|
170
|
-
</a>
|
|
171
|
-
<a href="{{ baseUrl }}/compose?repost={{ item.url | urlencode }}" class="ms-item-actions__button" title="Repost">
|
|
172
|
-
{{ icon("repost") }}
|
|
173
|
-
</a>
|
|
174
|
-
<a href="{{ baseUrl }}/compose?bookmark={{ item.url | urlencode }}" class="ms-item-actions__button" title="Bookmark">
|
|
175
|
-
{{ icon("bookmark") }}
|
|
176
|
-
</a>
|
|
177
|
-
</div>
|
|
178
|
-
</article>
|
|
179
|
-
{% endfor %}
|
|
180
|
-
</div>
|
|
181
|
-
{% else %}
|
|
182
|
-
<div class="ms-reader__empty">
|
|
183
|
-
{{ icon("syndicate") }}
|
|
184
|
-
<p>No posts found for this actor.</p>
|
|
185
|
-
</div>
|
|
186
|
-
{% endif %}
|
|
187
|
-
</div>
|
|
188
|
-
{% endblock %}
|