@rmdes/indiekit-endpoint-microsub 1.0.0 → 1.0.2
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 +1 -4
- package/lib/controllers/follow.js +1 -1
- package/lib/controllers/reader.js +11 -3
- package/lib/feeds/fetcher.js +118 -7
- package/lib/feeds/normalizer.js +84 -3
- package/lib/feeds/parser.js +12 -1
- package/lib/media/proxy.js +17 -11
- package/lib/polling/processor.js +5 -0
- package/lib/storage/channels.js +2 -1
- package/lib/storage/items.js +78 -13
- package/lib/utils/auth.js +0 -1
- package/lib/webmention/receiver.js +1 -0
- package/package.json +1 -1
- package/views/404.njk +17 -0
- package/views/item.njk +9 -9
- package/views/partials/item-card.njk +1 -1
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 (
|
|
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(
|
|
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(
|
|
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",
|
package/lib/feeds/fetcher.js
CHANGED
|
@@ -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:
|
|
263
|
+
type: "xml",
|
|
197
264
|
rel: "self",
|
|
198
265
|
},
|
|
199
266
|
];
|
|
200
267
|
}
|
|
201
268
|
|
|
202
|
-
//
|
|
203
|
-
|
|
204
|
-
|
|
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
|
}
|
package/lib/feeds/normalizer.js
CHANGED
|
@@ -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,9 +151,10 @@ export function normalizeItem(item, feedUrl, feedType) {
|
|
|
91
151
|
uid,
|
|
92
152
|
url,
|
|
93
153
|
name: item.title || undefined,
|
|
94
|
-
published:
|
|
95
|
-
updated:
|
|
154
|
+
published: toISOStringSafe(item.pubdate),
|
|
155
|
+
updated: toISOStringSafe(item.date),
|
|
96
156
|
_source: {
|
|
157
|
+
url: feedUrl,
|
|
97
158
|
feedUrl,
|
|
98
159
|
feedType,
|
|
99
160
|
originalId: item.guid,
|
|
@@ -250,6 +311,7 @@ export function normalizeJsonFeedItem(item, feedUrl) {
|
|
|
250
311
|
? new Date(item.date_modified).toISOString()
|
|
251
312
|
: undefined,
|
|
252
313
|
_source: {
|
|
314
|
+
url: feedUrl,
|
|
253
315
|
feedUrl,
|
|
254
316
|
feedType: "jsonfeed",
|
|
255
317
|
originalId: item.id,
|
|
@@ -391,6 +453,7 @@ export function normalizeHfeedItem(entry, feedUrl) {
|
|
|
391
453
|
uid,
|
|
392
454
|
url,
|
|
393
455
|
_source: {
|
|
456
|
+
url: feedUrl,
|
|
394
457
|
feedUrl,
|
|
395
458
|
feedType: "hfeed",
|
|
396
459
|
originalId: getFirst(properties.uid),
|
|
@@ -536,6 +599,24 @@ export function normalizeHfeedMeta(hfeed, feedUrl) {
|
|
|
536
599
|
return normalized;
|
|
537
600
|
}
|
|
538
601
|
|
|
602
|
+
/**
|
|
603
|
+
* Extract URL string from a photo value
|
|
604
|
+
* @param {object|string} photo - Photo value (can be string URL or object with value/url)
|
|
605
|
+
* @returns {string|undefined} Photo URL string
|
|
606
|
+
*/
|
|
607
|
+
function extractPhotoUrl(photo) {
|
|
608
|
+
if (!photo) {
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
if (typeof photo === "string") {
|
|
612
|
+
return photo;
|
|
613
|
+
}
|
|
614
|
+
if (typeof photo === "object") {
|
|
615
|
+
return photo.value || photo.url || photo.src;
|
|
616
|
+
}
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
|
|
539
620
|
/**
|
|
540
621
|
* Normalize h-card author
|
|
541
622
|
* @param {object|string} hcard - h-card or author name string
|
|
@@ -556,7 +637,7 @@ function normalizeHcard(hcard) {
|
|
|
556
637
|
type: "card",
|
|
557
638
|
name: getFirst(properties.name),
|
|
558
639
|
url: getFirst(properties.url),
|
|
559
|
-
photo: getFirst(properties.photo),
|
|
640
|
+
photo: extractPhotoUrl(getFirst(properties.photo)),
|
|
560
641
|
};
|
|
561
642
|
}
|
|
562
643
|
|
package/lib/feeds/parser.js
CHANGED
|
@@ -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
|
|
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
|
}
|
package/lib/media/proxy.js
CHANGED
|
@@ -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(
|
|
124
|
-
|
|
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.
|
|
130
|
-
console.error(
|
|
131
|
-
|
|
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(
|
|
141
|
-
|
|
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
|
|
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
|
|
170
|
+
return;
|
|
165
171
|
}
|
|
166
172
|
}
|
|
167
173
|
|
package/lib/polling/processor.js
CHANGED
|
@@ -73,6 +73,11 @@ export async function processFeed(application, feed) {
|
|
|
73
73
|
continue;
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
// Enrich item source with feed metadata
|
|
77
|
+
if (item._source) {
|
|
78
|
+
item._source.name = feed.title || parsed.name;
|
|
79
|
+
}
|
|
80
|
+
|
|
76
81
|
// Store the item
|
|
77
82
|
const stored = await addItem(application, {
|
|
78
83
|
channelId: feed.channelId,
|
package/lib/storage/channels.js
CHANGED
|
@@ -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
|
package/lib/storage/items.js
CHANGED
|
@@ -121,6 +121,50 @@ export async function getTimelineItems(application, channelId, options = {}) {
|
|
|
121
121
|
};
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
+
/**
|
|
125
|
+
* Extract URL string from a media value
|
|
126
|
+
* @param {object|string} media - Media value (can be string URL or object)
|
|
127
|
+
* @returns {string|undefined} URL string
|
|
128
|
+
*/
|
|
129
|
+
function extractMediaUrl(media) {
|
|
130
|
+
if (!media) {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (typeof media === "string") {
|
|
134
|
+
return media;
|
|
135
|
+
}
|
|
136
|
+
if (typeof media === "object") {
|
|
137
|
+
return media.value || media.url || media.src;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Normalize media array to URL strings
|
|
143
|
+
* @param {Array} mediaArray - Array of media items
|
|
144
|
+
* @returns {Array} Array of URL strings
|
|
145
|
+
*/
|
|
146
|
+
function normalizeMediaArray(mediaArray) {
|
|
147
|
+
if (!mediaArray || !Array.isArray(mediaArray)) {
|
|
148
|
+
return [];
|
|
149
|
+
}
|
|
150
|
+
return mediaArray.map((media) => extractMediaUrl(media)).filter(Boolean);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Normalize author object to ensure photo is a URL string
|
|
155
|
+
* @param {object} author - Author object
|
|
156
|
+
* @returns {object} Normalized author
|
|
157
|
+
*/
|
|
158
|
+
function normalizeAuthor(author) {
|
|
159
|
+
if (!author) {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
return {
|
|
163
|
+
...author,
|
|
164
|
+
photo: extractMediaUrl(author.photo),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
124
168
|
/**
|
|
125
169
|
* Transform database item to jf2 format
|
|
126
170
|
* @param {object} item - Database item
|
|
@@ -142,11 +186,17 @@ function transformToJf2(item, userId) {
|
|
|
142
186
|
if (item.content) jf2.content = item.content;
|
|
143
187
|
if (item.summary) jf2.summary = item.summary;
|
|
144
188
|
if (item.updated) jf2.updated = item.updated.toISOString();
|
|
145
|
-
if (item.author) jf2.author = item.author;
|
|
189
|
+
if (item.author) jf2.author = normalizeAuthor(item.author);
|
|
146
190
|
if (item.category?.length > 0) jf2.category = item.category;
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
191
|
+
|
|
192
|
+
// Normalize media arrays to ensure they contain URL strings
|
|
193
|
+
const photos = normalizeMediaArray(item.photo);
|
|
194
|
+
const videos = normalizeMediaArray(item.video);
|
|
195
|
+
const audios = normalizeMediaArray(item.audio);
|
|
196
|
+
|
|
197
|
+
if (photos.length > 0) jf2.photo = photos;
|
|
198
|
+
if (videos.length > 0) jf2.video = videos;
|
|
199
|
+
if (audios.length > 0) jf2.audio = audios;
|
|
150
200
|
|
|
151
201
|
// Interaction types
|
|
152
202
|
if (item.likeOf?.length > 0) jf2["like-of"] = item.likeOf;
|
|
@@ -161,18 +211,33 @@ function transformToJf2(item, userId) {
|
|
|
161
211
|
}
|
|
162
212
|
|
|
163
213
|
/**
|
|
164
|
-
* Get an item by ID
|
|
214
|
+
* Get an item by ID (MongoDB _id or uid)
|
|
165
215
|
* @param {object} application - Indiekit application
|
|
166
|
-
* @param {ObjectId|string} id - Item ObjectId
|
|
216
|
+
* @param {ObjectId|string} id - Item ObjectId or uid string
|
|
167
217
|
* @param {string} [userId] - User ID for read state
|
|
168
|
-
* @returns {Promise<object|
|
|
218
|
+
* @returns {Promise<object|undefined>} jf2 item or undefined
|
|
169
219
|
*/
|
|
170
220
|
export async function getItemById(application, id, userId) {
|
|
171
221
|
const collection = getCollection(application);
|
|
172
|
-
const objectId = typeof id === "string" ? new ObjectId(id) : id;
|
|
173
222
|
|
|
174
|
-
|
|
175
|
-
|
|
223
|
+
let item;
|
|
224
|
+
|
|
225
|
+
// Try MongoDB ObjectId first
|
|
226
|
+
try {
|
|
227
|
+
const objectId = typeof id === "string" ? new ObjectId(id) : id;
|
|
228
|
+
item = await collection.findOne({ _id: objectId });
|
|
229
|
+
} catch {
|
|
230
|
+
// Invalid ObjectId format, will try uid lookup
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// If not found by _id, try uid
|
|
234
|
+
if (!item) {
|
|
235
|
+
item = await collection.findOne({ uid: id });
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (!item) {
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
176
241
|
|
|
177
242
|
return transformToJf2(item, userId);
|
|
178
243
|
}
|
|
@@ -229,7 +294,7 @@ export async function markItemsRead(application, channelId, entryIds, userId) {
|
|
|
229
294
|
try {
|
|
230
295
|
return new ObjectId(id);
|
|
231
296
|
} catch {
|
|
232
|
-
return
|
|
297
|
+
return;
|
|
233
298
|
}
|
|
234
299
|
})
|
|
235
300
|
.filter(Boolean);
|
|
@@ -277,7 +342,7 @@ export async function markItemsUnread(
|
|
|
277
342
|
try {
|
|
278
343
|
return new ObjectId(id);
|
|
279
344
|
} catch {
|
|
280
|
-
return
|
|
345
|
+
return;
|
|
281
346
|
}
|
|
282
347
|
})
|
|
283
348
|
.filter(Boolean);
|
|
@@ -316,7 +381,7 @@ export async function removeItems(application, channelId, entryIds) {
|
|
|
316
381
|
try {
|
|
317
382
|
return new ObjectId(id);
|
|
318
383
|
} catch {
|
|
319
|
-
return
|
|
384
|
+
return;
|
|
320
385
|
}
|
|
321
386
|
})
|
|
322
387
|
.filter(Boolean);
|
package/lib/utils/auth.js
CHANGED
package/package.json
CHANGED
package/views/404.njk
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{% extends "document.njk" %}
|
|
2
|
+
|
|
3
|
+
{% block main %}
|
|
4
|
+
<article class="main__container -!-container">
|
|
5
|
+
<header class="heading">
|
|
6
|
+
<h1 class="heading__title">
|
|
7
|
+
{{ __("microsub.error.notFound.title") | default("Not found") }}
|
|
8
|
+
</h1>
|
|
9
|
+
</header>
|
|
10
|
+
{{ prose({ text: __("microsub.error.notFound.description") | default("The item you're looking for could not be found.") }) }}
|
|
11
|
+
<p>
|
|
12
|
+
<a href="{{ baseUrl }}/channels" class="button button--secondary">
|
|
13
|
+
{{ __("microsub.reader.backToChannels") | default("Back to channels") }}
|
|
14
|
+
</a>
|
|
15
|
+
</p>
|
|
16
|
+
</article>
|
|
17
|
+
{% endblock %}
|
package/views/item.njk
CHANGED
|
@@ -50,19 +50,19 @@
|
|
|
50
50
|
</div>
|
|
51
51
|
{% endif %}
|
|
52
52
|
|
|
53
|
-
{% if item
|
|
53
|
+
{% if item["in-reply-to"] or item["like-of"] or item["repost-of"] or item["bookmark-of"] %}
|
|
54
54
|
<div class="item__context">
|
|
55
|
-
{% if item
|
|
56
|
-
<p>{{ icon("reply") }} {{ __("Reply to") }}: <a href="{{ item
|
|
55
|
+
{% if item["in-reply-to"] %}
|
|
56
|
+
<p>{{ icon("reply") }} {{ __("Reply to") }}: <a href="{{ item["in-reply-to"][0] }}">{{ item["in-reply-to"][0] }}</a></p>
|
|
57
57
|
{% endif %}
|
|
58
|
-
{% if item
|
|
59
|
-
<p>{{ icon("like") }} {{ __("Liked") }}: <a href="{{ item
|
|
58
|
+
{% if item["like-of"] %}
|
|
59
|
+
<p>{{ icon("like") }} {{ __("Liked") }}: <a href="{{ item["like-of"][0] }}">{{ item["like-of"][0] }}</a></p>
|
|
60
60
|
{% endif %}
|
|
61
|
-
{% if item
|
|
62
|
-
<p>{{ icon("repost") }} {{ __("Reposted") }}: <a href="{{ item
|
|
61
|
+
{% if item["repost-of"] %}
|
|
62
|
+
<p>{{ icon("repost") }} {{ __("Reposted") }}: <a href="{{ item["repost-of"][0] }}">{{ item["repost-of"][0] }}</a></p>
|
|
63
63
|
{% endif %}
|
|
64
|
-
{% if item
|
|
65
|
-
<p>{{ icon("bookmark") }} {{ __("Bookmarked") }}: <a href="{{ item
|
|
64
|
+
{% if item["bookmark-of"] %}
|
|
65
|
+
<p>{{ icon("bookmark") }} {{ __("Bookmarked") }}: <a href="{{ item["bookmark-of"][0] }}">{{ item["bookmark-of"][0] }}</a></p>
|
|
66
66
|
{% endif %}
|
|
67
67
|
</div>
|
|
68
68
|
{% endif %}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{# Item card for timeline display #}
|
|
2
2
|
<article class="item-card{% if item._is_read %} item-card--read{% endif %}">
|
|
3
|
-
<a href="{{ baseUrl }}/item/{{ item.
|
|
3
|
+
<a href="{{ baseUrl }}/item/{{ item._id }}" class="item-card__link">
|
|
4
4
|
{% if item.author %}
|
|
5
5
|
<div class="item-card__author">
|
|
6
6
|
{% if item.author.photo %}
|