@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 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
- return res.json(
137
- serializeAccount(profile, { baseUrl, isLocal: true, handle }),
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
- // Search known actors (followers, following, timeline authors)
142
- // by checking if the deterministic hash matches
143
- const follower = await collections.ap_followers
144
- .find({})
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
- for (const f of follower) {
147
- if (remoteActorId(f.actorUrl) === id) {
148
- return res.json(
149
- serializeAccount(
150
- { name: f.name, url: f.actorUrl, photo: f.avatar, handle: f.handle },
151
- { baseUrl },
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 following = await collections.ap_following
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
- for (const f of following) {
161
- if (remoteActorId(f.actorUrl) === id) {
162
- return res.json(
163
- serializeAccount(
164
- { name: f.name, url: f.actorUrl, photo: f.avatar, handle: f.handle },
165
- { baseUrl },
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
- // Try timeline authors
172
- const timelineItem = await collections.ap_timeline.findOne({
173
- $expr: { $ne: [{ $type: "$author.url" }, "missing"] },
174
- });
175
- // For now, if not found in known actors, return 404
176
- return res.status(404).json({ error: "Record not found" });
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 if provided
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
- // Load local profile for the author field
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: extractHashtags(statusText || ""),
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
- // Only original posts (exclude boosts from public timeline unless local=true)
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.0.0",
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
  },