@rmdes/indiekit-endpoint-activitypub 3.5.9 → 3.6.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.
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { serializeAccount } from "./account.js";
|
|
15
15
|
import { serializeStatus } from "./status.js";
|
|
16
|
+
import { encodeCursor } from "../helpers/pagination.js";
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* Map internal notification types to Mastodon API types.
|
|
@@ -115,12 +116,14 @@ export function serializeNotification(notif, { baseUrl, statusMap, interactionSt
|
|
|
115
116
|
};
|
|
116
117
|
}
|
|
117
118
|
|
|
119
|
+
const createdAt = notif.published instanceof Date
|
|
120
|
+
? notif.published.toISOString()
|
|
121
|
+
: notif.published || notif.createdAt || new Date().toISOString();
|
|
122
|
+
|
|
118
123
|
return {
|
|
119
|
-
id: notif._id.toString(),
|
|
124
|
+
id: encodeCursor(createdAt) || notif._id.toString(),
|
|
120
125
|
type: mastodonType,
|
|
121
|
-
created_at:
|
|
122
|
-
? notif.published.toISOString()
|
|
123
|
-
: notif.published || notif.createdAt || new Date().toISOString(),
|
|
126
|
+
created_at: createdAt,
|
|
124
127
|
account,
|
|
125
128
|
status,
|
|
126
129
|
};
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { serializeAccount } from "./account.js";
|
|
17
17
|
import { sanitizeHtml } from "./sanitize.js";
|
|
18
|
+
import { encodeCursor } from "../helpers/pagination.js";
|
|
18
19
|
|
|
19
20
|
// Module-level defaults set once at startup via setLocalIdentity()
|
|
20
21
|
let _localPublicationUrl = "";
|
|
@@ -46,7 +47,10 @@ export function setLocalIdentity(publicationUrl, handle) {
|
|
|
46
47
|
export function serializeStatus(item, { baseUrl, favouritedIds, rebloggedIds, bookmarkedIds, pinnedIds }) {
|
|
47
48
|
if (!item) return null;
|
|
48
49
|
|
|
49
|
-
|
|
50
|
+
// Use published-based cursor as the status ID so pagination cursors
|
|
51
|
+
// (max_id/min_id) sort chronologically, not by insertion order.
|
|
52
|
+
const cursorDate = item.published || item.createdAt || item.boostedAt;
|
|
53
|
+
const id = encodeCursor(cursorDate) || item._id.toString();
|
|
50
54
|
const uid = item.uid || "";
|
|
51
55
|
const url = item.url || uid;
|
|
52
56
|
|
|
@@ -1,14 +1,50 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Mastodon-compatible cursor pagination helpers.
|
|
3
3
|
*
|
|
4
|
-
* Uses
|
|
4
|
+
* Uses `published` date as cursor (chronologically correct) instead of
|
|
5
|
+
* MongoDB ObjectId. ObjectId reflects insertion order, not publication
|
|
6
|
+
* order — backfilled or syndicated posts get new ObjectIds at import
|
|
7
|
+
* time, breaking chronological sort. The `published` field matches the
|
|
8
|
+
* native reader's sort and produces a correct timeline.
|
|
9
|
+
*
|
|
10
|
+
* Cursor values are `published` ISO strings, but Mastodon clients pass
|
|
11
|
+
* them as opaque `max_id`/`min_id`/`since_id` strings. We encode the
|
|
12
|
+
* published date as a Mastodon-style snowflake-ish ID (milliseconds
|
|
13
|
+
* since epoch) so clients treat them as comparable integers.
|
|
14
|
+
*
|
|
5
15
|
* Emits RFC 8288 Link headers that masto.js / Phanpy parse.
|
|
6
16
|
*/
|
|
7
|
-
import { ObjectId } from "mongodb";
|
|
8
17
|
|
|
9
18
|
const DEFAULT_LIMIT = 20;
|
|
10
19
|
const MAX_LIMIT = 40;
|
|
11
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Encode a published date string as a numeric cursor ID.
|
|
23
|
+
* Mastodon clients expect IDs to be numeric strings that sort chronologically.
|
|
24
|
+
* We use milliseconds since epoch — monotonic and comparable.
|
|
25
|
+
*
|
|
26
|
+
* @param {string|Date} published - ISO date string or Date object
|
|
27
|
+
* @returns {string} Numeric string (ms since epoch)
|
|
28
|
+
*/
|
|
29
|
+
export function encodeCursor(published) {
|
|
30
|
+
if (!published) return "0";
|
|
31
|
+
const ms = new Date(published).getTime();
|
|
32
|
+
return Number.isFinite(ms) ? String(ms) : "0";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Decode a numeric cursor ID back to an ISO date string.
|
|
37
|
+
*
|
|
38
|
+
* @param {string} cursor - Numeric cursor from client
|
|
39
|
+
* @returns {string|null} ISO date string, or null if invalid
|
|
40
|
+
*/
|
|
41
|
+
export function decodeCursor(cursor) {
|
|
42
|
+
if (!cursor) return null;
|
|
43
|
+
const ms = Number.parseInt(cursor, 10);
|
|
44
|
+
if (!Number.isFinite(ms) || ms <= 0) return null;
|
|
45
|
+
return new Date(ms).toISOString();
|
|
46
|
+
}
|
|
47
|
+
|
|
12
48
|
/**
|
|
13
49
|
* Parse and clamp the limit parameter.
|
|
14
50
|
*
|
|
@@ -24,48 +60,45 @@ export function parseLimit(raw) {
|
|
|
24
60
|
/**
|
|
25
61
|
* Build a MongoDB filter object for cursor-based pagination.
|
|
26
62
|
*
|
|
27
|
-
* Mastodon cursor params (all optional, applied to `
|
|
28
|
-
* max_id — return items older than this
|
|
29
|
-
* min_id — return items newer than this
|
|
30
|
-
* since_id — return items newer than this
|
|
63
|
+
* Mastodon cursor params (all optional, applied to `published`):
|
|
64
|
+
* max_id — return items older than this cursor (exclusive)
|
|
65
|
+
* min_id — return items newer than this cursor (exclusive), closest first
|
|
66
|
+
* since_id — return items newer than this cursor (exclusive), most recent first
|
|
31
67
|
*
|
|
32
68
|
* @param {object} baseFilter - Existing MongoDB filter to extend
|
|
33
69
|
* @param {object} cursors
|
|
34
|
-
* @param {string} [cursors.max_id]
|
|
35
|
-
* @param {string} [cursors.min_id]
|
|
36
|
-
* @param {string} [cursors.since_id]
|
|
70
|
+
* @param {string} [cursors.max_id] - Numeric cursor (ms since epoch)
|
|
71
|
+
* @param {string} [cursors.min_id] - Numeric cursor (ms since epoch)
|
|
72
|
+
* @param {string} [cursors.since_id] - Numeric cursor (ms since epoch)
|
|
37
73
|
* @returns {{ filter: object, sort: object, reverse: boolean }}
|
|
38
74
|
*/
|
|
39
75
|
export function buildPaginationQuery(baseFilter, { max_id, min_id, since_id } = {}) {
|
|
40
76
|
const filter = { ...baseFilter };
|
|
41
|
-
let sort = {
|
|
77
|
+
let sort = { published: -1 }; // newest first (default)
|
|
42
78
|
let reverse = false;
|
|
43
79
|
|
|
44
80
|
if (max_id) {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
// Invalid ObjectId — ignore
|
|
81
|
+
const date = decodeCursor(max_id);
|
|
82
|
+
if (date) {
|
|
83
|
+
filter.published = { ...filter.published, $lt: date };
|
|
49
84
|
}
|
|
50
85
|
}
|
|
51
86
|
|
|
52
87
|
if (since_id) {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
// Invalid ObjectId — ignore
|
|
88
|
+
const date = decodeCursor(since_id);
|
|
89
|
+
if (date) {
|
|
90
|
+
filter.published = { ...filter.published, $gt: date };
|
|
57
91
|
}
|
|
58
92
|
}
|
|
59
93
|
|
|
60
94
|
if (min_id) {
|
|
61
|
-
|
|
62
|
-
|
|
95
|
+
const date = decodeCursor(min_id);
|
|
96
|
+
if (date) {
|
|
97
|
+
filter.published = { ...filter.published, $gt: date };
|
|
63
98
|
// min_id returns results closest to the cursor, so sort ascending
|
|
64
99
|
// then reverse the results before returning
|
|
65
|
-
sort = {
|
|
100
|
+
sort = { published: 1 };
|
|
66
101
|
reverse = true;
|
|
67
|
-
} catch {
|
|
68
|
-
// Invalid ObjectId — ignore
|
|
69
102
|
}
|
|
70
103
|
}
|
|
71
104
|
|
|
@@ -77,7 +110,7 @@ export function buildPaginationQuery(baseFilter, { max_id, min_id, since_id } =
|
|
|
77
110
|
*
|
|
78
111
|
* @param {object} res - Express response object
|
|
79
112
|
* @param {object} req - Express request object (for building URLs)
|
|
80
|
-
* @param {Array} items - Result items (must have `
|
|
113
|
+
* @param {Array} items - Result items (must have `published`)
|
|
81
114
|
* @param {number} limit - The limit used for the query
|
|
82
115
|
*/
|
|
83
116
|
export function setPaginationHeaders(res, req, items, limit) {
|
|
@@ -86,10 +119,10 @@ export function setPaginationHeaders(res, req, items, limit) {
|
|
|
86
119
|
// Only emit Link if we got a full page (may have more)
|
|
87
120
|
if (items.length < limit) return;
|
|
88
121
|
|
|
89
|
-
const
|
|
90
|
-
const
|
|
122
|
+
const firstCursor = encodeCursor(items[0].published);
|
|
123
|
+
const lastCursor = encodeCursor(items[items.length - 1].published);
|
|
91
124
|
|
|
92
|
-
if (
|
|
125
|
+
if (firstCursor === "0" || lastCursor === "0") return;
|
|
93
126
|
|
|
94
127
|
const baseUrl = `${req.protocol}://${req.get("host")}${req.path}`;
|
|
95
128
|
|
|
@@ -106,25 +139,15 @@ export function setPaginationHeaders(res, req, items, limit) {
|
|
|
106
139
|
|
|
107
140
|
const links = [];
|
|
108
141
|
|
|
109
|
-
// rel="next" — older items (max_id = last item's
|
|
142
|
+
// rel="next" — older items (max_id = last item's cursor)
|
|
110
143
|
const nextParams = new URLSearchParams(existingParams);
|
|
111
|
-
nextParams.set("max_id",
|
|
144
|
+
nextParams.set("max_id", lastCursor);
|
|
112
145
|
links.push(`<${baseUrl}?${nextParams.toString()}>; rel="next"`);
|
|
113
146
|
|
|
114
|
-
// rel="prev" — newer items (min_id = first item's
|
|
147
|
+
// rel="prev" — newer items (min_id = first item's cursor)
|
|
115
148
|
const prevParams = new URLSearchParams(existingParams);
|
|
116
|
-
prevParams.set("min_id",
|
|
149
|
+
prevParams.set("min_id", firstCursor);
|
|
117
150
|
links.push(`<${baseUrl}?${prevParams.toString()}>; rel="prev"`);
|
|
118
151
|
|
|
119
152
|
res.set("Link", links.join(", "));
|
|
120
153
|
}
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* Extract the string ID from an item.
|
|
124
|
-
*/
|
|
125
|
-
function itemId(item) {
|
|
126
|
-
if (!item) return null;
|
|
127
|
-
if (item._id) return item._id.toString();
|
|
128
|
-
if (item.id) return String(item.id);
|
|
129
|
-
return null;
|
|
130
|
-
}
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import express from "express";
|
|
16
16
|
import { ObjectId } from "mongodb";
|
|
17
17
|
import { serializeStatus } from "../entities/status.js";
|
|
18
|
+
import { decodeCursor } from "../helpers/pagination.js";
|
|
18
19
|
import {
|
|
19
20
|
likePost, unlikePost,
|
|
20
21
|
boostPost, unboostPost,
|
|
@@ -32,14 +33,7 @@ router.get("/api/v1/statuses/:id", async (req, res, next) => {
|
|
|
32
33
|
const collections = req.app.locals.mastodonCollections;
|
|
33
34
|
const baseUrl = `${req.protocol}://${req.get("host")}`;
|
|
34
35
|
|
|
35
|
-
|
|
36
|
-
try {
|
|
37
|
-
objectId = new ObjectId(id);
|
|
38
|
-
} catch {
|
|
39
|
-
return res.status(404).json({ error: "Record not found" });
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const item = await collections.ap_timeline.findOne({ _id: objectId });
|
|
36
|
+
const item = await findTimelineItemById(collections.ap_timeline, id);
|
|
43
37
|
if (!item) {
|
|
44
38
|
return res.status(404).json({ error: "Record not found" });
|
|
45
39
|
}
|
|
@@ -67,14 +61,7 @@ router.get("/api/v1/statuses/:id/context", async (req, res, next) => {
|
|
|
67
61
|
const collections = req.app.locals.mastodonCollections;
|
|
68
62
|
const baseUrl = `${req.protocol}://${req.get("host")}`;
|
|
69
63
|
|
|
70
|
-
|
|
71
|
-
try {
|
|
72
|
-
objectId = new ObjectId(id);
|
|
73
|
-
} catch {
|
|
74
|
-
return res.status(404).json({ error: "Record not found" });
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
const item = await collections.ap_timeline.findOne({ _id: objectId });
|
|
64
|
+
const item = await findTimelineItemById(collections.ap_timeline, id);
|
|
78
65
|
if (!item) {
|
|
79
66
|
return res.status(404).json({ error: "Record not found" });
|
|
80
67
|
}
|
|
@@ -173,18 +160,12 @@ router.post("/api/v1/statuses", async (req, res, next) => {
|
|
|
173
160
|
return res.status(422).json({ error: "Validation failed: Text content is required" });
|
|
174
161
|
}
|
|
175
162
|
|
|
176
|
-
// Resolve in_reply_to URL from
|
|
163
|
+
// Resolve in_reply_to URL from status ID (cursor or ObjectId)
|
|
177
164
|
let inReplyTo = null;
|
|
178
165
|
if (inReplyToId) {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
});
|
|
183
|
-
if (replyItem) {
|
|
184
|
-
inReplyTo = replyItem.uid || replyItem.url;
|
|
185
|
-
}
|
|
186
|
-
} catch {
|
|
187
|
-
// Invalid ObjectId — ignore
|
|
166
|
+
const replyItem = await findTimelineItemById(collections.ap_timeline, inReplyToId);
|
|
167
|
+
if (replyItem) {
|
|
168
|
+
inReplyTo = replyItem.uid || replyItem.url;
|
|
188
169
|
}
|
|
189
170
|
}
|
|
190
171
|
|
|
@@ -300,14 +281,7 @@ router.delete("/api/v1/statuses/:id", async (req, res, next) => {
|
|
|
300
281
|
const collections = req.app.locals.mastodonCollections;
|
|
301
282
|
const baseUrl = `${req.protocol}://${req.get("host")}`;
|
|
302
283
|
|
|
303
|
-
|
|
304
|
-
try {
|
|
305
|
-
objectId = new ObjectId(id);
|
|
306
|
-
} catch {
|
|
307
|
-
return res.status(404).json({ error: "Record not found" });
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
const item = await collections.ap_timeline.findOne({ _id: objectId });
|
|
284
|
+
const item = await findTimelineItemById(collections.ap_timeline, id);
|
|
311
285
|
if (!item) {
|
|
312
286
|
return res.status(404).json({ error: "Record not found" });
|
|
313
287
|
}
|
|
@@ -553,20 +527,38 @@ router.post("/api/v1/statuses/:id/unbookmark", async (req, res, next) => {
|
|
|
553
527
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
554
528
|
|
|
555
529
|
/**
|
|
556
|
-
*
|
|
530
|
+
* Find a timeline item by cursor ID (published-based) or ObjectId (legacy).
|
|
531
|
+
* Status IDs are now encodeCursor(published) — milliseconds since epoch.
|
|
532
|
+
* Falls back to ObjectId lookup for backwards compatibility.
|
|
533
|
+
*
|
|
534
|
+
* @param {object} collection - ap_timeline collection
|
|
535
|
+
* @param {string} id - Status ID from client
|
|
536
|
+
* @returns {Promise<object|null>} Timeline document or null
|
|
557
537
|
*/
|
|
558
|
-
async function
|
|
559
|
-
|
|
560
|
-
const
|
|
538
|
+
async function findTimelineItemById(collection, id) {
|
|
539
|
+
// Try cursor-based lookup first (published date from ms-since-epoch)
|
|
540
|
+
const publishedDate = decodeCursor(id);
|
|
541
|
+
if (publishedDate) {
|
|
542
|
+
const item = await collection.findOne({ published: publishedDate });
|
|
543
|
+
if (item) return item;
|
|
544
|
+
}
|
|
561
545
|
|
|
562
|
-
|
|
546
|
+
// Fall back to ObjectId lookup (legacy IDs)
|
|
563
547
|
try {
|
|
564
|
-
|
|
548
|
+
return await collection.findOne({ _id: new ObjectId(id) });
|
|
565
549
|
} catch {
|
|
566
|
-
return
|
|
550
|
+
return null;
|
|
567
551
|
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Resolve a timeline item from the :id param, plus common context.
|
|
556
|
+
*/
|
|
557
|
+
async function resolveStatusForInteraction(req) {
|
|
558
|
+
const collections = req.app.locals.mastodonCollections;
|
|
559
|
+
const baseUrl = `${req.protocol}://${req.get("host")}`;
|
|
568
560
|
|
|
569
|
-
const item = await collections.ap_timeline.
|
|
561
|
+
const item = await findTimelineItemById(collections.ap_timeline, req.params.id);
|
|
570
562
|
return { item, collections, baseUrl };
|
|
571
563
|
}
|
|
572
564
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rmdes/indiekit-endpoint-activitypub",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.6.1",
|
|
4
4
|
"description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"indiekit",
|