@isomoes/iread 0.1.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 (44) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +127 -0
  3. package/dist/server/cli.js +84 -0
  4. package/dist/server/cli.js.map +1 -0
  5. package/dist/server/db.js +158 -0
  6. package/dist/server/db.js.map +1 -0
  7. package/dist/server/feed-service.js +523 -0
  8. package/dist/server/feed-service.js.map +1 -0
  9. package/dist/server/fetch-feed.js +83 -0
  10. package/dist/server/fetch-feed.js.map +1 -0
  11. package/dist/server/index.js +62 -0
  12. package/dist/server/index.js.map +1 -0
  13. package/dist/server/opml.js +137 -0
  14. package/dist/server/opml.js.map +1 -0
  15. package/dist/server/routes/feeds.js +68 -0
  16. package/dist/server/routes/feeds.js.map +1 -0
  17. package/dist/server/routes/helpers.js +44 -0
  18. package/dist/server/routes/helpers.js.map +1 -0
  19. package/dist/server/routes/items.js +95 -0
  20. package/dist/server/routes/items.js.map +1 -0
  21. package/dist/server/routes/opml.js +50 -0
  22. package/dist/server/routes/opml.js.map +1 -0
  23. package/dist/server/sanitize.js +75 -0
  24. package/dist/server/sanitize.js.map +1 -0
  25. package/dist/server/ssrf.js +275 -0
  26. package/dist/server/ssrf.js.map +1 -0
  27. package/dist/shared/types.js +5 -0
  28. package/dist/shared/types.js.map +1 -0
  29. package/dist/web/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  30. package/dist/web/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  31. package/dist/web/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  32. package/dist/web/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  33. package/dist/web/assets/geist-mono-cyrillic-ext-wght-normal-I4S5GZfc.woff2 +0 -0
  34. package/dist/web/assets/geist-mono-cyrillic-wght-normal-BmXc_FBt.woff2 +0 -0
  35. package/dist/web/assets/geist-mono-latin-ext-wght-normal-DrnZ1wKl.woff2 +0 -0
  36. package/dist/web/assets/geist-mono-latin-wght-normal-B_7UjwxQ.woff2 +0 -0
  37. package/dist/web/assets/geist-mono-symbols2-wght-normal-GZpp1pK2.woff2 +0 -0
  38. package/dist/web/assets/geist-mono-vietnamese-wght-normal-D8KDMBhC.woff2 +0 -0
  39. package/dist/web/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  40. package/dist/web/assets/index-BI1j2sXf.css +2 -0
  41. package/dist/web/assets/index-HhCr0pHx.js +17 -0
  42. package/dist/web/assets/index-HhCr0pHx.js.map +1 -0
  43. package/dist/web/index.html +25 -0
  44. package/package.json +75 -0
@@ -0,0 +1,523 @@
1
+ // src/server/feed-service.ts
2
+ // Domain logic: add/delete/refresh feeds, fetch+parse+sanitize+dedup+persist with
3
+ // a state-preserving upsert, and list/get/patch/mark items. All queries are
4
+ // parameterized; DB rows are mapped to the camelCase DTOs in shared/types.ts.
5
+ // (PLAN Sections 5, 7)
6
+ import { createHash } from 'node:crypto';
7
+ import Parser from 'rss-parser';
8
+ import { db, transaction, totalChanges, queryAll, queryGet, run, } from './db.js';
9
+ import { fetchFeed } from './fetch-feed.js';
10
+ import { assertSafeFeedUrl } from './ssrf.js';
11
+ import { sanitizeArticleHtml, toPlainText } from './sanitize.js';
12
+ const SUMMARY_MAX = 280;
13
+ const LIMIT_DEFAULT = 50;
14
+ const LIMIT_MAX = 200;
15
+ export class ServiceError extends Error {
16
+ code;
17
+ constructor(code, message) {
18
+ super(message);
19
+ this.code = code;
20
+ this.name = 'ServiceError';
21
+ }
22
+ }
23
+ const parser = new Parser({
24
+ customFields: {
25
+ item: [
26
+ ['content:encoded', 'contentEncoded'],
27
+ ['dc:creator', 'dcCreator'],
28
+ ['updated', 'atomUpdated'],
29
+ ],
30
+ },
31
+ });
32
+ // ---------------------------------------------------------------------------
33
+ // Row -> DTO mappers
34
+ // ---------------------------------------------------------------------------
35
+ function mapFeed(row) {
36
+ return {
37
+ id: row.id,
38
+ feedUrl: row.feed_url,
39
+ siteUrl: row.site_url,
40
+ title: row.title,
41
+ description: row.description,
42
+ lastFetchedAt: row.last_fetched_at,
43
+ fetchError: row.fetch_error,
44
+ createdAt: row.created_at,
45
+ };
46
+ }
47
+ function mapFeedWithCounts(row) {
48
+ return {
49
+ ...mapFeed(row),
50
+ unreadCount: row.unread_count,
51
+ totalCount: row.total_count,
52
+ };
53
+ }
54
+ function mapItem(row) {
55
+ return {
56
+ id: row.id,
57
+ feedId: row.feed_id,
58
+ feedTitle: row.feed_title,
59
+ guid: row.guid,
60
+ link: row.link,
61
+ title: row.title,
62
+ author: row.author,
63
+ contentHtml: row.content_html,
64
+ summary: row.summary,
65
+ publishedAt: row.published_at,
66
+ fetchedAt: row.fetched_at,
67
+ isRead: row.is_read === 1,
68
+ isStarred: row.is_starred === 1,
69
+ };
70
+ }
71
+ function mapItemSummary(row) {
72
+ return {
73
+ id: row.id,
74
+ feedId: row.feed_id,
75
+ feedTitle: row.feed_title,
76
+ title: row.title,
77
+ author: row.author,
78
+ link: row.link,
79
+ summary: row.summary,
80
+ publishedAt: row.published_at,
81
+ isRead: row.is_read === 1,
82
+ isStarred: row.is_starred === 1,
83
+ };
84
+ }
85
+ // ---------------------------------------------------------------------------
86
+ // Derivation helpers (PLAN 5.5, 5.6)
87
+ // ---------------------------------------------------------------------------
88
+ function sha256(s) {
89
+ return createHash('sha256').update(s).digest('hex');
90
+ }
91
+ function derivePublishedAt(item) {
92
+ for (const c of [item.isoDate, item.pubDate, item.atomUpdated, item.updated]) {
93
+ if (!c)
94
+ continue;
95
+ const ms = Date.parse(c);
96
+ if (!Number.isNaN(ms))
97
+ return ms;
98
+ }
99
+ return Date.now(); // last resort; anchors a dateless item to first-seen time
100
+ }
101
+ // ---------------------------------------------------------------------------
102
+ // Feed counts query (reused by list + per-feed responses)
103
+ // ---------------------------------------------------------------------------
104
+ const FEED_COUNTS_SELECT = `
105
+ SELECT
106
+ f.id, f.feed_url, f.site_url, f.title, f.description, f.etag, f.last_modified,
107
+ f.last_fetched_at, f.fetch_error, f.created_at,
108
+ COALESCE(SUM(CASE WHEN i.is_read = 0 THEN 1 ELSE 0 END), 0) AS unread_count,
109
+ COALESCE(COUNT(i.id), 0) AS total_count
110
+ FROM feeds f
111
+ LEFT JOIN items i ON i.feed_id = f.id
112
+ `;
113
+ function getFeedWithCounts(id) {
114
+ const row = queryGet(`${FEED_COUNTS_SELECT} WHERE f.id = ? GROUP BY f.id`, id);
115
+ return row ? mapFeedWithCounts(row) : null;
116
+ }
117
+ function computeTotals() {
118
+ const row = queryGet(`SELECT
119
+ COUNT(*) AS all_count,
120
+ COALESCE(SUM(CASE WHEN is_read = 0 THEN 1 ELSE 0 END), 0) AS unread_count,
121
+ COALESCE(SUM(CASE WHEN is_starred = 1 THEN 1 ELSE 0 END), 0) AS starred_count
122
+ FROM items`);
123
+ return { all: row.all_count, unread: row.unread_count, starred: row.starred_count };
124
+ }
125
+ // ---------------------------------------------------------------------------
126
+ // listFeeds (PLAN 7 GET /api/feeds)
127
+ // ---------------------------------------------------------------------------
128
+ export function listFeeds() {
129
+ const rows = queryAll(`${FEED_COUNTS_SELECT} GROUP BY f.id ORDER BY f.title COLLATE NOCASE ASC, f.id ASC`);
130
+ return {
131
+ feeds: rows.map(mapFeedWithCounts),
132
+ totals: computeTotals(),
133
+ };
134
+ }
135
+ const INSERT_ITEM = `
136
+ INSERT INTO items
137
+ (feed_id, guid, link, title, author, content_html, summary, published_at, dedup_key)
138
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
139
+ ON CONFLICT(feed_id, dedup_key) DO NOTHING
140
+ `;
141
+ const UPDATE_ITEM = `
142
+ UPDATE items SET
143
+ title = ?,
144
+ author = ?,
145
+ content_html = ?,
146
+ summary = ?,
147
+ link = ?
148
+ WHERE feed_id = ? AND dedup_key = ?
149
+ `;
150
+ /**
151
+ * Parse + sanitize a feed body. Returns the derived feed metadata and a
152
+ * SYNCHRONOUS `commit(feedId)` closure that performs the dedup upsert and returns
153
+ * the count of truly-new items. The async parse/sanitize work runs here (no DB);
154
+ * the caller runs `commit` inside a transaction so node:sqlite never awaits
155
+ * mid-transaction. (PLAN 5.3-5.7)
156
+ */
157
+ async function parseAndPrepare(feedUrl, xml) {
158
+ const parsed = await parser.parseString(xml);
159
+ const feedMeta = {
160
+ title: (parsed.title ?? '').toString(),
161
+ siteUrl: parsed.link ?? null,
162
+ description: parsed.description ?? null,
163
+ };
164
+ // Derive + sanitize every item first (pure work, no DB).
165
+ const prepared = (parsed.items ?? []).map((raw) => {
166
+ // rss-parser's Item type omits several fields we read (author, id, updated),
167
+ // so widen it locally; all reads are optional/guarded.
168
+ const item = raw;
169
+ const rawContent = item.contentEncoded ?? item.content ?? item.summary ?? '';
170
+ const author = item.creator ?? item.dcCreator ?? item.author ?? null;
171
+ const guid = item.guid ?? item.id ?? null;
172
+ const link = item.link ?? null;
173
+ const title = (item.title ?? '(untitled)').toString();
174
+ return {
175
+ guid,
176
+ link,
177
+ title,
178
+ author: author === null || author === undefined ? null : String(author),
179
+ contentHtml: sanitizeArticleHtml(rawContent),
180
+ summary: toPlainText(item.contentSnippet ?? rawContent).trim().slice(0, SUMMARY_MAX) || null,
181
+ publishedAt: derivePublishedAt(item),
182
+ // guid, then link, then a STABLE synthetic hash of title + feedUrl (never a timestamp).
183
+ dedupKey: guid ?? link ?? sha256(`${title}\0${feedUrl}`),
184
+ };
185
+ });
186
+ function commit(feedId) {
187
+ // Pass 1: INSERT ... DO NOTHING for every item, measuring the total_changes()
188
+ // delta around the loop. DO NOTHING adds 0 on conflict and 1 on a real insert,
189
+ // so the delta is exactly the number of truly-new rows.
190
+ const insertStmt = db.prepare(INSERT_ITEM);
191
+ const before = totalChanges();
192
+ for (const p of prepared) {
193
+ insertStmt.run(feedId, p.guid, p.link, p.title, p.author, p.contentHtml, p.summary, p.publishedAt, p.dedupKey);
194
+ }
195
+ const newItems = totalChanges() - before;
196
+ // Pass 2: UPDATE mutable content for existing rows (idempotent for state:
197
+ // preserves published_at, fetched_at, is_read, is_starred, guid).
198
+ const updateStmt = db.prepare(UPDATE_ITEM);
199
+ for (const p of prepared) {
200
+ updateStmt.run(p.title, p.author, p.contentHtml, p.summary, p.link, feedId, p.dedupKey);
201
+ }
202
+ return newItems;
203
+ }
204
+ return { feedMeta, commit };
205
+ }
206
+ const UPDATE_FEED_ON_SUCCESS = `
207
+ UPDATE feeds SET
208
+ title = COALESCE(NULLIF(?, ''), title),
209
+ site_url = ?, description = ?,
210
+ etag = ?, last_modified = ?,
211
+ last_fetched_at = ?, fetch_error = NULL
212
+ WHERE id = ?
213
+ `;
214
+ async function refreshFeedRow(row) {
215
+ const now = Date.now();
216
+ try {
217
+ const result = await fetchFeed({
218
+ feedUrl: row.feed_url,
219
+ etag: row.etag,
220
+ lastModified: row.last_modified,
221
+ });
222
+ if (result.kind === 'notModified') {
223
+ // 304: nothing changed. Update last_fetched_at and clear any prior error.
224
+ db.prepare(`UPDATE feeds SET last_fetched_at = ?, fetch_error = NULL WHERE id = ?`).run(now, row.id);
225
+ return { feed: getFeedWithCounts(row.id), newItems: 0, ok: true };
226
+ }
227
+ // Parse + sanitize OUTSIDE the transaction (async, CPU work, no DB). Only the
228
+ // synchronous DB writes (commit) run inside the transaction so node:sqlite
229
+ // never awaits mid-transaction. CRITICAL (review fix #13): the new
230
+ // etag/last_modified are persisted only here, after a fully successful
231
+ // parse+upsert.
232
+ const { feedMeta, commit } = await parseAndPrepare(row.feed_url, result.xml);
233
+ const newItems = transaction(() => {
234
+ const inserted = commit(row.id);
235
+ db.prepare(UPDATE_FEED_ON_SUCCESS).run(feedMeta.title, feedMeta.siteUrl, feedMeta.description, result.etag, result.lastModified, now, row.id);
236
+ return inserted;
237
+ });
238
+ return { feed: getFeedWithCounts(row.id), newItems, ok: true };
239
+ }
240
+ catch (err) {
241
+ const message = err instanceof Error ? err.message : String(err);
242
+ db.prepare(`UPDATE feeds SET last_fetched_at = ?, fetch_error = ? WHERE id = ?`).run(now, message, row.id);
243
+ return { feed: getFeedWithCounts(row.id), newItems: 0, ok: false };
244
+ }
245
+ }
246
+ function getFeedRow(id) {
247
+ return queryGet(`SELECT * FROM feeds WHERE id = ?`, id);
248
+ }
249
+ /** Refresh one feed by id. 404 (NOT_FOUND) if the id is unknown; a fetch failure
250
+ * is data, not an error (returns the feed with fetchError set, newItems 0). */
251
+ export async function refreshFeed(id) {
252
+ const row = getFeedRow(id);
253
+ if (!row)
254
+ throw new ServiceError('NOT_FOUND', 'Feed not found.');
255
+ const outcome = await refreshFeedRow(row);
256
+ return { feed: outcome.feed, newItems: outcome.newItems };
257
+ }
258
+ /** Refresh every feed sequentially. Never fails because one feed errored;
259
+ * tallies refreshed/failed/newItems and returns the full updated feed list. */
260
+ export async function refreshAll() {
261
+ const rows = queryAll(`SELECT * FROM feeds ORDER BY id ASC`);
262
+ let refreshed = 0;
263
+ let failed = 0;
264
+ let newItems = 0;
265
+ // Sequential, one transaction per feed (commit per feed keeps each transaction
266
+ // short on the synchronous DB; one failure never aborts the loop).
267
+ for (const row of rows) {
268
+ try {
269
+ const outcome = await refreshFeedRow(row);
270
+ if (outcome.ok)
271
+ refreshed += 1;
272
+ else
273
+ failed += 1;
274
+ newItems += outcome.newItems;
275
+ }
276
+ catch {
277
+ // refreshFeedRow already records its own errors; this guards against any
278
+ // unexpected throw so the loop always completes.
279
+ failed += 1;
280
+ }
281
+ }
282
+ return { feeds: listFeeds().feeds, refreshed, failed, newItems };
283
+ }
284
+ // ---------------------------------------------------------------------------
285
+ // addFeed (PLAN 7 POST /api/feeds)
286
+ // ---------------------------------------------------------------------------
287
+ // Validate URL (SSRF guard), insert the feed row, then do the initial
288
+ // fetch+parse+store synchronously. On parse failure the whole thing rolls back
289
+ // and surfaces as 422 (there is no existing row to attach the error to).
290
+ export async function addFeed(url) {
291
+ const trimmed = (url ?? '').trim();
292
+ if (!trimmed)
293
+ throw new ServiceError('BAD_INPUT', 'A feed URL is required.');
294
+ // SSRF guard up front (also validates scheme + resolvability).
295
+ try {
296
+ await assertSafeFeedUrl(trimmed);
297
+ }
298
+ catch (err) {
299
+ const message = err instanceof Error ? err.message : 'Unsafe or invalid URL.';
300
+ throw new ServiceError('UNSAFE_URL', message);
301
+ }
302
+ // Reject a duplicate before doing any network work.
303
+ const existing = queryGet(`SELECT id FROM feeds WHERE feed_url = ?`, trimmed);
304
+ if (existing)
305
+ throw new ServiceError('DUPLICATE', 'That feed is already subscribed.');
306
+ // Fetch + parse BEFORE touching the DB so a bad feed never leaves a row behind.
307
+ let fetchResult;
308
+ try {
309
+ fetchResult = await fetchFeed({ feedUrl: trimmed, etag: null, lastModified: null });
310
+ }
311
+ catch (err) {
312
+ const message = err instanceof Error ? err.message : String(err);
313
+ throw new ServiceError('UNPARSEABLE', `Could not fetch the feed: ${message}`);
314
+ }
315
+ if (fetchResult.kind === 'notModified') {
316
+ // No validators were sent, so a 304 here is anomalous; treat as unparseable.
317
+ throw new ServiceError('UNPARSEABLE', 'The server returned 304 with no prior validators.');
318
+ }
319
+ let prepared;
320
+ try {
321
+ prepared = await parseAndPrepare(trimmed, fetchResult.xml);
322
+ }
323
+ catch (err) {
324
+ const message = err instanceof Error ? err.message : String(err);
325
+ throw new ServiceError('UNPARSEABLE', `That URL did not parse as a feed: ${message}`);
326
+ }
327
+ const now = Date.now();
328
+ const feedId = transaction(() => {
329
+ // INSERT may still race a concurrent add; the UNIQUE constraint protects us.
330
+ const ins = run(`INSERT INTO feeds (feed_url, last_fetched_at) VALUES (?, ?)`, trimmed, now);
331
+ const id = ins.lastInsertRowid;
332
+ prepared.commit(id);
333
+ db.prepare(UPDATE_FEED_ON_SUCCESS).run(prepared.feedMeta.title, prepared.feedMeta.siteUrl, prepared.feedMeta.description, fetchResult.etag, fetchResult.lastModified, now, id);
334
+ return id;
335
+ });
336
+ const feed = getFeedWithCounts(feedId);
337
+ if (!feed)
338
+ throw new ServiceError('NOT_FOUND', 'Feed disappeared after insert.');
339
+ return feed;
340
+ }
341
+ // ---------------------------------------------------------------------------
342
+ // deleteFeed (PLAN 7 DELETE /api/feeds/:id) — cascades to items via FK.
343
+ // ---------------------------------------------------------------------------
344
+ export function deleteFeed(id) {
345
+ const res = run(`DELETE FROM feeds WHERE id = ?`, id);
346
+ if (res.changes === 0)
347
+ throw new ServiceError('NOT_FOUND', 'Feed not found.');
348
+ }
349
+ // ---------------------------------------------------------------------------
350
+ // listItems (PLAN 7 GET /api/items) — dynamic WHERE built from fixed fragments
351
+ // with bound params only; limit/offset coerced + clamped before binding.
352
+ // ---------------------------------------------------------------------------
353
+ const ITEM_SELECT = `
354
+ SELECT
355
+ i.id, i.feed_id, i.guid, i.link, i.title, i.author, i.content_html, i.summary,
356
+ i.published_at, i.fetched_at, i.is_read, i.is_starred, i.dedup_key,
357
+ f.title AS feed_title
358
+ FROM items i
359
+ JOIN feeds f ON f.id = i.feed_id
360
+ `;
361
+ function buildItemFilters(query) {
362
+ const clauses = [];
363
+ const params = [];
364
+ if (typeof query.feedId === 'number' && Number.isFinite(query.feedId)) {
365
+ clauses.push('i.feed_id = ?');
366
+ params.push(Math.trunc(query.feedId));
367
+ }
368
+ const view = query.view ?? 'all';
369
+ if (view === 'unread') {
370
+ clauses.push('i.is_read = 0');
371
+ }
372
+ else if (view === 'starred') {
373
+ clauses.push('i.is_starred = 1');
374
+ }
375
+ const q = (query.q ?? '').trim();
376
+ if (q) {
377
+ // Case-insensitive substring on title + summary. LIKE is case-insensitive for
378
+ // ASCII; we lower() both sides and escape LIKE wildcards in the user term.
379
+ const term = `%${q.toLowerCase().replace(/[\\%_]/g, (m) => `\\${m}`)}%`;
380
+ clauses.push("(lower(i.title) LIKE ? ESCAPE '\\' OR lower(COALESCE(i.summary, '')) LIKE ? ESCAPE '\\')");
381
+ params.push(term, term);
382
+ }
383
+ const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
384
+ return { where, params };
385
+ }
386
+ export function listItems(query) {
387
+ const limit = clampLimit(query.limit);
388
+ const offset = clampOffset(query.offset);
389
+ const { where, params } = buildItemFilters(query);
390
+ const total = queryGet(`SELECT COUNT(*) AS n FROM items i ${where}`, ...params).n;
391
+ const rows = queryAll(`${ITEM_SELECT} ${where} ORDER BY i.published_at DESC, i.id DESC LIMIT ? OFFSET ?`, ...params, limit, offset);
392
+ return { items: rows.map(mapItemSummary), total, limit, offset };
393
+ }
394
+ export function clampLimit(raw) {
395
+ if (raw === undefined || !Number.isFinite(raw))
396
+ return LIMIT_DEFAULT;
397
+ const n = Math.trunc(raw);
398
+ if (n < 1)
399
+ return 1;
400
+ if (n > LIMIT_MAX)
401
+ return LIMIT_MAX;
402
+ return n;
403
+ }
404
+ export function clampOffset(raw) {
405
+ if (raw === undefined || !Number.isFinite(raw))
406
+ return 0;
407
+ const n = Math.trunc(raw);
408
+ return n < 0 ? 0 : n;
409
+ }
410
+ // ---------------------------------------------------------------------------
411
+ // getItem (PLAN 7 GET /api/items/:id) — full item incl sanitized contentHtml.
412
+ // ---------------------------------------------------------------------------
413
+ export function getItem(id) {
414
+ const row = queryGet(`${ITEM_SELECT} WHERE i.id = ?`, id);
415
+ if (!row)
416
+ throw new ServiceError('NOT_FOUND', 'Item not found.');
417
+ return mapItem(row);
418
+ }
419
+ // ---------------------------------------------------------------------------
420
+ // patchItem (PLAN 7 PATCH /api/items/:id) — update read/starred state.
421
+ // ---------------------------------------------------------------------------
422
+ export function patchItem(id, patch) {
423
+ const sets = [];
424
+ const params = [];
425
+ if (typeof patch.isRead === 'boolean') {
426
+ sets.push('is_read = ?');
427
+ params.push(patch.isRead ? 1 : 0);
428
+ }
429
+ if (typeof patch.isStarred === 'boolean') {
430
+ sets.push('is_starred = ?');
431
+ params.push(patch.isStarred ? 1 : 0);
432
+ }
433
+ if (sets.length === 0) {
434
+ throw new ServiceError('BAD_INPUT', 'Provide at least one of isRead or isStarred.');
435
+ }
436
+ const res = run(`UPDATE items SET ${sets.join(', ')} WHERE id = ?`, ...params, id);
437
+ if (res.changes === 0)
438
+ throw new ServiceError('NOT_FOUND', 'Item not found.');
439
+ const row = queryGet(`${ITEM_SELECT} WHERE i.id = ?`, id);
440
+ return mapItemSummary(row);
441
+ }
442
+ // ---------------------------------------------------------------------------
443
+ // markAllRead (PLAN 7 POST /api/items/mark-all-read) — scope = all|unread|starred
444
+ // optionally constrained to a feed. Marking always sets is_read = 1.
445
+ // ---------------------------------------------------------------------------
446
+ export function markAllRead(scope) {
447
+ const clauses = ['is_read = 0']; // only count rows we actually flip
448
+ const params = [];
449
+ if (typeof scope.feedId === 'number' && Number.isFinite(scope.feedId)) {
450
+ clauses.push('feed_id = ?');
451
+ params.push(Math.trunc(scope.feedId));
452
+ }
453
+ // view 'all' and 'unread' mark the same set (reading sets is_read = 1);
454
+ // 'starred' marks all currently-starred items read.
455
+ if (scope.view === 'starred') {
456
+ clauses.push('is_starred = 1');
457
+ }
458
+ const res = run(`UPDATE items SET is_read = 1 WHERE ${clauses.join(' AND ')}`, ...params);
459
+ return res.changes;
460
+ }
461
+ // ---------------------------------------------------------------------------
462
+ // importFeeds (PLAN 7 POST /api/opml) — bulk add from a deduped URL list.
463
+ // ---------------------------------------------------------------------------
464
+ // Each URL is SSRF-guarded; feeds already present are skipped; new feeds are
465
+ // inserted and fetched best-effort (a fetch/parse failure is recorded in
466
+ // fetchError but the import still counts it as added, not failed). A guard
467
+ // rejection or insert race counts as failed.
468
+ export async function importFeeds(urls) {
469
+ let added = 0;
470
+ let skipped = 0;
471
+ let failed = 0;
472
+ for (const url of urls) {
473
+ const trimmed = url.trim();
474
+ if (!trimmed) {
475
+ failed += 1;
476
+ continue;
477
+ }
478
+ // Skip duplicates (dedup on feedUrl) before any network work.
479
+ const existing = queryGet(`SELECT id FROM feeds WHERE feed_url = ?`, trimmed);
480
+ if (existing) {
481
+ skipped += 1;
482
+ continue;
483
+ }
484
+ // SSRF guard before fetching.
485
+ try {
486
+ await assertSafeFeedUrl(trimmed);
487
+ }
488
+ catch {
489
+ failed += 1;
490
+ continue;
491
+ }
492
+ // Insert the row first so an initial-fetch failure can be recorded against it
493
+ // (import still succeeds; the feed shows an error badge in the UI).
494
+ let feedId;
495
+ try {
496
+ feedId = run(`INSERT INTO feeds (feed_url) VALUES (?)`, trimmed).lastInsertRowid;
497
+ }
498
+ catch {
499
+ // UNIQUE race or other insert error.
500
+ failed += 1;
501
+ continue;
502
+ }
503
+ added += 1;
504
+ // Best-effort initial fetch; refreshFeedRow records its own errors and never
505
+ // throws for fetch/parse failures.
506
+ const row = getFeedRow(feedId);
507
+ if (row) {
508
+ try {
509
+ await refreshFeedRow(row);
510
+ }
511
+ catch {
512
+ // Already recorded on the feed; nothing more to do.
513
+ }
514
+ }
515
+ }
516
+ return { added, skipped, failed, feeds: listFeeds().feeds };
517
+ }
518
+ /** All feeds as plain Feed DTOs (for OPML export). */
519
+ export function listFeedsForExport() {
520
+ const rows = queryAll(`SELECT * FROM feeds ORDER BY title COLLATE NOCASE ASC, id ASC`);
521
+ return rows.map(mapFeed);
522
+ }
523
+ //# sourceMappingURL=feed-service.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"feed-service.js","sourceRoot":"","sources":["../../src/server/feed-service.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,83 @@
1
+ // src/server/fetch-feed.ts
2
+ // Streaming, size-capped, conditional-GET fetch of a feed URL. (PLAN 5.2)
3
+ // The body is read incrementally and the request is aborted the moment cumulative
4
+ // bytes exceed MAX_BYTES; the whole response is never buffered before the check.
5
+ import { fetchWithGuardedRedirects } from './ssrf.js';
6
+ const USER_AGENT = 'iread/1.0 (+local RSS reader)';
7
+ const FETCH_TIMEOUT_MS = 15_000;
8
+ const MAX_BYTES = 10 * 1024 * 1024; // 10 MB
9
+ function concat(chunks, total) {
10
+ const out = new Uint8Array(total);
11
+ let offset = 0;
12
+ for (const chunk of chunks) {
13
+ out.set(chunk, offset);
14
+ offset += chunk.byteLength;
15
+ }
16
+ return out;
17
+ }
18
+ export async function fetchFeed(input) {
19
+ const ctrl = new AbortController();
20
+ const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
21
+ try {
22
+ const headers = {
23
+ 'User-Agent': USER_AGENT,
24
+ Accept: 'application/rss+xml, application/atom+xml, application/xml, text/xml, */*;q=0.1',
25
+ };
26
+ if (input.etag)
27
+ headers['If-None-Match'] = input.etag;
28
+ if (input.lastModified)
29
+ headers['If-Modified-Since'] = input.lastModified;
30
+ // assertSafeFeedUrl + manual redirect loop (max 5 hops, each hop re-checked)
31
+ // is enforced inside fetchWithGuardedRedirects.
32
+ const res = await fetchWithGuardedRedirects(input.feedUrl, {
33
+ headers,
34
+ signal: ctrl.signal,
35
+ });
36
+ if (res.status === 304) {
37
+ await res.body?.cancel().catch(() => { });
38
+ return { kind: 'notModified' };
39
+ }
40
+ if (!res.ok) {
41
+ await res.body?.cancel().catch(() => { });
42
+ throw new Error(`HTTP ${res.status} ${res.statusText}`);
43
+ }
44
+ // Fast reject on advertised Content-Length (untrusted but cheap).
45
+ const cl = Number(res.headers.get('content-length'));
46
+ if (Number.isFinite(cl) && cl > MAX_BYTES) {
47
+ await res.body?.cancel().catch(() => { });
48
+ throw new Error(`feed too large (${cl} bytes)`);
49
+ }
50
+ if (!res.body) {
51
+ throw new Error('empty response body');
52
+ }
53
+ // STREAM the body, aborting once cumulative size exceeds the cap. Never
54
+ // buffer the whole response before checking (arrayBuffer() would OOM first).
55
+ const reader = res.body.getReader();
56
+ const chunks = [];
57
+ let total = 0;
58
+ for (;;) {
59
+ const { done, value } = await reader.read();
60
+ if (done)
61
+ break;
62
+ if (!value)
63
+ continue;
64
+ total += value.byteLength;
65
+ if (total > MAX_BYTES) {
66
+ ctrl.abort();
67
+ throw new Error(`feed too large (> ${MAX_BYTES} bytes)`);
68
+ }
69
+ chunks.push(value);
70
+ }
71
+ const xml = new TextDecoder('utf-8').decode(concat(chunks, total));
72
+ return {
73
+ kind: 'ok',
74
+ xml,
75
+ etag: res.headers.get('etag'),
76
+ lastModified: res.headers.get('last-modified'),
77
+ };
78
+ }
79
+ finally {
80
+ clearTimeout(timer);
81
+ }
82
+ }
83
+ //# sourceMappingURL=fetch-feed.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fetch-feed.js","sourceRoot":"","sources":["../../src/server/fetch-feed.ts"],"names":[],"mappings":"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"}