fix: Frontend code cleanup @Nerivec (#24322)

Co-authored-by: Nerivec <62446222+Nerivec@users.noreply.github.com>
This commit is contained in:
Koen Kanters 2024-10-14 19:56:00 +02:00 committed by GitHub
parent 618b318214
commit e2f19f19b4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 89 additions and 58 deletions

View File

@ -1,10 +1,12 @@
import type {IncomingMessage, Server, ServerResponse} from 'http';
import type {Socket} from 'net';
import assert from 'assert';
import fs from 'fs';
import http from 'http';
import https from 'https';
import net from 'net';
import path from 'path';
import url from 'url';
import {existsSync, readFileSync} from 'fs';
import {createServer} from 'http';
import {createServer as createSecureServer} from 'https';
import {posix} from 'path';
import {parse} from 'url';
import bind from 'bind-decorator';
import gzipStatic, {RequestHandler} from 'connect-gzip-static';
@ -29,10 +31,10 @@ export default class Frontend extends Extension {
private sslCert: string | undefined;
private sslKey: string | undefined;
private authToken: string | undefined;
private server: http.Server | undefined;
private fileServer: RequestHandler | undefined;
private wss: WebSocket.Server | undefined;
private frontendBaseUrl: string;
private server!: Server;
private fileServer!: RequestHandler;
private wss!: WebSocket.Server;
private baseUrl: string;
constructor(
zigbee: Zigbee,
@ -53,16 +55,13 @@ export default class Frontend extends Extension {
this.sslCert = frontendSettings.ssl_cert;
this.sslKey = frontendSettings.ssl_key;
this.authToken = frontendSettings.auth_token;
this.baseUrl = frontendSettings.base_url;
this.mqttBaseTopic = settings.get().mqtt.base_topic;
this.frontendBaseUrl = settings.get().frontend?.base_url ?? '/';
if (!this.frontendBaseUrl.startsWith('/')) {
this.frontendBaseUrl = '/' + this.frontendBaseUrl;
}
}
private isHttpsConfigured(): boolean {
if (this.sslCert && this.sslKey) {
if (!fs.existsSync(this.sslCert) || !fs.existsSync(this.sslKey)) {
if (!existsSync(this.sslCert) || !existsSync(this.sslKey)) {
logger.error(`defined ssl_cert '${this.sslCert}' or ssl_key '${this.sslKey}' file path does not exists, server won't be secured.`);
return false;
}
@ -72,30 +71,30 @@ export default class Frontend extends Extension {
}
override async start(): Promise<void> {
if (this.isHttpsConfigured()) {
const serverOptions = {
key: fs.readFileSync(this.sslKey!), // valid from `isHttpsConfigured`
cert: fs.readFileSync(this.sslCert!), // valid from `isHttpsConfigured`
};
this.server = https.createServer(serverOptions, this.onRequest);
} else {
this.server = http.createServer(this.onRequest);
}
this.server.on('upgrade', this.onUpgrade);
/* istanbul ignore next */
const options = {
setHeaders: (res: http.ServerResponse, path: string): void => {
setHeaders: (res: ServerResponse, path: string): void => {
if (path.endsWith('index.html')) {
res.setHeader('Cache-Control', 'no-store');
}
},
};
this.fileServer = gzipStatic(frontend.getPath(), options);
this.wss = new WebSocket.Server({noServer: true, path: path.posix.join(this.frontendBaseUrl, 'api')});
this.wss = new WebSocket.Server({noServer: true, path: posix.join(this.baseUrl, 'api')});
this.wss.on('connection', this.onWebSocketConnection);
if (this.isHttpsConfigured()) {
const serverOptions = {
key: readFileSync(this.sslKey!), // valid from `isHttpsConfigured`
cert: readFileSync(this.sslCert!), // valid from `isHttpsConfigured`
};
this.server = createSecureServer(serverOptions, this.onRequest);
} else {
this.server = createServer(this.onRequest);
}
this.server.on('upgrade', this.onUpgrade);
this.eventBus.onMQTTMessagePublished(this, this.onMQTTPublishMessage);
if (!this.host) {
@ -112,43 +111,42 @@ export default class Frontend extends Extension {
override async stop(): Promise<void> {
await super.stop();
this.wss?.clients.forEach((client) => {
this.wss.clients.forEach((client) => {
client.send(stringify({topic: 'bridge/state', payload: 'offline'}));
client.terminate();
});
this.wss?.close();
/* istanbul ignore else */
if (this.server) {
return await new Promise((cb: () => void) => this.server!.close(cb));
}
this.wss.close();
await new Promise((resolve) => this.server.close(resolve));
}
@bind private onRequest(request: http.IncomingMessage, response: http.ServerResponse): void {
@bind private onRequest(request: IncomingMessage, response: ServerResponse): void {
const fin = finalhandler(request, response);
const newUrl = posix.relative(this.baseUrl, request.url!);
const newUrl = path.posix.relative(this.frontendBaseUrl, request.url!);
// The request url is not within the frontend base url, so the relative path starts with '..'
if (newUrl.startsWith('.')) {
return fin();
}
// Attach originalUrl so that static-server can perform a redirect to '/' when serving the
// root directory. This is necessary for the browser to resolve relative assets paths correctly.
// Attach originalUrl so that static-server can perform a redirect to '/' when serving the root directory.
// This is necessary for the browser to resolve relative assets paths correctly.
request.originalUrl = request.url;
request.url = '/' + newUrl;
this.fileServer?.(request, response, fin);
this.fileServer(request, response, fin);
}
private authenticate(request: http.IncomingMessage, cb: (authenticate: boolean) => void): void {
const {query} = url.parse(request.url!, true);
private authenticate(request: IncomingMessage, cb: (authenticate: boolean) => void): void {
const {query} = parse(request.url!, true);
cb(!this.authToken || this.authToken === query.token);
}
@bind private onUpgrade(request: http.IncomingMessage, socket: net.Socket, head: Buffer): void {
this.wss!.handleUpgrade(request, socket, head, (ws) => {
@bind private onUpgrade(request: IncomingMessage, socket: Socket, head: Buffer): void {
this.wss.handleUpgrade(request, socket, head, (ws) => {
this.authenticate(request, (isAuthenticated) => {
if (isAuthenticated) {
this.wss!.emit('connection', ws, request);
this.wss.emit('connection', ws, request);
} else {
ws.close(4401, 'Unauthorized');
}
@ -182,6 +180,7 @@ export default class Frontend extends Extension {
for (const device of this.zigbee.devicesIterator(utils.deviceNotCoordinator)) {
const payload = this.state.get(device);
const lastSeen = settings.get().advanced.last_seen;
/* istanbul ignore if */
if (lastSeen !== 'disable') {
payload.last_seen = utils.formatDate(device.zh.lastSeen ?? 0, lastSeen);
@ -202,7 +201,7 @@ export default class Frontend extends Extension {
const topic = data.topic.substring(this.mqttBaseTopic.length + 1);
const payload = utils.parseJSON(data.payload, data.payload);
for (const client of this.wss!.clients) {
for (const client of this.wss.clients) {
/* istanbul ignore else */
if (client.readyState === WebSocket.OPEN) {
client.send(stringify({topic, payload}));

View File

@ -179,7 +179,7 @@ declare global {
auth_token?: string;
host?: string;
port: number;
base_url?: string;
base_url: string;
url?: string;
ssl_cert?: string;
ssl_key?: string;
@ -264,9 +264,3 @@ declare global {
qos?: 0 | 1 | 2;
}
}
declare module 'http' {
interface IncomingMessage {
originalUrl?: string;
}
}

View File

@ -2,6 +2,12 @@ declare module 'zigbee2mqtt-frontend' {
export function getPath(): string;
}
declare module 'http' {
interface IncomingMessage {
originalUrl?: string;
}
}
declare module 'connect-gzip-static' {
import {IncomingMessage, ServerResponse} from 'http';
export type RequestHandler = (req: IncomingMessage, res: ServerResponse, finalhandler: (err: unknown) => void) => void;

View File

@ -402,9 +402,11 @@
"requiresRestart": true
},
"base_url": {
"type": ["string", "null"],
"type": "string",
"pattern": "^\\/.*",
"title": "Base URL",
"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'",
"description": "Base URL for the frontend. If hosted under a subpath, e.g. 'http://localhost:8080/z2m', set this to '/z2m'",
"default": "/",
"requiresRestart": true
}
}

View File

@ -170,7 +170,7 @@ function loadSettingsWithDefaults(): void {
}
if (_settingsWithDefaults.frontend) {
const defaults = {port: 8080, auth_token: false};
const defaults = {port: 8080, auth_token: null, base_url: '/'};
const s = typeof _settingsWithDefaults.frontend === 'object' ? _settingsWithDefaults.frontend : {};
// @ts-expect-error ignore typing
_settingsWithDefaults.frontend = {};

View File

@ -358,7 +358,7 @@ describe('Frontend', () => {
expect(mockWS.implementation.emit).toHaveBeenCalledWith('connection', mockWSocket, {url});
});
it.each(['z2m', 'z2m/', '/z2m'])('Works with non-default base url %s', async (baseUrl) => {
it.each(['/z2m/', '/z2m'])('Works with non-default base url %s', async (baseUrl) => {
settings.set(['frontend'], {base_url: baseUrl});
controller = new Controller(jest.fn(), jest.fn());
await controller.start();
@ -382,4 +382,34 @@ describe('Frontend', () => {
expect(mockNodeStatic.implementation).not.toHaveBeenCalled();
expect(mockFinalHandler.implementation).toHaveBeenCalled();
});
it('Works with non-default complex base url', async () => {
const baseUrl = '/z2m-more++/c0mplex.url/';
settings.set(['frontend'], {base_url: baseUrl});
controller = new Controller(jest.fn(), jest.fn());
await controller.start();
expect(ws.Server).toHaveBeenCalledWith({noServer: true, path: '/z2m-more++/c0mplex.url/api'});
mockHTTP.variables.onRequest({url: '/z2m-more++/c0mplex.url'}, 2);
expect(mockNodeStatic.implementation).toHaveBeenCalledTimes(1);
expect(mockNodeStatic.implementation).toHaveBeenCalledWith({originalUrl: '/z2m-more++/c0mplex.url', url: '/'}, 2, expect.any(Function));
expect(mockFinalHandler.implementation).not.toHaveBeenCalledWith();
mockNodeStatic.implementation.mockReset();
expect(mockFinalHandler.implementation).not.toHaveBeenCalledWith();
mockHTTP.variables.onRequest({url: '/z2m-more++/c0mplex.url/file.txt'}, 2);
expect(mockNodeStatic.implementation).toHaveBeenCalledTimes(1);
expect(mockNodeStatic.implementation).toHaveBeenCalledWith(
{originalUrl: '/z2m-more++/c0mplex.url/file.txt', url: '/file.txt'},
2,
expect.any(Function),
);
expect(mockFinalHandler.implementation).not.toHaveBeenCalledWith();
mockNodeStatic.implementation.mockReset();
mockHTTP.variables.onRequest({url: '/z/file.txt'}, 2);
expect(mockNodeStatic.implementation).not.toHaveBeenCalled();
expect(mockFinalHandler.implementation).toHaveBeenCalled();
});
});

View File

@ -978,7 +978,7 @@ describe('Settings', () => {
write(configurationFile, {...minimalConfig, frontend: true});
settings.reRead();
expect(settings.get().frontend).toStrictEqual({port: 8080, auth_token: false});
expect(settings.get().frontend).toStrictEqual({port: 8080, auth_token: null, base_url: '/'});
});
it('Baudrate config', () => {