@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,545 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth2 routes for Mastodon Client API.
|
|
3
|
+
*
|
|
4
|
+
* Handles app registration, authorization, token exchange, and revocation.
|
|
5
|
+
*/
|
|
6
|
+
import crypto from "node:crypto";
|
|
7
|
+
import express from "express";
|
|
8
|
+
|
|
9
|
+
const router = express.Router(); // eslint-disable-line new-cap
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Generate cryptographically random hex string.
|
|
13
|
+
* @param {number} bytes - Number of random bytes
|
|
14
|
+
* @returns {string} Hex-encoded random string
|
|
15
|
+
*/
|
|
16
|
+
function randomHex(bytes) {
|
|
17
|
+
return crypto.randomBytes(bytes).toString("hex");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Parse redirect_uris from request — accepts space-separated string or array.
|
|
22
|
+
* @param {string|string[]} value
|
|
23
|
+
* @returns {string[]}
|
|
24
|
+
*/
|
|
25
|
+
function parseRedirectUris(value) {
|
|
26
|
+
if (!value) return ["urn:ietf:wg:oauth:2.0:oob"];
|
|
27
|
+
if (Array.isArray(value)) return value.map((v) => v.trim());
|
|
28
|
+
return value
|
|
29
|
+
.trim()
|
|
30
|
+
.split(/\s+/)
|
|
31
|
+
.filter(Boolean);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Parse scopes from request — accepts space-separated string.
|
|
36
|
+
* @param {string} value
|
|
37
|
+
* @returns {string[]}
|
|
38
|
+
*/
|
|
39
|
+
function parseScopes(value) {
|
|
40
|
+
if (!value) return ["read"];
|
|
41
|
+
return value
|
|
42
|
+
.trim()
|
|
43
|
+
.split(/\s+/)
|
|
44
|
+
.filter(Boolean);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ─── POST /api/v1/apps — Register client application ────────────────────────
|
|
48
|
+
|
|
49
|
+
router.post("/api/v1/apps", async (req, res, next) => {
|
|
50
|
+
try {
|
|
51
|
+
const { client_name, redirect_uris, scopes, website } = req.body;
|
|
52
|
+
|
|
53
|
+
const clientId = randomHex(16);
|
|
54
|
+
const clientSecret = randomHex(32);
|
|
55
|
+
const redirectUris = parseRedirectUris(redirect_uris);
|
|
56
|
+
const parsedScopes = parseScopes(scopes);
|
|
57
|
+
|
|
58
|
+
const doc = {
|
|
59
|
+
clientId,
|
|
60
|
+
clientSecret,
|
|
61
|
+
name: client_name || "",
|
|
62
|
+
redirectUris,
|
|
63
|
+
scopes: parsedScopes,
|
|
64
|
+
website: website || null,
|
|
65
|
+
confidential: true,
|
|
66
|
+
createdAt: new Date(),
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const collections = req.app.locals.mastodonCollections;
|
|
70
|
+
await collections.ap_oauth_apps.insertOne(doc);
|
|
71
|
+
|
|
72
|
+
res.json({
|
|
73
|
+
id: doc._id?.toString() || clientId,
|
|
74
|
+
name: doc.name,
|
|
75
|
+
website: doc.website,
|
|
76
|
+
redirect_uris: redirectUris,
|
|
77
|
+
redirect_uri: redirectUris.join(" "),
|
|
78
|
+
client_id: clientId,
|
|
79
|
+
client_secret: clientSecret,
|
|
80
|
+
client_secret_expires_at: 0,
|
|
81
|
+
vapid_key: "",
|
|
82
|
+
});
|
|
83
|
+
} catch (error) {
|
|
84
|
+
next(error);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// ─── GET /api/v1/apps/verify_credentials ─────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
router.get("/api/v1/apps/verify_credentials", async (req, res, next) => {
|
|
91
|
+
try {
|
|
92
|
+
const token = req.mastodonToken;
|
|
93
|
+
if (!token) {
|
|
94
|
+
return res.status(401).json({ error: "The access token is invalid" });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const collections = req.app.locals.mastodonCollections;
|
|
98
|
+
const app = await collections.ap_oauth_apps.findOne({
|
|
99
|
+
clientId: token.clientId,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
if (!app) {
|
|
103
|
+
return res.status(404).json({ error: "Application not found" });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
res.json({
|
|
107
|
+
id: app._id.toString(),
|
|
108
|
+
name: app.name,
|
|
109
|
+
website: app.website,
|
|
110
|
+
scopes: app.scopes,
|
|
111
|
+
redirect_uris: app.redirectUris,
|
|
112
|
+
redirect_uri: app.redirectUris.join(" "),
|
|
113
|
+
});
|
|
114
|
+
} catch (error) {
|
|
115
|
+
next(error);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// ─── GET /.well-known/oauth-authorization-server ─────────────────────────────
|
|
120
|
+
|
|
121
|
+
router.get("/.well-known/oauth-authorization-server", (req, res) => {
|
|
122
|
+
const baseUrl = `${req.protocol}://${req.get("host")}`;
|
|
123
|
+
|
|
124
|
+
res.json({
|
|
125
|
+
issuer: baseUrl,
|
|
126
|
+
authorization_endpoint: `${baseUrl}/oauth/authorize`,
|
|
127
|
+
token_endpoint: `${baseUrl}/oauth/token`,
|
|
128
|
+
revocation_endpoint: `${baseUrl}/oauth/revoke`,
|
|
129
|
+
scopes_supported: [
|
|
130
|
+
"read",
|
|
131
|
+
"write",
|
|
132
|
+
"follow",
|
|
133
|
+
"push",
|
|
134
|
+
"profile",
|
|
135
|
+
"read:accounts",
|
|
136
|
+
"read:blocks",
|
|
137
|
+
"read:bookmarks",
|
|
138
|
+
"read:favourites",
|
|
139
|
+
"read:filters",
|
|
140
|
+
"read:follows",
|
|
141
|
+
"read:lists",
|
|
142
|
+
"read:mutes",
|
|
143
|
+
"read:notifications",
|
|
144
|
+
"read:search",
|
|
145
|
+
"read:statuses",
|
|
146
|
+
"write:accounts",
|
|
147
|
+
"write:blocks",
|
|
148
|
+
"write:bookmarks",
|
|
149
|
+
"write:conversations",
|
|
150
|
+
"write:favourites",
|
|
151
|
+
"write:filters",
|
|
152
|
+
"write:follows",
|
|
153
|
+
"write:lists",
|
|
154
|
+
"write:media",
|
|
155
|
+
"write:mutes",
|
|
156
|
+
"write:notifications",
|
|
157
|
+
"write:reports",
|
|
158
|
+
"write:statuses",
|
|
159
|
+
],
|
|
160
|
+
response_types_supported: ["code"],
|
|
161
|
+
grant_types_supported: ["authorization_code", "client_credentials"],
|
|
162
|
+
token_endpoint_auth_methods_supported: [
|
|
163
|
+
"client_secret_basic",
|
|
164
|
+
"client_secret_post",
|
|
165
|
+
"none",
|
|
166
|
+
],
|
|
167
|
+
code_challenge_methods_supported: ["S256"],
|
|
168
|
+
service_documentation: "https://docs.joinmastodon.org/api/",
|
|
169
|
+
app_registration_endpoint: `${baseUrl}/api/v1/apps`,
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// ─── GET /oauth/authorize — Show authorization page ──────────────────────────
|
|
174
|
+
|
|
175
|
+
router.get("/oauth/authorize", async (req, res, next) => {
|
|
176
|
+
try {
|
|
177
|
+
const {
|
|
178
|
+
client_id,
|
|
179
|
+
redirect_uri,
|
|
180
|
+
response_type,
|
|
181
|
+
scope,
|
|
182
|
+
code_challenge,
|
|
183
|
+
code_challenge_method,
|
|
184
|
+
force_login,
|
|
185
|
+
} = req.query;
|
|
186
|
+
|
|
187
|
+
if (response_type !== "code") {
|
|
188
|
+
return res.status(400).json({
|
|
189
|
+
error: "unsupported_response_type",
|
|
190
|
+
error_description: "Only response_type=code is supported",
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const collections = req.app.locals.mastodonCollections;
|
|
195
|
+
const app = await collections.ap_oauth_apps.findOne({ clientId: client_id });
|
|
196
|
+
|
|
197
|
+
if (!app) {
|
|
198
|
+
return res.status(400).json({
|
|
199
|
+
error: "invalid_client",
|
|
200
|
+
error_description: "Client application not found",
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Determine redirect URI — use provided or default to first registered
|
|
205
|
+
const resolvedRedirectUri =
|
|
206
|
+
redirect_uri || app.redirectUris[0] || "urn:ietf:wg:oauth:2.0:oob";
|
|
207
|
+
|
|
208
|
+
// Validate redirect_uri is registered
|
|
209
|
+
if (!app.redirectUris.includes(resolvedRedirectUri)) {
|
|
210
|
+
return res.status(400).json({
|
|
211
|
+
error: "invalid_redirect_uri",
|
|
212
|
+
error_description: "Redirect URI not registered for this application",
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Validate requested scopes are subset of app scopes
|
|
217
|
+
const requestedScopes = scope ? scope.split(/\s+/) : app.scopes;
|
|
218
|
+
|
|
219
|
+
// Check if user is logged in via IndieAuth session
|
|
220
|
+
const session = req.session;
|
|
221
|
+
if (!session?.access_token && !force_login) {
|
|
222
|
+
// Not logged in — redirect to Indiekit login, then back here
|
|
223
|
+
const returnUrl = `${req.protocol}://${req.get("host")}${req.originalUrl}`;
|
|
224
|
+
return res.redirect(
|
|
225
|
+
`/auth?redirect=${encodeURIComponent(returnUrl)}`,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Render simple authorization page
|
|
230
|
+
const appName = app.name || "An application";
|
|
231
|
+
res.type("html").send(`<!DOCTYPE html>
|
|
232
|
+
<html lang="en">
|
|
233
|
+
<head>
|
|
234
|
+
<meta charset="utf-8">
|
|
235
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
236
|
+
<title>Authorize ${appName}</title>
|
|
237
|
+
<style>
|
|
238
|
+
body { font-family: system-ui, sans-serif; max-width: 480px; margin: 2rem auto; padding: 0 1rem; }
|
|
239
|
+
h1 { font-size: 1.4rem; }
|
|
240
|
+
.scopes { background: #f5f5f5; padding: 0.75rem 1rem; border-radius: 6px; margin: 1rem 0; }
|
|
241
|
+
.scopes code { display: block; margin: 0.25rem 0; }
|
|
242
|
+
.actions { display: flex; gap: 0.75rem; margin-top: 1.5rem; }
|
|
243
|
+
button { padding: 0.6rem 1.5rem; border-radius: 6px; font-size: 1rem; cursor: pointer; border: 1px solid #ccc; }
|
|
244
|
+
.approve { background: #2b90d9; color: white; border-color: #2b90d9; }
|
|
245
|
+
.deny { background: white; }
|
|
246
|
+
</style>
|
|
247
|
+
</head>
|
|
248
|
+
<body>
|
|
249
|
+
<h1>Authorize ${appName}</h1>
|
|
250
|
+
<p>${appName} wants to access your account with these permissions:</p>
|
|
251
|
+
<div class="scopes">
|
|
252
|
+
${requestedScopes.map((s) => `<code>${s}</code>`).join("")}
|
|
253
|
+
</div>
|
|
254
|
+
<form method="POST" action="/oauth/authorize">
|
|
255
|
+
<input type="hidden" name="client_id" value="${client_id}">
|
|
256
|
+
<input type="hidden" name="redirect_uri" value="${resolvedRedirectUri}">
|
|
257
|
+
<input type="hidden" name="scope" value="${requestedScopes.join(" ")}">
|
|
258
|
+
<input type="hidden" name="code_challenge" value="${code_challenge || ""}">
|
|
259
|
+
<input type="hidden" name="code_challenge_method" value="${code_challenge_method || ""}">
|
|
260
|
+
<input type="hidden" name="response_type" value="code">
|
|
261
|
+
<div class="actions">
|
|
262
|
+
<button type="submit" name="decision" value="approve" class="approve">Authorize</button>
|
|
263
|
+
<button type="submit" name="decision" value="deny" class="deny">Deny</button>
|
|
264
|
+
</div>
|
|
265
|
+
</form>
|
|
266
|
+
</body>
|
|
267
|
+
</html>`);
|
|
268
|
+
} catch (error) {
|
|
269
|
+
next(error);
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// ─── POST /oauth/authorize — Process authorization decision ──────────────────
|
|
274
|
+
|
|
275
|
+
router.post("/oauth/authorize", async (req, res, next) => {
|
|
276
|
+
try {
|
|
277
|
+
const {
|
|
278
|
+
client_id,
|
|
279
|
+
redirect_uri,
|
|
280
|
+
scope,
|
|
281
|
+
code_challenge,
|
|
282
|
+
code_challenge_method,
|
|
283
|
+
decision,
|
|
284
|
+
} = req.body;
|
|
285
|
+
|
|
286
|
+
// User denied
|
|
287
|
+
if (decision === "deny") {
|
|
288
|
+
if (redirect_uri && redirect_uri !== "urn:ietf:wg:oauth:2.0:oob") {
|
|
289
|
+
const url = new URL(redirect_uri);
|
|
290
|
+
url.searchParams.set("error", "access_denied");
|
|
291
|
+
url.searchParams.set(
|
|
292
|
+
"error_description",
|
|
293
|
+
"The resource owner denied the request",
|
|
294
|
+
);
|
|
295
|
+
return res.redirect(url.toString());
|
|
296
|
+
}
|
|
297
|
+
return res.status(403).json({
|
|
298
|
+
error: "access_denied",
|
|
299
|
+
error_description: "The resource owner denied the request",
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Generate authorization code
|
|
304
|
+
const code = randomHex(32);
|
|
305
|
+
const collections = req.app.locals.mastodonCollections;
|
|
306
|
+
|
|
307
|
+
await collections.ap_oauth_tokens.insertOne({
|
|
308
|
+
code,
|
|
309
|
+
clientId: client_id,
|
|
310
|
+
scopes: scope ? scope.split(/\s+/) : ["read"],
|
|
311
|
+
redirectUri: redirect_uri,
|
|
312
|
+
codeChallenge: code_challenge || null,
|
|
313
|
+
codeChallengeMethod: code_challenge_method || null,
|
|
314
|
+
accessToken: null,
|
|
315
|
+
createdAt: new Date(),
|
|
316
|
+
expiresAt: new Date(Date.now() + 10 * 60 * 1000), // 10 minutes
|
|
317
|
+
usedAt: null,
|
|
318
|
+
revokedAt: null,
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// Out-of-band: show code on page
|
|
322
|
+
if (!redirect_uri || redirect_uri === "urn:ietf:wg:oauth:2.0:oob") {
|
|
323
|
+
return res.type("html").send(`<!DOCTYPE html>
|
|
324
|
+
<html lang="en">
|
|
325
|
+
<head>
|
|
326
|
+
<meta charset="utf-8">
|
|
327
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
328
|
+
<title>Authorization Code</title>
|
|
329
|
+
<style>
|
|
330
|
+
body { font-family: system-ui, sans-serif; max-width: 480px; margin: 2rem auto; padding: 0 1rem; }
|
|
331
|
+
code { display: block; background: #f5f5f5; padding: 1rem; border-radius: 6px; word-break: break-all; margin: 1rem 0; }
|
|
332
|
+
</style>
|
|
333
|
+
</head>
|
|
334
|
+
<body>
|
|
335
|
+
<h1>Authorization Code</h1>
|
|
336
|
+
<p>Copy this code and paste it into the application:</p>
|
|
337
|
+
<code>${code}</code>
|
|
338
|
+
</body>
|
|
339
|
+
</html>`);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Redirect with code
|
|
343
|
+
const url = new URL(redirect_uri);
|
|
344
|
+
url.searchParams.set("code", code);
|
|
345
|
+
res.redirect(url.toString());
|
|
346
|
+
} catch (error) {
|
|
347
|
+
next(error);
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
// ─── POST /oauth/token — Exchange code for access token ──────────────────────
|
|
352
|
+
|
|
353
|
+
router.post("/oauth/token", async (req, res, next) => {
|
|
354
|
+
try {
|
|
355
|
+
const { grant_type, code, redirect_uri, code_verifier } = req.body;
|
|
356
|
+
|
|
357
|
+
// Extract client credentials from request (3 methods)
|
|
358
|
+
const { clientId, clientSecret } = extractClientCredentials(req);
|
|
359
|
+
|
|
360
|
+
const collections = req.app.locals.mastodonCollections;
|
|
361
|
+
|
|
362
|
+
if (grant_type === "client_credentials") {
|
|
363
|
+
// Client credentials grant — limited access for pre-login API calls
|
|
364
|
+
if (!clientId || !clientSecret) {
|
|
365
|
+
return res.status(401).json({
|
|
366
|
+
error: "invalid_client",
|
|
367
|
+
error_description: "Client authentication required",
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const app = await collections.ap_oauth_apps.findOne({
|
|
372
|
+
clientId,
|
|
373
|
+
clientSecret,
|
|
374
|
+
confidential: true,
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
if (!app) {
|
|
378
|
+
return res.status(401).json({
|
|
379
|
+
error: "invalid_client",
|
|
380
|
+
error_description: "Invalid client credentials",
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const accessToken = randomHex(64);
|
|
385
|
+
await collections.ap_oauth_tokens.insertOne({
|
|
386
|
+
code: null,
|
|
387
|
+
clientId,
|
|
388
|
+
scopes: ["read"],
|
|
389
|
+
redirectUri: null,
|
|
390
|
+
codeChallenge: null,
|
|
391
|
+
codeChallengeMethod: null,
|
|
392
|
+
accessToken,
|
|
393
|
+
createdAt: new Date(),
|
|
394
|
+
expiresAt: null,
|
|
395
|
+
usedAt: null,
|
|
396
|
+
revokedAt: null,
|
|
397
|
+
grantType: "client_credentials",
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
return res.json({
|
|
401
|
+
access_token: accessToken,
|
|
402
|
+
token_type: "Bearer",
|
|
403
|
+
scope: "read",
|
|
404
|
+
created_at: Math.floor(Date.now() / 1000),
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (grant_type !== "authorization_code") {
|
|
409
|
+
return res.status(400).json({
|
|
410
|
+
error: "unsupported_grant_type",
|
|
411
|
+
error_description: "Only authorization_code and client_credentials are supported",
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (!code) {
|
|
416
|
+
return res.status(400).json({
|
|
417
|
+
error: "invalid_request",
|
|
418
|
+
error_description: "Missing authorization code",
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Atomic claim-or-fail: find the code and mark it used in one operation
|
|
423
|
+
const grant = await collections.ap_oauth_tokens.findOneAndUpdate(
|
|
424
|
+
{
|
|
425
|
+
code,
|
|
426
|
+
usedAt: null,
|
|
427
|
+
revokedAt: null,
|
|
428
|
+
expiresAt: { $gt: new Date() },
|
|
429
|
+
},
|
|
430
|
+
{ $set: { usedAt: new Date() } },
|
|
431
|
+
{ returnDocument: "before" },
|
|
432
|
+
);
|
|
433
|
+
|
|
434
|
+
if (!grant) {
|
|
435
|
+
return res.status(400).json({
|
|
436
|
+
error: "invalid_grant",
|
|
437
|
+
error_description:
|
|
438
|
+
"Authorization code is invalid, expired, or already used",
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Validate redirect_uri matches
|
|
443
|
+
if (redirect_uri && grant.redirectUri && redirect_uri !== grant.redirectUri) {
|
|
444
|
+
return res.status(400).json({
|
|
445
|
+
error: "invalid_grant",
|
|
446
|
+
error_description: "Redirect URI mismatch",
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Verify PKCE code_verifier if code_challenge was stored
|
|
451
|
+
if (grant.codeChallenge) {
|
|
452
|
+
if (!code_verifier) {
|
|
453
|
+
return res.status(400).json({
|
|
454
|
+
error: "invalid_grant",
|
|
455
|
+
error_description: "Missing code_verifier for PKCE",
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const expectedChallenge = crypto
|
|
460
|
+
.createHash("sha256")
|
|
461
|
+
.update(code_verifier)
|
|
462
|
+
.digest("base64url");
|
|
463
|
+
|
|
464
|
+
if (expectedChallenge !== grant.codeChallenge) {
|
|
465
|
+
return res.status(400).json({
|
|
466
|
+
error: "invalid_grant",
|
|
467
|
+
error_description: "Invalid code_verifier",
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Generate access token
|
|
473
|
+
const accessToken = randomHex(64);
|
|
474
|
+
await collections.ap_oauth_tokens.updateOne(
|
|
475
|
+
{ _id: grant._id },
|
|
476
|
+
{ $set: { accessToken } },
|
|
477
|
+
);
|
|
478
|
+
|
|
479
|
+
res.json({
|
|
480
|
+
access_token: accessToken,
|
|
481
|
+
token_type: "Bearer",
|
|
482
|
+
scope: grant.scopes.join(" "),
|
|
483
|
+
created_at: Math.floor(grant.createdAt.getTime() / 1000),
|
|
484
|
+
});
|
|
485
|
+
} catch (error) {
|
|
486
|
+
next(error);
|
|
487
|
+
}
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
// ─── POST /oauth/revoke — Revoke a token ────────────────────────────────────
|
|
491
|
+
|
|
492
|
+
router.post("/oauth/revoke", async (req, res, next) => {
|
|
493
|
+
try {
|
|
494
|
+
const { token } = req.body;
|
|
495
|
+
|
|
496
|
+
if (!token) {
|
|
497
|
+
return res.status(400).json({
|
|
498
|
+
error: "invalid_request",
|
|
499
|
+
error_description: "Missing token parameter",
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const collections = req.app.locals.mastodonCollections;
|
|
504
|
+
await collections.ap_oauth_tokens.updateOne(
|
|
505
|
+
{ accessToken: token },
|
|
506
|
+
{ $set: { revokedAt: new Date() } },
|
|
507
|
+
);
|
|
508
|
+
|
|
509
|
+
// RFC 7009: always return 200 even if token wasn't found
|
|
510
|
+
res.json({});
|
|
511
|
+
} catch (error) {
|
|
512
|
+
next(error);
|
|
513
|
+
}
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Extract client credentials from request using 3 methods:
|
|
520
|
+
* 1. HTTP Basic Auth (client_secret_basic)
|
|
521
|
+
* 2. POST body (client_secret_post)
|
|
522
|
+
* 3. client_id only (none — public clients)
|
|
523
|
+
*/
|
|
524
|
+
function extractClientCredentials(req) {
|
|
525
|
+
// Method 1: HTTP Basic Auth
|
|
526
|
+
const authHeader = req.get("authorization");
|
|
527
|
+
if (authHeader?.startsWith("Basic ")) {
|
|
528
|
+
const decoded = Buffer.from(authHeader.slice(6), "base64").toString();
|
|
529
|
+
const colonIndex = decoded.indexOf(":");
|
|
530
|
+
if (colonIndex > 0) {
|
|
531
|
+
return {
|
|
532
|
+
clientId: decoded.slice(0, colonIndex),
|
|
533
|
+
clientSecret: decoded.slice(colonIndex + 1),
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// Method 2 & 3: POST body
|
|
539
|
+
return {
|
|
540
|
+
clientId: req.body.client_id || null,
|
|
541
|
+
clientSecret: req.body.client_secret || null,
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
export default router;
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Search endpoint for Mastodon Client API.
|
|
3
|
+
*
|
|
4
|
+
* GET /api/v2/search — search accounts, statuses, and hashtags
|
|
5
|
+
*/
|
|
6
|
+
import express from "express";
|
|
7
|
+
import { serializeStatus } from "../entities/status.js";
|
|
8
|
+
import { serializeAccount } from "../entities/account.js";
|
|
9
|
+
import { parseLimit } from "../helpers/pagination.js";
|
|
10
|
+
|
|
11
|
+
const router = express.Router(); // eslint-disable-line new-cap
|
|
12
|
+
|
|
13
|
+
// ─── GET /api/v2/search ─────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
router.get("/api/v2/search", async (req, res, next) => {
|
|
16
|
+
try {
|
|
17
|
+
const collections = req.app.locals.mastodonCollections;
|
|
18
|
+
const baseUrl = `${req.protocol}://${req.get("host")}`;
|
|
19
|
+
const query = (req.query.q || "").trim();
|
|
20
|
+
const type = req.query.type; // "accounts", "statuses", "hashtags", or undefined (all)
|
|
21
|
+
const limit = parseLimit(req.query.limit);
|
|
22
|
+
const offset = Math.max(0, Number.parseInt(req.query.offset, 10) || 0);
|
|
23
|
+
|
|
24
|
+
if (!query) {
|
|
25
|
+
return res.json({ accounts: [], statuses: [], hashtags: [] });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const results = { accounts: [], statuses: [], hashtags: [] };
|
|
29
|
+
|
|
30
|
+
// ─── Account search ──────────────────────────────────────────────────
|
|
31
|
+
if (!type || type === "accounts") {
|
|
32
|
+
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
33
|
+
const nameRegex = new RegExp(escapedQuery, "i");
|
|
34
|
+
|
|
35
|
+
// Search followers and following by display name or handle
|
|
36
|
+
const accountDocs = [];
|
|
37
|
+
|
|
38
|
+
if (collections.ap_followers) {
|
|
39
|
+
const followers = await collections.ap_followers
|
|
40
|
+
.find({
|
|
41
|
+
$or: [
|
|
42
|
+
{ name: nameRegex },
|
|
43
|
+
{ preferredUsername: nameRegex },
|
|
44
|
+
{ url: nameRegex },
|
|
45
|
+
],
|
|
46
|
+
})
|
|
47
|
+
.limit(limit)
|
|
48
|
+
.toArray();
|
|
49
|
+
accountDocs.push(...followers);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (collections.ap_following) {
|
|
53
|
+
const following = await collections.ap_following
|
|
54
|
+
.find({
|
|
55
|
+
$or: [
|
|
56
|
+
{ name: nameRegex },
|
|
57
|
+
{ preferredUsername: nameRegex },
|
|
58
|
+
{ url: nameRegex },
|
|
59
|
+
],
|
|
60
|
+
})
|
|
61
|
+
.limit(limit)
|
|
62
|
+
.toArray();
|
|
63
|
+
accountDocs.push(...following);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Deduplicate by URL
|
|
67
|
+
const seen = new Set();
|
|
68
|
+
for (const doc of accountDocs) {
|
|
69
|
+
const url = doc.url || doc.id;
|
|
70
|
+
if (url && !seen.has(url)) {
|
|
71
|
+
seen.add(url);
|
|
72
|
+
results.accounts.push(
|
|
73
|
+
serializeAccount(doc, { baseUrl, isRemote: true }),
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
if (results.accounts.length >= limit) break;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ─── Status search ───────────────────────────────────────────────────
|
|
81
|
+
if (!type || type === "statuses") {
|
|
82
|
+
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
83
|
+
const contentRegex = new RegExp(escapedQuery, "i");
|
|
84
|
+
|
|
85
|
+
const items = await collections.ap_timeline
|
|
86
|
+
.find({
|
|
87
|
+
isContext: { $ne: true },
|
|
88
|
+
$or: [
|
|
89
|
+
{ "content.text": contentRegex },
|
|
90
|
+
{ "content.html": contentRegex },
|
|
91
|
+
],
|
|
92
|
+
})
|
|
93
|
+
.sort({ _id: -1 })
|
|
94
|
+
.skip(offset)
|
|
95
|
+
.limit(limit)
|
|
96
|
+
.toArray();
|
|
97
|
+
|
|
98
|
+
results.statuses = items.map((item) =>
|
|
99
|
+
serializeStatus(item, {
|
|
100
|
+
baseUrl,
|
|
101
|
+
favouritedIds: new Set(),
|
|
102
|
+
rebloggedIds: new Set(),
|
|
103
|
+
bookmarkedIds: new Set(),
|
|
104
|
+
pinnedIds: new Set(),
|
|
105
|
+
}),
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ─── Hashtag search ──────────────────────────────────────────────────
|
|
110
|
+
if (!type || type === "hashtags") {
|
|
111
|
+
const escapedQuery = query
|
|
112
|
+
.replace(/^#/, "")
|
|
113
|
+
.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
114
|
+
const tagRegex = new RegExp(escapedQuery, "i");
|
|
115
|
+
|
|
116
|
+
// Find distinct category values matching the query
|
|
117
|
+
const allCategories = await collections.ap_timeline.distinct("category", {
|
|
118
|
+
category: tagRegex,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// Flatten and deduplicate (category can be string or array)
|
|
122
|
+
const tagSet = new Set();
|
|
123
|
+
for (const cat of allCategories) {
|
|
124
|
+
if (Array.isArray(cat)) {
|
|
125
|
+
for (const c of cat) {
|
|
126
|
+
if (typeof c === "string" && tagRegex.test(c)) tagSet.add(c);
|
|
127
|
+
}
|
|
128
|
+
} else if (typeof cat === "string" && tagRegex.test(cat)) {
|
|
129
|
+
tagSet.add(cat);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
results.hashtags = [...tagSet].slice(0, limit).map((name) => ({
|
|
134
|
+
name,
|
|
135
|
+
url: `${baseUrl}/tags/${encodeURIComponent(name)}`,
|
|
136
|
+
history: [],
|
|
137
|
+
}));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
res.json(results);
|
|
141
|
+
} catch (error) {
|
|
142
|
+
next(error);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
export default router;
|