@rmdes/indiekit-endpoint-microsub 1.0.31 → 1.0.32

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/assets/styles.css CHANGED
@@ -936,3 +936,81 @@
936
936
  .feed-edit__action p {
937
937
  margin-bottom: var(--space-s);
938
938
  }
939
+
940
+ /* ==========================================================================
941
+ Actor Profile
942
+ ========================================================================== */
943
+
944
+ .actor-profile {
945
+ background: var(--color-offset);
946
+ border-radius: var(--border-radius);
947
+ margin-bottom: var(--space-m);
948
+ padding: var(--space-m);
949
+ }
950
+
951
+ .actor-profile__header {
952
+ align-items: flex-start;
953
+ display: flex;
954
+ gap: var(--space-m);
955
+ }
956
+
957
+ .actor-profile__avatar {
958
+ border-radius: 50%;
959
+ flex-shrink: 0;
960
+ object-fit: cover;
961
+ }
962
+
963
+ .actor-profile__info {
964
+ flex: 1;
965
+ min-width: 0;
966
+ }
967
+
968
+ .actor-profile__name {
969
+ font-size: 1.25em;
970
+ margin: 0 0 2px;
971
+ }
972
+
973
+ .actor-profile__handle {
974
+ color: var(--color-text-muted, #666);
975
+ font-size: 0.9em;
976
+ }
977
+
978
+ .actor-profile__summary {
979
+ font-size: 0.9em;
980
+ margin: var(--space-xs) 0 0;
981
+ }
982
+
983
+ .actor-profile__stats {
984
+ color: var(--color-text-muted, #666);
985
+ display: flex;
986
+ font-size: 0.85em;
987
+ gap: var(--space-m);
988
+ margin-top: var(--space-xs);
989
+ }
990
+
991
+ .actor-profile__actions {
992
+ display: flex;
993
+ gap: var(--space-s);
994
+ margin-top: var(--space-s);
995
+ }
996
+
997
+ /* ==========================================================================
998
+ AP Badge
999
+ ========================================================================== */
1000
+
1001
+ .item-card__badge {
1002
+ border-radius: 3px;
1003
+ display: inline-block;
1004
+ font-size: 0.7em;
1005
+ font-weight: 600;
1006
+ letter-spacing: 0.02em;
1007
+ line-height: 1;
1008
+ padding: 2px 4px;
1009
+ text-transform: uppercase;
1010
+ vertical-align: middle;
1011
+ }
1012
+
1013
+ .item-card__badge--ap {
1014
+ background: #7c3aed20;
1015
+ color: #7c3aed;
1016
+ }
package/index.js CHANGED
@@ -8,6 +8,7 @@ import { opmlController } from "./lib/controllers/opml.js";
8
8
  import { readerController } from "./lib/controllers/reader.js";
9
9
  import { handleMediaProxy } from "./lib/media/proxy.js";
10
10
  import { startScheduler, stopScheduler } from "./lib/polling/scheduler.js";
11
+ import { ensureActivityPubChannel } from "./lib/storage/channels.js";
11
12
  import { cleanupAllReadItems, createIndexes } from "./lib/storage/items.js";
12
13
  import { webmentionReceiver } from "./lib/webmention/receiver.js";
13
14
  import { websubHandler } from "./lib/websub/handler.js";
@@ -126,6 +127,9 @@ export default class MicrosubEndpoint {
126
127
  readerRouter.get("/search", readerController.searchPage);
127
128
  readerRouter.post("/search", readerController.searchFeeds);
128
129
  readerRouter.post("/subscribe", readerController.subscribe);
130
+ readerRouter.get("/actor", readerController.actorProfile);
131
+ readerRouter.post("/actor/follow", readerController.followActorAction);
132
+ readerRouter.post("/actor/unfollow", readerController.unfollowActorAction);
129
133
  readerRouter.post("/api/mark-read", readerController.markAllRead);
130
134
  readerRouter.get("/opml", opmlController.exportOpml);
131
135
  router.use("/reader", readerRouter);
@@ -184,6 +188,14 @@ export default class MicrosubEndpoint {
184
188
  console.info("[Microsub] Database available, starting scheduler");
185
189
  startScheduler(indiekit);
186
190
 
191
+ // Ensure system channels exist
192
+ ensureActivityPubChannel(indiekit).catch((error) => {
193
+ console.warn(
194
+ "[Microsub] ActivityPub channel creation failed:",
195
+ error.message,
196
+ );
197
+ });
198
+
187
199
  // Create indexes for optimal performance (runs in background)
188
200
  createIndexes(indiekit).catch((error) => {
189
201
  console.warn("[Microsub] Index creation failed:", error.message);
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Fetch a remote ActivityPub actor's outbox for on-demand reading.
3
+ * Returns ephemeral jf2 items — nothing is stored in MongoDB.
4
+ *
5
+ * @module activitypub/outbox-fetcher
6
+ */
7
+
8
+ const AP_ACCEPT =
9
+ 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"';
10
+ const FETCH_TIMEOUT = 10_000;
11
+ const USER_AGENT = "Indiekit/1.0 (Microsub reader)";
12
+
13
+ /**
14
+ * Fetch a remote actor's profile and recent posts from their outbox.
15
+ *
16
+ * @param {string} actorUrl - Full URL of the AP actor
17
+ * @param {object} [options]
18
+ * @param {number} [options.limit=20] - Max items to return
19
+ * @returns {Promise<{ actor: object, items: Array }>}
20
+ */
21
+ export async function fetchActorOutbox(actorUrl, options = {}) {
22
+ const limit = options.limit || 20;
23
+
24
+ // 1. Fetch actor profile
25
+ const actor = await fetchJson(actorUrl);
26
+ if (!actor || !actor.outbox) {
27
+ throw new Error("Could not resolve actor or outbox URL");
28
+ }
29
+
30
+ const actorInfo = {
31
+ name:
32
+ actor.name ||
33
+ actor.preferredUsername ||
34
+ new URL(actorUrl).pathname.split("/").pop(),
35
+ url: actor.url || actor.id || actorUrl,
36
+ photo: actor.icon?.url || actor.icon || "",
37
+ summary: stripHtml(actor.summary || ""),
38
+ handle: actor.preferredUsername || "",
39
+ followersCount: 0,
40
+ followingCount: 0,
41
+ };
42
+
43
+ // Resolve follower/following counts if available
44
+ if (typeof actor.followers === "string") {
45
+ try {
46
+ const followersCollection = await fetchJson(actor.followers);
47
+ actorInfo.followersCount = followersCollection?.totalItems || 0;
48
+ } catch {
49
+ /* ignore */
50
+ }
51
+ }
52
+ if (typeof actor.following === "string") {
53
+ try {
54
+ const followingCollection = await fetchJson(actor.following);
55
+ actorInfo.followingCount = followingCollection?.totalItems || 0;
56
+ } catch {
57
+ /* ignore */
58
+ }
59
+ }
60
+
61
+ // 2. Fetch outbox (OrderedCollection)
62
+ const outboxUrl =
63
+ typeof actor.outbox === "string" ? actor.outbox : actor.outbox?.id;
64
+ const outbox = await fetchJson(outboxUrl);
65
+ if (!outbox) {
66
+ return { actor: actorInfo, items: [] };
67
+ }
68
+
69
+ // 3. Get items — may be inline or on a first page
70
+ let activities = [];
71
+
72
+ if (outbox.orderedItems?.length > 0) {
73
+ activities = outbox.orderedItems;
74
+ } else if (outbox.first) {
75
+ const firstPageUrl =
76
+ typeof outbox.first === "string" ? outbox.first : outbox.first?.id;
77
+ if (firstPageUrl) {
78
+ const firstPage = await fetchJson(firstPageUrl);
79
+ activities = firstPage?.orderedItems || firstPage?.items || [];
80
+ }
81
+ }
82
+
83
+ // 4. Convert Create activities to jf2 items
84
+ const items = [];
85
+ for (const activity of activities) {
86
+ if (items.length >= limit) break;
87
+
88
+ const item = activityToJf2(activity, actorInfo);
89
+ if (item) items.push(item);
90
+ }
91
+
92
+ return { actor: actorInfo, items };
93
+ }
94
+
95
+ /**
96
+ * Convert a single AP activity (or bare object) to jf2 format.
97
+ * @param {object} activity - AP activity or object
98
+ * @param {object} actorInfo - Actor profile info
99
+ * @returns {object|null} jf2 item or null if not displayable
100
+ */
101
+ function activityToJf2(activity, actorInfo) {
102
+ // Unwrap Create/Announce — the displayable content is the inner object
103
+ let object = activity;
104
+ const activityType = activity.type;
105
+
106
+ if (activityType === "Create" || activityType === "Announce") {
107
+ object = activity.object;
108
+ if (!object || typeof object === "string") return null; // Unresolved reference
109
+ }
110
+
111
+ // Skip non-content types (Follow, Like, etc.)
112
+ const contentTypes = new Set([
113
+ "Note",
114
+ "Article",
115
+ "Page",
116
+ "Video",
117
+ "Audio",
118
+ "Image",
119
+ "Event",
120
+ "Question",
121
+ ]);
122
+ if (!contentTypes.has(object.type)) return null;
123
+
124
+ const contentHtml = object.content || "";
125
+ const contentText = stripHtml(contentHtml);
126
+
127
+ const jf2 = {
128
+ type: "entry",
129
+ url: object.url || object.id || "",
130
+ uid: object.id || object.url || "",
131
+ name: object.name || undefined,
132
+ content: contentHtml ? { text: contentText, html: contentHtml } : undefined,
133
+ summary: object.summary ? stripHtml(object.summary) : undefined,
134
+ published: object.published || activity.published || undefined,
135
+ author: {
136
+ name: actorInfo.name,
137
+ url: actorInfo.url,
138
+ photo: actorInfo.photo,
139
+ },
140
+ category: extractTags(object.tag),
141
+ photo: extractMedia(object.attachment, "image"),
142
+ video: extractMedia(object.attachment, "video"),
143
+ audio: extractMedia(object.attachment, "audio"),
144
+ _source: { type: "activitypub", actorUrl: actorInfo.url },
145
+ };
146
+
147
+ // Boost attribution
148
+ if (activityType === "Announce" && activity.actor) {
149
+ jf2._boostedBy = actorInfo;
150
+ // The inner object may have its own author
151
+ if (object.attributedTo) {
152
+ const attributedUrl =
153
+ typeof object.attributedTo === "string"
154
+ ? object.attributedTo
155
+ : object.attributedTo?.id || object.attributedTo?.url;
156
+ if (attributedUrl) {
157
+ jf2.author = {
158
+ name:
159
+ object.attributedTo?.name ||
160
+ object.attributedTo?.preferredUsername ||
161
+ attributedUrl,
162
+ url: attributedUrl,
163
+ photo: object.attributedTo?.icon?.url || "",
164
+ };
165
+ }
166
+ }
167
+ }
168
+
169
+ if (object.inReplyTo) {
170
+ const replyUrl =
171
+ typeof object.inReplyTo === "string"
172
+ ? object.inReplyTo
173
+ : object.inReplyTo?.id;
174
+ if (replyUrl) jf2["in-reply-to"] = [replyUrl];
175
+ }
176
+
177
+ return jf2;
178
+ }
179
+
180
+ /**
181
+ * Extract hashtags from AP tag array.
182
+ * @param {Array} tags - AP tag objects
183
+ * @returns {Array<string>}
184
+ */
185
+ function extractTags(tags) {
186
+ if (!Array.isArray(tags)) return [];
187
+ return tags
188
+ .filter((t) => t.type === "Hashtag" || t.type === "Tag")
189
+ .map((t) => (t.name || "").replace(/^#/, ""))
190
+ .filter(Boolean);
191
+ }
192
+
193
+ /**
194
+ * Extract media URLs from AP attachment array.
195
+ * @param {Array} attachments - AP attachment objects
196
+ * @param {string} mediaPrefix - "image", "video", or "audio"
197
+ * @returns {Array<string>}
198
+ */
199
+ function extractMedia(attachments, mediaPrefix) {
200
+ if (!Array.isArray(attachments)) return [];
201
+ return attachments
202
+ .filter((a) => (a.mediaType || "").startsWith(`${mediaPrefix}/`))
203
+ .map((a) => a.url || a.href || "")
204
+ .filter(Boolean);
205
+ }
206
+
207
+ /**
208
+ * Fetch a URL as ActivityPub JSON.
209
+ * @param {string} url
210
+ * @returns {Promise<object|null>}
211
+ */
212
+ async function fetchJson(url) {
213
+ if (!url) return null;
214
+
215
+ const controller = new AbortController();
216
+ const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT);
217
+
218
+ try {
219
+ const response = await fetch(url, {
220
+ headers: {
221
+ Accept: AP_ACCEPT,
222
+ "User-Agent": USER_AGENT,
223
+ },
224
+ signal: controller.signal,
225
+ redirect: "follow",
226
+ });
227
+
228
+ if (!response.ok) {
229
+ console.warn(
230
+ `[Microsub] AP fetch failed: ${response.status} for ${url}`,
231
+ );
232
+ return null;
233
+ }
234
+
235
+ return await response.json();
236
+ } catch (error) {
237
+ if (error.name === "AbortError") {
238
+ console.warn(`[Microsub] AP fetch timeout for ${url}`);
239
+ } else {
240
+ console.warn(`[Microsub] AP fetch error for ${url}: ${error.message}`);
241
+ }
242
+ return null;
243
+ } finally {
244
+ clearTimeout(timeout);
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Strip HTML tags for plain text.
250
+ * @param {string} html
251
+ * @returns {string}
252
+ */
253
+ function stripHtml(html) {
254
+ return (html || "").replace(/<[^>]*>/g, "").trim();
255
+ }
@@ -28,6 +28,7 @@ import {
28
28
  markItemsRead,
29
29
  countReadItems,
30
30
  } from "../storage/items.js";
31
+ import { fetchActorOutbox } from "../activitypub/outbox-fetcher.js";
31
32
  import { getUserId } from "../utils/auth.js";
32
33
  import {
33
34
  validateChannelName,
@@ -207,8 +208,8 @@ export async function deleteChannelAction(request, response) {
207
208
  const userId = getUserId(request);
208
209
  const { uid } = request.params;
209
210
 
210
- // Don't allow deleting notifications channel
211
- if (uid === "notifications") {
211
+ // Don't allow deleting system channels
212
+ if (uid === "notifications" || uid === "activitypub") {
212
213
  return response.redirect(`${request.baseUrl}/channels`);
213
214
  }
214
215
 
@@ -909,6 +910,118 @@ export async function refreshFeed(request, response) {
909
910
  response.redirect(`${request.baseUrl}/channels/${uid}/feeds`);
910
911
  }
911
912
 
913
+ /**
914
+ * Actor profile — fetch and display a remote AP actor's recent posts
915
+ * @param {object} request - Express request
916
+ * @param {object} response - Express response
917
+ */
918
+ /**
919
+ * Find the ActivityPub plugin instance from installed plugins.
920
+ * @param {object} request - Express request
921
+ * @returns {object|undefined} The AP plugin instance
922
+ */
923
+ function getApPlugin(request) {
924
+ const installedPlugins = request.app.locals.installedPlugins;
925
+ if (!installedPlugins) return undefined;
926
+ return [...installedPlugins].find(
927
+ (p) => p.name === "ActivityPub endpoint",
928
+ );
929
+ }
930
+
931
+ export async function actorProfile(request, response) {
932
+ const actorUrl = request.query.url;
933
+ if (!actorUrl) {
934
+ return response.status(400).render("404");
935
+ }
936
+
937
+ // Check if we already follow this actor
938
+ const { application } = request.app.locals;
939
+ const apFollowing = application?.collections?.get("ap_following");
940
+ let isFollowing = false;
941
+ if (apFollowing) {
942
+ const existing = await apFollowing.findOne({ actorUrl });
943
+ isFollowing = !!existing;
944
+ }
945
+
946
+ // Check if AP plugin is available (for follow button visibility)
947
+ const apPlugin = getApPlugin(request);
948
+ const canFollow = !!apPlugin;
949
+
950
+ try {
951
+ const { actor, items } = await fetchActorOutbox(actorUrl, { limit: 30 });
952
+
953
+ response.render("actor", {
954
+ title: actor.name || "Actor",
955
+ actor,
956
+ items,
957
+ actorUrl,
958
+ isFollowing,
959
+ canFollow,
960
+ baseUrl: request.baseUrl,
961
+ });
962
+ } catch (error) {
963
+ console.error(`[Microsub] Actor profile fetch failed: ${error.message}`);
964
+ response.render("actor", {
965
+ title: "Actor",
966
+ actor: { name: actorUrl, url: actorUrl, photo: "", summary: "" },
967
+ items: [],
968
+ actorUrl,
969
+ isFollowing,
970
+ canFollow,
971
+ baseUrl: request.baseUrl,
972
+ error: "Could not fetch this actor's profile. They may have restricted access.",
973
+ });
974
+ }
975
+ }
976
+
977
+ export async function followActorAction(request, response) {
978
+ const { actorUrl, actorName } = request.body;
979
+ if (!actorUrl) {
980
+ return response.status(400).redirect(request.baseUrl + "/channels/activitypub");
981
+ }
982
+
983
+ const apPlugin = getApPlugin(request);
984
+ if (!apPlugin) {
985
+ console.error("[Microsub] Cannot follow: ActivityPub plugin not installed");
986
+ return response.redirect(
987
+ `${request.baseUrl}/actor?url=${encodeURIComponent(actorUrl)}`,
988
+ );
989
+ }
990
+
991
+ const result = await apPlugin.followActor(actorUrl, { name: actorName });
992
+ if (!result.ok) {
993
+ console.error(`[Microsub] Follow via AP plugin failed: ${result.error}`);
994
+ }
995
+
996
+ return response.redirect(
997
+ `${request.baseUrl}/actor?url=${encodeURIComponent(actorUrl)}`,
998
+ );
999
+ }
1000
+
1001
+ export async function unfollowActorAction(request, response) {
1002
+ const { actorUrl } = request.body;
1003
+ if (!actorUrl) {
1004
+ return response.status(400).redirect(request.baseUrl + "/channels/activitypub");
1005
+ }
1006
+
1007
+ const apPlugin = getApPlugin(request);
1008
+ if (!apPlugin) {
1009
+ console.error("[Microsub] Cannot unfollow: ActivityPub plugin not installed");
1010
+ return response.redirect(
1011
+ `${request.baseUrl}/actor?url=${encodeURIComponent(actorUrl)}`,
1012
+ );
1013
+ }
1014
+
1015
+ const result = await apPlugin.unfollowActor(actorUrl);
1016
+ if (!result.ok) {
1017
+ console.error(`[Microsub] Unfollow via AP plugin failed: ${result.error}`);
1018
+ }
1019
+
1020
+ return response.redirect(
1021
+ `${request.baseUrl}/actor?url=${encodeURIComponent(actorUrl)}`,
1022
+ );
1023
+ }
1024
+
912
1025
  export const readerController = {
913
1026
  index,
914
1027
  channels,
@@ -933,4 +1046,7 @@ export const readerController = {
933
1046
  searchPage,
934
1047
  searchFeeds,
935
1048
  subscribe,
1049
+ actorProfile,
1050
+ followActorAction,
1051
+ unfollowActorAction,
936
1052
  };
@@ -207,8 +207,8 @@ export async function deleteChannel(application, uid, userId) {
207
207
  const query = { uid };
208
208
  if (userId) query.userId = userId;
209
209
 
210
- // Don't allow deleting notifications channel
211
- if (uid === "notifications") {
210
+ // Don't allow deleting system channels
211
+ if (uid === "notifications" || uid === "activitypub") {
212
212
  return false;
213
213
  }
214
214
 
@@ -306,3 +306,39 @@ export async function ensureNotificationsChannel(application, userId) {
306
306
  await collection.insertOne(channel);
307
307
  return channel;
308
308
  }
309
+
310
+ /**
311
+ * Ensure ActivityPub channel exists
312
+ * @param {object} application - Indiekit application
313
+ * @param {string} [userId] - User ID
314
+ * @returns {Promise<object>} ActivityPub channel
315
+ */
316
+ export async function ensureActivityPubChannel(application, userId) {
317
+ const collection = getCollection(application);
318
+
319
+ const existing = await collection.findOne({
320
+ uid: "activitypub",
321
+ ...(userId && { userId }),
322
+ });
323
+
324
+ if (existing) {
325
+ return existing;
326
+ }
327
+
328
+ const channel = {
329
+ uid: "activitypub",
330
+ name: "Fediverse",
331
+ userId,
332
+ source: "activitypub",
333
+ order: -0.5, // After notifications (-1), before user channels (0+)
334
+ settings: {
335
+ excludeTypes: [],
336
+ excludeRegex: undefined,
337
+ },
338
+ createdAt: new Date().toISOString(),
339
+ updatedAt: new Date().toISOString(),
340
+ };
341
+
342
+ await collection.insertOne(channel);
343
+ return channel;
344
+ }
@@ -298,6 +298,10 @@ const MAX_FULL_READ_ITEMS = 200;
298
298
  * Cleanup old read items by stripping content but preserving dedup skeletons.
299
299
  * This prevents the vicious cycle where deleted read items get re-ingested as
300
300
  * unread by the poller because the dedup record (channelId + uid) was destroyed.
301
+ *
302
+ * AP items (feedId: null) are hard-deleted instead of stripped, since no poller
303
+ * re-ingests them — they arrive via inbox push and don't need dedup skeletons.
304
+ *
301
305
  * @param {object} collection - MongoDB collection
302
306
  * @param {ObjectId} channelObjectId - Channel ObjectId
303
307
  * @param {string} userId - User ID
@@ -310,22 +314,44 @@ async function cleanupOldReadItems(collection, channelObjectId, userId) {
310
314
 
311
315
  if (readCount > MAX_FULL_READ_ITEMS) {
312
316
  // Find old read items beyond the retention limit
313
- const itemsToStrip = await collection
317
+ const itemsToCleanup = await collection
314
318
  .find({
315
319
  channelId: channelObjectId,
316
320
  readBy: userId,
317
- _stripped: { $ne: true }, // Don't re-strip already-stripped items
321
+ _stripped: { $ne: true },
318
322
  })
319
323
  .sort({ published: -1, _id: -1 })
320
324
  .skip(MAX_FULL_READ_ITEMS)
321
- .project({ _id: 1 })
325
+ .project({ _id: 1, feedId: 1 })
322
326
  .toArray();
323
327
 
324
- if (itemsToStrip.length > 0) {
325
- const idsToStrip = itemsToStrip.map((item) => item._id);
326
- // Strip content but keep dedup skeleton (channelId, uid, feedId, readBy)
327
- const result = await collection.updateMany(
328
- { _id: { $in: idsToStrip } },
328
+ if (itemsToCleanup.length === 0) return;
329
+
330
+ // Separate AP items (feedId: null) from RSS items (feedId: ObjectId)
331
+ const apItemIds = [];
332
+ const rssItemIds = [];
333
+ for (const item of itemsToCleanup) {
334
+ if (item.feedId) {
335
+ rssItemIds.push(item._id);
336
+ } else {
337
+ apItemIds.push(item._id);
338
+ }
339
+ }
340
+
341
+ // Hard-delete AP items — no poller to re-ingest, skeletons are useless
342
+ if (apItemIds.length > 0) {
343
+ const deleted = await collection.deleteMany({
344
+ _id: { $in: apItemIds },
345
+ });
346
+ console.info(
347
+ `[Microsub] Deleted ${deleted.deletedCount} old AP read items`,
348
+ );
349
+ }
350
+
351
+ // Strip RSS items to dedup skeletons — poller would re-ingest if deleted
352
+ if (rssItemIds.length > 0) {
353
+ const stripped = await collection.updateMany(
354
+ { _id: { $in: rssItemIds } },
329
355
  {
330
356
  $set: { _stripped: true },
331
357
  $unset: {
@@ -346,7 +372,7 @@ async function cleanupOldReadItems(collection, channelObjectId, userId) {
346
372
  },
347
373
  );
348
374
  console.info(
349
- `[Microsub] Stripped content from ${result.modifiedCount} old read items (keeping ${MAX_FULL_READ_ITEMS} full)`,
375
+ `[Microsub] Stripped ${stripped.modifiedCount} old RSS read items (keeping ${MAX_FULL_READ_ITEMS} full)`,
350
376
  );
351
377
  }
352
378
  }
@@ -354,16 +380,16 @@ async function cleanupOldReadItems(collection, channelObjectId, userId) {
354
380
 
355
381
  /**
356
382
  * Cleanup all read items across all channels (startup cleanup).
357
- * Strips content from old read items but preserves dedup skeletons.
383
+ * RSS items are stripped to dedup skeletons; AP items are hard-deleted.
358
384
  * @param {object} application - Indiekit application
359
- * @returns {Promise<number>} Total number of items stripped
385
+ * @returns {Promise<number>} Total number of items cleaned up
360
386
  */
361
387
  export async function cleanupAllReadItems(application) {
362
388
  const collection = getCollection(application);
363
389
  const channelsCollection = application.collections.get("microsub_channels");
364
390
 
365
391
  const channels = await channelsCollection.find({}).toArray();
366
- let totalStripped = 0;
392
+ let totalCleaned = 0;
367
393
 
368
394
  for (const channel of channels) {
369
395
  const readByUsers = await collection.distinct("readBy", {
@@ -381,7 +407,7 @@ export async function cleanupAllReadItems(application) {
381
407
  });
382
408
 
383
409
  if (readCount > MAX_FULL_READ_ITEMS) {
384
- const itemsToStrip = await collection
410
+ const itemsToCleanup = await collection
385
411
  .find({
386
412
  channelId: channel._id,
387
413
  readBy: userId,
@@ -389,48 +415,71 @@ export async function cleanupAllReadItems(application) {
389
415
  })
390
416
  .sort({ published: -1, _id: -1 })
391
417
  .skip(MAX_FULL_READ_ITEMS)
392
- .project({ _id: 1 })
418
+ .project({ _id: 1, feedId: 1 })
393
419
  .toArray();
394
420
 
395
- if (itemsToStrip.length > 0) {
396
- const idsToStrip = itemsToStrip.map((item) => item._id);
397
- const result = await collection.updateMany(
398
- { _id: { $in: idsToStrip } },
399
- {
400
- $set: { _stripped: true },
401
- $unset: {
402
- name: "",
403
- content: "",
404
- summary: "",
405
- author: "",
406
- category: "",
407
- photo: "",
408
- video: "",
409
- audio: "",
410
- likeOf: "",
411
- repostOf: "",
412
- bookmarkOf: "",
413
- inReplyTo: "",
414
- source: "",
421
+ if (itemsToCleanup.length > 0) {
422
+ const apItemIds = [];
423
+ const rssItemIds = [];
424
+ for (const item of itemsToCleanup) {
425
+ if (item.feedId) {
426
+ rssItemIds.push(item._id);
427
+ } else {
428
+ apItemIds.push(item._id);
429
+ }
430
+ }
431
+
432
+ // Hard-delete AP items
433
+ if (apItemIds.length > 0) {
434
+ const deleted = await collection.deleteMany({
435
+ _id: { $in: apItemIds },
436
+ });
437
+ totalCleaned += deleted.deletedCount;
438
+ console.info(
439
+ `[Microsub] Startup cleanup: deleted ${deleted.deletedCount} AP items from channel "${channel.name}"`,
440
+ );
441
+ }
442
+
443
+ // Strip RSS items to skeletons
444
+ if (rssItemIds.length > 0) {
445
+ const stripped = await collection.updateMany(
446
+ { _id: { $in: rssItemIds } },
447
+ {
448
+ $set: { _stripped: true },
449
+ $unset: {
450
+ name: "",
451
+ content: "",
452
+ summary: "",
453
+ author: "",
454
+ category: "",
455
+ photo: "",
456
+ video: "",
457
+ audio: "",
458
+ likeOf: "",
459
+ repostOf: "",
460
+ bookmarkOf: "",
461
+ inReplyTo: "",
462
+ source: "",
463
+ },
415
464
  },
416
- },
417
- );
418
- totalStripped += result.modifiedCount;
419
- console.info(
420
- `[Microsub] Startup cleanup: stripped ${result.modifiedCount} old items from channel "${channel.name}"`,
421
- );
465
+ );
466
+ totalCleaned += stripped.modifiedCount;
467
+ console.info(
468
+ `[Microsub] Startup cleanup: stripped ${stripped.modifiedCount} RSS items from channel "${channel.name}"`,
469
+ );
470
+ }
422
471
  }
423
472
  }
424
473
  }
425
474
  }
426
475
 
427
- if (totalStripped > 0) {
476
+ if (totalCleaned > 0) {
428
477
  console.info(
429
- `[Microsub] Startup cleanup complete: ${totalStripped} total items stripped`,
478
+ `[Microsub] Startup cleanup complete: ${totalCleaned} total items cleaned`,
430
479
  );
431
480
  }
432
481
 
433
- return totalStripped;
482
+ return totalCleaned;
434
483
  }
435
484
 
436
485
  export async function markItemsRead(application, channelId, entryIds, userId) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-microsub",
3
- "version": "1.0.31",
3
+ "version": "1.0.32",
4
4
  "description": "Microsub endpoint for Indiekit. Enables subscribing to feeds and reading content using the Microsub protocol.",
5
5
  "keywords": [
6
6
  "indiekit",
@@ -0,0 +1,179 @@
1
+ {% extends "layouts/reader.njk" %}
2
+
3
+ {% block reader %}
4
+ <div class="channel">
5
+ <header class="channel__header">
6
+ <a href="{{ baseUrl }}/channels/activitypub" class="back-link">
7
+ {{ icon("previous") }} Fediverse
8
+ </a>
9
+ </header>
10
+
11
+ {# Actor profile card #}
12
+ <div class="actor-profile">
13
+ <div class="actor-profile__header">
14
+ {% if actor.photo %}
15
+ <img src="{{ actor.photo }}"
16
+ alt=""
17
+ class="actor-profile__avatar"
18
+ width="80"
19
+ height="80"
20
+ onerror="this.style.display='none'">
21
+ {% endif %}
22
+ <div class="actor-profile__info">
23
+ <h2 class="actor-profile__name">{{ actor.name }}</h2>
24
+ {% if actor.handle %}
25
+ <span class="actor-profile__handle">@{{ actor.handle }}</span>
26
+ {% endif %}
27
+ {% if actor.summary %}
28
+ <p class="actor-profile__summary">{{ actor.summary }}</p>
29
+ {% endif %}
30
+ <div class="actor-profile__stats">
31
+ {% if actor.followersCount %}
32
+ <span>{{ actor.followersCount }} followers</span>
33
+ {% endif %}
34
+ {% if actor.followingCount %}
35
+ <span>{{ actor.followingCount }} following</span>
36
+ {% endif %}
37
+ </div>
38
+ </div>
39
+ </div>
40
+ <div class="actor-profile__actions">
41
+ <a href="{{ actor.url }}" class="button button--secondary button--small" target="_blank" rel="noopener">
42
+ {{ icon("external") }} View profile
43
+ </a>
44
+ {% if canFollow %}
45
+ {% if isFollowing %}
46
+ <form action="{{ baseUrl }}/actor/unfollow" method="POST" style="display: inline;">
47
+ <input type="hidden" name="actorUrl" value="{{ actorUrl }}">
48
+ <button type="submit" class="button button--secondary button--small">
49
+ {{ icon("checkboxChecked") }} Following
50
+ </button>
51
+ </form>
52
+ {% else %}
53
+ <form action="{{ baseUrl }}/actor/follow" method="POST" style="display: inline;">
54
+ <input type="hidden" name="actorUrl" value="{{ actorUrl }}">
55
+ <input type="hidden" name="actorName" value="{{ actor.name }}">
56
+ <button type="submit" class="button button--primary button--small">
57
+ {{ icon("syndicate") }} Follow
58
+ </button>
59
+ </form>
60
+ {% endif %}
61
+ {% endif %}
62
+ </div>
63
+ </div>
64
+
65
+ {% if error %}
66
+ <div class="reader__empty">
67
+ {{ icon("warning") }}
68
+ <p>{{ error }}</p>
69
+ </div>
70
+ {% elif items.length > 0 %}
71
+ <div class="timeline" id="timeline">
72
+ {% for item in items %}
73
+ <article class="item-card">
74
+ {# Author #}
75
+ {% if item.author %}
76
+ <div class="item-card__author" style="padding: 12px 16px 0;">
77
+ {% if item.author.photo %}
78
+ <img src="{{ item.author.photo }}"
79
+ alt=""
80
+ class="item-card__author-photo"
81
+ width="40"
82
+ height="40"
83
+ loading="lazy"
84
+ onerror="this.style.display='none'">
85
+ {% endif %}
86
+ <div class="item-card__author-info">
87
+ <span class="item-card__author-name">{{ item.author.name or "Unknown" }}</span>
88
+ {% if item.author.url %}
89
+ <span class="item-card__source">{{ item.author.url | replace("https://", "") | replace("http://", "") }}</span>
90
+ {% endif %}
91
+ </div>
92
+ </div>
93
+ {% endif %}
94
+
95
+ <a href="{{ item.url }}" class="item-card__link" target="_blank" rel="noopener">
96
+ {# Reply context #}
97
+ {% if item["in-reply-to"] and item["in-reply-to"].length > 0 %}
98
+ <div class="item-card__context">
99
+ {{ icon("reply") }}
100
+ <span>Reply to</span>
101
+ <span>{{ item["in-reply-to"][0] | replace("https://", "") | replace("http://", "") | truncate(50) }}</span>
102
+ </div>
103
+ {% endif %}
104
+
105
+ {# Title #}
106
+ {% if item.name %}
107
+ <h3 class="item-card__title">{{ item.name }}</h3>
108
+ {% endif %}
109
+
110
+ {# Content #}
111
+ {% if item.content %}
112
+ <div class="item-card__content{% if (item.content.text or '') | length > 300 %} item-card__content--truncated{% endif %}">
113
+ {% if item.content.html %}
114
+ {{ item.content.html | safe | striptags | truncate(400) }}
115
+ {% elif item.content.text %}
116
+ {{ item.content.text | truncate(400) }}
117
+ {% endif %}
118
+ </div>
119
+ {% endif %}
120
+
121
+ {# Tags #}
122
+ {% if item.category and item.category.length > 0 %}
123
+ <div class="item-card__categories">
124
+ {% for cat in item.category | slice(0, 5) %}
125
+ <span class="item-card__category">#{{ cat }}</span>
126
+ {% endfor %}
127
+ </div>
128
+ {% endif %}
129
+
130
+ {# Photos #}
131
+ {% if item.photo and item.photo.length > 0 %}
132
+ {% set photoCount = item.photo.length if item.photo.length <= 4 else 4 %}
133
+ <div class="item-card__photos item-card__photos--{{ photoCount }}">
134
+ {% for photo in item.photo | slice(0, 4) %}
135
+ <img src="{{ photo }}" alt="" class="item-card__photo" loading="lazy"
136
+ onerror="this.parentElement.removeChild(this)">
137
+ {% endfor %}
138
+ </div>
139
+ {% endif %}
140
+
141
+ {# Footer #}
142
+ <footer class="item-card__footer">
143
+ {% if item.published %}
144
+ <time datetime="{{ item.published }}" class="item-card__date">
145
+ {{ item.published | date("PP", { locale: locale, timeZone: application.timeZone }) }}
146
+ </time>
147
+ {% endif %}
148
+ </footer>
149
+ </a>
150
+
151
+ {# Actions #}
152
+ <div class="item-actions">
153
+ <a href="{{ item.url }}" class="item-actions__button" target="_blank" rel="noopener" title="View original">
154
+ {{ icon("external") }}
155
+ </a>
156
+ <a href="{{ baseUrl }}/compose?reply={{ item.url | urlencode }}" class="item-actions__button" title="Reply">
157
+ {{ icon("reply") }}
158
+ </a>
159
+ <a href="{{ baseUrl }}/compose?like={{ item.url | urlencode }}" class="item-actions__button" title="Like">
160
+ {{ icon("like") }}
161
+ </a>
162
+ <a href="{{ baseUrl }}/compose?repost={{ item.url | urlencode }}" class="item-actions__button" title="Repost">
163
+ {{ icon("repost") }}
164
+ </a>
165
+ <a href="{{ baseUrl }}/compose?bookmark={{ item.url | urlencode }}" class="item-actions__button" title="Bookmark">
166
+ {{ icon("bookmark") }}
167
+ </a>
168
+ </div>
169
+ </article>
170
+ {% endfor %}
171
+ </div>
172
+ {% else %}
173
+ <div class="reader__empty">
174
+ {{ icon("syndicate") }}
175
+ <p>No posts found for this actor.</p>
176
+ </div>
177
+ {% endif %}
178
+ </div>
179
+ {% endblock %}
@@ -63,7 +63,12 @@
63
63
  {% endif %}
64
64
  <div class="item-card__author-info">
65
65
  <span class="item-card__author-name">{{ item.author.name or "Unknown" }}</span>
66
- {% if item._source %}
66
+ {% if item._source and item._source.type === "activitypub" %}
67
+ <span class="item-card__source">
68
+ <span class="item-card__badge item-card__badge--ap" title="Fediverse">AP</span>
69
+ {{ item.author.url | replace("https://", "") | replace("http://", "") }}
70
+ </span>
71
+ {% elif item._source %}
67
72
  <span class="item-card__source">{{ item._source.name or item._source.url }}</span>
68
73
  {% elif item.author.url %}
69
74
  <span class="item-card__source">{{ item.author.url | replace("https://", "") | replace("http://", "") }}</span>
@@ -147,6 +152,12 @@
147
152
 
148
153
  {# Inline actions (Aperture pattern) #}
149
154
  <div class="item-actions">
155
+ {% if item._source and item._source.type === "activitypub" and item.author and item.author.url %}
156
+ <a href="{{ baseUrl }}/actor?url={{ item.author.url | urlencode }}" class="item-actions__button" title="View actor profile">
157
+ {{ icon("mention") }}
158
+ <span class="visually-hidden">Actor profile</span>
159
+ </a>
160
+ {% endif %}
150
161
  {% if item.url %}
151
162
  <a href="{{ item.url }}" class="item-actions__button" target="_blank" rel="noopener" title="View original">
152
163
  {{ icon("external") }}