@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,62 @@
1
+ // src/server/index.ts
2
+ // Hono entry point. Registers all /api/* routers FIRST; unknown /api/* paths
3
+ // return a JSON 404 in the uniform error shape (never HTML). In production serves
4
+ // the static web bundle from ../web (relative to the emitted dist/server/index.js)
5
+ // with SPA fallback to index.html for non-/api GET requests. (PLAN 7, 9, 10)
6
+ import { fileURLToPath } from 'node:url';
7
+ import { dirname, resolve } from 'node:path';
8
+ import { readFile } from 'node:fs/promises';
9
+ import { Hono } from 'hono';
10
+ import { serve } from '@hono/node-server';
11
+ import { serveStatic } from '@hono/node-server/serve-static';
12
+ import { feeds } from './routes/feeds.js';
13
+ import { items } from './routes/items.js';
14
+ import { opml } from './routes/opml.js';
15
+ const PORT = Number(process.env.PORT ?? 8787);
16
+ const IS_PROD = process.env.NODE_ENV === 'production';
17
+ // Importing ./db has the side effect of opening the DB and running migrations.
18
+ // Do it explicitly so startup failures surface immediately.
19
+ import './db.js';
20
+ const app = new Hono();
21
+ // --- API routes (registered FIRST) -----------------------------------------
22
+ const api = new Hono();
23
+ api.route('/feeds', feeds);
24
+ api.route('/items', items);
25
+ api.route('/opml', opml);
26
+ app.route('/api', api);
27
+ // Unknown /api/* -> JSON 404 in the uniform error shape, never the SPA HTML.
28
+ app.all('/api/*', (c) => {
29
+ const body = {
30
+ error: { message: `Not found: ${c.req.method} ${c.req.path}`, code: 'NOT_FOUND' },
31
+ };
32
+ return c.json(body, 404);
33
+ });
34
+ // --- Static + SPA fallback (production only) --------------------------------
35
+ if (IS_PROD) {
36
+ // Emitted file is dist/server/index.js; the web bundle is dist/web. The static
37
+ // root is therefore ../web relative to this module. serveStatic resolves only
38
+ // within that root (no ../ traversal escapes it).
39
+ const serverDir = dirname(fileURLToPath(import.meta.url));
40
+ const webRoot = resolve(serverDir, '../web');
41
+ const indexHtmlPath = resolve(webRoot, 'index.html');
42
+ // Serve real static assets first.
43
+ app.use('/*', serveStatic({ root: webRoot }));
44
+ // SPA fallback: any non-/api GET that did not match a static file returns
45
+ // index.html. (The /api/* handlers above already returned for API paths.)
46
+ app.get('/*', async (c) => {
47
+ try {
48
+ const html = await readFile(indexHtmlPath, 'utf-8');
49
+ return c.html(html);
50
+ }
51
+ catch {
52
+ return c.text('Web bundle not found. Run `pnpm build` first.', 500);
53
+ }
54
+ });
55
+ }
56
+ // --- Start ------------------------------------------------------------------
57
+ serve({ fetch: app.fetch, port: PORT }, (info) => {
58
+ const url = `http://localhost:${info.port}`;
59
+ console.log(`[iread] listening on ${url}`);
60
+ });
61
+ export { app };
62
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/server/index.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,137 @@
1
+ // src/server/opml.ts
2
+ // OPML import (XXE-safe parse -> feed URLs) and export (feeds -> OPML 2.0 XML).
3
+ // (PLAN 7 OPML, 10 XXE)
4
+ import { XMLParser } from 'fast-xml-parser';
5
+ const MAX_OPML_BYTES = 5 * 1024 * 1024; // 5 MB
6
+ export class OpmlError extends Error {
7
+ name = 'OpmlError';
8
+ }
9
+ // ---------------------------------------------------------------------------
10
+ // Import
11
+ // ---------------------------------------------------------------------------
12
+ const parser = new XMLParser({
13
+ ignoreAttributes: false,
14
+ attributeNamePrefix: '@_',
15
+ // XXE / billion-laughs defense: do NOT process DOCTYPE or expand any entities.
16
+ processEntities: false,
17
+ allowBooleanAttributes: true,
18
+ // Treat every <outline> as a potential array element so nested structures and
19
+ // single-child documents are handled uniformly.
20
+ isArray: (name) => name === 'outline',
21
+ });
22
+ /**
23
+ * Recursively collect every `xmlUrl` from the parsed OPML object tree. OPML nests
24
+ * <outline> elements under <body> and allows folder hierarchies; we walk every
25
+ * object/array value depth-first and flatten, since iread has no folder concept.
26
+ */
27
+ function collectXmlUrls(node, out) {
28
+ if (node === null || typeof node !== 'object')
29
+ return;
30
+ if (Array.isArray(node)) {
31
+ for (const child of node)
32
+ collectXmlUrls(child, out);
33
+ return;
34
+ }
35
+ const obj = node;
36
+ // Pick up an xmlUrl attribute on this node (attributeNamePrefix '@_'). Some
37
+ // exporters vary the casing of the attribute name.
38
+ const xmlUrl = obj['@_xmlUrl'] ?? obj['@_xmlurl'] ?? obj['@_xmlURL'];
39
+ if (typeof xmlUrl === 'string') {
40
+ const trimmed = xmlUrl.trim();
41
+ if (trimmed)
42
+ out.push(trimmed);
43
+ }
44
+ // Recurse into every child value so nested <body>/<outline> trees are reached
45
+ // regardless of depth. Attribute values (string/number) are skipped by the
46
+ // typeof guard at the top.
47
+ for (const key of Object.keys(obj)) {
48
+ if (key.startsWith('@_'))
49
+ continue; // attributes, not child elements
50
+ collectXmlUrls(obj[key], out);
51
+ }
52
+ }
53
+ /**
54
+ * Parse an OPML document and return the de-duplicated list of feed `xmlUrl`s in
55
+ * first-seen order. Rejects oversized input and documents that declare a DOCTYPE.
56
+ * Throws OpmlError on parse failure / size violation.
57
+ */
58
+ export function importOpml(xml) {
59
+ if (typeof xml !== 'string' || xml.trim() === '') {
60
+ throw new OpmlError('OPML body is empty.');
61
+ }
62
+ // Byte length, not character count, to match the on-wire size.
63
+ if (Buffer.byteLength(xml, 'utf-8') > MAX_OPML_BYTES) {
64
+ throw new OpmlError('OPML document exceeds the 5 MB size limit.');
65
+ }
66
+ // Defense in depth: reject any DOCTYPE outright (processEntities:false already
67
+ // refuses to expand entities, but we never want to even see a DTD).
68
+ if (/<!DOCTYPE/i.test(xml)) {
69
+ throw new OpmlError('OPML documents with a DOCTYPE are not allowed.');
70
+ }
71
+ let doc;
72
+ try {
73
+ doc = parser.parse(xml);
74
+ }
75
+ catch (err) {
76
+ const message = err instanceof Error ? err.message : String(err);
77
+ throw new OpmlError(`Could not parse OPML: ${message}`);
78
+ }
79
+ if (doc === null || typeof doc !== 'object') {
80
+ throw new OpmlError('OPML document is not well-formed.');
81
+ }
82
+ const root = doc;
83
+ const opml = root['opml'];
84
+ // Body lives at opml.body.outline, but some exporters omit the <opml> wrapper.
85
+ // Collect from the whole tree to be liberal in what we accept.
86
+ const urls = [];
87
+ collectXmlUrls(opml ?? root, urls);
88
+ // De-dup within the file, preserving first-seen order, so two identical
89
+ // xmlUrls do not collide on the UNIQUE index mid-transaction.
90
+ const seen = new Set();
91
+ const deduped = [];
92
+ for (const u of urls) {
93
+ if (!seen.has(u)) {
94
+ seen.add(u);
95
+ deduped.push(u);
96
+ }
97
+ }
98
+ return deduped;
99
+ }
100
+ // ---------------------------------------------------------------------------
101
+ // Export
102
+ // ---------------------------------------------------------------------------
103
+ function escapeXmlAttr(value) {
104
+ return value
105
+ .replace(/&/g, '&amp;')
106
+ .replace(/</g, '&lt;')
107
+ .replace(/>/g, '&gt;')
108
+ .replace(/"/g, '&quot;')
109
+ .replace(/'/g, '&apos;');
110
+ }
111
+ /**
112
+ * Produce an OPML 2.0 document, one <outline type="rss"> per feed with
113
+ * text/title/xmlUrl (and htmlUrl when a site URL is known). Attributes are
114
+ * XML-escaped.
115
+ */
116
+ export function exportOpml(feeds) {
117
+ const now = new Date().toUTCString();
118
+ const lines = [];
119
+ lines.push('<?xml version="1.0" encoding="UTF-8"?>');
120
+ lines.push('<opml version="2.0">');
121
+ lines.push(' <head>');
122
+ lines.push(' <title>iread subscriptions</title>');
123
+ lines.push(` <dateCreated>${escapeXmlAttr(now)}</dateCreated>`);
124
+ lines.push(' </head>');
125
+ lines.push(' <body>');
126
+ for (const feed of feeds) {
127
+ const text = escapeXmlAttr(feed.title || feed.feedUrl);
128
+ const xmlUrl = escapeXmlAttr(feed.feedUrl);
129
+ const htmlUrlAttr = feed.siteUrl ? ` htmlUrl="${escapeXmlAttr(feed.siteUrl)}"` : '';
130
+ lines.push(` <outline type="rss" text="${text}" title="${text}" xmlUrl="${xmlUrl}"${htmlUrlAttr} />`);
131
+ }
132
+ lines.push(' </body>');
133
+ lines.push('</opml>');
134
+ lines.push('');
135
+ return lines.join('\n');
136
+ }
137
+ //# sourceMappingURL=opml.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"opml.js","sourceRoot":"","sources":["../../src/server/opml.ts"],"names":[],"mappings":"AAAA,qBAAqB;AACrB,gFAAgF;AAChF,wBAAwB;AAExB,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAG5C,MAAM,cAAc,GAAG,CAAC,GAAG,IAAI,GAAG,IAAI,CAAC,CAAC,OAAO;AAE/C,MAAM,OAAO,SAAU,SAAQ,KAAK;IACzB,IAAI,GAAG,WAAW,CAAC;CAC7B;AAED,8EAA8E;AAC9E,SAAS;AACT,8EAA8E;AAE9E,MAAM,MAAM,GAAG,IAAI,SAAS,CAAC;IAC3B,gBAAgB,EAAE,KAAK;IACvB,mBAAmB,EAAE,IAAI;IACzB,+EAA+E;IAC/E,eAAe,EAAE,KAAK;IACtB,sBAAsB,EAAE,IAAI;IAC5B,8EAA8E;IAC9E,gDAAgD;IAChD,OAAO,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,KAAK,SAAS;CACtC,CAAC,CAAC;AAEH;;;;GAIG;AACH,SAAS,cAAc,CAAC,IAAa,EAAE,GAAa;IAClD,IAAI,IAAI,KAAK,IAAI,IAAI,OAAO,IAAI,KAAK,QAAQ;QAAE,OAAO;IAEtD,IAAI,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC;QACxB,KAAK,MAAM,KAAK,IAAI,IAAI;YAAE,cAAc,CAAC,KAAK,EAAE,GAAG,CAAC,CAAC;QACrD,OAAO;IACT,CAAC;IAED,MAAM,GAAG,GAAG,IAA+B,CAAC;IAE5C,4EAA4E;IAC5E,mDAAmD;IACnD,MAAM,MAAM,GAAG,GAAG,CAAC,UAAU,CAAC,IAAI,GAAG,CAAC,UAAU,CAAC,IAAI,GAAG,CAAC,UAAU,CAAC,CAAC;IACrE,IAAI,OAAO,MAAM,KAAK,QAAQ,EAAE,CAAC;QAC/B,MAAM,OAAO,GAAG,MAAM,CAAC,IAAI,EAAE,CAAC;QAC9B,IAAI,OAAO;YAAE,GAAG,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;IACjC,CAAC;IAED,8EAA8E;IAC9E,2EAA2E;IAC3E,2BAA2B;IAC3B,KAAK,MAAM,GAAG,IAAI,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC;QACnC,IAAI,GAAG,CAAC,UAAU,CAAC,IAAI,CAAC;YAAE,SAAS,CAAC,iCAAiC;QACrE,cAAc,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,GAAG,CAAC,CAAC;IAChC,CAAC;AACH,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,UAAU,CAAC,GAAW;IACpC,IAAI,OAAO,GAAG,KAAK,QAAQ,IAAI,GAAG,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE,CAAC;QACjD,MAAM,IAAI,SAAS,CAAC,qBAAqB,CAAC,CAAC;IAC7C,CAAC;IACD,+DAA+D;IAC/D,IAAI,MAAM,CAAC,UAAU,CAAC,GAAG,EAAE,OAAO,CAAC,GAAG,cAAc,EAAE,CAAC;QACrD,MAAM,IAAI,SAAS,CAAC,4CAA4C,CAAC,CAAC;IACpE,CAAC;IACD,+EAA+E;IAC/E,oEAAoE;IACpE,IAAI,YAAY,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC;QAC3B,MAAM,IAAI,SAAS,CAAC,gDAAgD,CAAC,CAAC;IACxE,CAAC;IAED,IAAI,GAAY,CAAC;IACjB,IAAI,CAAC;QACH,GAAG,GAAG,MAAM,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IAC1B,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,MAAM,OAAO,GAAG,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;QACjE,MAAM,IAAI,SAAS,CAAC,yBAAyB,OAAO,EAAE,CAAC,CAAC;IAC1D,CAAC;IAED,IAAI,GAAG,KAAK,IAAI,IAAI,OAAO,GAAG,KAAK,QAAQ,EAAE,CAAC;QAC5C,MAAM,IAAI,SAAS,CAAC,mCAAmC,CAAC,CAAC;IAC3D,CAAC;IAED,MAAM,IAAI,GAAG,GAA8B,CAAC;IAC5C,MAAM,IAAI,GAAG,IAAI,CAAC,MAAM,CAAC,CAAC;IAC1B,+EAA+E;IAC/E,+DAA+D;IAC/D,MAAM,IAAI,GAAa,EAAE,CAAC;IAC1B,cAAc,CAAC,IAAI,IAAI,IAAI,EAAE,IAAI,CAAC,CAAC;IAEnC,wEAAwE;IACxE,8DAA8D;IAC9D,MAAM,IAAI,GAAG,IAAI,GAAG,EAAU,CAAC;IAC/B,MAAM,OAAO,GAAa,EAAE,CAAC;IAC7B,KAAK,MAAM,CAAC,IAAI,IAAI,EAAE,CAAC;QACrB,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC;YACjB,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;YACZ,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QAClB,CAAC;IACH,CAAC;IACD,OAAO,OAAO,CAAC;AACjB,CAAC;AAED,8EAA8E;AAC9E,SAAS;AACT,8EAA8E;AAE9E,SAAS,aAAa,CAAC,KAAa;IAClC,OAAO,KAAK;SACT,OAAO,CAAC,IAAI,EAAE,OAAO,CAAC;SACtB,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC;SACrB,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC;SACrB,OAAO,CAAC,IAAI,EAAE,QAAQ,CAAC;SACvB,OAAO,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC;AAC7B,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,UAAU,CAAC,KAAa;IACtC,MAAM,GAAG,GAAG,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC;IACrC,MAAM,KAAK,GAAa,EAAE,CAAC;IAC3B,KAAK,CAAC,IAAI,CAAC,wCAAwC,CAAC,CAAC;IACrD,KAAK,CAAC,IAAI,CAAC,sBAAsB,CAAC,CAAC;IACnC,KAAK,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC;IACvB,KAAK,CAAC,IAAI,CAAC,wCAAwC,CAAC,CAAC;IACrD,KAAK,CAAC,IAAI,CAAC,oBAAoB,aAAa,CAAC,GAAG,CAAC,gBAAgB,CAAC,CAAC;IACnE,KAAK,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC;IACxB,KAAK,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC;IACvB,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QACzB,MAAM,IAAI,GAAG,aAAa,CAAC,IAAI,CAAC,KAAK,IAAI,IAAI,CAAC,OAAO,CAAC,CAAC;QACvD,MAAM,MAAM,GAAG,aAAa,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;QAC3C,MAAM,WAAW,GAAG,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,aAAa,aAAa,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC;QACpF,KAAK,CAAC,IAAI,CACR,iCAAiC,IAAI,YAAY,IAAI,aAAa,MAAM,IAAI,WAAW,KAAK,CAC7F,CAAC;IACJ,CAAC;IACD,KAAK,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC;IACxB,KAAK,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;IACtB,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IACf,OAAO,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AAC1B,CAAC"}
@@ -0,0 +1,68 @@
1
+ // src/server/routes/feeds.ts
2
+ // /api/feeds router: list, add, delete, refresh-one, refresh-all. (PLAN 7 Feeds)
3
+ import { Hono } from 'hono';
4
+ import { addFeed, deleteFeed, listFeeds, refreshAll, refreshFeed, } from '../feed-service.js';
5
+ import { errorResponse, parseId, serviceErrorToResponse } from './helpers.js';
6
+ export const feeds = new Hono();
7
+ // IMPORTANT: register the static "/refresh" route before the parameterized
8
+ // ":id/refresh" route so "refresh" is never captured as an :id.
9
+ // GET /api/feeds — list feeds with counts + global totals.
10
+ feeds.get('/', (c) => {
11
+ const body = listFeeds();
12
+ return c.json(body, 200);
13
+ });
14
+ // POST /api/feeds — add a feed by URL (fetches immediately).
15
+ feeds.post('/', async (c) => {
16
+ let payload;
17
+ try {
18
+ payload = await c.req.json();
19
+ }
20
+ catch {
21
+ return errorResponse(c, 400, 'Request body must be JSON.');
22
+ }
23
+ if (!payload || typeof payload.url !== 'string') {
24
+ return errorResponse(c, 400, 'Body must include a "url" string.', 'BAD_INPUT');
25
+ }
26
+ try {
27
+ const feed = await addFeed(payload.url);
28
+ const body = { feed };
29
+ return c.json(body, 201);
30
+ }
31
+ catch (err) {
32
+ return serviceErrorToResponse(c, err);
33
+ }
34
+ });
35
+ // POST /api/feeds/refresh — refresh all feeds (always 200).
36
+ feeds.post('/refresh', async (c) => {
37
+ const result = await refreshAll();
38
+ const body = result;
39
+ return c.json(body, 200);
40
+ });
41
+ // POST /api/feeds/:id/refresh — refresh one feed.
42
+ feeds.post('/:id/refresh', async (c) => {
43
+ const id = parseId(c.req.param('id'));
44
+ if (id === null)
45
+ return errorResponse(c, 400, 'Invalid feed id.', 'BAD_INPUT');
46
+ try {
47
+ const result = await refreshFeed(id);
48
+ const body = result;
49
+ return c.json(body, 200);
50
+ }
51
+ catch (err) {
52
+ return serviceErrorToResponse(c, err);
53
+ }
54
+ });
55
+ // DELETE /api/feeds/:id — delete a feed (cascades to items).
56
+ feeds.delete('/:id', (c) => {
57
+ const id = parseId(c.req.param('id'));
58
+ if (id === null)
59
+ return errorResponse(c, 400, 'Invalid feed id.', 'BAD_INPUT');
60
+ try {
61
+ deleteFeed(id);
62
+ return c.body(null, 204);
63
+ }
64
+ catch (err) {
65
+ return serviceErrorToResponse(c, err);
66
+ }
67
+ });
68
+ //# sourceMappingURL=feeds.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"feeds.js","sourceRoot":"","sources":["../../../src/server/routes/feeds.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,44 @@
1
+ // src/server/routes/helpers.ts
2
+ // Shared route utilities: uniform error responses, id/number parsing, and a
3
+ // mapping from ServiceError codes to HTTP statuses.
4
+ import { ServiceError } from '../feed-service.js';
5
+ /** Emit the uniform error shape `{ error: { message, code? } }`. */
6
+ export function errorResponse(c, status, message, code) {
7
+ const body = { error: code ? { message, code } : { message } };
8
+ return c.json(body, status);
9
+ }
10
+ const CODE_TO_STATUS = {
11
+ BAD_INPUT: 400,
12
+ UNSAFE_URL: 400,
13
+ NOT_FOUND: 404,
14
+ DUPLICATE: 409,
15
+ UNPARSEABLE: 422,
16
+ };
17
+ /**
18
+ * Map any thrown error to a response. ServiceError carries an explicit code;
19
+ * anything else becomes a 500 with a generic message (details are logged).
20
+ */
21
+ export function serviceErrorToResponse(c, err) {
22
+ if (err instanceof ServiceError) {
23
+ return errorResponse(c, CODE_TO_STATUS[err.code], err.message, err.code);
24
+ }
25
+ console.error('[iread] unexpected error:', err);
26
+ return errorResponse(c, 500, 'An unexpected error occurred.', 'INTERNAL');
27
+ }
28
+ /** Parse a positive integer id from a path param; null if invalid. */
29
+ export function parseId(raw) {
30
+ if (raw === undefined)
31
+ return null;
32
+ if (!/^\d+$/.test(raw))
33
+ return null;
34
+ const n = Number.parseInt(raw, 10);
35
+ return Number.isSafeInteger(n) && n > 0 ? n : null;
36
+ }
37
+ /** Parse an optional integer query param; returns undefined when absent/blank. */
38
+ export function parseOptionalInt(raw) {
39
+ if (raw === undefined || raw.trim() === '')
40
+ return undefined;
41
+ const n = Number(raw);
42
+ return Number.isFinite(n) ? Math.trunc(n) : undefined;
43
+ }
44
+ //# sourceMappingURL=helpers.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"helpers.js","sourceRoot":"","sources":["../../../src/server/routes/helpers.ts"],"names":[],"mappings":"AAAA,+BAA+B;AAC/B,4EAA4E;AAC5E,oDAAoD;AAKpD,OAAO,EAAE,YAAY,EAAyB,MAAM,oBAAoB,CAAC;AAEzE,oEAAoE;AACpE,MAAM,UAAU,aAAa,CAC3B,CAAU,EACV,MAA4B,EAC5B,OAAe,EACf,IAAa;IAEb,MAAM,IAAI,GAAa,EAAE,KAAK,EAAE,IAAI,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,EAAE,CAAC;IACzE,OAAO,CAAC,CAAC,IAAI,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;AAC9B,CAAC;AAED,MAAM,cAAc,GAAmD;IACrE,SAAS,EAAE,GAAG;IACd,UAAU,EAAE,GAAG;IACf,SAAS,EAAE,GAAG;IACd,SAAS,EAAE,GAAG;IACd,WAAW,EAAE,GAAG;CACjB,CAAC;AAEF;;;GAGG;AACH,MAAM,UAAU,sBAAsB,CAAC,CAAU,EAAE,GAAY;IAC7D,IAAI,GAAG,YAAY,YAAY,EAAE,CAAC;QAChC,OAAO,aAAa,CAAC,CAAC,EAAE,cAAc,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,GAAG,CAAC,OAAO,EAAE,GAAG,CAAC,IAAI,CAAC,CAAC;IAC3E,CAAC;IACD,OAAO,CAAC,KAAK,CAAC,2BAA2B,EAAE,GAAG,CAAC,CAAC;IAChD,OAAO,aAAa,CAAC,CAAC,EAAE,GAAG,EAAE,+BAA+B,EAAE,UAAU,CAAC,CAAC;AAC5E,CAAC;AAED,sEAAsE;AACtE,MAAM,UAAU,OAAO,CAAC,GAAuB;IAC7C,IAAI,GAAG,KAAK,SAAS;QAAE,OAAO,IAAI,CAAC;IACnC,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,GAAG,CAAC;QAAE,OAAO,IAAI,CAAC;IACpC,MAAM,CAAC,GAAG,MAAM,CAAC,QAAQ,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC;IACnC,OAAO,MAAM,CAAC,aAAa,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;AACrD,CAAC;AAED,kFAAkF;AAClF,MAAM,UAAU,gBAAgB,CAAC,GAAuB;IACtD,IAAI,GAAG,KAAK,SAAS,IAAI,GAAG,CAAC,IAAI,EAAE,KAAK,EAAE;QAAE,OAAO,SAAS,CAAC;IAC7D,MAAM,CAAC,GAAG,MAAM,CAAC,GAAG,CAAC,CAAC;IACtB,OAAO,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC;AACxD,CAAC"}
@@ -0,0 +1,95 @@
1
+ // src/server/routes/items.ts
2
+ // /api/items router: list, get, patch (read/star), mark-all-read. (PLAN 7 Items)
3
+ import { Hono } from 'hono';
4
+ import { getItem, listItems, markAllRead, patchItem, } from '../feed-service.js';
5
+ import { errorResponse, parseId, parseOptionalInt, serviceErrorToResponse } from './helpers.js';
6
+ export const items = new Hono();
7
+ function parseView(raw) {
8
+ if (raw === 'all' || raw === 'unread' || raw === 'starred')
9
+ return raw;
10
+ return undefined;
11
+ }
12
+ // GET /api/items — list item summaries.
13
+ items.get('/', (c) => {
14
+ const view = parseView(c.req.query('view')) ?? 'all';
15
+ const q = c.req.query('q') ?? '';
16
+ const feedId = parseOptionalInt(c.req.query('feedId'));
17
+ const limit = parseOptionalInt(c.req.query('limit'));
18
+ const offset = parseOptionalInt(c.req.query('offset'));
19
+ const query = { view, q };
20
+ if (feedId !== undefined)
21
+ query.feedId = feedId;
22
+ if (limit !== undefined)
23
+ query.limit = limit;
24
+ if (offset !== undefined)
25
+ query.offset = offset;
26
+ const body = listItems(query);
27
+ return c.json(body, 200);
28
+ });
29
+ // POST /api/items/mark-all-read — mark a scope read. Registered before /:id so
30
+ // "mark-all-read" is never captured as an item id.
31
+ items.post('/mark-all-read', async (c) => {
32
+ let payload = {};
33
+ try {
34
+ const text = await c.req.text();
35
+ if (text.trim() !== '')
36
+ payload = JSON.parse(text);
37
+ }
38
+ catch {
39
+ return errorResponse(c, 400, 'Request body must be JSON.', 'BAD_INPUT');
40
+ }
41
+ const scope = {};
42
+ if (typeof payload.feedId === 'number' && Number.isFinite(payload.feedId)) {
43
+ scope.feedId = Math.trunc(payload.feedId);
44
+ }
45
+ const view = parseView(payload.view);
46
+ if (view !== undefined)
47
+ scope.view = view;
48
+ const updated = markAllRead(scope);
49
+ const body = { updated };
50
+ return c.json(body, 200);
51
+ });
52
+ // GET /api/items/:id — full item for the reader pane.
53
+ items.get('/:id', (c) => {
54
+ const id = parseId(c.req.param('id'));
55
+ if (id === null)
56
+ return errorResponse(c, 400, 'Invalid item id.', 'BAD_INPUT');
57
+ try {
58
+ const item = getItem(id);
59
+ const body = { item };
60
+ return c.json(body, 200);
61
+ }
62
+ catch (err) {
63
+ return serviceErrorToResponse(c, err);
64
+ }
65
+ });
66
+ // PATCH /api/items/:id — update read/starred state.
67
+ items.patch('/:id', async (c) => {
68
+ const id = parseId(c.req.param('id'));
69
+ if (id === null)
70
+ return errorResponse(c, 400, 'Invalid item id.', 'BAD_INPUT');
71
+ let payload;
72
+ try {
73
+ payload = await c.req.json();
74
+ }
75
+ catch {
76
+ return errorResponse(c, 400, 'Request body must be JSON.', 'BAD_INPUT');
77
+ }
78
+ const patch = {};
79
+ if (typeof payload.isRead === 'boolean')
80
+ patch.isRead = payload.isRead;
81
+ if (typeof payload.isStarred === 'boolean')
82
+ patch.isStarred = payload.isStarred;
83
+ if (patch.isRead === undefined && patch.isStarred === undefined) {
84
+ return errorResponse(c, 400, 'Provide at least one of isRead or isStarred (boolean).', 'BAD_INPUT');
85
+ }
86
+ try {
87
+ const item = patchItem(id, patch);
88
+ const body = { item };
89
+ return c.json(body, 200);
90
+ }
91
+ catch (err) {
92
+ return serviceErrorToResponse(c, err);
93
+ }
94
+ });
95
+ //# sourceMappingURL=items.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"items.js","sourceRoot":"","sources":["../../../src/server/routes/items.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,50 @@
1
+ // src/server/routes/opml.ts
2
+ // /api/opml router: export (GET) and import (POST). (PLAN 7 OPML)
3
+ import { Hono } from 'hono';
4
+ import { importFeeds, listFeedsForExport } from '../feed-service.js';
5
+ import { exportOpml, importOpml, OpmlError } from '../opml.js';
6
+ import { errorResponse } from './helpers.js';
7
+ export const opml = new Hono();
8
+ // GET /api/opml — export subscriptions as OPML 2.0.
9
+ opml.get('/', (c) => {
10
+ const xml = exportOpml(listFeedsForExport());
11
+ c.header('Content-Type', 'text/x-opml; charset=utf-8');
12
+ c.header('Content-Disposition', 'attachment; filename="iread.opml"');
13
+ return c.body(xml, 200);
14
+ });
15
+ // POST /api/opml — import. Accepts a raw OPML body (application/xml,
16
+ // text/x-opml) or JSON { opml: string }.
17
+ opml.post('/', async (c) => {
18
+ const contentType = (c.req.header('content-type') ?? '').toLowerCase();
19
+ let xml;
20
+ try {
21
+ if (contentType.includes('application/json')) {
22
+ const payload = (await c.req.json());
23
+ if (!payload || typeof payload.opml !== 'string') {
24
+ return errorResponse(c, 400, 'JSON body must include an "opml" string.', 'BAD_INPUT');
25
+ }
26
+ xml = payload.opml;
27
+ }
28
+ else {
29
+ // Raw OPML body (application/xml, text/x-opml, or unspecified).
30
+ xml = await c.req.text();
31
+ }
32
+ }
33
+ catch {
34
+ return errorResponse(c, 400, 'Could not read the request body.', 'BAD_INPUT');
35
+ }
36
+ let urls;
37
+ try {
38
+ urls = importOpml(xml);
39
+ }
40
+ catch (err) {
41
+ if (err instanceof OpmlError) {
42
+ return errorResponse(c, 400, err.message, 'BAD_OPML');
43
+ }
44
+ return errorResponse(c, 400, 'Could not parse the OPML document.', 'BAD_OPML');
45
+ }
46
+ const result = await importFeeds(urls);
47
+ const body = result;
48
+ return c.json(body, 200);
49
+ });
50
+ //# sourceMappingURL=opml.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"opml.js","sourceRoot":"","sources":["../../../src/server/routes/opml.ts"],"names":[],"mappings":"AAAA,4BAA4B;AAC5B,kEAAkE;AAElE,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAE5B,OAAO,EAAE,WAAW,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAC;AACrE,OAAO,EAAE,UAAU,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAC/D,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAE7C,MAAM,CAAC,MAAM,IAAI,GAAG,IAAI,IAAI,EAAE,CAAC;AAE/B,oDAAoD;AACpD,IAAI,CAAC,GAAG,CAAC,GAAG,EAAE,CAAC,CAAC,EAAE,EAAE;IAClB,MAAM,GAAG,GAAG,UAAU,CAAC,kBAAkB,EAAE,CAAC,CAAC;IAC7C,CAAC,CAAC,MAAM,CAAC,cAAc,EAAE,4BAA4B,CAAC,CAAC;IACvD,CAAC,CAAC,MAAM,CAAC,qBAAqB,EAAE,mCAAmC,CAAC,CAAC;IACrE,OAAO,CAAC,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC;AAC1B,CAAC,CAAC,CAAC;AAEH,qEAAqE;AACrE,yCAAyC;AACzC,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,KAAK,EAAE,CAAC,EAAE,EAAE;IACzB,MAAM,WAAW,GAAG,CAAC,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,cAAc,CAAC,IAAI,EAAE,CAAC,CAAC,WAAW,EAAE,CAAC;IAEvE,IAAI,GAAW,CAAC;IAChB,IAAI,CAAC;QACH,IAAI,WAAW,CAAC,QAAQ,CAAC,kBAAkB,CAAC,EAAE,CAAC;YAC7C,MAAM,OAAO,GAAG,CAAC,MAAM,CAAC,CAAC,GAAG,CAAC,IAAI,EAAE,CAA+B,CAAC;YACnE,IAAI,CAAC,OAAO,IAAI,OAAO,OAAO,CAAC,IAAI,KAAK,QAAQ,EAAE,CAAC;gBACjD,OAAO,aAAa,CAAC,CAAC,EAAE,GAAG,EAAE,0CAA0C,EAAE,WAAW,CAAC,CAAC;YACxF,CAAC;YACD,GAAG,GAAG,OAAO,CAAC,IAAI,CAAC;QACrB,CAAC;aAAM,CAAC;YACN,gEAAgE;YAChE,GAAG,GAAG,MAAM,CAAC,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC;QAC3B,CAAC;IACH,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,aAAa,CAAC,CAAC,EAAE,GAAG,EAAE,kCAAkC,EAAE,WAAW,CAAC,CAAC;IAChF,CAAC;IAED,IAAI,IAAc,CAAC;IACnB,IAAI,CAAC;QACH,IAAI,GAAG,UAAU,CAAC,GAAG,CAAC,CAAC;IACzB,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,IAAI,GAAG,YAAY,SAAS,EAAE,CAAC;YAC7B,OAAO,aAAa,CAAC,CAAC,EAAE,GAAG,EAAE,GAAG,CAAC,OAAO,EAAE,UAAU,CAAC,CAAC;QACxD,CAAC;QACD,OAAO,aAAa,CAAC,CAAC,EAAE,GAAG,EAAE,oCAAoC,EAAE,UAAU,CAAC,CAAC;IACjF,CAAC;IAED,MAAM,MAAM,GAAG,MAAM,WAAW,CAAC,IAAI,CAAC,CAAC;IACvC,MAAM,IAAI,GAAuB,MAAM,CAAC;IACxC,OAAO,CAAC,CAAC,IAAI,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC;AAC3B,CAAC,CAAC,CAAC"}
@@ -0,0 +1,75 @@
1
+ // src/server/sanitize.ts
2
+ // Server-side HTML sanitization. Bodies are sanitized before they touch the DB,
3
+ // so stored content_html is already safe and the client renders it via
4
+ // dangerouslySetInnerHTML without re-sanitizing. (PLAN Section 5.4)
5
+ import sanitizeHtml from 'sanitize-html';
6
+ export const SANITIZE_OPTS = {
7
+ allowedTags: [
8
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
9
+ 'p', 'blockquote', 'pre', 'code',
10
+ 'ul', 'ol', 'li', 'dl', 'dt', 'dd',
11
+ 'a', 'b', 'i', 'strong', 'em', 'mark', 'small', 'sub', 'sup', 'u', 's', 'span', 'br', 'hr',
12
+ 'img', 'figure', 'figcaption',
13
+ 'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', 'caption',
14
+ 'video', 'audio', 'source',
15
+ ],
16
+ allowedAttributes: {
17
+ a: ['href', 'name', 'target', 'rel'],
18
+ img: ['src', 'srcset', 'alt', 'title', 'width', 'height', 'loading'],
19
+ video: ['src', 'controls', 'poster', 'width', 'height'],
20
+ audio: ['src', 'controls'],
21
+ source: ['src', 'srcset', 'type', 'media'],
22
+ td: ['colspan', 'rowspan'],
23
+ th: ['colspan', 'rowspan', 'scope'],
24
+ '*': ['class'],
25
+ },
26
+ allowedSchemes: ['http', 'https', 'mailto'],
27
+ // Raster data: images only. NO data: svg (can carry script in some renderers);
28
+ // the second pass below strips any non-raster data: URL that slips through here.
29
+ allowedSchemesByTag: { img: ['http', 'https', 'data'] },
30
+ allowedSchemesAppliedToAttributes: ['href', 'src', 'srcset'],
31
+ allowProtocolRelative: true,
32
+ // nonTextTags drops the TEXT CONTENT of these, not just the tags.
33
+ nonTextTags: ['script', 'style', 'textarea', 'option', 'noscript', 'iframe'],
34
+ transformTags: {
35
+ a: sanitizeHtml.simpleTransform('a', { target: '_blank', rel: 'noopener noreferrer nofollow' }),
36
+ img: sanitizeHtml.simpleTransform('img', { loading: 'lazy' }),
37
+ },
38
+ disallowedTagsMode: 'discard',
39
+ };
40
+ // Allowed raster data: image MIME types. data:image/svg+xml (and anything else)
41
+ // is stripped in the second pass below.
42
+ const RASTER_DATA_RE = /^data:image\/(?:png|jpeg|jpg|gif|webp)(?:;|,)/i;
43
+ // Matches a single `attr="data:..."` or `attr='data:...'` occurrence. We only
44
+ // need to scrub src / srcset / href because those are the only attributes
45
+ // allowedSchemesAppliedToAttributes lets data: through on, and img is the only
46
+ // tag with `data` in its scheme allowlist.
47
+ const DATA_URL_ATTR_RE = /\b(src|srcset|href)\s*=\s*("data:[^"]*"|'data:[^']*')/gi;
48
+ function isSafeDataUrl(rawUrl) {
49
+ // srcset can hold multiple comma-separated candidates and a data: URL itself
50
+ // contains a comma (data:<mime>,<payload>), so we cannot split on commas.
51
+ // Instead find every "data:" token (the MIME-type prefix up to the comma or
52
+ // semicolon) and require each one to be a raster image.
53
+ const matches = rawUrl.match(/data:[^\s]*/gi);
54
+ if (!matches)
55
+ return true; // no data: URL present, nothing to reject
56
+ return matches.every((m) => RASTER_DATA_RE.test(m));
57
+ }
58
+ /**
59
+ * Sanitize feed-provided article HTML. Runs sanitize-html with SANITIZE_OPTS,
60
+ * then a second regex pass strips any data: image URL that is not a raster
61
+ * format (notably data:image/svg+xml, which can carry script in some renderers).
62
+ */
63
+ export function sanitizeArticleHtml(raw) {
64
+ const clean = sanitizeHtml(raw, SANITIZE_OPTS);
65
+ // Second pass: remove src/srcset/href attributes whose data: URL is not raster.
66
+ return clean.replace(DATA_URL_ATTR_RE, (full, attr, quoted) => {
67
+ const value = quoted.slice(1, -1); // strip surrounding quotes
68
+ return isSafeDataUrl(value) ? full : `${attr}=""`;
69
+ });
70
+ }
71
+ /** Strip all markup, returning plain text. Used for list-view summaries. */
72
+ export function toPlainText(raw) {
73
+ return sanitizeHtml(raw, { allowedTags: [], allowedAttributes: {} });
74
+ }
75
+ //# sourceMappingURL=sanitize.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sanitize.js","sourceRoot":"","sources":["../../src/server/sanitize.ts"],"names":[],"mappings":"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"}