@rmdes/indiekit-endpoint-activitypub 3.8.7 → 3.9.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.
Files changed (52) hide show
  1. package/assets/css/base.css +144 -0
  2. package/assets/css/card.css +377 -0
  3. package/assets/css/compose.css +169 -0
  4. package/assets/css/dark-mode.css +94 -0
  5. package/assets/css/explore.css +530 -0
  6. package/assets/css/features.css +436 -0
  7. package/assets/css/federation.css +242 -0
  8. package/assets/css/interactions.css +236 -0
  9. package/assets/css/media.css +315 -0
  10. package/assets/css/messages.css +158 -0
  11. package/assets/css/moderation.css +119 -0
  12. package/assets/css/notifications.css +191 -0
  13. package/assets/css/profile.css +308 -0
  14. package/assets/css/responsive.css +33 -0
  15. package/assets/css/skeleton.css +74 -0
  16. package/assets/reader-interactions.js +115 -0
  17. package/assets/reader.css +20 -3439
  18. package/index.js +34 -694
  19. package/lib/batch-broadcast.js +98 -0
  20. package/lib/controllers/compose.js +5 -7
  21. package/lib/controllers/interactions-boost.js +8 -13
  22. package/lib/controllers/interactions-like.js +8 -13
  23. package/lib/federation-actions.js +70 -0
  24. package/lib/inbox-queue.js +13 -6
  25. package/lib/init-indexes.js +251 -0
  26. package/lib/item-processing.js +22 -2
  27. package/lib/lookup-cache.js +3 -0
  28. package/lib/mastodon/backfill-timeline.js +11 -2
  29. package/lib/mastodon/entities/sanitize.js +19 -88
  30. package/lib/mastodon/helpers/account-cache.js +3 -0
  31. package/lib/mastodon/helpers/enrich-accounts.js +42 -55
  32. package/lib/mastodon/router.js +31 -0
  33. package/lib/mastodon/routes/accounts.js +16 -49
  34. package/lib/mastodon/routes/media.js +6 -4
  35. package/lib/mastodon/routes/notifications.js +6 -24
  36. package/lib/mastodon/routes/oauth.js +91 -18
  37. package/lib/mastodon/routes/search.js +3 -1
  38. package/lib/mastodon/routes/statuses.js +14 -52
  39. package/lib/mastodon/routes/timelines.js +3 -6
  40. package/lib/og-unfurl.js +52 -33
  41. package/lib/storage/moderation.js +11 -2
  42. package/lib/syndicator.js +239 -0
  43. package/lib/timeline-store.js +11 -15
  44. package/package.json +2 -1
  45. package/views/activitypub-federation-mgmt.njk +2 -2
  46. package/views/activitypub-moderation.njk +1 -1
  47. package/views/activitypub-profile.njk +16 -76
  48. package/views/activitypub-reader.njk +2 -1
  49. package/views/layouts/ap-reader.njk +2 -0
  50. package/views/partials/ap-item-card.njk +14 -117
  51. package/views/partials/ap-item-content.njk +20 -0
  52. package/views/partials/ap-notification-card.njk +1 -1
@@ -43,6 +43,16 @@ export async function backfillTimeline(collections) {
43
43
  let inserted = 0;
44
44
  let skipped = 0;
45
45
 
46
+ // Batch-fetch existing UIDs to avoid N+1 per-post queries
47
+ const allUids = allPosts
48
+ .map((p) => p.properties?.url)
49
+ .filter(Boolean);
50
+ const existingDocs = await ap_timeline
51
+ .find({ uid: { $in: allUids } })
52
+ .project({ uid: 1 })
53
+ .toArray();
54
+ const existingUids = new Set(existingDocs.map((d) => d.uid));
55
+
46
56
  for (const post of allPosts) {
47
57
  const props = post.properties;
48
58
  if (!props?.url) {
@@ -53,8 +63,7 @@ export async function backfillTimeline(collections) {
53
63
  const uid = props.url;
54
64
 
55
65
  // Check if already in timeline (fast path to avoid unnecessary upserts)
56
- const exists = await ap_timeline.findOne({ uid }, { projection: { _id: 1 } });
57
- if (exists) {
66
+ if (existingUids.has(uid)) {
58
67
  skipped++;
59
68
  continue;
60
69
  }
@@ -1,105 +1,33 @@
1
1
  /**
2
- * XSS HTML sanitizer for Mastodon Client API responses.
2
+ * HTML sanitizer for Mastodon Client API responses.
3
3
  *
4
- * Strips dangerous HTML while preserving safe markup that
5
- * Mastodon clients expect (links, paragraphs, line breaks,
6
- * inline formatting, mentions, hashtags).
4
+ * Uses the sanitize-html library for robust XSS prevention.
5
+ * Preserves safe markup that Mastodon clients expect (links,
6
+ * paragraphs, line breaks, inline formatting, mentions, hashtags).
7
7
  */
8
-
9
- /**
10
- * Allowed HTML tags in Mastodon API content fields.
11
- * Matches what Mastodon itself permits in status content.
12
- */
13
- const ALLOWED_TAGS = new Set([
14
- "a",
15
- "br",
16
- "p",
17
- "span",
18
- "strong",
19
- "em",
20
- "b",
21
- "i",
22
- "u",
23
- "s",
24
- "del",
25
- "pre",
26
- "code",
27
- "blockquote",
28
- "ul",
29
- "ol",
30
- "li",
31
- ]);
32
-
33
- /**
34
- * Allowed attributes per tag.
35
- */
36
- const ALLOWED_ATTRS = {
37
- a: new Set(["href", "rel", "class", "target"]),
38
- span: new Set(["class"]),
39
- };
8
+ import sanitizeHtmlLib from "sanitize-html";
40
9
 
41
10
  /**
42
11
  * Sanitize HTML content for safe inclusion in API responses.
43
- *
44
- * Strips all tags not in the allowlist and removes disallowed attributes.
45
- * This is a lightweight sanitizer — for production, consider a
46
- * battle-tested library like DOMPurify or sanitize-html.
47
- *
48
12
  * @param {string} html - Raw HTML string
49
13
  * @returns {string} Sanitized HTML
50
14
  */
51
15
  export function sanitizeHtml(html) {
52
16
  if (!html || typeof html !== "string") return "";
53
17
 
54
- return html.replace(/<\/?([a-z][a-z0-9]*)\b[^>]*>/gi, (match, tagName) => {
55
- const tag = tagName.toLowerCase();
56
-
57
- // Closing tag
58
- if (match.startsWith("</")) {
59
- return ALLOWED_TAGS.has(tag) ? `</${tag}>` : "";
60
- }
61
-
62
- // Opening tag — check if allowed
63
- if (!ALLOWED_TAGS.has(tag)) return "";
64
-
65
- // Self-closing br
66
- if (tag === "br") return "<br>";
67
-
68
- // Strip disallowed attributes
69
- const allowedAttrs = ALLOWED_ATTRS[tag];
70
- if (!allowedAttrs) return `<${tag}>`;
71
-
72
- const attrs = [];
73
- const attrRegex = /([a-z][a-z0-9-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|(\S+)))?/gi;
74
- let attrMatch;
75
- while ((attrMatch = attrRegex.exec(match)) !== null) {
76
- const attrName = attrMatch[1].toLowerCase();
77
- if (attrName === tag) continue; // skip tag name itself
78
- const attrValue = attrMatch[2] ?? attrMatch[3] ?? attrMatch[4] ?? "";
79
- if (allowedAttrs.has(attrName)) {
80
- // Block javascript: URIs in href
81
- if (attrName === "href" && /^\s*javascript:/i.test(attrValue)) continue;
82
- attrs.push(`${attrName}="${escapeAttr(attrValue)}"`);
83
- }
84
- }
85
-
86
- return attrs.length > 0 ? `<${tag} ${attrs.join(" ")}>` : `<${tag}>`;
18
+ return sanitizeHtmlLib(html, {
19
+ allowedTags: [
20
+ "a", "br", "p", "span", "strong", "em", "b", "i", "u", "s",
21
+ "del", "pre", "code", "blockquote", "ul", "ol", "li",
22
+ ],
23
+ allowedAttributes: {
24
+ a: ["href", "rel", "class", "target"],
25
+ span: ["class"],
26
+ },
27
+ allowedSchemes: ["http", "https", "mailto"],
87
28
  });
88
29
  }
89
30
 
90
- /**
91
- * Escape HTML attribute value.
92
- * @param {string} value
93
- * @returns {string}
94
- */
95
- function escapeAttr(value) {
96
- return value
97
- .replace(/&/g, "&amp;")
98
- .replace(/"/g, "&quot;")
99
- .replace(/</g, "&lt;")
100
- .replace(/>/g, "&gt;");
101
- }
102
-
103
31
  /**
104
32
  * Strip all HTML tags, returning plain text.
105
33
  * @param {string} html
@@ -107,5 +35,8 @@ function escapeAttr(value) {
107
35
  */
108
36
  export function stripHtml(html) {
109
37
  if (!html || typeof html !== "string") return "";
110
- return html.replace(/<[^>]*>/g, "").trim();
38
+ return sanitizeHtmlLib(html, {
39
+ allowedTags: [],
40
+ allowedAttributes: {},
41
+ }).trim();
111
42
  }
@@ -57,6 +57,9 @@ export function getCachedAccountStats(actorUrl) {
57
57
  return null;
58
58
  }
59
59
 
60
+ // Promote to end of Map (true LRU)
61
+ cache.delete(actorUrl);
62
+ cache.set(actorUrl, entry);
60
63
  return entry;
61
64
  }
62
65
 
@@ -2,19 +2,17 @@
2
2
  * Enrich embedded account objects in serialized statuses with real
3
3
  * follower/following/post counts from remote AP collections.
4
4
  *
5
- * Phanpy (and some other clients) never call /accounts/:id they
6
- * trust the account object embedded in each status. Without enrichment,
7
- * these show 0/0/0 for all remote accounts.
8
- *
9
- * Uses the account stats cache to avoid redundant fetches. Only resolves
10
- * unique authors with 0 counts that aren't already cached.
5
+ * Applies cached stats immediately. Uncached accounts are resolved
6
+ * in the background (fire-and-forget) and will be populated for
7
+ * subsequent requests.
11
8
  */
12
9
  import { getCachedAccountStats } from "./account-cache.js";
13
10
  import { resolveRemoteAccount } from "./resolve-account.js";
14
11
 
15
12
  /**
16
13
  * Enrich account objects in a list of serialized statuses.
17
- * Resolves unique authors in parallel (max 5 concurrent).
14
+ * Applies cached stats synchronously. Uncached accounts are resolved
15
+ * in the background for future requests.
18
16
  *
19
17
  * @param {Array} statuses - Serialized Mastodon Status objects (mutated in place)
20
18
  * @param {object} pluginOptions - Plugin options with federation context
@@ -23,63 +21,33 @@ import { resolveRemoteAccount } from "./resolve-account.js";
23
21
  export async function enrichAccountStats(statuses, pluginOptions, baseUrl) {
24
22
  if (!statuses?.length || !pluginOptions?.federation) return;
25
23
 
26
- // Collect unique author URLs that need enrichment
27
- const accountsToEnrich = new Map(); // url -> [account references]
24
+ const uncachedUrls = [];
25
+
28
26
  for (const status of statuses) {
29
- collectAccount(status.account, accountsToEnrich);
27
+ applyCachedOrCollect(status.account, uncachedUrls);
30
28
  if (status.reblog?.account) {
31
- collectAccount(status.reblog.account, accountsToEnrich);
29
+ applyCachedOrCollect(status.reblog.account, uncachedUrls);
32
30
  }
33
31
  }
34
32
 
35
- if (accountsToEnrich.size === 0) return;
36
-
37
- // Resolve in parallel with concurrency limit
38
- const entries = [...accountsToEnrich.entries()];
39
- const CONCURRENCY = 5;
40
- for (let i = 0; i < entries.length; i += CONCURRENCY) {
41
- const batch = entries.slice(i, i + CONCURRENCY);
42
- await Promise.all(
43
- batch.map(async ([url, accounts]) => {
44
- try {
45
- const resolved = await resolveRemoteAccount(url, pluginOptions, baseUrl);
46
- if (resolved) {
47
- for (const account of accounts) {
48
- account.followers_count = resolved.followers_count;
49
- account.following_count = resolved.following_count;
50
- account.statuses_count = resolved.statuses_count;
51
- if (resolved.created_at && account.created_at) {
52
- account.created_at = resolved.created_at;
53
- }
54
- if (resolved.note) account.note = resolved.note;
55
- if (resolved.fields?.length) account.fields = resolved.fields;
56
- if (resolved.avatar && resolved.avatar !== account.avatar) {
57
- account.avatar = resolved.avatar;
58
- account.avatar_static = resolved.avatar;
59
- }
60
- if (resolved.header) {
61
- account.header = resolved.header;
62
- account.header_static = resolved.header;
63
- }
64
- }
65
- }
66
- } catch {
67
- // Silently skip failed resolutions
68
- }
69
- }),
70
- );
33
+ // Fire-and-forget background enrichment for uncached accounts.
34
+ // Next request will pick up the cached results.
35
+ if (uncachedUrls.length > 0) {
36
+ resolveInBackground(uncachedUrls, pluginOptions, baseUrl);
71
37
  }
72
38
  }
73
39
 
74
40
  /**
75
- * Collect an account reference for enrichment if it has 0 counts
76
- * and isn't already cached.
41
+ * Apply cached stats to an account, or collect its URL for background resolution.
42
+ * @param {object} account - Account object to enrich
43
+ * @param {string[]} uncachedUrls - Array to collect uncached URLs into
77
44
  */
78
- function collectAccount(account, map) {
45
+ function applyCachedOrCollect(account, uncachedUrls) {
79
46
  if (!account?.url) return;
47
+
48
+ // Already has real counts — skip
80
49
  if (account.followers_count > 0 || account.statuses_count > 0) return;
81
50
 
82
- // Check cache first — if cached, apply immediately
83
51
  const cached = getCachedAccountStats(account.url);
84
52
  if (cached) {
85
53
  account.followers_count = cached.followersCount || 0;
@@ -89,9 +57,28 @@ function collectAccount(account, map) {
89
57
  return;
90
58
  }
91
59
 
92
- // Queue for remote resolution
93
- if (!map.has(account.url)) {
94
- map.set(account.url, []);
60
+ if (!uncachedUrls.includes(account.url)) {
61
+ uncachedUrls.push(account.url);
95
62
  }
96
- map.get(account.url).push(account);
63
+ }
64
+
65
+ /**
66
+ * Resolve accounts in background. Fire-and-forget — errors are silently ignored.
67
+ * resolveRemoteAccount() populates the account cache as a side effect.
68
+ * @param {string[]} urls - Actor URLs to resolve
69
+ * @param {object} pluginOptions - Plugin options
70
+ * @param {string} baseUrl - Server base URL
71
+ */
72
+ function resolveInBackground(urls, pluginOptions, baseUrl) {
73
+ const unique = [...new Set(urls)];
74
+ const CONCURRENCY = 5;
75
+
76
+ (async () => {
77
+ for (let i = 0; i < unique.length; i += CONCURRENCY) {
78
+ const batch = unique.slice(i, i + CONCURRENCY);
79
+ await Promise.allSettled(
80
+ batch.map((url) => resolveRemoteAccount(url, pluginOptions, baseUrl)),
81
+ );
82
+ }
83
+ })().catch(() => {});
97
84
  }
@@ -6,6 +6,7 @@
6
6
  * /api/v1/*, /api/v2/*, /oauth/* at the domain root.
7
7
  */
8
8
  import express from "express";
9
+ import rateLimit from "express-rate-limit";
9
10
  import { corsMiddleware } from "./middleware/cors.js";
10
11
  import { tokenRequired, optionalToken } from "./middleware/token-required.js";
11
12
  import { errorHandler, notImplementedHandler } from "./middleware/error-handler.js";
@@ -21,6 +22,31 @@ import searchRouter from "./routes/search.js";
21
22
  import mediaRouter from "./routes/media.js";
22
23
  import stubsRouter from "./routes/stubs.js";
23
24
 
25
+ // Rate limiters for different endpoint categories
26
+ const apiLimiter = rateLimit({
27
+ windowMs: 5 * 60 * 1000, // 5 minutes
28
+ max: 300,
29
+ standardHeaders: true,
30
+ legacyHeaders: false,
31
+ message: { error: "Too many requests, please try again later" },
32
+ });
33
+
34
+ const authLimiter = rateLimit({
35
+ windowMs: 15 * 60 * 1000, // 15 minutes
36
+ max: 30,
37
+ standardHeaders: true,
38
+ legacyHeaders: false,
39
+ message: { error: "Too many authentication attempts" },
40
+ });
41
+
42
+ const appRegistrationLimiter = rateLimit({
43
+ windowMs: 60 * 60 * 1000, // 1 hour
44
+ max: 25,
45
+ standardHeaders: true,
46
+ legacyHeaders: false,
47
+ message: { error: "Too many app registrations" },
48
+ });
49
+
24
50
  /**
25
51
  * Create the combined Mastodon API router.
26
52
  *
@@ -46,6 +72,11 @@ export function createMastodonRouter({ collections, pluginOptions = {} }) {
46
72
  router.use("/oauth/revoke", corsMiddleware);
47
73
  router.use("/.well-known/oauth-authorization-server", corsMiddleware);
48
74
 
75
+ // ─── Rate limiting ─────────────────────────────────────────────────────
76
+ router.use("/api", apiLimiter);
77
+ router.use("/oauth/token", authLimiter);
78
+ router.use("/api/v1/apps", appRegistrationLimiter);
79
+
49
80
  // ─── Inject collections + plugin options into req ───────────────────────
50
81
  router.use("/api", (req, res, next) => {
51
82
  req.app.locals.mastodonCollections = collections;
@@ -11,18 +11,15 @@ import { accountId, remoteActorId } from "../helpers/id-mapping.js";
11
11
  import { buildPaginationQuery, parseLimit, setPaginationHeaders } from "../helpers/pagination.js";
12
12
  import { resolveRemoteAccount } from "../helpers/resolve-account.js";
13
13
  import { getActorUrlFromId } from "../helpers/account-cache.js";
14
+ import { tokenRequired } from "../middleware/token-required.js";
15
+ import { scopeRequired } from "../middleware/scope-required.js";
14
16
 
15
17
  const router = express.Router(); // eslint-disable-line new-cap
16
18
 
17
19
  // ─── GET /api/v1/accounts/verify_credentials ─────────────────────────────────
18
20
 
19
- router.get("/api/v1/accounts/verify_credentials", async (req, res, next) => {
21
+ router.get("/api/v1/accounts/verify_credentials", tokenRequired, scopeRequired("read", "read:accounts"), async (req, res, next) => {
20
22
  try {
21
- const token = req.mastodonToken;
22
- if (!token) {
23
- return res.status(401).json({ error: "The access token is invalid" });
24
- }
25
-
26
23
  const baseUrl = `${req.protocol}://${req.get("host")}`;
27
24
  const collections = req.app.locals.mastodonCollections;
28
25
  const pluginOptions = req.app.locals.mastodonPluginOptions || {};
@@ -62,7 +59,7 @@ router.get("/api/v1/accounts/verify_credentials", async (req, res, next) => {
62
59
 
63
60
  // ─── GET /api/v1/preferences ─────────────────────────────────────────────────
64
61
 
65
- router.get("/api/v1/preferences", (req, res) => {
62
+ router.get("/api/v1/preferences", tokenRequired, scopeRequired("read", "read:accounts"), (req, res) => {
66
63
  res.json({
67
64
  "posting:default:visibility": "public",
68
65
  "posting:default:sensitive": false,
@@ -159,7 +156,7 @@ router.get("/api/v1/accounts/lookup", async (req, res, next) => {
159
156
  // ─── GET /api/v1/accounts/relationships ──────────────────────────────────────
160
157
  // MUST be before /accounts/:id to prevent Express matching "relationships" as :id
161
158
 
162
- router.get("/api/v1/accounts/relationships", async (req, res, next) => {
159
+ router.get("/api/v1/accounts/relationships", tokenRequired, scopeRequired("read", "read:follows"), async (req, res, next) => {
163
160
  try {
164
161
  let ids = req.query["id[]"] || req.query.id || [];
165
162
  if (!Array.isArray(ids)) ids = [ids];
@@ -225,7 +222,7 @@ router.get("/api/v1/accounts/relationships", async (req, res, next) => {
225
222
  // ─── GET /api/v1/accounts/familiar_followers ─────────────────────────────────
226
223
  // MUST be before /accounts/:id
227
224
 
228
- router.get("/api/v1/accounts/familiar_followers", (req, res) => {
225
+ router.get("/api/v1/accounts/familiar_followers", tokenRequired, scopeRequired("read", "read:follows"), (req, res) => {
229
226
  let ids = req.query["id[]"] || req.query.id || [];
230
227
  if (!Array.isArray(ids)) ids = [ids];
231
228
  res.json(ids.map((id) => ({ id, accounts: [] })));
@@ -233,7 +230,7 @@ router.get("/api/v1/accounts/familiar_followers", (req, res) => {
233
230
 
234
231
  // ─── GET /api/v1/accounts/:id ────────────────────────────────────────────────
235
232
 
236
- router.get("/api/v1/accounts/:id", async (req, res, next) => {
233
+ router.get("/api/v1/accounts/:id", tokenRequired, scopeRequired("read", "read:accounts"), async (req, res, next) => {
237
234
  try {
238
235
  const { id } = req.params;
239
236
  const baseUrl = `${req.protocol}://${req.get("host")}`;
@@ -285,7 +282,7 @@ router.get("/api/v1/accounts/:id", async (req, res, next) => {
285
282
 
286
283
  // ─── GET /api/v1/accounts/:id/statuses ──────────────────────────────────────
287
284
 
288
- router.get("/api/v1/accounts/:id/statuses", async (req, res, next) => {
285
+ router.get("/api/v1/accounts/:id/statuses", tokenRequired, scopeRequired("read", "read:statuses"), async (req, res, next) => {
289
286
  try {
290
287
  const { id } = req.params;
291
288
  const collections = req.app.locals.mastodonCollections;
@@ -376,7 +373,7 @@ router.get("/api/v1/accounts/:id/statuses", async (req, res, next) => {
376
373
 
377
374
  // ─── GET /api/v1/accounts/:id/followers ─────────────────────────────────────
378
375
 
379
- router.get("/api/v1/accounts/:id/followers", async (req, res, next) => {
376
+ router.get("/api/v1/accounts/:id/followers", tokenRequired, scopeRequired("read", "read:follows"), async (req, res, next) => {
380
377
  try {
381
378
  const { id } = req.params;
382
379
  const collections = req.app.locals.mastodonCollections;
@@ -409,7 +406,7 @@ router.get("/api/v1/accounts/:id/followers", async (req, res, next) => {
409
406
 
410
407
  // ─── GET /api/v1/accounts/:id/following ─────────────────────────────────────
411
408
 
412
- router.get("/api/v1/accounts/:id/following", async (req, res, next) => {
409
+ router.get("/api/v1/accounts/:id/following", tokenRequired, scopeRequired("read", "read:follows"), async (req, res, next) => {
413
410
  try {
414
411
  const { id } = req.params;
415
412
  const collections = req.app.locals.mastodonCollections;
@@ -442,13 +439,8 @@ router.get("/api/v1/accounts/:id/following", async (req, res, next) => {
442
439
 
443
440
  // ─── POST /api/v1/accounts/:id/follow ───────────────────────────────────────
444
441
 
445
- router.post("/api/v1/accounts/:id/follow", async (req, res, next) => {
442
+ router.post("/api/v1/accounts/:id/follow", tokenRequired, scopeRequired("write", "write:follows", "follow"), async (req, res, next) => {
446
443
  try {
447
- const token = req.mastodonToken;
448
- if (!token) {
449
- return res.status(401).json({ error: "The access token is invalid" });
450
- }
451
-
452
444
  const { id } = req.params;
453
445
  const collections = req.app.locals.mastodonCollections;
454
446
  const pluginOptions = req.app.locals.mastodonPluginOptions || {};
@@ -504,13 +496,8 @@ router.post("/api/v1/accounts/:id/follow", async (req, res, next) => {
504
496
 
505
497
  // ─── POST /api/v1/accounts/:id/unfollow ─────────────────────────────────────
506
498
 
507
- router.post("/api/v1/accounts/:id/unfollow", async (req, res, next) => {
499
+ router.post("/api/v1/accounts/:id/unfollow", tokenRequired, scopeRequired("write", "write:follows", "follow"), async (req, res, next) => {
508
500
  try {
509
- const token = req.mastodonToken;
510
- if (!token) {
511
- return res.status(401).json({ error: "The access token is invalid" });
512
- }
513
-
514
501
  const { id } = req.params;
515
502
  const collections = req.app.locals.mastodonCollections;
516
503
  const pluginOptions = req.app.locals.mastodonPluginOptions || {};
@@ -557,13 +544,8 @@ router.post("/api/v1/accounts/:id/unfollow", async (req, res, next) => {
557
544
 
558
545
  // ─── POST /api/v1/accounts/:id/mute ────────────────────────────────────────
559
546
 
560
- router.post("/api/v1/accounts/:id/mute", async (req, res, next) => {
547
+ router.post("/api/v1/accounts/:id/mute", tokenRequired, scopeRequired("write", "write:mutes", "follow"), async (req, res, next) => {
561
548
  try {
562
- const token = req.mastodonToken;
563
- if (!token) {
564
- return res.status(401).json({ error: "The access token is invalid" });
565
- }
566
-
567
549
  const { id } = req.params;
568
550
  const collections = req.app.locals.mastodonCollections;
569
551
 
@@ -600,13 +582,8 @@ router.post("/api/v1/accounts/:id/mute", async (req, res, next) => {
600
582
 
601
583
  // ─── POST /api/v1/accounts/:id/unmute ───────────────────────────────────────
602
584
 
603
- router.post("/api/v1/accounts/:id/unmute", async (req, res, next) => {
585
+ router.post("/api/v1/accounts/:id/unmute", tokenRequired, scopeRequired("write", "write:mutes", "follow"), async (req, res, next) => {
604
586
  try {
605
- const token = req.mastodonToken;
606
- if (!token) {
607
- return res.status(401).json({ error: "The access token is invalid" });
608
- }
609
-
610
587
  const { id } = req.params;
611
588
  const collections = req.app.locals.mastodonCollections;
612
589
 
@@ -639,13 +616,8 @@ router.post("/api/v1/accounts/:id/unmute", async (req, res, next) => {
639
616
 
640
617
  // ─── POST /api/v1/accounts/:id/block ───────────────────────────────────────
641
618
 
642
- router.post("/api/v1/accounts/:id/block", async (req, res, next) => {
619
+ router.post("/api/v1/accounts/:id/block", tokenRequired, scopeRequired("write", "write:blocks", "follow"), async (req, res, next) => {
643
620
  try {
644
- const token = req.mastodonToken;
645
- if (!token) {
646
- return res.status(401).json({ error: "The access token is invalid" });
647
- }
648
-
649
621
  const { id } = req.params;
650
622
  const collections = req.app.locals.mastodonCollections;
651
623
 
@@ -682,13 +654,8 @@ router.post("/api/v1/accounts/:id/block", async (req, res, next) => {
682
654
 
683
655
  // ─── POST /api/v1/accounts/:id/unblock ──────────────────────────────────────
684
656
 
685
- router.post("/api/v1/accounts/:id/unblock", async (req, res, next) => {
657
+ router.post("/api/v1/accounts/:id/unblock", tokenRequired, scopeRequired("write", "write:blocks", "follow"), async (req, res, next) => {
686
658
  try {
687
- const token = req.mastodonToken;
688
- if (!token) {
689
- return res.status(401).json({ error: "The access token is invalid" });
690
- }
691
-
692
659
  const { id } = req.params;
693
660
  const collections = req.app.locals.mastodonCollections;
694
661
 
@@ -7,12 +7,14 @@
7
7
  * PUT /api/v1/media/:id — update media metadata (description/focus)
8
8
  */
9
9
  import express from "express";
10
+ import { tokenRequired } from "../middleware/token-required.js";
11
+ import { scopeRequired } from "../middleware/scope-required.js";
10
12
 
11
13
  const router = express.Router(); // eslint-disable-line new-cap
12
14
 
13
15
  // ─── POST /api/v2/media ─────────────────────────────────────────────────────
14
16
 
15
- router.post("/api/v2/media", (req, res) => {
17
+ router.post("/api/v2/media", tokenRequired, scopeRequired("write", "write:media"), (req, res) => {
16
18
  // Media upload requires multer/multipart handling + storage backend.
17
19
  // For now, return 422 so clients show a user-friendly error.
18
20
  res.status(422).json({
@@ -22,7 +24,7 @@ router.post("/api/v2/media", (req, res) => {
22
24
 
23
25
  // ─── POST /api/v1/media (legacy) ────────────────────────────────────────────
24
26
 
25
- router.post("/api/v1/media", (req, res) => {
27
+ router.post("/api/v1/media", tokenRequired, scopeRequired("write", "write:media"), (req, res) => {
26
28
  res.status(422).json({
27
29
  error: "Media uploads are not yet supported on this server",
28
30
  });
@@ -30,13 +32,13 @@ router.post("/api/v1/media", (req, res) => {
30
32
 
31
33
  // ─── GET /api/v1/media/:id ──────────────────────────────────────────────────
32
34
 
33
- router.get("/api/v1/media/:id", (req, res) => {
35
+ router.get("/api/v1/media/:id", tokenRequired, scopeRequired("read", "read:statuses"), (req, res) => {
34
36
  res.status(404).json({ error: "Record not found" });
35
37
  });
36
38
 
37
39
  // ─── PUT /api/v1/media/:id ──────────────────────────────────────────────────
38
40
 
39
- router.put("/api/v1/media/:id", (req, res) => {
41
+ router.put("/api/v1/media/:id", tokenRequired, scopeRequired("write", "write:media"), (req, res) => {
40
42
  res.status(404).json({ error: "Record not found" });
41
43
  });
42
44
 
@@ -10,6 +10,8 @@ import express from "express";
10
10
  import { ObjectId } from "mongodb";
11
11
  import { serializeNotification } from "../entities/notification.js";
12
12
  import { buildPaginationQuery, parseLimit, setPaginationHeaders } from "../helpers/pagination.js";
13
+ import { tokenRequired } from "../middleware/token-required.js";
14
+ import { scopeRequired } from "../middleware/scope-required.js";
13
15
 
14
16
  const router = express.Router(); // eslint-disable-line new-cap
15
17
 
@@ -29,13 +31,8 @@ const REVERSE_TYPE_MAP = {
29
31
 
30
32
  // ─── GET /api/v1/notifications ──────────────────────────────────────────────
31
33
 
32
- router.get("/api/v1/notifications", async (req, res, next) => {
34
+ router.get("/api/v1/notifications", tokenRequired, scopeRequired("read", "read:notifications"), async (req, res, next) => {
33
35
  try {
34
- const token = req.mastodonToken;
35
- if (!token) {
36
- return res.status(401).json({ error: "The access token is invalid" });
37
- }
38
-
39
36
  const collections = req.app.locals.mastodonCollections;
40
37
  const baseUrl = `${req.protocol}://${req.get("host")}`;
41
38
  const limit = parseLimit(req.query.limit);
@@ -105,13 +102,8 @@ router.get("/api/v1/notifications", async (req, res, next) => {
105
102
 
106
103
  // ─── GET /api/v1/notifications/:id ──────────────────────────────────────────
107
104
 
108
- router.get("/api/v1/notifications/:id", async (req, res, next) => {
105
+ router.get("/api/v1/notifications/:id", tokenRequired, scopeRequired("read", "read:notifications"), async (req, res, next) => {
109
106
  try {
110
- const token = req.mastodonToken;
111
- if (!token) {
112
- return res.status(401).json({ error: "The access token is invalid" });
113
- }
114
-
115
107
  const collections = req.app.locals.mastodonCollections;
116
108
  const baseUrl = `${req.protocol}://${req.get("host")}`;
117
109
 
@@ -147,13 +139,8 @@ router.get("/api/v1/notifications/:id", async (req, res, next) => {
147
139
 
148
140
  // ─── POST /api/v1/notifications/clear ───────────────────────────────────────
149
141
 
150
- router.post("/api/v1/notifications/clear", async (req, res, next) => {
142
+ router.post("/api/v1/notifications/clear", tokenRequired, scopeRequired("write", "write:notifications"), async (req, res, next) => {
151
143
  try {
152
- const token = req.mastodonToken;
153
- if (!token) {
154
- return res.status(401).json({ error: "The access token is invalid" });
155
- }
156
-
157
144
  const collections = req.app.locals.mastodonCollections;
158
145
  await collections.ap_notifications.deleteMany({});
159
146
  res.json({});
@@ -164,13 +151,8 @@ router.post("/api/v1/notifications/clear", async (req, res, next) => {
164
151
 
165
152
  // ─── POST /api/v1/notifications/:id/dismiss ─────────────────────────────────
166
153
 
167
- router.post("/api/v1/notifications/:id/dismiss", async (req, res, next) => {
154
+ router.post("/api/v1/notifications/:id/dismiss", tokenRequired, scopeRequired("write", "write:notifications"), async (req, res, next) => {
168
155
  try {
169
- const token = req.mastodonToken;
170
- if (!token) {
171
- return res.status(401).json({ error: "The access token is invalid" });
172
- }
173
-
174
156
  const collections = req.app.locals.mastodonCollections;
175
157
 
176
158
  let objectId;