UNPKG

homebridge-config-ui-x

Version:

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

747 lines 32.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 { EventEmitter } from 'node:events'; import { constants, createReadStream, createWriteStream, statSync } from 'node:fs'; import { access, lstat, mkdir, mkdtemp, readdir, realpath } from 'node:fs/promises'; import { platform, tmpdir } from 'node:os'; import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; import process from 'node:process'; import { pipeline } from 'node:stream'; import { promisify } from 'node:util'; import { BadRequestException, ConflictException, Inject, Injectable, InternalServerErrorException, NotFoundException, StreamableFile, } from '@nestjs/common'; import { cyan, green, red, yellow } from 'bash-color'; import dayjs from 'dayjs'; import { copy, ensureDir, pathExists, readJson, remove, writeJson } from 'fs-extra/esm'; import { networkInterfaces } from 'systeminformation'; import { create, extract } from 'tar'; import { Parse } from 'unzipper'; import { ConfigService } from '../../core/config/config.service.js'; import { JsonFileStoreService } from '../../core/fs/json-file-store.service.js'; import { HomebridgeIpcService } from '../../core/homebridge-ipc/homebridge-ipc.service.js'; import { Logger } from '../../core/logger/logger.service.js'; import { RE_BACKUP_FILENAME, RE_BACKUP_ID, RE_COLON } from '../../core/regex.constants.js'; import { SchedulerService } from '../../core/scheduler/scheduler.service.js'; import { PluginsService } from '../plugins/plugins.service.js'; const pump = promisify(pipeline); const RESTORE_PENDING = '__pending__'; let BackupService = class BackupService { configService; pluginsService; schedulerService; homebridgeIpcService; logger; jsonStore; restoreDirectory; constructor(configService, pluginsService, schedulerService, homebridgeIpcService, logger, jsonStore) { this.configService = configService; this.pluginsService = pluginsService; this.schedulerService = schedulerService; this.homebridgeIpcService = homebridgeIpcService; this.logger = logger; this.jsonStore = jsonStore; this.scheduleInstanceBackups(); } tarSafeFilter = (path, entry) => { const type = entry.type; if (type === 'SymbolicLink' || type === 'Link' || type === 'CharacterDevice' || type === 'BlockDevice' || type === 'FIFO') { return false; } if (path.startsWith('/') || path.startsWith('..') || path.includes(`..${sep}`) || path.includes('../')) { return false; } return true; }; async extractZipSafely(source, destDir) { const normalisedDest = resolve(destDir); return new Promise((res, rej) => { let pending = 1; let aborted = false; let firstError; const done = (err) => { if (err && !firstError) { firstError = err; aborted = true; } pending--; if (pending === 0) { firstError ? rej(firstError) : res(); } }; source.pipe(Parse()) .on('entry', (entry) => { if (aborted) { entry.autodrain(); return; } const entryPath = resolve(normalisedDest, entry.path); const rel = relative(normalisedDest, entryPath); if (rel.startsWith('..') || isAbsolute(rel)) { entry.autodrain(); done(new Error(`Zip entry escapes destination: ${entry.path}`)); return; } if (entry.type === 'Directory') { pending++; mkdir(entryPath, { recursive: true }) .then(() => done()) .catch(done); entry.autodrain(); return; } pending++; mkdir(dirname(entryPath), { recursive: true }) .then(() => { entry.pipe(createWriteStream(entryPath)) .on('finish', () => done()) .on('error', done); }) .catch(done); }) .on('error', done) .on('close', () => done()); }); } reserveRestoreSlot() { if (this.restoreDirectory !== undefined) { throw new ConflictException('Another restore is already pending. Trigger or cancel it first.'); } this.restoreDirectory = RESTORE_PENDING; } async assertNoSymlinks(dir) { const entries = await readdir(dir, { withFileTypes: true }); for (const entry of entries) { const entryPath = join(dir, entry.name); const stats = await lstat(entryPath); if (stats.isSymbolicLink()) { throw new Error(`Archive contains symlink at ${entryPath} — refusing to restore.`); } if (entry.isDirectory()) { await this.assertNoSymlinks(entryPath); } } } scheduleInstanceBackups() { this.schedulerService.cancelJob('instance-backup'); if (this.configService.ui.scheduledBackupDisable === true) { this.logger.debug('Scheduled backups disabled.'); return; } const scheduleRule = new this.schedulerService.RecurrenceRule(); scheduleRule.hour = Math.floor(Math.random() * 7); scheduleRule.minute = Math.floor(Math.random() * 59); scheduleRule.second = Math.floor(Math.random() * 59); this.schedulerService.scheduleJob('instance-backup', scheduleRule, () => { this.logger.log('Running scheduled instance backup...'); this.runScheduledBackupJob().catch((e) => { this.logger.error(`Scheduled instance backup failed as ${e?.message || e}.`); }); }); } refreshBackupSchedule() { this.scheduleInstanceBackups(); } async createBackup() { const instanceId = this.configService.homebridgeConfig.bridge.username.replace(RE_COLON, ''); const backupDir = await mkdtemp(join(tmpdir(), 'homebridge-backup-')); const backupFileName = `homebridge-backup-${instanceId}.${Date.now().toString()}.tar.gz`; const backupPath = resolve(backupDir, backupFileName); this.logger.log(`Creating temporary backup archive at ${backupPath}.`); try { const storagePath = await realpath(this.configService.storagePath); await copy(storagePath, resolve(backupDir, 'storage'), { filter: async (filePath) => { if ([ 'instance-backups', 'nssm.exe', 'homebridge.log', 'logs', 'node_modules', 'startup.sh', '.docker.env', 'docker-compose.yml', 'pnpm-lock.yaml', 'package.json', 'package-lock.json', '.npmrc', '.npm', 'FFmpeg', 'fdk-aac', '.git', 'recordings', '.homebridge.sock', '#recycle', '@eaDir', '.venv', '.cache', ].includes(basename(filePath))) { return false; } try { const stat = await lstat(filePath); return (stat.isDirectory() || stat.isFile()); } catch (e) { return false; } }, }); const installedPlugins = await this.pluginsService.getInstalledPlugins(); await writeJson(resolve(backupDir, 'plugins.json'), installedPlugins); await writeJson(resolve(backupDir, 'info.json'), { timestamp: new Date().toISOString(), platform: platform(), uix: this.configService.package.version, node: process.version, }); await create({ portable: true, gzip: true, file: backupPath, cwd: backupDir, filter: (filePath, stat) => { if (stat.size > globalThis.backup.maxBackupFileSize) { this.logger.warn(`Backup is skipping ${filePath} because it is larger than ${globalThis.backup.maxBackupFileSizeText}.`); return false; } return true; }, }, [ 'storage', 'plugins.json', 'info.json', ]); if (statSync(backupPath).size > globalThis.backup.maxBackupSize) { this.logger.error(`Backup file exceeds maximum restore file size (${globalThis.backup.maxBackupSizeText}) ${(statSync(backupPath).size / (1024 * 1024)).toFixed(1)}MB.`); } } catch (e) { this.logger.log(`Backup failed, removing ${backupDir}.`); await remove(resolve(backupDir)); throw e; } return { instanceId, backupDir, backupPath, backupFileName, }; } async ensureScheduledBackupPath() { if (this.configService.ui.scheduledBackupPath) { if (!await pathExists(this.configService.instanceBackupPath)) { throw new Error('Custom instance backup path does not exist'); } try { await access(this.configService.instanceBackupPath, constants.W_OK | constants.R_OK); } catch (e) { throw new Error(`Custom instance backup path is not writable / readable by service: ${e.message}`); } } else { return await ensureDir(this.configService.instanceBackupPath); } } async runScheduledBackupJob() { try { await this.ensureScheduledBackupPath(); } catch (e) { this.logger.warn(`Could not run scheduled backup as ${e.message}.`); return; } try { const { backupDir, backupPath, instanceId } = await this.createBackup(); await copy(backupPath, resolve(this.configService.instanceBackupPath, `homebridge-backup-${instanceId}.${Date.now().toString()}.tar.gz`)); await remove(resolve(backupDir)); } catch (e) { this.logger.warn(`Failed to create scheduled instance backup as ${e.message}.`); } try { const backups = await this.listScheduledBackups(); for (const backup of backups) { if (dayjs().diff(dayjs(backup.timestamp), 'day') >= 7) { await remove(resolve(this.configService.instanceBackupPath, backup.fileName)); } } } catch (e) { this.logger.warn(`Failed to remove old backups as ${e.message}.`); } } async getNextBackupTime() { if (this.configService.ui.scheduledBackupDisable === true) { return { next: false, }; } else { return { next: this.schedulerService.scheduledJobs['instance-backup']?.nextInvocation() || false, }; } } async listScheduledBackups() { try { await this.ensureScheduledBackupPath(); const dirContents = await readdir(this.configService.instanceBackupPath, { withFileTypes: true }); return dirContents .filter(x => x.isFile() && x.name.match(RE_BACKUP_FILENAME)) .map((x) => { const split = x.name.split('.'); const instanceId = split[0].split('-')[2]; if (split.length === 4 && !Number.isNaN(split[1])) { return { id: `${instanceId}.${split[1]}`, instanceId: split[0].split('-')[2], timestamp: new Date(Number.parseInt(split[1], 10)), fileName: x.name, size: (statSync(`${this.configService.instanceBackupPath}/${x.name}`).size / (1024 * 1024)).toFixed(1), maxBackupSize: globalThis.backup.maxBackupSize / (1024 * 1024), maxBackupSizeText: globalThis.backup.maxBackupSizeText, }; } else { return null; } }) .filter(x => x !== null) .sort((a, b) => { if (a.id > b.id) { return -1; } else if (a.id < b.id) { return 1; } else { return 0; } }); } catch (e) { this.logger.warn(`Could not get scheduled backups as ${e.message}.`); throw new InternalServerErrorException(e.message); } } async getScheduledBackup(backupId) { if (!RE_BACKUP_ID.test(backupId)) { throw new BadRequestException('Invalid backup ID.'); } const backupPath = resolve(this.configService.instanceBackupPath, `homebridge-backup-${backupId}.tar.gz`); if (!backupPath.startsWith(this.configService.instanceBackupPath)) { throw new BadRequestException('Invalid backup ID.'); } if (!await pathExists(backupPath)) { throw new NotFoundException(); } return new StreamableFile(createReadStream(backupPath)); } async deleteScheduledBackup(backupId) { if (!RE_BACKUP_ID.test(backupId)) { throw new BadRequestException('Invalid backup ID.'); } const backupPath = resolve(this.configService.instanceBackupPath, `homebridge-backup-${backupId}.tar.gz`); if (!backupPath.startsWith(this.configService.instanceBackupPath)) { throw new BadRequestException('Invalid backup ID.'); } if (!await pathExists(backupPath)) { throw new NotFoundException(); } try { await remove(backupPath); this.logger.warn(`Scheduled backup ${backupId} deleted by request.`); } catch (e) { this.logger.warn(`Failed to delete scheduled backup by request as ${e.message}.`); throw new InternalServerErrorException(e.message); } } async restoreScheduledBackup(backupId) { if (!RE_BACKUP_ID.test(backupId)) { throw new BadRequestException('Invalid backup ID.'); } const backupPath = resolve(this.configService.instanceBackupPath, `homebridge-backup-${backupId}.tar.gz`); if (!backupPath.startsWith(this.configService.instanceBackupPath)) { throw new BadRequestException('Invalid backup ID.'); } if (!await pathExists(backupPath)) { throw new NotFoundException(); } this.reserveRestoreSlot(); try { const restoreDir = await mkdtemp(join(tmpdir(), 'homebridge-backup-')); await pump(createReadStream(backupPath), extract({ cwd: restoreDir, strict: true, preservePaths: false, filter: this.tarSafeFilter, })); this.restoreDirectory = restoreDir; } catch (err) { this.restoreDirectory = undefined; throw err; } } async downloadBackup(reply) { const { backupDir, backupPath, backupFileName } = await this.createBackup(); reply.raw.setHeader('Content-type', 'application/octet-stream'); reply.raw.setHeader('Content-disposition', `attachment; filename=${backupFileName}`); reply.raw.setHeader('File-Name', backupFileName); if (reply.request.hostname === 'localhost:8080') { reply.raw.setHeader('access-control-allow-origin', 'http://localhost:4200'); } return new StreamableFile(createReadStream(backupPath).on('close', () => remove(resolve(backupDir)))); } async createBackupInDirectory() { try { await this.ensureScheduledBackupPath(); } catch (error) { this.logger.error(`Create backup failed: ${error.message}`); throw new NotFoundException(); } try { const { backupDir, backupPath, instanceId } = await this.createBackup(); await copy(backupPath, resolve(this.configService.instanceBackupPath, `homebridge-backup-${instanceId}.${Date.now().toString()}.tar.gz`)); await remove(resolve(backupDir)); } catch (error) { this.logger.error(`Create backup failed: ${error.message}`); throw new InternalServerErrorException(error.message); } } async uploadBackupRestore(data) { this.reserveRestoreSlot(); try { const backupDir = await mkdtemp(join(tmpdir(), 'homebridge-backup-')); await pump(data.file, extract({ cwd: backupDir, strict: true, preservePaths: false, filter: this.tarSafeFilter, })); this.restoreDirectory = backupDir; } catch (err) { this.restoreDirectory = undefined; throw err; } } async removeRestoreDirectory() { const dir = this.restoreDirectory; this.restoreDirectory = undefined; if (dir && dir !== RESTORE_PENDING) { return await remove(dir); } } async triggerHeadlessRestore() { if (!this.restoreDirectory || this.restoreDirectory === RESTORE_PENDING || !await pathExists(this.restoreDirectory)) { throw new BadRequestException('No backup file uploaded'); } const client = new EventEmitter(); client.on('stdout', (data) => { this.logger.log(data); }); client.on('stderr', (data) => { this.logger.log(data); }); await this.restoreFromBackup(client, true); return { status: 0 }; } async restoreFromBackup(client, autoRestart = false) { if (!this.restoreDirectory || this.restoreDirectory === RESTORE_PENDING) { throw new BadRequestException(); } if (!await pathExists(resolve(this.restoreDirectory, 'info.json'))) { await this.removeRestoreDirectory(); throw new Error('Uploaded file is not a valid Homebridge Backup Archive.'); } if (!await pathExists(resolve(this.restoreDirectory, 'plugins.json'))) { await this.removeRestoreDirectory(); throw new Error('Uploaded file is not a valid Homebridge Backup Archive.'); } if (!await pathExists(resolve(this.restoreDirectory, 'storage'))) { await this.removeRestoreDirectory(); throw new Error('Uploaded file is not a valid Homebridge Backup Archive.'); } try { await this.assertNoSymlinks(this.restoreDirectory); } catch (err) { await this.removeRestoreDirectory(); throw err; } const backupInfo = await readJson(resolve(this.restoreDirectory, 'info.json')); client.emit('stdout', cyan('Backup Archive Information\r\n')); client.emit('stdout', `Source Node.js Version: ${backupInfo.node}\r\n`); client.emit('stdout', `Source Homebridge UI Version: v${backupInfo.uix}\r\n`); client.emit('stdout', `Source Platform: ${backupInfo.platform}\r\n`); client.emit('stdout', `Created: ${backupInfo.timestamp}\r\n`); this.logger.warn('Starting backup restore...'); client.emit('stdout', cyan('\r\nRestoring backup...\r\n\r\n')); await new Promise(res => setTimeout(res, 1000)); const restoreFilter = [ join(this.restoreDirectory, 'storage', 'package.json'), join(this.restoreDirectory, 'storage', 'package-lock.json'), join(this.restoreDirectory, 'storage', '.npmrc'), join(this.restoreDirectory, 'storage', 'docker-compose.yml'), ]; const storagePath = await realpath(this.configService.storagePath); client.emit('stdout', yellow(`Restoring Homebridge storage to ${storagePath}\r\n`)); await new Promise(res => setTimeout(res, 100)); await copy(resolve(this.restoreDirectory, 'storage'), storagePath, { filter: async (filePath) => { if (restoreFilter.includes(filePath)) { client.emit('stdout', `Skipping ${basename(filePath)}\r\n`); return false; } try { const stat = await lstat(filePath); if (stat.isDirectory() || stat.isFile()) { client.emit('stdout', `Restoring ${basename(filePath)}\r\n`); return true; } else { client.emit('stdout', `Skipping ${basename(filePath)}\r\n`); return false; } } catch (e) { client.emit('stdout', `Skipping ${basename(filePath)}\r\n`); return false; } }, }); client.emit('stdout', yellow('File restore complete.\r\n')); await new Promise(res => setTimeout(res, 1000)); client.emit('stdout', cyan('\r\nRestoring plugins...\r\n')); const plugins = (await readJson(resolve(this.restoreDirectory, 'plugins.json'))) .filter((x) => ![ 'homebridge-config-ui-x', ].includes(x.name) && x.publicPackage); for (const plugin of plugins) { try { client.emit('stdout', yellow(`\r\nInstalling ${plugin.name}...\r\n`)); await this.pluginsService.managePlugin('install', { name: plugin.name, version: plugin.installedVersion }, client); } catch (e) { client.emit('stdout', red(`Failed to install ${plugin.name}.\r\n`)); } } const restoredConfig = await readJson(this.configService.configPath); if (restoredConfig.bridge) { restoredConfig.bridge.port = this.configService.homebridgeConfig.bridge.port; } if (restoredConfig.bridge.bind) { await this.checkBridgeBindConfig(restoredConfig); } if (!Array.isArray(restoredConfig.platforms)) { restoredConfig.platforms = []; } const uiConfigBlock = restoredConfig.platforms.find(x => x.platform === 'config'); if (uiConfigBlock) { uiConfigBlock.port = this.configService.ui.port; } else { restoredConfig.platforms.push({ name: 'Config', port: this.configService.ui.port, platform: 'config', }); } await this.jsonStore.write(this.configService.configPath, restoredConfig); await this.removeRestoreDirectory(); client.emit('stdout', green('\r\nRestore Complete!\r\n')); this.configService.hbServiceUiRestartRequired = true; if (autoRestart) { this.postBackupRestoreRestart(); } return { status: 0 }; } async uploadHbfxRestore(data) { this.reserveRestoreSlot(); try { const backupDir = await mkdtemp(join(tmpdir(), 'homebridge-backup-')); this.logger.log(`Extracting .hbfx file to ${backupDir}.`); await this.extractZipSafely(data.file, backupDir); this.restoreDirectory = backupDir; } catch (err) { this.restoreDirectory = undefined; throw err; } } async restoreHbfxBackup(client) { if (!this.restoreDirectory || this.restoreDirectory === RESTORE_PENDING) { throw new BadRequestException(); } if (!await pathExists(resolve(this.restoreDirectory, 'package.json'))) { await this.removeRestoreDirectory(); throw new Error('Uploaded file is not a valid HBFX Backup Archive.'); } if (!await pathExists(resolve(this.restoreDirectory, 'etc', 'config.json'))) { await this.removeRestoreDirectory(); throw new Error('Uploaded file is not a valid HBFX Backup Archive.'); } try { await this.assertNoSymlinks(this.restoreDirectory); } catch (err) { await this.removeRestoreDirectory(); throw err; } const backupInfo = await readJson(resolve(this.restoreDirectory, 'package.json')); client.emit('stdout', cyan('Backup Archive Information\r\n')); client.emit('stdout', `Backup Source: ${backupInfo.name}\r\n`); client.emit('stdout', `Version: v${backupInfo.version}\r\n`); this.logger.warn('Starting hbfx restore...'); client.emit('stdout', cyan('\r\nRestoring hbfx backup...\r\n\r\n')); await new Promise(res => setTimeout(res, 1000)); const storagePath = await realpath(this.configService.storagePath); client.emit('stdout', yellow(`Restoring Homebridge storage to ${storagePath}\r\n`)); await copy(resolve(this.restoreDirectory, 'etc'), resolve(storagePath), { filter: (filePath) => { if ([ 'access.json', 'dashboard.json', 'layout.json', 'config.json', ].includes(basename(filePath))) { return false; } client.emit('stdout', `Restoring ${basename(filePath)}\r\n`); return true; }, }); const sourceAccessoriesPath = resolve(this.restoreDirectory, 'etc', 'accessories'); const targetAccessoriesPath = resolve(storagePath, 'accessories'); if (await pathExists(sourceAccessoriesPath)) { await copy(sourceAccessoriesPath, targetAccessoriesPath, { filter: (filePath) => { client.emit('stdout', `Restoring ${basename(filePath)}\r\n`); return true; }, }); } const sourceConfig = await readJson(resolve(this.restoreDirectory, 'etc', 'config.json')); const pluginMap = { 'hue': 'homebridge-hue', 'chamberlain': 'homebridge-chamberlain', 'google-home': 'homebridge-gsh', 'ikea-tradfri': 'homebridge-ikea-tradfri-gateway', 'nest': 'homebridge-nest', 'ring': 'homebridge-ring', 'roborock': 'homebridge-roborock', 'shelly': 'homebridge-shelly', 'wink': 'homebridge-wink3', 'homebridge-tuya-web': '@milo526/homebridge-tuya-web', }; if (sourceConfig.plugins?.length) { for (let plugin of sourceConfig.plugins) { if (plugin in pluginMap) { plugin = pluginMap[plugin]; } try { client.emit('stdout', yellow(`\r\nInstalling ${plugin}...\r\n`)); await this.pluginsService.managePlugin('install', { name: plugin, version: 'latest' }, client); } catch (e) { client.emit('stdout', red(`Failed to install ${plugin}.\r\n`)); } } } const targetConfig = JSON.parse(JSON.stringify({ bridge: sourceConfig.bridge, accessories: sourceConfig.accessories?.map((x) => { delete x.plugin_map; return x; }) || [], platforms: sourceConfig.platforms?.map((x) => { if (x.platform === 'google-home') { x.platform = 'google-smarthome'; x.notice = 'Keep your token a secret!'; } delete x.plugin_map; return x; }) || [], })); targetConfig.bridge.name = `Homebridge ${targetConfig.bridge.username.substring(targetConfig.bridge.username.length - 5).replace(RE_COLON, '')}`; if (targetConfig.bridge.bind) { await this.checkBridgeBindConfig(targetConfig); } targetConfig.platforms.push({ ...this.configService.ui, platform: 'config', }); await this.jsonStore.write(this.configService.configPath, targetConfig); await this.removeRestoreDirectory(); client.emit('stdout', green('\r\nRestore Complete!\r\n')); this.configService.hbServiceUiRestartRequired = true; return { status: 0 }; } postBackupRestoreRestart() { setTimeout(async () => { const delivered = await this.homebridgeIpcService.killHomebridge(); if (!delivered) { this.logger.error('Skipping UI self-kill: Homebridge SIGKILL was not delivered.'); return; } setTimeout(() => { process.kill(process.pid, 'SIGKILL'); }, 500); }, 500); return { status: 0 }; } async checkBridgeBindConfig(restoredConfig) { if (restoredConfig.bridge.bind) { if (typeof restoredConfig.bridge.bind === 'string') { restoredConfig.bridge.bind = [restoredConfig.bridge.bind]; } if (!Array.isArray(restoredConfig.bridge.bind)) { delete restoredConfig.bridge.bind; return; } const interfaces = await networkInterfaces(); const ifaceNames = interfaces.map(i => i.iface); restoredConfig.bridge.bind = restoredConfig.bridge.bind.filter(x => ifaceNames.includes(x)); if (!restoredConfig.bridge.bind.length) { delete restoredConfig.bridge.bind; } } } }; BackupService = __decorate([ Injectable(), __param(0, Inject(ConfigService)), __param(1, Inject(PluginsService)), __param(2, Inject(SchedulerService)), __param(3, Inject(HomebridgeIpcService)), __param(4, Inject(Logger)), __param(5, Inject(JsonFileStoreService)), __metadata("design:paramtypes", [ConfigService, PluginsService, SchedulerService, HomebridgeIpcService, Logger, JsonFileStoreService]) ], BackupService); export { BackupService }; //# sourceMappingURL=backup.service.js.map