@rmdes/indiekit-endpoint-activitypub 3.0.0 → 3.3.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 +14 -0
- package/lib/mastodon/backfill-timeline.js +167 -0
- package/lib/mastodon/routes/accounts.js +235 -31
- package/lib/mastodon/routes/statuses.js +77 -106
- package/lib/mastodon/routes/timelines.js +16 -1
- package/package.json +2 -1
package/index.js
CHANGED
|
@@ -1552,6 +1552,20 @@ export default class ActivityPubEndpoint {
|
|
|
1552
1552
|
keyRefreshHandle,
|
|
1553
1553
|
);
|
|
1554
1554
|
|
|
1555
|
+
// Backfill ap_timeline from posts collection (idempotent, runs on every startup)
|
|
1556
|
+
import("./lib/mastodon/backfill-timeline.js").then(({ backfillTimeline }) => {
|
|
1557
|
+
// Delay to let MongoDB connections settle
|
|
1558
|
+
setTimeout(() => {
|
|
1559
|
+
backfillTimeline(this._collections).then(({ total, inserted, skipped }) => {
|
|
1560
|
+
if (inserted > 0) {
|
|
1561
|
+
console.log(`[Mastodon API] Timeline backfill: ${inserted} posts added (${skipped} already existed, ${total} total)`);
|
|
1562
|
+
}
|
|
1563
|
+
}).catch((error) => {
|
|
1564
|
+
console.warn("[Mastodon API] Timeline backfill failed:", error.message);
|
|
1565
|
+
});
|
|
1566
|
+
}, 5000);
|
|
1567
|
+
});
|
|
1568
|
+
|
|
1555
1569
|
// Start async inbox queue processor (processes one item every 3s)
|
|
1556
1570
|
this._inboxProcessorInterval = startInboxProcessor(
|
|
1557
1571
|
this._collections,
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Backfill ap_timeline from the posts collection.
|
|
3
|
+
*
|
|
4
|
+
* Runs on startup (idempotent — uses upsert by uid).
|
|
5
|
+
* Converts Micropub JF2 posts into ap_timeline format so they
|
|
6
|
+
* appear in Mastodon Client API timelines and profile views.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Backfill ap_timeline with published posts from the posts collection.
|
|
11
|
+
*
|
|
12
|
+
* @param {object} collections - MongoDB collections (must include posts, ap_timeline, ap_profile)
|
|
13
|
+
* @returns {Promise<{ total: number, inserted: number, skipped: number }>}
|
|
14
|
+
*/
|
|
15
|
+
export async function backfillTimeline(collections) {
|
|
16
|
+
const { posts, ap_timeline, ap_profile } = collections;
|
|
17
|
+
|
|
18
|
+
if (!posts || !ap_timeline) {
|
|
19
|
+
return { total: 0, inserted: 0, skipped: 0 };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Get local profile for author info
|
|
23
|
+
const profile = await ap_profile?.findOne({});
|
|
24
|
+
const author = profile
|
|
25
|
+
? {
|
|
26
|
+
name: profile.name || "",
|
|
27
|
+
url: profile.url || "",
|
|
28
|
+
photo: profile.icon || "",
|
|
29
|
+
handle: "",
|
|
30
|
+
}
|
|
31
|
+
: { name: "", url: "", photo: "", handle: "" };
|
|
32
|
+
|
|
33
|
+
// Fetch all published posts
|
|
34
|
+
const allPosts = await posts
|
|
35
|
+
.find({
|
|
36
|
+
"properties.post-status": { $ne: "draft" },
|
|
37
|
+
"properties.deleted": { $exists: false },
|
|
38
|
+
"properties.url": { $exists: true },
|
|
39
|
+
})
|
|
40
|
+
.toArray();
|
|
41
|
+
|
|
42
|
+
let inserted = 0;
|
|
43
|
+
let skipped = 0;
|
|
44
|
+
|
|
45
|
+
for (const post of allPosts) {
|
|
46
|
+
const props = post.properties;
|
|
47
|
+
if (!props?.url) {
|
|
48
|
+
skipped++;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const uid = props.url;
|
|
53
|
+
|
|
54
|
+
// Check if already in timeline (fast path to avoid unnecessary upserts)
|
|
55
|
+
const exists = await ap_timeline.findOne({ uid }, { projection: { _id: 1 } });
|
|
56
|
+
if (exists) {
|
|
57
|
+
skipped++;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Map JF2 properties to timeline item format
|
|
62
|
+
const content = normalizeContent(props.content);
|
|
63
|
+
const type = mapPostType(props["post-type"]);
|
|
64
|
+
|
|
65
|
+
const timelineItem = {
|
|
66
|
+
uid,
|
|
67
|
+
url: uid,
|
|
68
|
+
type,
|
|
69
|
+
content,
|
|
70
|
+
author,
|
|
71
|
+
published: props.published || props.date || new Date().toISOString(),
|
|
72
|
+
createdAt: props.published || props.date || new Date().toISOString(),
|
|
73
|
+
visibility: "public",
|
|
74
|
+
sensitive: false,
|
|
75
|
+
category: normalizeArray(props.category),
|
|
76
|
+
photo: normalizeMediaArray(props.photo),
|
|
77
|
+
video: normalizeMediaArray(props.video),
|
|
78
|
+
audio: normalizeMediaArray(props.audio),
|
|
79
|
+
readBy: [],
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// Optional fields
|
|
83
|
+
if (props.name) timelineItem.name = props.name;
|
|
84
|
+
if (props.summary) timelineItem.summary = props.summary;
|
|
85
|
+
if (props["in-reply-to"]) {
|
|
86
|
+
timelineItem.inReplyTo = Array.isArray(props["in-reply-to"])
|
|
87
|
+
? props["in-reply-to"][0]
|
|
88
|
+
: props["in-reply-to"];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
const result = await ap_timeline.updateOne(
|
|
93
|
+
{ uid },
|
|
94
|
+
{ $setOnInsert: timelineItem },
|
|
95
|
+
{ upsert: true },
|
|
96
|
+
);
|
|
97
|
+
if (result.upsertedCount > 0) {
|
|
98
|
+
inserted++;
|
|
99
|
+
} else {
|
|
100
|
+
skipped++;
|
|
101
|
+
}
|
|
102
|
+
} catch {
|
|
103
|
+
skipped++;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return { total: allPosts.length, inserted, skipped };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Normalize content from JF2 properties to { text, html } format.
|
|
112
|
+
*/
|
|
113
|
+
function normalizeContent(content) {
|
|
114
|
+
if (!content) return { text: "", html: "" };
|
|
115
|
+
if (typeof content === "string") return { text: content, html: `<p>${content}</p>` };
|
|
116
|
+
if (typeof content === "object") {
|
|
117
|
+
return {
|
|
118
|
+
text: content.text || content.value || "",
|
|
119
|
+
html: content.html || content.text || content.value || "",
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
return { text: "", html: "" };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Map Micropub post-type to timeline type.
|
|
127
|
+
*/
|
|
128
|
+
function mapPostType(postType) {
|
|
129
|
+
switch (postType) {
|
|
130
|
+
case "article":
|
|
131
|
+
return "article";
|
|
132
|
+
case "photo":
|
|
133
|
+
case "video":
|
|
134
|
+
case "audio":
|
|
135
|
+
return "note";
|
|
136
|
+
case "reply":
|
|
137
|
+
return "note";
|
|
138
|
+
case "repost":
|
|
139
|
+
return "boost";
|
|
140
|
+
case "like":
|
|
141
|
+
return "note";
|
|
142
|
+
default:
|
|
143
|
+
return "note";
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Normalize a value to an array of strings.
|
|
149
|
+
*/
|
|
150
|
+
function normalizeArray(value) {
|
|
151
|
+
if (!value) return [];
|
|
152
|
+
if (Array.isArray(value)) return value.map(String);
|
|
153
|
+
return [String(value)];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Normalize media values (can be strings or objects with url property).
|
|
158
|
+
*/
|
|
159
|
+
function normalizeMediaArray(value) {
|
|
160
|
+
if (!value) return [];
|
|
161
|
+
const arr = Array.isArray(value) ? value : [value];
|
|
162
|
+
return arr.map((item) => {
|
|
163
|
+
if (typeof item === "string") return item;
|
|
164
|
+
if (typeof item === "object" && item.url) return item;
|
|
165
|
+
return null;
|
|
166
|
+
}).filter(Boolean);
|
|
167
|
+
}
|
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import express from "express";
|
|
8
8
|
import { serializeCredentialAccount, serializeAccount } from "../entities/account.js";
|
|
9
|
+
import { serializeStatus } from "../entities/status.js";
|
|
9
10
|
import { accountId, remoteActorId } from "../helpers/id-mapping.js";
|
|
11
|
+
import { buildPaginationQuery, parseLimit, setPaginationHeaders } from "../helpers/pagination.js";
|
|
10
12
|
|
|
11
13
|
const router = express.Router(); // eslint-disable-line new-cap
|
|
12
14
|
|
|
@@ -133,47 +135,187 @@ router.get("/api/v1/accounts/:id", async (req, res, next) => {
|
|
|
133
135
|
// Check if it's the local profile
|
|
134
136
|
const profile = await collections.ap_profile.findOne({});
|
|
135
137
|
if (profile && profile._id.toString() === id) {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
138
|
+
const [statuses, followers, following] = await Promise.all([
|
|
139
|
+
collections.ap_timeline.countDocuments({ "author.url": profile.url }),
|
|
140
|
+
collections.ap_followers.countDocuments({}),
|
|
141
|
+
collections.ap_following.countDocuments({}),
|
|
142
|
+
]);
|
|
143
|
+
const account = serializeAccount(profile, { baseUrl, isLocal: true, handle });
|
|
144
|
+
account.statuses_count = statuses;
|
|
145
|
+
account.followers_count = followers;
|
|
146
|
+
account.following_count = following;
|
|
147
|
+
return res.json(account);
|
|
139
148
|
}
|
|
140
149
|
|
|
141
|
-
//
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
150
|
+
// Resolve remote actor from followers, following, or timeline
|
|
151
|
+
const { actor, actorUrl } = await resolveActorData(id, collections);
|
|
152
|
+
if (actor) {
|
|
153
|
+
const account = serializeAccount(actor, { baseUrl });
|
|
154
|
+
// Count this actor's posts in our timeline
|
|
155
|
+
account.statuses_count = await collections.ap_timeline.countDocuments({
|
|
156
|
+
"author.url": actorUrl,
|
|
157
|
+
});
|
|
158
|
+
return res.json(account);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return res.status(404).json({ error: "Record not found" });
|
|
162
|
+
} catch (error) {
|
|
163
|
+
next(error);
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// ─── GET /api/v1/accounts/:id/statuses ──────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
router.get("/api/v1/accounts/:id/statuses", async (req, res, next) => {
|
|
170
|
+
try {
|
|
171
|
+
const { id } = req.params;
|
|
172
|
+
const collections = req.app.locals.mastodonCollections;
|
|
173
|
+
const baseUrl = `${req.protocol}://${req.get("host")}`;
|
|
174
|
+
const limit = parseLimit(req.query.limit);
|
|
175
|
+
|
|
176
|
+
// Resolve account ID to an author URL
|
|
177
|
+
const actorUrl = await resolveActorUrl(id, collections);
|
|
178
|
+
if (!actorUrl) {
|
|
179
|
+
return res.status(404).json({ error: "Record not found" });
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Build filter for this author's posts
|
|
183
|
+
const baseFilter = {
|
|
184
|
+
"author.url": actorUrl,
|
|
185
|
+
isContext: { $ne: true },
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
// Mastodon filters
|
|
189
|
+
if (req.query.only_media === "true") {
|
|
190
|
+
baseFilter.$or = [
|
|
191
|
+
{ "photo.0": { $exists: true } },
|
|
192
|
+
{ "video.0": { $exists: true } },
|
|
193
|
+
{ "audio.0": { $exists: true } },
|
|
194
|
+
];
|
|
195
|
+
}
|
|
196
|
+
if (req.query.exclude_replies === "true") {
|
|
197
|
+
baseFilter.inReplyTo = { $exists: false };
|
|
198
|
+
}
|
|
199
|
+
if (req.query.exclude_reblogs === "true") {
|
|
200
|
+
baseFilter.type = { $ne: "boost" };
|
|
201
|
+
}
|
|
202
|
+
if (req.query.pinned === "true") {
|
|
203
|
+
baseFilter.pinned = true;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const { filter, sort, reverse } = buildPaginationQuery(baseFilter, {
|
|
207
|
+
max_id: req.query.max_id,
|
|
208
|
+
min_id: req.query.min_id,
|
|
209
|
+
since_id: req.query.since_id,
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
let items = await collections.ap_timeline
|
|
213
|
+
.find(filter)
|
|
214
|
+
.sort(sort)
|
|
215
|
+
.limit(limit)
|
|
145
216
|
.toArray();
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
217
|
+
|
|
218
|
+
if (reverse) {
|
|
219
|
+
items.reverse();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Load interaction state if authenticated
|
|
223
|
+
let favouritedIds = new Set();
|
|
224
|
+
let rebloggedIds = new Set();
|
|
225
|
+
let bookmarkedIds = new Set();
|
|
226
|
+
|
|
227
|
+
if (req.mastodonToken && collections.ap_interactions) {
|
|
228
|
+
const lookupUrls = items.flatMap((i) => [i.uid, i.url].filter(Boolean));
|
|
229
|
+
if (lookupUrls.length > 0) {
|
|
230
|
+
const interactions = await collections.ap_interactions
|
|
231
|
+
.find({ objectUrl: { $in: lookupUrls } })
|
|
232
|
+
.toArray();
|
|
233
|
+
for (const ix of interactions) {
|
|
234
|
+
if (ix.type === "like") favouritedIds.add(ix.objectUrl);
|
|
235
|
+
else if (ix.type === "boost") rebloggedIds.add(ix.objectUrl);
|
|
236
|
+
else if (ix.type === "bookmark") bookmarkedIds.add(ix.objectUrl);
|
|
237
|
+
}
|
|
154
238
|
}
|
|
155
239
|
}
|
|
156
240
|
|
|
157
|
-
const
|
|
241
|
+
const statuses = items.map((item) =>
|
|
242
|
+
serializeStatus(item, {
|
|
243
|
+
baseUrl,
|
|
244
|
+
favouritedIds,
|
|
245
|
+
rebloggedIds,
|
|
246
|
+
bookmarkedIds,
|
|
247
|
+
pinnedIds: new Set(),
|
|
248
|
+
}),
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
setPaginationHeaders(res, req, items, limit);
|
|
252
|
+
res.json(statuses);
|
|
253
|
+
} catch (error) {
|
|
254
|
+
next(error);
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// ─── GET /api/v1/accounts/:id/followers ─────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
router.get("/api/v1/accounts/:id/followers", async (req, res, next) => {
|
|
261
|
+
try {
|
|
262
|
+
const { id } = req.params;
|
|
263
|
+
const collections = req.app.locals.mastodonCollections;
|
|
264
|
+
const baseUrl = `${req.protocol}://${req.get("host")}`;
|
|
265
|
+
const limit = parseLimit(req.query.limit);
|
|
266
|
+
const profile = await collections.ap_profile.findOne({});
|
|
267
|
+
|
|
268
|
+
// Only serve followers for the local account
|
|
269
|
+
if (!profile || profile._id.toString() !== id) {
|
|
270
|
+
return res.json([]);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const followers = await collections.ap_followers
|
|
158
274
|
.find({})
|
|
275
|
+
.limit(limit)
|
|
159
276
|
.toArray();
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
277
|
+
|
|
278
|
+
const accounts = followers.map((f) =>
|
|
279
|
+
serializeAccount(
|
|
280
|
+
{ name: f.name, url: f.actorUrl, photo: f.avatar, handle: f.handle },
|
|
281
|
+
{ baseUrl },
|
|
282
|
+
),
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
res.json(accounts);
|
|
286
|
+
} catch (error) {
|
|
287
|
+
next(error);
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// ─── GET /api/v1/accounts/:id/following ─────────────────────────────────────
|
|
292
|
+
|
|
293
|
+
router.get("/api/v1/accounts/:id/following", async (req, res, next) => {
|
|
294
|
+
try {
|
|
295
|
+
const { id } = req.params;
|
|
296
|
+
const collections = req.app.locals.mastodonCollections;
|
|
297
|
+
const baseUrl = `${req.protocol}://${req.get("host")}`;
|
|
298
|
+
const limit = parseLimit(req.query.limit);
|
|
299
|
+
const profile = await collections.ap_profile.findOne({});
|
|
300
|
+
|
|
301
|
+
// Only serve following for the local account
|
|
302
|
+
if (!profile || profile._id.toString() !== id) {
|
|
303
|
+
return res.json([]);
|
|
169
304
|
}
|
|
170
305
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
306
|
+
const following = await collections.ap_following
|
|
307
|
+
.find({})
|
|
308
|
+
.limit(limit)
|
|
309
|
+
.toArray();
|
|
310
|
+
|
|
311
|
+
const accounts = following.map((f) =>
|
|
312
|
+
serializeAccount(
|
|
313
|
+
{ name: f.name, url: f.actorUrl, photo: f.avatar, handle: f.handle },
|
|
314
|
+
{ baseUrl },
|
|
315
|
+
),
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
res.json(accounts);
|
|
177
319
|
} catch (error) {
|
|
178
320
|
next(error);
|
|
179
321
|
}
|
|
@@ -546,7 +688,69 @@ async function resolveActorUrl(id, collections) {
|
|
|
546
688
|
}
|
|
547
689
|
}
|
|
548
690
|
|
|
691
|
+
// Check timeline authors
|
|
692
|
+
const timelineItems = await collections.ap_timeline
|
|
693
|
+
.find({ "author.url": { $exists: true } })
|
|
694
|
+
.project({ "author.url": 1 })
|
|
695
|
+
.toArray();
|
|
696
|
+
|
|
697
|
+
const seenUrls = new Set();
|
|
698
|
+
for (const item of timelineItems) {
|
|
699
|
+
const authorUrl = item.author?.url;
|
|
700
|
+
if (!authorUrl || seenUrls.has(authorUrl)) continue;
|
|
701
|
+
seenUrls.add(authorUrl);
|
|
702
|
+
if (remoteActorId(authorUrl) === id) {
|
|
703
|
+
return authorUrl;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
549
707
|
return null;
|
|
550
708
|
}
|
|
551
709
|
|
|
710
|
+
/**
|
|
711
|
+
* Resolve an account ID to both actor data and URL.
|
|
712
|
+
* Returns { actor, actorUrl } or { actor: null, actorUrl: null }.
|
|
713
|
+
*/
|
|
714
|
+
async function resolveActorData(id, collections) {
|
|
715
|
+
// Check followers
|
|
716
|
+
const followers = await collections.ap_followers.find({}).toArray();
|
|
717
|
+
for (const f of followers) {
|
|
718
|
+
if (remoteActorId(f.actorUrl) === id) {
|
|
719
|
+
return {
|
|
720
|
+
actor: { name: f.name, url: f.actorUrl, photo: f.avatar, handle: f.handle },
|
|
721
|
+
actorUrl: f.actorUrl,
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// Check following
|
|
727
|
+
const following = await collections.ap_following.find({}).toArray();
|
|
728
|
+
for (const f of following) {
|
|
729
|
+
if (remoteActorId(f.actorUrl) === id) {
|
|
730
|
+
return {
|
|
731
|
+
actor: { name: f.name, url: f.actorUrl, photo: f.avatar, handle: f.handle },
|
|
732
|
+
actorUrl: f.actorUrl,
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// Check timeline authors
|
|
738
|
+
const timelineItems = await collections.ap_timeline
|
|
739
|
+
.find({ "author.url": { $exists: true } })
|
|
740
|
+
.project({ author: 1 })
|
|
741
|
+
.toArray();
|
|
742
|
+
|
|
743
|
+
const seenUrls = new Set();
|
|
744
|
+
for (const item of timelineItems) {
|
|
745
|
+
const authorUrl = item.author?.url;
|
|
746
|
+
if (!authorUrl || seenUrls.has(authorUrl)) continue;
|
|
747
|
+
seenUrls.add(authorUrl);
|
|
748
|
+
if (remoteActorId(authorUrl) === id) {
|
|
749
|
+
return { actor: item.author, actorUrl: authorUrl };
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
return { actor: null, actorUrl: null };
|
|
754
|
+
}
|
|
755
|
+
|
|
552
756
|
export default router;
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* GET /api/v1/statuses/:id — single status
|
|
5
5
|
* GET /api/v1/statuses/:id/context — thread context (ancestors + descendants)
|
|
6
|
+
* POST /api/v1/statuses — create post via Micropub pipeline
|
|
7
|
+
* DELETE /api/v1/statuses/:id — delete post via Micropub pipeline
|
|
6
8
|
* POST /api/v1/statuses/:id/favourite — like a post
|
|
7
9
|
* POST /api/v1/statuses/:id/unfavourite — unlike a post
|
|
8
10
|
* POST /api/v1/statuses/:id/reblog — boost a post
|
|
@@ -13,12 +15,12 @@
|
|
|
13
15
|
import express from "express";
|
|
14
16
|
import { ObjectId } from "mongodb";
|
|
15
17
|
import { serializeStatus } from "../entities/status.js";
|
|
16
|
-
import { serializeAccount } from "../entities/account.js";
|
|
17
18
|
import {
|
|
18
19
|
likePost, unlikePost,
|
|
19
20
|
boostPost, unboostPost,
|
|
20
21
|
bookmarkPost, unbookmarkPost,
|
|
21
22
|
} from "../helpers/interactions.js";
|
|
23
|
+
import { addTimelineItem } from "../../storage/timeline.js";
|
|
22
24
|
|
|
23
25
|
const router = express.Router(); // eslint-disable-line new-cap
|
|
24
26
|
|
|
@@ -142,6 +144,8 @@ router.get("/api/v1/statuses/:id/context", async (req, res, next) => {
|
|
|
142
144
|
});
|
|
143
145
|
|
|
144
146
|
// ─── POST /api/v1/statuses ───────────────────────────────────────────────────
|
|
147
|
+
// Creates a post via the Micropub pipeline so it goes through the full flow:
|
|
148
|
+
// Micropub → content file → Eleventy build → syndication → AP federation.
|
|
145
149
|
|
|
146
150
|
router.post("/api/v1/statuses", async (req, res, next) => {
|
|
147
151
|
try {
|
|
@@ -150,6 +154,7 @@ router.post("/api/v1/statuses", async (req, res, next) => {
|
|
|
150
154
|
return res.status(401).json({ error: "The access token is invalid" });
|
|
151
155
|
}
|
|
152
156
|
|
|
157
|
+
const { application, publication } = req.app.locals;
|
|
153
158
|
const collections = req.app.locals.mastodonCollections;
|
|
154
159
|
const pluginOptions = req.app.locals.mastodonPluginOptions || {};
|
|
155
160
|
const baseUrl = `${req.protocol}://${req.get("host")}`;
|
|
@@ -168,7 +173,7 @@ router.post("/api/v1/statuses", async (req, res, next) => {
|
|
|
168
173
|
return res.status(422).json({ error: "Validation failed: Text content is required" });
|
|
169
174
|
}
|
|
170
175
|
|
|
171
|
-
// Resolve in_reply_to
|
|
176
|
+
// Resolve in_reply_to URL from timeline ObjectId
|
|
172
177
|
let inReplyTo = null;
|
|
173
178
|
if (inReplyToId) {
|
|
174
179
|
try {
|
|
@@ -183,33 +188,63 @@ router.post("/api/v1/statuses", async (req, res, next) => {
|
|
|
183
188
|
}
|
|
184
189
|
}
|
|
185
190
|
|
|
186
|
-
//
|
|
191
|
+
// Build JF2 properties for the Micropub pipeline
|
|
192
|
+
const jf2 = {
|
|
193
|
+
type: "entry",
|
|
194
|
+
content: statusText || "",
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
if (inReplyTo) {
|
|
198
|
+
jf2["in-reply-to"] = inReplyTo;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (spoilerText) {
|
|
202
|
+
jf2.summary = spoilerText;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (sensitive === true || sensitive === "true") {
|
|
206
|
+
jf2.sensitive = "true";
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (visibility && visibility !== "public") {
|
|
210
|
+
jf2.visibility = visibility;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (language) {
|
|
214
|
+
jf2["mp-language"] = language;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Create post via Micropub pipeline (same functions the Micropub endpoint uses)
|
|
218
|
+
// postData.create() handles: normalization, post type detection, path rendering,
|
|
219
|
+
// mp-syndicate-to auto-set (from checked syndicators), MongoDB posts collection
|
|
220
|
+
const { postData } = await import("@indiekit/endpoint-micropub/lib/post-data.js");
|
|
221
|
+
const { postContent } = await import("@indiekit/endpoint-micropub/lib/post-content.js");
|
|
222
|
+
|
|
223
|
+
const data = await postData.create(application, publication, jf2);
|
|
224
|
+
// postContent.create() handles: template rendering, file creation in store
|
|
225
|
+
await postContent.create(publication, data);
|
|
226
|
+
|
|
227
|
+
const postUrl = data.properties.url;
|
|
228
|
+
console.info(`[Mastodon API] Created post via Micropub: ${postUrl}`);
|
|
229
|
+
|
|
230
|
+
// Add to ap_timeline so the post is visible in the Mastodon Client API
|
|
187
231
|
const profile = await collections.ap_profile.findOne({});
|
|
188
232
|
const handle = pluginOptions.handle || "user";
|
|
189
233
|
const publicationUrl = pluginOptions.publicationUrl || baseUrl;
|
|
190
234
|
const actorUrl = profile?.url || `${publicationUrl}/users/${handle}`;
|
|
191
235
|
|
|
192
|
-
// Generate post ID and URL
|
|
193
|
-
const postId = crypto.randomUUID();
|
|
194
|
-
const postUrl = `${publicationUrl.replace(/\/$/, "")}/posts/${postId}`;
|
|
195
|
-
const uid = postUrl;
|
|
196
|
-
|
|
197
|
-
// Build the timeline item
|
|
198
236
|
const now = new Date().toISOString();
|
|
199
|
-
const timelineItem = {
|
|
200
|
-
uid,
|
|
237
|
+
const timelineItem = await addTimelineItem(collections, {
|
|
238
|
+
uid: postUrl,
|
|
201
239
|
url: postUrl,
|
|
202
|
-
type: "note",
|
|
203
|
-
content: {
|
|
204
|
-
text: statusText || "",
|
|
205
|
-
html: linkifyAndParagraph(statusText || ""),
|
|
206
|
-
},
|
|
240
|
+
type: data.properties["post-type"] || "note",
|
|
241
|
+
content: data.properties.content || { text: statusText || "", html: "" },
|
|
207
242
|
summary: spoilerText || "",
|
|
208
243
|
sensitive: sensitive === true || sensitive === "true",
|
|
209
244
|
visibility: visibility || "public",
|
|
210
245
|
language: language || null,
|
|
211
246
|
inReplyTo,
|
|
212
|
-
published: now,
|
|
247
|
+
published: data.properties.published || now,
|
|
213
248
|
createdAt: now,
|
|
214
249
|
author: {
|
|
215
250
|
name: profile?.name || handle,
|
|
@@ -219,26 +254,15 @@ router.post("/api/v1/statuses", async (req, res, next) => {
|
|
|
219
254
|
emojis: [],
|
|
220
255
|
bot: false,
|
|
221
256
|
},
|
|
222
|
-
photo: [],
|
|
223
|
-
video: [],
|
|
224
|
-
audio: [],
|
|
225
|
-
category:
|
|
257
|
+
photo: data.properties.photo || [],
|
|
258
|
+
video: data.properties.video || [],
|
|
259
|
+
audio: data.properties.audio || [],
|
|
260
|
+
category: data.properties.category || [],
|
|
226
261
|
counts: { replies: 0, boosts: 0, likes: 0 },
|
|
227
262
|
linkPreviews: [],
|
|
228
263
|
mentions: [],
|
|
229
264
|
emojis: [],
|
|
230
|
-
};
|
|
231
|
-
|
|
232
|
-
// Insert into timeline
|
|
233
|
-
const result = await collections.ap_timeline.insertOne(timelineItem);
|
|
234
|
-
timelineItem._id = result.insertedId;
|
|
235
|
-
|
|
236
|
-
// Trigger federation asynchronously (don't block the response)
|
|
237
|
-
if (pluginOptions.federation) {
|
|
238
|
-
federatePost(timelineItem, pluginOptions).catch((err) => {
|
|
239
|
-
console.error("[Mastodon API] Federation failed:", err.message);
|
|
240
|
-
});
|
|
241
|
-
}
|
|
265
|
+
});
|
|
242
266
|
|
|
243
267
|
// Serialize and return
|
|
244
268
|
const serialized = serializeStatus(timelineItem, {
|
|
@@ -256,6 +280,8 @@ router.post("/api/v1/statuses", async (req, res, next) => {
|
|
|
256
280
|
});
|
|
257
281
|
|
|
258
282
|
// ─── DELETE /api/v1/statuses/:id ────────────────────────────────────────────
|
|
283
|
+
// Deletes via Micropub pipeline (removes content file + MongoDB post) and
|
|
284
|
+
// cleans up the ap_timeline entry.
|
|
259
285
|
|
|
260
286
|
router.delete("/api/v1/statuses/:id", async (req, res, next) => {
|
|
261
287
|
try {
|
|
@@ -264,6 +290,7 @@ router.delete("/api/v1/statuses/:id", async (req, res, next) => {
|
|
|
264
290
|
return res.status(401).json({ error: "The access token is invalid" });
|
|
265
291
|
}
|
|
266
292
|
|
|
293
|
+
const { application, publication } = req.app.locals;
|
|
267
294
|
const { id } = req.params;
|
|
268
295
|
const collections = req.app.locals.mastodonCollections;
|
|
269
296
|
const baseUrl = `${req.protocol}://${req.get("host")}`;
|
|
@@ -296,6 +323,23 @@ router.delete("/api/v1/statuses/:id", async (req, res, next) => {
|
|
|
296
323
|
});
|
|
297
324
|
serialized.text = item.content?.text || "";
|
|
298
325
|
|
|
326
|
+
// Delete via Micropub pipeline (removes content file from store + MongoDB posts)
|
|
327
|
+
const postUrl = item.uid || item.url;
|
|
328
|
+
try {
|
|
329
|
+
const { postData } = await import("@indiekit/endpoint-micropub/lib/post-data.js");
|
|
330
|
+
const { postContent } = await import("@indiekit/endpoint-micropub/lib/post-content.js");
|
|
331
|
+
|
|
332
|
+
const existingPost = await postData.read(application, postUrl);
|
|
333
|
+
if (existingPost) {
|
|
334
|
+
const deletedData = await postData.delete(application, postUrl);
|
|
335
|
+
await postContent.delete(publication, deletedData);
|
|
336
|
+
console.info(`[Mastodon API] Deleted post via Micropub: ${postUrl}`);
|
|
337
|
+
}
|
|
338
|
+
} catch (err) {
|
|
339
|
+
// Log but don't block — the post may not exist in Micropub (e.g. old pre-pipeline posts)
|
|
340
|
+
console.warn(`[Mastodon API] Micropub delete failed for ${postUrl}: ${err.message}`);
|
|
341
|
+
}
|
|
342
|
+
|
|
299
343
|
// Delete from timeline
|
|
300
344
|
await collections.ap_timeline.deleteOne({ _id: objectId });
|
|
301
345
|
|
|
@@ -304,8 +348,6 @@ router.delete("/api/v1/statuses/:id", async (req, res, next) => {
|
|
|
304
348
|
await collections.ap_interactions.deleteMany({ objectUrl: item.uid });
|
|
305
349
|
}
|
|
306
350
|
|
|
307
|
-
// TODO: Broadcast Delete activity via federation
|
|
308
|
-
|
|
309
351
|
res.json(serialized);
|
|
310
352
|
} catch (error) {
|
|
311
353
|
next(error);
|
|
@@ -560,75 +602,4 @@ async function loadItemInteractions(collections, item) {
|
|
|
560
602
|
return { favouritedIds, rebloggedIds, bookmarkedIds };
|
|
561
603
|
}
|
|
562
604
|
|
|
563
|
-
/**
|
|
564
|
-
* Convert plain text to basic HTML (paragraphs + linkified URLs).
|
|
565
|
-
*/
|
|
566
|
-
function linkifyAndParagraph(text) {
|
|
567
|
-
if (!text) return "";
|
|
568
|
-
const paragraphs = text.split(/\n\n+/).filter(Boolean);
|
|
569
|
-
return paragraphs
|
|
570
|
-
.map((p) => {
|
|
571
|
-
const withBreaks = p.replace(/\n/g, "<br>");
|
|
572
|
-
const linked = withBreaks.replace(
|
|
573
|
-
/(?<![=">])(https?:\/\/[^\s<"]+)/g,
|
|
574
|
-
'<a href="$1">$1</a>',
|
|
575
|
-
);
|
|
576
|
-
return `<p>${linked}</p>`;
|
|
577
|
-
})
|
|
578
|
-
.join("");
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
/**
|
|
582
|
-
* Extract #hashtags from text content.
|
|
583
|
-
*/
|
|
584
|
-
function extractHashtags(text) {
|
|
585
|
-
if (!text) return [];
|
|
586
|
-
const tags = [];
|
|
587
|
-
const regex = /#([\w]+)/g;
|
|
588
|
-
let match;
|
|
589
|
-
while ((match = regex.exec(text)) !== null) {
|
|
590
|
-
tags.push(match[1]);
|
|
591
|
-
}
|
|
592
|
-
return [...new Set(tags)];
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
/**
|
|
596
|
-
* Federate a newly created post via ActivityPub.
|
|
597
|
-
* Runs asynchronously — errors logged, don't block API response.
|
|
598
|
-
*/
|
|
599
|
-
async function federatePost(item, pluginOptions) {
|
|
600
|
-
const { jf2ToAS2Activity } = await import("../../jf2-to-as2.js");
|
|
601
|
-
|
|
602
|
-
const handle = pluginOptions.handle || "user";
|
|
603
|
-
const publicationUrl = pluginOptions.publicationUrl;
|
|
604
|
-
const federation = pluginOptions.federation;
|
|
605
|
-
const actorUrl = `${publicationUrl.replace(/\/$/, "")}/users/${handle}`;
|
|
606
|
-
|
|
607
|
-
const ctx = federation.createContext(
|
|
608
|
-
new URL(publicationUrl),
|
|
609
|
-
{ handle, publicationUrl },
|
|
610
|
-
);
|
|
611
|
-
|
|
612
|
-
const properties = {
|
|
613
|
-
"post-type": "note",
|
|
614
|
-
url: item.url,
|
|
615
|
-
content: item.content,
|
|
616
|
-
summary: item.summary || undefined,
|
|
617
|
-
"in-reply-to": item.inReplyTo || undefined,
|
|
618
|
-
category: item.category,
|
|
619
|
-
visibility: item.visibility,
|
|
620
|
-
};
|
|
621
|
-
|
|
622
|
-
const activity = jf2ToAS2Activity(properties, actorUrl, publicationUrl, {
|
|
623
|
-
visibility: item.visibility,
|
|
624
|
-
});
|
|
625
|
-
|
|
626
|
-
if (activity) {
|
|
627
|
-
await ctx.sendActivity({ identifier: handle }, "followers", activity, {
|
|
628
|
-
preferSharedInbox: true,
|
|
629
|
-
});
|
|
630
|
-
console.info(`[Mastodon API] Federated post: ${item.url}`);
|
|
631
|
-
}
|
|
632
|
-
}
|
|
633
|
-
|
|
634
605
|
export default router;
|
|
@@ -99,7 +99,22 @@ router.get("/api/v1/timelines/public", async (req, res, next) => {
|
|
|
99
99
|
visibility: "public",
|
|
100
100
|
};
|
|
101
101
|
|
|
102
|
-
//
|
|
102
|
+
// Local timeline: only posts from the local instance author
|
|
103
|
+
if (req.query.local === "true") {
|
|
104
|
+
const profile = await collections.ap_profile.findOne({});
|
|
105
|
+
if (profile?.url) {
|
|
106
|
+
baseFilter["author.url"] = profile.url;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Remote-only: exclude local author posts
|
|
111
|
+
if (req.query.remote === "true") {
|
|
112
|
+
const profile = await collections.ap_profile.findOne({});
|
|
113
|
+
if (profile?.url) {
|
|
114
|
+
baseFilter["author.url"] = { $ne: profile.url };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
103
118
|
if (req.query.only_media === "true") {
|
|
104
119
|
baseFilter.$or = [
|
|
105
120
|
{ "photo.0": { $exists: true } },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rmdes/indiekit-endpoint-activitypub",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.3.0",
|
|
4
4
|
"description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"indiekit",
|
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
"unfurl.js": "^6.4.0"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
|
+
"@indiekit/endpoint-micropub": "^1.0.0-beta.25",
|
|
50
51
|
"@indiekit/error": "^1.0.0-beta.25",
|
|
51
52
|
"@indiekit/frontend": "^1.0.0-beta.25"
|
|
52
53
|
},
|