diff --git a/companion/lib/ImportExport/Backups.ts b/companion/lib/ImportExport/Backups.ts index e35b5a544b..61a458c7f3 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,56 @@ 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, { + signal: AbortSignal.timeout(30000), // 30 second timeout + 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 +535,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.