feedkeeper 0.31.0 → 0.33.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/package.json +1 -1
- package/server/dist/api/feeds.js +3 -2
- package/server/dist/api/v1/resources.js +4 -3
- package/server/dist/feeds/blockedFeeds.js +43 -0
- package/server/dist/feeds/blockedHosts.js +90 -0
- package/server/dist/feeds/content/embeds/headings.js +38 -0
- package/server/dist/feeds/content/embeds/index.js +3 -2
- package/server/dist/feeds/content/embeds/media.js +45 -0
- package/server/dist/feeds/content/index.js +5 -1
- package/server/dist/feeds/content/noise/boilerplate.js +106 -0
- package/server/dist/feeds/content/noise/loading.js +29 -0
- package/server/dist/feeds/content/noise/pixels.js +27 -0
- package/server/dist/feeds/content/patterns/boilerplate/de.js +22 -0
- package/server/dist/feeds/content/patterns/boilerplate/en.js +21 -0
- package/server/dist/feeds/content/patterns/boilerplate/index.js +6 -0
- package/server/dist/feeds/content/patterns/loading/de.js +5 -0
- package/server/dist/feeds/content/patterns/loading/en.js +8 -0
- package/server/dist/feeds/content/patterns/loading/index.js +5 -0
- package/server/dist/feeds/content/patterns/sidebars/de.js +2 -0
- package/server/dist/feeds/content/patterns/sidebars/en.js +2 -0
- package/server/dist/feeds/discovery.js +2 -2
- package/server/dist/feeds/extractor.js +3 -2
- package/server/dist/feeds/feedIcon.js +12 -6
- package/server/dist/feeds/fetcher.js +3 -2
- package/server/dist/feeds/inbox.js +3 -2
- package/server/dist/feeds/opml.js +3 -2
- package/server/dist/feeds/pollQueue.js +3 -0
- package/server/dist/feeds/poller.js +12 -10
- package/server/dist/feeds/service.js +2 -2
- package/server/dist/server.js +4 -0
- package/server/package.json +1 -1
package/package.json
CHANGED
package/server/dist/api/feeds.js
CHANGED
|
@@ -5,6 +5,7 @@ import { requireCapability } from "../auth/capabilities.js";
|
|
|
5
5
|
import { listSubscriptionsForUser, findFeedById, isUserSubscribed, reorderSubscriptions } from "../feeds/repository.js";
|
|
6
6
|
import { subscribeToFeed, unsubscribeFromFeed, updateFeedSettings, FeedError, MultipleFeedsFoundError } from "../feeds/service.js";
|
|
7
7
|
import { SsrfBlockedError, normalizeUrlCandidate } from "../feeds/ssrfGuard.js";
|
|
8
|
+
import { blockedUrlBody } from "../feeds/blockedHosts.js";
|
|
8
9
|
import { generateOpml, importOpmlFeeds } from "../feeds/opml.js";
|
|
9
10
|
import { pollFeed, pollFeeds } from "../feeds/poller.js";
|
|
10
11
|
import { discoverFeeds } from "../feeds/discovery.js";
|
|
@@ -94,7 +95,7 @@ feedsRouter.post("/discover", requireSync, async (req, res) => {
|
|
|
94
95
|
}
|
|
95
96
|
catch (error) {
|
|
96
97
|
if (error instanceof SsrfBlockedError) {
|
|
97
|
-
res.status(400).json(
|
|
98
|
+
res.status(400).json(blockedUrlBody(error));
|
|
98
99
|
return;
|
|
99
100
|
}
|
|
100
101
|
res.status(400).json({ error: "discovery_failed", message: error instanceof Error ? error.message : String(error) });
|
|
@@ -117,7 +118,7 @@ feedsRouter.post("/", requireSync, async (req, res) => {
|
|
|
117
118
|
}
|
|
118
119
|
catch (error) {
|
|
119
120
|
if (error instanceof SsrfBlockedError) {
|
|
120
|
-
res.status(400).json(
|
|
121
|
+
res.status(400).json(blockedUrlBody(error));
|
|
121
122
|
return;
|
|
122
123
|
}
|
|
123
124
|
if (error instanceof MultipleFeedsFoundError) {
|
|
@@ -15,6 +15,7 @@ import { addMutedKeyword, canAccessItem, createFolder, deleteFolder, deleteItemN
|
|
|
15
15
|
import { saveToInbox, deleteInboxItem } from "../../feeds/inbox.js";
|
|
16
16
|
import { changeFeedUrl, FeedError, MultipleFeedsFoundError, subscribeToFeed, unsubscribeFromFeed, updateFeedSettings } from "../../feeds/service.js";
|
|
17
17
|
import { SsrfBlockedError, normalizeUrlCandidate } from "../../feeds/ssrfGuard.js";
|
|
18
|
+
import { blockedUrlBody } from "../../feeds/blockedHosts.js";
|
|
18
19
|
import { buildOverview } from "../../mcp/digest.js";
|
|
19
20
|
import { decodeCursor, encodeCursor } from "../../mcp/items.js";
|
|
20
21
|
import { readingPositions, serializeItem, serializeSubscriptionDetail } from "./serialize.js";
|
|
@@ -55,7 +56,7 @@ resourcesRouter.post("/subscriptions", async (req, res) => {
|
|
|
55
56
|
}
|
|
56
57
|
catch (error) {
|
|
57
58
|
if (error instanceof SsrfBlockedError)
|
|
58
|
-
return void res.status(400).json(
|
|
59
|
+
return void res.status(400).json(blockedUrlBody(error));
|
|
59
60
|
if (error instanceof MultipleFeedsFoundError)
|
|
60
61
|
return void res.status(409).json({ error: "multiple_feeds_found", feeds: error.feeds });
|
|
61
62
|
if (error instanceof FeedError)
|
|
@@ -100,7 +101,7 @@ resourcesRouter.patch("/subscriptions/:id", async (req, res) => {
|
|
|
100
101
|
if (error instanceof MultipleFeedsFoundError)
|
|
101
102
|
return void res.status(409).json({ error: "multiple_feeds_found", feeds: error.feeds });
|
|
102
103
|
if (error instanceof SsrfBlockedError)
|
|
103
|
-
return void res.status(400).json(
|
|
104
|
+
return void res.status(400).json(blockedUrlBody(error));
|
|
104
105
|
if (error instanceof FeedError)
|
|
105
106
|
return void res.status(error.message === "not_subscribed" ? 404 : 400).json({ error: error.message });
|
|
106
107
|
res.status(400).json({ error: "invalid_feed", message: error instanceof Error ? error.message : String(error) });
|
|
@@ -167,7 +168,7 @@ resourcesRouter.post("/discover", async (req, res) => {
|
|
|
167
168
|
}
|
|
168
169
|
catch (error) {
|
|
169
170
|
if (error instanceof SsrfBlockedError)
|
|
170
|
-
return void res.status(400).json(
|
|
171
|
+
return void res.status(400).json(blockedUrlBody(error));
|
|
171
172
|
res.status(400).json({ error: "discovery_failed", message: error instanceof Error ? error.message : String(error) });
|
|
172
173
|
}
|
|
173
174
|
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { db } from "../db/index.js";
|
|
2
|
+
import { FEED_BLOCKED_ERROR, listBlockedHosts, matchingBlockedHost } from "./blockedHosts.js";
|
|
3
|
+
function hostOf(url) {
|
|
4
|
+
try {
|
|
5
|
+
return new URL(url).hostname;
|
|
6
|
+
}
|
|
7
|
+
catch {
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Applies the block list to what is already stored: feeds on blocked hosts get the error state and lose their articles,
|
|
13
|
+
* except saved articles, articles with notes and articles in a published edition (the same exceptions as retention).
|
|
14
|
+
* The subscription stays, so people see why nothing arrives and can remove it themselves.
|
|
15
|
+
*/
|
|
16
|
+
export function purgeBlockedFeeds() {
|
|
17
|
+
const blocked = listBlockedHosts();
|
|
18
|
+
if (blocked.length === 0)
|
|
19
|
+
return { feeds: 0, items: 0 };
|
|
20
|
+
const feeds = db.prepare("SELECT id, url FROM feeds WHERE is_system_inbox = 0").all()
|
|
21
|
+
.filter((feed) => {
|
|
22
|
+
const host = hostOf(feed.url);
|
|
23
|
+
return host !== null && matchingBlockedHost(host, blocked) !== null;
|
|
24
|
+
});
|
|
25
|
+
if (feeds.length === 0)
|
|
26
|
+
return { feeds: 0, items: 0 };
|
|
27
|
+
const markFeed = db.prepare("UPDATE feeds SET last_error = ?, etag = NULL, last_modified = NULL WHERE id = ?");
|
|
28
|
+
const deleteItems = db.prepare(`DELETE FROM items
|
|
29
|
+
WHERE feed_id = ?
|
|
30
|
+
AND id NOT IN (SELECT item_id FROM item_bookmarks)
|
|
31
|
+
AND id NOT IN (SELECT item_id FROM item_notes)
|
|
32
|
+
AND id NOT IN (SELECT CAST(selected.value AS INTEGER) FROM editions e, json_each(e.item_ids) selected
|
|
33
|
+
WHERE e.source = 'curated' AND e.status <> 'dismissed')`);
|
|
34
|
+
let items = 0;
|
|
35
|
+
db.transaction(() => {
|
|
36
|
+
for (const feed of feeds) {
|
|
37
|
+
markFeed.run(FEED_BLOCKED_ERROR, feed.id);
|
|
38
|
+
items += deleteItems.run(feed.id).changes;
|
|
39
|
+
}
|
|
40
|
+
})();
|
|
41
|
+
return { feeds: feeds.length, items };
|
|
42
|
+
}
|
|
43
|
+
//# sourceMappingURL=blockedFeeds.js.map
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { assertPublicHttpUrl, normalizeUrlCandidate, SsrfBlockedError } from "./ssrfGuard.js";
|
|
2
|
+
export const FEED_BLOCKED_ERROR = "feed_blocked";
|
|
3
|
+
export const FEED_BLOCKED_MESSAGE = "This source is not available on this server.";
|
|
4
|
+
/** The address is on the operator's block list; subscribing, polling, article text and images are all refused. */
|
|
5
|
+
export class FeedBlockedError extends SsrfBlockedError {
|
|
6
|
+
constructor(url) {
|
|
7
|
+
super(url);
|
|
8
|
+
this.name = "FeedBlockedError";
|
|
9
|
+
this.message = FEED_BLOCKED_MESSAGE;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
const LABEL = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
|
|
13
|
+
/**
|
|
14
|
+
* One block list entry as a lower-case ASCII domain. A single label ("com") is refused because it would block a whole
|
|
15
|
+
* top-level domain by accident; `*.example.com` and `.example.com` mean the same as `example.com`.
|
|
16
|
+
*/
|
|
17
|
+
export function normalizeBlockedHost(raw) {
|
|
18
|
+
const entry = raw.trim().replace(/^\*?\./, "");
|
|
19
|
+
let hostname;
|
|
20
|
+
try {
|
|
21
|
+
hostname = new URL(entry.includes("://") ? entry : `https://${entry}`).hostname;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
throw new Error(`Invalid blocked host "${raw}"`);
|
|
25
|
+
}
|
|
26
|
+
hostname = hostname.replace(/\.$/, "");
|
|
27
|
+
const labels = hostname.split(".");
|
|
28
|
+
if (labels.length < 2 || !labels.every((label) => LABEL.test(label))) {
|
|
29
|
+
throw new Error(`Invalid blocked host "${raw}": expected a domain such as example.com`);
|
|
30
|
+
}
|
|
31
|
+
return hostname;
|
|
32
|
+
}
|
|
33
|
+
/** `BLOCKED_FEED_HOSTS`: domains separated by commas, spaces or line breaks. Fails on a bad entry instead of silently allowing it. */
|
|
34
|
+
export function parseBlockedHosts(raw) {
|
|
35
|
+
if (!raw)
|
|
36
|
+
return [];
|
|
37
|
+
const entries = raw.split(/[\s,]+/).filter(Boolean);
|
|
38
|
+
return [...new Set(entries.map(normalizeBlockedHost))];
|
|
39
|
+
}
|
|
40
|
+
let provider = null;
|
|
41
|
+
let cachedEnv = null;
|
|
42
|
+
/** A host application adds its own list (for example one kept in a database) to the environment list. */
|
|
43
|
+
export function setBlockedHostsProvider(next) {
|
|
44
|
+
provider = next;
|
|
45
|
+
}
|
|
46
|
+
function environmentHosts() {
|
|
47
|
+
const raw = process.env.BLOCKED_FEED_HOSTS;
|
|
48
|
+
if (cachedEnv && cachedEnv.raw === raw)
|
|
49
|
+
return cachedEnv.hosts;
|
|
50
|
+
const hosts = parseBlockedHosts(raw);
|
|
51
|
+
cachedEnv = { raw, hosts };
|
|
52
|
+
return hosts;
|
|
53
|
+
}
|
|
54
|
+
export function listBlockedHosts() {
|
|
55
|
+
return [...new Set([...environmentHosts(), ...(provider?.() ?? [])])];
|
|
56
|
+
}
|
|
57
|
+
export function matchingBlockedHost(hostname, blocked = listBlockedHosts()) {
|
|
58
|
+
if (blocked.length === 0)
|
|
59
|
+
return null;
|
|
60
|
+
const set = new Set(blocked);
|
|
61
|
+
const labels = hostname.toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, "").split(".");
|
|
62
|
+
for (let start = 0; start < labels.length; start++) {
|
|
63
|
+
const candidate = labels.slice(start).join(".");
|
|
64
|
+
if (set.has(candidate))
|
|
65
|
+
return candidate;
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
export function isHostBlocked(hostname) {
|
|
70
|
+
return matchingBlockedHost(hostname) !== null;
|
|
71
|
+
}
|
|
72
|
+
/** The check for every address that is fetched for a feed, an article or an image: public, and not blocked by the operator. */
|
|
73
|
+
export async function assertPublicFeedUrl(rawUrl) {
|
|
74
|
+
// Before DNS: a blocked source costs no lookup. An unparseable address falls through to its usual error.
|
|
75
|
+
let hostname;
|
|
76
|
+
try {
|
|
77
|
+
hostname = new URL(normalizeUrlCandidate(rawUrl)).hostname;
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
hostname = null;
|
|
81
|
+
}
|
|
82
|
+
if (hostname !== null && isHostBlocked(hostname))
|
|
83
|
+
throw new FeedBlockedError(rawUrl);
|
|
84
|
+
return assertPublicHttpUrl(rawUrl);
|
|
85
|
+
}
|
|
86
|
+
/** The JSON body of a refused address; a blocked source has its own code so clients can tell it from an internal address. */
|
|
87
|
+
export function blockedUrlBody(error) {
|
|
88
|
+
return { error: error instanceof FeedBlockedError ? FEED_BLOCKED_ERROR : "blocked_url", message: error.message };
|
|
89
|
+
}
|
|
90
|
+
//# sourceMappingURL=blockedHosts.js.map
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { count, flat, pruneEmpty } from "../shared.js";
|
|
2
|
+
const HEADINGS = "h2, h3, h4, h5";
|
|
3
|
+
const SCOPE_LEVELS = 4;
|
|
4
|
+
/** The wrapper of a card and its heading is small; a larger block holds the story, and its headings stay. */
|
|
5
|
+
const SCOPE_TEXT_LIMIT = 400;
|
|
6
|
+
const FOLLOWING = 4;
|
|
7
|
+
/**
|
|
8
|
+
* Some sites put the title of a video or post above its player as a heading that links to a page of their own. Once the player is a
|
|
9
|
+
* card with the same title, the title stands twice. The heading goes when it holds nothing but that title, is at most one link, and sits
|
|
10
|
+
* in the small wrapper the card sits in.
|
|
11
|
+
*/
|
|
12
|
+
export const embedHeadings = {
|
|
13
|
+
name: "embed-headings",
|
|
14
|
+
description: "A heading that only repeats the title of the video or post card right after it is removed.",
|
|
15
|
+
apply(root, stats) {
|
|
16
|
+
let changed = false;
|
|
17
|
+
for (const card of [...root.querySelectorAll("a[data-media]")]) {
|
|
18
|
+
const title = flat(card.textContent).toLowerCase();
|
|
19
|
+
if (title === "")
|
|
20
|
+
continue;
|
|
21
|
+
let scope = card.parentElement;
|
|
22
|
+
for (let level = 0; scope && level < SCOPE_LEVELS && flat(scope.textContent).length <= SCOPE_TEXT_LIMIT; level++, scope = scope.parentElement) {
|
|
23
|
+
const heading = [...scope.querySelectorAll(HEADINGS)].find(candidate => root.contains(candidate) && !candidate.contains(card) && flat(candidate.textContent).toLowerCase() === title &&
|
|
24
|
+
candidate.querySelectorAll("a").length <= 1 && (candidate.compareDocumentPosition(card) & FOLLOWING) !== 0);
|
|
25
|
+
if (!heading)
|
|
26
|
+
continue;
|
|
27
|
+
const parent = heading.parentNode;
|
|
28
|
+
heading.remove();
|
|
29
|
+
pruneEmpty(parent);
|
|
30
|
+
count(stats, "embed-headings");
|
|
31
|
+
changed = true;
|
|
32
|
+
break;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return changed;
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
//# sourceMappingURL=headings.js.map
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { consentEmbeds } from "./consent.js";
|
|
2
|
+
import { mediaElements } from "./media.js";
|
|
2
3
|
import { socialEmbeds } from "./social.js";
|
|
3
4
|
import { videoPlayers } from "./video.js";
|
|
4
5
|
/**
|
|
5
6
|
* Embeds, in the order they must run: consent plugins first, because the address of the embed sits inside their
|
|
6
|
-
* notice, then the video players and iframes, then the social posts.
|
|
7
|
+
* notice, then the video players and iframes, then native audio and video elements, then the social posts.
|
|
7
8
|
*/
|
|
8
|
-
export const EMBED_RULES = [consentEmbeds, videoPlayers, socialEmbeds];
|
|
9
|
+
export const EMBED_RULES = [consentEmbeds, videoPlayers, mediaElements, socialEmbeds];
|
|
9
10
|
/**
|
|
10
11
|
* Turns every embed of a page into the marked link a reader shows as a card. A fetched page must go through this
|
|
11
12
|
* before its scripts, frames and `noscript` blocks are removed: that is where consent plugins and players keep the address.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { count, flat, linkParagraph, pruneEmpty } from "../shared.js";
|
|
2
|
+
/** The address a native `<audio>` or `<video>` plays: its own `src`, or the first `<source>` inside it. Anything but http(s) is no address. */
|
|
3
|
+
function mediaAddress(element) {
|
|
4
|
+
const source = element.getAttribute("src") ?? element.querySelector("source[src]")?.getAttribute("src") ?? "";
|
|
5
|
+
const address = source.trim();
|
|
6
|
+
if (address === "")
|
|
7
|
+
return null;
|
|
8
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(address) && !/^https?:/i.test(address))
|
|
9
|
+
return null;
|
|
10
|
+
return address;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* A native `<audio>` or `<video>` element needs the player of the reader's engine and the publisher's script to do anything useful: without
|
|
14
|
+
* them it shows a bar with an error. What names an address becomes one marked link to the file (`data-media="audio" | "video"`), what does
|
|
15
|
+
* not, because the file is set by a script, is left out.
|
|
16
|
+
*/
|
|
17
|
+
export const mediaElements = {
|
|
18
|
+
name: "media-elements",
|
|
19
|
+
description: "Native audio and video elements become one marked link to their file, or are removed when they name no address.",
|
|
20
|
+
apply(root, stats) {
|
|
21
|
+
const document = root.ownerDocument ?? root;
|
|
22
|
+
let changed = false;
|
|
23
|
+
for (const element of [...root.querySelectorAll("audio, video")]) {
|
|
24
|
+
if (!root.contains(element))
|
|
25
|
+
continue;
|
|
26
|
+
const kind = element.tagName.toLowerCase() === "audio" ? "audio" : "video";
|
|
27
|
+
const address = mediaAddress(element);
|
|
28
|
+
const parent = element.parentNode;
|
|
29
|
+
if (address) {
|
|
30
|
+
const label = flat(element.getAttribute("title")) || flat(element.getAttribute("aria-label")) || (kind === "audio" ? "Audio" : "Video");
|
|
31
|
+
const replacement = linkParagraph(document, address, label);
|
|
32
|
+
replacement.querySelector("a")?.setAttribute("data-media", kind);
|
|
33
|
+
element.replaceWith(replacement);
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
element.remove();
|
|
37
|
+
pruneEmpty(parent);
|
|
38
|
+
}
|
|
39
|
+
count(stats, "media-elements");
|
|
40
|
+
changed = true;
|
|
41
|
+
}
|
|
42
|
+
return changed;
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
//# sourceMappingURL=media.js.map
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { EMBED_RULES, resolveEmbeds } from "./embeds/index.js";
|
|
2
|
+
import { embedHeadings } from "./embeds/headings.js";
|
|
3
|
+
import { boilerplate } from "./noise/boilerplate.js";
|
|
4
|
+
import { loadingPlaceholders } from "./noise/loading.js";
|
|
5
|
+
import { trackingPixels } from "./noise/pixels.js";
|
|
2
6
|
import { sidebars } from "./noise/sidebars.js";
|
|
3
7
|
/**
|
|
4
8
|
* Every rule of the content pipeline, in the order they run on a fetched page: embeds first (their address sits in
|
|
5
9
|
* tags that are removed next), then the clutter around the story. The tests check each rule against its fixtures in
|
|
6
10
|
* `test/fixtures/content/<rule name>` and that running it twice changes nothing. See `docs/content-rules.md`.
|
|
7
11
|
*/
|
|
8
|
-
export const PAGE_RULES = [...EMBED_RULES, sidebars];
|
|
12
|
+
export const PAGE_RULES = [...EMBED_RULES, embedHeadings, sidebars, loadingPlaceholders, trackingPixels, boilerplate];
|
|
9
13
|
export { resolveEmbeds };
|
|
10
14
|
/** What a fetched page goes through before its scripts, frames and `noscript` blocks are removed. */
|
|
11
15
|
export function cleanPage(root, stats = {}) {
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { BOILERPLATE_ANYWHERE, BOILERPLATE_TRAILING } from "../patterns/boilerplate/index.js";
|
|
2
|
+
import { count, flat, pruneEmpty } from "../shared.js";
|
|
3
|
+
const BLOCKS = "p, div, li, h2, h3, h4, h5, h6, span";
|
|
4
|
+
const NESTED = "p, div, li, ul, ol, h2, h3, h4, h5, h6";
|
|
5
|
+
/** Footers can only sit in the second half of the text: before it, the same words are part of the story. */
|
|
6
|
+
const AFTER_SHARE = 0.5;
|
|
7
|
+
/** A list after a removed box ("Follow topics and authors …") that holds names, or nothing at all, belongs to the box. */
|
|
8
|
+
const LIST_ITEM_LIMIT = 60;
|
|
9
|
+
function textBefore(root, node) {
|
|
10
|
+
const range = root.ownerDocument?.createRange() ?? root.createRange();
|
|
11
|
+
range.setStart(root, 0);
|
|
12
|
+
range.setEndBefore(node);
|
|
13
|
+
return flat(range.toString()).length;
|
|
14
|
+
}
|
|
15
|
+
/** The list that directly follows a block, when it only holds short names or empty items. */
|
|
16
|
+
function followingNameList(block) {
|
|
17
|
+
const next = block.nextElementSibling;
|
|
18
|
+
if (!next || !/^(UL|OL)$/.test(next.tagName))
|
|
19
|
+
return null;
|
|
20
|
+
const items = [...next.querySelectorAll("li")];
|
|
21
|
+
return items.length > 0 && items.every(item => flat(item.textContent).length <= LIST_ITEM_LIMIT) ? next : null;
|
|
22
|
+
}
|
|
23
|
+
/** Elements that start a line of their own; a run of text between two `<br>` that holds one of them is not a plain line. */
|
|
24
|
+
const LINE_BREAKING = `${NESTED}, img, picture, video, audio, iframe, table, blockquote, figure`;
|
|
25
|
+
/** The text nodes and inline elements of a container, cut at every `<br>`. */
|
|
26
|
+
function linesOf(container) {
|
|
27
|
+
const lines = [[]];
|
|
28
|
+
container.childNodes.forEach(node => {
|
|
29
|
+
if (node.nodeName === "BR")
|
|
30
|
+
lines.push([]);
|
|
31
|
+
else
|
|
32
|
+
lines[lines.length - 1].push(node);
|
|
33
|
+
});
|
|
34
|
+
return lines;
|
|
35
|
+
}
|
|
36
|
+
function isPlainLine(nodes) {
|
|
37
|
+
return nodes.every(node => node.nodeType === 3 || (node.nodeType === 1 && !node.matches(LINE_BREAKING) && !node.querySelector(LINE_BREAKING)));
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Lines that sites add to their own text: "Follow us on Google", the words of a download button, the file name of a picture shown as text,
|
|
41
|
+
* "This article first appeared on …", the related links under a MacRumors story. A block goes when its whole text is one of these lines
|
|
42
|
+
* (footers only in the second half), together with the list of names that follows a "follow topics" box.
|
|
43
|
+
*/
|
|
44
|
+
export const boilerplate = {
|
|
45
|
+
name: "boilerplate",
|
|
46
|
+
description: "Blocks that only hold a follow-us call, a download button text, an image file name or an \"appeared first on\" footer are removed.",
|
|
47
|
+
apply(root, stats) {
|
|
48
|
+
let changed = false;
|
|
49
|
+
const total = flat(root.textContent).length;
|
|
50
|
+
for (const block of [...root.querySelectorAll(BLOCKS)]) {
|
|
51
|
+
if (!root.contains(block) || block.querySelector("img, picture, video, audio, iframe") || block.querySelector(NESTED))
|
|
52
|
+
continue;
|
|
53
|
+
const text = flat(block.textContent);
|
|
54
|
+
if (text === "" || text.length > 400)
|
|
55
|
+
continue;
|
|
56
|
+
const anywhere = BOILERPLATE_ANYWHERE.some(pattern => pattern.test(text));
|
|
57
|
+
const trailing = !anywhere && total > 0 && textBefore(root, block) / total >= AFTER_SHARE && BOILERPLATE_TRAILING.some(pattern => pattern.test(text));
|
|
58
|
+
if (!anywhere && !trailing)
|
|
59
|
+
continue;
|
|
60
|
+
const parent = block.parentNode;
|
|
61
|
+
followingNameList(block)?.remove();
|
|
62
|
+
block.remove();
|
|
63
|
+
pruneEmpty(parent);
|
|
64
|
+
count(stats, "boilerplate");
|
|
65
|
+
changed = true;
|
|
66
|
+
}
|
|
67
|
+
return lineFooters(root, stats, total) || changed;
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
/**
|
|
71
|
+
* Footers that stand as bare text between `<br>` ("This article … first appeared on …", "Discuss this article in our forums"), not in a block of
|
|
72
|
+
* their own. Each such line is checked like a block; the breaks left at the end of the container go with them.
|
|
73
|
+
*/
|
|
74
|
+
function lineFooters(root, stats, total) {
|
|
75
|
+
let changed = false;
|
|
76
|
+
const containers = [root, ...root.querySelectorAll("div, p, li, td, section, article, main, blockquote")];
|
|
77
|
+
for (const container of containers) {
|
|
78
|
+
if (![...container.childNodes].some(node => node.nodeName === "BR"))
|
|
79
|
+
continue;
|
|
80
|
+
let removed = false;
|
|
81
|
+
for (const line of linesOf(container)) {
|
|
82
|
+
if (line.length === 0 || !isPlainLine(line))
|
|
83
|
+
continue;
|
|
84
|
+
const text = flat(line.map(node => node.textContent ?? "").join(""));
|
|
85
|
+
if (text === "" || text.length > 400)
|
|
86
|
+
continue;
|
|
87
|
+
const anywhere = BOILERPLATE_ANYWHERE.some(pattern => pattern.test(text));
|
|
88
|
+
const trailing = !anywhere && total > 0 && textBefore(root, line[0]) / total >= AFTER_SHARE && BOILERPLATE_TRAILING.some(pattern => pattern.test(text));
|
|
89
|
+
if (!anywhere && !trailing)
|
|
90
|
+
continue;
|
|
91
|
+
const after = line[line.length - 1].nextSibling;
|
|
92
|
+
line.forEach(node => node.parentNode?.removeChild(node));
|
|
93
|
+
if (after?.nodeName === "BR")
|
|
94
|
+
after.parentNode?.removeChild(after);
|
|
95
|
+
count(stats, "boilerplate");
|
|
96
|
+
removed = true;
|
|
97
|
+
}
|
|
98
|
+
if (!removed)
|
|
99
|
+
continue;
|
|
100
|
+
while (container.lastChild && (container.lastChild.nodeName === "BR" || (container.lastChild.nodeType === 3 && flat(container.lastChild.textContent) === "")))
|
|
101
|
+
container.removeChild(container.lastChild);
|
|
102
|
+
changed = true;
|
|
103
|
+
}
|
|
104
|
+
return changed;
|
|
105
|
+
}
|
|
106
|
+
//# sourceMappingURL=boilerplate.js.map
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { LOADING_LABELS } from "../patterns/loading/index.js";
|
|
2
|
+
import { count, flat, pruneEmpty } from "../shared.js";
|
|
3
|
+
/**
|
|
4
|
+
* A script fills these blocks with content ("wird geladen …", "Loading…"). A reader does not run it, so the placeholder would stay in the
|
|
5
|
+
* story as a line of text. Only a block that holds nothing but the label goes: no link, no picture, no other words.
|
|
6
|
+
*/
|
|
7
|
+
export const loadingPlaceholders = {
|
|
8
|
+
name: "loading-placeholders",
|
|
9
|
+
description: "Blocks that hold only a loading label such as \"wird geladen …\" or \"Loading…\" are removed.",
|
|
10
|
+
apply(root, stats) {
|
|
11
|
+
let changed = false;
|
|
12
|
+
for (const block of [...root.querySelectorAll("p, div, span, li")]) {
|
|
13
|
+
if (!root.contains(block) || block.querySelector("a[href], img, picture, video, audio, iframe"))
|
|
14
|
+
continue;
|
|
15
|
+
if (!LOADING_LABELS.some(pattern => pattern.test(flat(block.textContent))))
|
|
16
|
+
continue;
|
|
17
|
+
// The innermost block that holds the label: a wrapper with more blocks inside is looked at on its own.
|
|
18
|
+
if (block.querySelector("p, div, li"))
|
|
19
|
+
continue;
|
|
20
|
+
const parent = block.parentNode;
|
|
21
|
+
block.remove();
|
|
22
|
+
pruneEmpty(parent);
|
|
23
|
+
count(stats, "loading-placeholders");
|
|
24
|
+
changed = true;
|
|
25
|
+
}
|
|
26
|
+
return changed;
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
//# sourceMappingURL=loading.js.map
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { count, pruneEmpty } from "../shared.js";
|
|
2
|
+
const LIMIT = 2;
|
|
3
|
+
function isPixel(image) {
|
|
4
|
+
const width = Number(image.getAttribute("width"));
|
|
5
|
+
const height = Number(image.getAttribute("height"));
|
|
6
|
+
return width > 0 && height > 0 && width <= LIMIT && height <= LIMIT;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Counting pixels (VG Wort, Chartbeat, publisher analytics) arrive as pictures of one by one pixel inside the text. They show nothing, only
|
|
10
|
+
* a gap in some readers, and every client would load them: they go.
|
|
11
|
+
*/
|
|
12
|
+
export const trackingPixels = {
|
|
13
|
+
name: "tracking-pixels",
|
|
14
|
+
description: "Pictures of one or two pixels in each direction, which only count a view, are removed.",
|
|
15
|
+
apply(root, stats) {
|
|
16
|
+
let changed = false;
|
|
17
|
+
for (const image of [...root.querySelectorAll("img")].filter(isPixel)) {
|
|
18
|
+
const parent = image.parentNode;
|
|
19
|
+
image.remove();
|
|
20
|
+
pruneEmpty(parent);
|
|
21
|
+
count(stats, "tracking-pixels");
|
|
22
|
+
changed = true;
|
|
23
|
+
}
|
|
24
|
+
return changed;
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
//# sourceMappingURL=pixels.js.map
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Blocks, in German, that publishers put into their text without being part of the story. A block counts only when its whole text is one
|
|
3
|
+
* of these lines (see `noise/boilerplate.ts`), so a sentence of the story that contains the words stays.
|
|
4
|
+
*/
|
|
5
|
+
export const ANYWHERE = [
|
|
6
|
+
// Aufrufe, der Seite bei Google oder in anderen Diensten zu folgen.
|
|
7
|
+
/^(?:Uns|Folge uns|Folgen Sie uns) (?:auf|bei) Google(?: News| Discover)? folgen$/i,
|
|
8
|
+
/^(?:Wähle|Wählen Sie|Füge|Fügen Sie) [^.!?]{2,50} (?:als )?(?:bevorzugte|bevorzugten|bevorzugter)\b[^.!?]{0,30}$/i,
|
|
9
|
+
// „★ ifun.de bei Google bevorzugen ★“ und ähnliche Bitten, die Seite als bevorzugte Quelle zu wählen.
|
|
10
|
+
/^★?\s*[^★!?]{2,40} bei Google bevorzugen\s*★?$/i,
|
|
11
|
+
// Texte von Download-Schaltflächen, deren Grafik nicht mehr da ist.
|
|
12
|
+
/^(?:Laden im (?:App|Mac App|Google Play) Store|Im (?:App|Mac App) Store laden|Jetzt bei Google Play|Bei Google Play herunterladen|Laden bei Google Play)$/i,
|
|
13
|
+
// Der Dateiname eines Bildes, den ein Link oder eine Beschriftung als Text zeigt.
|
|
14
|
+
/^[\w\-. ]{3,120}\.(?:jpe?g|png|webp|gif|avif)$/i,
|
|
15
|
+
];
|
|
16
|
+
/** Fußzeilen am Ende des Textes; sie zählen nur in der zweiten Hälfte. */
|
|
17
|
+
export const TRAILING = [
|
|
18
|
+
/^(?:Der Beitrag|Dieser Beitrag|Der Artikel|Dieser Artikel) .{3,200} (?:erschien|erschien zuerst|erschien erstmals) (?:zuerst )?(?:auf|bei) .{2,60}$/i,
|
|
19
|
+
/^Der Beitrag .{3,200} wurde (?:zuerst|erstmals) (?:auf|bei) .{2,60} veröffentlicht\.?$/i,
|
|
20
|
+
/^Diskutiere (?:diesen|den) (?:Artikel|Beitrag) .{0,40}$/i,
|
|
21
|
+
];
|
|
22
|
+
//# sourceMappingURL=de.js.map
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Blocks, in English, that publishers put into their text without being part of the story. A block counts only when its whole text is one
|
|
3
|
+
* of these lines (see `noise/boilerplate.ts`), so a sentence of the story that contains the words stays.
|
|
4
|
+
*/
|
|
5
|
+
export const ANYWHERE = [
|
|
6
|
+
// Calls to follow the site on Google or elsewhere.
|
|
7
|
+
/^(?:Follow us on Google(?: News| Discover)?|Add [^.!?]{2,50} as a preferred (?:source|publisher)(?: on Google)?)$/i,
|
|
8
|
+
// The text of download buttons whose picture is gone.
|
|
9
|
+
/^(?:Download on the (?:App|Mac App) Store|Get it on Google Play|Available on Google Play)$/i,
|
|
10
|
+
];
|
|
11
|
+
/** Footers at the end of the text; they count only in the second half. */
|
|
12
|
+
export const TRAILING = [
|
|
13
|
+
/^(?:This (?:article|post)|The post),? .{3,200} (?:first )?appeared (?:first )?on .{2,60}$/i,
|
|
14
|
+
/^Discuss this (?:article|story|post)\b.{0,40}$/i,
|
|
15
|
+
// MacRumors: the related links under every article.
|
|
16
|
+
/^Related Roundups?:.{0,300}$/i,
|
|
17
|
+
/^(?:Tag|Tags|Buyer's Guides?|Related Forums?):.{0,200}$/i,
|
|
18
|
+
// The Verge: the box that offers to follow the story's topics and authors.
|
|
19
|
+
/^Follow topics and authors from this story\b.{0,200}$/i,
|
|
20
|
+
];
|
|
21
|
+
//# sourceMappingURL=en.js.map
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import * as de from "./de.js";
|
|
2
|
+
import * as en from "./en.js";
|
|
3
|
+
/** One file per language, searched all at once. To add a language, add a file next to these and list it here. */
|
|
4
|
+
export const BOILERPLATE_ANYWHERE = [de.ANYWHERE, en.ANYWHERE].flat();
|
|
5
|
+
export const BOILERPLATE_TRAILING = [de.TRAILING, en.TRAILING].flat();
|
|
6
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whole-line placeholders that a script replaces with content (a comment box, a gallery, a player). A reader that does not run the
|
|
3
|
+
* script keeps them as text. Only a block that holds nothing else counts, so a sentence that contains the word stays.
|
|
4
|
+
*/
|
|
5
|
+
export default [
|
|
6
|
+
/^(?:loading|please wait)(?:\s*(?:\.{3}|…))?$/i,
|
|
7
|
+
];
|
|
8
|
+
//# sourceMappingURL=en.js.map
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/** Headings, in German, that start a list of other articles. See `en.ts` for how they are used. */
|
|
2
2
|
export default [
|
|
3
3
|
/^(?:Mehr zum Thema|Weitere Artikel|Weitere Meldungen|Ähnliche Artikel|Verwandte Artikel|Auch interessant|Das könnte (?:dich|Sie|euch) (?:auch )?interessieren|Meistgelesen(?:e Artikel)?|Beliebte Artikel|Lesen Sie auch|Lies auch|Empfehlungen)$/i,
|
|
4
|
+
// Das Willkommen einer Seite („Willkommen bei GamePro!“) mit Logo und Links am Ende des Textes.
|
|
5
|
+
/^Willkommen bei [^.!?]{2,40}!?$/i,
|
|
4
6
|
];
|
|
5
7
|
//# sourceMappingURL=de.js.map
|
|
@@ -4,5 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
export default [
|
|
6
6
|
/^(?:Popular (?:Stories|Articles|Posts)|Most (?:Read|Popular)|Trending(?: Now| Stories)?|Related (?:Articles|Stories|Posts|Reading|Content)|You (?:might|may) also like|More (?:from|stories|articles)\b.*|Read (?:more|next)|Further reading|Top Stories|Recommended(?: for you)?|Latest (?:Stories|News))$/i,
|
|
7
|
+
// A site's welcome ("Welcome to GamePro!") with logo and links at the end of the text.
|
|
8
|
+
/^Welcome to [^.!?]{2,40}!?$/i,
|
|
7
9
|
];
|
|
8
10
|
//# sourceMappingURL=en.js.map
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import Parser from "rss-parser";
|
|
2
2
|
import { fetchFeed } from "./fetcher.js";
|
|
3
|
-
import {
|
|
3
|
+
import { assertPublicFeedUrl } from "./blockedHosts.js";
|
|
4
4
|
import { decodeEntities, plainTitle } from "./text.js";
|
|
5
5
|
const rssParser = new Parser();
|
|
6
6
|
// Common alternate feed types found in HTML <head>
|
|
@@ -74,7 +74,7 @@ async function keepRealFeeds(candidates, pageUrl) {
|
|
|
74
74
|
* or if it is an HTML page that links to one or more feeds (auto-discovery).
|
|
75
75
|
*/
|
|
76
76
|
export async function discoverFeeds(inputUrl) {
|
|
77
|
-
const validated = await
|
|
77
|
+
const validated = await assertPublicFeedUrl(inputUrl);
|
|
78
78
|
const targetUrl = validated.toString();
|
|
79
79
|
// 1. The URL is already a feed.
|
|
80
80
|
const page = await parseFeedAt(targetUrl);
|
|
@@ -2,7 +2,8 @@ import { Readability } from "@mozilla/readability";
|
|
|
2
2
|
import { JSDOM } from "jsdom";
|
|
3
3
|
import { fetch as undiciFetch } from "undici";
|
|
4
4
|
import { cleanPage } from "./content/index.js";
|
|
5
|
-
import {
|
|
5
|
+
import { createPublicDispatcher, SsrfBlockedError } from "./ssrfGuard.js";
|
|
6
|
+
import { assertPublicFeedUrl } from "./blockedHosts.js";
|
|
6
7
|
import { articleWorkPool, ArticleHostBusyError, ArticleAccessDeniedError } from "./articleWorkPool.js";
|
|
7
8
|
import { parseRetryAfter } from "./fetcher.js";
|
|
8
9
|
import { sourceHost } from "./requestGate.js";
|
|
@@ -130,7 +131,7 @@ async function fetchArticleHtml(rawUrl, signal, dispatcher, fetchImpl, ownerSign
|
|
|
130
131
|
let currentUrl = rawUrl;
|
|
131
132
|
for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects++) {
|
|
132
133
|
articleWorkPool().move(ownerSignal, sourceHost(currentUrl));
|
|
133
|
-
const safeUrl = await
|
|
134
|
+
const safeUrl = await assertPublicFeedUrl(currentUrl);
|
|
134
135
|
signal.throwIfAborted();
|
|
135
136
|
const response = await fetchImpl(safeUrl, {
|
|
136
137
|
signal,
|
|
@@ -31,9 +31,13 @@ export function canAccessFeed(userId, feedId) {
|
|
|
31
31
|
OR EXISTS (SELECT 1 FROM items i JOIN item_bookmarks b ON b.item_id = i.id WHERE i.feed_id = f.id AND b.user_id = ?)
|
|
32
32
|
OR EXISTS (SELECT 1 FROM items i JOIN item_notes n ON n.item_id = i.id WHERE i.feed_id = f.id AND n.user_id = ?))`).get(feedId, userId, userId, userId));
|
|
33
33
|
}
|
|
34
|
-
|
|
34
|
+
/**
|
|
35
|
+
* Declared icons first, then the stored one, then the well-known files of the site, and last the feed's own logo (`<image>` of an RSS channel):
|
|
36
|
+
* a channel logo is often a wide or transparent banner and no icon, so it only serves when the site offers nothing better.
|
|
37
|
+
*/
|
|
38
|
+
function candidates(feed, declaredUrls, logoUrls) {
|
|
35
39
|
const urls = new Set(declaredUrls);
|
|
36
|
-
if (feed.icon_url)
|
|
40
|
+
if (feed.icon_url && !logoUrls.includes(feed.icon_url))
|
|
37
41
|
urls.add(feed.icon_url);
|
|
38
42
|
for (const source of [feed.site_url, feed.url]) {
|
|
39
43
|
try {
|
|
@@ -47,6 +51,8 @@ function candidates(feed, declaredUrls) {
|
|
|
47
51
|
// Try the next source.
|
|
48
52
|
}
|
|
49
53
|
}
|
|
54
|
+
for (const url of logoUrls)
|
|
55
|
+
urls.add(url);
|
|
50
56
|
return [...urls];
|
|
51
57
|
}
|
|
52
58
|
/** Rejects 8-bit grayscale+alpha or RGBA PNGs whose alpha channel is 0 across every pixel. */
|
|
@@ -160,7 +166,7 @@ export function storeFeedIcon(feedId, buffer, mime) {
|
|
|
160
166
|
}
|
|
161
167
|
/** Re-check remote candidates during the weekly feed poll, even if a stored icon exists. */
|
|
162
168
|
const refreshing = new Map();
|
|
163
|
-
export function refreshFeedIcon(feedId, siteUrl, declaredUrls = [], signal) {
|
|
169
|
+
export function refreshFeedIcon(feedId, siteUrl, declaredUrls = [], signal, logoUrls = []) {
|
|
164
170
|
const current = refreshing.get(feedId);
|
|
165
171
|
if (current)
|
|
166
172
|
return current;
|
|
@@ -169,16 +175,16 @@ export function refreshFeedIcon(feedId, siteUrl, declaredUrls = [], signal) {
|
|
|
169
175
|
const controller = new AbortController();
|
|
170
176
|
const timeout = setTimeout(() => controller.abort(new DOMException("Icon deadline exceeded", "TimeoutError")), readPollSettings().attemptTimeoutMs);
|
|
171
177
|
const deadline = signal ? AbortSignal.any([controller.signal, signal]) : controller.signal;
|
|
172
|
-
const promise = refreshIcon(feedId, siteUrl, declaredUrls, deadline).finally(() => {
|
|
178
|
+
const promise = refreshIcon(feedId, siteUrl, declaredUrls, deadline, logoUrls).finally(() => {
|
|
173
179
|
clearTimeout(timeout);
|
|
174
180
|
refreshing.delete(feedId);
|
|
175
181
|
});
|
|
176
182
|
refreshing.set(feedId, promise);
|
|
177
183
|
return promise;
|
|
178
184
|
}
|
|
179
|
-
async function refreshIcon(feedId, siteUrl, declaredUrls, signal) {
|
|
185
|
+
async function refreshIcon(feedId, siteUrl, declaredUrls, signal, logoUrls) {
|
|
180
186
|
const feed = db.prepare("SELECT icon_url, site_url, url FROM feeds WHERE id = ?").get(feedId);
|
|
181
|
-
for (const url of feed ? candidates({ ...feed, site_url: siteUrl ?? feed.site_url }, declaredUrls) : []) {
|
|
187
|
+
for (const url of feed ? candidates({ ...feed, site_url: siteUrl ?? feed.site_url }, declaredUrls, logoUrls) : []) {
|
|
182
188
|
if (signal.aborted)
|
|
183
189
|
break;
|
|
184
190
|
try {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { fetch as undiciFetch } from "undici";
|
|
2
|
-
import {
|
|
2
|
+
import { createPublicDispatcher, normalizeUrlCandidate } from "./ssrfGuard.js";
|
|
3
|
+
import { assertPublicFeedUrl } from "./blockedHosts.js";
|
|
3
4
|
import { APP_VERSION } from "../version.js";
|
|
4
5
|
import { feedRequests, sourceHost } from "./requestGate.js";
|
|
5
6
|
import { readPollSettings } from "./pollSettings.js";
|
|
@@ -72,7 +73,7 @@ async function validateUrl(url, signal) {
|
|
|
72
73
|
return new Promise((resolve, reject) => {
|
|
73
74
|
const abort = () => reject(signal.reason);
|
|
74
75
|
signal.addEventListener("abort", abort, { once: true });
|
|
75
|
-
|
|
76
|
+
assertPublicFeedUrl(url).then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
|
|
76
77
|
});
|
|
77
78
|
}
|
|
78
79
|
// Re-validate every redirect hop and bound headers, body, DNS and permit waiting together.
|
|
@@ -4,7 +4,8 @@ import { db } from "../db/index.js";
|
|
|
4
4
|
import { hasUserCapability } from "../auth/capabilities.js";
|
|
5
5
|
import { configureFullTextPool, deferFullTextHost } from "./fullTextQueue.js";
|
|
6
6
|
import { extractArticle } from "./extractor.js";
|
|
7
|
-
import {
|
|
7
|
+
import { normalizeUrlCandidate } from "./ssrfGuard.js";
|
|
8
|
+
import { assertPublicFeedUrl } from "./blockedHosts.js";
|
|
8
9
|
import { articleImageUrls, pruneArchive, scheduleArchive } from "./archive.js";
|
|
9
10
|
import { findFeedById, findFeedByUrl, isUserSubscribed, findSubscriptionForUser, subscribe, bookmarkItem, findItemById, findItemForUser, setItemNote, } from "./repository.js";
|
|
10
11
|
function escapeHtml(str) {
|
|
@@ -47,7 +48,7 @@ export async function saveToInbox(userId, input) {
|
|
|
47
48
|
let imageUrl = null;
|
|
48
49
|
if (input.url?.trim()) {
|
|
49
50
|
const normalized = normalizeUrlCandidate(input.url.trim());
|
|
50
|
-
await
|
|
51
|
+
await assertPublicFeedUrl(normalized);
|
|
51
52
|
targetUrl = normalized;
|
|
52
53
|
if (!contentHtml && hasUserCapability(userId, "fulltext")) {
|
|
53
54
|
try {
|
|
@@ -2,7 +2,8 @@ import { XMLParser } from "fast-xml-parser";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { config } from "../config.js";
|
|
4
4
|
import { listSubscriptionsForUser, findFeedByUrl, createFeed, isUserSubscribed, subscribe, createFolder, } from "./repository.js";
|
|
5
|
-
import {
|
|
5
|
+
import { SsrfBlockedError } from "./ssrfGuard.js";
|
|
6
|
+
import { assertPublicFeedUrl } from "./blockedHosts.js";
|
|
6
7
|
import { requestFeedPoll } from "./poller.js";
|
|
7
8
|
import { decodeEntities } from "./text.js";
|
|
8
9
|
function escapeXml(unsafe) {
|
|
@@ -146,7 +147,7 @@ export async function importOpmlFeeds(userId, xmlContent) {
|
|
|
146
147
|
}
|
|
147
148
|
let normalizedUrl;
|
|
148
149
|
try {
|
|
149
|
-
const validated = await
|
|
150
|
+
const validated = await assertPublicFeedUrl(item.url);
|
|
150
151
|
normalizedUrl = validated.toString();
|
|
151
152
|
}
|
|
152
153
|
catch (error) {
|
|
@@ -3,6 +3,7 @@ import { setImmediate as yieldTurn } from "node:timers/promises";
|
|
|
3
3
|
import { db } from "../db/index.js";
|
|
4
4
|
import { findFeedById, updateFeedAfterPoll } from "./repository.js";
|
|
5
5
|
import { FeedHttpError } from "./fetcher.js";
|
|
6
|
+
import { FEED_BLOCKED_ERROR, FeedBlockedError } from "./blockedHosts.js";
|
|
6
7
|
import { feedRequests, PollDeferredError, PollShutdownError, RequestQueueFullError, sourceHost } from "./requestGate.js";
|
|
7
8
|
import { readPollSettings } from "./pollSettings.js";
|
|
8
9
|
import { pushOutboxMetrics } from "../pushOutbox.js";
|
|
@@ -17,6 +18,8 @@ function isTransient(error) {
|
|
|
17
18
|
: error instanceof TypeError || error instanceof RequestQueueFullError || (error instanceof Error && ["TimeoutError", "AbortError"].includes(error.name));
|
|
18
19
|
}
|
|
19
20
|
export function pollErrorCode(error) {
|
|
21
|
+
if (error instanceof FeedBlockedError)
|
|
22
|
+
return FEED_BLOCKED_ERROR;
|
|
20
23
|
if (error instanceof FeedHttpError)
|
|
21
24
|
return `feed_http_${error.status}`;
|
|
22
25
|
if (error instanceof Error && ["TimeoutError", "AbortError"].includes(error.name))
|
|
@@ -34,24 +34,26 @@ const parser = new Parser({
|
|
|
34
34
|
],
|
|
35
35
|
},
|
|
36
36
|
});
|
|
37
|
+
/** The feed's own addresses: `icon` is meant as an icon, the `image` of an RSS channel is its logo and ranks below the site's icon files. */
|
|
37
38
|
function extractFeedIconUrls(parsed, baseUrl) {
|
|
39
|
+
const result = { icons: [], logos: [] };
|
|
38
40
|
if (!parsed)
|
|
39
|
-
return
|
|
40
|
-
const
|
|
41
|
-
for (const raw of [parsed.icon, parsed.image?.url]) {
|
|
41
|
+
return result;
|
|
42
|
+
const read = (raw, into) => {
|
|
42
43
|
if (typeof raw !== "string" || !raw.trim())
|
|
43
|
-
|
|
44
|
+
return;
|
|
44
45
|
try {
|
|
45
46
|
const resolved = new URL(raw.trim(), baseUrl);
|
|
46
|
-
if (resolved.protocol === "https:" || resolved.protocol === "http:")
|
|
47
|
-
|
|
48
|
-
}
|
|
47
|
+
if (resolved.protocol === "https:" || resolved.protocol === "http:")
|
|
48
|
+
into.push(resolved.toString());
|
|
49
49
|
}
|
|
50
50
|
catch {
|
|
51
51
|
// Ignore malformed feed icon URLs.
|
|
52
52
|
}
|
|
53
|
-
}
|
|
54
|
-
|
|
53
|
+
};
|
|
54
|
+
read(parsed.icon, result.icons);
|
|
55
|
+
read(parsed.image?.url, result.logos);
|
|
56
|
+
return result;
|
|
55
57
|
}
|
|
56
58
|
function cleanImageUrl(url) {
|
|
57
59
|
return url.trim().replace(/&/g, "&").replace(/&/g, "&");
|
|
@@ -157,7 +159,7 @@ async function pollAttempt(feed, signal) {
|
|
|
157
159
|
const htmlUrls = siteUrl ? await discoverIconUrls(siteUrl, signal) : [];
|
|
158
160
|
const feedUrls = extractFeedIconUrls(parsed, siteUrl || feed.url);
|
|
159
161
|
signal.throwIfAborted();
|
|
160
|
-
await refreshFeedIcon(feed.id, siteUrl, [...htmlUrls, ...feedUrls], signal);
|
|
162
|
+
await refreshFeedIcon(feed.id, siteUrl, [...htmlUrls, ...feedUrls.icons], signal, feedUrls.logos);
|
|
161
163
|
}
|
|
162
164
|
}
|
|
163
165
|
catch { /* Keep the successful poll; icon failures are best effort. */ }
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { config } from "../config.js";
|
|
2
2
|
import { createFeed, findFeedByUrl, isUserSubscribed, subscribe, unsubscribe as unsubscribeRepo, findSubscriptionForUser, updateSubscriptionLabel, updateSubscriptionFolder, updateFeedPollInterval, updateSubscriptionFullTextMode, updateSubscriptionNotify, updateSubscriptionBadge, findFolderById, findFeedById, countFeedSubscribers, replaceFeedUrl, moveSubscription, } from "./repository.js";
|
|
3
3
|
import { pollFeed, probeFeed } from "./poller.js";
|
|
4
|
-
import {
|
|
4
|
+
import { assertPublicFeedUrl } from "./blockedHosts.js";
|
|
5
5
|
import { discoverFeeds } from "./discovery.js";
|
|
6
6
|
export class FeedError extends Error {
|
|
7
7
|
}
|
|
@@ -21,7 +21,7 @@ export class MultipleFeedsFoundError extends FeedError {
|
|
|
21
21
|
}
|
|
22
22
|
/** Turn a feed or website URL into a feed URL, using auto-discovery for website addresses. */
|
|
23
23
|
async function resolveFeedUrl(inputUrl) {
|
|
24
|
-
const validated = await
|
|
24
|
+
const validated = await assertPublicFeedUrl(inputUrl.trim());
|
|
25
25
|
const targetUrl = validated.toString();
|
|
26
26
|
// A known shared feed needs no second discovery download beside its queued poll.
|
|
27
27
|
if (findFeedByUrl(targetUrl))
|
package/server/dist/server.js
CHANGED
|
@@ -20,12 +20,16 @@ import { repairEncodedText } from "./feeds/repository.js";
|
|
|
20
20
|
import { pruneArchive, scheduleMissingArchives } from "./feeds/archive.js";
|
|
21
21
|
import { startNativeRequestCleanup } from "./native/requests.js";
|
|
22
22
|
import { startLeadBackfill } from "./feeds/lead.js";
|
|
23
|
+
import { purgeBlockedFeeds } from "./feeds/blockedFeeds.js";
|
|
23
24
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
24
25
|
const WEB_DIST = join(__dirname, "../../web/dist");
|
|
25
26
|
runMigrations();
|
|
26
27
|
const repairedItems = repairEncodedText();
|
|
27
28
|
if (repairedItems > 0)
|
|
28
29
|
console.log(`[db] decoded HTML entities in ${repairedItems} stored items`);
|
|
30
|
+
const blockedFeeds = purgeBlockedFeeds();
|
|
31
|
+
if (blockedFeeds.feeds > 0)
|
|
32
|
+
console.log(`[feeds] ${blockedFeeds.feeds} feeds are on the block list; removed ${blockedFeeds.items} of their articles`);
|
|
29
33
|
startLeadBackfill();
|
|
30
34
|
pruneArchive();
|
|
31
35
|
const pendingArchives = scheduleMissingArchives();
|