UNPKG

homebridge-config-ui-x

Version:

A web based management, configuration and control platform for Homebridge.

646 lines • 28.3 kB
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 { readdir, readFile, rename, unlink } from 'node:fs/promises'; import { join, resolve } from 'node:path'; import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common'; import dayjs from 'dayjs'; import { ensureDir, move, pathExists, readJson, remove, writeJsonSync } from 'fs-extra/esm'; import { gte } from 'semver'; import { ConfigService } from '../../core/config/config.service.js'; import { HomebridgeIpcService } from '../../core/homebridge-ipc/homebridge-ipc.service.js'; import { Logger } from '../../core/logger/logger.service.js'; import { RE_COLON, RE_CONFIG_BACKUP, RE_PIN, RE_PLUGIN_NAME, RE_USERNAME } from '../../core/regex.constants.js'; import { SchedulerService } from '../../core/scheduler/scheduler.service.js'; import { PluginsService } from '../plugins/plugins.service.js'; let ConfigEditorService = class ConfigEditorService { logger; configService; schedulerService; pluginsService; homebridgeIpcService; constructor(logger, configService, schedulerService, pluginsService, homebridgeIpcService) { this.logger = logger; this.configService = configService; this.schedulerService = schedulerService; this.pluginsService = pluginsService; this.homebridgeIpcService = homebridgeIpcService; this.start(); this.scheduleConfigBackupCleanup(); } async start() { await this.ensureBackupPathExists(); await this.migrateConfigBackups(); } scheduleConfigBackupCleanup() { const scheduleRule = new this.schedulerService.RecurrenceRule(); scheduleRule.hour = 1; scheduleRule.minute = 10; scheduleRule.second = Math.floor(Math.random() * 59) + 1; this.logger.debug(`Next config.json backup cleanup scheduled for ${scheduleRule.nextInvocationDate(new Date()).toString()}.`); this.schedulerService.scheduleJob('cleanup-config-backups', scheduleRule, () => { this.logger.log('Running job to cleanup config.json backup files older than 60 days...'); this.cleanupConfigBackups(); }); } async getConfigFile() { const config = await readJson(this.configService.configPath); if (!config.bridge || typeof config.bridge !== 'object') { config.bridge = {}; } if (!config.accessories || !Array.isArray(config.accessories)) { config.accessories = []; } if (!config.platforms || !Array.isArray(config.platforms)) { config.platforms = []; } return config; } async updateConfigFile(config) { const now = new Date(); if (!config) { config = {}; } if (!config.bridge) { config.bridge = {}; } if (typeof config.bridge.port === 'string') { config.bridge.port = Number.parseInt(config.bridge.port, 10); } if (!config.bridge.port || typeof config.bridge.port !== 'number' || config.bridge.port > 65533 || config.bridge.port < 1025) { config.bridge.port = Math.floor(Math.random() * (52000 - 51000 + 1) + 51000); } if (!config.bridge.username) { config.bridge.username = this.generateUsername(); } if (!RE_USERNAME.test(config.bridge.username)) { if (RE_USERNAME.test(this.configService.homebridgeConfig.bridge.username)) { config.bridge.username = this.configService.homebridgeConfig.bridge.username; } else { config.bridge.username = this.generateUsername(); } } if (!config.bridge.pin) { config.bridge.pin = this.generatePin(); } if (!RE_PIN.test(config.bridge.pin)) { if (RE_PIN.test(this.configService.homebridgeConfig.bridge.pin)) { config.bridge.pin = this.configService.homebridgeConfig.bridge.pin; } else { config.bridge.pin = this.generatePin(); } } if (!config.bridge.name || typeof config.bridge.name !== 'string') { config.bridge.name = `Homebridge ${config.bridge.username.substring(config.bridge.username.length - 5).replace(RE_COLON, '')}`; } if (!config.accessories || !Array.isArray(config.accessories)) { config.accessories = []; } if (!config.platforms || !Array.isArray(config.platforms)) { config.platforms = []; } if (config.plugins && Array.isArray(config.plugins)) { if (!config.plugins.length) { delete config.plugins; } } else if (config.plugins) { delete config.plugins; } if (config.mdns && typeof config.mdns !== 'object') { delete config.mdns; } if (config.disabledPlugins && !Array.isArray(config.disabledPlugins)) { delete config.disabledPlugins; } try { await rename(this.configService.configPath, resolve(this.configService.configBackupPath, `config.json.${now.getTime().toString()}`)); } catch (e) { if (e.code === 'ENOENT') { await this.ensureBackupPathExists(); } else { this.logger.warn(`Could not create a backup of the config.json file to ${this.configService.configBackupPath} as ${e.message}.`); } } writeJsonSync(this.configService.configPath, config, { spaces: 4 }); this.logger.log('Changes to config.json saved.'); const configCopy = JSON.parse(JSON.stringify(config)); this.configService.parseConfig(configCopy); return config; } async getConfigForPlugin(pluginName) { const [plugin, config] = await Promise.all([ await this.pluginsService.getPluginAlias(pluginName), await this.getConfigFile(), ]); if (!plugin.pluginAlias) { return new BadRequestException('Plugin alias could not be determined.'); } const arrayKey = plugin.pluginType === 'accessory' ? 'accessories' : 'platforms'; return config[arrayKey].filter((block) => { return block[plugin.pluginType] === plugin.pluginAlias || block[plugin.pluginType] === `${pluginName}.${plugin.pluginAlias}`; }); } async updateConfigForPlugin(pluginName, pluginConfig) { const [plugin, config] = await Promise.all([ await this.pluginsService.getPluginAlias(pluginName), await this.getConfigFile(), ]); if (!plugin.pluginAlias) { return new BadRequestException('Plugin alias could not be determined.'); } const arrayKey = plugin.pluginType === 'accessory' ? 'accessories' : 'platforms'; if (!Array.isArray(pluginConfig)) { throw new BadRequestException('Plugin Config must be an array.'); } for (const block of pluginConfig) { if (typeof block !== 'object' || Array.isArray(block)) { throw new BadRequestException('Plugin config must be an array of objects.'); } block[plugin.pluginType] = plugin.pluginAlias; } let positionIndices; if (arrayKey === 'accessories') { config.accessories = config.accessories?.filter((block, index) => { if (block[plugin.pluginType] === plugin.pluginAlias || block[plugin.pluginType] === `${pluginName}.${plugin.pluginAlias}`) { positionIndices = index; return false; } else { return true; } }) || []; } else { config.platforms = config.platforms?.filter((block, index) => { if (block[plugin.pluginType] === plugin.pluginAlias || block[plugin.pluginType] === `${pluginName}.${plugin.pluginAlias}`) { positionIndices = index; return false; } else { return true; } }) || []; } pluginConfig.forEach((block) => { if (block._bridge) { if (plugin.pluginType === 'accessory' && block._bridge.matter) { this.logger.warn(`Removing Matter configuration from accessory-based plugin: ${pluginName}`); delete block._bridge.matter; } const isEnvObjAllowed = gte(this.configService.homebridgeVersion, '1.8.0'); Object.keys(block._bridge).forEach((key) => { if (key === 'env' && isEnvObjAllowed) { Object.keys(block._bridge.env).forEach((envKey) => { if (block._bridge.env[envKey] === undefined || typeof block._bridge.env[envKey] !== 'string' || block._bridge.env[envKey].trim() === '') { delete block._bridge.env[envKey]; } }); if (Object.keys(block._bridge.env).length === 0) { delete block._bridge.env; } } else { if (block._bridge[key] === undefined || (typeof block._bridge[key] === 'string' && block._bridge[key].trim() === '')) { delete block._bridge[key]; } } }); } }); if (arrayKey === 'accessories') { if (positionIndices !== undefined) { config.accessories?.splice(positionIndices, 0, ...pluginConfig); } else { config.accessories?.push(...pluginConfig); } } else { if (positionIndices !== undefined) { config.platforms?.splice(positionIndices, 0, ...pluginConfig); } else { config.platforms?.push(...pluginConfig); } } await this.updateConfigFile(config); return pluginConfig; } async getPropertyForUi(property) { const config = await this.getConfigFile(); const pluginConfig = config.platforms.find(x => x.platform === 'config'); if (property.includes('.')) { const properties = property.split('.'); let current = pluginConfig; for (const prop of properties) { if (current && typeof current === 'object') { current = current[prop]; } else { return undefined; } } return current; } return pluginConfig?.[property]; } async setPropertyForUi(property, value) { if (property === 'platform') { throw new BadRequestException('Cannot update the platform property.'); } const config = await this.getConfigFile(); const pluginConfig = config.platforms.find(x => x.platform === 'config'); const forbiddenKeys = ['__proto__', 'constructor', 'prototype']; if (property.includes('.')) { const properties = property.split('.'); let current = pluginConfig; for (let i = 0; i < properties.length - 1; i += 1) { if (!forbiddenKeys.includes(properties[i])) { if (!current[properties[i]]) { current[properties[i]] = {}; } current = current[properties[i]]; } } const finalProperty = properties.at(-1); if (!forbiddenKeys.includes(finalProperty)) { current[finalProperty] = value; } } else { if (!forbiddenKeys.includes(property)) { pluginConfig[property] = value; } } config.platforms[config.platforms.findIndex(x => x.platform === 'config')] = this.cleanUpUiConfig(pluginConfig); await this.updateConfigFile(config); } async setAccessoryControlInstanceBlacklist(value) { const config = await this.getConfigFile(); const pluginConfig = config.platforms.find(x => x.platform === 'config'); if (!pluginConfig.accessoryControl) { pluginConfig.accessoryControl = {}; } pluginConfig.accessoryControl.instanceBlacklist = (value || []) .filter(x => typeof x === 'string' && x.trim() !== '' && RE_USERNAME.test(x.trim())) .map(x => x.trim().toUpperCase()) .sort((a, b) => a.localeCompare(b)); config.platforms[config.platforms.findIndex(x => x.platform === 'config')] = this.cleanUpUiConfig(pluginConfig); await this.updateConfigFile(config); } async getPluginsHideUpdatesFor() { const config = await this.getConfigFile(); const pluginConfig = config.platforms.find(x => x.platform === 'config'); return pluginConfig?.plugins?.hideUpdatesFor || []; } async setPluginsHideUpdatesFor(value) { const config = await this.getConfigFile(); const pluginConfig = config.platforms.find(x => x.platform === 'config'); if (!pluginConfig.plugins) { pluginConfig.plugins = {}; } pluginConfig.plugins.hideUpdatesFor = (value || []) .filter(x => typeof x === 'string' && x.trim() !== '' && RE_PLUGIN_NAME.test(x.trim())) .map(x => x.trim().toLowerCase()); config.platforms[config.platforms.findIndex(x => x.platform === 'config')] = this.cleanUpUiConfig(pluginConfig); await this.updateConfigFile(config); } async getBridge(username) { if (!username || !RE_USERNAME.test(username.trim())) { return null; } const config = await this.getConfigFile(); const pluginConfig = config.platforms.find(x => x.platform === 'config'); const normalizedUsername = username.trim().toUpperCase(); const bridge = pluginConfig?.bridges?.find((b) => b.username.toUpperCase() === normalizedUsername); return { username: normalizedUsername, hideHapAlert: bridge?.hideHapAlert || false, hideMatterAlert: bridge?.hideMatterAlert || false, scheduledRestartCron: bridge?.scheduledRestartCron || null, ...(bridge ? Object.keys(bridge).reduce((acc, key) => { if (key !== 'username' && key !== 'hideHapAlert' && key !== 'hideMatterAlert' && key !== 'scheduledRestartCron') { acc[key] = bridge[key]; } return acc; }, {}) : {}), }; } async updateBridgeProperty(username, property, value) { if (!username || !RE_USERNAME.test(username.trim())) { throw new NotFoundException('Invalid bridge username format'); } const config = await this.getConfigFile(); const pluginConfig = config.platforms.find(x => x.platform === 'config'); const normalizedUsername = username.trim().toUpperCase(); if (!pluginConfig.bridges) { pluginConfig.bridges = []; } let bridge = pluginConfig.bridges.find((b) => b.username.toUpperCase() === normalizedUsername); const shouldSet = value !== null && value !== undefined && value !== false && value !== '' && value !== 'never'; if (shouldSet) { if (!bridge) { bridge = { username: normalizedUsername }; pluginConfig.bridges.push(bridge); } bridge[property] = value; } else { if (bridge) { delete bridge[property]; const hasOtherProps = Object.keys(bridge).some(key => key !== 'username'); if (!hasOtherProps) { pluginConfig.bridges = pluginConfig.bridges.filter((b) => b.username.toUpperCase() !== normalizedUsername); } } } config.platforms[config.platforms.findIndex(x => x.platform === 'config')] = this.cleanUpUiConfig(pluginConfig); await this.updateConfigFile(config); } async setBridgeHideHapAlert(username, value) { await this.updateBridgeProperty(username, 'hideHapAlert', value); } async setBridgeHideMatterAlert(username, value) { await this.updateBridgeProperty(username, 'hideMatterAlert', value); } async setBridgeScheduledRestartCron(username, value) { await this.updateBridgeProperty(username, 'scheduledRestartCron', value); } async disablePlugin(pluginName) { if (pluginName === this.configService.name) { throw new BadRequestException('Disabling this plugin is now allowed.'); } const config = await this.getConfigFile(); if (!Array.isArray(config.disabledPlugins)) { config.disabledPlugins = []; } config.disabledPlugins.push(pluginName); await this.updateConfigFile(config); return config.disabledPlugins; } async enablePlugin(pluginName) { const config = await this.getConfigFile(); if (!Array.isArray(config.disabledPlugins)) { config.disabledPlugins = []; } const idx = config.disabledPlugins.findIndex(x => x === pluginName); if (idx > -1) { config.disabledPlugins.splice(idx, 1); await this.updateConfigFile(config); } return config.disabledPlugins; } async listConfigBackups() { const dirContents = await readdir(this.configService.configBackupPath); return dirContents .filter(x => x.match(RE_CONFIG_BACKUP)) .sort() .reverse() .map((x) => { const ext = x.split('.'); if (ext.length === 3 && !Number.isNaN(ext[2])) { return { id: ext[2], timestamp: new Date(Number.parseInt(ext[2], 10)), file: x, }; } else { return null; } }) .filter(x => x && !Number.isNaN(x.timestamp.getTime())); } async getConfigBackup(backupId) { const requestedBackupPath = resolve(this.configService.configBackupPath, `config.json.${backupId}`); if (!await pathExists(requestedBackupPath)) { throw new NotFoundException(`Backup ${backupId} Not Found`); } return await readFile(requestedBackupPath); } async deleteConfigBackup(backupId) { const requestedBackupPath = resolve(this.configService.configBackupPath, `config.json.${backupId}`); if (!await pathExists(requestedBackupPath)) { throw new NotFoundException(`Backup ${backupId} Not Found`); } await unlink(resolve(this.configService.configBackupPath, `config.json.${backupId}`)); } async deleteAllConfigBackups() { const backups = await this.listConfigBackups(); for (const backupFile of backups) { await unlink(resolve(this.configService.configBackupPath, backupFile.file)); } } async ensureBackupPathExists() { try { await ensureDir(this.configService.configBackupPath); } catch (e) { this.logger.error(`Could not create directory for config backups ${this.configService.configBackupPath} as ${e.message}.`); this.logger.error(`Config backups will continue to use ${this.configService.storagePath}.`); this.configService.configBackupPath = this.configService.storagePath; } } async cleanupConfigBackups() { try { const backups = await this.listConfigBackups(); for (const backup of backups) { if (dayjs().diff(dayjs(backup.timestamp), 'day') >= 60) { await remove(resolve(this.configService.configBackupPath, backup.file)); } } } catch (e) { this.logger.warn(`Failed to cleanup old config.json backup files as ${e.message}`); } } async migrateConfigBackups() { try { if (this.configService.configBackupPath === this.configService.storagePath) { this.logger.error('Skipping migration of existing config.json backups...'); return; } const dirContents = await readdir(this.configService.storagePath); const backups = dirContents .filter(x => x.match(RE_CONFIG_BACKUP)) .sort() .reverse(); for (const backupFileName of backups.splice(0, 100)) { const sourcePath = resolve(this.configService.storagePath, backupFileName); const targetPath = resolve(this.configService.configBackupPath, backupFileName); await move(sourcePath, targetPath, { overwrite: true }); } for (const backupFileName of backups) { const sourcePath = resolve(this.configService.storagePath, backupFileName); await remove(sourcePath); } } catch (e) { this.logger.warn(`Migrating config.json backups to new location failed as ${e.message}.`); } } generatePin() { let code = `${Math.floor(10000000 + Math.random() * 90000000)}`; code = code.split(''); code.splice(3, 0, '-'); code.splice(6, 0, '-'); code = code.join(''); return code; } generateUsername() { const hexDigits = '0123456789ABCDEF'; let username = '0E:'; for (let i = 0; i < 5; i += 1) { username += hexDigits.charAt(Math.round(Math.random() * 15)); username += hexDigits.charAt(Math.round(Math.random() * 15)); if (i !== 4) { username += ':'; } } return username; } removeEmpty(obj) { Object.keys(obj).forEach((key) => { const value = obj[key]; if (value === '' || value === null || value === undefined || value === false || (Array.isArray(value) && value.length === 0)) { delete obj[key]; } else if (typeof value === 'object') { this.removeEmpty(value); if (Object.keys(value).length === 0) { delete obj[key]; } } }); } cleanUpUiConfig(uiConfig) { const { name, platform, ...rest } = uiConfig; const cleanedUiConfig = { name, platform: platform || 'config', ...rest, }; if (Array.isArray(cleanedUiConfig.bridges)) { cleanedUiConfig.bridges = cleanedUiConfig.bridges.filter(bridge => bridge && bridge.username); } this.removeEmpty(cleanedUiConfig); return cleanedUiConfig; } async getMatterPortRange() { const config = await this.getConfigFile(); return { start: config.matterPorts?.start, end: config.matterPorts?.end, }; } async setMatterPortRange(value) { this.validateMatterPortRange(value); let config = await this.getConfigFile(); if (value.start === null || value.start === undefined) { delete value.start; } if (value.end === null || value.end === undefined) { delete value.end; } if (!value.start && !value.end) { delete config.matterPorts; } else { config.matterPorts = {}; if (value.start) { config.matterPorts.start = value.start; } if (value.end) { config.matterPorts.end = value.end; } } const { bridge, ports, matterPorts, ...rest } = config; config = matterPorts ? (ports ? { bridge, ports, matterPorts, ...rest } : { bridge, matterPorts, ...rest }) : (ports ? { bridge, ports, ...rest } : { bridge, ...rest }); await this.updateConfigFile(config); } validateMatterPortRange(value) { if (value.start !== null && value.start !== undefined) { if (typeof value.start !== 'number' || value.start < 1025 || value.start > 65533) { throw new BadRequestException('Matter port range start must be a number between 1025 and 65533.'); } } if (value.end !== null && value.end !== undefined) { if (typeof value.end !== 'number' || value.end < 1025 || value.end > 65533) { throw new BadRequestException('Matter port range end must be a number between 1025 and 65533.'); } } if (value.start && value.end && value.start >= value.end) { throw new BadRequestException('Matter port range start must be less than end.'); } } async getMatterConfig() { const config = await this.getConfigFile(); return config.bridge.matter || null; } async updateMatterConfig(matterConfig) { this.validateMatterConfig(matterConfig); const config = await this.getConfigFile(); config.bridge.matter = matterConfig; await this.updateConfigFile(config); return matterConfig; } async deleteMatterConfig() { const config = await this.getConfigFile(); const deviceId = config.bridge.username.replace(RE_COLON, '').toUpperCase(); await this.homebridgeIpcService.restartAndWaitForClose(); delete config.bridge.matter; await this.updateConfigFile(config); const matterPath = join(this.configService.storagePath, 'matter', deviceId); if (await pathExists(matterPath)) { await remove(matterPath); this.logger.warn(`Bridge ${deviceId} reset: removed Matter bridge storage at ${matterPath}.`); } } validateMatterConfig(matterConfig) { if (matterConfig.port !== undefined) { if (typeof matterConfig.port !== 'number' || !Number.isInteger(matterConfig.port) || matterConfig.port < 1024 || matterConfig.port > 65535) { throw new BadRequestException('Port must be an integer between 1024 and 65535'); } if ([5353, 8080, 8443].includes(matterConfig.port)) { throw new BadRequestException('Port 5353, 8080, and 8443 are reserved and cannot be used'); } } } }; ConfigEditorService = __decorate([ Injectable(), __param(0, Inject(Logger)), __param(1, Inject(ConfigService)), __param(2, Inject(SchedulerService)), __param(3, Inject(PluginsService)), __param(4, Inject(HomebridgeIpcService)), __metadata("design:paramtypes", [Logger, ConfigService, SchedulerService, PluginsService, HomebridgeIpcService]) ], ConfigEditorService); export { ConfigEditorService }; //# sourceMappingURL=config-editor.service.js.map