@rmdes/indiekit-endpoint-microsub 1.0.26 → 1.0.28

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.
@@ -54,7 +54,7 @@ export async function block(request, response) {
54
54
  await collection.insertOne({
55
55
  userId,
56
56
  url,
57
- createdAt: new Date(),
57
+ createdAt: new Date().toISOString(),
58
58
  });
59
59
  }
60
60
 
@@ -14,6 +14,7 @@ import {
14
14
  getFeedsForChannel,
15
15
  } from "../storage/feeds.js";
16
16
  import { getUserId } from "../utils/auth.js";
17
+ import { notifyBlogroll } from "../utils/blogroll-notify.js";
17
18
  import { createFeedResponse } from "../utils/jf2.js";
18
19
  import { validateChannel, validateUrl } from "../utils/validation.js";
19
20
  import {
@@ -78,6 +79,17 @@ export async function follow(request, response) {
78
79
  console.error(`[Microsub] Error fetching new feed ${url}:`, error.message);
79
80
  });
80
81
 
82
+ // Notify blogroll plugin (fire-and-forget)
83
+ notifyBlogroll(application, "follow", {
84
+ url,
85
+ title: feed.title,
86
+ channelName: channelDocument.name,
87
+ feedId: feed._id.toString(),
88
+ channelId: channelDocument._id.toString(),
89
+ }).catch((error) => {
90
+ console.error(`[Microsub] Blogroll notify error:`, error.message);
91
+ });
92
+
81
93
  response.status(201).json(createFeedResponse(feed));
82
94
  }
83
95
 
@@ -122,6 +134,11 @@ export async function unfollow(request, response) {
122
134
  throw new IndiekitError("Feed not found", { status: 404 });
123
135
  }
124
136
 
137
+ // Notify blogroll plugin (fire-and-forget)
138
+ notifyBlogroll(application, "unfollow", { url }).catch((error) => {
139
+ console.error(`[Microsub] Blogroll notify error:`, error.message);
140
+ });
141
+
125
142
  response.json({ result: "ok" });
126
143
  }
127
144
 
@@ -85,7 +85,7 @@ export async function mute(request, response) {
85
85
  userId,
86
86
  channelId,
87
87
  url,
88
- createdAt: new Date(),
88
+ createdAt: new Date().toISOString(),
89
89
  });
90
90
  }
91
91
 
@@ -74,8 +74,8 @@ export async function createChannel(application, { name, userId }) {
74
74
  excludeTypes: [],
75
75
  excludeRegex: undefined,
76
76
  },
77
- createdAt: new Date(),
78
- updatedAt: new Date(),
77
+ createdAt: new Date().toISOString(),
78
+ updatedAt: new Date().toISOString(),
79
79
  };
80
80
 
81
81
  await collection.insertOne(channel);
@@ -185,7 +185,7 @@ export async function updateChannel(application, uid, updates, userId) {
185
185
  {
186
186
  $set: {
187
187
  ...updates,
188
- updatedAt: new Date(),
188
+ updatedAt: new Date().toISOString(),
189
189
  },
190
190
  },
191
191
  { returnDocument: "after" },
@@ -242,7 +242,7 @@ export async function reorderChannels(application, channelUids, userId) {
242
242
  const operations = channelUids.map((uid, index) => ({
243
243
  updateOne: {
244
244
  filter: userId ? { uid, userId } : { uid },
245
- update: { $set: { order: index, updatedAt: new Date() } },
245
+ update: { $set: { order: index, updatedAt: new Date().toISOString() } },
246
246
  },
247
247
  }));
248
248
 
@@ -298,8 +298,8 @@ export async function ensureNotificationsChannel(application, userId) {
298
298
  excludeTypes: [],
299
299
  excludeRegex: undefined,
300
300
  },
301
- createdAt: new Date(),
302
- updatedAt: new Date(),
301
+ createdAt: new Date().toISOString(),
302
+ updatedAt: new Date().toISOString(),
303
303
  };
304
304
 
305
305
  await collection.insertOne(channel);
@@ -45,11 +45,11 @@ export async function createFeed(
45
45
  photo: photo || undefined,
46
46
  tier: 1, // Start at tier 1 (2 minutes)
47
47
  unmodified: 0,
48
- nextFetchAt: new Date(), // Fetch immediately
48
+ nextFetchAt: new Date(), // Fetch immediately (kept as Date for query compatibility)
49
49
  lastFetchedAt: undefined,
50
50
  websub: undefined, // Will be populated if hub is discovered
51
- createdAt: new Date(),
52
- updatedAt: new Date(),
51
+ createdAt: new Date().toISOString(),
52
+ updatedAt: new Date().toISOString(),
53
53
  };
54
54
 
55
55
  await collection.insertOne(feed);
@@ -114,7 +114,7 @@ export async function updateFeed(application, id, updates) {
114
114
  {
115
115
  $set: {
116
116
  ...updates,
117
- updatedAt: new Date(),
117
+ updatedAt: new Date().toISOString(),
118
118
  },
119
119
  },
120
120
  { returnDocument: "after" },
@@ -227,15 +227,15 @@ export async function updateFeedAfterFetch(
227
227
  updateData = {
228
228
  tier,
229
229
  unmodified,
230
- nextFetchAt,
231
- lastFetchedAt: new Date(),
232
- updatedAt: new Date(),
230
+ nextFetchAt, // Kept as Date for query compatibility
231
+ lastFetchedAt: new Date().toISOString(),
232
+ updatedAt: new Date().toISOString(),
233
233
  };
234
234
  } else {
235
235
  updateData = {
236
236
  ...extra,
237
- lastFetchedAt: new Date(),
238
- updatedAt: new Date(),
237
+ lastFetchedAt: new Date().toISOString(),
238
+ updatedAt: new Date().toISOString(),
239
239
  };
240
240
  }
241
241
 
@@ -280,7 +280,7 @@ export async function updateFeedWebsub(application, id, websub) {
280
280
  {
281
281
  $set: {
282
282
  websub: websubData,
283
- updatedAt: new Date(),
283
+ updatedAt: new Date().toISOString(),
284
284
  },
285
285
  },
286
286
  { returnDocument: "after" },
@@ -314,12 +314,12 @@ export async function updateFeedStatus(application, id, status) {
314
314
  const objectId = typeof id === "string" ? new ObjectId(id) : id;
315
315
 
316
316
  const updateFields = {
317
- updatedAt: new Date(),
317
+ updatedAt: new Date().toISOString(),
318
318
  };
319
319
 
320
320
  if (status.success) {
321
321
  updateFields.status = "active";
322
- updateFields.lastSuccessAt = new Date();
322
+ updateFields.lastSuccessAt = new Date().toISOString();
323
323
  updateFields.consecutiveErrors = 0;
324
324
  updateFields.lastError = undefined;
325
325
  updateFields.lastErrorAt = undefined;
@@ -330,7 +330,7 @@ export async function updateFeedStatus(application, id, status) {
330
330
  } else {
331
331
  updateFields.status = "error";
332
332
  updateFields.lastError = status.error;
333
- updateFields.lastErrorAt = new Date();
333
+ updateFields.lastErrorAt = new Date().toISOString();
334
334
  }
335
335
 
336
336
  // Use $set for most fields, $inc for consecutiveErrors on failure
@@ -49,8 +49,8 @@ export async function addItem(application, { channelId, feedId, uid, item }) {
49
49
  name: item.name || undefined,
50
50
  content: item.content || undefined,
51
51
  summary: item.summary || undefined,
52
- published: item.published ? new Date(item.published) : new Date(),
53
- updated: item.updated ? new Date(item.updated) : undefined,
52
+ published: item.published ? new Date(item.published) : new Date(), // Keep as Date for query compatibility
53
+ updated: item.updated ? new Date(item.updated) : undefined, // Keep as Date for query compatibility
54
54
  author: item.author || undefined,
55
55
  category: item.category || [],
56
56
  photo: item.photo || [],
@@ -62,7 +62,7 @@ export async function addItem(application, { channelId, feedId, uid, item }) {
62
62
  inReplyTo: item["in-reply-to"] || item.inReplyTo || [],
63
63
  source: item._source || undefined,
64
64
  readBy: [], // Array of user IDs who have read this item
65
- createdAt: new Date(),
65
+ createdAt: new Date().toISOString(),
66
66
  };
67
67
 
68
68
  await collection.insertOne(document);
@@ -182,7 +182,7 @@ function transformToJf2(item, userId) {
182
182
  type: item.type,
183
183
  uid: item.uid,
184
184
  url: item.url,
185
- published: item.published?.toISOString(),
185
+ published: item.published?.toISOString(), // Convert Date to ISO string
186
186
  _id: item._id.toString(),
187
187
  _is_read: userId ? item.readBy?.includes(userId) : false,
188
188
  };
@@ -191,7 +191,7 @@ function transformToJf2(item, userId) {
191
191
  if (item.name) jf2.name = item.name;
192
192
  if (item.content) jf2.content = item.content;
193
193
  if (item.summary) jf2.summary = item.summary;
194
- if (item.updated) jf2.updated = item.updated.toISOString();
194
+ if (item.updated) jf2.updated = item.updated.toISOString(); // Convert Date to ISO string
195
195
  if (item.author) jf2.author = normalizeAuthor(item.author);
196
196
  if (item.category?.length > 0) jf2.category = item.category;
197
197
 
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Notify blogroll plugin of Microsub follow/unfollow events
3
+ * @module utils/blogroll-notify
4
+ */
5
+
6
+ /**
7
+ * Notify blogroll of a feed subscription change
8
+ * Fire-and-forget — errors are logged but don't block the response
9
+ * @param {object} application - Application instance
10
+ * @param {string} action - "follow" or "unfollow"
11
+ * @param {object} data - Feed data
12
+ * @param {string} data.url - Feed URL
13
+ * @param {string} [data.title] - Feed title
14
+ * @param {string} [data.channelName] - Channel name
15
+ * @param {string} [data.feedId] - Microsub feed ID
16
+ * @param {string} [data.channelId] - Microsub channel ID
17
+ */
18
+ export async function notifyBlogroll(application, action, data) {
19
+ // Check if blogroll plugin is installed
20
+ if (typeof application.getBlogrollDb !== "function") {
21
+ return;
22
+ }
23
+
24
+ const db = application.getBlogrollDb();
25
+ if (!db) {
26
+ return;
27
+ }
28
+
29
+ const collection = db.collection("blogrollBlogs");
30
+ const now = new Date();
31
+
32
+ if (action === "follow") {
33
+ // Skip if this feed was explicitly deleted by the user
34
+ const deleted = await collection.findOne({
35
+ feedUrl: data.url,
36
+ status: "deleted",
37
+ });
38
+ if (deleted) {
39
+ console.log(
40
+ `[Microsub→Blogroll] Skipping follow for ${data.url} — previously deleted by user`,
41
+ );
42
+ return;
43
+ }
44
+
45
+ // Upsert the blog entry
46
+ await collection.updateOne(
47
+ { feedUrl: data.url },
48
+ {
49
+ $set: {
50
+ title: data.title || extractDomain(data.url),
51
+ siteUrl: extractSiteUrl(data.url),
52
+ feedType: "rss",
53
+ category: data.channelName || "Microsub",
54
+ source: "microsub",
55
+ microsubFeedId: data.feedId || null,
56
+ microsubChannelId: data.channelId || null,
57
+ microsubChannelName: data.channelName || null,
58
+ skipItemFetch: true,
59
+ status: "active",
60
+ updatedAt: now,
61
+ },
62
+ $setOnInsert: {
63
+ description: null,
64
+ tags: [],
65
+ photo: null,
66
+ author: null,
67
+ lastFetchAt: null,
68
+ lastError: null,
69
+ itemCount: 0,
70
+ pinned: false,
71
+ hidden: false,
72
+ notes: null,
73
+ createdAt: now,
74
+ },
75
+ },
76
+ { upsert: true },
77
+ );
78
+
79
+ console.log(`[Microsub→Blogroll] Added/updated feed ${data.url}`);
80
+ } else if (action === "unfollow") {
81
+ // Soft-delete the blog entry if it came from microsub
82
+ const result = await collection.updateOne(
83
+ {
84
+ feedUrl: data.url,
85
+ source: "microsub",
86
+ status: { $ne: "deleted" },
87
+ },
88
+ {
89
+ $set: {
90
+ status: "deleted",
91
+ hidden: true,
92
+ deletedAt: now,
93
+ updatedAt: now,
94
+ },
95
+ },
96
+ );
97
+
98
+ if (result.modifiedCount > 0) {
99
+ console.log(`[Microsub→Blogroll] Soft-deleted feed ${data.url}`);
100
+ }
101
+ }
102
+ }
103
+
104
+ function extractDomain(url) {
105
+ try {
106
+ return new URL(url).hostname.replace(/^www\./, "");
107
+ } catch {
108
+ return url;
109
+ }
110
+ }
111
+
112
+ function extractSiteUrl(feedUrl) {
113
+ try {
114
+ const parsed = new URL(feedUrl);
115
+ return `${parsed.protocol}//${parsed.host}`;
116
+ } catch {
117
+ return "";
118
+ }
119
+ }
@@ -61,10 +61,10 @@ export async function processWebmention(application, source, target, userId) {
61
61
  url: verification.url,
62
62
  published: verification.published
63
63
  ? new Date(verification.published)
64
- : new Date(),
64
+ : new Date(), // Keep as Date for query compatibility
65
65
  verified: true,
66
66
  readBy: [],
67
- updatedAt: new Date(),
67
+ updatedAt: new Date().toISOString(),
68
68
  };
69
69
 
70
70
  if (existing) {
@@ -73,7 +73,7 @@ export async function processWebmention(application, source, target, userId) {
73
73
  notification._id = existing._id;
74
74
  } else {
75
75
  // Insert new notification
76
- notification.createdAt = new Date();
76
+ notification.createdAt = new Date().toISOString();
77
77
  await collection.insertOne(notification);
78
78
  }
79
79
 
@@ -190,7 +190,7 @@ function transformNotification(notification, userId) {
190
190
  type: "entry",
191
191
  uid: notification._id?.toString(),
192
192
  url: notification.url || notification.source,
193
- published: notification.published?.toISOString(),
193
+ published: notification.published?.toISOString(), // Convert Date to ISO string
194
194
  author: notification.author,
195
195
  content: notification.content,
196
196
  _source: notification.source,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-microsub",
3
- "version": "1.0.26",
3
+ "version": "1.0.28",
4
4
  "description": "Microsub endpoint for Indiekit. Enables subscribing to feeds and reading content using the Microsub protocol.",
5
5
  "keywords": [
6
6
  "indiekit",