b08d76a986
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>
116 lines
4.3 KiB
TypeScript
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 }
|
|
}
|