Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions defaultmodules/newsfeed/feeditem.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,10 @@ const sanitizeBasicHtml = (html, allowedTags = []) => {
* @param {object} item - The parsed feed item from feedparser.
* @param {object} [options] - Normalization options.
* @param {string[]} [options.allowedBasicHtmlTags] - Inline tags allowed to survive sanitization.
* @param {boolean} [options.useCorsProxy] - Whether the item's article should use the CORS proxy.
* @param {boolean} [options.logFeedWarnings] - Whether to log a warning for items missing a title or publication date.
* @returns {{title:string, description:string, pubdate:string, url:string, useCorsProxy:boolean, hash:string}|null} The normalized item, or null when title or pubdate is missing.
* @returns {{title:string, description:string, pubdate:string, url:string, hash:string}|null} The normalized item, or null when title or pubdate is missing.
*/
const normalizeFeedItem = (item, { allowedBasicHtmlTags = [], useCorsProxy = false, logFeedWarnings = false } = {}) => {
const normalizeFeedItem = (item, { allowedBasicHtmlTags = [], logFeedWarnings = false } = {}) => {
// feedparser strips HTML from item.title; recover the raw title so inline
// formatting tags (e.g. <em>) in titles survive sanitizeBasicHtml below.
const title = (item["rss:title"] && item["rss:title"]["#"]) || (item["atom:title"] && item["atom:title"]["#"]) || item.title;
Expand Down Expand Up @@ -111,7 +110,6 @@ const normalizeFeedItem = (item, { allowedBasicHtmlTags = [], useCorsProxy = fal
description,
pubdate,
url,
useCorsProxy,
// Hash on the original title so the dedup identity is stable regardless of allowedBasicHtmlTags
hash: crypto.createHash("sha256").update(`${pubdate} :: ${title} :: ${url}`).digest("hex")
};
Expand Down
5 changes: 1 addition & 4 deletions defaultmodules/newsfeed/newsfeedfetcher.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,12 @@ class NewsfeedFetcher {
* @param {number} reloadInterval - Time in ms between fetches
* @param {string} encoding - Encoding of the feed (e.g., 'UTF-8')
* @param {boolean} logFeedWarnings - If true log warnings when there is an error parsing a news article
* @param {boolean} useCorsProxy - If true cors proxy is used for article url's
* @param {string[]} allowedBasicHtmlTags - Basic formatting tags to keep in title and description. Only tags from the safe list are honored; anything else is ignored.
*/
constructor (url, reloadInterval, encoding, logFeedWarnings, useCorsProxy, allowedBasicHtmlTags = []) {
constructor (url, reloadInterval, encoding, logFeedWarnings, allowedBasicHtmlTags = []) {
this.url = url;
this.encoding = encoding;
this.logFeedWarnings = logFeedWarnings;
this.useCorsProxy = useCorsProxy;

// Keep only tags from the hardcoded safe list; warn about (and ignore) anything else.
const requestedTags = (Array.isArray(allowedBasicHtmlTags) ? allowedBasicHtmlTags : []).map((tag) => String(tag).trim().toLowerCase());
Expand Down Expand Up @@ -96,7 +94,6 @@ class NewsfeedFetcher {
parser.on("data", (item) => {
const normalizedItem = normalizeFeedItem(item, {
allowedBasicHtmlTags: this.allowedBasicHtmlTags,
useCorsProxy: this.useCorsProxy,
logFeedWarnings: this.logFeedWarnings
});
if (normalizedItem) {
Expand Down
3 changes: 1 addition & 2 deletions defaultmodules/newsfeed/node_helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,6 @@ module.exports = NodeHelper.create({
const url = feed.url || "";
const encoding = feed.encoding || "UTF-8";
const reloadInterval = feed.reloadInterval || config.reloadInterval || 5 * 60 * 1000;
const useCorsProxy = feed.useCorsProxy ?? true;

try {
new URL(url);
Expand All @@ -99,7 +98,7 @@ module.exports = NodeHelper.create({
let fetcher;
if (typeof this.fetchers[url] === "undefined") {
Log.log(`Create new newsfetcher for url: ${url} - Interval: ${reloadInterval}`);
fetcher = new NewsfeedFetcher(url, reloadInterval, encoding, config.logFeedWarnings, useCorsProxy, config.allowedBasicHtmlTags);
fetcher = new NewsfeedFetcher(url, reloadInterval, encoding, config.logFeedWarnings, config.allowedBasicHtmlTags);

fetcher.onReceive(() => {
this.broadcastFeeds();
Expand Down
58 changes: 39 additions & 19 deletions tests/unit/modules/default/newsfeed/newsfeedfetcher_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,32 +14,34 @@ const feedResponse = (fetcher, xml = xmlContent) => new Promise((resolve) => {

describe("NewsfeedFetcher", () => {
it("parses all items from the test fixture", async () => {
const fetcher = new NewsfeedFetcher("http://test.example/feed", 60000, "UTF-8", false, false);
const fetcher = new NewsfeedFetcher("http://test.example/feed", 60000, "UTF-8", false);
const items = await feedResponse(fetcher);

expect(items).toHaveLength(10);
});

it("parses title, URL, publication date and description", async () => {
const fetcher = new NewsfeedFetcher("http://test.example/feed", 60000, "UTF-8", false, false);
const fetcher = new NewsfeedFetcher("http://test.example/feed", 60000, "UTF-8", false);
const items = await feedResponse(fetcher);
const item = items[0];

expect(item.title).toBeTruthy();
expect(item.url).toMatch(/^https?:\/\//);
expect(item.pubdate).toBeTruthy();
expect(item.title).toBe("QPanel 0.13.0");
expect(item.url).toBe("https://rodrigoramirez.com/qpanel-0-13-0/");
expect(item.pubdate).toBe("2016-09-20T11:16:08.000Z");
expect(item.description).toBeTruthy();
});

it("strips HTML tags from descriptions", async () => {
const fetcher = new NewsfeedFetcher("http://test.example/feed", 60000, "UTF-8", false, false);
const fetcher = new NewsfeedFetcher("http://test.example/feed", 60000, "UTF-8", false);
const items = await feedResponse(fetcher);

expect(items[0].description).not.toContain("<p>");
for (const item of items) {
expect(item.description).not.toMatch(/<[^>]+>/);
}
});

it("defaults to an empty description when an Atom entry has no summary content", async () => {
const fetcher = new NewsfeedFetcher("http://test.example/feed", 60000, "UTF-8", false, false);
const fetcher = new NewsfeedFetcher("http://test.example/feed", 60000, "UTF-8", false);
const feed = `
<feed xmlns="http://www.w3.org/2005/Atom">
<entry>
Expand All @@ -55,7 +57,7 @@ describe("NewsfeedFetcher", () => {
});

it("generates a stable SHA-256 hash for each item", async () => {
const fetcher = new NewsfeedFetcher("http://test.example/feed", 60000, "UTF-8", false, false);
const fetcher = new NewsfeedFetcher("http://test.example/feed", 60000, "UTF-8", false);
const items = await feedResponse(fetcher);

for (const item of items) {
Expand All @@ -65,30 +67,30 @@ describe("NewsfeedFetcher", () => {

it("discards items without a title", async () => {
const xml = xmlContent.replace("<title>QPanel 0.13.0</title>", "<title></title>");
const fetcher = new NewsfeedFetcher("http://test.example/feed", 60000, "UTF-8", false, false);
const fetcher = new NewsfeedFetcher("http://test.example/feed", 60000, "UTF-8", false);
const items = await feedResponse(fetcher, xml);

expect(items).toHaveLength(9);
});

it("discards items without a publication date", async () => {
const xml = xmlContent.replace("<pubDate>Tue, 20 Sep 2016 11:16:08 +0000</pubDate>", "<pubDate></pubDate>");
const fetcher = new NewsfeedFetcher("http://test.example/feed", 60000, "UTF-8", false, false);
const fetcher = new NewsfeedFetcher("http://test.example/feed", 60000, "UTF-8", false);
const items = await feedResponse(fetcher, xml);

expect(items).toHaveLength(9);
});

it("calls onError when feed XML is malformed", async () => {
const fetcher = new NewsfeedFetcher("http://test.example/feed", 60000, "UTF-8", false, false);
const fetcher = new NewsfeedFetcher("http://test.example/feed", 60000, "UTF-8", false);
const error = new Promise((resolve) => fetcher.onError((_fetcher, info) => resolve(info)));
fetcher.httpFetcher.emit("response", { body: Readable.from(["this is not xml at all <<<"]) });

await expect(error).resolves.toMatchObject({ errorType: "PARSE_ERROR" });
});

it("calls onError when an HTTP error is emitted", async () => {
const fetcher = new NewsfeedFetcher("http://test.example/feed", 60000, "UTF-8", false, false);
const fetcher = new NewsfeedFetcher("http://test.example/feed", 60000, "UTF-8", false);
const error = new Promise((resolve) => fetcher.onError((_fetcher, info) => resolve(info)));
fetcher.httpFetcher.emit("error", { message: "404", translationKey: "MODULE_ERROR_CLIENT_ERROR" });

Expand All @@ -98,22 +100,30 @@ describe("NewsfeedFetcher", () => {
it("increases reloadInterval when feed TTL is larger", async () => {
const ttlMinutes = 30;
const xml = xmlContent.replace(" <item>", ` <ttl>${ttlMinutes}</ttl>\n <item>`);
const fetcher = new NewsfeedFetcher("http://test.example/feed", 60000, "UTF-8", false, false);
const fetcher = new NewsfeedFetcher("http://test.example/feed", 60000, "UTF-8", false);
await feedResponse(fetcher, xml);

expect(fetcher.httpFetcher.reloadInterval).toBe(ttlMinutes * 60 * 1000);
});

it("does not decrease reloadInterval when feed TTL is smaller", async () => {
const xml = xmlContent.replace(" <item>", " <ttl>1</ttl>\n <item>");
const fetcher = new NewsfeedFetcher("http://test.example/feed", 600000, "UTF-8", false);
await feedResponse(fetcher, xml);

expect(fetcher.httpFetcher.reloadInterval).toBe(600000);
});

it("caps TTL at 24 hours", async () => {
const xml = xmlContent.replace(" <item>", " <ttl>2880</ttl>\n <item>");
const fetcher = new NewsfeedFetcher("http://test.example/feed", 60000, "UTF-8", false, false);
const fetcher = new NewsfeedFetcher("http://test.example/feed", 60000, "UTF-8", false);
await feedResponse(fetcher, xml);

expect(fetcher.httpFetcher.reloadInterval).toBe(24 * 60 * 60 * 1000);
});

it("updates reloadInterval only when the configured value is smaller", () => {
const fetcher = new NewsfeedFetcher("http://test.example/feed", 60000, "UTF-8", false, false);
const fetcher = new NewsfeedFetcher("http://test.example/feed", 60000, "UTF-8", false);

fetcher.setReloadInterval(30000);
expect(fetcher.httpFetcher.reloadInterval).toBe(30000);
Expand All @@ -123,13 +133,23 @@ describe("NewsfeedFetcher", () => {
expect(fetcher.httpFetcher.reloadInterval).toBe(30000);
});

it("attaches useCorsProxy without changing item URLs", async () => {
const fetcher = new NewsfeedFetcher("http://test.example/feed", 60000, "UTF-8", false, true);
it("uses allowedBasicHtmlTags from the fifth constructor argument", async () => {
const xml = xmlContent.replace("<title>QPanel 0.13.0</title>", "<title><![CDATA[<em>QPanel</em> 0.13.0]]></title>");
const fetcher = new NewsfeedFetcher("http://test.example/feed", 60000, "UTF-8", false, ["em"]);
const items = await feedResponse(fetcher, xml);

expect(items[0].title).toBe("<em>QPanel</em> 0.13.0");
});

it("keeps article URLs direct and omits the removed proxy metadata", async () => {
const fetcher = new NewsfeedFetcher("http://test.example/feed", 60000, "UTF-8", false);
const items = await feedResponse(fetcher);

expect(items).toHaveLength(10);
for (const item of items) {
expect(item.useCorsProxy).toBe(true);
expect(item.url).toMatch(/^https?:\/\//);
expect(item.url).not.toContain("/cors?url=");
expect(item).not.toHaveProperty("useCorsProxy");
}
});
});
1 change: 0 additions & 1 deletion tests/unit/modules/default/newsfeed/node_helper_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,6 @@ describe("newsfeed node_helper", () => {
helper.createFetcher({
url: "https://example.com/feed.xml",
encoding: "ISO-8859-1",
useCorsProxy: false,
reloadInterval: 120000
}, config);

Expand Down
Loading