@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.
@@ -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
+ }
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Timeline item extraction helpers
3
+ * @module timeline-store
4
+ */
5
+
6
+ import sanitizeHtml from "sanitize-html";
7
+
8
+ /**
9
+ * Sanitize HTML content for safe display
10
+ * @param {string} html - Raw HTML content
11
+ * @returns {string} Sanitized HTML
12
+ */
13
+ export function sanitizeContent(html) {
14
+ if (!html) return "";
15
+
16
+ return sanitizeHtml(html, {
17
+ allowedTags: [
18
+ "p", "br", "a", "strong", "em", "ul", "ol", "li",
19
+ "blockquote", "code", "pre", "h1", "h2", "h3", "h4", "h5", "h6",
20
+ "span", "div", "img"
21
+ ],
22
+ allowedAttributes: {
23
+ a: ["href", "rel", "class"],
24
+ img: ["src", "alt", "class"],
25
+ span: ["class"],
26
+ div: ["class"]
27
+ },
28
+ allowedSchemes: ["http", "https", "mailto"],
29
+ allowedSchemesByTag: {
30
+ img: ["http", "https", "data"]
31
+ }
32
+ });
33
+ }
34
+
35
+ /**
36
+ * Extract actor information from Fedify Person/Application/Service object
37
+ * @param {object} actor - Fedify actor object
38
+ * @returns {object} { name, url, photo, handle }
39
+ */
40
+ export async function extractActorInfo(actor) {
41
+ if (!actor) {
42
+ return {
43
+ name: "Unknown",
44
+ url: "",
45
+ photo: "",
46
+ handle: ""
47
+ };
48
+ }
49
+
50
+ const rawName = actor.name?.toString() || actor.preferredUsername?.toString() || "Unknown";
51
+ // Strip all HTML from actor names to prevent stored XSS
52
+ const name = sanitizeHtml(rawName, { allowedTags: [], allowedAttributes: {} });
53
+ const url = actor.id?.href || "";
54
+
55
+ // Extract photo URL from icon (Fedify uses async getters)
56
+ let photo = "";
57
+ try {
58
+ if (typeof actor.getIcon === "function") {
59
+ const iconObj = await actor.getIcon();
60
+ photo = iconObj?.url?.href || "";
61
+ } else {
62
+ const iconObj = await actor.icon;
63
+ photo = iconObj?.url?.href || "";
64
+ }
65
+ } catch {
66
+ // No icon available
67
+ }
68
+
69
+ // Extract handle from actor URL
70
+ let handle = "";
71
+ try {
72
+ const actorUrl = new URL(url);
73
+ const username = actor.preferredUsername?.toString() || "";
74
+ if (username) {
75
+ handle = `@${username}@${actorUrl.hostname}`;
76
+ }
77
+ } catch {
78
+ // Invalid URL, keep handle empty
79
+ }
80
+
81
+ return { name, url, photo, handle };
82
+ }
83
+
84
+ /**
85
+ * Extract timeline item data from Fedify Note/Article object
86
+ * @param {object} object - Fedify Note or Article object
87
+ * @param {object} options - Extraction options
88
+ * @param {object} [options.boostedBy] - Actor info for boosts
89
+ * @param {Date} [options.boostedAt] - Boost timestamp
90
+ * @returns {Promise<object>} Timeline item data
91
+ */
92
+ export async function extractObjectData(object, options = {}) {
93
+ if (!object) {
94
+ throw new Error("Object is required");
95
+ }
96
+
97
+ const uid = object.id?.href || "";
98
+ const url = object.url?.href || uid;
99
+
100
+ // Determine type
101
+ let type = "note";
102
+ if (object.type?.toLowerCase() === "article") {
103
+ type = "article";
104
+ }
105
+ if (options.boostedBy) {
106
+ type = "boost";
107
+ }
108
+
109
+ // Extract content
110
+ const contentHtml = object.content?.toString() || "";
111
+ const contentText = object.source?.content?.toString() || contentHtml.replace(/<[^>]*>/g, "");
112
+
113
+ const content = {
114
+ text: contentText,
115
+ html: sanitizeContent(contentHtml)
116
+ };
117
+
118
+ // Extract name (articles only)
119
+ const name = type === "article" ? (object.name?.toString() || "") : "";
120
+
121
+ // Content warning / summary
122
+ const summary = object.summary?.toString() || "";
123
+ const sensitive = object.sensitive || false;
124
+
125
+ // Published date — store as ISO string per Indiekit convention
126
+ const published = object.published
127
+ ? new Date(object.published).toISOString()
128
+ : new Date().toISOString();
129
+
130
+ // Extract author — use async getAttributedTo() for Fedify objects
131
+ let authorObj = null;
132
+ try {
133
+ if (typeof object.getAttributedTo === "function") {
134
+ const attr = await object.getAttributedTo();
135
+ authorObj = Array.isArray(attr) ? attr[0] : attr;
136
+ }
137
+ } catch {
138
+ // Fallback: try direct property access for plain objects
139
+ authorObj = object.attribution || object.attributedTo || null;
140
+ }
141
+ const author = await extractActorInfo(authorObj);
142
+
143
+ // Extract tags/categories
144
+ const category = [];
145
+ if (object.tag) {
146
+ const tags = Array.isArray(object.tag) ? object.tag : [object.tag];
147
+ for (const tag of tags) {
148
+ if (tag.type === "Hashtag" && tag.name) {
149
+ category.push(tag.name.toString().replace(/^#/, ""));
150
+ }
151
+ }
152
+ }
153
+
154
+ // Extract media attachments
155
+ const photo = [];
156
+ const video = [];
157
+ const audio = [];
158
+
159
+ if (object.attachment) {
160
+ const attachments = Array.isArray(object.attachment) ? object.attachment : [object.attachment];
161
+ for (const att of attachments) {
162
+ const mediaUrl = att.url?.href || "";
163
+ if (!mediaUrl) continue;
164
+
165
+ const mediaType = att.mediaType?.toLowerCase() || "";
166
+
167
+ if (mediaType.startsWith("image/")) {
168
+ photo.push(mediaUrl);
169
+ } else if (mediaType.startsWith("video/")) {
170
+ video.push(mediaUrl);
171
+ } else if (mediaType.startsWith("audio/")) {
172
+ audio.push(mediaUrl);
173
+ }
174
+ }
175
+ }
176
+
177
+ // In-reply-to
178
+ const inReplyTo = object.inReplyTo?.href || "";
179
+
180
+ // Build base timeline item
181
+ const item = {
182
+ uid,
183
+ type,
184
+ url,
185
+ name,
186
+ content,
187
+ summary,
188
+ sensitive,
189
+ published,
190
+ author,
191
+ category,
192
+ photo,
193
+ video,
194
+ audio,
195
+ inReplyTo,
196
+ createdAt: new Date().toISOString()
197
+ };
198
+
199
+ // Add boost metadata if this is a boost
200
+ if (options.boostedBy) {
201
+ item.boostedBy = options.boostedBy;
202
+ item.boostedAt = options.boostedAt || new Date().toISOString();
203
+ item.originalUrl = url;
204
+ }
205
+
206
+ return item;
207
+ }