diff --git a/lib/extension/frontend.ts b/lib/extension/frontend.ts index 2de05efbb..05993a4bb 100644 --- a/lib/extension/frontend.ts +++ b/lib/extension/frontend.ts @@ -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 { diff --git a/lib/types/types.d.ts b/lib/types/types.d.ts index 365bd3fdb..3e1c056a3 100644 --- a/lib/types/types.d.ts +++ b/lib/types/types.d.ts @@ -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; + } +} diff --git a/lib/util/settings.schema.json b/lib/util/settings.schema.json index 7ee31ebe7..14b4e8ce0 100644 --- a/lib/util/settings.schema.json +++ b/lib/util/settings.schema.json @@ -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 } } } diff --git a/test/frontend.test.js b/test/frontend.test.js index 256b2ec43..857e79ea1 100644 --- a/test/frontend.test.js +++ b/test/frontend.test.js @@ -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(); + }); });