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 +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/discovery.js +2 -2
- package/server/dist/feeds/extractor.js +3 -2
- 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/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
|
|
@@ -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,
|
|
@@ -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))
|
|
@@ -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();
|