@isomoes/iread 0.2.4 → 0.2.6

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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  A concise, local, single-user RSS/Atom reader inspired by newsboat. Keyboard-first three-pane UI, TypeScript end to end. You add feeds by URL, refresh them on demand, and read server-sanitized article bodies in a calm, terminal-quiet reading surface with light, dark, and system themes.
4
4
 
5
- There is no auth, no multi-user, no cloud sync, and no background scheduler. Refresh is always user-initiated. All data lives in a single local SQLite file — by default `~/.config/iread/iread.db` — which is the trust boundary.
5
+ Optional token authentication supports private access over a public network. There is no multi-user, no cloud sync, and no background scheduler. Refresh is always user-initiated. All data lives in a single local SQLite file — by default `~/.config/iread/iread.db`.
6
6
 
7
7
  ![iread UI](https://github.com/user-attachments/assets/ca9489c9-630f-4a67-9cd5-60c18ce3544e)
8
8
 
@@ -21,6 +21,7 @@ Usage: iread [options]
21
21
 
22
22
  Options:
23
23
  -p, --port <port> Port to listen on (default: $PORT or 8787)
24
+ --auth-token <token> Require a bearer token (default: $AUTH_TOKEN; unset disables auth)
24
25
  --db <path> SQLite database file
25
26
  (default: $DB_PATH or ~/.config/iread/iread.db)
26
27
  --opml <path> OPML file auto-saved on every subscription change
@@ -50,6 +51,20 @@ Build the image from the checkout instead of pulling it with `docker compose up
50
51
 
51
52
  `PORT` (default 8787), `HOST` (default all interfaces), `DB_PATH` (default `/data/iread.db`), and `OPML_PATH` (default `/data/feeds.opml`) are configurable via `-e`/`environment:`. With host networking, set `HOST=127.0.0.1` to restrict access to localhost; with bridge networking, leave `HOST` unset and restrict the published port instead. To store data in a host directory instead of a named volume, bind-mount it and make it writable by the image's unprivileged `node` user (uid 1000), e.g. `mkdir iread-data && sudo chown 1000:1000 iread-data` then `-v "$PWD/iread-data:/data"`.
52
53
 
54
+ ## Public network access
55
+
56
+ Authentication is **disabled by default**. Set `AUTH_TOKEN` to a long, random secret to protect every API endpoint:
57
+
58
+ ```sh
59
+ AUTH_TOKEN='your-long-random-secret' npx @isomoes/iread
60
+ ```
61
+
62
+ You can also use `--auth-token <token>`. Environment variables are preferable to putting secrets in command-line arguments. For Compose, set `AUTH_TOKEN` in your environment or Compose `.env` file before `docker compose up -d`. For `docker run`, pass `-e AUTH_TOKEN='your-long-random-secret'`.
63
+
64
+ The web UI prompts for the token and remembers it for the current browser tab using sessionStorage. API clients must send `Authorization: Bearer <token>`, including for OPML export. The static UI remains public but no feed or article data is accessible without the token. Changing the server token requires restarting the server; clients with the old token must sign in again.
65
+
66
+ Use HTTPS through a reverse proxy for public access, and configure proxy-side rate limiting against token guessing. Never expose an instance without a token unless access is restricted by another trusted layer. `AUTH_TOKEN` unset or empty preserves the existing unauthenticated behavior. `config/.env.example` documents the settings; the server reads environment variables, not that file automatically.
67
+
53
68
  ## Features
54
69
 
55
70
  - Add a feed by URL (title resolved from feed metadata), delete a feed.
@@ -149,3 +164,7 @@ pnpm start
149
164
  Open http://localhost:8787
150
165
 
151
166
  `PORT` (default 8787), `HOST` (default all interfaces; set `127.0.0.1` for localhost only), `DB_PATH` (default `~/.config/iread/iread.db`), and `OPML_PATH` (default `feeds.opml` next to the database; set empty to disable the auto-saved mirror) are read from the environment. See `config/.env.example`.
167
+
168
+ Feeds are fetched directly by default. To go through an outbound HTTP proxy, set `HTTPS_PROXY` / `HTTP_PROXY` (an `http://host:port` URL, optionally with `user:pass@`) and optionally `NO_PROXY` (comma-separated hosts/domains to bypass). HTTPS feeds are tunnelled with `CONNECT`. Note that the SSRF guard still validates the feed host's DNS answer locally, but with a proxy the proxy does the final connect, so DNS-rebinding pinning cannot apply.
169
+
170
+ Each fetch attempt has a 30-second timeout. Transient network failures and HTTP 408/429/500/502/503/504 are retried once, normally after one second. A valid `Retry-After` is honored up to five seconds; longer delays are not retried automatically. Permanent HTTP errors, unsafe URLs, and oversized feeds are not retried. Run fetch regression tests with `pnpm test`.
@@ -0,0 +1,18 @@
1
+ import { createHash, timingSafeEqual } from 'node:crypto';
2
+ import { createMiddleware } from 'hono/factory';
3
+ export function tokenAuth(token) {
4
+ const expected = token ? createHash('sha256').update(token).digest() : undefined;
5
+ return createMiddleware(async (c, next) => {
6
+ c.header('Cache-Control', 'no-store');
7
+ if (expected) {
8
+ const authorization = c.req.header('Authorization') ?? '';
9
+ const match = /^Bearer ([^\s]+)$/i.exec(authorization);
10
+ const supplied = createHash('sha256').update(match?.[1] ?? '').digest();
11
+ if (!match || !timingSafeEqual(expected, supplied)) {
12
+ c.header('WWW-Authenticate', 'Bearer realm="iread"');
13
+ return c.json({ error: { message: 'Authentication required', code: 'UNAUTHORIZED' } }, 401);
14
+ }
15
+ }
16
+ await next();
17
+ });
18
+ }
@@ -28,6 +28,7 @@ Usage: iread [options]
28
28
 
29
29
  Options:
30
30
  -p, --port <port> Port to listen on (default: $PORT or 8787)
31
+ --auth-token <token> Require a bearer token (default: $AUTH_TOKEN; unset disables auth)
31
32
  --db <path> SQLite database file
32
33
  (default: $DB_PATH or ~/.config/iread/iread.db)
33
34
  --opml <path> OPML file auto-saved on every subscription change
@@ -70,6 +71,13 @@ for (let i = 0; i < args.length; i++) {
70
71
  process.env.PORT = value;
71
72
  break;
72
73
  }
74
+ case '--auth-token': {
75
+ const value = args[++i];
76
+ if (!value || /\s/.test(value))
77
+ fail('--auth-token requires a non-empty token without whitespace');
78
+ process.env.AUTH_TOKEN = value;
79
+ break;
80
+ }
73
81
  case '--db': {
74
82
  const value = args[++i];
75
83
  if (!value)
@@ -3,9 +3,36 @@
3
3
  // The body is read incrementally and the request is aborted the moment cumulative
4
4
  // bytes exceed MAX_BYTES; the whole response is never buffered before the check.
5
5
  import { fetchWithGuardedRedirects } from './ssrf.js';
6
+ import { setTimeout as delay } from 'node:timers/promises';
6
7
  const USER_AGENT = 'iread/1.0 (+local RSS reader)';
7
- const FETCH_TIMEOUT_MS = 15_000;
8
+ const FETCH_TIMEOUT_MS = 30_000;
8
9
  const MAX_BYTES = 10 * 1024 * 1024; // 10 MB
10
+ const RETRY_CODES = new Set([
11
+ 'ECONNRESET',
12
+ 'ECONNREFUSED',
13
+ 'ETIMEDOUT',
14
+ 'EAI_AGAIN',
15
+ 'ENETUNREACH',
16
+ 'EHOSTUNREACH',
17
+ 'EPIPE',
18
+ 'ERR_STREAM_PREMATURE_CLOSE',
19
+ ]);
20
+ class RetryableFetchError extends Error {
21
+ retryDelay;
22
+ constructor(message, retryDelay = 1000) {
23
+ super(message);
24
+ this.retryDelay = retryDelay;
25
+ }
26
+ }
27
+ function isTransient(err) {
28
+ if (err instanceof RetryableFetchError)
29
+ return true;
30
+ if (err instanceof AggregateError)
31
+ return err.errors.length > 0 && err.errors.every(isTransient);
32
+ if (!(err instanceof Error))
33
+ return false;
34
+ return RETRY_CODES.has(err.code ?? '') || isTransient(err.cause);
35
+ }
9
36
  function concat(chunks, total) {
10
37
  const out = new Uint8Array(total);
11
38
  let offset = 0;
@@ -16,6 +43,18 @@ function concat(chunks, total) {
16
43
  return out;
17
44
  }
18
45
  export async function fetchFeed(input) {
46
+ for (let attempt = 0;; attempt++) {
47
+ try {
48
+ return await fetchAttempt(input);
49
+ }
50
+ catch (err) {
51
+ if (attempt >= 1 || !isTransient(err))
52
+ throw err;
53
+ await delay(err instanceof RetryableFetchError ? err.retryDelay : 1000);
54
+ }
55
+ }
56
+ }
57
+ async function fetchAttempt(input) {
19
58
  const ctrl = new AbortController();
20
59
  const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
21
60
  try {
@@ -39,7 +78,18 @@ export async function fetchFeed(input) {
39
78
  }
40
79
  if (!res.ok) {
41
80
  await res.body?.cancel().catch(() => { });
42
- throw new Error(`HTTP ${res.status} ${res.statusText}`);
81
+ const message = `HTTP ${res.status} ${res.statusText}`;
82
+ const retryAfter = res.headers.get('retry-after');
83
+ const retryDelay = retryAfter === null
84
+ ? 1000
85
+ : /^\d+$/.test(retryAfter)
86
+ ? Number(retryAfter) * 1000
87
+ : Math.max(0, Date.parse(retryAfter) - Date.now());
88
+ if ([408, 429, 500, 502, 503, 504].includes(res.status) &&
89
+ Number.isFinite(retryDelay) && retryDelay <= 5000) {
90
+ throw new RetryableFetchError(message, retryDelay);
91
+ }
92
+ throw new Error(message);
43
93
  }
44
94
  // Fast reject on advertised Content-Length (untrusted but cheap).
45
95
  const cl = Number(res.headers.get('content-length'));
@@ -76,6 +126,12 @@ export async function fetchFeed(input) {
76
126
  lastModified: res.headers.get('last-modified'),
77
127
  };
78
128
  }
129
+ catch (err) {
130
+ if (ctrl.signal.aborted && err instanceof Error && err.name === 'AbortError') {
131
+ throw new RetryableFetchError(`Feed request timed out after ${FETCH_TIMEOUT_MS / 1000}s`);
132
+ }
133
+ throw err;
134
+ }
79
135
  finally {
80
136
  clearTimeout(timer);
81
137
  }
@@ -12,6 +12,7 @@ import { serveStatic } from '@hono/node-server/serve-static';
12
12
  import { feeds } from './routes/feeds.js';
13
13
  import { items } from './routes/items.js';
14
14
  import { opml } from './routes/opml.js';
15
+ import { tokenAuth } from './auth.js';
15
16
  const PORT = Number(process.env.PORT ?? 8787);
16
17
  const HOST = process.env.HOST || undefined;
17
18
  const IS_PROD = process.env.NODE_ENV === 'production';
@@ -21,6 +22,9 @@ import './db.js';
21
22
  import { syncOpmlMirror } from './feed-service.js';
22
23
  syncOpmlMirror();
23
24
  const app = new Hono();
25
+ app.use('/api/*', tokenAuth(process.env.AUTH_TOKEN));
26
+ app.use('/api', tokenAuth(process.env.AUTH_TOKEN));
27
+ app.get('/api/auth', (c) => c.body(null, 204));
24
28
  // --- API routes (registered FIRST) -----------------------------------------
25
29
  const api = new Hono();
26
30
  api.route('/feeds', feeds);
@@ -0,0 +1,114 @@
1
+ // src/server/proxy.ts
2
+ // Optional outbound HTTP(S) proxy support for feed fetching, driven by the usual
3
+ // HTTPS_PROXY / HTTP_PROXY / NO_PROXY environment variables (either case).
4
+ // Only `http://` proxy URLs are supported (HTTPS targets are tunnelled via CONNECT).
5
+ import { request as httpRequest } from 'node:http';
6
+ import { connect as tlsConnect } from 'node:tls';
7
+ function env(name) {
8
+ return process.env[name] ?? process.env[name.toLowerCase()] ?? undefined;
9
+ }
10
+ function bypassed(host, port, noProxy) {
11
+ const h = host.toLowerCase();
12
+ for (const raw of noProxy.split(',')) {
13
+ let entry = raw.trim().toLowerCase();
14
+ if (!entry)
15
+ continue;
16
+ if (entry === '*')
17
+ return true;
18
+ const colon = entry.lastIndexOf(':');
19
+ if (colon > 0 && !entry.endsWith(']') && /^\d+$/.test(entry.slice(colon + 1))) {
20
+ if (entry.slice(colon + 1) !== port)
21
+ continue;
22
+ entry = entry.slice(0, colon);
23
+ }
24
+ entry = entry.replace(/^\*?\./, '');
25
+ if (h === entry || h.endsWith(`.${entry}`))
26
+ return true;
27
+ }
28
+ return false;
29
+ }
30
+ /** Proxy to use for `target`, or null for a direct connection. */
31
+ export function proxyFor(target) {
32
+ const isHttps = target.protocol === 'https:';
33
+ const value = isHttps ? (env('HTTPS_PROXY') ?? env('HTTP_PROXY')) : env('HTTP_PROXY');
34
+ if (!value)
35
+ return null;
36
+ const port = target.port || (isHttps ? '443' : '80');
37
+ const host = target.hostname.replace(/^\[|\]$/g, '');
38
+ if (bypassed(host, port, env('NO_PROXY') ?? ''))
39
+ return null;
40
+ let p;
41
+ try {
42
+ p = new URL(value.includes('://') ? value : `http://${value}`);
43
+ }
44
+ catch {
45
+ return null;
46
+ }
47
+ if (p.protocol !== 'http:')
48
+ return null;
49
+ const auth = p.username
50
+ ? `${decodeURIComponent(p.username)}:${decodeURIComponent(p.password)}`
51
+ : null;
52
+ return { host: p.hostname.replace(/^\[|\]$/g, ''), port: Number(p.port || 80), auth };
53
+ }
54
+ function proxyAuthHeader(proxy) {
55
+ return proxy.auth
56
+ ? { 'Proxy-Authorization': `Basic ${Buffer.from(proxy.auth).toString('base64')}` }
57
+ : {};
58
+ }
59
+ /** Headers to add when sending an absolute-URI request to the proxy (http targets). */
60
+ export function plainProxyHeaders(proxy) {
61
+ return proxyAuthHeader(proxy);
62
+ }
63
+ /**
64
+ * Open a CONNECT tunnel through `proxy` to target host:port and wrap it in TLS
65
+ * (SNI = target hostname). Honors `signal` for cancellation.
66
+ */
67
+ export function tunnelTls(proxy, target, signal) {
68
+ const port = target.port || '443';
69
+ const hostname = target.hostname.replace(/^\[|\]$/g, '');
70
+ const authority = `${target.hostname}:${port}`;
71
+ return new Promise((resolve, reject) => {
72
+ const req = httpRequest({
73
+ host: proxy.host,
74
+ port: proxy.port,
75
+ method: 'CONNECT',
76
+ path: authority,
77
+ headers: { Host: authority, ...proxyAuthHeader(proxy) },
78
+ });
79
+ let tunnel;
80
+ const cleanup = () => signal?.removeEventListener('abort', abort);
81
+ const fail = (err) => {
82
+ cleanup();
83
+ reject(err);
84
+ };
85
+ const abort = () => {
86
+ const err = new DOMException('Aborted', 'AbortError');
87
+ tunnel?.destroy(err);
88
+ req.destroy(err);
89
+ fail(err);
90
+ };
91
+ if (signal) {
92
+ if (signal.aborted)
93
+ abort();
94
+ else
95
+ signal.addEventListener('abort', abort, { once: true });
96
+ }
97
+ req.once('connect', (res, socket) => {
98
+ if (res.statusCode !== 200) {
99
+ socket.destroy();
100
+ fail(new Error(`proxy CONNECT failed: HTTP ${res.statusCode ?? 0}`));
101
+ return;
102
+ }
103
+ const tls = tlsConnect({ socket, servername: hostname });
104
+ tunnel = tls;
105
+ tls.once('secureConnect', () => {
106
+ cleanup();
107
+ resolve(tls);
108
+ });
109
+ tls.once('error', fail);
110
+ });
111
+ req.once('error', fail);
112
+ req.end();
113
+ });
114
+ }
@@ -7,6 +7,7 @@ import { isIP } from 'node:net';
7
7
  import { request as httpRequest } from 'node:http';
8
8
  import { request as httpsRequest } from 'node:https';
9
9
  import { Readable } from 'node:stream';
10
+ import { plainProxyHeaders, proxyFor, tunnelTls } from './proxy.js';
10
11
  const MAX_REDIRECTS = 5;
11
12
  export class SsrfError extends Error {
12
13
  name = 'SsrfError';
@@ -190,48 +191,73 @@ function pinnedFetch(url, validated, init) {
190
191
  const isHttps = parsed.protocol === 'https:';
191
192
  const requestFn = isHttps ? httpsRequest : httpRequest;
192
193
  const signal = init.signal ?? undefined;
193
- return new Promise((resolve, reject) => {
194
- const req = requestFn(parsed, {
195
- method: (init.method ?? 'GET').toUpperCase(),
196
- headers: headersToObject(init.headers),
197
- lookup: pinnedLookup(validated),
198
- // servername defaults to the URL hostname for https, preserving SNI.
199
- }, (msg) => {
200
- const headers = new Headers();
201
- for (const [k, v] of Object.entries(msg.headers)) {
202
- if (v === undefined)
203
- continue;
204
- if (Array.isArray(v))
205
- for (const one of v)
206
- headers.append(k, one);
207
- else
208
- headers.set(k, v);
194
+ const proxy = proxyFor(parsed);
195
+ return (async () => {
196
+ // Via a proxy the destination was still validated (assertSafeFeedUrl) but the
197
+ // proxy performs the final resolve+connect, so IP pinning cannot apply.
198
+ let tunnel;
199
+ if (proxy && isHttps)
200
+ tunnel = await tunnelTls(proxy, parsed, signal);
201
+ return send(tunnel);
202
+ })();
203
+ function send(tunnel) {
204
+ return new Promise((resolve, reject) => {
205
+ const headers = headersToObject(init.headers);
206
+ let target = parsed;
207
+ let extra = { lookup: pinnedLookup(validated) };
208
+ let fn = requestFn;
209
+ if (tunnel) {
210
+ extra = { createConnection: () => tunnel };
209
211
  }
210
- const status = msg.statusCode ?? 0;
211
- // 204/304 must not carry a body in a Response.
212
- const nullBody = status === 204 || status === 304;
213
- const body = nullBody
214
- ? null
215
- : Readable.toWeb(msg);
216
- resolve(new Response(body, {
217
- status,
218
- statusText: msg.statusMessage ?? '',
219
- headers,
220
- }));
221
- });
222
- if (signal) {
223
- if (signal.aborted) {
224
- req.destroy(new DOMException('Aborted', 'AbortError'));
212
+ else if (proxy) {
213
+ // Plain-http target: absolute-URI request to the proxy.
214
+ fn = httpRequest;
215
+ target = new URL(`http://${proxy.host.includes(':') ? `[${proxy.host}]` : proxy.host}:${proxy.port}`);
216
+ extra = { path: parsed.href, setHost: false };
217
+ Object.assign(headers, { host: parsed.host }, plainProxyHeaders(proxy));
225
218
  }
226
- else {
227
- signal.addEventListener('abort', () => req.destroy(new DOMException('Aborted', 'AbortError')), {
228
- once: true,
229
- });
219
+ const req = fn(target, {
220
+ method: (init.method ?? 'GET').toUpperCase(),
221
+ headers,
222
+ ...extra,
223
+ // servername defaults to the URL hostname for https, preserving SNI.
224
+ }, (msg) => {
225
+ const headers = new Headers();
226
+ for (const [k, v] of Object.entries(msg.headers)) {
227
+ if (v === undefined)
228
+ continue;
229
+ if (Array.isArray(v))
230
+ for (const one of v)
231
+ headers.append(k, one);
232
+ else
233
+ headers.set(k, v);
234
+ }
235
+ const status = msg.statusCode ?? 0;
236
+ // 204/304 must not carry a body in a Response.
237
+ const nullBody = status === 204 || status === 304;
238
+ const body = nullBody
239
+ ? null
240
+ : Readable.toWeb(msg);
241
+ resolve(new Response(body, {
242
+ status,
243
+ statusText: msg.statusMessage ?? '',
244
+ headers,
245
+ }));
246
+ });
247
+ if (signal) {
248
+ if (signal.aborted) {
249
+ req.destroy(new DOMException('Aborted', 'AbortError'));
250
+ }
251
+ else {
252
+ signal.addEventListener('abort', () => req.destroy(new DOMException('Aborted', 'AbortError')), {
253
+ once: true,
254
+ });
255
+ }
230
256
  }
231
- }
232
- req.on('error', reject);
233
- req.end();
234
- });
257
+ req.on('error', reject);
258
+ req.end();
259
+ });
260
+ }
235
261
  }
236
262
  /**
237
263
  * fetch() that follows redirects manually (max 5 hops), re-running the SSRF