@rmdes/indiekit-endpoint-activitypub 2.15.4 → 3.2.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/index.js +47 -0
- package/lib/mastodon/entities/account.js +200 -0
- package/lib/mastodon/entities/instance.js +1 -0
- package/lib/mastodon/entities/media.js +38 -0
- package/lib/mastodon/entities/notification.js +118 -0
- package/lib/mastodon/entities/relationship.js +38 -0
- package/lib/mastodon/entities/sanitize.js +111 -0
- package/lib/mastodon/entities/status.js +289 -0
- package/lib/mastodon/helpers/id-mapping.js +32 -0
- package/lib/mastodon/helpers/interactions.js +278 -0
- package/lib/mastodon/helpers/pagination.js +130 -0
- package/lib/mastodon/middleware/cors.js +25 -0
- package/lib/mastodon/middleware/error-handler.js +37 -0
- package/lib/mastodon/middleware/scope-required.js +86 -0
- package/lib/mastodon/middleware/token-required.js +57 -0
- package/lib/mastodon/router.js +96 -0
- package/lib/mastodon/routes/accounts.js +740 -0
- package/lib/mastodon/routes/instance.js +207 -0
- package/lib/mastodon/routes/media.js +43 -0
- package/lib/mastodon/routes/notifications.js +257 -0
- package/lib/mastodon/routes/oauth.js +545 -0
- package/lib/mastodon/routes/search.js +146 -0
- package/lib/mastodon/routes/statuses.js +605 -0
- package/lib/mastodon/routes/stubs.js +380 -0
- package/lib/mastodon/routes/timelines.js +296 -0
- package/package.json +2 -1
|
@@ -0,0 +1,605 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Status endpoints for Mastodon Client API.
|
|
3
|
+
*
|
|
4
|
+
* GET /api/v1/statuses/:id — single status
|
|
5
|
+
* GET /api/v1/statuses/:id/context — thread context (ancestors + descendants)
|
|
6
|
+
* POST /api/v1/statuses — create post via Micropub pipeline
|
|
7
|
+
* DELETE /api/v1/statuses/:id — delete post via Micropub pipeline
|
|
8
|
+
* POST /api/v1/statuses/:id/favourite — like a post
|
|
9
|
+
* POST /api/v1/statuses/:id/unfavourite — unlike a post
|
|
10
|
+
* POST /api/v1/statuses/:id/reblog — boost a post
|
|
11
|
+
* POST /api/v1/statuses/:id/unreblog — unboost a post
|
|
12
|
+
* POST /api/v1/statuses/:id/bookmark — bookmark a post
|
|
13
|
+
* POST /api/v1/statuses/:id/unbookmark — remove bookmark
|
|
14
|
+
*/
|
|
15
|
+
import express from "express";
|
|
16
|
+
import { ObjectId } from "mongodb";
|
|
17
|
+
import { serializeStatus } from "../entities/status.js";
|
|
18
|
+
import {
|
|
19
|
+
likePost, unlikePost,
|
|
20
|
+
boostPost, unboostPost,
|
|
21
|
+
bookmarkPost, unbookmarkPost,
|
|
22
|
+
} from "../helpers/interactions.js";
|
|
23
|
+
import { addTimelineItem } from "../../storage/timeline.js";
|
|
24
|
+
|
|
25
|
+
const router = express.Router(); // eslint-disable-line new-cap
|
|
26
|
+
|
|
27
|
+
// ─── GET /api/v1/statuses/:id ───────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
router.get("/api/v1/statuses/:id", async (req, res, next) => {
|
|
30
|
+
try {
|
|
31
|
+
const { id } = req.params;
|
|
32
|
+
const collections = req.app.locals.mastodonCollections;
|
|
33
|
+
const baseUrl = `${req.protocol}://${req.get("host")}`;
|
|
34
|
+
|
|
35
|
+
let objectId;
|
|
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 });
|
|
43
|
+
if (!item) {
|
|
44
|
+
return res.status(404).json({ error: "Record not found" });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Load interaction state if authenticated
|
|
48
|
+
const interactionState = await loadItemInteractions(collections, item);
|
|
49
|
+
|
|
50
|
+
const status = serializeStatus(item, {
|
|
51
|
+
baseUrl,
|
|
52
|
+
...interactionState,
|
|
53
|
+
pinnedIds: new Set(),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
res.json(status);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
next(error);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// ─── GET /api/v1/statuses/:id/context ───────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
router.get("/api/v1/statuses/:id/context", async (req, res, next) => {
|
|
65
|
+
try {
|
|
66
|
+
const { id } = req.params;
|
|
67
|
+
const collections = req.app.locals.mastodonCollections;
|
|
68
|
+
const baseUrl = `${req.protocol}://${req.get("host")}`;
|
|
69
|
+
|
|
70
|
+
let objectId;
|
|
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 });
|
|
78
|
+
if (!item) {
|
|
79
|
+
return res.status(404).json({ error: "Record not found" });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Find ancestors: walk up the inReplyTo chain
|
|
83
|
+
const ancestors = [];
|
|
84
|
+
let currentReplyTo = item.inReplyTo;
|
|
85
|
+
const visited = new Set();
|
|
86
|
+
|
|
87
|
+
while (currentReplyTo && ancestors.length < 40) {
|
|
88
|
+
if (visited.has(currentReplyTo)) break;
|
|
89
|
+
visited.add(currentReplyTo);
|
|
90
|
+
|
|
91
|
+
const parent = await collections.ap_timeline.findOne({
|
|
92
|
+
$or: [{ uid: currentReplyTo }, { url: currentReplyTo }],
|
|
93
|
+
});
|
|
94
|
+
if (!parent) break;
|
|
95
|
+
|
|
96
|
+
ancestors.unshift(parent);
|
|
97
|
+
currentReplyTo = parent.inReplyTo;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Find descendants: items that reply to this post's uid or url
|
|
101
|
+
const targetUrls = [item.uid, item.url].filter(Boolean);
|
|
102
|
+
let descendants = [];
|
|
103
|
+
|
|
104
|
+
if (targetUrls.length > 0) {
|
|
105
|
+
// Get direct replies first
|
|
106
|
+
const directReplies = await collections.ap_timeline
|
|
107
|
+
.find({ inReplyTo: { $in: targetUrls } })
|
|
108
|
+
.sort({ _id: 1 })
|
|
109
|
+
.limit(60)
|
|
110
|
+
.toArray();
|
|
111
|
+
|
|
112
|
+
descendants = directReplies;
|
|
113
|
+
|
|
114
|
+
// Also fetch replies to direct replies (2 levels deep)
|
|
115
|
+
if (directReplies.length > 0) {
|
|
116
|
+
const replyUrls = directReplies
|
|
117
|
+
.flatMap((r) => [r.uid, r.url].filter(Boolean));
|
|
118
|
+
const nestedReplies = await collections.ap_timeline
|
|
119
|
+
.find({ inReplyTo: { $in: replyUrls } })
|
|
120
|
+
.sort({ _id: 1 })
|
|
121
|
+
.limit(60)
|
|
122
|
+
.toArray();
|
|
123
|
+
descendants.push(...nestedReplies);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Serialize all items
|
|
128
|
+
const emptyInteractions = {
|
|
129
|
+
favouritedIds: new Set(),
|
|
130
|
+
rebloggedIds: new Set(),
|
|
131
|
+
bookmarkedIds: new Set(),
|
|
132
|
+
pinnedIds: new Set(),
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const serializeOpts = { baseUrl, ...emptyInteractions };
|
|
136
|
+
|
|
137
|
+
res.json({
|
|
138
|
+
ancestors: ancestors.map((a) => serializeStatus(a, serializeOpts)),
|
|
139
|
+
descendants: descendants.map((d) => serializeStatus(d, serializeOpts)),
|
|
140
|
+
});
|
|
141
|
+
} catch (error) {
|
|
142
|
+
next(error);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// ─── POST /api/v1/statuses ───────────────────────────────────────────────────
|
|
147
|
+
// Creates a post via the Micropub pipeline so it goes through the full flow:
|
|
148
|
+
// Micropub → content file → Eleventy build → syndication → AP federation.
|
|
149
|
+
|
|
150
|
+
router.post("/api/v1/statuses", async (req, res, next) => {
|
|
151
|
+
try {
|
|
152
|
+
const token = req.mastodonToken;
|
|
153
|
+
if (!token) {
|
|
154
|
+
return res.status(401).json({ error: "The access token is invalid" });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const { application, publication } = req.app.locals;
|
|
158
|
+
const collections = req.app.locals.mastodonCollections;
|
|
159
|
+
const pluginOptions = req.app.locals.mastodonPluginOptions || {};
|
|
160
|
+
const baseUrl = `${req.protocol}://${req.get("host")}`;
|
|
161
|
+
|
|
162
|
+
const {
|
|
163
|
+
status: statusText,
|
|
164
|
+
spoiler_text: spoilerText,
|
|
165
|
+
visibility = "public",
|
|
166
|
+
sensitive = false,
|
|
167
|
+
language,
|
|
168
|
+
in_reply_to_id: inReplyToId,
|
|
169
|
+
media_ids: mediaIds,
|
|
170
|
+
} = req.body;
|
|
171
|
+
|
|
172
|
+
if (!statusText && (!mediaIds || mediaIds.length === 0)) {
|
|
173
|
+
return res.status(422).json({ error: "Validation failed: Text content is required" });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Resolve in_reply_to URL from timeline ObjectId
|
|
177
|
+
let inReplyTo = null;
|
|
178
|
+
if (inReplyToId) {
|
|
179
|
+
try {
|
|
180
|
+
const replyItem = await collections.ap_timeline.findOne({
|
|
181
|
+
_id: new ObjectId(inReplyToId),
|
|
182
|
+
});
|
|
183
|
+
if (replyItem) {
|
|
184
|
+
inReplyTo = replyItem.uid || replyItem.url;
|
|
185
|
+
}
|
|
186
|
+
} catch {
|
|
187
|
+
// Invalid ObjectId — ignore
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Build JF2 properties for the Micropub pipeline
|
|
192
|
+
const jf2 = {
|
|
193
|
+
type: "entry",
|
|
194
|
+
content: statusText || "",
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
if (inReplyTo) {
|
|
198
|
+
jf2["in-reply-to"] = inReplyTo;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (spoilerText) {
|
|
202
|
+
jf2.summary = spoilerText;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (sensitive === true || sensitive === "true") {
|
|
206
|
+
jf2.sensitive = "true";
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (visibility && visibility !== "public") {
|
|
210
|
+
jf2.visibility = visibility;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (language) {
|
|
214
|
+
jf2["mp-language"] = language;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Create post via Micropub pipeline (same functions the Micropub endpoint uses)
|
|
218
|
+
// postData.create() handles: normalization, post type detection, path rendering,
|
|
219
|
+
// mp-syndicate-to auto-set (from checked syndicators), MongoDB posts collection
|
|
220
|
+
const { postData } = await import("@indiekit/endpoint-micropub/lib/post-data.js");
|
|
221
|
+
const { postContent } = await import("@indiekit/endpoint-micropub/lib/post-content.js");
|
|
222
|
+
|
|
223
|
+
const data = await postData.create(application, publication, jf2);
|
|
224
|
+
// postContent.create() handles: template rendering, file creation in store
|
|
225
|
+
await postContent.create(publication, data);
|
|
226
|
+
|
|
227
|
+
const postUrl = data.properties.url;
|
|
228
|
+
console.info(`[Mastodon API] Created post via Micropub: ${postUrl}`);
|
|
229
|
+
|
|
230
|
+
// Add to ap_timeline so the post is visible in the Mastodon Client API
|
|
231
|
+
const profile = await collections.ap_profile.findOne({});
|
|
232
|
+
const handle = pluginOptions.handle || "user";
|
|
233
|
+
const publicationUrl = pluginOptions.publicationUrl || baseUrl;
|
|
234
|
+
const actorUrl = profile?.url || `${publicationUrl}/users/${handle}`;
|
|
235
|
+
|
|
236
|
+
const now = new Date().toISOString();
|
|
237
|
+
const timelineItem = await addTimelineItem(collections, {
|
|
238
|
+
uid: postUrl,
|
|
239
|
+
url: postUrl,
|
|
240
|
+
type: data.properties["post-type"] || "note",
|
|
241
|
+
content: data.properties.content || { text: statusText || "", html: "" },
|
|
242
|
+
summary: spoilerText || "",
|
|
243
|
+
sensitive: sensitive === true || sensitive === "true",
|
|
244
|
+
visibility: visibility || "public",
|
|
245
|
+
language: language || null,
|
|
246
|
+
inReplyTo,
|
|
247
|
+
published: data.properties.published || now,
|
|
248
|
+
createdAt: now,
|
|
249
|
+
author: {
|
|
250
|
+
name: profile?.name || handle,
|
|
251
|
+
url: actorUrl,
|
|
252
|
+
photo: profile?.icon || "",
|
|
253
|
+
handle: `@${handle}`,
|
|
254
|
+
emojis: [],
|
|
255
|
+
bot: false,
|
|
256
|
+
},
|
|
257
|
+
photo: data.properties.photo || [],
|
|
258
|
+
video: data.properties.video || [],
|
|
259
|
+
audio: data.properties.audio || [],
|
|
260
|
+
category: data.properties.category || [],
|
|
261
|
+
counts: { replies: 0, boosts: 0, likes: 0 },
|
|
262
|
+
linkPreviews: [],
|
|
263
|
+
mentions: [],
|
|
264
|
+
emojis: [],
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// Serialize and return
|
|
268
|
+
const serialized = serializeStatus(timelineItem, {
|
|
269
|
+
baseUrl,
|
|
270
|
+
favouritedIds: new Set(),
|
|
271
|
+
rebloggedIds: new Set(),
|
|
272
|
+
bookmarkedIds: new Set(),
|
|
273
|
+
pinnedIds: new Set(),
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
res.json(serialized);
|
|
277
|
+
} catch (error) {
|
|
278
|
+
next(error);
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// ─── DELETE /api/v1/statuses/:id ────────────────────────────────────────────
|
|
283
|
+
// Deletes via Micropub pipeline (removes content file + MongoDB post) and
|
|
284
|
+
// cleans up the ap_timeline entry.
|
|
285
|
+
|
|
286
|
+
router.delete("/api/v1/statuses/:id", async (req, res, next) => {
|
|
287
|
+
try {
|
|
288
|
+
const token = req.mastodonToken;
|
|
289
|
+
if (!token) {
|
|
290
|
+
return res.status(401).json({ error: "The access token is invalid" });
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const { application, publication } = req.app.locals;
|
|
294
|
+
const { id } = req.params;
|
|
295
|
+
const collections = req.app.locals.mastodonCollections;
|
|
296
|
+
const baseUrl = `${req.protocol}://${req.get("host")}`;
|
|
297
|
+
|
|
298
|
+
let objectId;
|
|
299
|
+
try {
|
|
300
|
+
objectId = new ObjectId(id);
|
|
301
|
+
} catch {
|
|
302
|
+
return res.status(404).json({ error: "Record not found" });
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const item = await collections.ap_timeline.findOne({ _id: objectId });
|
|
306
|
+
if (!item) {
|
|
307
|
+
return res.status(404).json({ error: "Record not found" });
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Verify ownership — only allow deleting own posts
|
|
311
|
+
const profile = await collections.ap_profile.findOne({});
|
|
312
|
+
if (profile && item.author?.url !== profile.url) {
|
|
313
|
+
return res.status(403).json({ error: "This action is not allowed" });
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Serialize before deleting (Mastodon returns the deleted status with text source)
|
|
317
|
+
const serialized = serializeStatus(item, {
|
|
318
|
+
baseUrl,
|
|
319
|
+
favouritedIds: new Set(),
|
|
320
|
+
rebloggedIds: new Set(),
|
|
321
|
+
bookmarkedIds: new Set(),
|
|
322
|
+
pinnedIds: new Set(),
|
|
323
|
+
});
|
|
324
|
+
serialized.text = item.content?.text || "";
|
|
325
|
+
|
|
326
|
+
// Delete via Micropub pipeline (removes content file from store + MongoDB posts)
|
|
327
|
+
const postUrl = item.uid || item.url;
|
|
328
|
+
try {
|
|
329
|
+
const { postData } = await import("@indiekit/endpoint-micropub/lib/post-data.js");
|
|
330
|
+
const { postContent } = await import("@indiekit/endpoint-micropub/lib/post-content.js");
|
|
331
|
+
|
|
332
|
+
const existingPost = await postData.read(application, postUrl);
|
|
333
|
+
if (existingPost) {
|
|
334
|
+
const deletedData = await postData.delete(application, postUrl);
|
|
335
|
+
await postContent.delete(publication, deletedData);
|
|
336
|
+
console.info(`[Mastodon API] Deleted post via Micropub: ${postUrl}`);
|
|
337
|
+
}
|
|
338
|
+
} catch (err) {
|
|
339
|
+
// Log but don't block — the post may not exist in Micropub (e.g. old pre-pipeline posts)
|
|
340
|
+
console.warn(`[Mastodon API] Micropub delete failed for ${postUrl}: ${err.message}`);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Delete from timeline
|
|
344
|
+
await collections.ap_timeline.deleteOne({ _id: objectId });
|
|
345
|
+
|
|
346
|
+
// Clean up interactions
|
|
347
|
+
if (collections.ap_interactions && item.uid) {
|
|
348
|
+
await collections.ap_interactions.deleteMany({ objectUrl: item.uid });
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
res.json(serialized);
|
|
352
|
+
} catch (error) {
|
|
353
|
+
next(error);
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
// ─── GET /api/v1/statuses/:id/favourited_by ─────────────────────────────────
|
|
358
|
+
|
|
359
|
+
router.get("/api/v1/statuses/:id/favourited_by", async (req, res) => {
|
|
360
|
+
// Stub — we don't track who favourited remotely
|
|
361
|
+
res.json([]);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// ─── GET /api/v1/statuses/:id/reblogged_by ──────────────────────────────────
|
|
365
|
+
|
|
366
|
+
router.get("/api/v1/statuses/:id/reblogged_by", async (req, res) => {
|
|
367
|
+
// Stub — we don't track who boosted remotely
|
|
368
|
+
res.json([]);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
// ─── POST /api/v1/statuses/:id/favourite ────────────────────────────────────
|
|
372
|
+
|
|
373
|
+
router.post("/api/v1/statuses/:id/favourite", async (req, res, next) => {
|
|
374
|
+
try {
|
|
375
|
+
const token = req.mastodonToken;
|
|
376
|
+
if (!token) {
|
|
377
|
+
return res.status(401).json({ error: "The access token is invalid" });
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const { item, collections, baseUrl } = await resolveStatusForInteraction(req);
|
|
381
|
+
if (!item) {
|
|
382
|
+
return res.status(404).json({ error: "Record not found" });
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const opts = getFederationOpts(req);
|
|
386
|
+
await likePost({
|
|
387
|
+
targetUrl: item.uid || item.url,
|
|
388
|
+
...opts,
|
|
389
|
+
interactions: collections.ap_interactions,
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
const interactionState = await loadItemInteractions(collections, item);
|
|
393
|
+
// Force favourited=true since we just liked it
|
|
394
|
+
interactionState.favouritedIds.add(item.uid);
|
|
395
|
+
|
|
396
|
+
res.json(serializeStatus(item, { baseUrl, ...interactionState, pinnedIds: new Set() }));
|
|
397
|
+
} catch (error) {
|
|
398
|
+
next(error);
|
|
399
|
+
}
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
// ─── POST /api/v1/statuses/:id/unfavourite ──────────────────────────────────
|
|
403
|
+
|
|
404
|
+
router.post("/api/v1/statuses/:id/unfavourite", async (req, res, next) => {
|
|
405
|
+
try {
|
|
406
|
+
const token = req.mastodonToken;
|
|
407
|
+
if (!token) {
|
|
408
|
+
return res.status(401).json({ error: "The access token is invalid" });
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const { item, collections, baseUrl } = await resolveStatusForInteraction(req);
|
|
412
|
+
if (!item) {
|
|
413
|
+
return res.status(404).json({ error: "Record not found" });
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const opts = getFederationOpts(req);
|
|
417
|
+
await unlikePost({
|
|
418
|
+
targetUrl: item.uid || item.url,
|
|
419
|
+
...opts,
|
|
420
|
+
interactions: collections.ap_interactions,
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
const interactionState = await loadItemInteractions(collections, item);
|
|
424
|
+
interactionState.favouritedIds.delete(item.uid);
|
|
425
|
+
|
|
426
|
+
res.json(serializeStatus(item, { baseUrl, ...interactionState, pinnedIds: new Set() }));
|
|
427
|
+
} catch (error) {
|
|
428
|
+
next(error);
|
|
429
|
+
}
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
// ─── POST /api/v1/statuses/:id/reblog ───────────────────────────────────────
|
|
433
|
+
|
|
434
|
+
router.post("/api/v1/statuses/:id/reblog", async (req, res, next) => {
|
|
435
|
+
try {
|
|
436
|
+
const token = req.mastodonToken;
|
|
437
|
+
if (!token) {
|
|
438
|
+
return res.status(401).json({ error: "The access token is invalid" });
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const { item, collections, baseUrl } = await resolveStatusForInteraction(req);
|
|
442
|
+
if (!item) {
|
|
443
|
+
return res.status(404).json({ error: "Record not found" });
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const opts = getFederationOpts(req);
|
|
447
|
+
await boostPost({
|
|
448
|
+
targetUrl: item.uid || item.url,
|
|
449
|
+
...opts,
|
|
450
|
+
interactions: collections.ap_interactions,
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
const interactionState = await loadItemInteractions(collections, item);
|
|
454
|
+
interactionState.rebloggedIds.add(item.uid);
|
|
455
|
+
|
|
456
|
+
res.json(serializeStatus(item, { baseUrl, ...interactionState, pinnedIds: new Set() }));
|
|
457
|
+
} catch (error) {
|
|
458
|
+
next(error);
|
|
459
|
+
}
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
// ─── POST /api/v1/statuses/:id/unreblog ─────────────────────────────────────
|
|
463
|
+
|
|
464
|
+
router.post("/api/v1/statuses/:id/unreblog", async (req, res, next) => {
|
|
465
|
+
try {
|
|
466
|
+
const token = req.mastodonToken;
|
|
467
|
+
if (!token) {
|
|
468
|
+
return res.status(401).json({ error: "The access token is invalid" });
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const { item, collections, baseUrl } = await resolveStatusForInteraction(req);
|
|
472
|
+
if (!item) {
|
|
473
|
+
return res.status(404).json({ error: "Record not found" });
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const opts = getFederationOpts(req);
|
|
477
|
+
await unboostPost({
|
|
478
|
+
targetUrl: item.uid || item.url,
|
|
479
|
+
...opts,
|
|
480
|
+
interactions: collections.ap_interactions,
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
const interactionState = await loadItemInteractions(collections, item);
|
|
484
|
+
interactionState.rebloggedIds.delete(item.uid);
|
|
485
|
+
|
|
486
|
+
res.json(serializeStatus(item, { baseUrl, ...interactionState, pinnedIds: new Set() }));
|
|
487
|
+
} catch (error) {
|
|
488
|
+
next(error);
|
|
489
|
+
}
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
// ─── POST /api/v1/statuses/:id/bookmark ─────────────────────────────────────
|
|
493
|
+
|
|
494
|
+
router.post("/api/v1/statuses/:id/bookmark", async (req, res, next) => {
|
|
495
|
+
try {
|
|
496
|
+
const token = req.mastodonToken;
|
|
497
|
+
if (!token) {
|
|
498
|
+
return res.status(401).json({ error: "The access token is invalid" });
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const { item, collections, baseUrl } = await resolveStatusForInteraction(req);
|
|
502
|
+
if (!item) {
|
|
503
|
+
return res.status(404).json({ error: "Record not found" });
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
await bookmarkPost({
|
|
507
|
+
targetUrl: item.uid || item.url,
|
|
508
|
+
interactions: collections.ap_interactions,
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
const interactionState = await loadItemInteractions(collections, item);
|
|
512
|
+
interactionState.bookmarkedIds.add(item.uid);
|
|
513
|
+
|
|
514
|
+
res.json(serializeStatus(item, { baseUrl, ...interactionState, pinnedIds: new Set() }));
|
|
515
|
+
} catch (error) {
|
|
516
|
+
next(error);
|
|
517
|
+
}
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
// ─── POST /api/v1/statuses/:id/unbookmark ───────────────────────────────────
|
|
521
|
+
|
|
522
|
+
router.post("/api/v1/statuses/:id/unbookmark", async (req, res, next) => {
|
|
523
|
+
try {
|
|
524
|
+
const token = req.mastodonToken;
|
|
525
|
+
if (!token) {
|
|
526
|
+
return res.status(401).json({ error: "The access token is invalid" });
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const { item, collections, baseUrl } = await resolveStatusForInteraction(req);
|
|
530
|
+
if (!item) {
|
|
531
|
+
return res.status(404).json({ error: "Record not found" });
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
await unbookmarkPost({
|
|
535
|
+
targetUrl: item.uid || item.url,
|
|
536
|
+
interactions: collections.ap_interactions,
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
const interactionState = await loadItemInteractions(collections, item);
|
|
540
|
+
interactionState.bookmarkedIds.delete(item.uid);
|
|
541
|
+
|
|
542
|
+
res.json(serializeStatus(item, { baseUrl, ...interactionState, pinnedIds: new Set() }));
|
|
543
|
+
} catch (error) {
|
|
544
|
+
next(error);
|
|
545
|
+
}
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Resolve a timeline item from the :id param, plus common context.
|
|
552
|
+
*/
|
|
553
|
+
async function resolveStatusForInteraction(req) {
|
|
554
|
+
const collections = req.app.locals.mastodonCollections;
|
|
555
|
+
const baseUrl = `${req.protocol}://${req.get("host")}`;
|
|
556
|
+
|
|
557
|
+
let objectId;
|
|
558
|
+
try {
|
|
559
|
+
objectId = new ObjectId(req.params.id);
|
|
560
|
+
} catch {
|
|
561
|
+
return { item: null, collections, baseUrl };
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const item = await collections.ap_timeline.findOne({ _id: objectId });
|
|
565
|
+
return { item, collections, baseUrl };
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Build federation options from request context for interaction helpers.
|
|
570
|
+
*/
|
|
571
|
+
function getFederationOpts(req) {
|
|
572
|
+
const pluginOptions = req.app.locals.mastodonPluginOptions || {};
|
|
573
|
+
return {
|
|
574
|
+
federation: pluginOptions.federation,
|
|
575
|
+
handle: pluginOptions.handle || "user",
|
|
576
|
+
publicationUrl: pluginOptions.publicationUrl,
|
|
577
|
+
collections: req.app.locals.mastodonCollections,
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
async function loadItemInteractions(collections, item) {
|
|
582
|
+
const favouritedIds = new Set();
|
|
583
|
+
const rebloggedIds = new Set();
|
|
584
|
+
const bookmarkedIds = new Set();
|
|
585
|
+
|
|
586
|
+
if (!collections.ap_interactions || !item.uid) {
|
|
587
|
+
return { favouritedIds, rebloggedIds, bookmarkedIds };
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const lookupUrls = [item.uid, item.url].filter(Boolean);
|
|
591
|
+
const interactions = await collections.ap_interactions
|
|
592
|
+
.find({ objectUrl: { $in: lookupUrls } })
|
|
593
|
+
.toArray();
|
|
594
|
+
|
|
595
|
+
for (const i of interactions) {
|
|
596
|
+
const uid = item.uid;
|
|
597
|
+
if (i.type === "like") favouritedIds.add(uid);
|
|
598
|
+
else if (i.type === "boost") rebloggedIds.add(uid);
|
|
599
|
+
else if (i.type === "bookmark") bookmarkedIds.add(uid);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
return { favouritedIds, rebloggedIds, bookmarkedIds };
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
export default router;
|