@rmdes/indiekit-endpoint-activitypub 2.15.4 → 3.0.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 CHANGED
@@ -1,6 +1,7 @@
1
1
  import express from "express";
2
2
 
3
3
  import { setupFederation, buildPersonActor } from "./lib/federation-setup.js";
4
+ import { createMastodonRouter } from "./lib/mastodon/router.js";
4
5
  import { initRedisCache } from "./lib/redis-cache.js";
5
6
  import { lookupWithSecurity } from "./lib/lookup-helpers.js";
6
7
  import {
@@ -1137,6 +1138,10 @@ export default class ActivityPubEndpoint {
1137
1138
  Indiekit.addCollection("ap_key_freshness");
1138
1139
  // Async inbox processing queue
1139
1140
  Indiekit.addCollection("ap_inbox_queue");
1141
+ // Mastodon Client API collections
1142
+ Indiekit.addCollection("ap_oauth_apps");
1143
+ Indiekit.addCollection("ap_oauth_tokens");
1144
+ Indiekit.addCollection("ap_markers");
1140
1145
 
1141
1146
  // Store collection references (posts resolved lazily)
1142
1147
  const indiekitCollections = Indiekit.collections;
@@ -1170,6 +1175,10 @@ export default class ActivityPubEndpoint {
1170
1175
  ap_key_freshness: indiekitCollections.get("ap_key_freshness"),
1171
1176
  // Async inbox processing queue
1172
1177
  ap_inbox_queue: indiekitCollections.get("ap_inbox_queue"),
1178
+ // Mastodon Client API collections
1179
+ ap_oauth_apps: indiekitCollections.get("ap_oauth_apps"),
1180
+ ap_oauth_tokens: indiekitCollections.get("ap_oauth_tokens"),
1181
+ ap_markers: indiekitCollections.get("ap_markers"),
1173
1182
  get posts() {
1174
1183
  return indiekitCollections.get("posts");
1175
1184
  },
@@ -1391,6 +1400,24 @@ export default class ActivityPubEndpoint {
1391
1400
  { processedAt: 1 },
1392
1401
  { expireAfterSeconds: 86_400, background: true },
1393
1402
  );
1403
+
1404
+ // Mastodon Client API indexes
1405
+ this._collections.ap_oauth_apps.createIndex(
1406
+ { clientId: 1 },
1407
+ { unique: true, background: true },
1408
+ );
1409
+ this._collections.ap_oauth_tokens.createIndex(
1410
+ { accessToken: 1 },
1411
+ { unique: true, background: true },
1412
+ );
1413
+ this._collections.ap_oauth_tokens.createIndex(
1414
+ { code: 1 },
1415
+ { unique: true, sparse: true, background: true },
1416
+ );
1417
+ this._collections.ap_markers.createIndex(
1418
+ { userId: 1, timeline: 1 },
1419
+ { unique: true, background: true },
1420
+ );
1394
1421
  } catch {
1395
1422
  // Index creation failed — collections not yet available.
1396
1423
  // Indexes already exist from previous startups; non-fatal.
@@ -1457,6 +1484,26 @@ export default class ActivityPubEndpoint {
1457
1484
  routesPublic: this.contentNegotiationRoutes,
1458
1485
  });
1459
1486
 
1487
+ // Mastodon Client API — virtual endpoint at root
1488
+ // Mastodon-compatible clients (Phanpy, Elk, etc.) expect /api/v1/*,
1489
+ // /api/v2/*, /oauth/* at the domain root, not under /activitypub.
1490
+ const pluginRef = this;
1491
+ const mastodonRouter = createMastodonRouter({
1492
+ collections: this._collections,
1493
+ pluginOptions: {
1494
+ handle: this.options.actor?.handle || "user",
1495
+ publicationUrl: this._publicationUrl,
1496
+ federation: this._federation,
1497
+ followActor: (url, info) => pluginRef.followActor(url, info),
1498
+ unfollowActor: (url) => pluginRef.unfollowActor(url),
1499
+ },
1500
+ });
1501
+ Indiekit.addEndpoint({
1502
+ name: "Mastodon Client API",
1503
+ mountPath: "/",
1504
+ routesPublic: mastodonRouter,
1505
+ });
1506
+
1460
1507
  // Register syndicator (appears in post editing UI)
1461
1508
  Indiekit.addSyndicator(this.syndicator);
1462
1509
 
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Account entity serializer for Mastodon Client API.
3
+ *
4
+ * Converts local profile (ap_profile) and remote actor objects
5
+ * (from timeline author, follower/following docs) into the
6
+ * Mastodon Account JSON shape that masto.js expects.
7
+ */
8
+ import { accountId } from "../helpers/id-mapping.js";
9
+ import { sanitizeHtml, stripHtml } from "./sanitize.js";
10
+
11
+ /**
12
+ * Serialize an actor as a Mastodon Account entity.
13
+ *
14
+ * Handles two shapes:
15
+ * - Local profile: { _id, name, summary, url, icon, image, actorType,
16
+ * manuallyApprovesFollowers, attachments, createdAt, ... }
17
+ * - Remote author (from timeline): { name, url, photo, handle, emojis, bot }
18
+ * - Follower/following doc: { actorUrl, name, handle, avatar, ... }
19
+ *
20
+ * @param {object} actor - Actor document (profile, author, or follower)
21
+ * @param {object} options
22
+ * @param {string} options.baseUrl - Server base URL
23
+ * @param {boolean} [options.isLocal=false] - Whether this is the local user
24
+ * @param {string} [options.handle] - Local actor handle (for local accounts)
25
+ * @returns {object} Mastodon Account entity
26
+ */
27
+ export function serializeAccount(actor, { baseUrl, isLocal = false, handle = "" }) {
28
+ if (!actor) {
29
+ return null;
30
+ }
31
+
32
+ const id = accountId(actor, isLocal);
33
+
34
+ // Resolve username and acct
35
+ let username;
36
+ let acct;
37
+ if (isLocal) {
38
+ username = handle || extractUsername(actor.url) || "user";
39
+ acct = username; // local accounts use bare username
40
+ } else {
41
+ // Remote: extract from handle (@user@domain) or URL
42
+ const remoteHandle = actor.handle || "";
43
+ if (remoteHandle.startsWith("@")) {
44
+ username = remoteHandle.split("@")[1] || "";
45
+ acct = remoteHandle.slice(1); // strip leading @
46
+ } else if (remoteHandle.includes("@")) {
47
+ username = remoteHandle.split("@")[0];
48
+ acct = remoteHandle;
49
+ } else {
50
+ username = extractUsername(actor.url || actor.actorUrl) || "unknown";
51
+ const domain = extractDomain(actor.url || actor.actorUrl);
52
+ acct = domain ? `${username}@${domain}` : username;
53
+ }
54
+ }
55
+
56
+ // Resolve display name
57
+ const displayName = actor.name || actor.displayName || username || "";
58
+
59
+ // Resolve URLs for avatar and header
60
+ const avatarUrl =
61
+ actor.icon || actor.avatarUrl || actor.photo || actor.avatar || "";
62
+ const headerUrl = actor.image || actor.bannerUrl || "";
63
+
64
+ // Resolve URL
65
+ const url = actor.url || actor.actorUrl || "";
66
+
67
+ // Resolve note/summary
68
+ const note = actor.summary || "";
69
+
70
+ // Bot detection
71
+ const bot =
72
+ actor.bot === true ||
73
+ actor.actorType === "Service" ||
74
+ actor.actorType === "Application";
75
+
76
+ // Profile fields from attachments
77
+ const fields = (actor.attachments || actor.fields || []).map((f) => ({
78
+ name: f.name || "",
79
+ value: sanitizeHtml(f.value || ""),
80
+ verified_at: null,
81
+ }));
82
+
83
+ // Custom emojis
84
+ const emojis = (actor.emojis || []).map((e) => ({
85
+ shortcode: e.shortcode || "",
86
+ url: e.url || "",
87
+ static_url: e.url || "",
88
+ visible_in_picker: true,
89
+ }));
90
+
91
+ return {
92
+ id,
93
+ username,
94
+ acct,
95
+ url,
96
+ display_name: displayName,
97
+ note: sanitizeHtml(note),
98
+ avatar: avatarUrl || `${baseUrl}/placeholder-avatar.png`,
99
+ avatar_static: avatarUrl || `${baseUrl}/placeholder-avatar.png`,
100
+ header: headerUrl || "",
101
+ header_static: headerUrl || "",
102
+ locked: actor.manuallyApprovesFollowers || false,
103
+ fields,
104
+ emojis,
105
+ bot,
106
+ group: actor.actorType === "Group" || false,
107
+ discoverable: true,
108
+ noindex: false,
109
+ created_at: actor.createdAt || new Date().toISOString(),
110
+ last_status_at: actor.lastStatusAt || null,
111
+ statuses_count: actor.statusesCount || 0,
112
+ followers_count: actor.followersCount || 0,
113
+ following_count: actor.followingCount || 0,
114
+ moved: actor.movedTo || null,
115
+ suspended: false,
116
+ limited: false,
117
+ memorial: false,
118
+ roles: [],
119
+ hide_collections: false,
120
+ };
121
+ }
122
+
123
+ /**
124
+ * Serialize the local profile as a CredentialAccount (includes source + role).
125
+ *
126
+ * @param {object} profile - ap_profile document
127
+ * @param {object} options
128
+ * @param {string} options.baseUrl - Server base URL
129
+ * @param {string} options.handle - Local actor handle
130
+ * @param {object} [options.counts] - { statuses, followers, following }
131
+ * @returns {object} Mastodon CredentialAccount entity
132
+ */
133
+ export function serializeCredentialAccount(profile, { baseUrl, handle, counts = {} }) {
134
+ const account = serializeAccount(profile, {
135
+ baseUrl,
136
+ isLocal: true,
137
+ handle,
138
+ });
139
+
140
+ // Add counts if provided
141
+ account.statuses_count = counts.statuses || 0;
142
+ account.followers_count = counts.followers || 0;
143
+ account.following_count = counts.following || 0;
144
+
145
+ // CredentialAccount extensions
146
+ account.source = {
147
+ privacy: "public",
148
+ sensitive: false,
149
+ language: "",
150
+ note: stripHtml(profile.summary || ""),
151
+ fields: (profile.attachments || []).map((f) => ({
152
+ name: f.name || "",
153
+ value: f.value || "",
154
+ verified_at: null,
155
+ })),
156
+ follow_requests_count: 0,
157
+ };
158
+
159
+ account.role = {
160
+ id: "-99",
161
+ name: "",
162
+ permissions: "0",
163
+ color: "",
164
+ highlighted: false,
165
+ };
166
+
167
+ return account;
168
+ }
169
+
170
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
171
+
172
+ /**
173
+ * Extract username from a URL path.
174
+ * Handles /@username, /users/username patterns.
175
+ */
176
+ function extractUsername(url) {
177
+ if (!url) return "";
178
+ try {
179
+ const { pathname } = new URL(url);
180
+ const atMatch = pathname.match(/\/@([^/]+)/);
181
+ if (atMatch) return atMatch[1];
182
+ const usersMatch = pathname.match(/\/users\/([^/]+)/);
183
+ if (usersMatch) return usersMatch[1];
184
+ return "";
185
+ } catch {
186
+ return "";
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Extract domain from a URL.
192
+ */
193
+ function extractDomain(url) {
194
+ if (!url) return "";
195
+ try {
196
+ return new URL(url).hostname;
197
+ } catch {
198
+ return "";
199
+ }
200
+ }
@@ -0,0 +1 @@
1
+ // Instance v1/v2 serializer — implemented in Task 8
@@ -0,0 +1,38 @@
1
+ /**
2
+ * MediaAttachment entity serializer for Mastodon Client API.
3
+ *
4
+ * Converts stored media metadata to Mastodon MediaAttachment shape.
5
+ */
6
+
7
+ /**
8
+ * Serialize a MediaAttachment entity.
9
+ *
10
+ * @param {object} media - Media document from ap_media collection
11
+ * @returns {object} Mastodon MediaAttachment entity
12
+ */
13
+ export function serializeMediaAttachment(media) {
14
+ const type = detectMediaType(media.contentType || media.type || "");
15
+
16
+ return {
17
+ id: media._id ? media._id.toString() : media.id || "",
18
+ type,
19
+ url: media.url || "",
20
+ preview_url: media.thumbnailUrl || media.url || "",
21
+ remote_url: null,
22
+ text_url: media.url || "",
23
+ meta: media.meta || {},
24
+ description: media.description || media.alt || null,
25
+ blurhash: media.blurhash || null,
26
+ };
27
+ }
28
+
29
+ /**
30
+ * Map MIME type or simple type string to Mastodon media type.
31
+ */
32
+ function detectMediaType(contentType) {
33
+ if (contentType.startsWith("image/") || contentType === "image") return "image";
34
+ if (contentType.startsWith("video/") || contentType === "video") return "video";
35
+ if (contentType.startsWith("audio/") || contentType === "audio") return "audio";
36
+ if (contentType.startsWith("image/gif")) return "gifv";
37
+ return "unknown";
38
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Notification entity serializer for Mastodon Client API.
3
+ *
4
+ * Converts ap_notifications documents into the Mastodon Notification JSON shape.
5
+ *
6
+ * Internal type -> Mastodon type mapping:
7
+ * like -> favourite
8
+ * boost -> reblog
9
+ * follow -> follow
10
+ * reply -> mention
11
+ * mention -> mention
12
+ * dm -> mention (status will have visibility: "direct")
13
+ */
14
+ import { serializeAccount } from "./account.js";
15
+ import { serializeStatus } from "./status.js";
16
+
17
+ /**
18
+ * Map internal notification types to Mastodon API types.
19
+ */
20
+ const TYPE_MAP = {
21
+ like: "favourite",
22
+ boost: "reblog",
23
+ follow: "follow",
24
+ follow_request: "follow_request",
25
+ reply: "mention",
26
+ mention: "mention",
27
+ dm: "mention",
28
+ report: "admin.report",
29
+ };
30
+
31
+ /**
32
+ * Serialize a notification document as a Mastodon Notification entity.
33
+ *
34
+ * @param {object} notif - ap_notifications document
35
+ * @param {object} options
36
+ * @param {string} options.baseUrl - Server base URL
37
+ * @param {Map<string, object>} [options.statusMap] - Pre-fetched statuses keyed by targetUrl
38
+ * @param {object} [options.interactionState] - { favouritedIds, rebloggedIds, bookmarkedIds }
39
+ * @returns {object|null} Mastodon Notification entity
40
+ */
41
+ export function serializeNotification(notif, { baseUrl, statusMap, interactionState }) {
42
+ if (!notif) return null;
43
+
44
+ const mastodonType = TYPE_MAP[notif.type] || notif.type;
45
+
46
+ // Build the actor account from notification fields
47
+ const account = serializeAccount(
48
+ {
49
+ name: notif.actorName,
50
+ url: notif.actorUrl,
51
+ photo: notif.actorPhoto,
52
+ handle: notif.actorHandle,
53
+ },
54
+ { baseUrl },
55
+ );
56
+
57
+ // Resolve the associated status (for favourite, reblog, mention types)
58
+ let status = null;
59
+ if (notif.targetUrl && statusMap) {
60
+ const timelineItem = statusMap.get(notif.targetUrl);
61
+ if (timelineItem) {
62
+ status = serializeStatus(timelineItem, {
63
+ baseUrl,
64
+ favouritedIds: interactionState?.favouritedIds || new Set(),
65
+ rebloggedIds: interactionState?.rebloggedIds || new Set(),
66
+ bookmarkedIds: interactionState?.bookmarkedIds || new Set(),
67
+ pinnedIds: new Set(),
68
+ });
69
+ }
70
+ }
71
+
72
+ // For mentions/replies that don't have a matching timeline item,
73
+ // construct a minimal status from the notification content
74
+ if (!status && notif.content && (mastodonType === "mention")) {
75
+ status = {
76
+ id: notif._id.toString(),
77
+ created_at: notif.published || notif.createdAt || new Date().toISOString(),
78
+ in_reply_to_id: null,
79
+ in_reply_to_account_id: null,
80
+ sensitive: false,
81
+ spoiler_text: "",
82
+ visibility: notif.type === "dm" ? "direct" : "public",
83
+ language: null,
84
+ uri: notif.uid || "",
85
+ url: notif.targetUrl || notif.uid || "",
86
+ replies_count: 0,
87
+ reblogs_count: 0,
88
+ favourites_count: 0,
89
+ edited_at: null,
90
+ favourited: false,
91
+ reblogged: false,
92
+ muted: false,
93
+ bookmarked: false,
94
+ pinned: false,
95
+ content: notif.content?.html || notif.content?.text || "",
96
+ filtered: null,
97
+ reblog: null,
98
+ application: null,
99
+ account,
100
+ media_attachments: [],
101
+ mentions: [],
102
+ tags: [],
103
+ emojis: [],
104
+ card: null,
105
+ poll: null,
106
+ };
107
+ }
108
+
109
+ return {
110
+ id: notif._id.toString(),
111
+ type: mastodonType,
112
+ created_at: notif.published instanceof Date
113
+ ? notif.published.toISOString()
114
+ : notif.published || notif.createdAt || new Date().toISOString(),
115
+ account,
116
+ status,
117
+ };
118
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Relationship entity serializer for Mastodon Client API.
3
+ *
4
+ * Represents the relationship between the authenticated user
5
+ * and another account.
6
+ */
7
+
8
+ /**
9
+ * Serialize a Relationship entity.
10
+ *
11
+ * @param {string} id - Account ID
12
+ * @param {object} state - Relationship state
13
+ * @param {boolean} [state.following=false]
14
+ * @param {boolean} [state.followed_by=false]
15
+ * @param {boolean} [state.blocking=false]
16
+ * @param {boolean} [state.muting=false]
17
+ * @param {boolean} [state.requested=false]
18
+ * @returns {object} Mastodon Relationship entity
19
+ */
20
+ export function serializeRelationship(id, state = {}) {
21
+ return {
22
+ id,
23
+ following: state.following || false,
24
+ showing_reblogs: state.following || false,
25
+ notifying: false,
26
+ languages: [],
27
+ followed_by: state.followed_by || false,
28
+ blocking: state.blocking || false,
29
+ blocked_by: false,
30
+ muting: state.muting || false,
31
+ muting_notifications: state.muting || false,
32
+ requested: state.requested || false,
33
+ requested_by: false,
34
+ domain_blocking: false,
35
+ endorsed: false,
36
+ note: "",
37
+ };
38
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * XSS HTML sanitizer for Mastodon Client API responses.
3
+ *
4
+ * Strips dangerous HTML while preserving safe markup that
5
+ * Mastodon clients expect (links, paragraphs, line breaks,
6
+ * inline formatting, mentions, hashtags).
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
+ };
40
+
41
+ /**
42
+ * 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
+ * @param {string} html - Raw HTML string
49
+ * @returns {string} Sanitized HTML
50
+ */
51
+ export function sanitizeHtml(html) {
52
+ if (!html || typeof html !== "string") return "";
53
+
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}>`;
87
+ });
88
+ }
89
+
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
+ /**
104
+ * Strip all HTML tags, returning plain text.
105
+ * @param {string} html
106
+ * @returns {string}
107
+ */
108
+ export function stripHtml(html) {
109
+ if (!html || typeof html !== "string") return "";
110
+ return html.replace(/<[^>]*>/g, "").trim();
111
+ }