@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 +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 +552 -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 +634 -0
- package/lib/mastodon/routes/stubs.js +380 -0
- package/lib/mastodon/routes/timelines.js +281 -0
- package/package.json +1 -1
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, "&")
|
|
98
|
+
.replace(/"/g, """)
|
|
99
|
+
.replace(/</g, "<")
|
|
100
|
+
.replace(/>/g, ">");
|
|
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
|
+
}
|