From 5cb1818ef68751620f0cc78e121a79acd718d621 Mon Sep 17 00:00:00 2001 From: Jonas Dautel Date: Wed, 1 Apr 2026 02:56:06 +0200 Subject: [PATCH 1/2] Support HTTP(S) push backups Add support for pushing export backups to HTTP/HTTPS endpoints and avoid treating remote URLs as local files. Changes include: - BackupController: detect HTTP/HTTPS backup paths via a new #isHttpUrl helper. - Create backup: if rule.backupPath is an HTTP(S) URL, reject raw DB backups and call new #pushExportBackup to POST export data to the URL instead of writing a local file. - #pushExportBackup: generate and stringify export data, determine content type, POST to the target URL, and return a PreviousBackupInfo entry for history trimming. - Cleanup and deletion logic: when trimming old backups, skip unlinking entries whose filePath is an HTTP(S) URL and only remove the history entry; similarly avoid unlinking remote paths when deleting individual backups. - UI: update BackupRuleEditor text to clarify that raw DB backups cannot be pushed to HTTP(S) URLs and that export backups may be posted to http:// or https:// endpoints. These changes enable remote push-style backups for export formats while preserving local file behavior and preventing accidental attempts to write raw DB files to remote URLs. --- companion/lib/ImportExport/Backups.ts | 91 +++++++++++++++++++---- webui/src/UserConfig/BackupRuleEditor.tsx | 5 +- 2 files changed, 81 insertions(+), 15 deletions(-) diff --git a/companion/lib/ImportExport/Backups.ts b/companion/lib/ImportExport/Backups.ts index e35b5a544b..740f39fb6b 100644 --- a/companion/lib/ImportExport/Backups.ts +++ b/companion/lib/ImportExport/Backups.ts @@ -297,8 +297,13 @@ export class BackupController { this.#logger.info(`Cleaning up ${backupsToDelete.length} old backups for rule ${rule.name}`) - // Delete the old backup files + // Delete old local backup files. For HTTP push backups, only trim history. const deletionPromises = backupsToDelete.map(async (backup) => { + if (this.#isHttpUrl(backup.filePath)) { + this.#logger.info(`Removed old push backup entry: ${backup.filePath}`) + return backup + } + try { await fs.unlink(backup.filePath) this.#logger.info(`Deleted old backup: ${backup.filePath}`) @@ -376,12 +381,6 @@ export class BackupController { */ async #createBackup(logger: Logger, rule: BackupRulesConfig): Promise { try { - await this.#ensureBackupDirExists() - - // Determine backup directory - use rule path if specified, otherwise default - const backupDir = rule.backupPath ? rule.backupPath : this.#defaultBackupDir - await fs.mkdir(backupDir, { recursive: true }) - // Generate backup filename const parser = this.#variableValuesController.createVariablesAndExpressionParser(null, null, null) const backupName = parser.parseVariables(rule.backupNamePattern).text @@ -390,6 +389,21 @@ export class BackupController { return null } + const backupPath = rule.backupPath ? rule.backupPath : this.#defaultBackupDir + if (this.#isHttpUrl(backupPath)) { + if (rule.backupType === 'db') { + throw new Error('Raw database backups must be saved to a local file path, not HTTP/HTTPS URLs') + } + + return this.#pushExportBackup(logger, backupPath, backupName, rule.backupType) + } + + await this.#ensureBackupDirExists() + + // Determine backup directory - use rule path if specified, otherwise default + const backupDir = backupPath + await fs.mkdir(backupDir, { recursive: true }) + // Create the backup based on type switch (rule.backupType) { case 'db': { @@ -425,6 +439,55 @@ export class BackupController { } } + #isHttpUrl(value: string): boolean { + try { + const parsed = new URL(value) + return parsed.protocol === 'http:' || parsed.protocol === 'https:' + } catch { + return false + } + } + + async #pushExportBackup( + logger: Logger, + backupUrl: string, + filename: string, + backupType: BackupRulesConfig['backupType'] + ): Promise { + const data = this.#exportController.generateCustomExport(null) + + const format: ExportFormat = backupType === 'export-gz' ? 'json-gz' : backupType === 'export-json' ? 'json' : 'yaml' + const exportData = await stringifyExport(logger, data, `${filename}.companionconfig`, format) + if (!exportData) throw new Error('Failed to stringify export data') + + const contentType = + backupType === 'export-gz' + ? 'application/gzip' + : backupType === 'export-yaml' + ? 'application/yaml' + : 'application/json' + + logger.info(`Pushing backup to ${backupUrl}`) + + const response = await fetch(backupUrl, { + method: 'POST', + headers: { + 'Content-Type': contentType, + }, + body: exportData.data, + }) + + if (!response.ok) { + throw new Error(`Push backup failed: HTTP ${response.status} ${response.statusText}`) + } + + return { + filePath: backupUrl, + fileSize: typeof exportData.data === 'string' ? Buffer.byteLength(exportData.data) : exportData.data.length, + createdAt: Date.now(), + } + } + async #generateExportBackup( logger: Logger, backupDir: string, @@ -471,12 +534,14 @@ export class BackupController { return false } - // Delete the file if it exists - try { - await fs.unlink(filePath) - this.#logger.info(`Deleted backup file: ${filePath}`) - } catch (err) { - this.#logger.warn(`Failed to delete backup file ${filePath}, but will remove from rule: ${err}`) + if (!this.#isHttpUrl(filePath)) { + // Delete the file if it exists + try { + await fs.unlink(filePath) + this.#logger.info(`Deleted backup file: ${filePath}`) + } catch (err) { + this.#logger.warn(`Failed to delete backup file ${filePath}, but will remove from rule: ${err}`) + } } // Update backup rules to remove this backup from the specific rule diff --git a/webui/src/UserConfig/BackupRuleEditor.tsx b/webui/src/UserConfig/BackupRuleEditor.tsx index c82b758aa4..fa7e882e4d 100644 --- a/webui/src/UserConfig/BackupRuleEditor.tsx +++ b/webui/src/UserConfig/BackupRuleEditor.tsx @@ -155,7 +155,7 @@ export const BackupRuleEditor = observer(function BackupRuleEditor({ ruleId }: B Raw backups are a direct copy of the database file. They cannot be restored through the web interface, but - contain more data than the default exports. + contain more data than the default exports. HTTP/HTTPS URLs are not supported for raw database backups. )} @@ -166,7 +166,8 @@ export const BackupRuleEditor = observer(function BackupRuleEditor({ ruleId }: B - Directory path where backups will be saved. Leave empty for default location. + Directory path where backups will be saved. You can also enter an http:// or https:// URL to POST export + backups to a server. Leave empty for default location. From 3df21c7239c94d34664b12832255293310a7103f Mon Sep 17 00:00:00 2001 From: Jonas Dautel Date: Wed, 1 Apr 2026 13:12:23 +0200 Subject: [PATCH 2/2] Update companion/lib/ImportExport/Backups.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- companion/lib/ImportExport/Backups.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/companion/lib/ImportExport/Backups.ts b/companion/lib/ImportExport/Backups.ts index 740f39fb6b..61a458c7f3 100644 --- a/companion/lib/ImportExport/Backups.ts +++ b/companion/lib/ImportExport/Backups.ts @@ -470,6 +470,7 @@ export class BackupController { logger.info(`Pushing backup to ${backupUrl}`) const response = await fetch(backupUrl, { + signal: AbortSignal.timeout(30000), // 30 second timeout method: 'POST', headers: { 'Content-Type': contentType,