homebridge-config-ui-x
Version:
A web based management, configuration and control platform for Homebridge.
213 lines • 9.33 kB
JavaScript
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
var __param = (this && this.__param) || function (paramIndex, decorator) {
return function (target, key) { decorator(target, key, paramIndex); }
};
import { fork } from 'node:child_process';
import { readFile } from 'node:fs/promises';
import { basename, dirname, join, normalize, resolve } from 'node:path';
import process from 'node:process';
import { HttpService } from '@nestjs/axios';
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
import { pathExists } from 'fs-extra/esm';
import NodeCache from 'node-cache';
import { firstValueFrom } from 'rxjs';
import { ConfigService } from '../../../core/config/config.service.js';
import { Logger } from '../../../core/logger/logger.service.js';
import { RE_PATH_TRAVERSAL, RE_STATIC_ASSET_EXT } from '../../../core/regex.constants.js';
import { PluginsService } from '../../plugins/plugins.service.js';
let PluginsSettingsUiService = class PluginsSettingsUiService {
loggerService;
pluginsService;
configService;
httpService;
pluginUiMetadataCache = new NodeCache({ stdTTL: 86400 });
pluginUiLastVersionCache = new NodeCache({ stdTTL: 86400 });
constructor(loggerService, pluginsService, configService, httpService) {
this.loggerService = loggerService;
this.pluginsService = pluginsService;
this.configService = configService;
this.httpService = httpService;
}
async serveCustomUiAsset(reply, pluginName, assetPath, origin, version) {
try {
if (!assetPath) {
assetPath = 'index.html';
}
if (assetPath === 'index.html' && version) {
if (version !== this.pluginUiLastVersionCache.get(pluginName)) {
this.pluginUiMetadataCache.del(pluginName);
}
}
const pluginUi = this.pluginUiMetadataCache.get(pluginName)
|| (await this.getPluginUiMetadata(pluginName));
const safeSuffix = normalize(assetPath).replace(RE_PATH_TRAVERSAL, '');
const filePath = join(pluginUi.publicPath, safeSuffix);
if (!filePath.startsWith(resolve(pluginUi.publicPath))) {
return reply.code(404).send('Not Found');
}
reply.header('Content-Security-Policy', '');
if (assetPath === 'index.html') {
return reply
.type('text/html')
.send(await this.buildIndexHtml(pluginUi, origin));
}
if (pluginUi.devServer) {
return await this.serveAssetsFromDevServer(reply, pluginUi, assetPath);
}
const fallbackPath = resolve(process.env.UIX_BASE_PATH, 'public', basename(filePath));
if (await pathExists(filePath)) {
return reply.sendFile(basename(filePath), dirname(filePath));
}
else if (RE_STATIC_ASSET_EXT.test(fallbackPath) && await pathExists(fallbackPath)) {
return reply.sendFile(basename(fallbackPath), dirname(fallbackPath));
}
else {
this.loggerService.warn(`[${pluginName}] asset not found: ${assetPath}.`);
return reply.code(404).send('Not Found');
}
}
catch (e) {
this.loggerService.error(`[${pluginName}] UI threw an error - ${e.message}.`);
return e.message === 'Not Found' ? reply.code(404).send(e.message) : reply.code(500).send(e.message);
}
}
async getPluginUiMetadata(pluginName) {
try {
const pluginUi = await this.pluginsService.getPluginUiMetadata(pluginName);
this.pluginUiMetadataCache.set(pluginName, pluginUi);
this.pluginUiLastVersionCache.set(pluginName, pluginUi.plugin.installedVersion);
return pluginUi;
}
catch (e) {
this.loggerService.warn(`[${pluginName}] custom UI threw an error - ${e.message}.`);
throw new NotFoundException();
}
}
async serveAssetsFromDevServer(reply, pluginUi, assetPath) {
try {
const response = await firstValueFrom(this.httpService.get(`${pluginUi.devServer}/${assetPath}`, { responseType: 'text' }));
for (const [key, value] of Object.entries(response.headers)) {
reply.header(key, value);
}
reply.send(response.data);
}
catch {
reply.code(404).send('Not Found');
}
}
async getIndexHtmlBody(pluginUi) {
if (pluginUi.devServer) {
return (await firstValueFrom(this.httpService.get(pluginUi.devServer, { responseType: 'text' }))).data;
}
else {
return await readFile(join(pluginUi.publicPath, 'index.html'), 'utf8');
}
}
async buildIndexHtml(pluginUi, origin) {
const body = await this.getIndexHtmlBody(pluginUi);
return `
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>${pluginUi.plugin.name}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<script>
window._homebridge = {
plugin: ${JSON.stringify(pluginUi.plugin)},
serverEnv: ${JSON.stringify(this.configService.uiSettings(true))},
};
</script>
<script src="${origin || 'http://localhost:4200'}/assets/plugin-ui-utils/ui.js?v=${this.configService.package.version}"></script>
<script>
window.addEventListener('load', () => {
window.parent.postMessage({action: 'loaded'}, '*');
}, false)
</script>
</head>
<body style="display:none;">
${body}
</body>
</html>
`;
}
async startCustomUiHandler(pluginName, client) {
const pluginUi = this.pluginUiMetadataCache.get(pluginName)
|| (await this.getPluginUiMetadata(pluginName));
if (!await pathExists(resolve(pluginUi.serverPath))) {
client.emit('ready', { server: false });
return;
}
const childEnv = { ...process.env };
childEnv.HOMEBRIDGE_STORAGE_PATH = this.configService.storagePath;
childEnv.HOMEBRIDGE_CONFIG_PATH = this.configService.configPath;
childEnv.HOMEBRIDGE_UI_VERSION = this.configService.package.version;
const child = fork(pluginUi.serverPath, [], {
silent: true,
env: childEnv,
});
child.stdout.on('data', (data) => {
this.loggerService.log(`[${pluginName}] ${data.toString().trim()}`);
});
child.stderr.on('data', (data) => {
this.loggerService.error(`[${pluginName}] ${data.toString().trim()}`);
});
child.on('exit', () => {
this.loggerService.debug(`[${pluginName}] custom UI closed (child process ended).`);
});
child.addListener('message', (response) => {
if (typeof response === 'object' && response.action) {
response.action = response.action === 'error' ? 'server_error' : response.action;
client.emit(response.action, response.payload);
}
});
const cleanup = () => {
this.loggerService.debug(`[${pluginName}] custom UI closing (terminating child process)...`);
const childPid = child.pid;
if (child.connected) {
child.disconnect();
}
setTimeout(() => {
try {
process.kill(childPid, 'SIGTERM');
}
catch (e) { }
}, 5000);
client.removeAllListeners('end');
client.removeAllListeners('disconnect');
client.removeAllListeners('request');
};
client.on('disconnect', () => {
cleanup();
});
client.on('end', () => {
cleanup();
});
client.on('request', (request) => {
if (child.connected) {
child.send(request);
}
});
}
};
PluginsSettingsUiService = __decorate([
Injectable(),
__param(0, Inject(Logger)),
__param(1, Inject(PluginsService)),
__param(2, Inject(ConfigService)),
__param(3, Inject(HttpService)),
__metadata("design:paramtypes", [Logger,
PluginsService,
ConfigService,
HttpService])
], PluginsSettingsUiService);
export { PluginsSettingsUiService };
//# sourceMappingURL=plugins-settings-ui.service.js.map