@rmdes/indiekit-endpoint-activitypub 3.11.3 → 3.11.6
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/status.js +3 -3
- package/lib/mastodon/helpers/resolve-reply-ids.js +44 -0
- package/lib/mastodon/routes/media.js +12 -2
- package/lib/mastodon/routes/oauth.js +3 -0
- package/lib/mastodon/routes/statuses.js +7 -2
- package/lib/mastodon/routes/timelines.js +11 -0
- package/lib/syndicator.js +12 -1
- package/package.json +1 -1
|
@@ -44,7 +44,7 @@ export function setLocalIdentity(publicationUrl, handle) {
|
|
|
44
44
|
* @param {Set<string>} [options.pinnedIds] - UIDs the user has pinned
|
|
45
45
|
* @returns {object} Mastodon Status entity
|
|
46
46
|
*/
|
|
47
|
-
export function serializeStatus(item, { baseUrl, favouritedIds, rebloggedIds, bookmarkedIds, pinnedIds }) {
|
|
47
|
+
export function serializeStatus(item, { baseUrl, favouritedIds, rebloggedIds, bookmarkedIds, pinnedIds, replyIdMap } = {}) {
|
|
48
48
|
if (!item) return null;
|
|
49
49
|
|
|
50
50
|
// Use published-based cursor as the status ID so pagination cursors
|
|
@@ -204,8 +204,8 @@ export function serializeStatus(item, { baseUrl, favouritedIds, rebloggedIds, bo
|
|
|
204
204
|
return {
|
|
205
205
|
id,
|
|
206
206
|
created_at: published,
|
|
207
|
-
in_reply_to_id: item.inReplyTo
|
|
208
|
-
in_reply_to_account_id: null,
|
|
207
|
+
in_reply_to_id: replyIdMap?.get(item.inReplyTo) ?? null,
|
|
208
|
+
in_reply_to_account_id: null,
|
|
209
209
|
sensitive,
|
|
210
210
|
spoiler_text: spoilerText,
|
|
211
211
|
visibility,
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Batch-resolve inReplyTo URLs to Mastodon cursor IDs.
|
|
3
|
+
*
|
|
4
|
+
* Looks up parent posts in ap_timeline by uid/url and returns a Map
|
|
5
|
+
* of inReplyTo URL → cursor ID (milliseconds since epoch as string).
|
|
6
|
+
* Used by route handlers before calling serializeStatus().
|
|
7
|
+
*
|
|
8
|
+
* @param {object} collection - ap_timeline MongoDB collection
|
|
9
|
+
* @param {Array<object>} items - Timeline items with optional inReplyTo
|
|
10
|
+
* @returns {Promise<Map<string, string>>} Map of URL → cursor ID
|
|
11
|
+
*/
|
|
12
|
+
import { encodeCursor } from "./pagination.js";
|
|
13
|
+
|
|
14
|
+
export async function resolveReplyIds(collection, items) {
|
|
15
|
+
const map = new Map();
|
|
16
|
+
if (!collection || !items?.length) return map;
|
|
17
|
+
|
|
18
|
+
// Collect unique inReplyTo URLs
|
|
19
|
+
const urls = [
|
|
20
|
+
...new Set(
|
|
21
|
+
items
|
|
22
|
+
.map((item) => item.inReplyTo)
|
|
23
|
+
.filter(Boolean),
|
|
24
|
+
),
|
|
25
|
+
];
|
|
26
|
+
if (urls.length === 0) return map;
|
|
27
|
+
|
|
28
|
+
// Batch lookup parents by uid or url
|
|
29
|
+
const parents = await collection
|
|
30
|
+
.find({ $or: [{ uid: { $in: urls } }, { url: { $in: urls } }] })
|
|
31
|
+
.project({ uid: 1, url: 1, published: 1 })
|
|
32
|
+
.toArray();
|
|
33
|
+
|
|
34
|
+
for (const parent of parents) {
|
|
35
|
+
const cursorId = encodeCursor(parent.published);
|
|
36
|
+
if (cursorId && cursorId !== "0") {
|
|
37
|
+
// Map both uid and url to the cursor ID
|
|
38
|
+
if (parent.uid) map.set(parent.uid, cursorId);
|
|
39
|
+
if (parent.url && parent.url !== parent.uid) map.set(parent.url, cursorId);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return map;
|
|
44
|
+
}
|
|
@@ -98,8 +98,13 @@ router.post(
|
|
|
98
98
|
try {
|
|
99
99
|
const { application } = req.app.locals;
|
|
100
100
|
const collections = req.app.locals.mastodonCollections;
|
|
101
|
+
// Use IndieAuth token stored during OAuth authorization, falling back
|
|
102
|
+
// to session token (native reader) or Mastodon token (won't work for
|
|
103
|
+
// Micropub media endpoint but covers direct internal calls).
|
|
101
104
|
const token =
|
|
102
|
-
req.session?.access_token ||
|
|
105
|
+
req.session?.access_token ||
|
|
106
|
+
req.mastodonToken?.indieauthToken ||
|
|
107
|
+
req.mastodonToken?.accessToken;
|
|
103
108
|
|
|
104
109
|
const file = req.files?.file;
|
|
105
110
|
if (!file) {
|
|
@@ -142,8 +147,13 @@ router.post(
|
|
|
142
147
|
try {
|
|
143
148
|
const { application } = req.app.locals;
|
|
144
149
|
const collections = req.app.locals.mastodonCollections;
|
|
150
|
+
// Use IndieAuth token stored during OAuth authorization, falling back
|
|
151
|
+
// to session token (native reader) or Mastodon token (won't work for
|
|
152
|
+
// Micropub media endpoint but covers direct internal calls).
|
|
145
153
|
const token =
|
|
146
|
-
req.session?.access_token ||
|
|
154
|
+
req.session?.access_token ||
|
|
155
|
+
req.mastodonToken?.indieauthToken ||
|
|
156
|
+
req.mastodonToken?.accessToken;
|
|
147
157
|
|
|
148
158
|
const file = req.files?.file;
|
|
149
159
|
if (!file) {
|
|
@@ -388,6 +388,9 @@ router.post("/oauth/authorize", async (req, res, next) => {
|
|
|
388
388
|
redirectUri: redirect_uri,
|
|
389
389
|
codeChallenge: code_challenge || null,
|
|
390
390
|
codeChallengeMethod: code_challenge_method || null,
|
|
391
|
+
// Store the IndieAuth session token so Mastodon API routes can call
|
|
392
|
+
// Micropub/media endpoints on behalf of this user (single-user server).
|
|
393
|
+
indieauthToken: req.session?.access_token || null,
|
|
391
394
|
createdAt: new Date(),
|
|
392
395
|
expiresAt: new Date(Date.now() + 10 * 60 * 1000), // 10 minutes
|
|
393
396
|
});
|
|
@@ -18,6 +18,7 @@ import express from "express";
|
|
|
18
18
|
import { ObjectId } from "mongodb";
|
|
19
19
|
import { serializeStatus } from "../entities/status.js";
|
|
20
20
|
import { decodeCursor } from "../helpers/pagination.js";
|
|
21
|
+
import { resolveReplyIds } from "../helpers/resolve-reply-ids.js";
|
|
21
22
|
import {
|
|
22
23
|
likePost, unlikePost,
|
|
23
24
|
boostPost, unboostPost,
|
|
@@ -43,11 +44,13 @@ router.get("/api/v1/statuses/:id", tokenRequired, scopeRequired("read", "read:st
|
|
|
43
44
|
|
|
44
45
|
// Load interaction state if authenticated
|
|
45
46
|
const interactionState = await loadItemInteractions(collections, item);
|
|
47
|
+
const replyIdMap = await resolveReplyIds(collections.ap_timeline, [item]);
|
|
46
48
|
|
|
47
49
|
const status = serializeStatus(item, {
|
|
48
50
|
baseUrl,
|
|
49
51
|
...interactionState,
|
|
50
52
|
pinnedIds: new Set(),
|
|
53
|
+
replyIdMap,
|
|
51
54
|
});
|
|
52
55
|
|
|
53
56
|
res.json(status);
|
|
@@ -122,7 +125,9 @@ router.get("/api/v1/statuses/:id/context", tokenRequired, scopeRequired("read",
|
|
|
122
125
|
pinnedIds: new Set(),
|
|
123
126
|
};
|
|
124
127
|
|
|
125
|
-
const
|
|
128
|
+
const allItems = [...ancestors, ...descendants];
|
|
129
|
+
const replyIdMap = await resolveReplyIds(collections.ap_timeline, allItems);
|
|
130
|
+
const serializeOpts = { baseUrl, ...emptyInteractions, replyIdMap };
|
|
126
131
|
|
|
127
132
|
res.json({
|
|
128
133
|
ancestors: ancestors.map((a) => serializeStatus(a, serializeOpts)),
|
|
@@ -245,7 +250,7 @@ router.post("/api/v1/statuses", tokenRequired, scopeRequired("write", "write:sta
|
|
|
245
250
|
for (const m of mediaUrls) {
|
|
246
251
|
if (m.type?.startsWith("image/")) {
|
|
247
252
|
if (!jf2.photo) jf2.photo = [];
|
|
248
|
-
jf2.photo.push({
|
|
253
|
+
jf2.photo.push({ url: m.url, alt: m.alt });
|
|
249
254
|
} else if (m.type?.startsWith("video/")) {
|
|
250
255
|
if (!jf2.video) jf2.video = [];
|
|
251
256
|
jf2.video.push(m.url);
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import express from "express";
|
|
9
9
|
import { serializeStatus } from "../entities/status.js";
|
|
10
10
|
import { buildPaginationQuery, parseLimit, setPaginationHeaders } from "../helpers/pagination.js";
|
|
11
|
+
import { resolveReplyIds } from "../helpers/resolve-reply-ids.js";
|
|
11
12
|
import { loadModerationData, applyModerationFilters } from "../../item-processing.js";
|
|
12
13
|
import { enrichAccountStats } from "../helpers/enrich-accounts.js";
|
|
13
14
|
import { tokenRequired } from "../middleware/token-required.js";
|
|
@@ -63,6 +64,9 @@ router.get("/api/v1/timelines/home", tokenRequired, scopeRequired("read", "read:
|
|
|
63
64
|
items,
|
|
64
65
|
);
|
|
65
66
|
|
|
67
|
+
// Resolve reply parent IDs for threading
|
|
68
|
+
const replyIdMap = await resolveReplyIds(collections.ap_timeline, items);
|
|
69
|
+
|
|
66
70
|
// Serialize to Mastodon Status entities
|
|
67
71
|
const statuses = items.map((item) =>
|
|
68
72
|
serializeStatus(item, {
|
|
@@ -71,6 +75,7 @@ router.get("/api/v1/timelines/home", tokenRequired, scopeRequired("read", "read:
|
|
|
71
75
|
rebloggedIds,
|
|
72
76
|
bookmarkedIds,
|
|
73
77
|
pinnedIds: new Set(),
|
|
78
|
+
replyIdMap,
|
|
74
79
|
}),
|
|
75
80
|
);
|
|
76
81
|
|
|
@@ -163,6 +168,8 @@ router.get("/api/v1/timelines/public", async (req, res, next) => {
|
|
|
163
168
|
));
|
|
164
169
|
}
|
|
165
170
|
|
|
171
|
+
const replyIdMap = await resolveReplyIds(collections.ap_timeline, items);
|
|
172
|
+
|
|
166
173
|
const statuses = items.map((item) =>
|
|
167
174
|
serializeStatus(item, {
|
|
168
175
|
baseUrl,
|
|
@@ -170,6 +177,7 @@ router.get("/api/v1/timelines/public", async (req, res, next) => {
|
|
|
170
177
|
rebloggedIds,
|
|
171
178
|
bookmarkedIds,
|
|
172
179
|
pinnedIds: new Set(),
|
|
180
|
+
replyIdMap,
|
|
173
181
|
}),
|
|
174
182
|
);
|
|
175
183
|
|
|
@@ -226,6 +234,8 @@ router.get("/api/v1/timelines/tag/:hashtag", async (req, res, next) => {
|
|
|
226
234
|
));
|
|
227
235
|
}
|
|
228
236
|
|
|
237
|
+
const replyIdMap = await resolveReplyIds(collections.ap_timeline, items);
|
|
238
|
+
|
|
229
239
|
const statuses = items.map((item) =>
|
|
230
240
|
serializeStatus(item, {
|
|
231
241
|
baseUrl,
|
|
@@ -233,6 +243,7 @@ router.get("/api/v1/timelines/tag/:hashtag", async (req, res, next) => {
|
|
|
233
243
|
rebloggedIds,
|
|
234
244
|
bookmarkedIds,
|
|
235
245
|
pinnedIds: new Set(),
|
|
246
|
+
replyIdMap,
|
|
236
247
|
}),
|
|
237
248
|
);
|
|
238
249
|
|
package/lib/syndicator.js
CHANGED
|
@@ -344,8 +344,19 @@ function buildTimelineContent(properties) {
|
|
|
344
344
|
};
|
|
345
345
|
}
|
|
346
346
|
|
|
347
|
-
// Regular post —
|
|
347
|
+
// Regular post — append permalink to match federated AS2 content.
|
|
348
|
+
// Without this, the Mastodon API timeline entry lacks the link back
|
|
349
|
+
// to the source post that fediverse users see via federation.
|
|
348
350
|
if (bodyText || bodyHtml) {
|
|
351
|
+
const postUrl = properties.url;
|
|
352
|
+
if (postUrl) {
|
|
353
|
+
const linkText = `\n\n\u{1F517} ${postUrl}`;
|
|
354
|
+
const linkHtml = `<p>\u{1F517} <a href="${esc(postUrl)}">${esc(postUrl)}</a></p>`;
|
|
355
|
+
return {
|
|
356
|
+
text: `${bodyText}${linkText}`,
|
|
357
|
+
html: `${bodyHtml}\n${linkHtml}`,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
349
360
|
return { text: bodyText, html: bodyHtml };
|
|
350
361
|
}
|
|
351
362
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rmdes/indiekit-endpoint-activitypub",
|
|
3
|
-
"version": "3.11.
|
|
3
|
+
"version": "3.11.6",
|
|
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",
|