@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.
- package/assets/css/base.css +144 -0
- package/assets/css/card.css +377 -0
- package/assets/css/compose.css +169 -0
- package/assets/css/dark-mode.css +94 -0
- package/assets/css/explore.css +530 -0
- package/assets/css/features.css +436 -0
- package/assets/css/federation.css +242 -0
- package/assets/css/interactions.css +236 -0
- package/assets/css/media.css +315 -0
- package/assets/css/messages.css +158 -0
- package/assets/css/moderation.css +119 -0
- package/assets/css/notifications.css +191 -0
- package/assets/css/profile.css +308 -0
- package/assets/css/responsive.css +33 -0
- package/assets/css/skeleton.css +74 -0
- package/assets/reader-interactions.js +115 -0
- package/assets/reader.css +20 -3439
- package/index.js +34 -694
- package/lib/batch-broadcast.js +98 -0
- package/lib/controllers/compose.js +5 -7
- package/lib/controllers/interactions-boost.js +8 -13
- package/lib/controllers/interactions-like.js +8 -13
- package/lib/federation-actions.js +70 -0
- package/lib/inbox-queue.js +13 -6
- package/lib/init-indexes.js +251 -0
- package/lib/item-processing.js +22 -2
- package/lib/lookup-cache.js +3 -0
- package/lib/mastodon/backfill-timeline.js +11 -2
- package/lib/mastodon/entities/sanitize.js +19 -88
- package/lib/mastodon/helpers/account-cache.js +3 -0
- package/lib/mastodon/helpers/enrich-accounts.js +42 -55
- package/lib/mastodon/router.js +31 -0
- package/lib/mastodon/routes/accounts.js +16 -49
- package/lib/mastodon/routes/media.js +6 -4
- package/lib/mastodon/routes/notifications.js +6 -24
- package/lib/mastodon/routes/oauth.js +91 -18
- package/lib/mastodon/routes/search.js +3 -1
- package/lib/mastodon/routes/statuses.js +14 -52
- package/lib/mastodon/routes/timelines.js +3 -6
- package/lib/og-unfurl.js +52 -33
- package/lib/storage/moderation.js +11 -2
- package/lib/syndicator.js +239 -0
- package/lib/timeline-store.js +11 -15
- package/package.json +2 -1
- package/views/activitypub-federation-mgmt.njk +2 -2
- package/views/activitypub-moderation.njk +1 -1
- package/views/activitypub-profile.njk +16 -76
- package/views/activitypub-reader.njk +2 -1
- package/views/layouts/ap-reader.njk +2 -0
- package/views/partials/ap-item-card.njk +14 -117
- package/views/partials/ap-item-content.njk +20 -0
- 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
|
-
|
|
57
|
-
if (exists) {
|
|
66
|
+
if (existingUids.has(uid)) {
|
|
58
67
|
skipped++;
|
|
59
68
|
continue;
|
|
60
69
|
}
|
|
@@ -1,105 +1,33 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* HTML sanitizer for Mastodon Client API responses.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* Mastodon clients expect (links,
|
|
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
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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, "&")
|
|
98
|
-
.replace(/"/g, """)
|
|
99
|
-
.replace(/</g, "<")
|
|
100
|
-
.replace(/>/g, ">");
|
|
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
|
|
38
|
+
return sanitizeHtmlLib(html, {
|
|
39
|
+
allowedTags: [],
|
|
40
|
+
allowedAttributes: {},
|
|
41
|
+
}).trim();
|
|
111
42
|
}
|
|
@@ -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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
27
|
-
|
|
24
|
+
const uncachedUrls = [];
|
|
25
|
+
|
|
28
26
|
for (const status of statuses) {
|
|
29
|
-
|
|
27
|
+
applyCachedOrCollect(status.account, uncachedUrls);
|
|
30
28
|
if (status.reblog?.account) {
|
|
31
|
-
|
|
29
|
+
applyCachedOrCollect(status.reblog.account, uncachedUrls);
|
|
32
30
|
}
|
|
33
31
|
}
|
|
34
32
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
*
|
|
76
|
-
*
|
|
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
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
map.set(account.url, []);
|
|
60
|
+
if (!uncachedUrls.includes(account.url)) {
|
|
61
|
+
uncachedUrls.push(account.url);
|
|
95
62
|
}
|
|
96
|
-
|
|
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
|
}
|
package/lib/mastodon/router.js
CHANGED
|
@@ -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;
|