Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 16 additions & 2 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
100 changes: 98 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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",
Expand Down
64 changes: 64 additions & 0 deletions src/server/apiCompression.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
}

async function startApp(): Promise<AppContext> {
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<void>((resolve) => server.once('listening', resolve));
const { port } = server.address() as AddressInfo;

return {
base: `http://localhost:${port}`,
close: async () => {
await new Promise<void>((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();
}
});
});
18 changes: 18 additions & 0 deletions src/server/apiCompression.ts
Original file line number Diff line number Diff line change
@@ -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)
});
}
8 changes: 7 additions & 1 deletion src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -73,7 +74,12 @@ async function main(): Promise<void> {
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);
Expand Down