@rmdes/indiekit-endpoint-activitypub 3.11.7 → 3.12.0
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/lib/mastodon/entities/notification.js +1 -2
- package/lib/mastodon/entities/status.js +2 -5
- package/lib/mastodon/helpers/pagination.js +48 -69
- package/lib/mastodon/helpers/resolve-reply-ids.js +7 -14
- package/lib/mastodon/routes/statuses.js +2 -22
- package/lib/mastodon/routes/timelines.js +6 -4
- package/package.json +1 -1
|
@@ -13,7 +13,6 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { serializeAccount } from "./account.js";
|
|
15
15
|
import { serializeStatus } from "./status.js";
|
|
16
|
-
import { encodeCursor } from "../helpers/pagination.js";
|
|
17
16
|
|
|
18
17
|
/**
|
|
19
18
|
* Map internal notification types to Mastodon API types.
|
|
@@ -121,7 +120,7 @@ export function serializeNotification(notif, { baseUrl, statusMap, interactionSt
|
|
|
121
120
|
: notif.published || notif.createdAt || new Date().toISOString();
|
|
122
121
|
|
|
123
122
|
return {
|
|
124
|
-
id:
|
|
123
|
+
id: notif._id.toString(),
|
|
125
124
|
type: mastodonType,
|
|
126
125
|
created_at: createdAt,
|
|
127
126
|
account,
|
|
@@ -15,7 +15,6 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { serializeAccount } from "./account.js";
|
|
17
17
|
import { sanitizeHtml } from "./sanitize.js";
|
|
18
|
-
import { encodeCursor } from "../helpers/pagination.js";
|
|
19
18
|
|
|
20
19
|
// Module-level defaults set once at startup via setLocalIdentity()
|
|
21
20
|
let _localPublicationUrl = "";
|
|
@@ -47,10 +46,8 @@ export function setLocalIdentity(publicationUrl, handle) {
|
|
|
47
46
|
export function serializeStatus(item, { baseUrl, favouritedIds, rebloggedIds, bookmarkedIds, pinnedIds, replyIdMap, replyAccountIdMap } = {}) {
|
|
48
47
|
if (!item) return null;
|
|
49
48
|
|
|
50
|
-
// Use
|
|
51
|
-
|
|
52
|
-
const cursorDate = item.published || item.createdAt || item.boostedAt;
|
|
53
|
-
const id = encodeCursor(cursorDate) || item._id.toString();
|
|
49
|
+
// Use MongoDB ObjectId as the status ID — unique and chronologically sortable.
|
|
50
|
+
const id = item._id.toString();
|
|
54
51
|
const uid = item.uid || "";
|
|
55
52
|
const url = item.url || uid;
|
|
56
53
|
|
|
@@ -1,50 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Mastodon-compatible
|
|
2
|
+
* Mastodon-compatible pagination helpers using MongoDB ObjectId.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* time, breaking chronological sort. The `published` field matches the
|
|
8
|
-
* native reader's sort and produces a correct timeline.
|
|
4
|
+
* ObjectIds are 12-byte values with a 4-byte timestamp prefix, making
|
|
5
|
+
* them chronologically sortable. Status IDs are _id.toString() — unique,
|
|
6
|
+
* sortable, and directly usable as pagination cursors.
|
|
9
7
|
*
|
|
10
|
-
*
|
|
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
|
-
*
|
|
15
|
-
* Emits RFC 8288 Link headers that masto.js / Phanpy parse.
|
|
8
|
+
* Emits RFC 8288 Link headers that Phanpy/Elk/Moshidon parse.
|
|
16
9
|
*/
|
|
10
|
+
import { ObjectId } from "mongodb";
|
|
17
11
|
|
|
18
12
|
const DEFAULT_LIMIT = 20;
|
|
19
13
|
const MAX_LIMIT = 40;
|
|
20
14
|
|
|
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
|
-
|
|
48
15
|
/**
|
|
49
16
|
* Parse and clamp the limit parameter.
|
|
50
17
|
*
|
|
@@ -58,46 +25,60 @@ export function parseLimit(raw) {
|
|
|
58
25
|
}
|
|
59
26
|
|
|
60
27
|
/**
|
|
61
|
-
*
|
|
28
|
+
* Try to parse a cursor string as an ObjectId.
|
|
29
|
+
* Returns null if invalid.
|
|
62
30
|
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
|
|
66
|
-
|
|
31
|
+
* @param {string} cursor - ObjectId hex string from client
|
|
32
|
+
* @returns {ObjectId|null}
|
|
33
|
+
*/
|
|
34
|
+
function parseCursor(cursor) {
|
|
35
|
+
if (!cursor || typeof cursor !== "string") return null;
|
|
36
|
+
try {
|
|
37
|
+
return new ObjectId(cursor);
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Build a MongoDB filter object for ObjectId-based pagination.
|
|
45
|
+
*
|
|
46
|
+
* Mastodon cursor params (all optional, applied to `_id`):
|
|
47
|
+
* max_id — return items older than this ID (exclusive)
|
|
48
|
+
* min_id — return items newer than this ID (exclusive), closest first
|
|
49
|
+
* since_id — return items newer than this ID (exclusive), most recent first
|
|
67
50
|
*
|
|
68
51
|
* @param {object} baseFilter - Existing MongoDB filter to extend
|
|
69
52
|
* @param {object} cursors
|
|
70
|
-
* @param {string} [cursors.max_id] -
|
|
71
|
-
* @param {string} [cursors.min_id] -
|
|
72
|
-
* @param {string} [cursors.since_id] -
|
|
53
|
+
* @param {string} [cursors.max_id] - ObjectId hex string
|
|
54
|
+
* @param {string} [cursors.min_id] - ObjectId hex string
|
|
55
|
+
* @param {string} [cursors.since_id] - ObjectId hex string
|
|
73
56
|
* @returns {{ filter: object, sort: object, reverse: boolean }}
|
|
74
57
|
*/
|
|
75
58
|
export function buildPaginationQuery(baseFilter, { max_id, min_id, since_id } = {}) {
|
|
76
59
|
const filter = { ...baseFilter };
|
|
77
|
-
let sort = {
|
|
60
|
+
let sort = { _id: -1 }; // newest first (default)
|
|
78
61
|
let reverse = false;
|
|
79
62
|
|
|
80
63
|
if (max_id) {
|
|
81
|
-
const
|
|
82
|
-
if (
|
|
83
|
-
filter.
|
|
64
|
+
const oid = parseCursor(max_id);
|
|
65
|
+
if (oid) {
|
|
66
|
+
filter._id = { ...filter._id, $lt: oid };
|
|
84
67
|
}
|
|
85
68
|
}
|
|
86
69
|
|
|
87
70
|
if (since_id) {
|
|
88
|
-
const
|
|
89
|
-
if (
|
|
90
|
-
filter.
|
|
71
|
+
const oid = parseCursor(since_id);
|
|
72
|
+
if (oid) {
|
|
73
|
+
filter._id = { ...filter._id, $gt: oid };
|
|
91
74
|
}
|
|
92
75
|
}
|
|
93
76
|
|
|
94
77
|
if (min_id) {
|
|
95
|
-
const
|
|
96
|
-
if (
|
|
97
|
-
filter.
|
|
98
|
-
|
|
99
|
-
// then reverse the results before returning
|
|
100
|
-
sort = { published: 1 };
|
|
78
|
+
const oid = parseCursor(min_id);
|
|
79
|
+
if (oid) {
|
|
80
|
+
filter._id = { ...filter._id, $gt: oid };
|
|
81
|
+
sort = { _id: 1 };
|
|
101
82
|
reverse = true;
|
|
102
83
|
}
|
|
103
84
|
}
|
|
@@ -110,7 +91,7 @@ export function buildPaginationQuery(baseFilter, { max_id, min_id, since_id } =
|
|
|
110
91
|
*
|
|
111
92
|
* @param {object} res - Express response object
|
|
112
93
|
* @param {object} req - Express request object (for building URLs)
|
|
113
|
-
* @param {Array} items - Result items (must have `
|
|
94
|
+
* @param {Array} items - Result items (must have `_id`)
|
|
114
95
|
* @param {number} limit - The limit used for the query
|
|
115
96
|
*/
|
|
116
97
|
export function setPaginationHeaders(res, req, items, limit) {
|
|
@@ -119,10 +100,8 @@ export function setPaginationHeaders(res, req, items, limit) {
|
|
|
119
100
|
// Only emit Link if we got a full page (may have more)
|
|
120
101
|
if (items.length < limit) return;
|
|
121
102
|
|
|
122
|
-
const
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
if (firstCursor === "0" || lastCursor === "0") return;
|
|
103
|
+
const firstId = items[0]._id.toString();
|
|
104
|
+
const lastId = items[items.length - 1]._id.toString();
|
|
126
105
|
|
|
127
106
|
const baseUrl = `${req.protocol}://${req.get("host")}${req.path}`;
|
|
128
107
|
|
|
@@ -139,14 +118,14 @@ export function setPaginationHeaders(res, req, items, limit) {
|
|
|
139
118
|
|
|
140
119
|
const links = [];
|
|
141
120
|
|
|
142
|
-
// rel="next" — older items (max_id = last item's
|
|
121
|
+
// rel="next" — older items (max_id = last item's ID)
|
|
143
122
|
const nextParams = new URLSearchParams(existingParams);
|
|
144
|
-
nextParams.set("max_id",
|
|
123
|
+
nextParams.set("max_id", lastId);
|
|
145
124
|
links.push(`<${baseUrl}?${nextParams.toString()}>; rel="next"`);
|
|
146
125
|
|
|
147
|
-
// rel="prev" — newer items (min_id = first item's
|
|
126
|
+
// rel="prev" — newer items (min_id = first item's ID)
|
|
148
127
|
const prevParams = new URLSearchParams(existingParams);
|
|
149
|
-
prevParams.set("min_id",
|
|
128
|
+
prevParams.set("min_id", firstId);
|
|
150
129
|
links.push(`<${baseUrl}?${prevParams.toString()}>; rel="prev"`);
|
|
151
130
|
|
|
152
131
|
res.set("Link", links.join(", "));
|
|
@@ -1,17 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Batch-resolve inReplyTo URLs to
|
|
2
|
+
* Batch-resolve inReplyTo URLs to ObjectId strings and account IDs.
|
|
3
3
|
*
|
|
4
4
|
* Looks up parent posts in ap_timeline by uid/url and returns two Maps:
|
|
5
|
-
* - replyIdMap: inReplyTo URL →
|
|
6
|
-
* - replyAccountIdMap: inReplyTo URL → author account ID
|
|
7
|
-
*
|
|
8
|
-
* Used by route handlers before calling serializeStatus().
|
|
5
|
+
* - replyIdMap: inReplyTo URL → parent _id.toString()
|
|
6
|
+
* - replyAccountIdMap: inReplyTo URL → parent author account ID
|
|
9
7
|
*
|
|
10
8
|
* @param {object} collection - ap_timeline MongoDB collection
|
|
11
9
|
* @param {Array<object>} items - Timeline items with optional inReplyTo
|
|
12
10
|
* @returns {Promise<{replyIdMap: Map<string, string>, replyAccountIdMap: Map<string, string>}>}
|
|
13
11
|
*/
|
|
14
|
-
import { encodeCursor } from "./pagination.js";
|
|
15
12
|
import { remoteActorId } from "./id-mapping.js";
|
|
16
13
|
|
|
17
14
|
export async function resolveReplyIds(collection, items) {
|
|
@@ -19,29 +16,25 @@ export async function resolveReplyIds(collection, items) {
|
|
|
19
16
|
const replyAccountIdMap = new Map();
|
|
20
17
|
if (!collection || !items?.length) return { replyIdMap, replyAccountIdMap };
|
|
21
18
|
|
|
22
|
-
// Collect unique inReplyTo URLs
|
|
23
19
|
const urls = [
|
|
24
20
|
...new Set(
|
|
25
|
-
items
|
|
26
|
-
.map((item) => item.inReplyTo)
|
|
27
|
-
.filter(Boolean),
|
|
21
|
+
items.map((item) => item.inReplyTo).filter(Boolean),
|
|
28
22
|
),
|
|
29
23
|
];
|
|
30
24
|
if (urls.length === 0) return { replyIdMap, replyAccountIdMap };
|
|
31
25
|
|
|
32
|
-
// Batch lookup parents by uid or url
|
|
33
26
|
const parents = await collection
|
|
34
27
|
.find({ $or: [{ uid: { $in: urls } }, { url: { $in: urls } }] })
|
|
35
|
-
.project({ uid: 1, url: 1,
|
|
28
|
+
.project({ uid: 1, url: 1, "author.url": 1 })
|
|
36
29
|
.toArray();
|
|
37
30
|
|
|
38
31
|
for (const parent of parents) {
|
|
39
|
-
const
|
|
32
|
+
const parentId = parent._id.toString();
|
|
40
33
|
const authorUrl = parent.author?.url;
|
|
41
34
|
const authorAccountId = authorUrl ? remoteActorId(authorUrl) : null;
|
|
42
35
|
|
|
43
36
|
const setMaps = (key) => {
|
|
44
|
-
|
|
37
|
+
replyIdMap.set(key, parentId);
|
|
45
38
|
if (authorAccountId) replyAccountIdMap.set(key, authorAccountId);
|
|
46
39
|
};
|
|
47
40
|
|
|
@@ -17,7 +17,6 @@
|
|
|
17
17
|
import express from "express";
|
|
18
18
|
import { ObjectId } from "mongodb";
|
|
19
19
|
import { serializeStatus } from "../entities/status.js";
|
|
20
|
-
import { decodeCursor } from "../helpers/pagination.js";
|
|
21
20
|
import { resolveReplyIds } from "../helpers/resolve-reply-ids.js";
|
|
22
21
|
import {
|
|
23
22
|
likePost, unlikePost,
|
|
@@ -844,32 +843,13 @@ router.get("/api/v1/statuses/:id/card", async (req, res, next) => {
|
|
|
844
843
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
845
844
|
|
|
846
845
|
/**
|
|
847
|
-
* Find a timeline item by
|
|
848
|
-
* Status IDs are now encodeCursor(published) — milliseconds since epoch.
|
|
849
|
-
* Falls back to ObjectId lookup for backwards compatibility.
|
|
846
|
+
* Find a timeline item by ObjectId.
|
|
850
847
|
*
|
|
851
848
|
* @param {object} collection - ap_timeline collection
|
|
852
|
-
* @param {string} id -
|
|
849
|
+
* @param {string} id - MongoDB ObjectId string
|
|
853
850
|
* @returns {Promise<object|null>} Timeline document or null
|
|
854
851
|
*/
|
|
855
852
|
async function findTimelineItemById(collection, id) {
|
|
856
|
-
// Try cursor-based lookup first (published date from ms-since-epoch)
|
|
857
|
-
const publishedDate = decodeCursor(id);
|
|
858
|
-
if (publishedDate) {
|
|
859
|
-
// Try exact match first (with .000Z suffix from toISOString)
|
|
860
|
-
let item = await collection.findOne({ published: publishedDate });
|
|
861
|
-
if (item) return item;
|
|
862
|
-
|
|
863
|
-
// Try without milliseconds — stored dates often lack .000Z
|
|
864
|
-
// e.g., "2026-03-21T15:33:50Z" vs "2026-03-21T15:33:50.000Z"
|
|
865
|
-
const withoutMs = publishedDate.replace(/\.000Z$/, "Z");
|
|
866
|
-
if (withoutMs !== publishedDate) {
|
|
867
|
-
item = await collection.findOne({ published: withoutMs });
|
|
868
|
-
if (item) return item;
|
|
869
|
-
}
|
|
870
|
-
}
|
|
871
|
-
|
|
872
|
-
// Fall back to ObjectId lookup (legacy IDs)
|
|
873
853
|
try {
|
|
874
854
|
return await collection.findOne({ _id: new ObjectId(id) });
|
|
875
855
|
} catch {
|
|
@@ -169,7 +169,7 @@ router.get("/api/v1/timelines/public", async (req, res, next) => {
|
|
|
169
169
|
));
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
-
const replyIdMap = await resolveReplyIds(collections.ap_timeline, items);
|
|
172
|
+
const { replyIdMap: rIdMap, replyAccountIdMap: rAcctMap } = await resolveReplyIds(collections.ap_timeline, items);
|
|
173
173
|
|
|
174
174
|
const statuses = items.map((item) =>
|
|
175
175
|
serializeStatus(item, {
|
|
@@ -178,7 +178,8 @@ router.get("/api/v1/timelines/public", async (req, res, next) => {
|
|
|
178
178
|
rebloggedIds,
|
|
179
179
|
bookmarkedIds,
|
|
180
180
|
pinnedIds: new Set(),
|
|
181
|
-
replyIdMap,
|
|
181
|
+
replyIdMap: rIdMap,
|
|
182
|
+
replyAccountIdMap: rAcctMap,
|
|
182
183
|
}),
|
|
183
184
|
);
|
|
184
185
|
|
|
@@ -235,7 +236,7 @@ router.get("/api/v1/timelines/tag/:hashtag", async (req, res, next) => {
|
|
|
235
236
|
));
|
|
236
237
|
}
|
|
237
238
|
|
|
238
|
-
const replyIdMap = await resolveReplyIds(collections.ap_timeline, items);
|
|
239
|
+
const { replyIdMap: rIdMap, replyAccountIdMap: rAcctMap } = await resolveReplyIds(collections.ap_timeline, items);
|
|
239
240
|
|
|
240
241
|
const statuses = items.map((item) =>
|
|
241
242
|
serializeStatus(item, {
|
|
@@ -244,7 +245,8 @@ router.get("/api/v1/timelines/tag/:hashtag", async (req, res, next) => {
|
|
|
244
245
|
rebloggedIds,
|
|
245
246
|
bookmarkedIds,
|
|
246
247
|
pinnedIds: new Set(),
|
|
247
|
-
replyIdMap,
|
|
248
|
+
replyIdMap: rIdMap,
|
|
249
|
+
replyAccountIdMap: rAcctMap,
|
|
248
250
|
}),
|
|
249
251
|
);
|
|
250
252
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rmdes/indiekit-endpoint-activitypub",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.12.0",
|
|
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",
|