@rmdes/indiekit-endpoint-microsub 1.0.0 → 1.0.1

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
@@ -83,10 +83,7 @@ export default class MicrosubEndpoint {
83
83
  "/channels/:uid/settings",
84
84
  readerController.updateSettings,
85
85
  );
86
- readerRouter.post(
87
- "/channels/:uid/delete",
88
- readerController.deleteChannel,
89
- );
86
+ readerRouter.post("/channels/:uid/delete", readerController.deleteChannel);
90
87
  readerRouter.get("/channels/:uid/feeds", readerController.feeds);
91
88
  readerRouter.post("/channels/:uid/feeds", readerController.addFeed);
92
89
  readerRouter.post(
@@ -13,8 +13,8 @@ import {
13
13
  getFeedByUrl,
14
14
  getFeedsForChannel,
15
15
  } from "../storage/feeds.js";
16
- import { createFeedResponse } from "../utils/jf2.js";
17
16
  import { getUserId } from "../utils/auth.js";
17
+ import { createFeedResponse } from "../utils/jf2.js";
18
18
  import { validateChannel, validateUrl } from "../utils/validation.js";
19
19
  import {
20
20
  unsubscribe as websubUnsubscribe,
@@ -392,10 +392,16 @@ export async function submitCompose(request, response) {
392
392
  body: micropubData.toString(),
393
393
  });
394
394
 
395
- if (micropubResponse.ok || micropubResponse.status === 201 || micropubResponse.status === 202) {
395
+ if (
396
+ micropubResponse.ok ||
397
+ micropubResponse.status === 201 ||
398
+ micropubResponse.status === 202
399
+ ) {
396
400
  // Success - get the Location header for the new post URL
397
401
  const location = micropubResponse.headers.get("Location");
398
- console.info(`[Microsub] Created post via Micropub: ${location || "success"}`);
402
+ console.info(
403
+ `[Microsub] Created post via Micropub: ${location || "success"}`,
404
+ );
399
405
 
400
406
  // Redirect back to reader with success message
401
407
  return response.redirect(`${request.baseUrl}/channels`);
@@ -403,7 +409,9 @@ export async function submitCompose(request, response) {
403
409
 
404
410
  // Handle error
405
411
  const errorBody = await micropubResponse.text();
406
- console.error(`[Microsub] Micropub error: ${micropubResponse.status} ${errorBody}`);
412
+ console.error(
413
+ `[Microsub] Micropub error: ${micropubResponse.status} ${errorBody}`,
414
+ );
407
415
 
408
416
  return response.status(micropubResponse.status).render("error", {
409
417
  title: "Error",
@@ -150,7 +150,7 @@ function extractSelfFromLinkHeader(linkHeader) {
150
150
  * @returns {Promise<object>} Parsed feed
151
151
  */
152
152
  export async function fetchAndParseFeed(url, options = {}) {
153
- const { parseFeed } = await import("./parser.js");
153
+ const { parseFeed, detectFeedType } = await import("./parser.js");
154
154
 
155
155
  const result = await fetchFeed(url, options);
156
156
 
@@ -161,6 +161,32 @@ export async function fetchAndParseFeed(url, options = {}) {
161
161
  };
162
162
  }
163
163
 
164
+ // Check if we got a parseable feed
165
+ const feedType = detectFeedType(result.content, result.contentType);
166
+
167
+ // If we got ActivityPub or unknown, try common feed paths
168
+ if (feedType === "activitypub" || feedType === "unknown") {
169
+ const fallbackFeed = await tryCommonFeedPaths(url, options);
170
+ if (fallbackFeed) {
171
+ // Fetch and parse the discovered feed
172
+ const feedResult = await fetchFeed(fallbackFeed.url, options);
173
+ if (!feedResult.notModified) {
174
+ const parsed = await parseFeed(feedResult.content, fallbackFeed.url, {
175
+ contentType: feedResult.contentType,
176
+ });
177
+ return {
178
+ ...feedResult,
179
+ ...parsed,
180
+ hub: feedResult.hub || parsed._hub,
181
+ discoveredFrom: url,
182
+ };
183
+ }
184
+ }
185
+ throw new Error(
186
+ `Unable to find a feed at ${url}. Try the direct feed URL.`,
187
+ );
188
+ }
189
+
164
190
  const parsed = await parseFeed(result.content, url, {
165
191
  contentType: result.contentType,
166
192
  });
@@ -172,6 +198,48 @@ export async function fetchAndParseFeed(url, options = {}) {
172
198
  };
173
199
  }
174
200
 
201
+ /**
202
+ * Common feed paths to try when discovery fails
203
+ */
204
+ const COMMON_FEED_PATHS = ["/feed/", "/feed", "/rss", "/rss.xml", "/atom.xml"];
205
+
206
+ /**
207
+ * Try to fetch a feed from common paths
208
+ * @param {string} baseUrl - Base URL of the site
209
+ * @param {object} options - Fetch options
210
+ * @returns {Promise<object|undefined>} Feed result or undefined
211
+ */
212
+ async function tryCommonFeedPaths(baseUrl, options = {}) {
213
+ const base = new URL(baseUrl);
214
+
215
+ for (const feedPath of COMMON_FEED_PATHS) {
216
+ const feedUrl = new URL(feedPath, base).href;
217
+ try {
218
+ const result = await fetchFeed(feedUrl, { ...options, timeout: 10_000 });
219
+ const contentType = result.contentType?.toLowerCase() || "";
220
+
221
+ // Check if we got a feed
222
+ if (
223
+ contentType.includes("xml") ||
224
+ contentType.includes("rss") ||
225
+ contentType.includes("atom") ||
226
+ (contentType.includes("json") &&
227
+ result.content?.includes("jsonfeed.org"))
228
+ ) {
229
+ return {
230
+ url: feedUrl,
231
+ type: contentType.includes("json") ? "jsonfeed" : "xml",
232
+ rel: "alternate",
233
+ };
234
+ }
235
+ } catch {
236
+ // Try next path
237
+ }
238
+ }
239
+
240
+ return;
241
+ }
242
+
175
243
  /**
176
244
  * Discover feeds from a URL
177
245
  * @param {string} url - Page URL
@@ -187,19 +255,62 @@ export async function discoverFeedsFromUrl(url, options = {}) {
187
255
  if (
188
256
  contentType.includes("xml") ||
189
257
  contentType.includes("rss") ||
190
- contentType.includes("atom") ||
191
- contentType.includes("json")
258
+ contentType.includes("atom")
192
259
  ) {
193
260
  return [
194
261
  {
195
262
  url,
196
- type: contentType.includes("json") ? "jsonfeed" : "xml",
263
+ type: "xml",
197
264
  rel: "self",
198
265
  },
199
266
  ];
200
267
  }
201
268
 
202
- // Otherwise, discover feeds from HTML
203
- const feeds = await discoverFeeds(result.content, url);
204
- return feeds;
269
+ // Check for JSON Feed specifically
270
+ if (
271
+ contentType.includes("json") &&
272
+ result.content?.includes("jsonfeed.org")
273
+ ) {
274
+ return [
275
+ {
276
+ url,
277
+ type: "jsonfeed",
278
+ rel: "self",
279
+ },
280
+ ];
281
+ }
282
+
283
+ // Check if we got ActivityPub JSON or other non-feed JSON
284
+ // This happens with WordPress sites using ActivityPub plugin
285
+ if (
286
+ contentType.includes("json") ||
287
+ (result.content?.trim().startsWith("{") &&
288
+ result.content?.includes("@context"))
289
+ ) {
290
+ // Try common feed paths as fallback
291
+ const fallbackFeed = await tryCommonFeedPaths(url, options);
292
+ if (fallbackFeed) {
293
+ return [fallbackFeed];
294
+ }
295
+ }
296
+
297
+ // If content looks like HTML, discover feeds from it
298
+ if (
299
+ contentType.includes("html") ||
300
+ result.content?.includes("<!DOCTYPE html") ||
301
+ result.content?.includes("<html")
302
+ ) {
303
+ const feeds = await discoverFeeds(result.content, url);
304
+ if (feeds.length > 0) {
305
+ return feeds;
306
+ }
307
+ }
308
+
309
+ // Last resort: try common feed paths
310
+ const fallbackFeed = await tryCommonFeedPaths(url, options);
311
+ if (fallbackFeed) {
312
+ return [fallbackFeed];
313
+ }
314
+
315
+ return [];
205
316
  }
@@ -7,6 +7,66 @@ import crypto from "node:crypto";
7
7
 
8
8
  import sanitizeHtml from "sanitize-html";
9
9
 
10
+ /**
11
+ * Parse a date string with fallback for non-standard formats
12
+ * @param {string|Date} dateInput - Date string or Date object
13
+ * @returns {Date|undefined} Parsed Date or undefined if invalid
14
+ */
15
+ function parseDate(dateInput) {
16
+ if (!dateInput) {
17
+ return;
18
+ }
19
+
20
+ // Already a valid Date
21
+ if (dateInput instanceof Date && !Number.isNaN(dateInput.getTime())) {
22
+ return dateInput;
23
+ }
24
+
25
+ const dateString = String(dateInput).trim();
26
+
27
+ // Try standard parsing first
28
+ let date = new Date(dateString);
29
+ if (!Number.isNaN(date.getTime())) {
30
+ return date;
31
+ }
32
+
33
+ // Handle "YYYY-MM-DD HH:MM" format (missing seconds and timezone)
34
+ // e.g., "2026-01-28 08:40"
35
+ const shortDateTime = dateString.match(
36
+ /^(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2})$/,
37
+ );
38
+ if (shortDateTime) {
39
+ date = new Date(`${shortDateTime[1]}T${shortDateTime[2]}:00Z`);
40
+ if (!Number.isNaN(date.getTime())) {
41
+ return date;
42
+ }
43
+ }
44
+
45
+ // Handle "YYYY-MM-DD HH:MM:SS" without timezone
46
+ const dateTimeNoTz = dateString.match(
47
+ /^(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2})$/,
48
+ );
49
+ if (dateTimeNoTz) {
50
+ date = new Date(`${dateTimeNoTz[1]}T${dateTimeNoTz[2]}Z`);
51
+ if (!Number.isNaN(date.getTime())) {
52
+ return date;
53
+ }
54
+ }
55
+
56
+ // If all else fails, return undefined
57
+ return;
58
+ }
59
+
60
+ /**
61
+ * Safely convert date to ISO string
62
+ * @param {string|Date} dateInput - Date input
63
+ * @returns {string|undefined} ISO string or undefined
64
+ */
65
+ function toISOStringSafe(dateInput) {
66
+ const date = parseDate(dateInput);
67
+ return date ? date.toISOString() : undefined;
68
+ }
69
+
10
70
  /**
11
71
  * Sanitize HTML options
12
72
  */
@@ -91,8 +151,8 @@ export function normalizeItem(item, feedUrl, feedType) {
91
151
  uid,
92
152
  url,
93
153
  name: item.title || undefined,
94
- published: item.pubdate ? new Date(item.pubdate).toISOString() : undefined,
95
- updated: item.date ? new Date(item.date).toISOString() : undefined,
154
+ published: toISOStringSafe(item.pubdate),
155
+ updated: toISOStringSafe(item.date),
96
156
  _source: {
97
157
  feedUrl,
98
158
  feedType,
@@ -51,13 +51,18 @@ export function detectFeedType(content, contentType = "") {
51
51
  // Fall back to content inspection
52
52
  const trimmed = content.trim();
53
53
 
54
- // JSON Feed
54
+ // JSON content
55
55
  if (trimmed.startsWith("{")) {
56
56
  try {
57
57
  const json = JSON.parse(trimmed);
58
+ // JSON Feed
58
59
  if (json.version && json.version.includes("jsonfeed.org")) {
59
60
  return "jsonfeed";
60
61
  }
62
+ // ActivityPub - return special type to indicate we need feed discovery
63
+ if (json["@context"] || json.type === "Group" || json.inbox) {
64
+ return "activitypub";
65
+ }
61
66
  } catch {
62
67
  // Not JSON
63
68
  }
@@ -112,6 +117,12 @@ export async function parseFeed(content, feedUrl, options = {}) {
112
117
  return parseHfeed(content, feedUrl);
113
118
  }
114
119
 
120
+ case "activitypub": {
121
+ throw new Error(
122
+ `URL returns ActivityPub JSON instead of a feed. Try the direct feed URL (e.g., ${feedUrl}feed/)`,
123
+ );
124
+ }
125
+
115
126
  default: {
116
127
  throw new Error(`Unable to detect feed type for ${feedUrl}`);
117
128
  }
@@ -9,13 +9,13 @@ import { getCache, setCache } from "../cache/redis.js";
9
9
 
10
10
  const MAX_SIZE = 2 * 1024 * 1024; // 2MB max image size
11
11
  const CACHE_TTL = 4 * 60 * 60; // 4 hours
12
- const ALLOWED_TYPES = [
12
+ const ALLOWED_TYPES = new Set([
13
13
  "image/jpeg",
14
14
  "image/png",
15
15
  "image/gif",
16
16
  "image/webp",
17
17
  "image/svg+xml",
18
- ];
18
+ ]);
19
19
 
20
20
  /**
21
21
  * Generate a hash for a URL to use as cache key
@@ -120,15 +120,19 @@ export async function fetchImage(redis, url) {
120
120
  });
121
121
 
122
122
  if (!response.ok) {
123
- console.error(`[Microsub] Media proxy fetch failed: ${response.status} for ${url}`);
124
- return null;
123
+ console.error(
124
+ `[Microsub] Media proxy fetch failed: ${response.status} for ${url}`,
125
+ );
126
+ return;
125
127
  }
126
128
 
127
129
  // Check content type
128
130
  const contentType = response.headers.get("content-type")?.split(";")[0];
129
- if (!ALLOWED_TYPES.includes(contentType)) {
130
- console.error(`[Microsub] Media proxy rejected type: ${contentType} for ${url}`);
131
- return null;
131
+ if (!ALLOWED_TYPES.has(contentType)) {
132
+ console.error(
133
+ `[Microsub] Media proxy rejected type: ${contentType} for ${url}`,
134
+ );
135
+ return;
132
136
  }
133
137
 
134
138
  // Check content length
@@ -137,14 +141,16 @@ export async function fetchImage(redis, url) {
137
141
  10,
138
142
  );
139
143
  if (contentLength > MAX_SIZE) {
140
- console.error(`[Microsub] Media proxy rejected size: ${contentLength} for ${url}`);
141
- return null;
144
+ console.error(
145
+ `[Microsub] Media proxy rejected size: ${contentLength} for ${url}`,
146
+ );
147
+ return;
142
148
  }
143
149
 
144
150
  // Read the body
145
151
  const buffer = await response.arrayBuffer();
146
152
  if (buffer.byteLength > MAX_SIZE) {
147
- return null;
153
+ return;
148
154
  }
149
155
 
150
156
  const imageData = {
@@ -161,7 +167,7 @@ export async function fetchImage(redis, url) {
161
167
  return imageData;
162
168
  } catch (error) {
163
169
  console.error(`[Microsub] Media proxy error: ${error.message} for ${url}`);
164
- return null;
170
+ return;
165
171
  }
166
172
  }
167
173
 
@@ -5,9 +5,10 @@
5
5
 
6
6
  import { ObjectId } from "mongodb";
7
7
 
8
+ import { generateChannelUid } from "../utils/jf2.js";
9
+
8
10
  import { deleteFeedsForChannel } from "./feeds.js";
9
11
  import { deleteItemsForChannel } from "./items.js";
10
- import { generateChannelUid } from "../utils/jf2.js";
11
12
 
12
13
  /**
13
14
  * Get channels collection from application
@@ -229,7 +229,7 @@ export async function markItemsRead(application, channelId, entryIds, userId) {
229
229
  try {
230
230
  return new ObjectId(id);
231
231
  } catch {
232
- return undefined;
232
+ return;
233
233
  }
234
234
  })
235
235
  .filter(Boolean);
@@ -277,7 +277,7 @@ export async function markItemsUnread(
277
277
  try {
278
278
  return new ObjectId(id);
279
279
  } catch {
280
- return undefined;
280
+ return;
281
281
  }
282
282
  })
283
283
  .filter(Boolean);
@@ -316,7 +316,7 @@ export async function removeItems(application, channelId, entryIds) {
316
316
  try {
317
317
  return new ObjectId(id);
318
318
  } catch {
319
- return undefined;
319
+ return;
320
320
  }
321
321
  })
322
322
  .filter(Boolean);
package/lib/utils/auth.js CHANGED
@@ -10,7 +10,6 @@
10
10
  * 1. request.session.userId (if explicitly set)
11
11
  * 2. request.session.me (from token introspection)
12
12
  * 3. application.publication.me (single-user fallback)
13
- *
14
13
  * @param {object} request - Express request
15
14
  * @returns {string|undefined} User ID
16
15
  */
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  import { getUserId } from "../utils/auth.js";
7
+
7
8
  import { processWebmention } from "./processor.js";
8
9
 
9
10
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-microsub",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "Microsub endpoint for Indiekit. Enables subscribing to feeds and reading content using the Microsub protocol.",
5
5
  "keywords": [
6
6
  "indiekit",