import { dialog, type BrowserWindow } from 'electron' import { readFileSync, writeFileSync } from 'fs' import { getSettings, setSettings, SECRET_KEYS } from './settings' import { listTemplates, replaceTemplates } from './templates' import { isTemplateLike } from './validation' 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 { const opts = { defaultPath: 'aerofetch-backup.json', filters: [{ name: 'JSON', extensions: ['json'] }] } const res = await (win ? dialog.showSaveDialog(win, opts) : dialog.showSaveDialog(opts)) if (res.canceled || !res.filePath) return { ok: false } // Strip credentials (proxy creds, API tokens) so a backup file shared or synced // to the cloud doesn't leak secrets in plaintext (M22). The user re-enters them // after import. Shares SECRET_KEYS with settings.ts so the lists can't drift. const settings = { ...getSettings() } for (const key of SECRET_KEYS) (settings as Record)[key] = '' const payload: BackupFile = { version: 1, settings, 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 { const openOpts = { properties: ['openFile' as const], filters: [{ name: 'JSON', extensions: ['json'] }] } const res = await (win ? dialog.showOpenDialog(win, openOpts) : dialog.showOpenDialog(openOpts)) 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 const incomingSettings = file.settings && typeof file.settings === 'object' ? (file.settings as Partial) : undefined // Drop non-object / id-less entries up front (audit T3) so both the consent // check below and replaceTemplates see only well-shaped rows. Without this a // backup with a null/garbage templates entry throws in sanitize() instead of // degrading gracefully as this function promises. const incomingTemplates = Array.isArray(file.templates) ? (file.templates as CommandTemplate[]).filter(isTemplateLike) : [] // 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 msgOpts = { type: 'warning' as const, 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 } const choice = await (win ? dialog.showMessageBox(win, msgOpts) : dialog.showMessageBox(msgOpts)) if (choice.response === 2) return { ok: false } // Cancel — change nothing applyCustomCommands = choice.response === 0 } if (incomingSettings) { // Never restore credential fields from a backup. Exports strip them (M22), so an // imported '' would otherwise wipe a proxy/token already configured on this // machine; honoring a hand-edited one would reintroduce the leak vector. Either // way, import leaves the user's existing secrets untouched — they re-enter as needed. const restored: Partial = { ...incomingSettings } for (const key of SECRET_KEYS) delete restored[key] // 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 ? restored : { ...restored, customCommandEnabled: false } setSettings(safeSettings) } if (incomingTemplates.length > 0 || Array.isArray(file.templates)) { replaceTemplates(incomingTemplates) } return { ok: true } }