feedkeeper 0.23.0 → 0.24.1

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.23.0",
3
+ "version": "0.24.1",
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,9 +5,9 @@ import { config } from "../config.js";
5
5
  import { requireSession, requireAdmin } from "../auth/middleware.js";
6
6
  import { createUser, deleteUserAvatar, findUserByEmail, findUserById, getUserAvatar, listUsers, setUserAvatar, toPublicUser, updateDisplayName, dismissIntro, updateUserPassword, } from "../auth/users.js";
7
7
  import { AVATAR_MAX_BYTES, detectAvatarType } from "../auth/avatar.js";
8
- import { verifyPassword } from "../auth/password.js";
8
+ import { verifyPassword, verifyPasswordOrDummy } from "../auth/password.js";
9
9
  import { getUserCapabilities } from "../auth/capabilities.js";
10
- import { beginMfa, securityVersion } from "../auth/security.js";
10
+ import { beginMfa, revokeBrowserSessions, securityVersion } from "../auth/security.js";
11
11
  import { createSecurityRouter } from "../auth/securityRoutes.js";
12
12
  export const authRouter = Router();
13
13
  authRouter.use("/security", createSecurityRouter({ completeLogin: (req, res, userId) => {
@@ -31,7 +31,9 @@ authRouter.post("/login", loginLimiter, (req, res) => {
31
31
  return;
32
32
  }
33
33
  const user = findUserByEmail(parsed.data.email);
34
- if (!user || !verifyPassword(parsed.data.password, user.password_hash)) {
34
+ // Always check a password, so an unknown address answers as slowly as a wrong password.
35
+ const passwordMatches = verifyPasswordOrDummy(parsed.data.password, user?.password_hash);
36
+ if (!user || !passwordMatches) {
35
37
  res.status(401).json({ error: "invalid_credentials" });
36
38
  return;
37
39
  }
@@ -110,6 +112,9 @@ authRouter.post("/change-password", requireSession, loginLimiter, (req, res) =>
110
112
  return;
111
113
  }
112
114
  updateUserPassword(user.id, parsed.data.newPassword);
115
+ // Other browsers that were signed in with the old password are signed out; this one stays.
116
+ revokeBrowserSessions(user.id);
117
+ req.session = { userId: user.id, securityVersion: securityVersion(user.id) };
113
118
  res.status(204).end();
114
119
  });
115
120
  const createUserSchema = z.object({
@@ -0,0 +1,15 @@
1
+ import { createHash } from "node:crypto";
2
+ /** Mutable proxy images revalidate; archive routes keep their separate immutable policy. */
3
+ export function sendArticleImage(req, res, image) {
4
+ const age = Math.max(0, Math.min(21600, Math.floor((image.cacheTtlMs ?? 21_600_000) / 1000)));
5
+ const etag = `"${createHash("sha256").update(image.buffer).digest("hex")}"`;
6
+ res.setHeader("Cache-Control", age ? `private, max-age=${age}` : "private, no-store");
7
+ res.setHeader("ETag", etag);
8
+ const matches = req.headers["if-none-match"]?.split(",").some(value => value.trim().replace(/^W\//, "") === etag || value.trim() === "*");
9
+ if (matches) {
10
+ res.status(304).end();
11
+ return;
12
+ }
13
+ res.type(image.mime).send(image.buffer);
14
+ }
15
+ //# sourceMappingURL=imageResponse.js.map
@@ -1,3 +1,4 @@
1
+ import { sendArticleImage } from "./imageResponse.js";
1
2
  import { Router } from "express";
2
3
  import rateLimit from "express-rate-limit";
3
4
  import { z } from "zod";
@@ -13,7 +14,7 @@ export const FULL_TEXT_STATUS = {
13
14
  full_text_disabled: 409,
14
15
  consent_wall: 422,
15
16
  could_not_extract_content: 422,
16
- extraction_failed: 502,
17
+ extraction_failed: 502, queue_full: 503, source_busy: 503,
17
18
  };
18
19
  export const itemsRouter = Router();
19
20
  // The origin the browser used (honours TRUST_PROXY), so archive URLs work behind proxies and in development.
@@ -62,8 +63,7 @@ itemsRouter.get("/:itemId/image", imageLimiter, async (req, res) => {
62
63
  res.status(415).json({ error: "unsupported_image" });
63
64
  }
64
65
  else {
65
- res.setHeader("Cache-Control", "private, max-age=31536000, immutable");
66
- res.type(image.mime).send(image.buffer);
66
+ sendArticleImage(req, res, image);
67
67
  }
68
68
  }
69
69
  catch (error) {
@@ -1,3 +1,4 @@
1
+ import { sendArticleImage } from "../imageResponse.js";
1
2
  import { enqueueArticleContents } from "../../feeds/fullTextQueue.js";
2
3
  import { Router } from "express";
3
4
  import rateLimit from "express-rate-limit";
@@ -628,8 +629,7 @@ resourcesRouter.get("/items/:id/image", imageLimiter, async (req, res) => {
628
629
  return void res.status(404).json({ error: "not_found" });
629
630
  if (image === "unsupported")
630
631
  return void res.status(415).json({ error: "unsupported_image" });
631
- res.setHeader("Cache-Control", "private, max-age=31536000, immutable");
632
- res.type(image.mime).send(image.buffer);
632
+ sendArticleImage(req, res, image);
633
633
  }
634
634
  catch (error) {
635
635
  if (error instanceof ImageCacheBusyError) {
@@ -1,3 +1,4 @@
1
+ import { randomBytes } from "node:crypto";
1
2
  import bcrypt from "bcryptjs";
2
3
  const SALT_ROUNDS = process.env.NODE_ENV === "test" ? 4 : 12;
3
4
  export function hashPassword(plain) {
@@ -6,4 +7,15 @@ export function hashPassword(plain) {
6
7
  export function verifyPassword(plain, hash) {
7
8
  return bcrypt.compareSync(plain, hash);
8
9
  }
10
+ // A hash nobody knows the password for. Checking it costs what checking a real one costs.
11
+ let unknownAccountHash;
12
+ /**
13
+ * Like verifyPassword for a sign-in, where the account may not exist: an unknown address takes as long as a known
14
+ * one, so the response time does not tell whether an account is registered.
15
+ */
16
+ export function verifyPasswordOrDummy(plain, hash) {
17
+ unknownAccountHash ??= bcrypt.hashSync(randomBytes(16).toString("hex"), SALT_ROUNDS);
18
+ const matches = bcrypt.compareSync(plain, hash ?? unknownAccountHash);
19
+ return hash !== undefined && matches;
20
+ }
9
21
  //# sourceMappingURL=password.js.map
@@ -96,6 +96,15 @@ export function newRecoveryCodes(userId) {
96
96
  db.prepare("INSERT INTO recovery_codes (user_id, code_hash) VALUES (?, ?)").run(userId, hash(code));
97
97
  return codes.map(code => code.match(/.{6}/g).join("-"));
98
98
  }
99
+ /**
100
+ * A new password ends every browser session and pending sign-in that knew the old one. Access tokens, paired
101
+ * devices and connected apps are the person's own and keep working; they can be removed one by one.
102
+ */
103
+ export function revokeBrowserSessions(userId) {
104
+ securityAccount(userId);
105
+ db.prepare("UPDATE account_security SET version = version + 1 WHERE user_id = ?").run(userId);
106
+ db.prepare("DELETE FROM security_challenges WHERE user_id = ?").run(userId);
107
+ }
99
108
  /** Enabling MFA invalidates older password-only sessions, tokens, pairing codes and pending logins. */
100
109
  export function revokeSecuritySessions(userId) {
101
110
  db.prepare("UPDATE account_security SET version = version + 1 WHERE user_id = ?").run(userId);
@@ -0,0 +1,12 @@
1
+ -- Migration 0032: publish dates that are not dates.
2
+ --
3
+ -- Entries whose feed gave a date that could not be read ("gestern", localised weekday names, an empty string) had
4
+ -- the raw text stored. Compared as text it sorted above every real date and never aged. Dates before 1991 and
5
+ -- dates more than a day ahead are placeholders (1970-01-01, year 9999). All of them become NULL, which makes the
6
+ -- app use the time it stored the article.
7
+ UPDATE items SET published_at = NULL
8
+ WHERE published_at IS NOT NULL AND julianday(published_at) IS NULL;
9
+
10
+ UPDATE items SET published_at = NULL
11
+ WHERE published_at IS NOT NULL
12
+ AND (julianday(published_at) < julianday('1991-01-01') OR julianday(published_at) > julianday('now', '+1 day'));
@@ -0,0 +1,5 @@
1
+ ALTER TABLE full_text_jobs ADD COLUMN created_at TEXT NOT NULL DEFAULT '';
2
+ UPDATE full_text_jobs SET created_at = available_at;
3
+ ALTER TABLE full_text_jobs ADD COLUMN attempts INTEGER NOT NULL DEFAULT 0;
4
+ ALTER TABLE full_text_jobs ADD COLUMN force INTEGER NOT NULL DEFAULT 0;
5
+ CREATE TABLE full_text_hosts (host TEXT PRIMARY KEY, retry_at TEXT NOT NULL);
@@ -297,7 +297,7 @@ export function itemImageUrls(item) {
297
297
  return [...new Set([...(hero ? [hero] : []), ...articleImageUrls(item.full_content_html ?? "", item.link), ...articleImageUrls(item.content_html ?? "", item.link)])];
298
298
  }
299
299
  function proxiedImageUrl(origin, itemId, imageUrl, apiBase = "/api") {
300
- return `${origin.replace(/\/$/, "")}${apiBase}/items/${itemId}/image?src=${encodeURIComponent(imageUrl).replace(/[!'()*]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`)}`;
300
+ return `${origin.replace(/\/$/, "")}${apiBase}/items/${itemId}/image?src=${encodeURIComponent(imageUrl).replace(/[!'()*]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`)}&v=2`;
301
301
  }
302
302
  /**
303
303
  * For offline copies: point the first images of articles that are not archived at an endpoint
@@ -0,0 +1,147 @@
1
+ export class ArticleWorkBusyError extends Error {
2
+ constructor() { super("article_work_busy"); }
3
+ }
4
+ export class ArticleAccessDeniedError extends Error {
5
+ }
6
+ export class ArticleHostBusyError extends Error {
7
+ host;
8
+ retryAt;
9
+ constructor(host, retryAt) {
10
+ super("article_host_busy");
11
+ this.host = host;
12
+ this.retryAt = retryAt;
13
+ }
14
+ }
15
+ export function readArticleWorkLimits(env = process.env) {
16
+ const integer = (name, fallback, min, max) => {
17
+ const value = Number(env[name] ?? fallback);
18
+ if (!Number.isSafeInteger(value) || value < min || value > max)
19
+ throw new Error(`${name} must be between ${min} and ${max}`);
20
+ return value;
21
+ };
22
+ return {
23
+ concurrency: integer("FULLTEXT_CONCURRENCY", 2, 1, 8),
24
+ hostConcurrency: integer("FULLTEXT_HOST_CONCURRENCY", 1, 1, 4),
25
+ maxWaiting: integer("FULLTEXT_MAX_WAITING", 32, 0, 128),
26
+ timeoutMs: integer("FULLTEXT_TIMEOUT_MS", 20_000, 50, 60_000),
27
+ agingMs: integer("FULLTEXT_AGING_MS", 60_000, 50, 600_000),
28
+ };
29
+ }
30
+ /** A bounded admission pool shared by saved links and stored-article extraction. */
31
+ export class ArticleWorkPool {
32
+ limits;
33
+ now;
34
+ waiting = [];
35
+ active = new Map();
36
+ deadlines = new Map();
37
+ closing = false;
38
+ completed = 0;
39
+ failed = 0;
40
+ durationMs = 0;
41
+ drained = [];
42
+ constructor(limits, now = Date.now) {
43
+ this.limits = limits;
44
+ this.now = now;
45
+ }
46
+ cooldown = () => 0;
47
+ get isClosing() { return this.closing; }
48
+ metrics() { return { running: this.active.size, waiting: this.waiting.length, completed: this.completed, failed: this.failed, durationMs: this.durationMs }; }
49
+ canStart(host) {
50
+ return !this.closing && this.cooldown(host) <= this.now() && this.active.size < this.limits.concurrency && [...this.active.values()].filter(value => value === host).length < this.limits.hostConcurrency;
51
+ }
52
+ move(signal, host) {
53
+ const until = this.cooldown(host);
54
+ if (until > this.now())
55
+ throw new ArticleHostBusyError(host, until);
56
+ const controller = [...this.active.keys()].find(entry => entry.signal === signal);
57
+ if (!controller || this.active.get(controller) === host)
58
+ return;
59
+ const occupied = [...this.active.entries()].filter(([, current]) => current === host);
60
+ if (occupied.length >= this.limits.hostConcurrency) {
61
+ throw new ArticleHostBusyError(host, Math.min(this.now() + 1000, ...occupied.map(([entry]) => this.deadlines.get(entry))));
62
+ }
63
+ this.active.set(controller, host);
64
+ }
65
+ run(host, work, priority = 5) {
66
+ const until = this.cooldown(host);
67
+ if (until > this.now())
68
+ return Promise.reject(new ArticleHostBusyError(host, until));
69
+ if (this.closing || (!this.canStart(host) && this.waiting.length >= this.limits.maxWaiting))
70
+ return Promise.reject(new ArticleWorkBusyError());
71
+ const controller = new AbortController();
72
+ const added = this.now();
73
+ return new Promise((resolve, reject) => {
74
+ const timer = setTimeout(() => {
75
+ controller.abort(new DOMException("Article deadline exceeded", "TimeoutError"));
76
+ const index = this.waiting.indexOf(job);
77
+ if (index >= 0) {
78
+ this.waiting.splice(index, 1);
79
+ reject(controller.signal.reason);
80
+ }
81
+ }, this.limits.timeoutMs);
82
+ const job = { host, priority, added, timer, controller, reject, start: () => {
83
+ this.active.set(controller, host);
84
+ this.deadlines.set(controller, added + this.limits.timeoutMs);
85
+ const finish = () => {
86
+ clearTimeout(timer);
87
+ this.active.delete(controller);
88
+ this.deadlines.delete(controller);
89
+ this.completed++;
90
+ this.durationMs += this.now() - added;
91
+ this.pump();
92
+ if (!this.active.size)
93
+ this.drained.splice(0).forEach(done => done());
94
+ };
95
+ void Promise.resolve().then(() => work(controller.signal)).then(value => {
96
+ if (this.now() - added >= this.limits.timeoutMs) {
97
+ this.failed++;
98
+ finish();
99
+ reject(new DOMException("Article deadline exceeded", "TimeoutError"));
100
+ }
101
+ else {
102
+ finish();
103
+ resolve(value);
104
+ }
105
+ }, error => { this.failed++; finish(); reject(error); });
106
+ } };
107
+ this.waiting.push(job);
108
+ this.pump();
109
+ });
110
+ }
111
+ pump() {
112
+ this.waiting.sort((a, b) => (b.priority + Math.floor((this.now() - b.added) / this.limits.agingMs)) -
113
+ (a.priority + Math.floor((this.now() - a.added) / this.limits.agingMs)) || a.added - b.added);
114
+ for (let index = 0; index < this.waiting.length;) {
115
+ const job = this.waiting[index];
116
+ const until = this.cooldown(job.host);
117
+ if (until > this.now()) {
118
+ this.waiting.splice(index, 1);
119
+ clearTimeout(job.timer);
120
+ job.reject(new ArticleHostBusyError(job.host, until));
121
+ continue;
122
+ }
123
+ if (!this.canStart(job.host)) {
124
+ index++;
125
+ continue;
126
+ }
127
+ this.waiting.splice(index, 1);
128
+ job.start();
129
+ }
130
+ }
131
+ async close() {
132
+ this.closing = true;
133
+ for (const job of this.waiting.splice(0)) {
134
+ clearTimeout(job.timer);
135
+ job.reject(new ArticleWorkBusyError());
136
+ }
137
+ for (const controller of this.active.keys())
138
+ controller.abort();
139
+ if (this.active.size)
140
+ await new Promise(resolve => this.drained.push(resolve));
141
+ }
142
+ }
143
+ let shared;
144
+ export function articleWorkPool() { return shared ??= new ArticleWorkPool(readArticleWorkLimits()); }
145
+ export function restartArticleWorkPool() { if (shared?.isClosing)
146
+ shared = undefined; }
147
+ //# sourceMappingURL=articleWorkPool.js.map
@@ -99,7 +99,7 @@ export function runCleanup(custom) {
99
99
  WHERE s.feed_id = i.feed_id
100
100
  AND NOT EXISTS (SELECT 1 FROM item_reads r WHERE r.item_id = i.id AND r.user_id = s.user_id)
101
101
  )
102
- AND COALESCE(i.published_at, i.created_at) <= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-' || ? || ' days')
102
+ AND COALESCE(julianday(i.published_at), julianday(i.created_at)) <= julianday('now', '-' || ? || ' days')
103
103
  )`).run(settings.retentionReadDays);
104
104
  deletedReadItems = res.changes;
105
105
  }
@@ -111,7 +111,7 @@ export function runCleanup(custom) {
111
111
  AND feed_id NOT IN (SELECT id FROM feeds WHERE is_system_inbox = 1)
112
112
  AND NOT EXISTS (SELECT 1 FROM editions e, json_each(e.item_ids) selected
113
113
  WHERE selected.value = items.id AND e.status = 'active' AND julianday(e.expires_at) > julianday('now'))
114
- AND COALESCE(published_at, created_at) <= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-' || ? || ' days')`).run(settings.retentionMaxDays);
114
+ AND COALESCE(julianday(published_at), julianday(created_at)) <= julianday('now', '-' || ? || ' days')`).run(settings.retentionMaxDays);
115
115
  deletedOldItems = res.changes;
116
116
  }
117
117
  // 3. Delete items exceeding retentionMaxItemsPerFeed (excluding bookmarked items and items with notes)
@@ -126,7 +126,7 @@ export function runCleanup(custom) {
126
126
  SELECT id FROM (
127
127
  SELECT id, ROW_NUMBER() OVER (
128
128
  PARTITION BY feed_id
129
- ORDER BY COALESCE(published_at, created_at) DESC, id DESC
129
+ ORDER BY COALESCE(julianday(published_at), julianday(created_at)) DESC, id DESC
130
130
  ) as rn
131
131
  FROM items
132
132
  )
@@ -0,0 +1,21 @@
1
+ // Nothing on the web is older than this, so an earlier date is a placeholder such as 1970-01-01.
2
+ const EARLIEST_PLAUSIBLE = Date.UTC(1991, 0, 1);
3
+ // Feeds may announce an article a little ahead of time or in another zone, but not by years.
4
+ const MAX_AHEAD_MS = 24 * 60 * 60 * 1000;
5
+ /**
6
+ * The publish date of a feed entry as an ISO timestamp, or null when the feed gives none we can trust: missing,
7
+ * not a date ("gestern", localised weekday names), a placeholder in the past or a date far in the future. Null makes
8
+ * the app use the time it stored the article, so the article lists, ages and is cleaned up like any other.
9
+ */
10
+ export function normalizePublishedAt(value, now = Date.now()) {
11
+ if (typeof value !== "string")
12
+ return null;
13
+ const text = value.trim();
14
+ if (!text)
15
+ return null;
16
+ const time = Date.parse(text);
17
+ if (!Number.isFinite(time) || time < EARLIEST_PLAUSIBLE || time > now + MAX_AHEAD_MS)
18
+ return null;
19
+ return new Date(time).toISOString();
20
+ }
21
+ //# sourceMappingURL=dates.js.map
@@ -2,6 +2,9 @@ import { Readability } from "@mozilla/readability";
2
2
  import { JSDOM } from "jsdom";
3
3
  import { fetch as undiciFetch } from "undici";
4
4
  import { assertPublicHttpUrl, createPublicDispatcher, SsrfBlockedError } from "./ssrfGuard.js";
5
+ import { articleWorkPool, ArticleHostBusyError, ArticleAccessDeniedError } from "./articleWorkPool.js";
6
+ import { parseRetryAfter } from "./fetcher.js";
7
+ import { sourceHost } from "./requestGate.js";
5
8
  import { APP_VERSION } from "../version.js";
6
9
  const defaultFetch = undiciFetch;
7
10
  const CONSENT_SELECTORS = [
@@ -79,9 +82,13 @@ const FETCH_TIMEOUT_MS = 12_000;
79
82
  const MAX_REDIRECTS = 5;
80
83
  class ArticleHttpError extends Error {
81
84
  status;
82
- constructor(status) {
85
+ retryAt;
86
+ host;
87
+ constructor(status, retryAt, host) {
83
88
  super("article_http_error");
84
89
  this.status = status;
90
+ this.retryAt = retryAt;
91
+ this.host = host;
85
92
  }
86
93
  }
87
94
  const MAX_HTML_BYTES = 2 * 1024 * 1024;
@@ -93,10 +100,12 @@ function stripHeavyTags(html) {
93
100
  .replace(/<iframe\b[^<]*(?:(?!<\/iframe>)<[^<]*)*<\/iframe>/gi, "")
94
101
  .replace(/<noscript\b[^<]*(?:(?!<\/noscript>)<[^<]*)*<\/noscript>/gi, "");
95
102
  }
96
- async function fetchArticleHtml(rawUrl, signal, dispatcher, fetchImpl) {
103
+ async function fetchArticleHtml(rawUrl, signal, dispatcher, fetchImpl, ownerSignal) {
97
104
  let currentUrl = rawUrl;
98
105
  for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects++) {
106
+ articleWorkPool().move(ownerSignal, sourceHost(currentUrl));
99
107
  const safeUrl = await assertPublicHttpUrl(currentUrl);
108
+ signal.throwIfAborted();
100
109
  const response = await fetchImpl(safeUrl, {
101
110
  signal,
102
111
  headers: {
@@ -111,19 +120,21 @@ async function fetchArticleHtml(rawUrl, signal, dispatcher, fetchImpl) {
111
120
  });
112
121
  if (response.status >= 300 && response.status < 400) {
113
122
  const location = response.headers.get("location");
123
+ await response.body?.cancel();
114
124
  if (!location)
115
125
  return null;
116
- await response.body?.cancel();
117
126
  currentUrl = new URL(location, safeUrl).toString();
118
127
  continue;
119
128
  }
120
129
  if (!response.ok) {
121
130
  await response.body?.cancel();
122
- throw new ArticleHttpError(response.status);
131
+ throw new ArticleHttpError(response.status, parseRetryAfter(response.headers.get("retry-after")), sourceHost(safeUrl.toString()));
123
132
  }
124
133
  const contentLength = Number(response.headers.get("content-length"));
125
- if (contentLength > MAX_HTML_BYTES)
134
+ if (contentLength > MAX_HTML_BYTES) {
135
+ await response.body?.cancel();
126
136
  return null;
137
+ }
127
138
  const reader = response.body?.getReader();
128
139
  if (!reader)
129
140
  return null;
@@ -150,13 +161,22 @@ export async function extractArticleFromUrl(rawUrl, fetchImpl = defaultFetch) {
150
161
  return result.status === "ok" ? result.article : null;
151
162
  }
152
163
  /** Like extractArticleFromUrl, but reports whether the site answered with a consent wall. */
153
- export async function extractArticle(rawUrl, fetchImpl = defaultFetch) {
164
+ export async function extractArticle(rawUrl, fetchImpl = defaultFetch, priority = 5, authorized = () => true) {
165
+ return articleWorkPool().run(sourceHost(rawUrl), signal => {
166
+ if (!authorized())
167
+ throw new ArticleAccessDeniedError();
168
+ return extractArticleContent(rawUrl, fetchImpl, signal);
169
+ }, priority);
170
+ }
171
+ async function extractArticleContent(rawUrl, fetchImpl, signal) {
172
+ signal.throwIfAborted();
154
173
  const safeUrl = await assertPublicHttpUrl(rawUrl);
174
+ signal.throwIfAborted();
155
175
  const dispatcher = createPublicDispatcher();
156
176
  const controller = new AbortController();
157
177
  const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
158
178
  try {
159
- const fetched = await fetchArticleHtml(safeUrl.toString(), controller.signal, dispatcher, fetchImpl);
179
+ const fetched = await fetchArticleHtml(safeUrl.toString(), AbortSignal.any([signal, controller.signal]), dispatcher, fetchImpl, signal);
160
180
  if (!fetched)
161
181
  return { status: "failed" };
162
182
  const cleanedHtml = stripHeavyTags(fetched.html);
@@ -190,13 +210,17 @@ export async function extractArticle(rawUrl, fetchImpl = defaultFetch) {
190
210
  };
191
211
  }
192
212
  catch (error) {
213
+ if (error instanceof ArticleHostBusyError)
214
+ return { status: "throttled", host: error.host, retryAt: error.retryAt, deferred: true };
193
215
  if (error instanceof SsrfBlockedError)
194
216
  throw error;
195
- if (controller.signal.aborted)
217
+ if (signal.aborted || controller.signal.aborted)
196
218
  return { status: "timeout" };
197
219
  if (error instanceof ArticleHttpError && [401, 402].includes(error.status))
198
220
  return { status: "paywall" };
199
- if (error instanceof ArticleHttpError && [403, 429, 503].includes(error.status))
221
+ if (error instanceof ArticleHttpError && [429, 503].includes(error.status))
222
+ return { status: "throttled", retryAt: error.retryAt, host: error.host };
223
+ if (error instanceof ArticleHttpError && error.status === 403)
200
224
  return { status: "bot_blocked" };
201
225
  return { status: "failed" };
202
226
  }
@@ -1,21 +1,13 @@
1
1
  import { extractArticle, isCachedConsentSnippet } from "./extractor.js";
2
2
  import { hasUserCapability } from "../auth/capabilities.js";
3
3
  import { db } from "../db/index.js";
4
+ import { articleWorkPool, ArticleWorkBusyError, ArticleAccessDeniedError, ArticleHostBusyError } from "./articleWorkPool.js";
5
+ import { requestFullText, authorizedFullTextRequester } from "./fullTextQueue.js";
6
+ import { SsrfBlockedError } from "./ssrfGuard.js";
4
7
  import { improvesContent } from "./articleContent.js";
5
8
  import { canAccessItem, findItemById, findSubscriptionFullTextMode, updateItemFullContent } from "./repository.js";
6
9
  // Kept for integrations importing the old constant; retries now belong to individual articles.
7
10
  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
11
  function recordAttempt(id, status) {
20
12
  const blocked = ["paywall", "consent_wall"].includes(status);
21
13
  const retry = status === "ready" || status === "feed_only" ? null :
@@ -24,7 +16,7 @@ function recordAttempt(id, status) {
24
16
  .run(status, new Date().toISOString(), retry, id);
25
17
  }
26
18
  /** All callers share bounded host-aware extraction, cache quality and per-article retry policy. */
27
- export async function loadFullText(userId, itemId, options = {}) {
19
+ export function cachedFullText(userId, itemId, force = false) {
28
20
  const item = findItemById(itemId);
29
21
  const mode = item ? findSubscriptionFullTextMode(userId, item.feed_id) ?? (canAccessItem(userId, itemId) ? "auto" : undefined) : undefined;
30
22
  if (!item || !mode)
@@ -34,58 +26,73 @@ export async function loadFullText(userId, itemId, options = {}) {
34
26
  db.prepare("UPDATE items SET full_content_html = NULL, extraction_status = 'pending', extraction_retry_at = NULL WHERE id = ?").run(itemId);
35
27
  item.full_content_html = null;
36
28
  }
37
- if (!options.force && item.full_content_html && !isCachedConsentSnippet(item.full_content_html)) {
29
+ if (!force && item.full_content_html && !isCachedConsentSnippet(item.full_content_html)) {
38
30
  return { ok: true, id: item.id, fullContentHtml: item.full_content_html, cached: true };
39
31
  }
40
32
  if (!item.link)
41
33
  return { ok: false, error: "item_has_no_link" };
42
34
  if (mode === "never")
43
35
  return { ok: false, error: "full_text_disabled" };
44
- if (!options.force && item.extraction_status === "feed_only")
36
+ if (!force && item.extraction_status === "feed_only")
45
37
  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()) {
38
+ if (!force && item.extraction_retry_at && Date.parse(item.extraction_retry_at) > Date.now() && !db.prepare("SELECT 1 FROM full_text_jobs WHERE item_id=?").get(itemId)) {
47
39
  const status = item.extraction_status;
48
40
  return { ok: false, error: ["consent_wall", "paywall", "bot_blocked", "timeout", "partial"].includes(status) ? status : "could_not_extract_content" };
49
41
  }
50
42
  if (!hasUserCapability(userId, "fulltext"))
51
43
  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 };
44
+ return undefined;
45
+ }
46
+ export async function loadFullText(userId, itemId, options = {}) {
47
+ return cachedFullText(userId, itemId, options.force) ?? requestFullText(userId, itemId, options.force ?? false);
48
+ }
49
+ export async function executeFullText(userId, itemId, force, priority) {
50
+ const requester = authorizedFullTextRequester(itemId, userId);
51
+ if (requester === undefined)
52
+ return { ok: false, error: "capability_not_available" };
53
+ const cached = cachedFullText(requester, itemId, force);
54
+ if (cached)
55
+ return cached;
56
+ const item = findItemById(itemId);
57
+ try {
58
+ const result = await extractArticle(item.link, undefined, priority, () => authorizedFullTextRequester(itemId, userId) !== undefined);
59
+ if (articleWorkPool().isClosing)
60
+ return { ok: false, error: "queue_full" };
61
+ if (authorizedFullTextRequester(itemId, userId) === undefined)
62
+ return { ok: false, error: "capability_not_available" };
63
+ if (result.status === "throttled")
64
+ return { ok: false, error: "source_busy", retryable: true, retryAt: result.retryAt, host: result.host, deferred: result.deferred };
65
+ if (result.status !== "ok") {
66
+ recordAttempt(itemId, result.status);
67
+ return { ok: false, error: result.status === "failed" ? "could_not_extract_content" : result.status,
68
+ ...(result.status === "failed" || result.status === "timeout" ? { retryable: true } : {}) };
78
69
  }
79
- catch {
80
- recordAttempt(itemId, "failed");
81
- return { ok: false, error: "extraction_failed" };
70
+ const current = findItemById(itemId);
71
+ if (!current || current.link !== item.link)
72
+ return { ok: false, error: "item_not_found" };
73
+ if (improvesContent(current, result.article.contentHtml)) {
74
+ db.transaction(() => { updateItemFullContent(itemId, result.article.contentHtml); recordAttempt(itemId, "ready"); })();
82
75
  }
83
- finally {
84
- release?.();
85
- inFlight.delete(itemId);
76
+ else
77
+ recordAttempt(itemId, current.full_content_html ? "ready" : "feed_only");
78
+ return { ok: true, id: itemId, fullContentHtml: findItemById(itemId).full_content_html || current.content_html || "",
79
+ title: result.article.title, byline: result.article.byline, cached: false };
80
+ }
81
+ catch (error) {
82
+ if (error instanceof SsrfBlockedError)
83
+ return { ok: false, error: "extraction_failed" };
84
+ if (error instanceof ArticleHostBusyError)
85
+ return { ok: false, error: "source_busy", retryable: true, deferred: true, host: error.host, retryAt: error.retryAt };
86
+ if (error instanceof ArticleAccessDeniedError)
87
+ return { ok: false, error: "capability_not_available" };
88
+ if (articleWorkPool().isClosing || error instanceof ArticleWorkBusyError)
89
+ return { ok: false, error: "queue_full", retryable: true };
90
+ if (error instanceof Error && ["TimeoutError", "AbortError"].includes(error.name)) {
91
+ recordAttempt(itemId, "timeout");
92
+ return { ok: false, error: "timeout", retryable: true };
86
93
  }
87
- })();
88
- inFlight.set(itemId, task);
89
- return task;
94
+ recordAttempt(itemId, "failed");
95
+ return { ok: false, error: "extraction_failed", retryable: true };
96
+ }
90
97
  }
91
98
  //# sourceMappingURL=fullText.js.map