diff --git a/README.md b/README.md index e3bcdee..6a9ec5a 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,8 @@ When the app runs behind a reverse proxy (Nginx, Caddy, Traefik), set `TRUST_PRO The app has no built-in authentication and relies on the browser's same-origin policy, which [DNS rebinding](https://en.wikipedia.org/wiki/DNS_rebinding) can bypass. Setting `ALLOWED_HOSTS` to the host names you actually use to reach the viewer closes that gap: a rebinding request still carries the attacker's `Host` header, which the allowlist rejects with `403`. This is a hardening measure, not authentication — for real access control, put the app behind an authenticating reverse proxy. +`/api/bootstrap` and `/api/resync` responses are gzip-compressed when the client sends `Accept-Encoding: gzip` (log-heavy JSON payloads compress well, which helps most over Wi-Fi or a remote reverse-proxy connection). `/api/stream` is never compressed, since buffering would delay live SSE delivery. + ## Development and build ```bash diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2ba078d..b0e60a4 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -61,8 +61,8 @@ deployment described in [`README.md`](../README.md#systemd-installation-on-linux *before* the HTTP server starts listening, so no SSE client can observe a half-seeded buffer. 6. Builds the Express `app`: security headers, optional host validation, - `trust proxy`, two rate limiters, the `/api` router, and static serving of - `dist/client`. + `trust proxy`, `/api` response compression, two rate limiters, the `/api` + router, and static serving of `dist/client`. 7. Starts listening and wires `createShutdown()` to `SIGINT`/`SIGTERM`. ### Security headers and same-origin policy @@ -94,6 +94,20 @@ the app (see [#132](https://github.com/LarsLaskowski/OpenHabLogViewer/issues/132 Unset (the default), the middleware is not registered at all, preserving prior behavior. +### API response compression + +`createApiCompression()` (`src/server/apiCompression.ts`) wraps the +`compression` middleware and is mounted first under `/api`, ahead of the +router. `/api/bootstrap` and reset-mode `/api/resync` responses can run to a +few hundred KB of repetitive JSON (raw line text duplicated across `rawLine` +and `message`, timestamps, logger names), which gzips well; `/api/stream` is +excluded so buffering does not delay live SSE delivery. The exclusion checks +`request.path !== '/api/stream'` using the full mounted path rather than a +router-relative `/stream`, because `compression`'s filter runs lazily on +first write, by which point Express has already restored `request.path` to +the incoming request path (see +[#140](https://github.com/LarsLaskowski/OpenHabLogViewer/issues/140)). + ### Rate limiting Two `express-rate-limit` instances are mounted: `apiLimiter` (200 req/min, diff --git a/package-lock.json b/package-lock.json index 61d2523..e6d31a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,20 @@ { "name": "openhab-log-viewer", - "version": "2.4.1", + "version": "2.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openhab-log-viewer", - "version": "2.4.1", + "version": "2.5.0", "license": "MIT", "dependencies": { + "compression": "^1.8.1", "express": "^5.1.0", "express-rate-limit": "^8.6.2" }, "devDependencies": { + "@types/compression": "^1.8.1", "@types/express": "^5.0.3", "@types/jsdom": "^30.0.0", "@types/node": "^26.2.0", @@ -682,6 +684,17 @@ "@types/node": "*" } }, + "node_modules/@types/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-kCFuWS0ebDbmxs0AXYn6e2r2nrGAb5KwQhknjSPSPgJcGd8+HVSILlUyFhGqML2gk39HcG7D1ydW9/qpYkN00Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/node": "*" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -1234,6 +1247,60 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/compression/node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/content-disposition": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", @@ -1915,6 +1982,15 @@ "node": ">= 0.8" } }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -2044,6 +2120,26 @@ "node": ">= 18" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", diff --git a/package.json b/package.json index 6ae4332..c1fffc5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openhab-log-viewer", - "version": "2.4.1", + "version": "2.5.0", "private": true, "type": "module", "description": "Live web viewer for openHAB events.log and openhab.log", @@ -17,10 +17,12 @@ "coverage:lcov": "node -e \"require('node:fs').mkdirSync('coverage',{recursive:true})\" && node --enable-source-maps --import tsx --test --experimental-test-coverage --test-reporter=spec --test-reporter-destination=stdout --test-reporter=lcov --test-reporter-destination=coverage/lcov.info src/server/*.test.ts src/client/*.test.ts" }, "dependencies": { + "compression": "^1.8.1", "express": "^5.1.0", "express-rate-limit": "^8.6.2" }, "devDependencies": { + "@types/compression": "^1.8.1", "@types/express": "^5.0.3", "@types/jsdom": "^30.0.0", "@types/node": "^26.2.0", diff --git a/src/server/apiCompression.test.ts b/src/server/apiCompression.test.ts new file mode 100644 index 0000000..3d8d015 --- /dev/null +++ b/src/server/apiCompression.test.ts @@ -0,0 +1,64 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import type { AddressInfo } from 'node:net'; +import type { Server } from 'node:http'; +import express from 'express'; +import { createApiCompression } from './apiCompression.js'; + +interface AppContext { + base: string; + close: () => Promise; +} + +async function startApp(): Promise { + const app = express(); + app.use('/api', createApiCompression()); + // A large, repetitive payload so it clears compression's default 1 KB + // threshold and actually gets gzipped. + app.get('/api/bootstrap', (_request, response) => { + response.json({ lines: Array.from({ length: 200 }, () => 'x'.repeat(50)) }); + }); + app.get('/api/stream', (_request, response) => { + response.setHeader('Content-Type', 'text/event-stream; charset=utf-8'); + response.write(`data: ${'x'.repeat(2000)}\n\n`); + response.end(); + }); + + const server: Server = app.listen(0); + await new Promise((resolve) => server.once('listening', resolve)); + const { port } = server.address() as AddressInfo; + + return { + base: `http://localhost:${port}`, + close: async () => { + await new Promise((resolve) => server.close(() => resolve())); + } + }; +} + +describe('createApiCompression', () => { + it('gzip-compresses a large /api/bootstrap response and preserves the JSON body', async () => { + const app = await startApp(); + try { + const response = await fetch(`${app.base}/api/bootstrap`, { headers: { 'Accept-Encoding': 'gzip' } }); + const body = (await response.json()) as { lines: string[] }; + + assert.equal(response.headers.get('content-encoding'), 'gzip'); + assert.equal(body.lines.length, 200); + } finally { + await app.close(); + } + }); + + it('never compresses /api/stream so SSE delivery is not buffered', async () => { + const app = await startApp(); + try { + const response = await fetch(`${app.base}/api/stream`, { headers: { 'Accept-Encoding': 'gzip' } }); + await response.text(); + + assert.equal(response.headers.get('content-encoding'), null); + } finally { + await app.close(); + } + }); +}); diff --git a/src/server/apiCompression.ts b/src/server/apiCompression.ts new file mode 100644 index 0000000..0890b51 --- /dev/null +++ b/src/server/apiCompression.ts @@ -0,0 +1,18 @@ +import compression from 'compression'; +import type express from 'express'; + +// Compresses /api responses (bootstrap and reset-mode resync can run to a few +// hundred KB of repetitive JSON) but explicitly skips /stream: gzip buffers +// output until enough data accumulates, which would delay or batch SSE events +// and defeat live delivery (see issue #140). +// +// compression's filter runs lazily, the first time the response is written +// to rather than at mount time. By then Express has already restored +// request.path to the full incoming path (verified with a test rather than +// assumed), so it is matched here as "/api/stream", not the router-relative +// "/stream" a synchronously-invoked middleware would see. +export function createApiCompression(): express.RequestHandler { + return compression({ + filter: (request, response) => request.path !== '/api/stream' && compression.filter(request, response) + }); +} diff --git a/src/server/index.ts b/src/server/index.ts index 1f2e981..6bafdcd 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -7,6 +7,7 @@ import { SourceStatus } from './types.js'; import { LogLineParser } from './logLineParser.js'; import { LogTailer } from './logTailer.js'; import { createApiRouter } from './routes.js'; +import { createApiCompression } from './apiCompression.js'; import { createHostValidator } from './hostValidation.js'; import { createSpaFallback } from './spaFallback.js'; import { createShutdown } from './shutdown.js'; @@ -73,7 +74,12 @@ async function main(): Promise { legacyHeaders: false }); - app.use('/api', apiLimiter, createApiRouter({ config, buffer, sseHub, getStatuses: () => Array.from(sourceStatuses.values()) })); + app.use( + '/api', + createApiCompression(), + apiLimiter, + createApiRouter({ config, buffer, sseHub, getStatuses: () => Array.from(sourceStatuses.values()) }) + ); app.use(express.static(clientDistDir)); app.use(htmlLimiter);