Skip to content

Commit e2f19f1

Browse files
KoenkkNerivec
andauthored
fix: Frontend code cleanup @Nerivec (#24322)
Co-authored-by: Nerivec <[email protected]>
1 parent 618b318 commit e2f19f1

File tree

7 files changed

+89
-58
lines changed

7 files changed

+89
-58
lines changed

lib/extension/frontend.ts

+45-46
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
import type {IncomingMessage, Server, ServerResponse} from 'http';
2+
import type {Socket} from 'net';
3+
14
import assert from 'assert';
2-
import fs from 'fs';
3-
import http from 'http';
4-
import https from 'https';
5-
import net from 'net';
6-
import path from 'path';
7-
import url from 'url';
5+
import {existsSync, readFileSync} from 'fs';
6+
import {createServer} from 'http';
7+
import {createServer as createSecureServer} from 'https';
8+
import {posix} from 'path';
9+
import {parse} from 'url';
810

911
import bind from 'bind-decorator';
1012
import gzipStatic, {RequestHandler} from 'connect-gzip-static';
@@ -29,10 +31,10 @@ export default class Frontend extends Extension {
2931
private sslCert: string | undefined;
3032
private sslKey: string | undefined;
3133
private authToken: string | undefined;
32-
private server: http.Server | undefined;
33-
private fileServer: RequestHandler | undefined;
34-
private wss: WebSocket.Server | undefined;
35-
private frontendBaseUrl: string;
34+
private server!: Server;
35+
private fileServer!: RequestHandler;
36+
private wss!: WebSocket.Server;
37+
private baseUrl: string;
3638

3739
constructor(
3840
zigbee: Zigbee,
@@ -53,16 +55,13 @@ export default class Frontend extends Extension {
5355
this.sslCert = frontendSettings.ssl_cert;
5456
this.sslKey = frontendSettings.ssl_key;
5557
this.authToken = frontendSettings.auth_token;
58+
this.baseUrl = frontendSettings.base_url;
5659
this.mqttBaseTopic = settings.get().mqtt.base_topic;
57-
this.frontendBaseUrl = settings.get().frontend?.base_url ?? '/';
58-
if (!this.frontendBaseUrl.startsWith('/')) {
59-
this.frontendBaseUrl = '/' + this.frontendBaseUrl;
60-
}
6160
}
6261

6362
private isHttpsConfigured(): boolean {
6463
if (this.sslCert && this.sslKey) {
65-
if (!fs.existsSync(this.sslCert) || !fs.existsSync(this.sslKey)) {
64+
if (!existsSync(this.sslCert) || !existsSync(this.sslKey)) {
6665
logger.error(`defined ssl_cert '${this.sslCert}' or ssl_key '${this.sslKey}' file path does not exists, server won't be secured.`);
6766
return false;
6867
}
@@ -72,30 +71,30 @@ export default class Frontend extends Extension {
7271
}
7372

7473
override async start(): Promise<void> {
75-
if (this.isHttpsConfigured()) {
76-
const serverOptions = {
77-
key: fs.readFileSync(this.sslKey!), // valid from `isHttpsConfigured`
78-
cert: fs.readFileSync(this.sslCert!), // valid from `isHttpsConfigured`
79-
};
80-
this.server = https.createServer(serverOptions, this.onRequest);
81-
} else {
82-
this.server = http.createServer(this.onRequest);
83-
}
84-
85-
this.server.on('upgrade', this.onUpgrade);
86-
8774
/* istanbul ignore next */
8875
const options = {
89-
setHeaders: (res: http.ServerResponse, path: string): void => {
76+
setHeaders: (res: ServerResponse, path: string): void => {
9077
if (path.endsWith('index.html')) {
9178
res.setHeader('Cache-Control', 'no-store');
9279
}
9380
},
9481
};
9582
this.fileServer = gzipStatic(frontend.getPath(), options);
96-
this.wss = new WebSocket.Server({noServer: true, path: path.posix.join(this.frontendBaseUrl, 'api')});
83+
this.wss = new WebSocket.Server({noServer: true, path: posix.join(this.baseUrl, 'api')});
84+
9785
this.wss.on('connection', this.onWebSocketConnection);
9886

87+
if (this.isHttpsConfigured()) {
88+
const serverOptions = {
89+
key: readFileSync(this.sslKey!), // valid from `isHttpsConfigured`
90+
cert: readFileSync(this.sslCert!), // valid from `isHttpsConfigured`
91+
};
92+
this.server = createSecureServer(serverOptions, this.onRequest);
93+
} else {
94+
this.server = createServer(this.onRequest);
95+
}
96+
97+
this.server.on('upgrade', this.onUpgrade);
9998
this.eventBus.onMQTTMessagePublished(this, this.onMQTTPublishMessage);
10099

101100
if (!this.host) {
@@ -112,43 +111,42 @@ export default class Frontend extends Extension {
112111

113112
override async stop(): Promise<void> {
114113
await super.stop();
115-
this.wss?.clients.forEach((client) => {
114+
this.wss.clients.forEach((client) => {
116115
client.send(stringify({topic: 'bridge/state', payload: 'offline'}));
117116
client.terminate();
118117
});
119-
this.wss?.close();
120-
/* istanbul ignore else */
121-
if (this.server) {
122-
return await new Promise((cb: () => void) => this.server!.close(cb));
123-
}
118+
this.wss.close();
119+
120+
await new Promise((resolve) => this.server.close(resolve));
124121
}
125122

126-
@bind private onRequest(request: http.IncomingMessage, response: http.ServerResponse): void {
123+
@bind private onRequest(request: IncomingMessage, response: ServerResponse): void {
127124
const fin = finalhandler(request, response);
125+
const newUrl = posix.relative(this.baseUrl, request.url!);
128126

129-
const newUrl = path.posix.relative(this.frontendBaseUrl, request.url!);
130127
// The request url is not within the frontend base url, so the relative path starts with '..'
131128
if (newUrl.startsWith('.')) {
132129
return fin();
133130
}
134131

135-
// Attach originalUrl so that static-server can perform a redirect to '/' when serving the
136-
// root directory. This is necessary for the browser to resolve relative assets paths correctly.
132+
// Attach originalUrl so that static-server can perform a redirect to '/' when serving the root directory.
133+
// This is necessary for the browser to resolve relative assets paths correctly.
137134
request.originalUrl = request.url;
138135
request.url = '/' + newUrl;
139-
this.fileServer?.(request, response, fin);
136+
137+
this.fileServer(request, response, fin);
140138
}
141139

142-
private authenticate(request: http.IncomingMessage, cb: (authenticate: boolean) => void): void {
143-
const {query} = url.parse(request.url!, true);
140+
private authenticate(request: IncomingMessage, cb: (authenticate: boolean) => void): void {
141+
const {query} = parse(request.url!, true);
144142
cb(!this.authToken || this.authToken === query.token);
145143
}
146144

147-
@bind private onUpgrade(request: http.IncomingMessage, socket: net.Socket, head: Buffer): void {
148-
this.wss!.handleUpgrade(request, socket, head, (ws) => {
145+
@bind private onUpgrade(request: IncomingMessage, socket: Socket, head: Buffer): void {
146+
this.wss.handleUpgrade(request, socket, head, (ws) => {
149147
this.authenticate(request, (isAuthenticated) => {
150148
if (isAuthenticated) {
151-
this.wss!.emit('connection', ws, request);
149+
this.wss.emit('connection', ws, request);
152150
} else {
153151
ws.close(4401, 'Unauthorized');
154152
}
@@ -182,6 +180,7 @@ export default class Frontend extends Extension {
182180
for (const device of this.zigbee.devicesIterator(utils.deviceNotCoordinator)) {
183181
const payload = this.state.get(device);
184182
const lastSeen = settings.get().advanced.last_seen;
183+
185184
/* istanbul ignore if */
186185
if (lastSeen !== 'disable') {
187186
payload.last_seen = utils.formatDate(device.zh.lastSeen ?? 0, lastSeen);
@@ -202,7 +201,7 @@ export default class Frontend extends Extension {
202201
const topic = data.topic.substring(this.mqttBaseTopic.length + 1);
203202
const payload = utils.parseJSON(data.payload, data.payload);
204203

205-
for (const client of this.wss!.clients) {
204+
for (const client of this.wss.clients) {
206205
/* istanbul ignore else */
207206
if (client.readyState === WebSocket.OPEN) {
208207
client.send(stringify({topic, payload}));

lib/types/types.d.ts

+1-7
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ declare global {
179179
auth_token?: string;
180180
host?: string;
181181
port: number;
182-
base_url?: string;
182+
base_url: string;
183183
url?: string;
184184
ssl_cert?: string;
185185
ssl_key?: string;
@@ -264,9 +264,3 @@ declare global {
264264
qos?: 0 | 1 | 2;
265265
}
266266
}
267-
268-
declare module 'http' {
269-
interface IncomingMessage {
270-
originalUrl?: string;
271-
}
272-
}

lib/types/zigbee2mqtt-frontend.d.ts

+6
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@ declare module 'zigbee2mqtt-frontend' {
22
export function getPath(): string;
33
}
44

5+
declare module 'http' {
6+
interface IncomingMessage {
7+
originalUrl?: string;
8+
}
9+
}
10+
511
declare module 'connect-gzip-static' {
612
import {IncomingMessage, ServerResponse} from 'http';
713
export type RequestHandler = (req: IncomingMessage, res: ServerResponse, finalhandler: (err: unknown) => void) => void;

lib/util/settings.schema.json

+4-2
Original file line numberDiff line numberDiff line change
@@ -402,9 +402,11 @@
402402
"requiresRestart": true
403403
},
404404
"base_url": {
405-
"type": ["string", "null"],
405+
"type": "string",
406+
"pattern": "^\\/.*",
406407
"title": "Base URL",
407-
"description": "Base URL for the frontend if the frontend is hosted under subpath. E.g. if your frontend is available at 'http://localhost/z2m', set this to '/z2m'",
408+
"description": "Base URL for the frontend. If hosted under a subpath, e.g. 'http://localhost:8080/z2m', set this to '/z2m'",
409+
"default": "/",
408410
"requiresRestart": true
409411
}
410412
}

lib/util/settings.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ function loadSettingsWithDefaults(): void {
170170
}
171171

172172
if (_settingsWithDefaults.frontend) {
173-
const defaults = {port: 8080, auth_token: false};
173+
const defaults = {port: 8080, auth_token: null, base_url: '/'};
174174
const s = typeof _settingsWithDefaults.frontend === 'object' ? _settingsWithDefaults.frontend : {};
175175
// @ts-expect-error ignore typing
176176
_settingsWithDefaults.frontend = {};

test/frontend.test.js

+31-1
Original file line numberDiff line numberDiff line change
@@ -358,7 +358,7 @@ describe('Frontend', () => {
358358
expect(mockWS.implementation.emit).toHaveBeenCalledWith('connection', mockWSocket, {url});
359359
});
360360

361-
it.each(['z2m', 'z2m/', '/z2m'])('Works with non-default base url %s', async (baseUrl) => {
361+
it.each(['/z2m/', '/z2m'])('Works with non-default base url %s', async (baseUrl) => {
362362
settings.set(['frontend'], {base_url: baseUrl});
363363
controller = new Controller(jest.fn(), jest.fn());
364364
await controller.start();
@@ -382,4 +382,34 @@ describe('Frontend', () => {
382382
expect(mockNodeStatic.implementation).not.toHaveBeenCalled();
383383
expect(mockFinalHandler.implementation).toHaveBeenCalled();
384384
});
385+
386+
it('Works with non-default complex base url', async () => {
387+
const baseUrl = '/z2m-more++/c0mplex.url/';
388+
settings.set(['frontend'], {base_url: baseUrl});
389+
controller = new Controller(jest.fn(), jest.fn());
390+
await controller.start();
391+
392+
expect(ws.Server).toHaveBeenCalledWith({noServer: true, path: '/z2m-more++/c0mplex.url/api'});
393+
394+
mockHTTP.variables.onRequest({url: '/z2m-more++/c0mplex.url'}, 2);
395+
expect(mockNodeStatic.implementation).toHaveBeenCalledTimes(1);
396+
expect(mockNodeStatic.implementation).toHaveBeenCalledWith({originalUrl: '/z2m-more++/c0mplex.url', url: '/'}, 2, expect.any(Function));
397+
expect(mockFinalHandler.implementation).not.toHaveBeenCalledWith();
398+
399+
mockNodeStatic.implementation.mockReset();
400+
expect(mockFinalHandler.implementation).not.toHaveBeenCalledWith();
401+
mockHTTP.variables.onRequest({url: '/z2m-more++/c0mplex.url/file.txt'}, 2);
402+
expect(mockNodeStatic.implementation).toHaveBeenCalledTimes(1);
403+
expect(mockNodeStatic.implementation).toHaveBeenCalledWith(
404+
{originalUrl: '/z2m-more++/c0mplex.url/file.txt', url: '/file.txt'},
405+
2,
406+
expect.any(Function),
407+
);
408+
expect(mockFinalHandler.implementation).not.toHaveBeenCalledWith();
409+
410+
mockNodeStatic.implementation.mockReset();
411+
mockHTTP.variables.onRequest({url: '/z/file.txt'}, 2);
412+
expect(mockNodeStatic.implementation).not.toHaveBeenCalled();
413+
expect(mockFinalHandler.implementation).toHaveBeenCalled();
414+
});
385415
});

test/settings.test.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -978,7 +978,7 @@ describe('Settings', () => {
978978
write(configurationFile, {...minimalConfig, frontend: true});
979979

980980
settings.reRead();
981-
expect(settings.get().frontend).toStrictEqual({port: 8080, auth_token: false});
981+
expect(settings.get().frontend).toStrictEqual({port: 8080, auth_token: null, base_url: '/'});
982982
});
983983

984984
it('Baudrate config', () => {

0 commit comments

Comments
 (0)