Files
AeroFetch/src/main/backup.ts
T
debont80 b08d76a986 Apply all CODE-AUDIT.md findings (S1-S5, P1-P3, M1-M3)
Security:
- S1 (backup.ts): require explicit confirmation before enabling custom-command
  templates from a backup file; import templates in a disabled state if declined
- S2 (cookies.ts): deny non-http/https popups in cookie login window, matching
  the main window's setWindowOpenHandler defence
- S3 (download.ts): main-process concurrency cap — startDownload now rejects
  spawns when active.size >= maxConcurrent (defence-in-depth; renderer already
  enforces this but should not be the only gate)
- S4 (settings.ts, validation.ts): reject filenameTemplate values containing
  path traversal (.. segments or absolute paths); validate outputDir is absolute
- S5 (history.ts, errorlog.ts, templates.ts, validation.ts): per-field
  validation on JSON reads — invalid entries dropped rather than blindly trusted

Performance:
- P1 (settings.ts): getSettings() now only writes to disk when sanitization
  actually changed a value; removes synchronous file I/O on every hot-path read
- P2 (ipc.ts, download.ts, downloads.ts): renderer forwards its pre-probed
  metadata (title/channel/duration) via StartDownloadOptions.meta; main uses
  it directly instead of spawning a redundant second yt-dlp probe

Maintainability:
- M1 (log.ts, download.ts, probe.ts): extract duplicated cleanError() to
  src/main/log.ts; both callers import from the shared module
- M2 (buildArgs.ts): document parseExtraArgs escape-sequence limitations
- M3 (electron-builder.yml): clarify icon TODO as a pre-release action
- P3 (downloads.ts): document that lowering maxConcurrent mid-flight does
  not pause active downloads (intended behaviour, now written down)

Tests:
- Add test/validation.test.ts covering isSafeFilenameTemplate, isSafeOutputDir,
  isValidHistoryEntry, isValidErrorLogEntry, isTemplateLike (76 tests pass)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 10:22:43 -04:00

116 lines
4.3 KiB
TypeScript

import { dialog, type BrowserWindow } from 'electron'
import { readFileSync, writeFileSync } from 'fs'
import { getSettings, setSettings } from './settings'
import { listTemplates, replaceTemplates } from './templates'
import type {
BackupExportResult,
BackupImportResult,
Settings,
CommandTemplate
} from '@shared/ipc'
interface BackupFile {
version: 1
settings: Settings
templates: CommandTemplate[]
}
export async function exportBackup(win: BrowserWindow | undefined): Promise<BackupExportResult> {
const res = await dialog.showSaveDialog(win!, {
defaultPath: 'aerofetch-backup.json',
filters: [{ name: 'JSON', extensions: ['json'] }]
})
if (res.canceled || !res.filePath) return { ok: false }
const payload: BackupFile = { version: 1, settings: getSettings(), templates: listTemplates() }
try {
writeFileSync(res.filePath, JSON.stringify(payload, null, 2))
return { ok: true, path: res.filePath }
} catch (e) {
return { ok: false, error: (e as Error).message }
}
}
export async function importBackup(win: BrowserWindow | undefined): Promise<BackupImportResult> {
const res = await dialog.showOpenDialog(win!, {
properties: ['openFile'],
filters: [{ name: 'JSON', extensions: ['json'] }]
})
if (res.canceled || !res.filePaths[0]) return { ok: false }
let parsed: unknown
try {
parsed = JSON.parse(readFileSync(res.filePaths[0], 'utf8'))
} catch (e) {
return { ok: false, error: `Could not read backup file: ${(e as Error).message}` }
}
if (!parsed || typeof parsed !== 'object') {
return { ok: false, error: 'Not a valid AeroFetch backup file.' }
}
// setSettings/replaceTemplates validate and sanitize every field, so a
// hand-edited or partial backup file degrades gracefully rather than
// corrupting the store.
const file = parsed as Partial<BackupFile>
const incomingSettings =
file.settings && typeof file.settings === 'object'
? (file.settings as Partial<Settings>)
: undefined
const incomingTemplates = Array.isArray(file.templates)
? (file.templates as CommandTemplate[])
: []
// A custom-command template's `args` are extra yt-dlp flags that get spawned on
// the next download. A backup that arrives with customCommandEnabled + a default
// template carrying args would silently enable code execution the moment a
// download starts — so require an explicit, informed confirmation before
// honouring that, and import the templates in a *disabled* state if declined.
const commandTemplates = incomingTemplates.filter(
(t) => t && typeof t === 'object' && typeof t.args === 'string' && t.args.trim() !== ''
)
const wouldEnableCustomCommands =
incomingSettings?.customCommandEnabled === true && commandTemplates.length > 0
let applyCustomCommands = true
if (wouldEnableCustomCommands) {
const preview = commandTemplates
.slice(0, 10)
.map((t) => {
const name = (t.name ?? '').trim() || 'Untitled command'
const args = t.args.length > 100 ? `${t.args.slice(0, 100)}…` : t.args
return `• ${name}: ${args}`
})
.join('\n')
const more =
commandTemplates.length > 10 ? `\n…and ${commandTemplates.length - 10} more.` : ''
const choice = await dialog.showMessageBox(win!, {
type: 'warning',
buttons: ['Enable custom commands', 'Import without enabling', 'Cancel'],
defaultId: 1,
cancelId: 2,
title: 'Backup contains custom commands',
message: `This backup enables ${commandTemplates.length} custom-command template${
commandTemplates.length === 1 ? '' : 's'
}.`,
detail:
'Custom commands run extra yt-dlp flags on every download. Only enable them if you trust this backup file.\n\n' +
preview +
more
})
if (choice.response === 2) return { ok: false } // Cancel — change nothing
applyCustomCommands = choice.response === 0
}
if (incomingSettings) {
// When the user declined to enable custom commands (or there were none to
// enable), force the toggle off so an imported defaultTemplateId can't auto-run.
const safeSettings = applyCustomCommands
? incomingSettings
: { ...incomingSettings, customCommandEnabled: false }
setSettings(safeSettings)
}
if (incomingTemplates.length > 0 || Array.isArray(file.templates)) {
replaceTemplates(incomingTemplates)
}
return { ok: true }
}