@rmdes/indiekit-endpoint-activitypub 3.0.0 → 3.2.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.
|
@@ -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
|
|
|
@@ -168,17 +170,187 @@ router.get("/api/v1/accounts/:id", async (req, res, next) => {
|
|
|
168
170
|
}
|
|
169
171
|
}
|
|
170
172
|
|
|
171
|
-
// Try timeline authors
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
173
|
+
// Try timeline authors — find any post whose author URL hashes to this ID
|
|
174
|
+
const timelineItems = await collections.ap_timeline
|
|
175
|
+
.find({ "author.url": { $exists: true } })
|
|
176
|
+
.project({ author: 1 })
|
|
177
|
+
.toArray();
|
|
178
|
+
|
|
179
|
+
const seenUrls = new Set();
|
|
180
|
+
for (const item of timelineItems) {
|
|
181
|
+
const authorUrl = item.author?.url;
|
|
182
|
+
if (!authorUrl || seenUrls.has(authorUrl)) continue;
|
|
183
|
+
seenUrls.add(authorUrl);
|
|
184
|
+
if (remoteActorId(authorUrl) === id) {
|
|
185
|
+
return res.json(
|
|
186
|
+
serializeAccount(item.author, { baseUrl }),
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
176
191
|
return res.status(404).json({ error: "Record not found" });
|
|
177
192
|
} catch (error) {
|
|
178
193
|
next(error);
|
|
179
194
|
}
|
|
180
195
|
});
|
|
181
196
|
|
|
197
|
+
// ─── GET /api/v1/accounts/:id/statuses ──────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
router.get("/api/v1/accounts/:id/statuses", async (req, res, next) => {
|
|
200
|
+
try {
|
|
201
|
+
const { id } = req.params;
|
|
202
|
+
const collections = req.app.locals.mastodonCollections;
|
|
203
|
+
const baseUrl = `${req.protocol}://${req.get("host")}`;
|
|
204
|
+
const limit = parseLimit(req.query.limit);
|
|
205
|
+
|
|
206
|
+
// Resolve account ID to an author URL
|
|
207
|
+
const actorUrl = await resolveActorUrl(id, collections);
|
|
208
|
+
if (!actorUrl) {
|
|
209
|
+
return res.status(404).json({ error: "Record not found" });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Build filter for this author's posts
|
|
213
|
+
const baseFilter = {
|
|
214
|
+
"author.url": actorUrl,
|
|
215
|
+
isContext: { $ne: true },
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
// Mastodon filters
|
|
219
|
+
if (req.query.only_media === "true") {
|
|
220
|
+
baseFilter.$or = [
|
|
221
|
+
{ "photo.0": { $exists: true } },
|
|
222
|
+
{ "video.0": { $exists: true } },
|
|
223
|
+
{ "audio.0": { $exists: true } },
|
|
224
|
+
];
|
|
225
|
+
}
|
|
226
|
+
if (req.query.exclude_replies === "true") {
|
|
227
|
+
baseFilter.inReplyTo = { $exists: false };
|
|
228
|
+
}
|
|
229
|
+
if (req.query.exclude_reblogs === "true") {
|
|
230
|
+
baseFilter.type = { $ne: "boost" };
|
|
231
|
+
}
|
|
232
|
+
if (req.query.pinned === "true") {
|
|
233
|
+
baseFilter.pinned = true;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const { filter, sort, reverse } = buildPaginationQuery(baseFilter, {
|
|
237
|
+
max_id: req.query.max_id,
|
|
238
|
+
min_id: req.query.min_id,
|
|
239
|
+
since_id: req.query.since_id,
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
let items = await collections.ap_timeline
|
|
243
|
+
.find(filter)
|
|
244
|
+
.sort(sort)
|
|
245
|
+
.limit(limit)
|
|
246
|
+
.toArray();
|
|
247
|
+
|
|
248
|
+
if (reverse) {
|
|
249
|
+
items.reverse();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Load interaction state if authenticated
|
|
253
|
+
let favouritedIds = new Set();
|
|
254
|
+
let rebloggedIds = new Set();
|
|
255
|
+
let bookmarkedIds = new Set();
|
|
256
|
+
|
|
257
|
+
if (req.mastodonToken && collections.ap_interactions) {
|
|
258
|
+
const lookupUrls = items.flatMap((i) => [i.uid, i.url].filter(Boolean));
|
|
259
|
+
if (lookupUrls.length > 0) {
|
|
260
|
+
const interactions = await collections.ap_interactions
|
|
261
|
+
.find({ objectUrl: { $in: lookupUrls } })
|
|
262
|
+
.toArray();
|
|
263
|
+
for (const ix of interactions) {
|
|
264
|
+
if (ix.type === "like") favouritedIds.add(ix.objectUrl);
|
|
265
|
+
else if (ix.type === "boost") rebloggedIds.add(ix.objectUrl);
|
|
266
|
+
else if (ix.type === "bookmark") bookmarkedIds.add(ix.objectUrl);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const statuses = items.map((item) =>
|
|
272
|
+
serializeStatus(item, {
|
|
273
|
+
baseUrl,
|
|
274
|
+
favouritedIds,
|
|
275
|
+
rebloggedIds,
|
|
276
|
+
bookmarkedIds,
|
|
277
|
+
pinnedIds: new Set(),
|
|
278
|
+
}),
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
setPaginationHeaders(res, req, items, limit);
|
|
282
|
+
res.json(statuses);
|
|
283
|
+
} catch (error) {
|
|
284
|
+
next(error);
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
// ─── GET /api/v1/accounts/:id/followers ─────────────────────────────────────
|
|
289
|
+
|
|
290
|
+
router.get("/api/v1/accounts/:id/followers", async (req, res, next) => {
|
|
291
|
+
try {
|
|
292
|
+
const { id } = req.params;
|
|
293
|
+
const collections = req.app.locals.mastodonCollections;
|
|
294
|
+
const baseUrl = `${req.protocol}://${req.get("host")}`;
|
|
295
|
+
const limit = parseLimit(req.query.limit);
|
|
296
|
+
const profile = await collections.ap_profile.findOne({});
|
|
297
|
+
|
|
298
|
+
// Only serve followers for the local account
|
|
299
|
+
if (!profile || profile._id.toString() !== id) {
|
|
300
|
+
return res.json([]);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const followers = await collections.ap_followers
|
|
304
|
+
.find({})
|
|
305
|
+
.limit(limit)
|
|
306
|
+
.toArray();
|
|
307
|
+
|
|
308
|
+
const accounts = followers.map((f) =>
|
|
309
|
+
serializeAccount(
|
|
310
|
+
{ name: f.name, url: f.actorUrl, photo: f.avatar, handle: f.handle },
|
|
311
|
+
{ baseUrl },
|
|
312
|
+
),
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
res.json(accounts);
|
|
316
|
+
} catch (error) {
|
|
317
|
+
next(error);
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// ─── GET /api/v1/accounts/:id/following ─────────────────────────────────────
|
|
322
|
+
|
|
323
|
+
router.get("/api/v1/accounts/:id/following", async (req, res, next) => {
|
|
324
|
+
try {
|
|
325
|
+
const { id } = req.params;
|
|
326
|
+
const collections = req.app.locals.mastodonCollections;
|
|
327
|
+
const baseUrl = `${req.protocol}://${req.get("host")}`;
|
|
328
|
+
const limit = parseLimit(req.query.limit);
|
|
329
|
+
const profile = await collections.ap_profile.findOne({});
|
|
330
|
+
|
|
331
|
+
// Only serve following for the local account
|
|
332
|
+
if (!profile || profile._id.toString() !== id) {
|
|
333
|
+
return res.json([]);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const following = await collections.ap_following
|
|
337
|
+
.find({})
|
|
338
|
+
.limit(limit)
|
|
339
|
+
.toArray();
|
|
340
|
+
|
|
341
|
+
const accounts = following.map((f) =>
|
|
342
|
+
serializeAccount(
|
|
343
|
+
{ name: f.name, url: f.actorUrl, photo: f.avatar, handle: f.handle },
|
|
344
|
+
{ baseUrl },
|
|
345
|
+
),
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
res.json(accounts);
|
|
349
|
+
} catch (error) {
|
|
350
|
+
next(error);
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
|
|
182
354
|
// ─── GET /api/v1/accounts/relationships ──────────────────────────────────────
|
|
183
355
|
|
|
184
356
|
router.get("/api/v1/accounts/relationships", async (req, res, next) => {
|
|
@@ -546,6 +718,22 @@ async function resolveActorUrl(id, collections) {
|
|
|
546
718
|
}
|
|
547
719
|
}
|
|
548
720
|
|
|
721
|
+
// Check timeline authors
|
|
722
|
+
const timelineItems = await collections.ap_timeline
|
|
723
|
+
.find({ "author.url": { $exists: true } })
|
|
724
|
+
.project({ "author.url": 1 })
|
|
725
|
+
.toArray();
|
|
726
|
+
|
|
727
|
+
const seenUrls = new Set();
|
|
728
|
+
for (const item of timelineItems) {
|
|
729
|
+
const authorUrl = item.author?.url;
|
|
730
|
+
if (!authorUrl || seenUrls.has(authorUrl)) continue;
|
|
731
|
+
seenUrls.add(authorUrl);
|
|
732
|
+
if (remoteActorId(authorUrl) === id) {
|
|
733
|
+
return authorUrl;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
549
737
|
return null;
|
|
550
738
|
}
|
|
551
739
|
|
|
@@ -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.2.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
|
},
|