ep_rss 11.0.27 → 11.0.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +41 -70
- package/package.json +4 -1
- package/static/tests/frontend-new/specs/feed.spec.ts +62 -4
package/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const {Feed} = require('feed');
|
|
3
4
|
const API = require('ep_etherpad-lite/node/db/API');
|
|
4
5
|
const padManager = require('ep_etherpad-lite/node/db/PadManager');
|
|
5
6
|
const settings = require('ep_etherpad-lite/node/utils/Settings');
|
|
@@ -9,9 +10,13 @@ if (!settings.rss) {
|
|
|
9
10
|
console.log('ep_rss settings have not been configured');
|
|
10
11
|
}
|
|
11
12
|
|
|
12
|
-
|
|
13
|
-
|
|
13
|
+
// How long to serve a cached feed before regenerating. Bumped on every
|
|
14
|
+
// pad edit anyway via the lastEdited check below, so this only affects
|
|
15
|
+
// pads that haven't been edited recently.
|
|
16
|
+
const staleTime = settings.rss.staleTime || 300000;
|
|
14
17
|
|
|
18
|
+
// Per-pad cache: { lastEdited: number, body: string }.
|
|
19
|
+
const feeds = {};
|
|
15
20
|
|
|
16
21
|
exports.eejsBlock_htmlHead = (hookName, args, cb) => {
|
|
17
22
|
args.content +=
|
|
@@ -28,79 +33,45 @@ exports.registerRoute = (hookName, args, cb) => {
|
|
|
28
33
|
args.app.get('/p/:padId/atom.xml', redirectToFeed);
|
|
29
34
|
|
|
30
35
|
args.app.get('/p/:padId/feed', async (req, res) => {
|
|
31
|
-
const fullURL = `${req.protocol}://${req.get('host')}${req.url}`;
|
|
32
36
|
const padId = req.params.padId;
|
|
33
|
-
const
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
+
const origin = `${req.protocol}://${req.get('host')}`;
|
|
38
|
+
const padURL = `${origin}/p/${padId}`;
|
|
39
|
+
// Strip query string so the atom:self link matches the URL feed
|
|
40
|
+
// readers actually fetched (e.g. when they tack on cache-busters).
|
|
41
|
+
const feedURL = `${origin}${req.url.split('?')[0]}`;
|
|
37
42
|
|
|
38
|
-
|
|
39
|
-
const
|
|
40
|
-
const
|
|
41
|
-
if (
|
|
42
|
-
|
|
43
|
+
const {lastEdited} = await API.getLastEdited(padId);
|
|
44
|
+
const now = Date.now();
|
|
45
|
+
const cached = feeds[padId];
|
|
46
|
+
if (cached && cached.lastEdited === lastEdited && now - cached.builtAt < staleTime) {
|
|
47
|
+
res.type('application/rss+xml');
|
|
48
|
+
res.send(cached.body);
|
|
49
|
+
return;
|
|
43
50
|
}
|
|
44
51
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
52
|
+
const pad = await padManager.getPad(padId);
|
|
53
|
+
const feed = new Feed({
|
|
54
|
+
title: padId,
|
|
55
|
+
description: `Etherpad feed for ${padId}`,
|
|
56
|
+
id: padURL,
|
|
57
|
+
link: padURL,
|
|
58
|
+
language: 'en-us',
|
|
59
|
+
feedLinks: {rss: feedURL},
|
|
60
|
+
updated: new Date(lastEdited),
|
|
61
|
+
});
|
|
62
|
+
feed.addItem({
|
|
63
|
+
title: padId,
|
|
64
|
+
id: `${padURL}#${lastEdited}`,
|
|
65
|
+
link: padURL,
|
|
66
|
+
description: pad.text(),
|
|
67
|
+
date: new Date(lastEdited),
|
|
68
|
+
});
|
|
48
69
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
if (!feeds[padId].feed) { // If it's not already stored in memory
|
|
54
|
-
console.debug('RSS Feed not already in memory so writing memory', feeds);
|
|
55
|
-
isPublished = false;
|
|
56
|
-
feeds[padId].lastEdited = message.lastEdited; // Add it to the timer object
|
|
57
|
-
} else {
|
|
58
|
-
isPublished = true;
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
if (!isPublished) {
|
|
62
|
-
const pad = await padManager.getPad(padId);
|
|
63
|
-
text = safe_tags(pad.text()).replace(/\n/g, '<br/>');
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
if (isPublished) {
|
|
67
|
-
console.debug(`Sending RSS from memory for ${padId}`);
|
|
68
|
-
res.contentType('rss');
|
|
69
|
-
res.send(feeds[padId].feed);
|
|
70
|
-
} else {
|
|
71
|
-
console.debug(`Building RSS for ${padId}`);
|
|
72
|
-
res.contentType('rss');
|
|
73
|
-
args.content = '<rss version="2.0" \n';
|
|
74
|
-
args.content += ' xmlns:content="http://purl.org/rss/1.0/modules/content/"\n';
|
|
75
|
-
args.content += ' xmlns:atom="http://www.w3.org/2005/Atom"\n';
|
|
76
|
-
args.content += '>\n';
|
|
77
|
-
args.content += '<channel>\n';
|
|
78
|
-
args.content += `<title>${padId}</title>\n`;
|
|
79
|
-
args.content += `<atom:link href="${fullURL}" rel="self" type="application/rss+xml" />`;
|
|
80
|
-
args.content += `<link>${padURL}</link>\n`;
|
|
81
|
-
args.content += '<description/>\n';
|
|
82
|
-
args.content += '<language>en-us</language>\n';
|
|
83
|
-
args.content += `<pubDate>${dateString}</pubDate>\n`;
|
|
84
|
-
args.content += `<lastBuildDate>${dateString}</lastBuildDate>\n`;
|
|
85
|
-
args.content += '<item>\n';
|
|
86
|
-
args.content += '<title>\n';
|
|
87
|
-
args.content += `${padId}\n`;
|
|
88
|
-
args.content += '</title>\n';
|
|
89
|
-
args.content += '<description>\n';
|
|
90
|
-
args.content += `<![CDATA[${text}]]>\n`;
|
|
91
|
-
args.content += '</description>\n';
|
|
92
|
-
args.content += `<link>${padURL}</link>\n`;
|
|
93
|
-
args.content += '</item>\n';
|
|
94
|
-
args.content += '</channel>\n';
|
|
95
|
-
args.content += '</rss>';
|
|
96
|
-
feeds[padId].feed = args.content;
|
|
97
|
-
res.send(args.content); // Send it to the requester
|
|
98
|
-
}
|
|
70
|
+
const body = feed.rss2();
|
|
71
|
+
feeds[padId] = {lastEdited, builtAt: now, body};
|
|
72
|
+
res.type('application/rss+xml');
|
|
73
|
+
res.send(body);
|
|
99
74
|
});
|
|
75
|
+
|
|
100
76
|
cb();
|
|
101
77
|
};
|
|
102
|
-
|
|
103
|
-
const safe_tags =
|
|
104
|
-
(str) => str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
105
|
-
|
|
106
|
-
const isAlreadyPublished = (padId, editTime) => (feeds[padId] === editTime);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ep_rss",
|
|
3
3
|
"description": "Get an RSS feed of pad updates",
|
|
4
|
-
"version": "11.0.
|
|
4
|
+
"version": "11.0.28",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "John McLear",
|
|
7
7
|
"email": "john@mclear.co.uk",
|
|
@@ -19,6 +19,9 @@
|
|
|
19
19
|
"type": "individual",
|
|
20
20
|
"url": "https://etherpad.org/"
|
|
21
21
|
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"feed": "^5.2.1"
|
|
24
|
+
},
|
|
22
25
|
"devDependencies": {
|
|
23
26
|
"eslint": "^8.57.1",
|
|
24
27
|
"eslint-config-etherpad": "^4.0.5",
|
|
@@ -25,21 +25,79 @@ test.describe('ep_rss feed', () => {
|
|
|
25
25
|
expect(res.status()).toBe(200);
|
|
26
26
|
expect(res.headers()['content-type']).toContain('application/rss+xml');
|
|
27
27
|
const body = await res.text();
|
|
28
|
-
expect(body).toMatch(
|
|
28
|
+
expect(body).toMatch(/^<\?xml version="1\.0" encoding="utf-8"\?>\n<rss /);
|
|
29
|
+
// Channel title is a plain element; item title is CDATA-wrapped by
|
|
30
|
+
// the feed library. Both must carry the pad id.
|
|
29
31
|
expect(body).toContain(`<title>${padId}</title>`);
|
|
32
|
+
expect(body).toContain(`<title><![CDATA[${padId}]]></title>`);
|
|
30
33
|
expect(body).toContain(marker);
|
|
31
34
|
expect(body.trim().endsWith('</rss>')).toBe(true);
|
|
32
35
|
});
|
|
33
36
|
|
|
34
|
-
test('
|
|
37
|
+
test('uses RFC-822 dates for pubDate and lastBuildDate (feedvalidator.org)', async ({page, request}) => {
|
|
38
|
+
await goToNewPad(page);
|
|
39
|
+
const padId = padIdFromUrl(page.url());
|
|
40
|
+
await writeToPad(page, 'date check');
|
|
41
|
+
await page.waitForTimeout(1200);
|
|
42
|
+
const body = await (await request.get(`${BASE_URL}/p/${padId}/feed`)).text();
|
|
43
|
+
// "Mon, 18 May 2026 09:53:13 GMT" — what Date.toUTCString() emits and
|
|
44
|
+
// what feedvalidator.org requires per RSS 2.0's RFC-822 rule.
|
|
45
|
+
const RFC822 = /^[A-Z][a-z]{2}, \d{2} [A-Z][a-z]{2} \d{4} \d{2}:\d{2}:\d{2} GMT$/;
|
|
46
|
+
const pub = body.match(/<pubDate>([^<]+)<\/pubDate>/g) || [];
|
|
47
|
+
const build = body.match(/<lastBuildDate>([^<]+)<\/lastBuildDate>/);
|
|
48
|
+
expect(pub.length).toBeGreaterThanOrEqual(1);
|
|
49
|
+
for (const tag of pub) {
|
|
50
|
+
const value = tag.replace(/<\/?pubDate>/g, '');
|
|
51
|
+
expect(value, `pubDate "${value}"`).toMatch(RFC822);
|
|
52
|
+
}
|
|
53
|
+
expect(build).not.toBeNull();
|
|
54
|
+
expect(build![1]).toMatch(RFC822);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('item carries a stable non-permalink guid', async ({page, request}) => {
|
|
58
|
+
await goToNewPad(page);
|
|
59
|
+
const padId = padIdFromUrl(page.url());
|
|
60
|
+
await writeToPad(page, 'guid check');
|
|
61
|
+
await page.waitForTimeout(1200);
|
|
62
|
+
|
|
63
|
+
const first = await (await request.get(`${BASE_URL}/p/${padId}/feed`)).text();
|
|
64
|
+
const m = first.match(/<guid isPermaLink="false">([^<]+)<\/guid>/);
|
|
65
|
+
expect(m, 'guid element missing').not.toBeNull();
|
|
66
|
+
expect(m![1]).toContain(`/p/${padId}#`);
|
|
67
|
+
|
|
68
|
+
// Polling the same pad without editing must return the same guid so
|
|
69
|
+
// feed readers don't show a duplicate item every fetch.
|
|
70
|
+
const second = await (await request.get(`${BASE_URL}/p/${padId}/feed`)).text();
|
|
71
|
+
const m2 = second.match(/<guid isPermaLink="false">([^<]+)<\/guid>/);
|
|
72
|
+
expect(m2![1]).toBe(m![1]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('atom:self link drops the query string so it matches the document URL', async ({page, request}) => {
|
|
76
|
+
await goToNewPad(page);
|
|
77
|
+
const padId = padIdFromUrl(page.url());
|
|
78
|
+
await writeToPad(page, 'self-ref check');
|
|
79
|
+
await page.waitForTimeout(1200);
|
|
80
|
+
const body = await (await request.get(`${BASE_URL}/p/${padId}/feed?_=cachebust`)).text();
|
|
81
|
+
const self = body.match(/<atom:link href="([^"]+)" rel="self"/);
|
|
82
|
+
expect(self).not.toBeNull();
|
|
83
|
+
expect(self![1]).toBe(`${BASE_URL}/p/${padId}/feed`);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('pad text with XML-significant characters is CDATA-wrapped', async ({page, request}) => {
|
|
35
87
|
await goToNewPad(page);
|
|
36
88
|
const padId = padIdFromUrl(page.url());
|
|
37
89
|
await writeToPad(page, 'tags <b>&</b>');
|
|
38
90
|
await page.waitForTimeout(1200);
|
|
39
91
|
|
|
40
92
|
const body = await (await request.get(`${BASE_URL}/p/${padId}/feed`)).text();
|
|
41
|
-
|
|
42
|
-
|
|
93
|
+
// CDATA section protects raw `<` and `&` from XML parsing, so the
|
|
94
|
+
// feed library passes pad text through verbatim instead of double-
|
|
95
|
+
// encoding. The body must (a) be well-formed XML — that's covered
|
|
96
|
+
// implicitly by every test that parses the response — and (b)
|
|
97
|
+
// include the text inside the CDATA section.
|
|
98
|
+
const descMatch = body.match(/<description><!\[CDATA\[([\s\S]*?)\]\]><\/description>/);
|
|
99
|
+
expect(descMatch, 'item description must be CDATA-wrapped').not.toBeNull();
|
|
100
|
+
expect(descMatch![1]).toContain('tags <b>&</b>');
|
|
43
101
|
});
|
|
44
102
|
|
|
45
103
|
for (const alias of ['rss', 'feed.rss', 'atom.xml']) {
|