@rmdes/indiekit-endpoint-activitypub 2.15.3 → 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.
@@ -0,0 +1,289 @@
1
+ /**
2
+ * Status entity serializer for Mastodon Client API.
3
+ *
4
+ * Converts ap_timeline documents into the Mastodon Status JSON shape.
5
+ *
6
+ * CORRECTED field mappings (based on actual extractObjectData output):
7
+ * content <- content.html (NOT contentHtml)
8
+ * uri <- uid (NOT activityUrl)
9
+ * account <- author { name, url, photo, handle, emojis, bot }
10
+ * media <- photo[] + video[] + audio[] (NOT single attachments[])
11
+ * card <- linkPreviews[0] (NOT single card)
12
+ * tags <- category[] (NOT tags[])
13
+ * counts <- counts.boosts, counts.likes, counts.replies
14
+ * boost <- type:"boost" + boostedBy (flat, NOT nested sharedItem)
15
+ */
16
+ import { serializeAccount } from "./account.js";
17
+ import { sanitizeHtml } from "./sanitize.js";
18
+
19
+ /**
20
+ * Serialize an ap_timeline document as a Mastodon Status entity.
21
+ *
22
+ * @param {object} item - ap_timeline document
23
+ * @param {object} options
24
+ * @param {string} options.baseUrl - Server base URL
25
+ * @param {Set<string>} [options.favouritedIds] - UIDs the user has liked
26
+ * @param {Set<string>} [options.rebloggedIds] - UIDs the user has boosted
27
+ * @param {Set<string>} [options.bookmarkedIds] - UIDs the user has bookmarked
28
+ * @param {Set<string>} [options.pinnedIds] - UIDs the user has pinned
29
+ * @returns {object} Mastodon Status entity
30
+ */
31
+ export function serializeStatus(item, { baseUrl, favouritedIds, rebloggedIds, bookmarkedIds, pinnedIds }) {
32
+ if (!item) return null;
33
+
34
+ const id = item._id.toString();
35
+ const uid = item.uid || "";
36
+ const url = item.url || uid;
37
+
38
+ // Handle boosts — reconstruct nested reblog wrapper
39
+ if (item.type === "boost" && item.boostedBy) {
40
+ // The outer status represents the boost action
41
+ // The inner status is the original post (the item itself minus boost metadata)
42
+ const innerItem = { ...item, type: "note", boostedBy: undefined, boostedAt: undefined };
43
+ const innerStatus = serializeStatus(innerItem, {
44
+ baseUrl,
45
+ favouritedIds,
46
+ rebloggedIds,
47
+ bookmarkedIds,
48
+ pinnedIds,
49
+ });
50
+
51
+ return {
52
+ id,
53
+ created_at: item.boostedAt || item.createdAt || new Date().toISOString(),
54
+ in_reply_to_id: null,
55
+ in_reply_to_account_id: null,
56
+ sensitive: false,
57
+ spoiler_text: "",
58
+ visibility: item.visibility || "public",
59
+ language: null,
60
+ uri: uid,
61
+ url,
62
+ replies_count: 0,
63
+ reblogs_count: 0,
64
+ favourites_count: 0,
65
+ edited_at: null,
66
+ favourited: false,
67
+ reblogged: rebloggedIds?.has(uid) || false,
68
+ muted: false,
69
+ bookmarked: false,
70
+ pinned: false,
71
+ content: "",
72
+ filtered: null,
73
+ reblog: innerStatus,
74
+ application: null,
75
+ account: serializeAccount(item.boostedBy, { baseUrl }),
76
+ media_attachments: [],
77
+ mentions: [],
78
+ tags: [],
79
+ emojis: [],
80
+ card: null,
81
+ poll: null,
82
+ };
83
+ }
84
+
85
+ // Regular status (note, article, question)
86
+ const content = item.content?.html || item.content?.text || "";
87
+ const spoilerText = item.summary || "";
88
+ const sensitive = item.sensitive || false;
89
+ const visibility = item.visibility || "public";
90
+ const language = item.language || null;
91
+ const published = item.published || item.createdAt || new Date().toISOString();
92
+ const editedAt = item.updated || item.updatedAt || null;
93
+
94
+ // Media attachments — merge photo, video, audio arrays
95
+ const mediaAttachments = [];
96
+ let attachmentCounter = 0;
97
+
98
+ if (item.photo?.length > 0) {
99
+ for (const p of item.photo) {
100
+ mediaAttachments.push({
101
+ id: `${id}-${attachmentCounter++}`,
102
+ type: "image",
103
+ url: typeof p === "string" ? p : p.url,
104
+ preview_url: typeof p === "string" ? p : p.url,
105
+ remote_url: typeof p === "string" ? p : p.url,
106
+ text_url: null,
107
+ meta: buildImageMeta(p),
108
+ description: typeof p === "object" ? p.alt || "" : "",
109
+ blurhash: null,
110
+ });
111
+ }
112
+ }
113
+
114
+ if (item.video?.length > 0) {
115
+ for (const v of item.video) {
116
+ mediaAttachments.push({
117
+ id: `${id}-${attachmentCounter++}`,
118
+ type: "video",
119
+ url: typeof v === "string" ? v : v.url,
120
+ preview_url: typeof v === "string" ? v : v.url,
121
+ remote_url: typeof v === "string" ? v : v.url,
122
+ text_url: null,
123
+ meta: null,
124
+ description: typeof v === "object" ? v.alt || "" : "",
125
+ blurhash: null,
126
+ });
127
+ }
128
+ }
129
+
130
+ if (item.audio?.length > 0) {
131
+ for (const a of item.audio) {
132
+ mediaAttachments.push({
133
+ id: `${id}-${attachmentCounter++}`,
134
+ type: "audio",
135
+ url: typeof a === "string" ? a : a.url,
136
+ preview_url: typeof a === "string" ? a : a.url,
137
+ remote_url: typeof a === "string" ? a : a.url,
138
+ text_url: null,
139
+ meta: null,
140
+ description: typeof a === "object" ? a.alt || "" : "",
141
+ blurhash: null,
142
+ });
143
+ }
144
+ }
145
+
146
+ // Link preview -> card
147
+ const card = serializeCard(item.linkPreviews?.[0]);
148
+
149
+ // Tags from category[]
150
+ const tags = (item.category || []).map((tag) => ({
151
+ name: tag,
152
+ url: `${baseUrl}/tags/${encodeURIComponent(tag)}`,
153
+ }));
154
+
155
+ // Mentions
156
+ const mentions = (item.mentions || []).map((m) => ({
157
+ id: "0", // We don't have stable IDs for mentioned accounts
158
+ username: m.name || "",
159
+ url: m.url || "",
160
+ acct: m.name || "",
161
+ }));
162
+
163
+ // Custom emojis
164
+ const emojis = (item.emojis || []).map((e) => ({
165
+ shortcode: e.shortcode || "",
166
+ url: e.url || "",
167
+ static_url: e.url || "",
168
+ visible_in_picker: true,
169
+ }));
170
+
171
+ // Counts
172
+ const repliesCount = item.counts?.replies ?? 0;
173
+ const reblogsCount = item.counts?.boosts ?? 0;
174
+ const favouritesCount = item.counts?.likes ?? 0;
175
+
176
+ // Poll
177
+ const poll = serializePoll(item, id);
178
+
179
+ // Interaction state
180
+ const favourited = favouritedIds?.has(uid) || false;
181
+ const reblogged = rebloggedIds?.has(uid) || false;
182
+ const bookmarked = bookmarkedIds?.has(uid) || false;
183
+ const pinned = pinnedIds?.has(uid) || false;
184
+
185
+ return {
186
+ id,
187
+ created_at: published,
188
+ in_reply_to_id: item.inReplyTo ? null : null, // TODO: resolve to local ID
189
+ in_reply_to_account_id: null, // TODO: resolve
190
+ sensitive,
191
+ spoiler_text: spoilerText,
192
+ visibility,
193
+ language,
194
+ uri: uid,
195
+ url,
196
+ replies_count: repliesCount,
197
+ reblogs_count: reblogsCount,
198
+ favourites_count: favouritesCount,
199
+ edited_at: editedAt || null,
200
+ favourited,
201
+ reblogged,
202
+ muted: false,
203
+ bookmarked,
204
+ pinned,
205
+ content: sanitizeHtml(content),
206
+ filtered: null,
207
+ reblog: null,
208
+ application: null,
209
+ account: item.author
210
+ ? serializeAccount(item.author, { baseUrl })
211
+ : null,
212
+ media_attachments: mediaAttachments,
213
+ mentions,
214
+ tags,
215
+ emojis,
216
+ card,
217
+ poll,
218
+ };
219
+ }
220
+
221
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
222
+
223
+ /**
224
+ * Serialize a linkPreview object as a Mastodon PreviewCard.
225
+ */
226
+ function serializeCard(preview) {
227
+ if (!preview) return null;
228
+
229
+ return {
230
+ url: preview.url || "",
231
+ title: preview.title || "",
232
+ description: preview.description || "",
233
+ type: "link",
234
+ author_name: "",
235
+ author_url: "",
236
+ provider_name: preview.domain || "",
237
+ provider_url: "",
238
+ html: "",
239
+ width: 0,
240
+ height: 0,
241
+ image: preview.image || null,
242
+ embed_url: "",
243
+ blurhash: null,
244
+ language: null,
245
+ published_at: null,
246
+ };
247
+ }
248
+
249
+ /**
250
+ * Build image meta object for media attachments.
251
+ */
252
+ function buildImageMeta(photo) {
253
+ if (typeof photo === "string") return null;
254
+ if (!photo.width && !photo.height) return null;
255
+
256
+ return {
257
+ original: {
258
+ width: photo.width || 0,
259
+ height: photo.height || 0,
260
+ size: photo.width && photo.height ? `${photo.width}x${photo.height}` : null,
261
+ aspect: photo.width && photo.height ? photo.width / photo.height : null,
262
+ },
263
+ };
264
+ }
265
+
266
+ /**
267
+ * Serialize poll data from a timeline item.
268
+ */
269
+ function serializePoll(item, statusId) {
270
+ if (!item.pollOptions?.length) return null;
271
+
272
+ const totalVotes = item.pollOptions.reduce((sum, o) => sum + (o.votes || 0), 0);
273
+
274
+ return {
275
+ id: statusId,
276
+ expires_at: item.pollEndTime || null,
277
+ expired: item.pollClosed || false,
278
+ multiple: false,
279
+ votes_count: totalVotes,
280
+ voters_count: item.votersCount || null,
281
+ options: item.pollOptions.map((o) => ({
282
+ title: o.name || "",
283
+ votes_count: o.votes || 0,
284
+ })),
285
+ emojis: [],
286
+ voted: false,
287
+ own_votes: [],
288
+ };
289
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Deterministic ID mapping for Mastodon Client API.
3
+ *
4
+ * Local accounts use MongoDB _id.toString().
5
+ * Remote actors use sha256(actorUrl).slice(0, 24) for stable IDs
6
+ * without requiring a dedicated accounts collection.
7
+ */
8
+ import crypto from "node:crypto";
9
+
10
+ /**
11
+ * Generate a deterministic ID for a remote actor URL.
12
+ * @param {string} actorUrl - The remote actor's URL
13
+ * @returns {string} 24-character hex ID
14
+ */
15
+ export function remoteActorId(actorUrl) {
16
+ return crypto.createHash("sha256").update(actorUrl).digest("hex").slice(0, 24);
17
+ }
18
+
19
+ /**
20
+ * Get the Mastodon API ID for an account.
21
+ * @param {object} actor - Actor object (local profile or remote author)
22
+ * @param {boolean} isLocal - Whether this is the local profile
23
+ * @returns {string}
24
+ */
25
+ export function accountId(actor, isLocal = false) {
26
+ if (isLocal && actor._id) {
27
+ return actor._id.toString();
28
+ }
29
+ // Remote actors: use URL-based deterministic hash
30
+ const url = actor.url || actor.actorUrl || "";
31
+ return url ? remoteActorId(url) : "0";
32
+ }
@@ -0,0 +1,278 @@
1
+ /**
2
+ * Shared interaction logic for like/unlike, boost/unboost, bookmark/unbookmark.
3
+ *
4
+ * Extracted from admin controllers (interactions-like.js, interactions-boost.js)
5
+ * so that both the admin UI and Mastodon Client API can reuse the same core logic.
6
+ *
7
+ * Each function accepts a context object instead of Express req/res,
8
+ * making them transport-agnostic.
9
+ */
10
+
11
+ import { resolveAuthor } from "../../resolve-author.js";
12
+
13
+ /**
14
+ * Like a post — send Like activity and track in ap_interactions.
15
+ *
16
+ * @param {object} params
17
+ * @param {string} params.targetUrl - URL of the post to like
18
+ * @param {object} params.federation - Fedify federation instance
19
+ * @param {string} params.handle - Local actor handle
20
+ * @param {string} params.publicationUrl - Publication base URL
21
+ * @param {object} params.collections - MongoDB collections (Map or object)
22
+ * @param {object} params.interactions - ap_interactions collection
23
+ * @returns {Promise<{ activityId: string }>}
24
+ */
25
+ export async function likePost({ targetUrl, federation, handle, publicationUrl, collections, interactions }) {
26
+ const { Like } = await import("@fedify/fedify/vocab");
27
+ const ctx = federation.createContext(
28
+ new URL(publicationUrl),
29
+ { handle, publicationUrl },
30
+ );
31
+
32
+ const documentLoader = await ctx.getDocumentLoader({ identifier: handle });
33
+ const recipient = await resolveAuthor(targetUrl, ctx, documentLoader, collections);
34
+
35
+ const uuid = crypto.randomUUID();
36
+ const baseUrl = publicationUrl.replace(/\/$/, "");
37
+ const activityId = `${baseUrl}/activitypub/likes/${uuid}`;
38
+
39
+ const like = new Like({
40
+ id: new URL(activityId),
41
+ actor: ctx.getActorUri(handle),
42
+ object: new URL(targetUrl),
43
+ });
44
+
45
+ if (recipient) {
46
+ await ctx.sendActivity({ identifier: handle }, recipient, like, {
47
+ orderingKey: targetUrl,
48
+ });
49
+ }
50
+
51
+ if (interactions) {
52
+ await interactions.updateOne(
53
+ { objectUrl: targetUrl, type: "like" },
54
+ {
55
+ $set: {
56
+ objectUrl: targetUrl,
57
+ type: "like",
58
+ activityId,
59
+ recipientUrl: recipient?.id?.href || "",
60
+ createdAt: new Date().toISOString(),
61
+ },
62
+ },
63
+ { upsert: true },
64
+ );
65
+ }
66
+
67
+ return { activityId };
68
+ }
69
+
70
+ /**
71
+ * Unlike a post — send Undo(Like) activity and remove from ap_interactions.
72
+ *
73
+ * @param {object} params
74
+ * @param {string} params.targetUrl - URL of the post to unlike
75
+ * @param {object} params.federation - Fedify federation instance
76
+ * @param {string} params.handle - Local actor handle
77
+ * @param {string} params.publicationUrl - Publication base URL
78
+ * @param {object} params.collections - MongoDB collections
79
+ * @param {object} params.interactions - ap_interactions collection
80
+ * @returns {Promise<void>}
81
+ */
82
+ export async function unlikePost({ targetUrl, federation, handle, publicationUrl, collections, interactions }) {
83
+ const existing = interactions
84
+ ? await interactions.findOne({ objectUrl: targetUrl, type: "like" })
85
+ : null;
86
+
87
+ if (!existing) {
88
+ return;
89
+ }
90
+
91
+ const { Like, Undo } = await import("@fedify/fedify/vocab");
92
+ const ctx = federation.createContext(
93
+ new URL(publicationUrl),
94
+ { handle, publicationUrl },
95
+ );
96
+
97
+ const documentLoader = await ctx.getDocumentLoader({ identifier: handle });
98
+ const recipient = await resolveAuthor(targetUrl, ctx, documentLoader, collections);
99
+
100
+ if (recipient) {
101
+ const like = new Like({
102
+ id: existing.activityId ? new URL(existing.activityId) : undefined,
103
+ actor: ctx.getActorUri(handle),
104
+ object: new URL(targetUrl),
105
+ });
106
+
107
+ const undo = new Undo({
108
+ actor: ctx.getActorUri(handle),
109
+ object: like,
110
+ });
111
+
112
+ await ctx.sendActivity({ identifier: handle }, recipient, undo, {
113
+ orderingKey: targetUrl,
114
+ });
115
+ }
116
+
117
+ if (interactions) {
118
+ await interactions.deleteOne({ objectUrl: targetUrl, type: "like" });
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Boost a post — send Announce activity and track in ap_interactions.
124
+ *
125
+ * @param {object} params
126
+ * @param {string} params.targetUrl - URL of the post to boost
127
+ * @param {object} params.federation - Fedify federation instance
128
+ * @param {string} params.handle - Local actor handle
129
+ * @param {string} params.publicationUrl - Publication base URL
130
+ * @param {object} params.collections - MongoDB collections
131
+ * @param {object} params.interactions - ap_interactions collection
132
+ * @returns {Promise<{ activityId: string }>}
133
+ */
134
+ export async function boostPost({ targetUrl, federation, handle, publicationUrl, collections, interactions }) {
135
+ const { Announce } = await import("@fedify/fedify/vocab");
136
+ const ctx = federation.createContext(
137
+ new URL(publicationUrl),
138
+ { handle, publicationUrl },
139
+ );
140
+
141
+ const uuid = crypto.randomUUID();
142
+ const baseUrl = publicationUrl.replace(/\/$/, "");
143
+ const activityId = `${baseUrl}/activitypub/boosts/${uuid}`;
144
+
145
+ const publicAddress = new URL("https://www.w3.org/ns/activitystreams#Public");
146
+ const followersUri = ctx.getFollowersUri(handle);
147
+
148
+ const announce = new Announce({
149
+ id: new URL(activityId),
150
+ actor: ctx.getActorUri(handle),
151
+ object: new URL(targetUrl),
152
+ to: publicAddress,
153
+ cc: followersUri,
154
+ });
155
+
156
+ // Send to followers
157
+ await ctx.sendActivity({ identifier: handle }, "followers", announce, {
158
+ preferSharedInbox: true,
159
+ syncCollection: true,
160
+ orderingKey: targetUrl,
161
+ });
162
+
163
+ // Also send directly to the original post author
164
+ const documentLoader = await ctx.getDocumentLoader({ identifier: handle });
165
+ const recipient = await resolveAuthor(targetUrl, ctx, documentLoader, collections);
166
+ if (recipient) {
167
+ try {
168
+ await ctx.sendActivity({ identifier: handle }, recipient, announce, {
169
+ orderingKey: targetUrl,
170
+ });
171
+ } catch {
172
+ // Non-critical — follower delivery already happened
173
+ }
174
+ }
175
+
176
+ if (interactions) {
177
+ await interactions.updateOne(
178
+ { objectUrl: targetUrl, type: "boost" },
179
+ {
180
+ $set: {
181
+ objectUrl: targetUrl,
182
+ type: "boost",
183
+ activityId,
184
+ createdAt: new Date().toISOString(),
185
+ },
186
+ },
187
+ { upsert: true },
188
+ );
189
+ }
190
+
191
+ return { activityId };
192
+ }
193
+
194
+ /**
195
+ * Unboost a post — send Undo(Announce) activity and remove from ap_interactions.
196
+ *
197
+ * @param {object} params
198
+ * @param {string} params.targetUrl - URL of the post to unboost
199
+ * @param {object} params.federation - Fedify federation instance
200
+ * @param {string} params.handle - Local actor handle
201
+ * @param {string} params.publicationUrl - Publication base URL
202
+ * @param {object} params.interactions - ap_interactions collection
203
+ * @returns {Promise<void>}
204
+ */
205
+ export async function unboostPost({ targetUrl, federation, handle, publicationUrl, interactions }) {
206
+ const existing = interactions
207
+ ? await interactions.findOne({ objectUrl: targetUrl, type: "boost" })
208
+ : null;
209
+
210
+ if (!existing) {
211
+ return;
212
+ }
213
+
214
+ const { Announce, Undo } = await import("@fedify/fedify/vocab");
215
+ const ctx = federation.createContext(
216
+ new URL(publicationUrl),
217
+ { handle, publicationUrl },
218
+ );
219
+
220
+ const announce = new Announce({
221
+ id: existing.activityId ? new URL(existing.activityId) : undefined,
222
+ actor: ctx.getActorUri(handle),
223
+ object: new URL(targetUrl),
224
+ });
225
+
226
+ const undo = new Undo({
227
+ actor: ctx.getActorUri(handle),
228
+ object: announce,
229
+ });
230
+
231
+ await ctx.sendActivity({ identifier: handle }, "followers", undo, {
232
+ preferSharedInbox: true,
233
+ syncCollection: true,
234
+ orderingKey: targetUrl,
235
+ });
236
+
237
+ if (interactions) {
238
+ await interactions.deleteOne({ objectUrl: targetUrl, type: "boost" });
239
+ }
240
+ }
241
+
242
+ /**
243
+ * Bookmark a post — local-only, no federation.
244
+ *
245
+ * @param {object} params
246
+ * @param {string} params.targetUrl - URL of the post to bookmark
247
+ * @param {object} params.interactions - ap_interactions collection
248
+ * @returns {Promise<void>}
249
+ */
250
+ export async function bookmarkPost({ targetUrl, interactions }) {
251
+ if (!interactions) return;
252
+
253
+ await interactions.updateOne(
254
+ { objectUrl: targetUrl, type: "bookmark" },
255
+ {
256
+ $set: {
257
+ objectUrl: targetUrl,
258
+ type: "bookmark",
259
+ createdAt: new Date().toISOString(),
260
+ },
261
+ },
262
+ { upsert: true },
263
+ );
264
+ }
265
+
266
+ /**
267
+ * Remove a bookmark — local-only, no federation.
268
+ *
269
+ * @param {object} params
270
+ * @param {string} params.targetUrl - URL of the post to unbookmark
271
+ * @param {object} params.interactions - ap_interactions collection
272
+ * @returns {Promise<void>}
273
+ */
274
+ export async function unbookmarkPost({ targetUrl, interactions }) {
275
+ if (!interactions) return;
276
+
277
+ await interactions.deleteOne({ objectUrl: targetUrl, type: "bookmark" });
278
+ }