feedkeeper 0.31.0 → 0.32.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "feedkeeper",
3
- "version": "0.31.0",
3
+ "version": "0.32.0",
4
4
  "description": "A lightweight, self-hosted RSS reader and remote MCP server for your news, blogs, and AI workflows.",
5
5
  "keywords": [
6
6
  "rss",
@@ -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({ error: "blocked_url", message: error.message });
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({ error: "blocked_url", message: error.message });
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({ error: "blocked_url", message: error.message });
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({ error: "blocked_url", message: error.message });
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({ error: "blocked_url", message: error.message });
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
@@ -1,6 +1,6 @@
1
1
  import Parser from "rss-parser";
2
2
  import { fetchFeed } from "./fetcher.js";
3
- import { assertPublicHttpUrl } from "./ssrfGuard.js";
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 assertPublicHttpUrl(inputUrl);
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 { assertPublicHttpUrl, createPublicDispatcher, SsrfBlockedError } from "./ssrfGuard.js";
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 assertPublicHttpUrl(currentUrl);
134
+ const safeUrl = await assertPublicFeedUrl(currentUrl);
134
135
  signal.throwIfAborted();
135
136
  const response = await fetchImpl(safeUrl, {
136
137
  signal,
@@ -1,5 +1,6 @@
1
1
  import { fetch as undiciFetch } from "undici";
2
- import { assertPublicHttpUrl, createPublicDispatcher, normalizeUrlCandidate } from "./ssrfGuard.js";
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
- assertPublicHttpUrl(url).then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
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 { assertPublicHttpUrl, normalizeUrlCandidate } from "./ssrfGuard.js";
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 assertPublicHttpUrl(normalized);
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 { assertPublicHttpUrl, SsrfBlockedError } from "./ssrfGuard.js";
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 assertPublicHttpUrl(item.url);
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))
@@ -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 { assertPublicHttpUrl } from "./ssrfGuard.js";
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 assertPublicHttpUrl(inputUrl.trim());
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))
@@ -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();
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feedkeeper/server",
3
- "version": "0.31.0",
3
+ "version": "0.32.0",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }