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,222 @@
1
+ import Parser from "rss-parser";
2
+ import { fetchFeed } from "./fetcher.js";
3
+ import { discoverIconUrls, iconCheckDue } from "./icon.js";
4
+ import { refreshFeedIcon } from "./feedIcon.js";
5
+ import { decodeEntities, plainTitle } from "./text.js";
6
+ import { enqueueNewItemNotifications, startPushOutbox, stopPushOutbox } from "../pushOutbox.js";
7
+ import { hasUserCapability } from "../auth/capabilities.js";
8
+ import { findFeedById, listSubscriberIds, updateFeedAfterPoll, updateFeedIcon, upsertItems } from "./repository.js";
9
+ import { db } from "../db/index.js";
10
+ import { configurePollHostCooldowns, PollQueue } from "./pollQueue.js";
11
+ import { feedRequests, PollDeferredError, sourceHost } from "./requestGate.js";
12
+ configurePollHostCooldowns();
13
+ function mediaAttribute(value, key) {
14
+ if (typeof value !== "object" || value === null)
15
+ return undefined;
16
+ const record = value;
17
+ const attributes = record.$;
18
+ if (typeof attributes === "object" && attributes !== null) {
19
+ const nested = attributes[key];
20
+ if (nested !== undefined)
21
+ return nested;
22
+ }
23
+ return record[key];
24
+ }
25
+ const parser = new Parser({
26
+ customFields: {
27
+ item: [
28
+ ["media:content", "mediaContent", { keepArray: false }],
29
+ ["media:thumbnail", "mediaThumbnail", { keepArray: false }],
30
+ ["content:encoded", "contentEncoded", { keepArray: false }],
31
+ ],
32
+ },
33
+ });
34
+ function cleanImageUrl(url) {
35
+ return url.trim().replace(/&/g, "&").replace(/&/g, "&");
36
+ }
37
+ function isValidImageUrl(url) {
38
+ if (typeof url !== "string")
39
+ return false;
40
+ const trimmed = url.trim();
41
+ if (!trimmed.startsWith("http://") && !trimmed.startsWith("https://"))
42
+ return false;
43
+ if (trimmed.includes("/1x1") || trimmed.includes("pixel.wp.com") || trimmed.includes("tracking") || trimmed.includes("beacon")) {
44
+ if (trimmed.includes("pixel") || trimmed.includes("1x1"))
45
+ return false;
46
+ }
47
+ return true;
48
+ }
49
+ function extractImageUrl(item) {
50
+ // 1. media:content (Media RSS)
51
+ const mediaContent = item.mediaContent ?? item["media:content"];
52
+ if (mediaContent) {
53
+ if (isValidImageUrl(mediaContent))
54
+ return cleanImageUrl(mediaContent);
55
+ const url = mediaAttribute(mediaContent, "url");
56
+ const medium = mediaAttribute(mediaContent, "medium");
57
+ const type = mediaAttribute(mediaContent, "type");
58
+ if (isValidImageUrl(url) && (medium === "image" || !medium || (typeof type === "string" && type.startsWith("image/")))) {
59
+ return cleanImageUrl(url);
60
+ }
61
+ }
62
+ // 2. media:thumbnail
63
+ const mediaThumbnail = item.mediaThumbnail ?? item["media:thumbnail"];
64
+ if (mediaThumbnail) {
65
+ if (isValidImageUrl(mediaThumbnail))
66
+ return cleanImageUrl(mediaThumbnail);
67
+ const url = mediaAttribute(mediaThumbnail, "url");
68
+ if (isValidImageUrl(url))
69
+ return cleanImageUrl(url);
70
+ }
71
+ // 3. enclosure
72
+ if (item.enclosure?.url) {
73
+ const url = item.enclosure.url;
74
+ const type = item.enclosure.type ?? "";
75
+ if (type.startsWith("image/") || /\.(jpe?g|png|webp|gif|avif|svg)(\?.*)?$/i.test(url)) {
76
+ if (isValidImageUrl(url))
77
+ return cleanImageUrl(url);
78
+ }
79
+ }
80
+ // 4. First <img> tag in HTML content or summary
81
+ const htmlContent = item.contentEncoded ?? item["content:encoded"] ?? item.content ?? item.summary ?? "";
82
+ if (typeof htmlContent === "string" && htmlContent.includes("<img")) {
83
+ const match = htmlContent.match(/<img[^>]+src=["'](https?:\/\/[^"'\s>]+)["']/i);
84
+ if (match && isValidImageUrl(match[1])) {
85
+ return cleanImageUrl(match[1]);
86
+ }
87
+ }
88
+ return null;
89
+ }
90
+ /** One attempt; the queue owns retries, admission, single-flight and the total deadline. */
91
+ async function pollAttempt(feed, signal) {
92
+ const fetched = await fetchFeed(feed.url, { etag: feed.etag ?? undefined, lastModified: feed.last_modified ?? undefined, signal });
93
+ const parsed = fetched.notModified ? null : await parser.parseString(fetched.body);
94
+ signal.throwIfAborted();
95
+ const current = findFeedById(feed.id);
96
+ if (!current || current.url !== feed.url)
97
+ throw new PollDeferredError(Date.now(), current ? sourceHost(current.url) : sourceHost(feed.url));
98
+ // Recheck hosted capabilities after network I/O, before committing or sending notifications.
99
+ if (!feedHasActiveSubscriber(feed.id))
100
+ return { newItems: 0, error: "feed_poll_not_allowed" };
101
+ const items = parsed?.items.map(item => ({
102
+ guid: item.guid ?? item.link ?? item.title ?? crypto.randomUUID(),
103
+ title: plainTitle(item.title), link: item.link,
104
+ contentSnippet: decodeEntities(item.contentSnippet ?? item.content) ?? undefined,
105
+ contentHtml: item.contentEncoded ?? item["content:encoded"] ?? item.content ?? null,
106
+ publishedAt: item.isoDate ?? item.pubDate, imageUrl: extractImageUrl(item),
107
+ }));
108
+ // No await in this transaction. Items, health/validators and job completion become visible together.
109
+ const newItems = db.transaction(() => {
110
+ const count = items ? upsertItems(feed.id, items) : 0;
111
+ updateFeedAfterPoll(feed.id, {
112
+ ...(parsed ? { title: plainTitle(parsed.title), siteUrl: parsed.link, etag: fetched.etag, lastModified: fetched.lastModified } : {}),
113
+ error: null,
114
+ });
115
+ if (count > 0 && feed.last_success_at)
116
+ enqueueNewItemNotifications(feed.id, count);
117
+ db.prepare("DELETE FROM feed_poll_jobs WHERE feed_id = ?").run(feed.id);
118
+ return count;
119
+ })();
120
+ // Push is committed above and dispatched independently of the feed deadline.
121
+ startPushOutbox();
122
+ // Bounded icon maintenance cannot retry a committed feed.
123
+ try {
124
+ signal.throwIfAborted();
125
+ if (iconCheckDue(feed.icon_checked_at)) {
126
+ const siteUrl = parsed?.link || feed.site_url;
127
+ const urls = siteUrl ? await discoverIconUrls(siteUrl, signal) : [];
128
+ signal.throwIfAborted();
129
+ updateFeedIcon(feed.id, feed.icon_url);
130
+ await refreshFeedIcon(feed.id, siteUrl, urls, signal);
131
+ }
132
+ }
133
+ catch { /* Keep the successful poll; icon failures are best effort. */ }
134
+ return { newItems, error: null };
135
+ }
136
+ let queue;
137
+ function pollingQueue() {
138
+ queue ??= new PollQueue(pollAttempt, feedHasActiveSubscriber);
139
+ queue.start();
140
+ startPushOutbox();
141
+ return queue;
142
+ }
143
+ /** Concurrent triggers share the same attempt result. Deferred work survives the HTTP caller. */
144
+ export function pollFeed(feed) { return pollingQueue().submit(feed); }
145
+ /** Bounded consumers for explicit refresh-all. Each feed still uses the shared durable queue. */
146
+ export async function pollFeeds(feeds) {
147
+ let cursor = 0;
148
+ let newItems = 0;
149
+ let errors = 0;
150
+ let deferred = 0;
151
+ await Promise.all(Array.from({ length: Math.min(pollingQueue().settings.concurrency, feeds.length) }, async () => {
152
+ while (cursor < feeds.length) {
153
+ const result = await pollFeed(feeds[cursor++]);
154
+ newItems += result.newItems;
155
+ if (result.error)
156
+ errors++;
157
+ if (result.deferred)
158
+ deferred++;
159
+ }
160
+ }));
161
+ return { newItems, errors, deferred };
162
+ }
163
+ /** Bulk imports need no suspended JavaScript task per subscription. */
164
+ export function requestFeedPoll(feed) {
165
+ const queue = pollingQueue();
166
+ queue.enqueue(feed);
167
+ queue.kick();
168
+ }
169
+ export function pollingMetrics() { return pollingQueue().metrics(); }
170
+ /** Fetch and parse a feed without storing anything, to validate a URL before switching to it. */
171
+ export async function probeFeed(url) {
172
+ const fetched = await fetchFeed(url);
173
+ const parsed = await parser.parseString(fetched.body);
174
+ return { title: parsed.title ?? null, itemCount: parsed.items.length };
175
+ }
176
+ /**
177
+ * Whether anyone who subscribes to the feed may have it updated. An account whose plan does not include syncing (a hosted
178
+ * service after the trial) gets no updates, and a feed that only such accounts subscribe to is not fetched at all.
179
+ */
180
+ export function feedHasActiveSubscriber(feedId) {
181
+ return listSubscriberIds(feedId).some((userId) => hasUserCapability(userId, "sync"));
182
+ }
183
+ /** Schedules due work, returning after bounded-page admission rather than a network round. */
184
+ export function pollDueFeeds() { return pollingQueue().seedDue(); }
185
+ let scheduler;
186
+ export function startPollingScheduler() {
187
+ if (scheduler)
188
+ return;
189
+ const tick = () => { void pollDueFeeds().catch(() => console.error(JSON.stringify({ event: "feed_poll_seed_error" }))); };
190
+ tick();
191
+ scheduler = setInterval(tick, 60_000);
192
+ scheduler.unref();
193
+ }
194
+ export async function stopPollingScheduler() {
195
+ if (scheduler)
196
+ clearInterval(scheduler);
197
+ scheduler = undefined;
198
+ const { stopImageCache } = await import("./archive.js");
199
+ await stopImageCache();
200
+ await Promise.all([queue?.stop(), stopPushOutbox()]);
201
+ feedRequests.stop();
202
+ }
203
+ /** Graceful shutdown for server integrations; process-manager grace should be >= 45 seconds. */
204
+ export function installPollingShutdown(server) {
205
+ let stopping = false;
206
+ const shutdown = () => {
207
+ if (stopping)
208
+ return;
209
+ stopping = true;
210
+ console.log(JSON.stringify({ event: "feed_poll_shutdown" }));
211
+ const deadline = setTimeout(() => process.exit(1), 40_000);
212
+ deadline.unref();
213
+ const drained = new Promise(resolve => server.close(() => resolve()));
214
+ void Promise.all([stopPollingScheduler(), drained]).then(() => {
215
+ server.closeAllConnections();
216
+ process.exit(0);
217
+ }, () => process.exit(1));
218
+ };
219
+ process.once("SIGTERM", shutdown);
220
+ process.once("SIGINT", shutdown);
221
+ }
222
+ //# sourceMappingURL=poller.js.map