Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 79 additions & 13 deletions companion/lib/ImportExport/Backups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`)

Copilot AI Apr 1, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Logging the full push-backup URL can leak credentials or tokens if the URL contains userinfo/query parameters. Please redact sensitive URL components (eg strip username/password and querystring) before logging.

Suggested change
this.#logger.info(`Removed old push backup entry: ${backup.filePath}`)
let redactedUrl = backup.filePath
try {
const parsedUrl = new URL(backup.filePath)
parsedUrl.username = ''
parsedUrl.password = ''
parsedUrl.search = ''
redactedUrl = parsedUrl.toString()
} catch {
// If parsing fails, fall back to logging the original value
}
this.#logger.info(`Removed old push backup entry: ${redactedUrl}`)

Copilot uses AI. Check for mistakes.
return backup
}
Comment on lines +300 to +305

Copilot AI Apr 1, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For HTTP push backups this returns the same backup.filePath (the URL) for every backup. Downstream cleanup removes history entries by matching on filePath, so if multiple backups share the same URL it can end up removing all entries (including those that should be kept). Consider storing a unique identifier per push (eg include createdAt and/or backupName in the stored path/id) and/or change cleanup comparison to include createdAt as well as filePath.

Copilot uses AI. Check for mistakes.

try {
await fs.unlink(backup.filePath)
this.#logger.info(`Deleted old backup: ${backup.filePath}`)
Expand Down Expand Up @@ -376,12 +381,6 @@ export class BackupController {
*/
async #createBackup(logger: Logger, rule: BackupRulesConfig): Promise<PreviousBackupInfo | null> {
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
Expand All @@ -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')
}
Comment on lines +392 to +396

Copilot AI Apr 1, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

backupPath comes from user input and isn't trimmed before URL detection. A value like ' https://host/path' will fail new URL() parsing and then be treated as a local directory, potentially creating unexpected folders like " https:". Trim whitespace (and ideally validate) before #isHttpUrl and before using it in fs.mkdir/path operations.

Copilot uses AI. Check for mistakes.

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': {
Expand Down Expand Up @@ -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<PreviousBackupInfo> {
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}`)

Comment on lines +470 to +471

Copilot AI Apr 1, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This log line includes the full backupUrl, which may contain credentials or tokens. Consider logging a redacted form (eg protocol+host+pathname only) to avoid secrets ending up in logs.

Suggested change
logger.info(`Pushing backup to ${backupUrl}`)
let redactedUrl = backupUrl
try {
const parsed = new URL(backupUrl)
redactedUrl = `${parsed.protocol}//${parsed.host}${parsed.pathname}`
} catch {
// If parsing fails, fall back to the original string
}
logger.info(`Pushing backup to ${redactedUrl}`)

Copilot uses AI. Check for mistakes.
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}`)
}
Comment thread
SNRSE marked this conversation as resolved.

return {
filePath: backupUrl,
fileSize: typeof exportData.data === 'string' ? Buffer.byteLength(exportData.data) : exportData.data.length,
createdAt: Date.now(),
Comment on lines +485 to +488

Copilot AI Apr 1, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Returning filePath: backupUrl for push backups makes multiple history entries indistinguishable (same filePath each run). That breaks keep-count trimming and makes UI deletion ambiguous because deletion is keyed by filePath. Consider storing a unique per-backup identifier (eg ${backupUrl}#${createdAt} or include the generated filename) and/or extending the delete API to target by both filePath and createdAt.

Suggested change
return {
filePath: backupUrl,
fileSize: typeof exportData.data === 'string' ? Buffer.byteLength(exportData.data) : exportData.data.length,
createdAt: Date.now(),
const createdAt = Date.now()
return {
filePath: `${backupUrl}#${createdAt}`,
fileSize: typeof exportData.data === 'string' ? Buffer.byteLength(exportData.data) : exportData.data.length,
createdAt,

Copilot uses AI. Check for mistakes.
}
}

async #generateExportBackup(
logger: Logger,
backupDir: string,
Expand Down Expand Up @@ -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}`)
}
}
Comment on lines +538 to 546

Copilot AI Apr 1, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skipping filesystem deletion for HTTP paths is fine, but the backend removes a history entry by looking up previousBackups via filePath. If multiple push backups share the same URL, the UI can't delete a specific entry (it will remove the first match). Consider updating the delete input to include createdAt (or an explicit id) so push-backup history entries are uniquely addressable.

Copilot uses AI. Check for mistakes.

// Update backup rules to remove this backup from the specific rule
Expand Down
5 changes: 3 additions & 2 deletions webui/src/UserConfig/BackupRuleEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ export const BackupRuleEditor = observer(function BackupRuleEditor({ ruleId }: B
<CCol sm={12}>
<CAlert color="warning" className="mt-2">
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.
</CAlert>
</CCol>
)}
Expand All @@ -166,7 +166,8 @@ export const BackupRuleEditor = observer(function BackupRuleEditor({ ruleId }: B
</CCol>
<CCol className={`fieldtype-textinput mt-0`} sm={{ offset: 4, span: 8 }}>
<small className="form-text text-muted">
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.
</small>
</CCol>

Expand Down