@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.
- package/index.js +47 -0
- package/lib/controllers/compose.js +1 -1
- package/lib/jf2-to-as2.js +9 -3
- 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
|
@@ -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
|
+
}
|