@rmdes/indiekit-endpoint-rss 1.0.17 → 1.1.0
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/README.md +2 -1
- package/index.js +2 -0
- package/lib/controllers/dashboard.js +19 -2
- package/lib/controllers/feeds.js +143 -12
- package/lib/jf2-builder.js +115 -0
- package/lib/publisher.js +239 -0
- package/lib/sync.js +44 -2
- package/lib/utils.js +9 -0
- package/locales/en.json +12 -1
- package/package.json +5 -5
- package/views/rss.njk +67 -0
package/README.md
CHANGED
|
@@ -37,7 +37,8 @@ export default {
|
|
|
37
37
|
fetchTimeout: 10_000, // 10 second timeout per feed
|
|
38
38
|
maxConcurrentFetches: 3, // Parallel feed fetches
|
|
39
39
|
retentionDays: 30, // Days to keep items
|
|
40
|
-
minItemsPerFeed: 10
|
|
40
|
+
minItemsPerFeed: 10, // Newest items always kept, whatever their age
|
|
41
|
+
maxPostsPerCycle: 10 // Max posts created per sync cycle
|
|
41
42
|
})
|
|
42
43
|
],
|
|
43
44
|
// MongoDB database is REQUIRED
|
package/index.js
CHANGED
|
@@ -19,6 +19,7 @@ const defaults = {
|
|
|
19
19
|
maxConcurrentFetches: 3,
|
|
20
20
|
retentionDays: 30,
|
|
21
21
|
minItemsPerFeed: 10,
|
|
22
|
+
maxPostsPerCycle: 10,
|
|
22
23
|
};
|
|
23
24
|
|
|
24
25
|
export default class RssEndpoint {
|
|
@@ -77,6 +78,7 @@ export default class RssEndpoint {
|
|
|
77
78
|
router.post("/api/feeds", express.json(), feedsController.add);
|
|
78
79
|
router.delete("/api/feeds/:id", feedsController.remove);
|
|
79
80
|
router.patch("/api/feeds/:id", express.json(), feedsController.toggle);
|
|
81
|
+
router.post("/api/feeds/:id/backfill", express.json(), feedsController.backfill);
|
|
80
82
|
|
|
81
83
|
// Manual refresh (protected)
|
|
82
84
|
router.post("/api/refresh", statusController.refresh);
|
|
@@ -73,6 +73,7 @@ export const dashboardController = {
|
|
|
73
73
|
},
|
|
74
74
|
mountPath: request.baseUrl,
|
|
75
75
|
publicUrl: rssEndpoint,
|
|
76
|
+
postTypes: Object.keys(request.app.locals.publication?.postTypes || {}),
|
|
76
77
|
...flash,
|
|
77
78
|
});
|
|
78
79
|
} catch (error) {
|
|
@@ -111,7 +112,15 @@ export const dashboardController = {
|
|
|
111
112
|
await feedsCollection.updateMany({}, { $set: { itemCount: 0 } });
|
|
112
113
|
|
|
113
114
|
// Trigger sync
|
|
114
|
-
const
|
|
115
|
+
const { application, publication } = request.app.locals;
|
|
116
|
+
const publishContext =
|
|
117
|
+
application?.micropubEndpoint && publication?.me
|
|
118
|
+
? {
|
|
119
|
+
micropubEndpoint: application.micropubEndpoint,
|
|
120
|
+
me: publication.me,
|
|
121
|
+
}
|
|
122
|
+
: null;
|
|
123
|
+
const result = await runSync(db, rssConfig, publishContext);
|
|
115
124
|
|
|
116
125
|
if (result.error) {
|
|
117
126
|
return response.status(500).json({
|
|
@@ -164,7 +173,15 @@ export const dashboardController = {
|
|
|
164
173
|
return response.redirect(request.baseUrl);
|
|
165
174
|
}
|
|
166
175
|
|
|
167
|
-
const
|
|
176
|
+
const { application, publication } = request.app.locals;
|
|
177
|
+
const publishContext =
|
|
178
|
+
application?.micropubEndpoint && publication?.me
|
|
179
|
+
? {
|
|
180
|
+
micropubEndpoint: application.micropubEndpoint,
|
|
181
|
+
me: publication.me,
|
|
182
|
+
}
|
|
183
|
+
: null;
|
|
184
|
+
const result = await runSync(db, rssConfig, publishContext);
|
|
168
185
|
|
|
169
186
|
if (result.error) {
|
|
170
187
|
request.session.messages = [
|
package/lib/controllers/feeds.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { ObjectId } from "mongodb";
|
|
2
2
|
import { RssClient } from "../rss-client.js";
|
|
3
|
+
import { defaultContent, defaultLinkProperty } from "../jf2-builder.js";
|
|
4
|
+
import { watermarkFor } from "../publisher.js";
|
|
3
5
|
import { formatFeed, isValidUrl, normalizeUrl } from "../utils.js";
|
|
4
6
|
|
|
5
7
|
export const feedsController = {
|
|
@@ -147,20 +149,24 @@ export const feedsController = {
|
|
|
147
149
|
},
|
|
148
150
|
|
|
149
151
|
/**
|
|
150
|
-
*
|
|
152
|
+
* Update a feed: enable/disable, or change its publish configuration
|
|
151
153
|
* PATCH /api/feeds/:id
|
|
152
|
-
* Body: { enabled
|
|
154
|
+
* Body: { enabled?: boolean, publish?: object }
|
|
153
155
|
*/
|
|
154
156
|
async toggle(request, response) {
|
|
155
157
|
try {
|
|
156
158
|
const { id } = request.params;
|
|
157
|
-
const { enabled } = request.body;
|
|
159
|
+
const { enabled, publish } = request.body;
|
|
158
160
|
|
|
159
161
|
if (!ObjectId.isValid(id)) {
|
|
160
162
|
return response.status(400).json({ error: "Invalid feed ID" });
|
|
161
163
|
}
|
|
162
164
|
|
|
163
|
-
if (
|
|
165
|
+
if (enabled === undefined && publish === undefined) {
|
|
166
|
+
return response.status(400).json({ error: "Nothing to update" });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (enabled !== undefined && typeof enabled !== "boolean") {
|
|
164
170
|
return response.status(400).json({ error: "enabled must be boolean" });
|
|
165
171
|
}
|
|
166
172
|
|
|
@@ -171,27 +177,152 @@ export const feedsController = {
|
|
|
171
177
|
|
|
172
178
|
const feedsCollection = db.collection("rssFeeds");
|
|
173
179
|
const feedId = new ObjectId(id);
|
|
180
|
+
const feed = await feedsCollection.findOne({ _id: feedId });
|
|
181
|
+
|
|
182
|
+
if (!feed) {
|
|
183
|
+
return response.status(404).json({
|
|
184
|
+
error: response.locals.__("rss.error.feedNotFound"),
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const update = {};
|
|
189
|
+
|
|
190
|
+
if (enabled !== undefined) {
|
|
191
|
+
update.enabled = enabled;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (publish !== undefined) {
|
|
195
|
+
const { postTypes } = request.app.locals.publication || {};
|
|
196
|
+
|
|
197
|
+
if (publish.enabled) {
|
|
198
|
+
if (!postTypes) {
|
|
199
|
+
return response.status(500).json({
|
|
200
|
+
error: "Post type configuration unavailable",
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// postData.create() throws notImplemented for an unconfigured type,
|
|
205
|
+
// and the failure would be buried deep in the sync loop. Sites
|
|
206
|
+
// differ: chardonsbleus has audio, event, jam and rsvp disabled.
|
|
207
|
+
if (!Object.hasOwn(postTypes, publish.postType)) {
|
|
208
|
+
return response.status(400).json({
|
|
209
|
+
error: `Post type "${publish.postType}" is not enabled on this site`,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const merged = { ...feed.publish, ...publish };
|
|
215
|
+
|
|
216
|
+
update.publish = {
|
|
217
|
+
...merged,
|
|
218
|
+
// Pre-fill from the post type when the caller left them out, so a
|
|
219
|
+
// minimal PATCH of { enabled, postType } yields a working config.
|
|
220
|
+
// An explicit null is respected: it means "no link property".
|
|
221
|
+
content: merged.content || defaultContent(merged.postType),
|
|
222
|
+
linkProperty:
|
|
223
|
+
publish.linkProperty === undefined
|
|
224
|
+
? (merged.linkProperty ?? defaultLinkProperty(merged.postType))
|
|
225
|
+
: publish.linkProperty,
|
|
226
|
+
// Stamp the watermark the first time publishing is switched on, so
|
|
227
|
+
// enabling a feed does not publish its entire cached backlog.
|
|
228
|
+
since:
|
|
229
|
+
publish.enabled && !feed.publish?.since
|
|
230
|
+
? new Date().toISOString()
|
|
231
|
+
: (feed.publish?.since ?? null),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
174
234
|
|
|
175
235
|
const result = await feedsCollection.findOneAndUpdate(
|
|
176
236
|
{ _id: feedId },
|
|
177
|
-
{ $set:
|
|
178
|
-
{ returnDocument: "after" }
|
|
237
|
+
{ $set: update },
|
|
238
|
+
{ returnDocument: "after" },
|
|
179
239
|
);
|
|
180
240
|
|
|
181
|
-
|
|
241
|
+
response.json({
|
|
242
|
+
message:
|
|
243
|
+
enabled === undefined
|
|
244
|
+
? response.locals.__("rss.success.feedUpdated")
|
|
245
|
+
: enabled
|
|
246
|
+
? response.locals.__("rss.success.feedEnabled")
|
|
247
|
+
: response.locals.__("rss.success.feedDisabled"),
|
|
248
|
+
feed: formatFeed(result),
|
|
249
|
+
});
|
|
250
|
+
} catch (error) {
|
|
251
|
+
console.error("[RSS] Error updating feed:", error.message);
|
|
252
|
+
response.status(500).json({ error: error.message });
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Rewind a feed's watermark so past items get published
|
|
258
|
+
* POST /api/feeds/:id/backfill
|
|
259
|
+
* Body: { since?: string } or { last?: number }
|
|
260
|
+
*/
|
|
261
|
+
async backfill(request, response) {
|
|
262
|
+
try {
|
|
263
|
+
const { id } = request.params;
|
|
264
|
+
const { since, last } = request.body;
|
|
265
|
+
|
|
266
|
+
if (!ObjectId.isValid(id)) {
|
|
267
|
+
return response.status(400).json({ error: "Invalid feed ID" });
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const db = request.app.locals.application.getRssDb?.();
|
|
271
|
+
if (!db) {
|
|
272
|
+
return response.status(500).json({ error: "Database not available" });
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const feedsCollection = db.collection("rssFeeds");
|
|
276
|
+
const itemsCollection = db.collection("rssItems");
|
|
277
|
+
const feedId = new ObjectId(id);
|
|
278
|
+
const feed = await feedsCollection.findOne({ _id: feedId });
|
|
279
|
+
|
|
280
|
+
if (!feed) {
|
|
182
281
|
return response.status(404).json({
|
|
183
282
|
error: response.locals.__("rss.error.feedNotFound"),
|
|
184
283
|
});
|
|
185
284
|
}
|
|
186
285
|
|
|
286
|
+
if (!feed.publish?.enabled) {
|
|
287
|
+
return response.status(400).json({
|
|
288
|
+
error: "Enable publishing on this feed first",
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
let oldestPubDate = null;
|
|
293
|
+
if (last) {
|
|
294
|
+
const items = await itemsCollection
|
|
295
|
+
.find({ feedId })
|
|
296
|
+
.sort({ pubDate: -1 })
|
|
297
|
+
.limit(Number(last))
|
|
298
|
+
.toArray();
|
|
299
|
+
oldestPubDate = items.at(-1)?.pubDate || null;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
let watermark;
|
|
303
|
+
try {
|
|
304
|
+
watermark = watermarkFor({ since, last }, oldestPubDate);
|
|
305
|
+
} catch (error) {
|
|
306
|
+
return response.status(400).json({ error: error.message });
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
await feedsCollection.updateOne(
|
|
310
|
+
{ _id: feedId },
|
|
311
|
+
{
|
|
312
|
+
$set: {
|
|
313
|
+
"publish.since": watermark,
|
|
314
|
+
// Imported history is always drafts, whatever the feed is set to.
|
|
315
|
+
"publish.status": "draft",
|
|
316
|
+
},
|
|
317
|
+
},
|
|
318
|
+
);
|
|
319
|
+
|
|
187
320
|
response.json({
|
|
188
|
-
message:
|
|
189
|
-
|
|
190
|
-
: response.locals.__("rss.success.feedDisabled"),
|
|
191
|
-
feed: formatFeed(result),
|
|
321
|
+
message: response.locals.__("rss.success.backfillQueued"),
|
|
322
|
+
since: watermark,
|
|
192
323
|
});
|
|
193
324
|
} catch (error) {
|
|
194
|
-
console.error("[RSS] Error
|
|
325
|
+
console.error("[RSS] Error queueing backfill:", error.message);
|
|
195
326
|
response.status(500).json({ error: error.message });
|
|
196
327
|
}
|
|
197
328
|
},
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { sanitizeHtml, stripHtml } from "./utils.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Discovery property pre-filled for each post type. Form defaults only —
|
|
5
|
+
* any combination the user sets is accepted.
|
|
6
|
+
*/
|
|
7
|
+
const LINK_PROPERTY_DEFAULTS = {
|
|
8
|
+
bookmark: "bookmark-of",
|
|
9
|
+
like: "like-of",
|
|
10
|
+
repost: "repost-of",
|
|
11
|
+
reply: "in-reply-to",
|
|
12
|
+
video: "video",
|
|
13
|
+
photo: "photo",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const PLACEHOLDERS = {
|
|
17
|
+
title: (item) => stripHtml(item.title),
|
|
18
|
+
link: (item) => item.link,
|
|
19
|
+
description: (item) => stripHtml(item.description),
|
|
20
|
+
content: (item) => sanitizeHtml(item.content),
|
|
21
|
+
author: (item) => stripHtml(item.author),
|
|
22
|
+
sourceTitle: (item) => stripHtml(item.sourceTitle),
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Link property offered when a post type is chosen
|
|
27
|
+
* @param {string} postType - Post type key
|
|
28
|
+
* @returns {string|null} Property name, or null when the type needs none
|
|
29
|
+
*/
|
|
30
|
+
export const defaultLinkProperty = (postType) =>
|
|
31
|
+
LINK_PROPERTY_DEFAULTS[postType] || null;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Content template offered when a post type is chosen
|
|
35
|
+
* @param {string} postType - Post type key
|
|
36
|
+
* @returns {string} Template string
|
|
37
|
+
*/
|
|
38
|
+
export const defaultContent = (postType) =>
|
|
39
|
+
postType === "article" ? "{{content}}" : "{{description}}";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Render a feed item into a content string
|
|
43
|
+
* @param {string} template - Template with {{placeholder}} tokens
|
|
44
|
+
* @param {object} item - RSS item document
|
|
45
|
+
* @returns {string} Rendered content
|
|
46
|
+
*/
|
|
47
|
+
export function renderTemplate(template, item) {
|
|
48
|
+
return template.replaceAll(/\{\{(\w+)\}\}/g, (match, key) => {
|
|
49
|
+
const source = PLACEHOLDERS[key];
|
|
50
|
+
return source ? (source(item) ?? "") : match;
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Build JF2 properties for one feed item.
|
|
56
|
+
*
|
|
57
|
+
* The post type is never sent: getPostType() derives it from the properties
|
|
58
|
+
* present, so `publish.postType` decides which discovery property to set.
|
|
59
|
+
* @param {object} item - RSS item document
|
|
60
|
+
* @param {object} publish - Feed publish configuration
|
|
61
|
+
* @returns {object} JF2 properties
|
|
62
|
+
*/
|
|
63
|
+
export function buildJf2(item, publish) {
|
|
64
|
+
const template = publish.content || defaultContent(publish.postType);
|
|
65
|
+
const rendered = renderTemplate(template, item);
|
|
66
|
+
const isHtml = template.includes("{{content}}");
|
|
67
|
+
|
|
68
|
+
const properties = {
|
|
69
|
+
type: "entry",
|
|
70
|
+
content: isHtml ? { html: rendered } : rendered,
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// A `name` next to content makes discovery return "article" — see
|
|
74
|
+
// post-type-discovery.js: `if (content && properties.name) return "article"`.
|
|
75
|
+
// A note must therefore carry none.
|
|
76
|
+
const name = stripHtml(item.title);
|
|
77
|
+
if (publish.postType !== "note" && name) {
|
|
78
|
+
properties.name = name;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (publish.linkProperty) {
|
|
82
|
+
const value =
|
|
83
|
+
publish.linkProperty === "photo" ? item.imageUrl : item.link;
|
|
84
|
+
|
|
85
|
+
if (!value) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
`Item has no value for "${publish.linkProperty}": ${item.guid}`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
properties[publish.linkProperty] = value;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (item.pubDate) {
|
|
95
|
+
properties.published =
|
|
96
|
+
item.pubDate instanceof Date ? item.pubDate.toISOString() : item.pubDate;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (item.categories?.length) {
|
|
100
|
+
properties.category = item.categories;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (publish.declareSyndication && item.link) {
|
|
104
|
+
properties.syndication = [item.link];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
properties["post-status"] =
|
|
108
|
+
publish.status === "published" ? "published" : "draft";
|
|
109
|
+
|
|
110
|
+
if (publish.syndicateTo?.length) {
|
|
111
|
+
properties["mp-syndicate-to"] = publish.syndicateTo;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return properties;
|
|
115
|
+
}
|
package/lib/publisher.js
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import jwt from "jsonwebtoken";
|
|
2
|
+
import { jf2ToMf2 } from "@indiekit/endpoint-micropub/lib/mf2.js";
|
|
3
|
+
import { buildJf2 } from "./jf2-builder.js";
|
|
4
|
+
|
|
5
|
+
const TOKEN_TTL = "5m";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Mint a short-lived token for background publishing.
|
|
9
|
+
*
|
|
10
|
+
* Background sync has no user session, so it cannot reuse the admin UI's
|
|
11
|
+
* access token. endpoint-auth signs and verifies with process.env.SECRET,
|
|
12
|
+
* the same mechanism start.sh already uses for the syndication poller.
|
|
13
|
+
* @param {string} me - Publication URL
|
|
14
|
+
* @returns {string} Signed JWT
|
|
15
|
+
*/
|
|
16
|
+
export function mintToken(me) {
|
|
17
|
+
if (!process.env.SECRET) {
|
|
18
|
+
throw new Error("Cannot publish: SECRET is not configured");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return jwt.sign({ me, scope: "create" }, process.env.SECRET, {
|
|
22
|
+
expiresIn: TOKEN_TTL,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* POST one post to the site's own Micropub endpoint.
|
|
28
|
+
*
|
|
29
|
+
* The endpoint parses any JSON body as mf2, so JF2 properties are converted
|
|
30
|
+
* first — the same thing endpoint-posts does before calling the endpoint.
|
|
31
|
+
* jf2ToMf2 mutates its input (deletes `properties.type`), so a copy is
|
|
32
|
+
* passed to avoid corrupting the caller's object.
|
|
33
|
+
* @param {string} micropubEndpoint - Micropub endpoint URL
|
|
34
|
+
* @param {string} accessToken - Bearer token
|
|
35
|
+
* @param {object} properties - JF2 properties
|
|
36
|
+
* @param {object} [options] - Options
|
|
37
|
+
* @param {Function} [options.fetchImpl] - fetch implementation, for tests
|
|
38
|
+
* @returns {Promise<string|null>} URL of the created post
|
|
39
|
+
*/
|
|
40
|
+
export async function postToMicropub(
|
|
41
|
+
micropubEndpoint,
|
|
42
|
+
accessToken,
|
|
43
|
+
properties,
|
|
44
|
+
{ fetchImpl = fetch } = {},
|
|
45
|
+
) {
|
|
46
|
+
const mf2 = jf2ToMf2({ properties: { ...properties } });
|
|
47
|
+
|
|
48
|
+
const response = await fetchImpl(micropubEndpoint, {
|
|
49
|
+
method: "POST",
|
|
50
|
+
headers: {
|
|
51
|
+
accept: "application/json",
|
|
52
|
+
authorization: `Bearer ${accessToken}`,
|
|
53
|
+
"content-type": "application/json",
|
|
54
|
+
},
|
|
55
|
+
body: JSON.stringify(mf2),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
if (!response.ok) {
|
|
59
|
+
const body = await response.text();
|
|
60
|
+
const error = new Error(`Micropub ${response.status}: ${body.slice(0, 200)}`);
|
|
61
|
+
error.status = response.status;
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return response.headers.get("location");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Resolve what publishing needs from Indiekit configuration.
|
|
70
|
+
*
|
|
71
|
+
* micropubEndpoint is only resolved against the request in Indiekit's locals
|
|
72
|
+
* middleware, and background sync has no request. Relative endpoints are
|
|
73
|
+
* therefore resolved against localhost — the same loopback start.sh already
|
|
74
|
+
* uses for the syndication and webmention pollers.
|
|
75
|
+
* @param {object} application - Application configuration
|
|
76
|
+
* @param {object} publication - Publication configuration
|
|
77
|
+
* @returns {object|null} { micropubEndpoint, me }, or null when unconfigured
|
|
78
|
+
*/
|
|
79
|
+
export function resolvePublishContext(application = {}, publication = {}) {
|
|
80
|
+
const { micropubEndpoint, port } = application;
|
|
81
|
+
const { me } = publication;
|
|
82
|
+
|
|
83
|
+
if (!micropubEndpoint || !me) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (URL.canParse(micropubEndpoint)) {
|
|
88
|
+
return { micropubEndpoint, me };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// A misconfigured port must cost this site its publishing, not its whole
|
|
92
|
+
// sync cycle: this runs before runSync's try block, so a throw here would
|
|
93
|
+
// skip the feed fetch, the inserts and the prune too, every cycle, with
|
|
94
|
+
// nothing but a console.error to show for it.
|
|
95
|
+
const base = `http://localhost:${port || "3000"}`;
|
|
96
|
+
if (!URL.canParse(micropubEndpoint, base)) {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return { micropubEndpoint: new URL(micropubEndpoint, base).href, me };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const MAX_ATTEMPTS = 3;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Publish the pending items of one feed.
|
|
107
|
+
*
|
|
108
|
+
* Retries are bounded on purpose: leaving postedAt unset retries next cycle,
|
|
109
|
+
* and unbounded that reproduces the prune churn fixed in 1.0.17 — a
|
|
110
|
+
* permanently invalid item retried every fifteen minutes forever.
|
|
111
|
+
* @param {object} feed - Feed document
|
|
112
|
+
* @param {object} itemsCollection - Items collection
|
|
113
|
+
* @param {object} options - Options
|
|
114
|
+
* @param {string} options.micropubEndpoint - Micropub endpoint URL
|
|
115
|
+
* @param {string} options.me - Publication URL
|
|
116
|
+
* @param {number} options.maxPostsPerCycle - Cap per cycle
|
|
117
|
+
* @param {Function} [options.postImpl] - Post implementation, for tests
|
|
118
|
+
* @param {Function} [options.mintImpl] - Token minter, for tests
|
|
119
|
+
* @returns {Promise<object>} Counts for this feed
|
|
120
|
+
*/
|
|
121
|
+
export async function publishPending(feed, itemsCollection, options) {
|
|
122
|
+
if (!feed.publish?.enabled) {
|
|
123
|
+
return { published: 0, failed: 0 };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const {
|
|
127
|
+
micropubEndpoint,
|
|
128
|
+
me,
|
|
129
|
+
maxPostsPerCycle,
|
|
130
|
+
postImpl = postToMicropub,
|
|
131
|
+
mintImpl = mintToken,
|
|
132
|
+
} = options;
|
|
133
|
+
|
|
134
|
+
const query = {
|
|
135
|
+
feedId: feed._id,
|
|
136
|
+
postedAt: { $exists: false },
|
|
137
|
+
postSkipped: { $ne: true },
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
if (feed.publish.since) {
|
|
141
|
+
// An item with no parsable date would be excluded forever by a bare
|
|
142
|
+
// `pubDate: { $gt: since }`, because null sorts below every Date in BSON —
|
|
143
|
+
// a silent permanent no-op. fetchedAt is always set on insert and is
|
|
144
|
+
// monotonic with cache entry, so it stands in when pubDate is missing.
|
|
145
|
+
query.$or = [
|
|
146
|
+
{ pubDate: { $gt: feed.publish.since } },
|
|
147
|
+
{ pubDate: null, fetchedAt: { $gt: feed.publish.since } },
|
|
148
|
+
];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const items = await itemsCollection
|
|
152
|
+
.find(query)
|
|
153
|
+
.sort({ pubDate: 1 })
|
|
154
|
+
.limit(maxPostsPerCycle)
|
|
155
|
+
.toArray();
|
|
156
|
+
|
|
157
|
+
if (items.length === 0) {
|
|
158
|
+
return { published: 0, failed: 0 };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
let published = 0;
|
|
162
|
+
let failed = 0;
|
|
163
|
+
|
|
164
|
+
// ponytail: a crash between POST and $set re-posts the item next cycle;
|
|
165
|
+
// postData.create replaceOne's on the same URL, so it overwrites rather
|
|
166
|
+
// than duplicates. Add a q=source reconciliation if a feed ever produces
|
|
167
|
+
// non-deterministic slugs.
|
|
168
|
+
for (const item of items) {
|
|
169
|
+
try {
|
|
170
|
+
// Minted per item, not once for the batch: signing is a cheap HMAC, and
|
|
171
|
+
// a single batch token can expire mid-cycle on a slow endpoint. The
|
|
172
|
+
// resulting 401 is a 4xx, so under the permanent-failure rule the tail
|
|
173
|
+
// of the batch would be marked postSkipped and lost for good.
|
|
174
|
+
const token = mintImpl(me);
|
|
175
|
+
const properties = buildJf2(item, feed.publish);
|
|
176
|
+
const postUrl = await postImpl(micropubEndpoint, token, properties);
|
|
177
|
+
|
|
178
|
+
await itemsCollection.updateOne(
|
|
179
|
+
{ _id: item._id },
|
|
180
|
+
{ $set: { postedAt: new Date().toISOString(), postUrl } },
|
|
181
|
+
);
|
|
182
|
+
published++;
|
|
183
|
+
} catch (error) {
|
|
184
|
+
failed++;
|
|
185
|
+
|
|
186
|
+
// A 4xx does not become a 201 on retry, and neither does an item that
|
|
187
|
+
// structurally cannot be built. Both are permanent. Note postedAt is
|
|
188
|
+
// never written here: pruneOldItems spares any item where it exists.
|
|
189
|
+
const isPermanent =
|
|
190
|
+
!error.status || (error.status >= 400 && error.status < 500);
|
|
191
|
+
const attempts = (item.postAttempts || 0) + 1;
|
|
192
|
+
|
|
193
|
+
await itemsCollection.updateOne(
|
|
194
|
+
{ _id: item._id },
|
|
195
|
+
{
|
|
196
|
+
$set: {
|
|
197
|
+
postError: error.message,
|
|
198
|
+
postErrorAt: new Date().toISOString(),
|
|
199
|
+
...(isPermanent || attempts >= MAX_ATTEMPTS
|
|
200
|
+
? { postSkipped: true }
|
|
201
|
+
: {}),
|
|
202
|
+
},
|
|
203
|
+
$inc: { postAttempts: 1 },
|
|
204
|
+
},
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
console.error(`[RSS] Publish failed for ${item.guid}: ${error.message}`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
console.log(
|
|
212
|
+
`[RSS] Published ${published} item(s) from ${feed.title || feed.url}${failed ? `, ${failed} failed` : ""}`,
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
return { published, failed };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Resolve the watermark a backfill request should move the feed to.
|
|
220
|
+
*
|
|
221
|
+
* Backfill is not a separate publishing path: it rewinds publish.since and
|
|
222
|
+
* lets the normal loop catch up at maxPostsPerCycle per cycle, which is
|
|
223
|
+
* where the cap and the rate limit already live.
|
|
224
|
+
* @param {object} request - { since } ISO date, or { last } item count
|
|
225
|
+
* @param {Date|string|null} oldestPubDate - pubDate of the Nth newest item
|
|
226
|
+
* @returns {string} ISO watermark
|
|
227
|
+
*/
|
|
228
|
+
export function watermarkFor({ since, last }, oldestPubDate) {
|
|
229
|
+
if (since) {
|
|
230
|
+
return new Date(since).toISOString();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (last && oldestPubDate) {
|
|
234
|
+
// One millisecond below, so the Nth item passes the loop's `$gt` filter.
|
|
235
|
+
return new Date(new Date(oldestPubDate).getTime() - 1).toISOString();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
throw new Error("Backfill requires since or last");
|
|
239
|
+
}
|
package/lib/sync.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { RssClient } from "./rss-client.js";
|
|
2
|
+
import { publishPending, resolvePublishContext } from "./publisher.js";
|
|
2
3
|
|
|
3
4
|
let syncInterval = null;
|
|
4
5
|
let initialSyncTimeout = null;
|
|
@@ -71,9 +72,13 @@ export function stopSync() {
|
|
|
71
72
|
* Run a single sync cycle
|
|
72
73
|
* @param {Object} dbOrIndiekit - Database instance or Indiekit instance (for backwards compat)
|
|
73
74
|
* @param {Object} options - Plugin options
|
|
75
|
+
* @param {Object|null} [publishContext] - { micropubEndpoint, me }. Callers with
|
|
76
|
+
* a request (manual sync controllers) should supply their own resolved
|
|
77
|
+
* values; background sync has none, so it derives its own from the
|
|
78
|
+
* Indiekit instance.
|
|
74
79
|
* @returns {Promise<Object>}
|
|
75
80
|
*/
|
|
76
|
-
export async function runSync(dbOrIndiekit, options) {
|
|
81
|
+
export async function runSync(dbOrIndiekit, options, publishContext) {
|
|
77
82
|
// Support both direct db object and Indiekit object (for background sync)
|
|
78
83
|
const db = dbOrIndiekit.database || dbOrIndiekit;
|
|
79
84
|
if (!db || typeof db.collection !== "function") {
|
|
@@ -81,6 +86,15 @@ export async function runSync(dbOrIndiekit, options) {
|
|
|
81
86
|
return { error: syncState.lastError };
|
|
82
87
|
}
|
|
83
88
|
|
|
89
|
+
publishContext =
|
|
90
|
+
publishContext ??
|
|
91
|
+
(dbOrIndiekit.config
|
|
92
|
+
? resolvePublishContext(
|
|
93
|
+
dbOrIndiekit.config.application,
|
|
94
|
+
dbOrIndiekit.config.publication,
|
|
95
|
+
)
|
|
96
|
+
: null);
|
|
97
|
+
|
|
84
98
|
if (syncState.syncing) {
|
|
85
99
|
return { error: "Sync already in progress" };
|
|
86
100
|
}
|
|
@@ -129,6 +143,30 @@ export async function runSync(dbOrIndiekit, options) {
|
|
|
129
143
|
syncState.feedsProcessed++;
|
|
130
144
|
}
|
|
131
145
|
|
|
146
|
+
// Publish items from feeds wired to Micropub. Runs after insertion so it
|
|
147
|
+
// reads what syncFeed just wrote, and before the prune so nothing is
|
|
148
|
+
// published from items about to be removed.
|
|
149
|
+
let itemsPublished = 0;
|
|
150
|
+
|
|
151
|
+
if (publishContext) {
|
|
152
|
+
for (const feed of feeds.filter((entry) => entry.publish?.enabled)) {
|
|
153
|
+
try {
|
|
154
|
+
const result = await publishPending(feed, itemsCollection, {
|
|
155
|
+
...publishContext,
|
|
156
|
+
maxPostsPerCycle: options.maxPostsPerCycle || 10,
|
|
157
|
+
});
|
|
158
|
+
itemsPublished += result.published;
|
|
159
|
+
} catch (error) {
|
|
160
|
+
// One feed's failure must not cost the remaining feeds their publish
|
|
161
|
+
// step, nor the cycle its prune. syncFeed isolates per feed the same
|
|
162
|
+
// way.
|
|
163
|
+
console.error(
|
|
164
|
+
`[RSS] Publish failed for ${feed.url}: ${error.message}`,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
132
170
|
// Prune old items
|
|
133
171
|
const retentionDays = options.retentionDays || 30;
|
|
134
172
|
const itemsPruned = await pruneOldItems(
|
|
@@ -142,12 +180,13 @@ export async function runSync(dbOrIndiekit, options) {
|
|
|
142
180
|
syncState.syncing = false;
|
|
143
181
|
|
|
144
182
|
console.log(
|
|
145
|
-
`[RSS] Sync complete: ${syncState.feedsProcessed} feeds, ${syncState.itemsAdded} new items, ${itemsPruned} pruned`
|
|
183
|
+
`[RSS] Sync complete: ${syncState.feedsProcessed} feeds, ${syncState.itemsAdded} new items, ${itemsPublished} published, ${itemsPruned} pruned`
|
|
146
184
|
);
|
|
147
185
|
|
|
148
186
|
return {
|
|
149
187
|
feedsProcessed: syncState.feedsProcessed,
|
|
150
188
|
itemsAdded: syncState.itemsAdded,
|
|
189
|
+
itemsPublished,
|
|
151
190
|
itemsPruned,
|
|
152
191
|
};
|
|
153
192
|
} catch (error) {
|
|
@@ -339,6 +378,9 @@ export async function pruneOldItems(
|
|
|
339
378
|
// $ne: null also excludes missing dates: in BSON null sorts before
|
|
340
379
|
// Date, so a bare $lt would delete every undated item as "too old".
|
|
341
380
|
pubDate: { $lt: cutoff, $ne: null },
|
|
381
|
+
// A published item is never forgotten: if the feed re-serves it later
|
|
382
|
+
// it would return without postedAt and be published a second time.
|
|
383
|
+
postedAt: { $exists: false },
|
|
342
384
|
_id: { $nin: keep.map((item) => item._id) },
|
|
343
385
|
});
|
|
344
386
|
|
package/lib/utils.js
CHANGED
|
@@ -38,6 +38,12 @@ export function sanitizeHtml(html) {
|
|
|
38
38
|
],
|
|
39
39
|
allowedAttributes: {
|
|
40
40
|
a: ["href", "title", "rel"],
|
|
41
|
+
code: ["class"],
|
|
42
|
+
pre: ["class"],
|
|
43
|
+
},
|
|
44
|
+
allowedClasses: {
|
|
45
|
+
code: ["language-*"],
|
|
46
|
+
pre: ["language-*"],
|
|
41
47
|
},
|
|
42
48
|
allowedSchemes: ["http", "https", "mailto"],
|
|
43
49
|
});
|
|
@@ -172,6 +178,9 @@ export function formatFeed(feed) {
|
|
|
172
178
|
lastFetchedAt: toISO(feed.lastFetchedAt),
|
|
173
179
|
lastError: feed.lastError,
|
|
174
180
|
itemCount: feed.itemCount || 0,
|
|
181
|
+
publish: feed.publish
|
|
182
|
+
? { ...feed.publish, since: toISO(feed.publish.since) }
|
|
183
|
+
: null,
|
|
175
184
|
};
|
|
176
185
|
}
|
|
177
186
|
|
package/locales/en.json
CHANGED
|
@@ -39,8 +39,10 @@
|
|
|
39
39
|
"success": {
|
|
40
40
|
"feedAdded": "Feed added successfully",
|
|
41
41
|
"feedRemoved": "Feed removed",
|
|
42
|
-
"
|
|
42
|
+
"feedUpdated": "Feed updated",
|
|
43
|
+
"feedEnabled": "Feed enabled",
|
|
43
44
|
"feedDisabled": "Feed disabled",
|
|
45
|
+
"backfillQueued": "Backfill queued; items will be published as drafts over the next sync cycles",
|
|
44
46
|
"syncComplete": "Sync complete",
|
|
45
47
|
"clearResync": "Items cleared and re-synced"
|
|
46
48
|
},
|
|
@@ -48,6 +50,15 @@
|
|
|
48
50
|
"title": "Public Page",
|
|
49
51
|
"description": "View aggregated RSS feeds on the public page",
|
|
50
52
|
"view": "View News Page"
|
|
53
|
+
},
|
|
54
|
+
"publish": {
|
|
55
|
+
"title": "Publish to site",
|
|
56
|
+
"enabled": "Create posts from this feed",
|
|
57
|
+
"postType": "Post type",
|
|
58
|
+
"content": "Content template",
|
|
59
|
+
"linkProperty": "Link property",
|
|
60
|
+
"status": "Post status",
|
|
61
|
+
"save": "Save publish settings"
|
|
51
62
|
}
|
|
52
63
|
}
|
|
53
64
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rmdes/indiekit-endpoint-rss",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "RSS feed reader endpoint for Indiekit. Aggregates multiple feeds, caches in MongoDB, displays on frontend.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"indiekit",
|
|
@@ -44,15 +44,15 @@
|
|
|
44
44
|
"index.js"
|
|
45
45
|
],
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"@
|
|
47
|
+
"@indiekit/endpoint-micropub": "^1.0.0-beta.29",
|
|
48
48
|
"@indiekit/error": "^1.0.0-beta.25",
|
|
49
|
+
"@rmdes/indiekit-startup-gate": "^1.0.0",
|
|
49
50
|
"express": "^5.0.0",
|
|
51
|
+
"jsonwebtoken": "^9.0.2",
|
|
52
|
+
"mongodb": "^7.5.0",
|
|
50
53
|
"rss-parser": "^3.13.0",
|
|
51
54
|
"sanitize-html": "^2.13.0"
|
|
52
55
|
},
|
|
53
|
-
"peerDependencies": {
|
|
54
|
-
"@indiekit/indiekit": ">=1.0.0-beta.25"
|
|
55
|
-
},
|
|
56
56
|
"publishConfig": {
|
|
57
57
|
"access": "public"
|
|
58
58
|
}
|
package/views/rss.njk
CHANGED
|
@@ -101,6 +101,39 @@
|
|
|
101
101
|
<div class="rss-feed-error">{{ feed.lastError }}</div>
|
|
102
102
|
{% endif %}
|
|
103
103
|
</div>
|
|
104
|
+
<details class="rss-publish">
|
|
105
|
+
<summary>{{ __("rss.publish.title") }}{% if feed.publish.enabled %} • {{ feed.publish.postType }}{% endif %}</summary>
|
|
106
|
+
<form data-publish-feed="{{ feed.id }}">
|
|
107
|
+
<label>
|
|
108
|
+
<input type="checkbox" name="enabled" {{ "checked" if feed.publish.enabled }}>
|
|
109
|
+
{{ __("rss.publish.enabled") }}
|
|
110
|
+
</label>
|
|
111
|
+
<label>
|
|
112
|
+
{{ __("rss.publish.postType") }}
|
|
113
|
+
<select name="postType">
|
|
114
|
+
{% for type in postTypes %}
|
|
115
|
+
<option value="{{ type }}" {{ "selected" if feed.publish.postType == type }}>{{ type }}</option>
|
|
116
|
+
{% endfor %}
|
|
117
|
+
</select>
|
|
118
|
+
</label>
|
|
119
|
+
<label>
|
|
120
|
+
{{ __("rss.publish.content") }}
|
|
121
|
+
<input class="input" type="text" name="content" value="{{ feed.publish.content or '{{description}}' }}">
|
|
122
|
+
</label>
|
|
123
|
+
<label>
|
|
124
|
+
{{ __("rss.publish.linkProperty") }}
|
|
125
|
+
<input class="input" type="text" name="linkProperty" value="{{ feed.publish.linkProperty or '' }}">
|
|
126
|
+
</label>
|
|
127
|
+
<label>
|
|
128
|
+
{{ __("rss.publish.status") }}
|
|
129
|
+
<select name="status">
|
|
130
|
+
<option value="draft" {{ "selected" if feed.publish.status != "published" }}>draft</option>
|
|
131
|
+
<option value="published" {{ "selected" if feed.publish.status == "published" }}>published</option>
|
|
132
|
+
</select>
|
|
133
|
+
</label>
|
|
134
|
+
{{ button({ type: "submit", text: __("rss.publish.save") }) }}
|
|
135
|
+
</form>
|
|
136
|
+
</details>
|
|
104
137
|
<div class="rss-feed-actions">
|
|
105
138
|
<label class="rss-toggle">
|
|
106
139
|
<input
|
|
@@ -280,5 +313,39 @@
|
|
|
280
313
|
}
|
|
281
314
|
});
|
|
282
315
|
});
|
|
316
|
+
|
|
317
|
+
// Handle publish configuration
|
|
318
|
+
document.querySelectorAll('[data-publish-feed]').forEach(form => {
|
|
319
|
+
form.addEventListener('submit', async (e) => {
|
|
320
|
+
e.preventDefault();
|
|
321
|
+
const feedId = e.target.dataset.publishFeed;
|
|
322
|
+
const data = new FormData(e.target);
|
|
323
|
+
|
|
324
|
+
const publish = {
|
|
325
|
+
enabled: data.get('enabled') === 'on',
|
|
326
|
+
postType: data.get('postType'),
|
|
327
|
+
content: data.get('content'),
|
|
328
|
+
linkProperty: data.get('linkProperty') || null,
|
|
329
|
+
status: data.get('status'),
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
try {
|
|
333
|
+
const response = await fetch(`{{ mountPath }}/api/feeds/${feedId}`, {
|
|
334
|
+
method: 'PATCH',
|
|
335
|
+
headers: { 'Content-Type': 'application/json' },
|
|
336
|
+
body: JSON.stringify({ publish })
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
if (response.ok) {
|
|
340
|
+
location.reload();
|
|
341
|
+
} else {
|
|
342
|
+
const body = await response.json();
|
|
343
|
+
alert(body.error || 'Failed to save publish settings');
|
|
344
|
+
}
|
|
345
|
+
} catch (err) {
|
|
346
|
+
alert('Failed to save publish settings: ' + err.message);
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
});
|
|
283
350
|
</script>
|
|
284
351
|
{% endblock %}
|