@rmdes/indiekit-endpoint-activitypub 1.0.29 → 1.1.2
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 +8 -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 +28 -1
- 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 +84 -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-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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notification storage operations
|
|
3
|
+
* @module storage/notifications
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Add a notification (uses atomic upsert for deduplication)
|
|
8
|
+
* @param {object} collections - MongoDB collections
|
|
9
|
+
* @param {object} notification - Notification data
|
|
10
|
+
* @param {string} notification.uid - Activity ID or constructed dedup key
|
|
11
|
+
* @param {string} notification.type - "like" | "boost" | "follow" | "mention" | "reply"
|
|
12
|
+
* @param {string} notification.actorUrl - Remote actor URL
|
|
13
|
+
* @param {string} notification.actorName - Display name
|
|
14
|
+
* @param {string} notification.actorPhoto - Avatar URL
|
|
15
|
+
* @param {string} notification.actorHandle - @user@instance
|
|
16
|
+
* @param {string} [notification.targetUrl] - The post they liked/boosted/replied to
|
|
17
|
+
* @param {string} [notification.targetName] - Post title
|
|
18
|
+
* @param {object} [notification.content] - { text, html } for mentions/replies
|
|
19
|
+
* @param {Date} notification.published - Activity timestamp (kept as Date for sort)
|
|
20
|
+
* @param {string} notification.createdAt - ISO string creation timestamp
|
|
21
|
+
* @returns {Promise<object>} Created or existing notification
|
|
22
|
+
*/
|
|
23
|
+
export async function addNotification(collections, notification) {
|
|
24
|
+
const { ap_notifications } = collections;
|
|
25
|
+
|
|
26
|
+
const result = await ap_notifications.updateOne(
|
|
27
|
+
{ uid: notification.uid },
|
|
28
|
+
{
|
|
29
|
+
$setOnInsert: {
|
|
30
|
+
...notification,
|
|
31
|
+
read: false,
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
{ upsert: true },
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
if (result.upsertedCount > 0) {
|
|
38
|
+
return await ap_notifications.findOne({ uid: notification.uid });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Return existing document if it was a duplicate
|
|
42
|
+
return await ap_notifications.findOne({ uid: notification.uid });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Get notifications with cursor-based pagination
|
|
47
|
+
* @param {object} collections - MongoDB collections
|
|
48
|
+
* @param {object} options - Query options
|
|
49
|
+
* @param {string} [options.before] - Before cursor (published date)
|
|
50
|
+
* @param {number} [options.limit=20] - Items per page
|
|
51
|
+
* @param {boolean} [options.unreadOnly=false] - Show only unread notifications
|
|
52
|
+
* @returns {Promise<object>} { items, before }
|
|
53
|
+
*/
|
|
54
|
+
export async function getNotifications(collections, options = {}) {
|
|
55
|
+
const { ap_notifications } = collections;
|
|
56
|
+
const parsedLimit = Number.parseInt(options.limit, 10);
|
|
57
|
+
const limit = Math.min(
|
|
58
|
+
Number.isFinite(parsedLimit) && parsedLimit > 0 ? parsedLimit : 20,
|
|
59
|
+
100,
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const query = {};
|
|
63
|
+
|
|
64
|
+
// Unread filter
|
|
65
|
+
if (options.unreadOnly) {
|
|
66
|
+
query.read = false;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Cursor pagination
|
|
70
|
+
if (options.before) {
|
|
71
|
+
query.published = { $lt: new Date(options.before) };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const rawItems = await ap_notifications
|
|
75
|
+
.find(query)
|
|
76
|
+
.sort({ published: -1 })
|
|
77
|
+
.limit(limit)
|
|
78
|
+
.toArray();
|
|
79
|
+
|
|
80
|
+
// Normalize published dates to ISO strings for Nunjucks | date filter
|
|
81
|
+
const items = rawItems.map((item) => ({
|
|
82
|
+
...item,
|
|
83
|
+
published: item.published instanceof Date
|
|
84
|
+
? item.published.toISOString()
|
|
85
|
+
: item.published,
|
|
86
|
+
}));
|
|
87
|
+
|
|
88
|
+
// Generate cursor for next page
|
|
89
|
+
const before =
|
|
90
|
+
items.length > 0
|
|
91
|
+
? items[items.length - 1].published
|
|
92
|
+
: null;
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
items,
|
|
96
|
+
before,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Get count of unread notifications
|
|
102
|
+
* @param {object} collections - MongoDB collections
|
|
103
|
+
* @returns {Promise<number>} Unread notification count
|
|
104
|
+
*/
|
|
105
|
+
export async function getUnreadNotificationCount(collections) {
|
|
106
|
+
const { ap_notifications } = collections;
|
|
107
|
+
return await ap_notifications.countDocuments({ read: false });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Mark notifications as read
|
|
112
|
+
* @param {object} collections - MongoDB collections
|
|
113
|
+
* @param {string[]} uids - Notification UIDs to mark read
|
|
114
|
+
* @returns {Promise<object>} Update result
|
|
115
|
+
*/
|
|
116
|
+
export async function markNotificationsRead(collections, uids) {
|
|
117
|
+
const { ap_notifications } = collections;
|
|
118
|
+
return await ap_notifications.updateMany(
|
|
119
|
+
{ uid: { $in: uids } },
|
|
120
|
+
{ $set: { read: true } },
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Mark all notifications as read
|
|
126
|
+
* @param {object} collections - MongoDB collections
|
|
127
|
+
* @returns {Promise<object>} Update result
|
|
128
|
+
*/
|
|
129
|
+
export async function markAllNotificationsRead(collections) {
|
|
130
|
+
const { ap_notifications } = collections;
|
|
131
|
+
return await ap_notifications.updateMany({}, { $set: { read: true } });
|
|
132
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Timeline item storage operations
|
|
3
|
+
* @module storage/timeline
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Add a timeline item (uses atomic upsert for deduplication)
|
|
8
|
+
* @param {object} collections - MongoDB collections
|
|
9
|
+
* @param {object} item - Timeline item data
|
|
10
|
+
* @param {string} item.uid - Canonical AP object URL (dedup key)
|
|
11
|
+
* @param {string} item.type - "note" | "article" | "boost"
|
|
12
|
+
* @param {string} item.url - Post URL
|
|
13
|
+
* @param {string} [item.name] - Post title (articles only)
|
|
14
|
+
* @param {object} item.content - { text, html }
|
|
15
|
+
* @param {string} [item.summary] - Content warning text
|
|
16
|
+
* @param {boolean} item.sensitive - Sensitive content flag
|
|
17
|
+
* @param {Date} item.published - Published date (kept as Date for sort queries)
|
|
18
|
+
* @param {object} item.author - { name, url, photo, handle }
|
|
19
|
+
* @param {string[]} item.category - Tags/categories
|
|
20
|
+
* @param {string[]} item.photo - Photo URLs
|
|
21
|
+
* @param {string[]} item.video - Video URLs
|
|
22
|
+
* @param {string[]} item.audio - Audio URLs
|
|
23
|
+
* @param {string} [item.inReplyTo] - Parent post URL
|
|
24
|
+
* @param {object} [item.boostedBy] - { name, url, photo, handle } for boosts
|
|
25
|
+
* @param {Date} [item.boostedAt] - Boost timestamp
|
|
26
|
+
* @param {string} [item.originalUrl] - Original post URL for boosts
|
|
27
|
+
* @param {string} item.createdAt - ISO string creation timestamp
|
|
28
|
+
* @returns {Promise<object>} Created or existing item
|
|
29
|
+
*/
|
|
30
|
+
export async function addTimelineItem(collections, item) {
|
|
31
|
+
const { ap_timeline } = collections;
|
|
32
|
+
|
|
33
|
+
const result = await ap_timeline.updateOne(
|
|
34
|
+
{ uid: item.uid },
|
|
35
|
+
{
|
|
36
|
+
$setOnInsert: {
|
|
37
|
+
...item,
|
|
38
|
+
readBy: [],
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
{ upsert: true },
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
if (result.upsertedCount > 0) {
|
|
45
|
+
return await ap_timeline.findOne({ uid: item.uid });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Return existing document if it was a duplicate
|
|
49
|
+
return await ap_timeline.findOne({ uid: item.uid });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Get timeline items with cursor-based pagination
|
|
54
|
+
* @param {object} collections - MongoDB collections
|
|
55
|
+
* @param {object} options - Query options
|
|
56
|
+
* @param {string} [options.before] - Before cursor (published date)
|
|
57
|
+
* @param {string} [options.after] - After cursor (published date)
|
|
58
|
+
* @param {number} [options.limit=20] - Items per page
|
|
59
|
+
* @param {string} [options.type] - Filter by type
|
|
60
|
+
* @param {string} [options.authorUrl] - Filter by author URL
|
|
61
|
+
* @returns {Promise<object>} { items, before, after }
|
|
62
|
+
*/
|
|
63
|
+
export async function getTimelineItems(collections, options = {}) {
|
|
64
|
+
const { ap_timeline } = collections;
|
|
65
|
+
const parsedLimit = Number.parseInt(options.limit, 10);
|
|
66
|
+
const limit = Math.min(
|
|
67
|
+
Number.isFinite(parsedLimit) && parsedLimit > 0 ? parsedLimit : 20,
|
|
68
|
+
100,
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
const query = {};
|
|
72
|
+
|
|
73
|
+
// Type filter
|
|
74
|
+
if (options.type) {
|
|
75
|
+
query.type = options.type;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Author filter (for profile view) — validate string type to prevent operator injection
|
|
79
|
+
if (options.authorUrl) {
|
|
80
|
+
if (typeof options.authorUrl !== "string") {
|
|
81
|
+
throw new Error("Invalid authorUrl");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
query["author.url"] = options.authorUrl;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Cursor pagination — validate dates
|
|
88
|
+
if (options.before) {
|
|
89
|
+
const beforeDate = new Date(options.before);
|
|
90
|
+
|
|
91
|
+
if (Number.isNaN(beforeDate.getTime())) {
|
|
92
|
+
throw new Error("Invalid before cursor");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
query.published = { $lt: beforeDate };
|
|
96
|
+
} else if (options.after) {
|
|
97
|
+
const afterDate = new Date(options.after);
|
|
98
|
+
|
|
99
|
+
if (Number.isNaN(afterDate.getTime())) {
|
|
100
|
+
throw new Error("Invalid after cursor");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
query.published = { $gt: afterDate };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const rawItems = await ap_timeline
|
|
107
|
+
.find(query)
|
|
108
|
+
.sort({ published: -1 })
|
|
109
|
+
.limit(limit)
|
|
110
|
+
.toArray();
|
|
111
|
+
|
|
112
|
+
// Normalize published dates to ISO strings for Nunjucks | date filter
|
|
113
|
+
const items = rawItems.map((item) => ({
|
|
114
|
+
...item,
|
|
115
|
+
published: item.published instanceof Date
|
|
116
|
+
? item.published.toISOString()
|
|
117
|
+
: item.published,
|
|
118
|
+
}));
|
|
119
|
+
|
|
120
|
+
// Generate cursors for pagination
|
|
121
|
+
const before =
|
|
122
|
+
items.length > 0
|
|
123
|
+
? items[0].published
|
|
124
|
+
: null;
|
|
125
|
+
const after =
|
|
126
|
+
items.length > 0
|
|
127
|
+
? items[items.length - 1].published
|
|
128
|
+
: null;
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
items,
|
|
132
|
+
before,
|
|
133
|
+
after,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Get a single timeline item by UID
|
|
139
|
+
* @param {object} collections - MongoDB collections
|
|
140
|
+
* @param {string} uid - Item UID (canonical URL)
|
|
141
|
+
* @returns {Promise<object|null>} Timeline item or null
|
|
142
|
+
*/
|
|
143
|
+
export async function getTimelineItem(collections, uid) {
|
|
144
|
+
const { ap_timeline } = collections;
|
|
145
|
+
return await ap_timeline.findOne({ uid });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Delete a timeline item by UID
|
|
150
|
+
* @param {object} collections - MongoDB collections
|
|
151
|
+
* @param {string} uid - Item UID
|
|
152
|
+
* @returns {Promise<object>} Delete result
|
|
153
|
+
*/
|
|
154
|
+
export async function deleteTimelineItem(collections, uid) {
|
|
155
|
+
const { ap_timeline } = collections;
|
|
156
|
+
return await ap_timeline.deleteOne({ uid });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Update a timeline item's content (for Update activities)
|
|
161
|
+
* @param {object} collections - MongoDB collections
|
|
162
|
+
* @param {string} uid - Item UID
|
|
163
|
+
* @param {object} updates - Fields to update
|
|
164
|
+
* @param {object} [updates.content] - New content
|
|
165
|
+
* @param {string} [updates.name] - New title
|
|
166
|
+
* @param {string} [updates.summary] - New content warning
|
|
167
|
+
* @param {boolean} [updates.sensitive] - New sensitive flag
|
|
168
|
+
* @returns {Promise<object>} Update result
|
|
169
|
+
*/
|
|
170
|
+
export async function updateTimelineItem(collections, uid, updates) {
|
|
171
|
+
const { ap_timeline } = collections;
|
|
172
|
+
return await ap_timeline.updateOne({ uid }, { $set: updates });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Delete timeline items older than a cutoff date (retention cleanup)
|
|
177
|
+
* @param {object} collections - MongoDB collections
|
|
178
|
+
* @param {Date} cutoffDate - Delete items published before this date
|
|
179
|
+
* @returns {Promise<number>} Number of items deleted
|
|
180
|
+
*/
|
|
181
|
+
export async function deleteOldTimelineItems(collections, cutoffDate) {
|
|
182
|
+
const { ap_timeline } = collections;
|
|
183
|
+
const result = await ap_timeline.deleteMany({ published: { $lt: cutoffDate } });
|
|
184
|
+
return result.deletedCount;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Delete timeline items by count-based retention (keep N newest)
|
|
189
|
+
* @param {object} collections - MongoDB collections
|
|
190
|
+
* @param {number} keepCount - Number of items to keep
|
|
191
|
+
* @returns {Promise<number>} Number of items deleted
|
|
192
|
+
*/
|
|
193
|
+
export async function cleanupTimelineByCount(collections, keepCount) {
|
|
194
|
+
const { ap_timeline } = collections;
|
|
195
|
+
|
|
196
|
+
// Find the Nth newest item's published date
|
|
197
|
+
const items = await ap_timeline
|
|
198
|
+
.find({})
|
|
199
|
+
.sort({ published: -1 })
|
|
200
|
+
.skip(keepCount)
|
|
201
|
+
.limit(1)
|
|
202
|
+
.toArray();
|
|
203
|
+
|
|
204
|
+
if (items.length === 0) {
|
|
205
|
+
return 0; // Fewer than keepCount items exist
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const cutoffDate = items[0].published;
|
|
209
|
+
return await deleteOldTimelineItems(collections, cutoffDate);
|
|
210
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Timeline retention cleanup — removes old timeline items to prevent
|
|
3
|
+
* unbounded collection growth and cleans up stale interaction tracking.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Remove timeline items beyond the retention limit and clean up
|
|
8
|
+
* corresponding ap_interactions entries.
|
|
9
|
+
*
|
|
10
|
+
* Uses aggregation to identify exact items to delete by UID,
|
|
11
|
+
* avoiding race conditions between finding and deleting.
|
|
12
|
+
*
|
|
13
|
+
* @param {object} collections - MongoDB collections
|
|
14
|
+
* @param {number} retentionLimit - Max number of timeline items to keep
|
|
15
|
+
* @returns {Promise<{removed: number, interactionsRemoved: number}>}
|
|
16
|
+
*/
|
|
17
|
+
export async function cleanupTimeline(collections, retentionLimit) {
|
|
18
|
+
if (!collections.ap_timeline || retentionLimit <= 0) {
|
|
19
|
+
return { removed: 0, interactionsRemoved: 0 };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const totalCount = await collections.ap_timeline.countDocuments();
|
|
23
|
+
if (totalCount <= retentionLimit) {
|
|
24
|
+
return { removed: 0, interactionsRemoved: 0 };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Use aggregation to get exact UIDs beyond the retention limit.
|
|
28
|
+
// This avoids race conditions: we delete by UID, not by date.
|
|
29
|
+
const toDelete = await collections.ap_timeline
|
|
30
|
+
.aggregate([
|
|
31
|
+
{ $sort: { published: -1 } },
|
|
32
|
+
{ $skip: retentionLimit },
|
|
33
|
+
{ $project: { uid: 1 } },
|
|
34
|
+
])
|
|
35
|
+
.toArray();
|
|
36
|
+
|
|
37
|
+
if (!toDelete.length) {
|
|
38
|
+
return { removed: 0, interactionsRemoved: 0 };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const removedUids = toDelete.map((item) => item.uid).filter(Boolean);
|
|
42
|
+
|
|
43
|
+
// Delete old timeline items by UID
|
|
44
|
+
const deleteResult = await collections.ap_timeline.deleteMany({
|
|
45
|
+
_id: { $in: toDelete.map((item) => item._id) },
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// Clean up stale interactions for removed items
|
|
49
|
+
let interactionsRemoved = 0;
|
|
50
|
+
if (removedUids.length > 0 && collections.ap_interactions) {
|
|
51
|
+
const interactionResult = await collections.ap_interactions.deleteMany({
|
|
52
|
+
objectUrl: { $in: removedUids },
|
|
53
|
+
});
|
|
54
|
+
interactionsRemoved = interactionResult.deletedCount || 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const removed = deleteResult.deletedCount || 0;
|
|
58
|
+
|
|
59
|
+
if (removed > 0) {
|
|
60
|
+
console.info(
|
|
61
|
+
`[ActivityPub] Timeline cleanup: removed ${removed} items, ${interactionsRemoved} stale interactions`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return { removed, interactionsRemoved };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Schedule periodic timeline cleanup.
|
|
70
|
+
*
|
|
71
|
+
* @param {object} collections - MongoDB collections
|
|
72
|
+
* @param {number} retentionLimit - Max number of timeline items to keep
|
|
73
|
+
* @param {number} intervalMs - Cleanup interval in milliseconds (default: 24 hours)
|
|
74
|
+
* @returns {NodeJS.Timeout} The interval timer (for cleanup if needed)
|
|
75
|
+
*/
|
|
76
|
+
export function scheduleCleanup(collections, retentionLimit, intervalMs = 86_400_000) {
|
|
77
|
+
// Run immediately on startup
|
|
78
|
+
cleanupTimeline(collections, retentionLimit).catch((error) => {
|
|
79
|
+
console.error("[ActivityPub] Timeline cleanup failed:", error.message);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Then run periodically
|
|
83
|
+
return setInterval(() => {
|
|
84
|
+
cleanupTimeline(collections, retentionLimit).catch((error) => {
|
|
85
|
+
console.error("[ActivityPub] Timeline cleanup failed:", error.message);
|
|
86
|
+
});
|
|
87
|
+
}, intervalMs);
|
|
88
|
+
}
|