@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,740 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Account endpoints for Mastodon Client API.
|
|
3
|
+
*
|
|
4
|
+
* Phase 1: verify_credentials, preferences, account lookup
|
|
5
|
+
* Phase 2: relationships, follow/unfollow, account statuses
|
|
6
|
+
*/
|
|
7
|
+
import express from "express";
|
|
8
|
+
import { serializeCredentialAccount, serializeAccount } from "../entities/account.js";
|
|
9
|
+
import { serializeStatus } from "../entities/status.js";
|
|
10
|
+
import { accountId, remoteActorId } from "../helpers/id-mapping.js";
|
|
11
|
+
import { buildPaginationQuery, parseLimit, setPaginationHeaders } from "../helpers/pagination.js";
|
|
12
|
+
|
|
13
|
+
const router = express.Router(); // eslint-disable-line new-cap
|
|
14
|
+
|
|
15
|
+
// ─── GET /api/v1/accounts/verify_credentials ─────────────────────────────────
|
|
16
|
+
|
|
17
|
+
router.get("/api/v1/accounts/verify_credentials", async (req, res, next) => {
|
|
18
|
+
try {
|
|
19
|
+
const token = req.mastodonToken;
|
|
20
|
+
if (!token) {
|
|
21
|
+
return res.status(401).json({ error: "The access token is invalid" });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const baseUrl = `${req.protocol}://${req.get("host")}`;
|
|
25
|
+
const collections = req.app.locals.mastodonCollections;
|
|
26
|
+
const pluginOptions = req.app.locals.mastodonPluginOptions || {};
|
|
27
|
+
const handle = pluginOptions.handle || "user";
|
|
28
|
+
|
|
29
|
+
const profile = await collections.ap_profile.findOne({});
|
|
30
|
+
if (!profile) {
|
|
31
|
+
return res.status(404).json({ error: "Profile not found" });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Get counts
|
|
35
|
+
let counts = {};
|
|
36
|
+
try {
|
|
37
|
+
const [statuses, followers, following] = await Promise.all([
|
|
38
|
+
collections.ap_timeline.countDocuments({
|
|
39
|
+
"author.url": profile.url,
|
|
40
|
+
}),
|
|
41
|
+
collections.ap_followers.countDocuments({}),
|
|
42
|
+
collections.ap_following.countDocuments({}),
|
|
43
|
+
]);
|
|
44
|
+
counts = { statuses, followers, following };
|
|
45
|
+
} catch {
|
|
46
|
+
counts = { statuses: 0, followers: 0, following: 0 };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const account = serializeCredentialAccount(profile, {
|
|
50
|
+
baseUrl,
|
|
51
|
+
handle,
|
|
52
|
+
counts,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
res.json(account);
|
|
56
|
+
} catch (error) {
|
|
57
|
+
next(error);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// ─── GET /api/v1/preferences ─────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
router.get("/api/v1/preferences", (req, res) => {
|
|
64
|
+
res.json({
|
|
65
|
+
"posting:default:visibility": "public",
|
|
66
|
+
"posting:default:sensitive": false,
|
|
67
|
+
"posting:default:language": "en",
|
|
68
|
+
"reading:expand:media": "default",
|
|
69
|
+
"reading:expand:spoilers": false,
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// ─── GET /api/v1/accounts/lookup ─────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
router.get("/api/v1/accounts/lookup", async (req, res, next) => {
|
|
76
|
+
try {
|
|
77
|
+
const { acct } = req.query;
|
|
78
|
+
if (!acct) {
|
|
79
|
+
return res.status(400).json({ error: "Missing acct parameter" });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const baseUrl = `${req.protocol}://${req.get("host")}`;
|
|
83
|
+
const collections = req.app.locals.mastodonCollections;
|
|
84
|
+
const pluginOptions = req.app.locals.mastodonPluginOptions || {};
|
|
85
|
+
const handle = pluginOptions.handle || "user";
|
|
86
|
+
|
|
87
|
+
// Check if looking up local account
|
|
88
|
+
const bareAcct = acct.startsWith("@") ? acct.slice(1) : acct;
|
|
89
|
+
const localDomain = req.get("host");
|
|
90
|
+
|
|
91
|
+
if (
|
|
92
|
+
bareAcct === handle ||
|
|
93
|
+
bareAcct === `${handle}@${localDomain}`
|
|
94
|
+
) {
|
|
95
|
+
const profile = await collections.ap_profile.findOne({});
|
|
96
|
+
if (profile) {
|
|
97
|
+
return res.json(
|
|
98
|
+
serializeAccount(profile, { baseUrl, isLocal: true, handle }),
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Check followers/following for known remote actors
|
|
104
|
+
const follower = await collections.ap_followers.findOne({
|
|
105
|
+
$or: [
|
|
106
|
+
{ handle: `@${bareAcct}` },
|
|
107
|
+
{ handle: bareAcct },
|
|
108
|
+
],
|
|
109
|
+
});
|
|
110
|
+
if (follower) {
|
|
111
|
+
return res.json(
|
|
112
|
+
serializeAccount(
|
|
113
|
+
{ name: follower.name, url: follower.actorUrl, photo: follower.avatar, handle: follower.handle },
|
|
114
|
+
{ baseUrl },
|
|
115
|
+
),
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return res.status(404).json({ error: "Record not found" });
|
|
120
|
+
} catch (error) {
|
|
121
|
+
next(error);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// ─── GET /api/v1/accounts/:id ────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
router.get("/api/v1/accounts/:id", async (req, res, next) => {
|
|
128
|
+
try {
|
|
129
|
+
const { id } = req.params;
|
|
130
|
+
const baseUrl = `${req.protocol}://${req.get("host")}`;
|
|
131
|
+
const collections = req.app.locals.mastodonCollections;
|
|
132
|
+
const pluginOptions = req.app.locals.mastodonPluginOptions || {};
|
|
133
|
+
const handle = pluginOptions.handle || "user";
|
|
134
|
+
|
|
135
|
+
// Check if it's the local profile
|
|
136
|
+
const profile = await collections.ap_profile.findOne({});
|
|
137
|
+
if (profile && profile._id.toString() === id) {
|
|
138
|
+
return res.json(
|
|
139
|
+
serializeAccount(profile, { baseUrl, isLocal: true, handle }),
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Search known actors (followers, following, timeline authors)
|
|
144
|
+
// by checking if the deterministic hash matches
|
|
145
|
+
const follower = await collections.ap_followers
|
|
146
|
+
.find({})
|
|
147
|
+
.toArray();
|
|
148
|
+
for (const f of follower) {
|
|
149
|
+
if (remoteActorId(f.actorUrl) === id) {
|
|
150
|
+
return res.json(
|
|
151
|
+
serializeAccount(
|
|
152
|
+
{ name: f.name, url: f.actorUrl, photo: f.avatar, handle: f.handle },
|
|
153
|
+
{ baseUrl },
|
|
154
|
+
),
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const following = await collections.ap_following
|
|
160
|
+
.find({})
|
|
161
|
+
.toArray();
|
|
162
|
+
for (const f of following) {
|
|
163
|
+
if (remoteActorId(f.actorUrl) === id) {
|
|
164
|
+
return res.json(
|
|
165
|
+
serializeAccount(
|
|
166
|
+
{ name: f.name, url: f.actorUrl, photo: f.avatar, handle: f.handle },
|
|
167
|
+
{ baseUrl },
|
|
168
|
+
),
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Try timeline authors — find any post whose author URL hashes to this ID
|
|
174
|
+
const timelineItems = await collections.ap_timeline
|
|
175
|
+
.find({ "author.url": { $exists: true } })
|
|
176
|
+
.project({ author: 1 })
|
|
177
|
+
.toArray();
|
|
178
|
+
|
|
179
|
+
const seenUrls = new Set();
|
|
180
|
+
for (const item of timelineItems) {
|
|
181
|
+
const authorUrl = item.author?.url;
|
|
182
|
+
if (!authorUrl || seenUrls.has(authorUrl)) continue;
|
|
183
|
+
seenUrls.add(authorUrl);
|
|
184
|
+
if (remoteActorId(authorUrl) === id) {
|
|
185
|
+
return res.json(
|
|
186
|
+
serializeAccount(item.author, { baseUrl }),
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return res.status(404).json({ error: "Record not found" });
|
|
192
|
+
} catch (error) {
|
|
193
|
+
next(error);
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// ─── GET /api/v1/accounts/:id/statuses ──────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
router.get("/api/v1/accounts/:id/statuses", async (req, res, next) => {
|
|
200
|
+
try {
|
|
201
|
+
const { id } = req.params;
|
|
202
|
+
const collections = req.app.locals.mastodonCollections;
|
|
203
|
+
const baseUrl = `${req.protocol}://${req.get("host")}`;
|
|
204
|
+
const limit = parseLimit(req.query.limit);
|
|
205
|
+
|
|
206
|
+
// Resolve account ID to an author URL
|
|
207
|
+
const actorUrl = await resolveActorUrl(id, collections);
|
|
208
|
+
if (!actorUrl) {
|
|
209
|
+
return res.status(404).json({ error: "Record not found" });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Build filter for this author's posts
|
|
213
|
+
const baseFilter = {
|
|
214
|
+
"author.url": actorUrl,
|
|
215
|
+
isContext: { $ne: true },
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
// Mastodon filters
|
|
219
|
+
if (req.query.only_media === "true") {
|
|
220
|
+
baseFilter.$or = [
|
|
221
|
+
{ "photo.0": { $exists: true } },
|
|
222
|
+
{ "video.0": { $exists: true } },
|
|
223
|
+
{ "audio.0": { $exists: true } },
|
|
224
|
+
];
|
|
225
|
+
}
|
|
226
|
+
if (req.query.exclude_replies === "true") {
|
|
227
|
+
baseFilter.inReplyTo = { $exists: false };
|
|
228
|
+
}
|
|
229
|
+
if (req.query.exclude_reblogs === "true") {
|
|
230
|
+
baseFilter.type = { $ne: "boost" };
|
|
231
|
+
}
|
|
232
|
+
if (req.query.pinned === "true") {
|
|
233
|
+
baseFilter.pinned = true;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const { filter, sort, reverse } = buildPaginationQuery(baseFilter, {
|
|
237
|
+
max_id: req.query.max_id,
|
|
238
|
+
min_id: req.query.min_id,
|
|
239
|
+
since_id: req.query.since_id,
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
let items = await collections.ap_timeline
|
|
243
|
+
.find(filter)
|
|
244
|
+
.sort(sort)
|
|
245
|
+
.limit(limit)
|
|
246
|
+
.toArray();
|
|
247
|
+
|
|
248
|
+
if (reverse) {
|
|
249
|
+
items.reverse();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Load interaction state if authenticated
|
|
253
|
+
let favouritedIds = new Set();
|
|
254
|
+
let rebloggedIds = new Set();
|
|
255
|
+
let bookmarkedIds = new Set();
|
|
256
|
+
|
|
257
|
+
if (req.mastodonToken && collections.ap_interactions) {
|
|
258
|
+
const lookupUrls = items.flatMap((i) => [i.uid, i.url].filter(Boolean));
|
|
259
|
+
if (lookupUrls.length > 0) {
|
|
260
|
+
const interactions = await collections.ap_interactions
|
|
261
|
+
.find({ objectUrl: { $in: lookupUrls } })
|
|
262
|
+
.toArray();
|
|
263
|
+
for (const ix of interactions) {
|
|
264
|
+
if (ix.type === "like") favouritedIds.add(ix.objectUrl);
|
|
265
|
+
else if (ix.type === "boost") rebloggedIds.add(ix.objectUrl);
|
|
266
|
+
else if (ix.type === "bookmark") bookmarkedIds.add(ix.objectUrl);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const statuses = items.map((item) =>
|
|
272
|
+
serializeStatus(item, {
|
|
273
|
+
baseUrl,
|
|
274
|
+
favouritedIds,
|
|
275
|
+
rebloggedIds,
|
|
276
|
+
bookmarkedIds,
|
|
277
|
+
pinnedIds: new Set(),
|
|
278
|
+
}),
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
setPaginationHeaders(res, req, items, limit);
|
|
282
|
+
res.json(statuses);
|
|
283
|
+
} catch (error) {
|
|
284
|
+
next(error);
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
// ─── GET /api/v1/accounts/:id/followers ─────────────────────────────────────
|
|
289
|
+
|
|
290
|
+
router.get("/api/v1/accounts/:id/followers", async (req, res, next) => {
|
|
291
|
+
try {
|
|
292
|
+
const { id } = req.params;
|
|
293
|
+
const collections = req.app.locals.mastodonCollections;
|
|
294
|
+
const baseUrl = `${req.protocol}://${req.get("host")}`;
|
|
295
|
+
const limit = parseLimit(req.query.limit);
|
|
296
|
+
const profile = await collections.ap_profile.findOne({});
|
|
297
|
+
|
|
298
|
+
// Only serve followers for the local account
|
|
299
|
+
if (!profile || profile._id.toString() !== id) {
|
|
300
|
+
return res.json([]);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const followers = await collections.ap_followers
|
|
304
|
+
.find({})
|
|
305
|
+
.limit(limit)
|
|
306
|
+
.toArray();
|
|
307
|
+
|
|
308
|
+
const accounts = followers.map((f) =>
|
|
309
|
+
serializeAccount(
|
|
310
|
+
{ name: f.name, url: f.actorUrl, photo: f.avatar, handle: f.handle },
|
|
311
|
+
{ baseUrl },
|
|
312
|
+
),
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
res.json(accounts);
|
|
316
|
+
} catch (error) {
|
|
317
|
+
next(error);
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// ─── GET /api/v1/accounts/:id/following ─────────────────────────────────────
|
|
322
|
+
|
|
323
|
+
router.get("/api/v1/accounts/:id/following", async (req, res, next) => {
|
|
324
|
+
try {
|
|
325
|
+
const { id } = req.params;
|
|
326
|
+
const collections = req.app.locals.mastodonCollections;
|
|
327
|
+
const baseUrl = `${req.protocol}://${req.get("host")}`;
|
|
328
|
+
const limit = parseLimit(req.query.limit);
|
|
329
|
+
const profile = await collections.ap_profile.findOne({});
|
|
330
|
+
|
|
331
|
+
// Only serve following for the local account
|
|
332
|
+
if (!profile || profile._id.toString() !== id) {
|
|
333
|
+
return res.json([]);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const following = await collections.ap_following
|
|
337
|
+
.find({})
|
|
338
|
+
.limit(limit)
|
|
339
|
+
.toArray();
|
|
340
|
+
|
|
341
|
+
const accounts = following.map((f) =>
|
|
342
|
+
serializeAccount(
|
|
343
|
+
{ name: f.name, url: f.actorUrl, photo: f.avatar, handle: f.handle },
|
|
344
|
+
{ baseUrl },
|
|
345
|
+
),
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
res.json(accounts);
|
|
349
|
+
} catch (error) {
|
|
350
|
+
next(error);
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
// ─── GET /api/v1/accounts/relationships ──────────────────────────────────────
|
|
355
|
+
|
|
356
|
+
router.get("/api/v1/accounts/relationships", async (req, res, next) => {
|
|
357
|
+
try {
|
|
358
|
+
// id[] can come as single value or array
|
|
359
|
+
let ids = req.query["id[]"] || req.query.id || [];
|
|
360
|
+
if (!Array.isArray(ids)) ids = [ids];
|
|
361
|
+
|
|
362
|
+
if (ids.length === 0) {
|
|
363
|
+
return res.json([]);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const collections = req.app.locals.mastodonCollections;
|
|
367
|
+
|
|
368
|
+
// Load all followers/following for efficient lookup
|
|
369
|
+
const [followers, following, blocked, muted] = await Promise.all([
|
|
370
|
+
collections.ap_followers.find({}).toArray(),
|
|
371
|
+
collections.ap_following.find({}).toArray(),
|
|
372
|
+
collections.ap_blocked.find({}).toArray(),
|
|
373
|
+
collections.ap_muted.find({}).toArray(),
|
|
374
|
+
]);
|
|
375
|
+
|
|
376
|
+
const followerIds = new Set(followers.map((f) => remoteActorId(f.actorUrl)));
|
|
377
|
+
const followingIds = new Set(following.map((f) => remoteActorId(f.actorUrl)));
|
|
378
|
+
const blockedIds = new Set(blocked.map((b) => remoteActorId(b.url)));
|
|
379
|
+
const mutedIds = new Set(muted.filter((m) => m.url).map((m) => remoteActorId(m.url)));
|
|
380
|
+
|
|
381
|
+
const relationships = ids.map((id) => ({
|
|
382
|
+
id,
|
|
383
|
+
following: followingIds.has(id),
|
|
384
|
+
showing_reblogs: followingIds.has(id),
|
|
385
|
+
notifying: false,
|
|
386
|
+
languages: [],
|
|
387
|
+
followed_by: followerIds.has(id),
|
|
388
|
+
blocking: blockedIds.has(id),
|
|
389
|
+
blocked_by: false,
|
|
390
|
+
muting: mutedIds.has(id),
|
|
391
|
+
muting_notifications: mutedIds.has(id),
|
|
392
|
+
requested: false,
|
|
393
|
+
requested_by: false,
|
|
394
|
+
domain_blocking: false,
|
|
395
|
+
endorsed: false,
|
|
396
|
+
note: "",
|
|
397
|
+
}));
|
|
398
|
+
|
|
399
|
+
res.json(relationships);
|
|
400
|
+
} catch (error) {
|
|
401
|
+
next(error);
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
// ─── GET /api/v1/accounts/familiar_followers ─────────────────────────────────
|
|
406
|
+
|
|
407
|
+
router.get("/api/v1/accounts/familiar_followers", (req, res) => {
|
|
408
|
+
// Stub — returns empty for each requested ID
|
|
409
|
+
let ids = req.query["id[]"] || req.query.id || [];
|
|
410
|
+
if (!Array.isArray(ids)) ids = [ids];
|
|
411
|
+
res.json(ids.map((id) => ({ id, accounts: [] })));
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
// ─── POST /api/v1/accounts/:id/follow ───────────────────────────────────────
|
|
415
|
+
|
|
416
|
+
router.post("/api/v1/accounts/:id/follow", async (req, res, next) => {
|
|
417
|
+
try {
|
|
418
|
+
const token = req.mastodonToken;
|
|
419
|
+
if (!token) {
|
|
420
|
+
return res.status(401).json({ error: "The access token is invalid" });
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const { id } = req.params;
|
|
424
|
+
const collections = req.app.locals.mastodonCollections;
|
|
425
|
+
const pluginOptions = req.app.locals.mastodonPluginOptions || {};
|
|
426
|
+
|
|
427
|
+
// Resolve the account ID to an actor URL
|
|
428
|
+
const actorUrl = await resolveActorUrl(id, collections);
|
|
429
|
+
if (!actorUrl) {
|
|
430
|
+
return res.status(404).json({ error: "Record not found" });
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Use the plugin's followActor method
|
|
434
|
+
if (pluginOptions.followActor) {
|
|
435
|
+
const result = await pluginOptions.followActor(actorUrl);
|
|
436
|
+
if (!result.ok) {
|
|
437
|
+
return res.status(422).json({ error: result.error || "Follow failed" });
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Return relationship
|
|
442
|
+
const followingIds = new Set();
|
|
443
|
+
const following = await collections.ap_following.find({}).toArray();
|
|
444
|
+
for (const f of following) {
|
|
445
|
+
followingIds.add(remoteActorId(f.actorUrl));
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const followerIds = new Set();
|
|
449
|
+
const followers = await collections.ap_followers.find({}).toArray();
|
|
450
|
+
for (const f of followers) {
|
|
451
|
+
followerIds.add(remoteActorId(f.actorUrl));
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
res.json({
|
|
455
|
+
id,
|
|
456
|
+
following: true,
|
|
457
|
+
showing_reblogs: true,
|
|
458
|
+
notifying: false,
|
|
459
|
+
languages: [],
|
|
460
|
+
followed_by: followerIds.has(id),
|
|
461
|
+
blocking: false,
|
|
462
|
+
blocked_by: false,
|
|
463
|
+
muting: false,
|
|
464
|
+
muting_notifications: false,
|
|
465
|
+
requested: false,
|
|
466
|
+
requested_by: false,
|
|
467
|
+
domain_blocking: false,
|
|
468
|
+
endorsed: false,
|
|
469
|
+
note: "",
|
|
470
|
+
});
|
|
471
|
+
} catch (error) {
|
|
472
|
+
next(error);
|
|
473
|
+
}
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
// ─── POST /api/v1/accounts/:id/unfollow ─────────────────────────────────────
|
|
477
|
+
|
|
478
|
+
router.post("/api/v1/accounts/:id/unfollow", async (req, res, next) => {
|
|
479
|
+
try {
|
|
480
|
+
const token = req.mastodonToken;
|
|
481
|
+
if (!token) {
|
|
482
|
+
return res.status(401).json({ error: "The access token is invalid" });
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const { id } = req.params;
|
|
486
|
+
const collections = req.app.locals.mastodonCollections;
|
|
487
|
+
const pluginOptions = req.app.locals.mastodonPluginOptions || {};
|
|
488
|
+
|
|
489
|
+
const actorUrl = await resolveActorUrl(id, collections);
|
|
490
|
+
if (!actorUrl) {
|
|
491
|
+
return res.status(404).json({ error: "Record not found" });
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
if (pluginOptions.unfollowActor) {
|
|
495
|
+
const result = await pluginOptions.unfollowActor(actorUrl);
|
|
496
|
+
if (!result.ok) {
|
|
497
|
+
return res.status(422).json({ error: result.error || "Unfollow failed" });
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const followerIds = new Set();
|
|
502
|
+
const followers = await collections.ap_followers.find({}).toArray();
|
|
503
|
+
for (const f of followers) {
|
|
504
|
+
followerIds.add(remoteActorId(f.actorUrl));
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
res.json({
|
|
508
|
+
id,
|
|
509
|
+
following: false,
|
|
510
|
+
showing_reblogs: true,
|
|
511
|
+
notifying: false,
|
|
512
|
+
languages: [],
|
|
513
|
+
followed_by: followerIds.has(id),
|
|
514
|
+
blocking: false,
|
|
515
|
+
blocked_by: false,
|
|
516
|
+
muting: false,
|
|
517
|
+
muting_notifications: false,
|
|
518
|
+
requested: false,
|
|
519
|
+
requested_by: false,
|
|
520
|
+
domain_blocking: false,
|
|
521
|
+
endorsed: false,
|
|
522
|
+
note: "",
|
|
523
|
+
});
|
|
524
|
+
} catch (error) {
|
|
525
|
+
next(error);
|
|
526
|
+
}
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
// ─── POST /api/v1/accounts/:id/mute ────────────────────────────────────────
|
|
530
|
+
|
|
531
|
+
router.post("/api/v1/accounts/:id/mute", async (req, res, next) => {
|
|
532
|
+
try {
|
|
533
|
+
const token = req.mastodonToken;
|
|
534
|
+
if (!token) {
|
|
535
|
+
return res.status(401).json({ error: "The access token is invalid" });
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const { id } = req.params;
|
|
539
|
+
const collections = req.app.locals.mastodonCollections;
|
|
540
|
+
|
|
541
|
+
const actorUrl = await resolveActorUrl(id, collections);
|
|
542
|
+
if (actorUrl && collections.ap_muted) {
|
|
543
|
+
await collections.ap_muted.updateOne(
|
|
544
|
+
{ url: actorUrl },
|
|
545
|
+
{ $set: { url: actorUrl, createdAt: new Date().toISOString() } },
|
|
546
|
+
{ upsert: true },
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
res.json({
|
|
551
|
+
id,
|
|
552
|
+
following: false,
|
|
553
|
+
showing_reblogs: true,
|
|
554
|
+
notifying: false,
|
|
555
|
+
languages: [],
|
|
556
|
+
followed_by: false,
|
|
557
|
+
blocking: false,
|
|
558
|
+
blocked_by: false,
|
|
559
|
+
muting: true,
|
|
560
|
+
muting_notifications: true,
|
|
561
|
+
requested: false,
|
|
562
|
+
requested_by: false,
|
|
563
|
+
domain_blocking: false,
|
|
564
|
+
endorsed: false,
|
|
565
|
+
note: "",
|
|
566
|
+
});
|
|
567
|
+
} catch (error) {
|
|
568
|
+
next(error);
|
|
569
|
+
}
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
// ─── POST /api/v1/accounts/:id/unmute ───────────────────────────────────────
|
|
573
|
+
|
|
574
|
+
router.post("/api/v1/accounts/:id/unmute", async (req, res, next) => {
|
|
575
|
+
try {
|
|
576
|
+
const token = req.mastodonToken;
|
|
577
|
+
if (!token) {
|
|
578
|
+
return res.status(401).json({ error: "The access token is invalid" });
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const { id } = req.params;
|
|
582
|
+
const collections = req.app.locals.mastodonCollections;
|
|
583
|
+
|
|
584
|
+
const actorUrl = await resolveActorUrl(id, collections);
|
|
585
|
+
if (actorUrl && collections.ap_muted) {
|
|
586
|
+
await collections.ap_muted.deleteOne({ url: actorUrl });
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
res.json({
|
|
590
|
+
id,
|
|
591
|
+
following: false,
|
|
592
|
+
showing_reblogs: true,
|
|
593
|
+
notifying: false,
|
|
594
|
+
languages: [],
|
|
595
|
+
followed_by: false,
|
|
596
|
+
blocking: false,
|
|
597
|
+
blocked_by: false,
|
|
598
|
+
muting: false,
|
|
599
|
+
muting_notifications: false,
|
|
600
|
+
requested: false,
|
|
601
|
+
requested_by: false,
|
|
602
|
+
domain_blocking: false,
|
|
603
|
+
endorsed: false,
|
|
604
|
+
note: "",
|
|
605
|
+
});
|
|
606
|
+
} catch (error) {
|
|
607
|
+
next(error);
|
|
608
|
+
}
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
// ─── POST /api/v1/accounts/:id/block ───────────────────────────────────────
|
|
612
|
+
|
|
613
|
+
router.post("/api/v1/accounts/:id/block", async (req, res, next) => {
|
|
614
|
+
try {
|
|
615
|
+
const token = req.mastodonToken;
|
|
616
|
+
if (!token) {
|
|
617
|
+
return res.status(401).json({ error: "The access token is invalid" });
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const { id } = req.params;
|
|
621
|
+
const collections = req.app.locals.mastodonCollections;
|
|
622
|
+
|
|
623
|
+
const actorUrl = await resolveActorUrl(id, collections);
|
|
624
|
+
if (actorUrl && collections.ap_blocked) {
|
|
625
|
+
await collections.ap_blocked.updateOne(
|
|
626
|
+
{ url: actorUrl },
|
|
627
|
+
{ $set: { url: actorUrl, createdAt: new Date().toISOString() } },
|
|
628
|
+
{ upsert: true },
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
res.json({
|
|
633
|
+
id,
|
|
634
|
+
following: false,
|
|
635
|
+
showing_reblogs: true,
|
|
636
|
+
notifying: false,
|
|
637
|
+
languages: [],
|
|
638
|
+
followed_by: false,
|
|
639
|
+
blocking: true,
|
|
640
|
+
blocked_by: false,
|
|
641
|
+
muting: false,
|
|
642
|
+
muting_notifications: false,
|
|
643
|
+
requested: false,
|
|
644
|
+
requested_by: false,
|
|
645
|
+
domain_blocking: false,
|
|
646
|
+
endorsed: false,
|
|
647
|
+
note: "",
|
|
648
|
+
});
|
|
649
|
+
} catch (error) {
|
|
650
|
+
next(error);
|
|
651
|
+
}
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
// ─── POST /api/v1/accounts/:id/unblock ──────────────────────────────────────
|
|
655
|
+
|
|
656
|
+
router.post("/api/v1/accounts/:id/unblock", async (req, res, next) => {
|
|
657
|
+
try {
|
|
658
|
+
const token = req.mastodonToken;
|
|
659
|
+
if (!token) {
|
|
660
|
+
return res.status(401).json({ error: "The access token is invalid" });
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const { id } = req.params;
|
|
664
|
+
const collections = req.app.locals.mastodonCollections;
|
|
665
|
+
|
|
666
|
+
const actorUrl = await resolveActorUrl(id, collections);
|
|
667
|
+
if (actorUrl && collections.ap_blocked) {
|
|
668
|
+
await collections.ap_blocked.deleteOne({ url: actorUrl });
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
res.json({
|
|
672
|
+
id,
|
|
673
|
+
following: false,
|
|
674
|
+
showing_reblogs: true,
|
|
675
|
+
notifying: false,
|
|
676
|
+
languages: [],
|
|
677
|
+
followed_by: false,
|
|
678
|
+
blocking: false,
|
|
679
|
+
blocked_by: false,
|
|
680
|
+
muting: false,
|
|
681
|
+
muting_notifications: false,
|
|
682
|
+
requested: false,
|
|
683
|
+
requested_by: false,
|
|
684
|
+
domain_blocking: false,
|
|
685
|
+
endorsed: false,
|
|
686
|
+
note: "",
|
|
687
|
+
});
|
|
688
|
+
} catch (error) {
|
|
689
|
+
next(error);
|
|
690
|
+
}
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* Resolve an account ID back to an actor URL by scanning followers/following.
|
|
697
|
+
*/
|
|
698
|
+
async function resolveActorUrl(id, collections) {
|
|
699
|
+
// Check if it's the local profile
|
|
700
|
+
const profile = await collections.ap_profile.findOne({});
|
|
701
|
+
if (profile && profile._id.toString() === id) {
|
|
702
|
+
return profile.url;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// Check followers
|
|
706
|
+
const followers = await collections.ap_followers.find({}).toArray();
|
|
707
|
+
for (const f of followers) {
|
|
708
|
+
if (remoteActorId(f.actorUrl) === id) {
|
|
709
|
+
return f.actorUrl;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// Check following
|
|
714
|
+
const following = await collections.ap_following.find({}).toArray();
|
|
715
|
+
for (const f of following) {
|
|
716
|
+
if (remoteActorId(f.actorUrl) === id) {
|
|
717
|
+
return f.actorUrl;
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// Check timeline authors
|
|
722
|
+
const timelineItems = await collections.ap_timeline
|
|
723
|
+
.find({ "author.url": { $exists: true } })
|
|
724
|
+
.project({ "author.url": 1 })
|
|
725
|
+
.toArray();
|
|
726
|
+
|
|
727
|
+
const seenUrls = new Set();
|
|
728
|
+
for (const item of timelineItems) {
|
|
729
|
+
const authorUrl = item.author?.url;
|
|
730
|
+
if (!authorUrl || seenUrls.has(authorUrl)) continue;
|
|
731
|
+
seenUrls.add(authorUrl);
|
|
732
|
+
if (remoteActorId(authorUrl) === id) {
|
|
733
|
+
return authorUrl;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
return null;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
export default router;
|