mirror of
https://github.com/Koenkk/zigbee2mqtt.git
synced 2026-08-28 05:24:29 +00:00
feat: Allow serving frontend under subpath (#24244)
* Allow app running in the server subdirectory * Use path.posix to manage urls * Add base_utl to config schema
This commit is contained in:
@@ -3,6 +3,7 @@ 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 bind from 'bind-decorator';
|
||||
@@ -31,6 +32,7 @@ export default class Frontend extends Extension {
|
||||
private server: http.Server | undefined;
|
||||
private fileServer: RequestHandler | undefined;
|
||||
private wss: WebSocket.Server | undefined;
|
||||
private frontendBaseUrl: string;
|
||||
|
||||
constructor(
|
||||
zigbee: Zigbee,
|
||||
@@ -52,6 +54,10 @@ export default class Frontend extends Extension {
|
||||
this.sslKey = frontendSettings.ssl_key;
|
||||
this.authToken = frontendSettings.auth_token;
|
||||
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 {
|
||||
@@ -87,7 +93,7 @@ export default class Frontend extends Extension {
|
||||
},
|
||||
};
|
||||
this.fileServer = gzipStatic(frontend.getPath(), options);
|
||||
this.wss = new WebSocket.Server({noServer: true});
|
||||
this.wss = new WebSocket.Server({noServer: true, path: path.posix.join(this.frontendBaseUrl, 'api')});
|
||||
this.wss.on('connection', this.onWebSocketConnection);
|
||||
|
||||
this.eventBus.onMQTTMessagePublished(this, this.onMQTTPublishMessage);
|
||||
@@ -118,7 +124,19 @@ export default class Frontend extends Extension {
|
||||
}
|
||||
|
||||
@bind private onRequest(request: http.IncomingMessage, response: http.ServerResponse): void {
|
||||
this.fileServer?.(request, response, finalhandler(request, response));
|
||||
const fin = finalhandler(request, response);
|
||||
|
||||
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.
|
||||
request.originalUrl = request.url;
|
||||
request.url = '/' + newUrl;
|
||||
this.fileServer?.(request, response, fin);
|
||||
}
|
||||
|
||||
private authenticate(request: http.IncomingMessage, cb: (authenticate: boolean) => void): void {
|
||||
|
||||
Vendored
+7
@@ -179,6 +179,7 @@ declare global {
|
||||
auth_token?: string;
|
||||
host?: string;
|
||||
port: number;
|
||||
base_url?: string;
|
||||
url?: string;
|
||||
ssl_cert?: string;
|
||||
ssl_key?: string;
|
||||
@@ -263,3 +264,9 @@ declare global {
|
||||
qos?: 0 | 1 | 2;
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'http' {
|
||||
interface IncomingMessage {
|
||||
originalUrl?: string;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -400,6 +400,12 @@
|
||||
"title": "key file path",
|
||||
"description": "SSL key file path for exposing HTTPS. The sibling property 'ssl_cert' must be set for HTTPS to be activated.",
|
||||
"requiresRestart": true
|
||||
},
|
||||
"base_url": {
|
||||
"type": ["string", "null"],
|
||||
"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'",
|
||||
"requiresRestart": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+43
-22
@@ -8,8 +8,14 @@ const stringify = require('json-stable-stringify-without-jsonify');
|
||||
const flushPromises = require('./lib/flushPromises');
|
||||
const zigbeeHerdsman = require('./stub/zigbeeHerdsman');
|
||||
const path = require('path');
|
||||
const finalhandler = require('finalhandler');
|
||||
const ws = require('ws');
|
||||
jest.spyOn(process, 'exit').mockImplementation(() => {});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
const mockHTTP = {
|
||||
implementation: {
|
||||
listen: jest.fn(),
|
||||
@@ -60,6 +66,10 @@ const mockNodeStatic = {
|
||||
events: {},
|
||||
};
|
||||
|
||||
const mockFinalHandler = {
|
||||
implementation: jest.fn(),
|
||||
};
|
||||
|
||||
jest.mock('http', () => ({
|
||||
createServer: jest.fn().mockImplementation((onRequest) => {
|
||||
mockHTTP.variables.onRequest = onRequest;
|
||||
@@ -94,6 +104,12 @@ jest.mock('ws', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('finalhandler', () =>
|
||||
jest.fn().mockImplementation(() => {
|
||||
return mockFinalHandler.implementation;
|
||||
}),
|
||||
);
|
||||
|
||||
describe('Frontend', () => {
|
||||
let controller;
|
||||
|
||||
@@ -136,10 +152,6 @@ describe('Frontend', () => {
|
||||
expect(mockWSClient.implementation.terminate).toHaveBeenCalledTimes(1);
|
||||
expect(mockHTTP.implementation.close).toHaveBeenCalledTimes(1);
|
||||
expect(mockWS.implementation.close).toHaveBeenCalledTimes(1);
|
||||
mockWS.implementation.close.mockClear();
|
||||
mockHTTP.implementation.close.mockClear();
|
||||
mockHTTP.implementation.listen.mockClear();
|
||||
mockHTTPS.implementation.listen.mockClear();
|
||||
});
|
||||
|
||||
it('Start/stop without host', async () => {
|
||||
@@ -160,10 +172,6 @@ describe('Frontend', () => {
|
||||
expect(mockWSClient.implementation.terminate).toHaveBeenCalledTimes(1);
|
||||
expect(mockHTTP.implementation.close).toHaveBeenCalledTimes(1);
|
||||
expect(mockWS.implementation.close).toHaveBeenCalledTimes(1);
|
||||
mockWS.implementation.close.mockClear();
|
||||
mockHTTP.implementation.close.mockClear();
|
||||
mockHTTP.implementation.listen.mockClear();
|
||||
mockHTTPS.implementation.listen.mockClear();
|
||||
});
|
||||
|
||||
it('Start/stop unix socket', async () => {
|
||||
@@ -184,10 +192,6 @@ describe('Frontend', () => {
|
||||
expect(mockWSClient.implementation.terminate).toHaveBeenCalledTimes(1);
|
||||
expect(mockHTTP.implementation.close).toHaveBeenCalledTimes(1);
|
||||
expect(mockWS.implementation.close).toHaveBeenCalledTimes(1);
|
||||
mockWS.implementation.close.mockClear();
|
||||
mockHTTP.implementation.close.mockClear();
|
||||
mockHTTP.implementation.listen.mockClear();
|
||||
mockHTTPS.implementation.listen.mockClear();
|
||||
});
|
||||
|
||||
it('Start/stop HTTPS valid', async () => {
|
||||
@@ -198,8 +202,6 @@ describe('Frontend', () => {
|
||||
expect(mockHTTP.implementation.listen).not.toHaveBeenCalledWith(8081, '127.0.0.1');
|
||||
expect(mockHTTPS.implementation.listen).toHaveBeenCalledWith(8081, '127.0.0.1');
|
||||
await controller.stop();
|
||||
mockHTTP.implementation.listen.mockClear();
|
||||
mockHTTPS.implementation.listen.mockClear();
|
||||
});
|
||||
|
||||
it('Start/stop HTTPS invalid : missing config', async () => {
|
||||
@@ -209,8 +211,6 @@ describe('Frontend', () => {
|
||||
expect(mockHTTP.implementation.listen).toHaveBeenCalledWith(8081, '127.0.0.1');
|
||||
expect(mockHTTPS.implementation.listen).not.toHaveBeenCalledWith(8081, '127.0.0.1');
|
||||
await controller.stop();
|
||||
mockHTTP.implementation.listen.mockClear();
|
||||
mockHTTPS.implementation.listen.mockClear();
|
||||
});
|
||||
|
||||
it('Start/stop HTTPS invalid : missing file', async () => {
|
||||
@@ -221,8 +221,6 @@ describe('Frontend', () => {
|
||||
expect(mockHTTP.implementation.listen).toHaveBeenCalledWith(8081, '127.0.0.1');
|
||||
expect(mockHTTPS.implementation.listen).not.toHaveBeenCalledWith(8081, '127.0.0.1');
|
||||
await controller.stop();
|
||||
mockHTTP.implementation.listen.mockClear();
|
||||
mockHTTPS.implementation.listen.mockClear();
|
||||
});
|
||||
|
||||
it('Websocket interaction', async () => {
|
||||
@@ -314,7 +312,6 @@ describe('Frontend', () => {
|
||||
await controller.start();
|
||||
|
||||
const mockSocket = {destroy: jest.fn()};
|
||||
mockWS.implementation.handleUpgrade.mockClear();
|
||||
mockHTTP.events.upgrade({url: 'http://localhost:8080/api'}, mockSocket, 3);
|
||||
expect(mockWS.implementation.handleUpgrade).toHaveBeenCalledTimes(1);
|
||||
expect(mockSocket.destroy).toHaveBeenCalledTimes(0);
|
||||
@@ -322,9 +319,9 @@ describe('Frontend', () => {
|
||||
mockWS.implementation.handleUpgrade.mock.calls[0][3](99);
|
||||
expect(mockWS.implementation.emit).toHaveBeenCalledWith('connection', 99, {url: 'http://localhost:8080/api'});
|
||||
|
||||
mockHTTP.variables.onRequest(1, 2);
|
||||
mockHTTP.variables.onRequest({url: '/file.txt'}, 2);
|
||||
expect(mockNodeStatic.implementation).toHaveBeenCalledTimes(1);
|
||||
expect(mockNodeStatic.implementation).toHaveBeenCalledWith(1, 2, expect.any(Function));
|
||||
expect(mockNodeStatic.implementation).toHaveBeenCalledWith({originalUrl: '/file.txt', url: '/file.txt'}, 2, expect.any(Function));
|
||||
});
|
||||
|
||||
it('Static server', async () => {
|
||||
@@ -341,7 +338,6 @@ describe('Frontend', () => {
|
||||
await controller.start();
|
||||
|
||||
const mockSocket = {destroy: jest.fn()};
|
||||
mockWS.implementation.handleUpgrade.mockClear();
|
||||
mockHTTP.events.upgrade({url: '/api'}, mockSocket, mockWSocket);
|
||||
expect(mockWS.implementation.handleUpgrade).toHaveBeenCalledTimes(1);
|
||||
expect(mockSocket.destroy).toHaveBeenCalledTimes(0);
|
||||
@@ -361,4 +357,29 @@ describe('Frontend', () => {
|
||||
mockWS.implementation.handleUpgrade.mock.calls[0][3](mockWSocket);
|
||||
expect(mockWS.implementation.emit).toHaveBeenCalledWith('connection', mockWSocket, {url});
|
||||
});
|
||||
|
||||
it.each(['z2m', '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();
|
||||
|
||||
expect(ws.Server).toHaveBeenCalledWith({noServer: true, path: '/z2m/api'});
|
||||
|
||||
mockHTTP.variables.onRequest({url: '/z2m'}, 2);
|
||||
expect(mockNodeStatic.implementation).toHaveBeenCalledTimes(1);
|
||||
expect(mockNodeStatic.implementation).toHaveBeenCalledWith({originalUrl: '/z2m', url: '/'}, 2, expect.any(Function));
|
||||
expect(mockFinalHandler.implementation).not.toHaveBeenCalledWith();
|
||||
|
||||
mockNodeStatic.implementation.mockReset();
|
||||
expect(mockFinalHandler.implementation).not.toHaveBeenCalledWith();
|
||||
mockHTTP.variables.onRequest({url: '/z2m/file.txt'}, 2);
|
||||
expect(mockNodeStatic.implementation).toHaveBeenCalledTimes(1);
|
||||
expect(mockNodeStatic.implementation).toHaveBeenCalledWith({originalUrl: '/z2m/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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user