@rmdes/indiekit-endpoint-microsub 1.0.56 → 1.0.58
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/reader.js +408 -0
- package/index.js +37 -36
- package/lib/cache/redis.js +12 -3
- package/lib/controllers/reader/actor.js +142 -0
- package/lib/controllers/reader/channel.js +301 -0
- package/lib/controllers/reader/compose.js +242 -0
- package/lib/controllers/reader/deck.js +129 -0
- package/lib/controllers/reader/feed-repair.js +117 -0
- package/lib/controllers/reader/feed.js +246 -0
- package/lib/controllers/reader/index.js +126 -0
- package/lib/controllers/reader/search.js +157 -0
- package/lib/controllers/reader/timeline.js +251 -0
- package/lib/controllers/timeline.js +4 -2
- package/lib/feeds/atom.js +1 -1
- package/lib/feeds/fetcher.js +1 -30
- package/lib/feeds/hfeed.js +1 -1
- package/lib/feeds/jsonfeed.js +1 -1
- package/lib/feeds/normalizer-hfeed.js +209 -0
- package/lib/feeds/normalizer-jsonfeed.js +171 -0
- package/lib/feeds/normalizer-rss.js +178 -0
- package/lib/feeds/normalizer.js +20 -560
- package/lib/feeds/rss.js +1 -1
- package/lib/polling/processor.js +3 -17
- package/lib/storage/items-read-state.js +287 -0
- package/lib/storage/items-retention.js +174 -0
- package/lib/storage/items-search.js +34 -0
- package/lib/storage/items.js +99 -590
- package/lib/storage/read-state.js +1 -1
- package/lib/utils/async-handler.js +7 -0
- package/lib/utils/html.js +25 -0
- package/lib/utils/source-type.js +28 -0
- package/lib/webmention/processor.js +1 -1
- package/locales/de.json +3 -0
- package/locales/en.json +2 -0
- package/locales/es-419.json +3 -0
- package/locales/es.json +3 -0
- package/locales/fr.json +3 -0
- package/locales/hi.json +3 -0
- package/locales/id.json +3 -0
- package/locales/it.json +3 -0
- package/locales/nl.json +3 -0
- package/locales/pl.json +3 -0
- package/locales/pt-BR.json +3 -0
- package/locales/pt.json +3 -0
- package/locales/sr.json +3 -0
- package/locales/sv.json +3 -0
- package/locales/zh-Hans-CN.json +3 -0
- package/package.json +1 -1
- package/views/channel.njk +1 -348
- package/views/timeline.njk +3 -274
- package/lib/controllers/reader.js +0 -1562
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feed discovery UI
|
|
3
|
+
* @module controllers/reader/search
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { discoverAndValidateFeeds } from "../../feeds/discovery.js";
|
|
7
|
+
import { validateFeedUrl } from "../../feeds/validator.js";
|
|
8
|
+
import { refreshFeedNow } from "../../polling/scheduler.js";
|
|
9
|
+
import { getChannels, getChannel } from "../../storage/channels.js";
|
|
10
|
+
import { createFeed } from "../../storage/feeds.js";
|
|
11
|
+
import { getUserId } from "../../utils/auth.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Search/discover feeds page
|
|
15
|
+
* @param {object} request - Express request
|
|
16
|
+
* @param {object} response - Express response
|
|
17
|
+
* @returns {Promise<void>}
|
|
18
|
+
*/
|
|
19
|
+
export async function searchPage(request, response) {
|
|
20
|
+
const { application } = request.app.locals;
|
|
21
|
+
const userId = getUserId(request);
|
|
22
|
+
|
|
23
|
+
const channelList = await getChannels(application, userId);
|
|
24
|
+
|
|
25
|
+
response.render("search", {
|
|
26
|
+
title: request.__("microsub.search.title"),
|
|
27
|
+
channels: channelList,
|
|
28
|
+
baseUrl: request.baseUrl,
|
|
29
|
+
readerBaseUrl: request.baseUrl,
|
|
30
|
+
activeView: "channels",
|
|
31
|
+
breadcrumbs: [
|
|
32
|
+
{ text: "Reader", href: request.baseUrl },
|
|
33
|
+
{ text: "Search" },
|
|
34
|
+
],
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Search for feeds from URL - enhanced with validation
|
|
40
|
+
* @param {object} request - Express request
|
|
41
|
+
* @param {object} response - Express response
|
|
42
|
+
* @returns {Promise<void>}
|
|
43
|
+
*/
|
|
44
|
+
export async function searchFeeds(request, response) {
|
|
45
|
+
const { application } = request.app.locals;
|
|
46
|
+
const userId = getUserId(request);
|
|
47
|
+
const { query } = request.body;
|
|
48
|
+
|
|
49
|
+
const channelList = await getChannels(application, userId);
|
|
50
|
+
|
|
51
|
+
let results = [];
|
|
52
|
+
let discoveryError = null;
|
|
53
|
+
|
|
54
|
+
if (query) {
|
|
55
|
+
try {
|
|
56
|
+
// Use enhanced discovery with validation
|
|
57
|
+
results = await discoverAndValidateFeeds(query);
|
|
58
|
+
} catch (error) {
|
|
59
|
+
discoveryError = error.message;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
response.render("search", {
|
|
64
|
+
title: request.__("microsub.search.title"),
|
|
65
|
+
channels: channelList,
|
|
66
|
+
query,
|
|
67
|
+
results,
|
|
68
|
+
discoveryError,
|
|
69
|
+
searched: true,
|
|
70
|
+
baseUrl: request.baseUrl,
|
|
71
|
+
readerBaseUrl: request.baseUrl,
|
|
72
|
+
activeView: "channels",
|
|
73
|
+
breadcrumbs: [
|
|
74
|
+
{ text: "Reader", href: request.baseUrl },
|
|
75
|
+
{ text: "Search" },
|
|
76
|
+
],
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Subscribe to a feed from search results - with validation
|
|
82
|
+
* @param {object} request - Express request
|
|
83
|
+
* @param {object} response - Express response
|
|
84
|
+
* @returns {Promise<void>}
|
|
85
|
+
*/
|
|
86
|
+
export async function subscribe(request, response) {
|
|
87
|
+
const { application } = request.app.locals;
|
|
88
|
+
const userId = getUserId(request);
|
|
89
|
+
const { url, channel: channelUid, skipValidation } = request.body;
|
|
90
|
+
|
|
91
|
+
const channelDocument = await getChannel(application, channelUid, userId);
|
|
92
|
+
if (!channelDocument) {
|
|
93
|
+
return response.status(404).render("404");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Validate feed unless explicitly skipped (for power users)
|
|
97
|
+
if (!skipValidation) {
|
|
98
|
+
const validation = await validateFeedUrl(url);
|
|
99
|
+
|
|
100
|
+
if (!validation.valid) {
|
|
101
|
+
const channelList = await getChannels(application, userId);
|
|
102
|
+
return response.render("search", {
|
|
103
|
+
title: request.__("microsub.search.title"),
|
|
104
|
+
channels: channelList,
|
|
105
|
+
query: url,
|
|
106
|
+
validationError: validation.error,
|
|
107
|
+
baseUrl: request.baseUrl,
|
|
108
|
+
readerBaseUrl: request.baseUrl,
|
|
109
|
+
activeView: "channels",
|
|
110
|
+
breadcrumbs: [
|
|
111
|
+
{ text: "Reader", href: request.baseUrl },
|
|
112
|
+
{ text: "Search" },
|
|
113
|
+
],
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Warn about comments feeds but allow subscription
|
|
118
|
+
if (validation.isCommentsFeed) {
|
|
119
|
+
console.warn(`[Microsub] Subscribing to comments feed: ${url}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Create feed subscription (throws DUPLICATE_FEED if already exists elsewhere)
|
|
124
|
+
try {
|
|
125
|
+
const feed = await createFeed(application, {
|
|
126
|
+
channelId: channelDocument._id,
|
|
127
|
+
url,
|
|
128
|
+
title: undefined,
|
|
129
|
+
photo: undefined,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// Trigger immediate fetch in background
|
|
133
|
+
refreshFeedNow(application, feed._id).catch((error) => {
|
|
134
|
+
console.error(`[Microsub] Error fetching new feed ${url}:`, error.message);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
response.redirect(`${request.baseUrl}/channels/${channelUid}/feeds`);
|
|
138
|
+
} catch (error) {
|
|
139
|
+
if (error.code === "DUPLICATE_FEED") {
|
|
140
|
+
const channelList = await getChannels(application, userId);
|
|
141
|
+
return response.render("search", {
|
|
142
|
+
title: request.__("microsub.search.title"),
|
|
143
|
+
channels: channelList,
|
|
144
|
+
query: url,
|
|
145
|
+
validationError: `This feed already exists in channel "${error.channelName}"`,
|
|
146
|
+
baseUrl: request.baseUrl,
|
|
147
|
+
readerBaseUrl: request.baseUrl,
|
|
148
|
+
activeView: "channels",
|
|
149
|
+
breadcrumbs: [
|
|
150
|
+
{ text: "Reader", href: request.baseUrl },
|
|
151
|
+
{ text: "Search" },
|
|
152
|
+
],
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
throw error;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Timeline views + read state
|
|
3
|
+
* @module controllers/reader/timeline
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { getChannel, getChannelById, getChannelsWithColors } from "../../storage/channels.js";
|
|
7
|
+
import {
|
|
8
|
+
getTimelineItems,
|
|
9
|
+
getAllTimelineItems,
|
|
10
|
+
getItemById,
|
|
11
|
+
} from "../../storage/items.js";
|
|
12
|
+
import { markItemsRead } from "../../storage/items-read-state.js";
|
|
13
|
+
import { getUserId } from "../../utils/auth.js";
|
|
14
|
+
import { proxyItemImages } from "../../media/proxy.js";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Timeline view - all channels chronologically
|
|
18
|
+
* @param {object} request - Express request
|
|
19
|
+
* @param {object} response - Express response
|
|
20
|
+
*/
|
|
21
|
+
export async function timeline(request, response) {
|
|
22
|
+
const { application } = request.app.locals;
|
|
23
|
+
const userId = getUserId(request);
|
|
24
|
+
const { before, after } = request.query;
|
|
25
|
+
|
|
26
|
+
// Get channels with colors for filtering UI and item decoration
|
|
27
|
+
const channelList = await getChannelsWithColors(application, userId);
|
|
28
|
+
|
|
29
|
+
// Build channel lookup map (ObjectId string -> { name, color, uid })
|
|
30
|
+
const channelMap = new Map();
|
|
31
|
+
for (const ch of channelList) {
|
|
32
|
+
channelMap.set(ch._id.toString(), { name: ch.name, color: ch.color, uid: ch.uid });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Parse excluded channel IDs from query params
|
|
36
|
+
const excludeParam = request.query.exclude;
|
|
37
|
+
const excludeIds = excludeParam
|
|
38
|
+
? (Array.isArray(excludeParam) ? excludeParam : [excludeParam])
|
|
39
|
+
: [];
|
|
40
|
+
|
|
41
|
+
// Exclude the notifications channel by default
|
|
42
|
+
const notificationsChannel = channelList.find((ch) => ch.uid === "notifications");
|
|
43
|
+
const excludeChannelIds = [...excludeIds];
|
|
44
|
+
if (notificationsChannel && !excludeChannelIds.includes(notificationsChannel._id.toString())) {
|
|
45
|
+
excludeChannelIds.push(notificationsChannel._id.toString());
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const result = await getAllTimelineItems(application, {
|
|
49
|
+
before,
|
|
50
|
+
after,
|
|
51
|
+
userId,
|
|
52
|
+
excludeChannelIds,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Proxy images
|
|
56
|
+
const proxyBaseUrl = application.url;
|
|
57
|
+
if (proxyBaseUrl && result.items) {
|
|
58
|
+
result.items = result.items.map((item) => proxyItemImages(item, proxyBaseUrl));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Decorate items with channel name and color
|
|
62
|
+
for (const item of result.items) {
|
|
63
|
+
if (item._channelId) {
|
|
64
|
+
const info = channelMap.get(item._channelId);
|
|
65
|
+
if (info) {
|
|
66
|
+
item._channelName = info.name;
|
|
67
|
+
item._channelColor = info.color;
|
|
68
|
+
item._channelUid = info.uid;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Set view preference cookie
|
|
74
|
+
if (request.session) request.session.microsubView = "timeline";
|
|
75
|
+
|
|
76
|
+
response.render("timeline", {
|
|
77
|
+
title: "Timeline",
|
|
78
|
+
channels: channelList,
|
|
79
|
+
items: result.items,
|
|
80
|
+
paging: result.paging,
|
|
81
|
+
excludeIds,
|
|
82
|
+
baseUrl: request.baseUrl,
|
|
83
|
+
readerBaseUrl: request.baseUrl,
|
|
84
|
+
activeView: "timeline",
|
|
85
|
+
breadcrumbs: [
|
|
86
|
+
{ text: "Reader", href: request.baseUrl },
|
|
87
|
+
{ text: "Timeline" },
|
|
88
|
+
],
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Return rendered HTML fragments for timeline infinite scroll
|
|
94
|
+
* @param {object} request - Express request
|
|
95
|
+
* @param {object} response - Express response
|
|
96
|
+
* @returns {Promise<void>}
|
|
97
|
+
*/
|
|
98
|
+
export async function timelineHtml(request, response) {
|
|
99
|
+
const { application } = request.app.locals;
|
|
100
|
+
const userId = getUserId(request);
|
|
101
|
+
const { before, after } = request.query;
|
|
102
|
+
|
|
103
|
+
const channelList = await getChannelsWithColors(application, userId);
|
|
104
|
+
const channelMap = new Map();
|
|
105
|
+
for (const ch of channelList) {
|
|
106
|
+
channelMap.set(ch._id.toString(), { name: ch.name, color: ch.color, uid: ch.uid });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const excludeParam = request.query.exclude;
|
|
110
|
+
const excludeIds = excludeParam
|
|
111
|
+
? (Array.isArray(excludeParam) ? excludeParam : [excludeParam])
|
|
112
|
+
: [];
|
|
113
|
+
|
|
114
|
+
const notificationsChannel = channelList.find((ch) => ch.uid === "notifications");
|
|
115
|
+
const excludeChannelIds = [...excludeIds];
|
|
116
|
+
if (notificationsChannel && !excludeChannelIds.includes(notificationsChannel._id.toString())) {
|
|
117
|
+
excludeChannelIds.push(notificationsChannel._id.toString());
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const result = await getAllTimelineItems(application, {
|
|
121
|
+
before,
|
|
122
|
+
after,
|
|
123
|
+
userId,
|
|
124
|
+
excludeChannelIds,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const proxyBaseUrl = application.url;
|
|
128
|
+
if (proxyBaseUrl && result.items) {
|
|
129
|
+
result.items = result.items.map((item) => proxyItemImages(item, proxyBaseUrl));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
for (const item of result.items) {
|
|
133
|
+
if (item._channelId) {
|
|
134
|
+
const info = channelMap.get(item._channelId);
|
|
135
|
+
if (info) {
|
|
136
|
+
item._channelName = info.name;
|
|
137
|
+
item._channelColor = info.color;
|
|
138
|
+
item._channelUid = info.uid;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const fragmentHtml = await new Promise((resolve, reject) => {
|
|
144
|
+
response.render("partials/items-fragment-timeline", {
|
|
145
|
+
items: result.items,
|
|
146
|
+
baseUrl: request.baseUrl,
|
|
147
|
+
}, (error, html) => error ? reject(error) : resolve(html));
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
response.json({
|
|
151
|
+
html: fragmentHtml,
|
|
152
|
+
paging: result.paging,
|
|
153
|
+
count: result.items.length,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Mark all items in channel as read
|
|
159
|
+
* @param {object} request - Express request
|
|
160
|
+
* @param {object} response - Express response
|
|
161
|
+
* @returns {Promise<void>}
|
|
162
|
+
*/
|
|
163
|
+
export async function markAllRead(request, response) {
|
|
164
|
+
const { application } = request.app.locals;
|
|
165
|
+
const userId = getUserId(request);
|
|
166
|
+
const { channel: channelUid } = request.body;
|
|
167
|
+
|
|
168
|
+
const channelDocument = await getChannel(application, channelUid, userId);
|
|
169
|
+
if (!channelDocument) {
|
|
170
|
+
return response.status(404).render("404");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Mark all items as read using the special "last-read-entry" value
|
|
174
|
+
await markItemsRead(
|
|
175
|
+
application,
|
|
176
|
+
channelDocument._id,
|
|
177
|
+
["last-read-entry"],
|
|
178
|
+
userId,
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
response.redirect(`${request.baseUrl}/channels/${channelUid}`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Mark specific items as read (no-JS form fallback for mark-view-as-read)
|
|
186
|
+
* @param {object} request - Express request
|
|
187
|
+
* @param {object} response - Express response
|
|
188
|
+
*/
|
|
189
|
+
export async function markViewRead(request, response) {
|
|
190
|
+
const { application } = request.app.locals;
|
|
191
|
+
const userId = getUserId(request);
|
|
192
|
+
const { channel: channelUid } = request.body;
|
|
193
|
+
let { entry } = request.body;
|
|
194
|
+
|
|
195
|
+
const channelDocument = await getChannel(application, channelUid, userId);
|
|
196
|
+
if (!channelDocument) {
|
|
197
|
+
return response.status(404).render("404");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const entryIds = Array.isArray(entry) ? entry : entry ? [entry] : [];
|
|
201
|
+
if (entryIds.length > 0) {
|
|
202
|
+
await markItemsRead(application, channelDocument._id, entryIds, userId);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
response.redirect(`${request.baseUrl}/channels/${channelUid}`);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* View single item
|
|
210
|
+
* @param {object} request - Express request
|
|
211
|
+
* @param {object} response - Express response
|
|
212
|
+
* @returns {Promise<void>}
|
|
213
|
+
*/
|
|
214
|
+
export async function item(request, response) {
|
|
215
|
+
const { application } = request.app.locals;
|
|
216
|
+
const userId = getUserId(request);
|
|
217
|
+
const { id } = request.params;
|
|
218
|
+
|
|
219
|
+
const itemDocument = await getItemById(application, id, userId);
|
|
220
|
+
if (!itemDocument) {
|
|
221
|
+
return response.status(404).render("404");
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Get the channel for this item (needed for mark-read)
|
|
225
|
+
// Note: transformToJf2() renames channelId → _channelId (string)
|
|
226
|
+
let channel = null;
|
|
227
|
+
if (itemDocument._channelId) {
|
|
228
|
+
channel = await getChannelById(application, itemDocument._channelId);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const itemBreadcrumbs = [
|
|
232
|
+
{ text: "Reader", href: request.baseUrl },
|
|
233
|
+
];
|
|
234
|
+
if (channel) {
|
|
235
|
+
itemBreadcrumbs.push(
|
|
236
|
+
{ text: "Channels", href: `${request.baseUrl}/channels` },
|
|
237
|
+
{ text: channel.name, href: `${request.baseUrl}/channels/${channel.uid}` },
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
itemBreadcrumbs.push({ text: itemDocument.name || "Item" });
|
|
241
|
+
|
|
242
|
+
response.render("item", {
|
|
243
|
+
title: itemDocument.name || "Item",
|
|
244
|
+
item: itemDocument,
|
|
245
|
+
channel,
|
|
246
|
+
baseUrl: request.baseUrl,
|
|
247
|
+
readerBaseUrl: request.baseUrl,
|
|
248
|
+
activeView: "channels",
|
|
249
|
+
breadcrumbs: itemBreadcrumbs,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
@@ -9,11 +9,13 @@ import { proxyItemImages } from "../media/proxy.js";
|
|
|
9
9
|
import { getChannel, getChannelById } from "../storage/channels.js";
|
|
10
10
|
import {
|
|
11
11
|
getTimelineItems,
|
|
12
|
+
removeItems,
|
|
13
|
+
} from "../storage/items.js";
|
|
14
|
+
import {
|
|
12
15
|
markFeedItemsRead,
|
|
13
16
|
markItemsRead,
|
|
14
17
|
markItemsUnread,
|
|
15
|
-
|
|
16
|
-
} from "../storage/items.js";
|
|
18
|
+
} from "../storage/items-read-state.js";
|
|
17
19
|
import { getUserId } from "../utils/auth.js";
|
|
18
20
|
import {
|
|
19
21
|
validateChannel,
|
package/lib/feeds/atom.js
CHANGED
|
@@ -7,7 +7,7 @@ import { Readable } from "node:stream";
|
|
|
7
7
|
|
|
8
8
|
import FeedParser from "feedparser";
|
|
9
9
|
|
|
10
|
-
import { normalizeItem, normalizeFeedMeta } from "./normalizer.js";
|
|
10
|
+
import { normalizeItem, normalizeFeedMeta } from "./normalizer-rss.js";
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* Parse Atom feed content
|
package/lib/feeds/fetcher.js
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
* @module feeds/fetcher
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { getCache, setCache } from "../cache/redis.js";
|
|
7
6
|
import { isPrivateUrl } from "../media/proxy.js";
|
|
8
7
|
|
|
9
8
|
const DEFAULT_TIMEOUT = 30_000; // 30 seconds
|
|
@@ -16,32 +15,16 @@ const DEFAULT_USER_AGENT = "Indiekit Microsub/1.0 (+https://getindiekit.com)";
|
|
|
16
15
|
* @param {string} [options.etag] - Previous ETag for conditional request
|
|
17
16
|
* @param {string} [options.lastModified] - Previous Last-Modified for conditional request
|
|
18
17
|
* @param {number} [options.timeout] - Request timeout in ms
|
|
19
|
-
* @param {object} [options.redis] - Redis client for caching
|
|
20
18
|
* @returns {Promise<object>} Fetch result with content and headers
|
|
21
19
|
*/
|
|
22
20
|
export async function fetchFeed(url, options = {}) {
|
|
23
|
-
const { etag, lastModified, timeout = DEFAULT_TIMEOUT
|
|
21
|
+
const { etag, lastModified, timeout = DEFAULT_TIMEOUT } = options;
|
|
24
22
|
|
|
25
23
|
// SSRF protection — block private/internal IPs (including DNS rebinding)
|
|
26
24
|
if (await isPrivateUrl(url)) {
|
|
27
25
|
throw new Error(`Feed URL blocked (private/internal address): ${url}`);
|
|
28
26
|
}
|
|
29
27
|
|
|
30
|
-
// Check cache first
|
|
31
|
-
if (redis) {
|
|
32
|
-
const cached = await getCache(redis, `feed:${url}`);
|
|
33
|
-
if (cached) {
|
|
34
|
-
return {
|
|
35
|
-
content: cached.content,
|
|
36
|
-
contentType: cached.contentType,
|
|
37
|
-
etag: cached.etag,
|
|
38
|
-
lastModified: cached.lastModified,
|
|
39
|
-
fromCache: true,
|
|
40
|
-
status: 200,
|
|
41
|
-
};
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
28
|
const headers = {
|
|
46
29
|
Accept:
|
|
47
30
|
"application/atom+xml, application/rss+xml, application/json, application/feed+json, text/xml, text/html;q=0.9, */*;q=0.8",
|
|
@@ -105,18 +88,6 @@ export async function fetchFeed(url, options = {}) {
|
|
|
105
88
|
result.self = extractSelfFromLinkHeader(linkHeader);
|
|
106
89
|
}
|
|
107
90
|
|
|
108
|
-
// Cache the result
|
|
109
|
-
if (redis) {
|
|
110
|
-
const cacheData = {
|
|
111
|
-
content,
|
|
112
|
-
contentType,
|
|
113
|
-
etag: responseEtag,
|
|
114
|
-
lastModified: responseLastModified,
|
|
115
|
-
};
|
|
116
|
-
// Cache for 5 minutes by default
|
|
117
|
-
await setCache(redis, `feed:${url}`, cacheData, 300);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
91
|
return result;
|
|
121
92
|
} catch (error) {
|
|
122
93
|
clearTimeout(timeoutId);
|
package/lib/feeds/hfeed.js
CHANGED
package/lib/feeds/jsonfeed.js
CHANGED