Skip to content

Commit c389ef1

Browse files
committed
added config option to configure a webroot for the ui
1 parent 394d8c3 commit c389ef1

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

51 files changed

+1593
-47
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ All notable changes to `homebridge-config-ui-x` will be documented in this file.
88

99
- added config option to advertise the ui over mdns
1010
- added config option to for inactivity-based session timeout
11+
- added config option to configure a webroot for the ui
1112

1213
### Homebridge Dependencies
1314

src/core/config/config.interfaces.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export interface StartupConfig {
1010
}
1111
cspWsOverride?: string
1212
debug?: boolean
13+
webroot?: string
1314
}
1415

1516
interface PluginChildBridge {
@@ -70,6 +71,7 @@ export interface HomebridgeUiConfig {
7071
name: string
7172
port: number
7273
host?: '::' | '0.0.0.0' | string
74+
webroot?: string
7375
proxyHost?: string
7476
auth: 'form' | 'none'
7577
theme: string

src/core/config/config.service.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export class ConfigService {
4646
public runningInLinux = (!this.runningInDocker && !this.runningInSynologyPackage && !this.runningInPackageMode && platform() === 'linux')
4747
public runningInFreeBSD = (platform() === 'freebsd')
4848
public canShutdownRestartHost = (this.runningInLinux || process.env.UIX_CAN_SHUTDOWN_RESTART_HOST === '1')
49+
public originalWebroot: string // set later by setOriginalWebroot()
4950
public enableTerminalAccess = (this.runningInDocker && process.env.HOMEBRIDGE_CONFIG_UI_TERMINAL !== '0')
5051
|| (this.runningInPackageMode && process.env.HOMEBRIDGE_CONFIG_UI_TERMINAL !== '0')
5152
|| this.runningInSynologyPackage
@@ -229,13 +230,23 @@ export class ConfigService {
229230
menuMode: this.ui.menuMode || 'default',
230231
wallpaper: this.ui.wallpaper,
231232
host: this.ui.host || '',
233+
webroot: this.ui.webroot || '',
234+
originalWebroot: this.originalWebroot || '',
232235
proxyHost: this.ui.proxyHost || '',
233236
homebridgePackagePath: this.ui.homebridgePackagePath,
234237
disableServerMetricsMonitoring: this.ui.disableServerMetricsMonitoring,
235238
keepOrphans: this.hbStartupSettings?.keepOrphans || false,
236239
}
237240
}
238241

242+
/**
243+
* Set the original webroot that is used (used by main.ts)
244+
* @param webroot
245+
*/
246+
public setOriginalWebroot(webroot: string) {
247+
this.originalWebroot = webroot
248+
}
249+
239250
/**
240251
* Checks to see if the UI requires a restart due to changed ui or bridge settings
241252
*/

src/core/config/config.startup.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export async function getStartupConfig() {
2727
}).length
2828

2929
config.host = ipv6 ? '::' : '0.0.0.0'
30+
config.webroot = '' // need to set as empty string rather than leaving as undefined
3031

3132
// If no ui settings configured - we are done
3233
if (!ui) {
@@ -75,5 +76,18 @@ export async function getStartupConfig() {
7576
process.env.UIX_DEBUG_LOGGING = '0'
7677
}
7778

79+
// Preload webroot settings
80+
if (ui.webroot && process.env.UIX_DEVELOPMENT !== '1') {
81+
// Normalise webroot: remove multiple slashes, ensure single leading slash, no trailing slash
82+
let webroot = `/${ui.webroot.toString().trim()}`.replace(/\/+/g, '/').replace(/\/$/, '')
83+
if (webroot === '/') {
84+
webroot = ''
85+
}
86+
config.webroot = webroot
87+
logger.log(`Setting up the UI on webroot: ${webroot}`)
88+
} else {
89+
config.webroot = '' // need to set as empty string rather than leaving as undefined
90+
}
91+
7892
return config
7993
}

src/core/spa/spa-html.service.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { resolve } from 'node:path'
2+
import process from 'node:process'
3+
4+
import { exists, readFile, writeFile } from 'fs-extra'
5+
6+
export class SpaHtmlService {
7+
/**
8+
* Updates the index.html file to use the specified webroot
9+
* @param webroot The webroot path (can include leading/trailing slashes, will be normalized)
10+
*/
11+
static async updateIndexHtml(webroot: string): Promise<void> {
12+
const indexPath = resolve(process.env.UIX_BASE_PATH, 'public/index.html')
13+
if (!(await exists(indexPath))) {
14+
return
15+
}
16+
const originalHtml = await readFile(indexPath, 'utf-8')
17+
const normalizedWebroot = this.normalizeWebroot(webroot)
18+
const modifiedHtml = this.transformHtmlForWebroot(originalHtml, normalizedWebroot)
19+
await writeFile(indexPath, modifiedHtml)
20+
}
21+
22+
/**
23+
* Normalizes webroot by removing leading/trailing slashes
24+
* @param webroot Raw webroot string
25+
* @returns Normalized webroot (empty string if no webroot)
26+
*/
27+
private static normalizeWebroot(webroot: string): string {
28+
if (!webroot || webroot === '/') {
29+
return ''
30+
}
31+
32+
return webroot.replace(/\/+/g, '/').replace(/^\/+|\/+$/g, '')
33+
}
34+
35+
/**
36+
* Transforms HTML to use relative paths with proper base href
37+
* @param html Original HTML content
38+
* @param webroot Normalized webroot (no leading/trailing slashes)
39+
* @returns Modified HTML with correct base href and relative asset paths
40+
*/
41+
private static transformHtmlForWebroot(html: string, webroot: string): string {
42+
const baseHref = webroot ? `/${webroot}/` : '/'
43+
44+
return html
45+
.replace(/<base href="[^"]*"(\s*)\/?>/g, `<base href="${baseHref}"$1/>`)
46+
.replace(/href="\/(?:.*?\/)?assets\//g, 'href="assets/')
47+
.replace(/href="\/(?:.*?\/)?favicon\.ico"/g, 'href="favicon.ico"')
48+
.replace(/href="\/(?:.*?\/)?([^"/]+\.(png|svg|webmanifest))"/g, 'href="$1"')
49+
.replace(/src="\/(?:.*?\/)?assets\//g, 'src="assets/')
50+
}
51+
}

src/core/spa/spa.filter.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,37 @@ import process from 'node:process'
66
import { Catch, NotFoundException } from '@nestjs/common'
77
import { readFileSync } from 'fs-extra'
88

9+
import '../../globalDefaults'
10+
911
@Catch(NotFoundException)
1012
export class SpaFilter implements ExceptionFilter {
13+
private readonly webroot: string
14+
15+
constructor() {
16+
const envWebroot = process.env.UIX_ORIGINAL_WEBROOT
17+
this.webroot = (envWebroot && envWebroot !== globalThis.webroot.errorCode)
18+
? envWebroot
19+
: ''
20+
}
21+
1122
catch(_exception: HttpException, host: ArgumentsHost) {
1223
const ctx = host.switchToHttp()
1324
const req = ctx.getRequest()
1425
const res = ctx.getResponse()
1526

16-
if (req.url.startsWith('/api/') || req.url.startsWith('/socket.io') || req.url.startsWith('/assets')) {
27+
// Check if request is for API, socket.io, assets, or static files (adjusted for webroot)
28+
const urlWithoutWebroot = this.webroot ? req.url.replace(new RegExp(`^${this.webroot}`), '') : req.url
29+
30+
if (urlWithoutWebroot.startsWith('/api/')
31+
|| urlWithoutWebroot.startsWith('/socket.io')
32+
|| urlWithoutWebroot.startsWith('/assets')
33+
|| urlWithoutWebroot.startsWith('/swagger')
34+
|| urlWithoutWebroot.match(/\.(js|css|png|jpg|jpeg|gif|svg|ico|woff|woff2|ttf|eot|webmanifest)$/)) {
35+
return res.code(404).send('Not Found')
36+
}
37+
38+
// Only serve SPA for requests that start with webroot (or all requests if no webroot)
39+
if (this.webroot && !req.url.startsWith(this.webroot)) {
1740
return res.code(404).send('Not Found')
1841
}
1942

src/globalDefaults.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,8 @@ globalThis.terminal = {
1313
// Default buffer size for terminal output in bytes
1414
bufferSize: 50000,
1515
}
16+
17+
globalThis.webroot = {
18+
// Error code to indicate webroot cannot be used
19+
errorCode: 'EACCES',
20+
}

src/main.ts

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { AppModule } from './app.module'
1717
import { ConfigService } from './core/config/config.service'
1818
import { getStartupConfig } from './core/config/config.startup'
1919
import { Logger } from './core/logger/logger.service'
20+
import { SpaHtmlService } from './core/spa/spa-html.service'
2021
import { SpaFilter } from './core/spa/spa.filter'
2122

2223
import './self-check'
@@ -88,8 +89,21 @@ async function bootstrap(): Promise<NestFastifyApplication> {
8889
const configService: ConfigService = app.get(ConfigService)
8990
const logger: Logger = app.get(Logger)
9091

91-
// Serve index.html without a cache
92-
app.getHttpAdapter().get('/', async (req: FastifyRequest, res: FastifyReply) => {
92+
// (5) Sort out the webroot - update index.html and set env var for spa filter
93+
let realWebroot = startupConfig.webroot || ''
94+
try {
95+
await SpaHtmlService.updateIndexHtml(startupConfig.webroot)
96+
process.env.UIX_ORIGINAL_WEBROOT = startupConfig.webroot
97+
configService.setOriginalWebroot(startupConfig.webroot)
98+
} catch (error) {
99+
logger.warn(`Could not update index.html with webroot ${startupConfig.webroot}: ${error.message}`)
100+
realWebroot = ''
101+
process.env.UIX_ORIGINAL_WEBROOT = globalThis.webroot.errorCode
102+
configService.setOriginalWebroot(globalThis.webroot.errorCode)
103+
}
104+
105+
// (6) Serve index.html without a cache
106+
app.getHttpAdapter().get(realWebroot || '/', async (req: FastifyRequest, res: FastifyReply) => {
93107
res.type('text/html')
94108
res.header('Cache-Control', 'no-cache, no-store, must-revalidate')
95109
res.header('Pragma', 'no-cache')
@@ -103,10 +117,11 @@ async function bootstrap(): Promise<NestFastifyApplication> {
103117
setHeaders(res) {
104118
res.setHeader('Cache-Control', 'public,max-age=31536000,immutable')
105119
},
120+
...realWebroot ? { prefix: realWebroot } : {},
106121
})
107122

108-
// Set prefix
109-
app.setGlobalPrefix('/api')
123+
// (8) Set api prefix (including webroot)
124+
app.setGlobalPrefix(`${realWebroot || ''}/api`)
110125

111126
// (9) Set up cors
112127
app.enableCors({
@@ -136,7 +151,7 @@ async function bootstrap(): Promise<NestFastifyApplication> {
136151
})
137152
.build()
138153
const document = SwaggerModule.createDocument(app, options)
139-
SwaggerModule.setup('swagger', app, document)
154+
SwaggerModule.setup(`${realWebroot}/swagger`.replace(/^\//, ''), app, document)
140155

141156
// (12) Use the spa filter to serve index.html for any non-api routes
142157
app.useGlobalFilters(new SpaFilter())

src/modules/custom-plugins/plugins-settings-ui/plugins-settings-ui.service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ export class PluginsSettingsUiService {
144144
serverEnv: ${JSON.stringify(this.configService.uiSettings(true))},
145145
};
146146
</script>
147-
<script src="${origin || 'http://localhost:4200'}/assets/plugin-ui-utils/ui.js?v=${this.configService.package.version}"></script>
147+
<script src="${origin || 'http://localhost:4200'}${origin ? (this.configService.ui.webroot || '') : ''}/assets/plugin-ui-utils/ui.js?v=${this.configService.package.version}"></script>
148148
<script>
149149
window.addEventListener('load', () => {
150150
window.parent.postMessage({action: 'loaded'}, '*');

src/modules/plugins/plugins.service.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -521,6 +521,19 @@ export class PluginsService {
521521

522522
const userPlatform = platform()
523523

524+
// Guard rails to keep users safe!
525+
// Here we can throw any error, and it will appear in the UI terminal for the user to see
526+
527+
// (1) If user has a webroot configured and is trying to install a UI version that doesn't support it
528+
if (this.configService.ui.webroot && lt(pluginAction.version, '5.7.1-alpha.0')) {
529+
throw new Error(
530+
`Cannot install HB UI v${pluginAction.version} when a webroot is configured.\n\r`
531+
+ 'Please either:\n\r'
532+
+ ' - Remove the configured webroot, restart Homebridge, then try the install again, or\n\r'
533+
+ ' - Install HB UI v5.8.0 or later.\n\r\n\r',
534+
)
535+
}
536+
524537
// Set the default install path
525538
let installPath = this.configService.customPluginPath
526539
? this.configService.customPluginPath

0 commit comments

Comments
 (0)