feedkeeper 0.0.0-stage → 0.23.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.
Files changed (137) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +22 -2
  3. package/package.json +57 -4
  4. package/server/dist/api/archive.js +19 -0
  5. package/server/dist/api/auth.js +162 -0
  6. package/server/dist/api/feeds.js +207 -0
  7. package/server/dist/api/filters.js +32 -0
  8. package/server/dist/api/folders.js +53 -0
  9. package/server/dist/api/index.js +37 -0
  10. package/server/dist/api/items.js +129 -0
  11. package/server/dist/api/oauth.js +14 -0
  12. package/server/dist/api/onboarding.js +30 -0
  13. package/server/dist/api/push.js +51 -0
  14. package/server/dist/api/system.js +42 -0
  15. package/server/dist/api/tokens.js +28 -0
  16. package/server/dist/api/v1/index.js +178 -0
  17. package/server/dist/api/v1/resources.js +653 -0
  18. package/server/dist/api/v1/serialize.js +48 -0
  19. package/server/dist/app.js +100 -0
  20. package/server/dist/auth/avatar.js +13 -0
  21. package/server/dist/auth/capabilities.js +53 -0
  22. package/server/dist/auth/middleware.js +77 -0
  23. package/server/dist/auth/pairing.js +36 -0
  24. package/server/dist/auth/password.js +9 -0
  25. package/server/dist/auth/plan.js +58 -0
  26. package/server/dist/auth/security.js +117 -0
  27. package/server/dist/auth/securityRoutes.js +249 -0
  28. package/server/dist/auth/tokens.js +64 -0
  29. package/server/dist/auth/users.js +54 -0
  30. package/server/dist/backup-cli.js +101 -0
  31. package/server/dist/cli.js +135 -0
  32. package/server/dist/clientAddress.js +26 -0
  33. package/server/dist/config.js +51 -0
  34. package/server/dist/db/index.js +40 -0
  35. package/server/dist/db/migrations/0001_init.sql +70 -0
  36. package/server/dist/db/migrations/0002_feed_health.sql +5 -0
  37. package/server/dist/db/migrations/0003_retention.sql +13 -0
  38. package/server/dist/db/migrations/0004_bookmarks_and_filters.sql +20 -0
  39. package/server/dist/db/migrations/0005_folders.sql +15 -0
  40. package/server/dist/db/migrations/0006_feed_position.sql +5 -0
  41. package/server/dist/db/migrations/0007_item_image_url.sql +2 -0
  42. package/server/dist/db/migrations/0008_item_content_html.sql +2 -0
  43. package/server/dist/db/migrations/0009_item_full_content_html.sql +1 -0
  44. package/server/dist/db/migrations/0010_token_scope.sql +3 -0
  45. package/server/dist/db/migrations/0011_user_avatars.sql +8 -0
  46. package/server/dist/db/migrations/0012_full_text_settings.sql +5 -0
  47. package/server/dist/db/migrations/0013_feed_icons.sql +4 -0
  48. package/server/dist/db/migrations/0014_bookmark_archive.sql +16 -0
  49. package/server/dist/db/migrations/0015_push.sql +13 -0
  50. package/server/dist/db/migrations/0016_feed_badge.sql +2 -0
  51. package/server/dist/db/migrations/0017_devices.sql +13 -0
  52. package/server/dist/db/migrations/0018_change_log.sql +166 -0
  53. package/server/dist/db/migrations/0019_native_extensions.sql +111 -0
  54. package/server/dist/db/migrations/0020_fts.sql +32 -0
  55. package/server/dist/db/migrations/0021_note_revisions.sql +33 -0
  56. package/server/dist/db/migrations/0022_high_resolution_icons.sql +2 -0
  57. package/server/dist/db/migrations/0023_server_editions.sql +81 -0
  58. package/server/dist/db/migrations/0024_inbox.sql +5 -0
  59. package/server/dist/db/migrations/0025_edition_overview.sql +2 -0
  60. package/server/dist/db/migrations/0026_article_content.sql +43 -0
  61. package/server/dist/db/migrations/0027_oauth.sql +38 -0
  62. package/server/dist/db/migrations/0028_user_intro.sql +2 -0
  63. package/server/dist/db/migrations/0029_account_security.sql +35 -0
  64. package/server/dist/db/migrations/0030_feed_poll_jobs.sql +15 -0
  65. package/server/dist/db/migrations/0031_push_outbox.sql +19 -0
  66. package/server/dist/feedkeeper.js +38 -0
  67. package/server/dist/feeds/archive.js +334 -0
  68. package/server/dist/feeds/articleContent.js +26 -0
  69. package/server/dist/feeds/cleanup.js +179 -0
  70. package/server/dist/feeds/discovery.js +153 -0
  71. package/server/dist/feeds/extractor.js +208 -0
  72. package/server/dist/feeds/feedIcon.js +171 -0
  73. package/server/dist/feeds/fetcher.js +217 -0
  74. package/server/dist/feeds/fullText.js +91 -0
  75. package/server/dist/feeds/fullTextQueue.js +159 -0
  76. package/server/dist/feeds/icon.js +97 -0
  77. package/server/dist/feeds/imageCache.js +266 -0
  78. package/server/dist/feeds/imageCacheSettings.js +19 -0
  79. package/server/dist/feeds/imageTypes.js +16 -0
  80. package/server/dist/feeds/inbox.js +128 -0
  81. package/server/dist/feeds/opml.js +190 -0
  82. package/server/dist/feeds/pollQueue.js +301 -0
  83. package/server/dist/feeds/pollSettings.js +23 -0
  84. package/server/dist/feeds/poller.js +222 -0
  85. package/server/dist/feeds/repository.js +674 -0
  86. package/server/dist/feeds/requestGate.js +99 -0
  87. package/server/dist/feeds/service.js +162 -0
  88. package/server/dist/feeds/ssrfGuard.js +98 -0
  89. package/server/dist/feeds/text.js +35 -0
  90. package/server/dist/index.js +15 -0
  91. package/server/dist/mcp/digest.js +73 -0
  92. package/server/dist/mcp/http.js +46 -0
  93. package/server/dist/mcp/items.js +73 -0
  94. package/server/dist/mcp/promptsAndResources.js +43 -0
  95. package/server/dist/mcp/server.js +547 -0
  96. package/server/dist/native/editionSelection.js +127 -0
  97. package/server/dist/native/editionText.js +15 -0
  98. package/server/dist/native/editions.js +218 -0
  99. package/server/dist/native/preferences.js +66 -0
  100. package/server/dist/native/requests.js +35 -0
  101. package/server/dist/oauth/constants.js +6 -0
  102. package/server/dist/oauth/host.js +8 -0
  103. package/server/dist/oauth/pages.js +148 -0
  104. package/server/dist/oauth/routes.js +361 -0
  105. package/server/dist/oauth/store.js +104 -0
  106. package/server/dist/push.js +147 -0
  107. package/server/dist/pushOutbox.js +164 -0
  108. package/server/dist/security-recover-cli.js +29 -0
  109. package/server/dist/server.js +108 -0
  110. package/server/dist/setup-cli.js +97 -0
  111. package/server/dist/sync/changeLog.js +170 -0
  112. package/server/dist/sync/mutations.js +144 -0
  113. package/server/dist/trustProxy.js +14 -0
  114. package/server/dist/version.js +6 -0
  115. package/server/package.json +6 -0
  116. package/web/dist/app-icon-180.png +0 -0
  117. package/web/dist/app-icon-192.png +0 -0
  118. package/web/dist/app-icon-512.png +0 -0
  119. package/web/dist/assets/index-B4e2oqbo.css +2 -0
  120. package/web/dist/assets/index-xx6TyOwB.js +32 -0
  121. package/web/dist/assets/inter-latin-400-normal-C38fXH4l.woff2 +0 -0
  122. package/web/dist/assets/inter-latin-400-normal-CyCys3Eg.woff +0 -0
  123. package/web/dist/assets/inter-latin-500-normal-BL9OpVg8.woff +0 -0
  124. package/web/dist/assets/inter-latin-500-normal-Cerq10X2.woff2 +0 -0
  125. package/web/dist/assets/inter-latin-600-normal-CiBQ2DWP.woff +0 -0
  126. package/web/dist/assets/inter-latin-600-normal-LgqL8muc.woff2 +0 -0
  127. package/web/dist/assets/manrope-latin-600-normal-4f0koTD-.woff2 +0 -0
  128. package/web/dist/assets/manrope-latin-600-normal-BqgrALkZ.woff +0 -0
  129. package/web/dist/assets/manrope-latin-700-normal-BZp_XxE4.woff2 +0 -0
  130. package/web/dist/assets/manrope-latin-700-normal-DGRFkw-m.woff +0 -0
  131. package/web/dist/favicon-32.png +0 -0
  132. package/web/dist/index.html +40 -0
  133. package/web/dist/logo.svg +50 -0
  134. package/web/dist/manifest.webmanifest +64 -0
  135. package/web/dist/robots.txt +3 -0
  136. package/web/dist/sw.js +198 -0
  137. package/web/dist/theme-init.js +10 -0
@@ -0,0 +1,171 @@
1
+ import crypto from "node:crypto";
2
+ import { Resvg } from "@resvg/resvg-js";
3
+ import { extractLargestImageAsPng } from "@humanwhocodes/ico-to-png";
4
+ import { db } from "../db/index.js";
5
+ import { fetchImage } from "./fetcher.js";
6
+ import { readPollSettings } from "./pollSettings.js";
7
+ import { detectImageType } from "./archive.js";
8
+ const MAX_ICON_BYTES = 1024 * 1024;
9
+ const TTL_MS = 24 * 60 * 60 * 1000;
10
+ const MAX_CACHED = 500;
11
+ const cache = new Map();
12
+ /** Compute stable content hash for versioning and caching. */
13
+ export function hashIconBuffer(buffer) {
14
+ return crypto.createHash("sha256").update(buffer).digest("hex").slice(0, 16);
15
+ }
16
+ /** Icons are small, so besides raster images the formats typical for favicons are accepted. */
17
+ export function detectIconType(buffer) {
18
+ const raster = detectImageType(buffer);
19
+ if (raster)
20
+ return raster.mime;
21
+ if (buffer.length >= 4 && buffer[0] === 0 && buffer[1] === 0 && buffer[2] === 1 && buffer[3] === 0)
22
+ return "image/x-icon";
23
+ const head = Buffer.from(buffer.subarray(0, 512)).toString("utf8").trimStart();
24
+ return /^(<\?xml[^>]*>\s*)?(<!--[\s\S]*?-->\s*)?<svg[\s>]/i.test(head) ? "image/svg+xml" : null;
25
+ }
26
+ /** Whether the user subscribes to the feed or has saved or annotated one of its articles. */
27
+ export function canAccessFeed(userId, feedId) {
28
+ return Boolean(db.prepare(`SELECT 1 FROM feeds f WHERE f.id = ?
29
+ AND (EXISTS (SELECT 1 FROM subscriptions s WHERE s.feed_id = f.id AND s.user_id = ?)
30
+ 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 = ?)
31
+ 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));
32
+ }
33
+ function candidates(feed, declaredUrls) {
34
+ const urls = new Set(declaredUrls);
35
+ if (feed.icon_url)
36
+ urls.add(feed.icon_url);
37
+ for (const source of [feed.site_url, feed.url]) {
38
+ try {
39
+ if (source)
40
+ urls.add(`${new URL(source).origin}/favicon.ico`);
41
+ }
42
+ catch {
43
+ // Try the next source.
44
+ }
45
+ }
46
+ return [...urls];
47
+ }
48
+ /** Keep the same raster icon in every client, including clients without SVG or ICO support. */
49
+ function rasterIcon(buffer, mime) {
50
+ if (mime === "image/svg+xml") {
51
+ const svg = new Resvg(buffer, { fitTo: { mode: "width", value: 512 }, font: { loadSystemFonts: false } });
52
+ if (svg.width > 4096 || svg.height > 4096 || svg.width < 1 || svg.height < 1 ||
53
+ svg.width / svg.height > 8 || svg.height / svg.width > 8)
54
+ throw new Error("Invalid SVG icon size");
55
+ return { buffer: svg.render().asPng(), mime: "image/png" };
56
+ }
57
+ if (mime === "image/x-icon") {
58
+ const image = extractLargestImageAsPng(buffer);
59
+ if (!image)
60
+ throw new Error("Invalid ICO icon");
61
+ return { buffer: Buffer.from(image.data), mime: "image/png" };
62
+ }
63
+ return { buffer, mime };
64
+ }
65
+ /** Store or update feed icon in database, only notifying subscribers when its content changes. */
66
+ export function storeFeedIcon(feedId, buffer, mime) {
67
+ ({ buffer, mime } = rasterIcon(buffer, mime));
68
+ const hash = hashIconBuffer(buffer);
69
+ db.prepare(`INSERT INTO feed_icons (feed_id, data, mime, hash, updated_at)
70
+ VALUES (?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
71
+ ON CONFLICT (feed_id) DO UPDATE SET data = excluded.data, mime = excluded.mime, hash = excluded.hash, updated_at = excluded.updated_at
72
+ WHERE feed_icons.hash != excluded.hash OR feed_icons.mime != excluded.mime`).run(feedId, buffer, mime, hash);
73
+ const icon = { buffer, mime, hash };
74
+ if (cache.size >= MAX_CACHED)
75
+ cache.delete(cache.keys().next().value);
76
+ cache.set(feedId, { icon, fetchedAt: Date.now() });
77
+ return icon;
78
+ }
79
+ /** Re-check remote candidates during the weekly feed poll, even if a stored icon exists. */
80
+ const refreshing = new Map();
81
+ export function refreshFeedIcon(feedId, siteUrl, declaredUrls = [], signal) {
82
+ const current = refreshing.get(feedId);
83
+ if (current)
84
+ return current;
85
+ if (refreshing.size >= readPollSettings().maxWaiters)
86
+ return Promise.resolve(null);
87
+ const controller = new AbortController();
88
+ const timeout = setTimeout(() => controller.abort(new DOMException("Icon deadline exceeded", "TimeoutError")), readPollSettings().attemptTimeoutMs);
89
+ const deadline = signal ? AbortSignal.any([controller.signal, signal]) : controller.signal;
90
+ const promise = refreshIcon(feedId, siteUrl, declaredUrls, deadline).finally(() => {
91
+ clearTimeout(timeout);
92
+ refreshing.delete(feedId);
93
+ });
94
+ refreshing.set(feedId, promise);
95
+ return promise;
96
+ }
97
+ async function refreshIcon(feedId, siteUrl, declaredUrls, signal) {
98
+ const feed = db.prepare("SELECT icon_url, site_url, url FROM feeds WHERE id = ?").get(feedId);
99
+ for (const url of feed ? candidates({ ...feed, site_url: siteUrl ?? feed.site_url }, declaredUrls) : []) {
100
+ if (signal.aborted)
101
+ break;
102
+ try {
103
+ const { buffer } = await fetchImage(url, MAX_ICON_BYTES, signal);
104
+ const mime = detectIconType(buffer);
105
+ if (mime) {
106
+ const icon = storeFeedIcon(feedId, buffer, mime);
107
+ db.prepare("UPDATE feeds SET icon_url = ? WHERE id = ?").run(url, feedId);
108
+ return icon;
109
+ }
110
+ }
111
+ catch {
112
+ // Keep the last good icon and try the next candidate.
113
+ }
114
+ }
115
+ return null;
116
+ }
117
+ /** Retrieve icon hash from database without reading full blob. */
118
+ export function getFeedIconHash(feedId) {
119
+ const row = db.prepare("SELECT hash FROM feed_icons WHERE feed_id = ?").get(feedId);
120
+ return row?.hash ?? null;
121
+ }
122
+ /**
123
+ * The feed's icon, fetched by this server so the browser and native apps can keep it for offline use
124
+ * without asking third-party sites. Stored persistently in the database and cached in memory.
125
+ */
126
+ export async function loadFeedIcon(feedId) {
127
+ const known = cache.get(feedId);
128
+ if (known && Date.now() - known.fetchedAt < TTL_MS)
129
+ return known.icon;
130
+ // Check persistent storage
131
+ const row = db.prepare("SELECT data, mime, hash FROM feed_icons WHERE feed_id = ?").get(feedId);
132
+ if (row) {
133
+ // Icons stored by older versions may still be SVG or ICO.
134
+ let icon;
135
+ try {
136
+ icon = row.mime === "image/svg+xml" || row.mime === "image/x-icon"
137
+ ? storeFeedIcon(feedId, row.data, row.mime)
138
+ : { buffer: row.data, mime: row.mime, hash: row.hash };
139
+ }
140
+ catch {
141
+ return refreshFeedIcon(feedId);
142
+ }
143
+ if (cache.size >= MAX_CACHED)
144
+ cache.delete(cache.keys().next().value);
145
+ cache.set(feedId, { icon, fetchedAt: Date.now() });
146
+ return icon;
147
+ }
148
+ // Capacity pressure is temporary, not evidence that the publisher has no icon.
149
+ // Do not poison the 24-hour negative cache when another refresh can try shortly.
150
+ if (!refreshing.has(feedId) && refreshing.size >= readPollSettings().maxWaiters)
151
+ return null;
152
+ const icon = await refreshFeedIcon(feedId);
153
+ if (!icon) {
154
+ if (cache.size >= MAX_CACHED)
155
+ cache.delete(cache.keys().next().value);
156
+ cache.set(feedId, { icon: null, fetchedAt: Date.now() });
157
+ }
158
+ return icon;
159
+ }
160
+ /** Forget remembered icons (tests). */
161
+ export function clearIconCache(feedId) {
162
+ if (feedId !== undefined) {
163
+ cache.delete(feedId);
164
+ db.prepare("DELETE FROM feed_icons WHERE feed_id = ?").run(feedId);
165
+ }
166
+ else {
167
+ cache.clear();
168
+ db.prepare("DELETE FROM feed_icons").run();
169
+ }
170
+ }
171
+ //# sourceMappingURL=feedIcon.js.map
@@ -0,0 +1,217 @@
1
+ import { fetch as undiciFetch } from "undici";
2
+ import { assertPublicHttpUrl, createPublicDispatcher, normalizeUrlCandidate } from "./ssrfGuard.js";
3
+ import { APP_VERSION } from "../version.js";
4
+ import { feedRequests, sourceHost } from "./requestGate.js";
5
+ import { readPollSettings } from "./pollSettings.js";
6
+ const MAX_RESPONSE_BYTES = 10 * 1024 * 1024; // 10 MB
7
+ const MAX_REDIRECTS = 5;
8
+ const USER_AGENT = `FeedKeeper/${APP_VERSION} (+https://github.com/visualfusion/feedkeeper)`;
9
+ function cacheLifetime(headers) {
10
+ const control = headers.get("cache-control") ?? "";
11
+ if (/(?:^|,)\s*(?:no-store|no-cache|private)\b/i.test(control))
12
+ return 0;
13
+ const maxAge = /(?:^|,)\s*max-age\s*=\s*"?(\d+)"?/i.exec(control);
14
+ const age = Number(headers.get("age") ?? 0);
15
+ const date = Date.parse(headers.get("date") ?? "");
16
+ const elapsed = Math.max(0, Number.isFinite(age) ? age * 1_000 : 0, Number.isFinite(date) ? Date.now() - date : 0);
17
+ if (maxAge)
18
+ return Math.max(0, Number(maxAge[1]) * 1_000 - elapsed);
19
+ const expires = Date.parse(headers.get("expires") ?? "");
20
+ return Number.isFinite(expires) ? Math.max(0, expires - Date.now()) : undefined;
21
+ }
22
+ export class FeedHttpError extends Error {
23
+ status;
24
+ retryAt;
25
+ host;
26
+ constructor(status, retryAt, host) {
27
+ super(`Feed responded with HTTP ${status}`);
28
+ this.status = status;
29
+ this.retryAt = retryAt;
30
+ this.host = host;
31
+ }
32
+ }
33
+ /** RFC 9110: non-negative integer seconds or an HTTP date (including obsolete HTTP dates). */
34
+ export function parseRetryAfter(value, now = Date.now()) {
35
+ if (value === null)
36
+ return undefined;
37
+ const text = value.trim();
38
+ if (/^\d+$/.test(text)) {
39
+ const seconds = Number(text);
40
+ if (!Number.isFinite(seconds))
41
+ return undefined;
42
+ return Math.min(8.64e15, now + seconds * 1000);
43
+ }
44
+ const day = "(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)";
45
+ const month = "(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)";
46
+ const time = "\\d{2}:\\d{2}:\\d{2}";
47
+ const imf = new RegExp(`^${day}, \\d{2} ${month} \\d{4} ${time} GMT$`);
48
+ const obsolete = text.match(new RegExp(`^(Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), (\\d{2})-(${month})-(\\d{2}) (${time}) GMT$`));
49
+ const asctime = text.match(new RegExp(`^(${day}) (${month}) ( [1-9]|\\d{2}) (${time}) (\\d{4})$`));
50
+ let normalized = text;
51
+ if (obsolete) {
52
+ const yearLimit = new Date(now).getUTCFullYear() + 50;
53
+ let year = Math.floor(yearLimit / 100) * 100 + Number(obsolete[4]);
54
+ if (year > yearLimit)
55
+ year -= 100;
56
+ normalized = `${obsolete[1].slice(0, 3)}, ${obsolete[2]} ${obsolete[3]} ${year} ${obsolete[5]} GMT`;
57
+ }
58
+ else if (asctime) {
59
+ // asctime has no zone suffix; HTTP dates are always UTC, never the server's local time.
60
+ normalized = `${asctime[1]}, ${asctime[3].trim().padStart(2, "0")} ${asctime[2]} ${asctime[5]} ${asctime[4]} GMT`;
61
+ }
62
+ else if (!imf.test(text))
63
+ return undefined;
64
+ const date = Date.parse(normalized);
65
+ if (!Number.isFinite(date) || new Date(date).toUTCString() !== normalized)
66
+ return undefined;
67
+ return Math.max(now, date);
68
+ }
69
+ /** DNS preflight cannot be cancelled, but must not hold the caller past its deadline. */
70
+ async function validateUrl(url, signal) {
71
+ signal.throwIfAborted();
72
+ return new Promise((resolve, reject) => {
73
+ const abort = () => reject(signal.reason);
74
+ signal.addEventListener("abort", abort, { once: true });
75
+ assertPublicHttpUrl(url).then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
76
+ });
77
+ }
78
+ // Re-validate every redirect hop and bound headers, body, DNS and permit waiting together.
79
+ async function downloadPublic(url, opts) {
80
+ let currentUrl = url;
81
+ const controller = new AbortController();
82
+ const timeout = setTimeout(() => controller.abort(new DOMException("Download deadline exceeded", "TimeoutError")), readPollSettings().requestTimeoutMs);
83
+ const signal = opts.signal ? AbortSignal.any([controller.signal, opts.signal]) : controller.signal;
84
+ const dispatcher = createPublicDispatcher();
85
+ try {
86
+ for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects++) {
87
+ const candidate = new URL(normalizeUrlCandidate(currentUrl));
88
+ const host = sourceHost(candidate.href);
89
+ const release = await feedRequests.acquire(host, signal);
90
+ try {
91
+ const validated = await validateUrl(candidate.href, signal);
92
+ signal.throwIfAborted();
93
+ const headers = { "User-Agent": USER_AGENT, Accept: opts.accept };
94
+ if (redirects === 0 && opts.etag)
95
+ headers["If-None-Match"] = opts.etag;
96
+ if (redirects === 0 && opts.lastModified)
97
+ headers["If-Modified-Since"] = opts.lastModified;
98
+ const response = await undiciFetch(validated, { headers, redirect: "manual", signal, dispatcher });
99
+ if (response.status === 304) {
100
+ await response.body?.cancel();
101
+ return { notModified: true, buffer: Buffer.alloc(0), finalUrl: currentUrl, contentType: response.headers.get("content-type") ?? undefined };
102
+ }
103
+ if (response.status >= 300 && response.status < 400) {
104
+ const location = response.headers.get("location");
105
+ await response.body?.cancel();
106
+ if (!location)
107
+ throw new Error("Redirect without a Location header");
108
+ currentUrl = new URL(location, validated).toString();
109
+ continue;
110
+ }
111
+ if (!response.ok) {
112
+ let retryAt = parseRetryAfter(response.headers.get("retry-after"));
113
+ if (response.status === 429 || response.status === 503) {
114
+ retryAt = Math.max(retryAt ?? 0, Date.now() + readPollSettings().retryBaseMs * (0.5 + Math.random() * 0.5));
115
+ feedRequests.defer(host, retryAt);
116
+ }
117
+ await response.body?.cancel();
118
+ throw new FeedHttpError(response.status, retryAt, host);
119
+ }
120
+ const contentLength = response.headers.get("content-length");
121
+ if (contentLength && Number(contentLength) > opts.maxBytes) {
122
+ await response.body?.cancel();
123
+ throw new Error("Feed response exceeds the maximum allowed size");
124
+ }
125
+ const reader = response.body?.getReader();
126
+ if (!reader)
127
+ throw new Error("Feed response had no body");
128
+ const chunks = [];
129
+ let total = 0;
130
+ for (;;) {
131
+ const { done, value } = await reader.read();
132
+ if (done)
133
+ break;
134
+ total += value.byteLength;
135
+ if (total > opts.maxBytes) {
136
+ await reader.cancel();
137
+ throw new Error("Feed response exceeds the maximum allowed size");
138
+ }
139
+ chunks.push(value);
140
+ }
141
+ return {
142
+ cacheTtlMs: cacheLifetime(response.headers),
143
+ notModified: false, buffer: Buffer.concat(chunks, total),
144
+ contentType: response.headers.get("content-type") ?? undefined,
145
+ etag: response.headers.get("etag") ?? undefined,
146
+ lastModified: response.headers.get("last-modified") ?? undefined, finalUrl: currentUrl,
147
+ };
148
+ }
149
+ finally {
150
+ release();
151
+ }
152
+ }
153
+ throw new Error("Too many redirects while fetching feed");
154
+ }
155
+ finally {
156
+ clearTimeout(timeout);
157
+ await dispatcher.destroy();
158
+ }
159
+ }
160
+ // Fetches a feed URL with conditional GET headers and decodes it to text.
161
+ export async function fetchFeed(url, opts = {}) {
162
+ const download = await downloadPublic(url, {
163
+ accept: "application/rss+xml, application/atom+xml, application/xml, text/xml, text/html, application/xhtml+xml;q=0.9, */*;q=0.5",
164
+ maxBytes: MAX_RESPONSE_BYTES,
165
+ etag: opts.etag,
166
+ lastModified: opts.lastModified,
167
+ signal: opts.signal,
168
+ });
169
+ if (download.notModified) {
170
+ return { notModified: true, body: "", finalUrl: download.finalUrl, contentType: download.contentType };
171
+ }
172
+ return {
173
+ body: decodeFeedBuffer(download.buffer, download.contentType),
174
+ notModified: false,
175
+ etag: download.etag,
176
+ lastModified: download.lastModified,
177
+ contentType: download.contentType,
178
+ finalUrl: download.finalUrl,
179
+ };
180
+ }
181
+ /** Downloads an image for the archive; the caller checks the file signature. */
182
+ export async function fetchImage(url, maxBytes, signal) {
183
+ const download = await downloadPublic(url, { accept: "image/avif,image/webp,image/png,image/jpeg,image/gif;q=0.9,*/*;q=0.5", maxBytes, signal });
184
+ return { buffer: download.buffer, finalUrl: download.finalUrl, cacheTtlMs: download.cacheTtlMs };
185
+ }
186
+ /**
187
+ * Decodes raw response buffer into a string respecting character encoding from:
188
+ * 1. HTTP Content-Type header (e.g. charset=ISO-8859-1)
189
+ * 2. XML declaration header (e.g. <?xml version="1.0" encoding="ISO-8859-1"?>)
190
+ * Defaults to UTF-8.
191
+ */
192
+ function decodeFeedBuffer(buffer, contentType) {
193
+ let charset = null;
194
+ if (contentType) {
195
+ const match = contentType.match(/charset=([^;]+)/i);
196
+ if (match) {
197
+ charset = match[1].trim().replace(/^["']|["']$/g, "");
198
+ }
199
+ }
200
+ if (!charset) {
201
+ const snippet = buffer.subarray(0, 1024).toString("ascii");
202
+ const xmlMatch = snippet.match(/<\?xml[^>]+encoding=["']([^"']+)["']/i);
203
+ if (xmlMatch) {
204
+ charset = xmlMatch[1].trim();
205
+ }
206
+ }
207
+ if (charset) {
208
+ try {
209
+ return new TextDecoder(charset).decode(buffer);
210
+ }
211
+ catch {
212
+ // If TextDecoder does not recognize the charset, fall back to utf-8
213
+ }
214
+ }
215
+ return buffer.toString("utf8");
216
+ }
217
+ //# sourceMappingURL=fetcher.js.map
@@ -0,0 +1,91 @@
1
+ import { extractArticle, isCachedConsentSnippet } from "./extractor.js";
2
+ import { hasUserCapability } from "../auth/capabilities.js";
3
+ import { db } from "../db/index.js";
4
+ import { improvesContent } from "./articleContent.js";
5
+ import { canAccessItem, findItemById, findSubscriptionFullTextMode, updateItemFullContent } from "./repository.js";
6
+ // Kept for integrations importing the old constant; retries now belong to individual articles.
7
+ export const FULL_TEXT_BLOCK_THRESHOLD = 2;
8
+ const inFlight = new Map();
9
+ const hosts = new Set();
10
+ let active = 0;
11
+ const waiters = [];
12
+ async function acquire(host) {
13
+ while (active >= 2 || hosts.has(host))
14
+ await new Promise(resolve => waiters.push(resolve));
15
+ active++;
16
+ hosts.add(host);
17
+ return () => { active--; hosts.delete(host); waiters.splice(0).forEach(resolve => resolve()); };
18
+ }
19
+ function recordAttempt(id, status) {
20
+ const blocked = ["paywall", "consent_wall"].includes(status);
21
+ const retry = status === "ready" || status === "feed_only" ? null :
22
+ new Date(Date.now() + (blocked ? 7 * 86400_000 : status === "partial" ? 86400_000 : 3600_000)).toISOString();
23
+ db.prepare("UPDATE items SET extraction_status = ?, extraction_attempted_at = ?, extraction_retry_at = ? WHERE id = ?")
24
+ .run(status, new Date().toISOString(), retry, id);
25
+ }
26
+ /** All callers share bounded host-aware extraction, cache quality and per-article retry policy. */
27
+ export async function loadFullText(userId, itemId, options = {}) {
28
+ const item = findItemById(itemId);
29
+ const mode = item ? findSubscriptionFullTextMode(userId, item.feed_id) ?? (canAccessItem(userId, itemId) ? "auto" : undefined) : undefined;
30
+ if (!item || !mode)
31
+ return { ok: false, error: "item_not_found" };
32
+ if (item.full_content_html && isCachedConsentSnippet(item.full_content_html)) {
33
+ // Repair cached walls before any caller can mistake them for reader content again.
34
+ db.prepare("UPDATE items SET full_content_html = NULL, extraction_status = 'pending', extraction_retry_at = NULL WHERE id = ?").run(itemId);
35
+ item.full_content_html = null;
36
+ }
37
+ if (!options.force && item.full_content_html && !isCachedConsentSnippet(item.full_content_html)) {
38
+ return { ok: true, id: item.id, fullContentHtml: item.full_content_html, cached: true };
39
+ }
40
+ if (!item.link)
41
+ return { ok: false, error: "item_has_no_link" };
42
+ if (mode === "never")
43
+ return { ok: false, error: "full_text_disabled" };
44
+ if (!options.force && item.extraction_status === "feed_only")
45
+ return { ok: true, id: itemId, fullContentHtml: item.content_html ?? "", cached: true };
46
+ if (!options.force && item.extraction_retry_at && Date.parse(item.extraction_retry_at) > Date.now()) {
47
+ const status = item.extraction_status;
48
+ return { ok: false, error: ["consent_wall", "paywall", "bot_blocked", "timeout", "partial"].includes(status) ? status : "could_not_extract_content" };
49
+ }
50
+ if (!hasUserCapability(userId, "fulltext"))
51
+ return { ok: false, error: "capability_not_available" };
52
+ if (inFlight.has(itemId))
53
+ return inFlight.get(itemId);
54
+ const task = (async () => {
55
+ let release;
56
+ try {
57
+ release = await acquire(new URL(item.link).host);
58
+ if (!hasUserCapability(userId, "fulltext") || !canAccessItem(userId, itemId))
59
+ return { ok: false, error: "capability_not_available" };
60
+ const result = await extractArticle(item.link);
61
+ if (result.status !== "ok") {
62
+ recordAttempt(itemId, result.status);
63
+ return { ok: false, error: result.status === "failed" ? "could_not_extract_content" : result.status };
64
+ }
65
+ // Content may have improved while this request was running.
66
+ const current = findItemById(itemId);
67
+ if (!current)
68
+ return { ok: false, error: "item_not_found" };
69
+ if (current.link !== item.link)
70
+ return { ok: false, error: "extraction_failed" };
71
+ if (improvesContent(current, result.article.contentHtml)) {
72
+ db.transaction(() => { updateItemFullContent(itemId, result.article.contentHtml); recordAttempt(itemId, "ready"); })();
73
+ }
74
+ else
75
+ recordAttempt(itemId, current.full_content_html ? "ready" : "feed_only");
76
+ return { ok: true, id: itemId, fullContentHtml: findItemById(itemId).full_content_html || current.content_html || "",
77
+ title: result.article.title, byline: result.article.byline, cached: false };
78
+ }
79
+ catch {
80
+ recordAttempt(itemId, "failed");
81
+ return { ok: false, error: "extraction_failed" };
82
+ }
83
+ finally {
84
+ release?.();
85
+ inFlight.delete(itemId);
86
+ }
87
+ })();
88
+ inFlight.set(itemId, task);
89
+ return task;
90
+ }
91
+ //# sourceMappingURL=fullText.js.map
@@ -0,0 +1,159 @@
1
+ import { isCachedConsentSnippet } from "./extractor.js";
2
+ import { db } from "../db/index.js";
3
+ import { hasUserCapability } from "../auth/capabilities.js";
4
+ import { loadFullText } from "./fullText.js";
5
+ import { canAccessItem, findSubscriptionFullTextMode, findItemById } from "./repository.js";
6
+ /** Refillable durable queue, bounded independently of library size. */
7
+ export function scheduleFullTexts() {
8
+ const queued = db.prepare("SELECT COUNT(*) n FROM full_text_jobs").get();
9
+ const room = Math.max(0, 200 - queued.n);
10
+ if (!room)
11
+ return 0;
12
+ const eligible = db.prepare("SELECT id FROM users").all().filter(user => hasUserCapability(user.id, "fulltext")).map(user => user.id);
13
+ if (!eligible.length)
14
+ return 0;
15
+ const candidates = db.prepare(`
16
+ WITH candidate_articles AS (
17
+ SELECT ids.value AS item_id, e.user_id, 3 AS priority, i.created_at
18
+ FROM editions e, json_each(e.item_ids) ids
19
+ JOIN items i ON i.id = ids.value
20
+ WHERE e.user_id IN (SELECT value FROM json_each(?))
21
+ AND e.status = 'active' AND e.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now')
22
+ AND i.link IS NOT NULL AND i.full_content_html IS NULL
23
+ AND i.extraction_status != 'feed_only'
24
+ AND (i.extraction_retry_at IS NULL OR i.extraction_retry_at <= strftime('%Y-%m-%dT%H:%M:%fZ','now'))
25
+ AND NOT EXISTS (SELECT 1 FROM full_text_jobs j WHERE j.item_id = i.id)
26
+
27
+ UNION ALL
28
+
29
+ SELECT b.item_id, b.user_id, 2 AS priority, i.created_at
30
+ FROM item_bookmarks b
31
+ JOIN items i ON i.id = b.item_id
32
+ WHERE b.user_id IN (SELECT value FROM json_each(?))
33
+ AND i.link IS NOT NULL AND i.full_content_html IS NULL
34
+ AND i.extraction_status != 'feed_only'
35
+ AND (i.extraction_retry_at IS NULL OR i.extraction_retry_at <= strftime('%Y-%m-%dT%H:%M:%fZ','now'))
36
+ AND NOT EXISTS (SELECT 1 FROM full_text_jobs j WHERE j.item_id = i.id)
37
+
38
+ UNION ALL
39
+
40
+ SELECT n.item_id, n.user_id, 2 AS priority, i.created_at
41
+ FROM item_notes n
42
+ JOIN items i ON i.id = n.item_id
43
+ WHERE n.user_id IN (SELECT value FROM json_each(?))
44
+ AND i.link IS NOT NULL AND i.full_content_html IS NULL
45
+ AND i.extraction_status != 'feed_only'
46
+ AND (i.extraction_retry_at IS NULL OR i.extraction_retry_at <= strftime('%Y-%m-%dT%H:%M:%fZ','now'))
47
+ AND NOT EXISTS (SELECT 1 FROM full_text_jobs j WHERE j.item_id = i.id)
48
+
49
+ UNION ALL
50
+
51
+ SELECT i.id AS item_id, s.user_id, 1 AS priority, i.created_at
52
+ FROM subscriptions s
53
+ JOIN items i ON i.feed_id = s.feed_id
54
+ WHERE s.user_id IN (SELECT value FROM json_each(?))
55
+ AND COALESCE(s.full_text_mode, 'auto') = 'auto'
56
+ AND i.link IS NOT NULL AND i.full_content_html IS NULL
57
+ AND i.extraction_status != 'feed_only'
58
+ AND (i.extraction_retry_at IS NULL OR i.extraction_retry_at <= strftime('%Y-%m-%dT%H:%M:%fZ','now'))
59
+ AND NOT EXISTS (SELECT 1 FROM full_text_jobs j WHERE j.item_id = i.id)
60
+ )
61
+ SELECT item_id AS id, user_id, MAX(priority) AS priority
62
+ FROM candidate_articles
63
+ GROUP BY item_id
64
+ ORDER BY priority DESC, created_at DESC LIMIT ?`).all(JSON.stringify(eligible), JSON.stringify(eligible), JSON.stringify(eligible), JSON.stringify(eligible), room);
65
+ const add = db.prepare("INSERT OR IGNORE INTO full_text_jobs(item_id, user_id, priority) VALUES (?, ?, ?)");
66
+ let count = 0;
67
+ db.transaction(() => {
68
+ for (const row of candidates)
69
+ if (hasUserCapability(row.user_id, "fulltext"))
70
+ count += add.run(row.id, row.user_id, row.priority).changes;
71
+ })();
72
+ return count;
73
+ }
74
+ /** Explicit priority requests never run publisher I/O inside an HTTP batch request. */
75
+ export function enqueueArticleContents(userId, ids) {
76
+ if (ids.length > 50 || ids.some(id => !Number.isSafeInteger(id) || id <= 0))
77
+ throw new Error("invalid_source_selection");
78
+ if (!hasUserCapability(userId, "fulltext"))
79
+ throw new Error("capability_not_available");
80
+ const unique = [...new Set(ids)];
81
+ if (unique.some(id => !canAccessItem(userId, id)))
82
+ throw new Error("item_not_found");
83
+ return db.transaction(() => {
84
+ const count = db.prepare("SELECT COUNT(*) n FROM full_text_jobs").get();
85
+ if (count.n + unique.length > 1000)
86
+ throw new Error("queue_full");
87
+ const add = db.prepare("INSERT INTO full_text_jobs(item_id,user_id,priority) VALUES (?,?,4) ON CONFLICT(item_id) DO UPDATE SET priority = MAX(priority,4), user_id = excluded.user_id");
88
+ const queued = [];
89
+ for (const id of unique) {
90
+ const item = findItemById(id);
91
+ if (item.full_content_html || item.extraction_status === "feed_only" || findSubscriptionFullTextMode(userId, item.feed_id) === "never")
92
+ continue;
93
+ if (item.extraction_retry_at && Date.parse(item.extraction_retry_at) > Date.now())
94
+ continue;
95
+ add.run(id, userId);
96
+ queued.push(id);
97
+ }
98
+ return queued;
99
+ })();
100
+ }
101
+ /** Synchronous consumers can prepare selected articles without waiting for the worker. */
102
+ export async function prepareArticleContents(userId, ids) {
103
+ if (ids.length > 50 || ids.some(id => !Number.isSafeInteger(id) || id <= 0))
104
+ throw new Error("invalid_source_selection");
105
+ const unique = [...new Set(ids)];
106
+ if (unique.some(id => !canAccessItem(userId, id)))
107
+ throw new Error("item_not_found");
108
+ if (!hasUserCapability(userId, "fulltext"))
109
+ throw new Error("capability_not_available");
110
+ const results = [];
111
+ // Avoid queueing an unbounded number of suspended host slots.
112
+ for (let start = 0; start < unique.length; start += 2) {
113
+ results.push(...await Promise.all(unique.slice(start, start + 2).map(async (id) => {
114
+ if (!hasUserCapability(userId, "fulltext"))
115
+ throw new Error("capability_not_available");
116
+ const result = await loadFullText(userId, id);
117
+ return { id, ok: result.ok, ...(!result.ok ? { error: result.error } : {}) };
118
+ })));
119
+ }
120
+ return results;
121
+ }
122
+ let running = false;
123
+ export async function runFullTextQueue() {
124
+ if (running)
125
+ return;
126
+ running = true;
127
+ try {
128
+ const queued = db.prepare("SELECT COUNT(*) n FROM full_text_jobs").get();
129
+ if (queued.n < 200)
130
+ scheduleFullTexts();
131
+ const jobs = db.prepare("SELECT * FROM full_text_jobs WHERE available_at <= ? ORDER BY priority DESC, available_at LIMIT 1").all(new Date().toISOString());
132
+ await Promise.all(jobs.map(async (job) => {
133
+ const item = findItemById(job.item_id);
134
+ if (item && hasUserCapability(job.user_id, "fulltext") && canAccessItem(job.user_id, job.item_id) && findSubscriptionFullTextMode(job.user_id, item.feed_id) !== "never") {
135
+ await loadFullText(job.user_id, job.item_id);
136
+ }
137
+ db.prepare("DELETE FROM full_text_jobs WHERE item_id = ?").run(job.item_id);
138
+ }));
139
+ }
140
+ finally {
141
+ running = false;
142
+ }
143
+ }
144
+ export function startFullTextScheduler() {
145
+ const tick = () => { void runFullTextQueue().catch(() => console.error("[fulltext] queue failed")); };
146
+ // One-time startup repair of old consent-only caches, without contacting publishers.
147
+ const cached = db.prepare("SELECT id, full_content_html FROM items WHERE full_content_html IS NOT NULL AND (lower(full_content_html) LIKE '%cookie%' OR lower(full_content_html) LIKE '%consent%') LIMIT 100").all();
148
+ db.transaction(() => {
149
+ for (const item of cached)
150
+ if (isCachedConsentSnippet(item.full_content_html)) {
151
+ db.prepare("UPDATE items SET full_content_html = NULL, extraction_status = 'pending', extraction_retry_at = NULL WHERE id = ?").run(item.id);
152
+ }
153
+ })();
154
+ tick();
155
+ const timer = setInterval(tick, 15000);
156
+ timer.unref();
157
+ return timer;
158
+ }
159
+ //# sourceMappingURL=fullTextQueue.js.map