@rmdes/indiekit-endpoint-activitypub 1.0.28 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/reader.css +884 -0
- package/index.js +172 -15
- package/lib/controllers/compose.js +323 -0
- package/lib/controllers/featured-tags.js +12 -2
- package/lib/controllers/featured.js +12 -2
- package/lib/controllers/interactions-boost.js +208 -0
- package/lib/controllers/interactions-like.js +231 -0
- package/lib/controllers/interactions.js +7 -0
- package/lib/controllers/moderation.js +294 -0
- package/lib/controllers/profile.js +27 -1
- package/lib/controllers/profile.remote.js +218 -0
- package/lib/controllers/reader.js +187 -0
- package/lib/csrf.js +49 -0
- package/lib/federation-setup.js +33 -2
- package/lib/inbox-listeners.js +217 -213
- package/lib/storage/moderation.js +180 -0
- package/lib/storage/notifications.js +132 -0
- package/lib/storage/timeline.js +210 -0
- package/lib/timeline-cleanup.js +88 -0
- package/lib/timeline-store.js +207 -0
- package/locales/en.json +92 -1
- package/package.json +3 -2
- package/views/activitypub-compose.njk +94 -0
- package/views/activitypub-moderation.njk +118 -0
- package/views/activitypub-notifications.njk +31 -0
- package/views/activitypub-profile.njk +98 -0
- package/views/activitypub-reader.njk +61 -0
- package/views/activitypub-remote-profile.njk +117 -0
- package/views/layouts/reader.njk +9 -0
- package/views/partials/ap-item-card.njk +157 -0
- package/views/partials/ap-item-media.njk +37 -0
- package/views/partials/ap-notification-card.njk +58 -0
|
@@ -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
|
+
}
|