diff --git a/lib/backup-crypto.js b/lib/backup-crypto.js new file mode 100644 index 0000000..c857f93 --- /dev/null +++ b/lib/backup-crypto.js @@ -0,0 +1,71 @@ +const crypto = require('crypto'); + +const MAGIC = Buffer.from('MHU1'); +const SALT_LEN = 16; +const IV_LEN = 12; +const TAG_LEN = 16; +const KEY_LEN = 32; +const ITERATIONS = 100_000; +const DIGEST = 'sha512'; +const ALGO = 'aes-256-gcm'; + +function deriveKey(password, salt) { + return crypto.pbkdf2Sync(password, salt, ITERATIONS, KEY_LEN, DIGEST); +} + +/** + * Encrypt a config object with a password. + * Returns a Buffer: MHU1 | salt(16) | iv(12) | tag(16) | ciphertext + */ +function encrypt(config, password) { + const plaintext = Buffer.from(JSON.stringify(config), 'utf-8'); + const salt = crypto.randomBytes(SALT_LEN); + const iv = crypto.randomBytes(IV_LEN); + const key = deriveKey(password, salt); + + const cipher = crypto.createCipheriv(ALGO, key, iv); + const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]); + const tag = cipher.getAuthTag(); + + return Buffer.concat([MAGIC, salt, iv, tag, encrypted]); +} + +/** + * Decrypt a .mhu buffer with a password. + * Returns the config object or throws on invalid password/data. + */ +function decrypt(buffer, password) { + if (buffer.length < MAGIC.length + SALT_LEN + IV_LEN + TAG_LEN + 1) { + throw new Error('Ungültiges Backup-Format'); + } + + const magic = buffer.subarray(0, 4); + if (!magic.equals(MAGIC)) { + throw new Error('Keine gültige .mhu Backup-Datei'); + } + + let offset = MAGIC.length; + const salt = buffer.subarray(offset, offset += SALT_LEN); + const iv = buffer.subarray(offset, offset += IV_LEN); + const tag = buffer.subarray(offset, offset += TAG_LEN); + const ciphertext = buffer.subarray(offset); + + const key = deriveKey(password, salt); + const decipher = crypto.createDecipheriv(ALGO, key, iv); + decipher.setAuthTag(tag); + + let decrypted; + try { + decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]); + } catch { + throw new Error('Falsches Passwort oder beschädigte Datei'); + } + + try { + return JSON.parse(decrypted.toString('utf-8')); + } catch { + throw new Error('Entschlüsselte Daten sind kein gültiges JSON'); + } +} + +module.exports = { encrypt, decrypt }; diff --git a/main.js b/main.js index 7daf8ec..65bf055 100644 --- a/main.js +++ b/main.js @@ -8,6 +8,7 @@ const VidmolyUploader = require('./lib/vidmoly-upload'); const VoeUploader = require('./lib/voe-upload'); const DoodstreamUploader = require('./lib/doodstream-upload'); const { checkForUpdate, installUpdate, abortUpdate } = require('./lib/updater'); +const backupCrypto = require('./lib/backup-crypto'); let mainWindow; const configStore = new ConfigStore(app); @@ -626,6 +627,42 @@ ipcMain.handle('clear-history', () => { return true; }); +// --- Backup export / import --- +ipcMain.handle('export-backup', async (_event, password) => { + const config = configStore.load(); + const encrypted = backupCrypto.encrypt(config, password); + const { canceled, filePath } = await dialog.showSaveDialog(mainWindow, { + title: 'Backup exportieren', + defaultPath: `multi-hoster-backup-${new Date().toISOString().slice(0, 10)}.mhu`, + filters: [{ name: 'Multi-Hoster Backup', extensions: ['mhu'] }] + }); + if (canceled || !filePath) return { ok: false, canceled: true }; + fs.writeFileSync(filePath, encrypted); + return { ok: true, path: filePath }; +}); + +ipcMain.handle('import-backup', async (_event, password) => { + const { canceled, filePaths } = await dialog.showOpenDialog(mainWindow, { + title: 'Backup importieren', + filters: [{ name: 'Multi-Hoster Backup', extensions: ['mhu'] }], + properties: ['openFile'] + }); + if (canceled || !filePaths.length) return { ok: false, canceled: true }; + const buffer = fs.readFileSync(filePaths[0]); + const config = backupCrypto.decrypt(buffer, password); + // Safety net: save current config before overwriting + const preImportPath = configStore.filePath + '.pre-import.json'; + try { fs.copyFileSync(configStore.filePath, preImportPath); } catch {} + await configStore.save(config); + if (config.history) { + // Overwrite history too (save() doesn't touch history) + const full = configStore.load(); + full.history = config.history; + await configStore._atomicWrite(JSON.stringify(full, null, 2)); + } + return { ok: true, config: configStore.load() }; +}); + ipcMain.handle('copy-to-clipboard', (_event, text) => { clipboard.writeText(text); return true; diff --git a/preload.js b/preload.js index 9e18cfa..9aa725d 100644 --- a/preload.js +++ b/preload.js @@ -50,6 +50,10 @@ contextBridge.exposeInMainWorld('api', { ipcRenderer.on('app:update-progress', (_event, data) => callback(data)); }, + // Backup + exportBackup: (password) => ipcRenderer.invoke('export-backup', password), + importBackup: (password) => ipcRenderer.invoke('import-backup', password), + // Debug debugTestUpload: () => ipcRenderer.invoke('debug-test-upload'), debugLog: (msg) => ipcRenderer.invoke('debug-log', msg), diff --git a/renderer/app.js b/renderer/app.js index b9c1285..3e5b353 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -800,6 +800,75 @@ function copySelectedRecentLinks() { if (links.length) { window.api.copyToClipboard(links.join('\n')); showCopyToast(`${links.length} Links kopiert`); } } +// --- Backup modal --- +let _backupMode = null; // 'export' | 'import' + +function openBackupModal(mode) { + _backupMode = mode; + const modal = document.getElementById('backupPasswordModal'); + const title = document.getElementById('backupModalTitle'); + const confirmRow = document.getElementById('backupConfirmRow'); + const status = document.getElementById('backupModalStatus'); + document.getElementById('backupPassword').value = ''; + document.getElementById('backupPasswordConfirm').value = ''; + status.textContent = ''; + if (mode === 'export') { + title.textContent = 'Backup verschlüsseln'; + confirmRow.style.display = ''; + document.getElementById('confirmBackupBtn').textContent = 'Exportieren'; + } else { + title.textContent = 'Backup entschlüsseln'; + confirmRow.style.display = 'none'; + document.getElementById('confirmBackupBtn').textContent = 'Importieren'; + } + modal.style.display = 'flex'; + document.getElementById('backupPassword').focus(); +} + +function closeBackupModal() { + document.getElementById('backupPasswordModal').style.display = 'none'; + _backupMode = null; +} + +async function confirmBackupAction() { + const pw = document.getElementById('backupPassword').value; + const status = document.getElementById('backupModalStatus'); + if (!pw) { status.textContent = 'Bitte Passwort eingeben.'; return; } + + if (_backupMode === 'export') { + const pw2 = document.getElementById('backupPasswordConfirm').value; + if (pw !== pw2) { status.textContent = 'Passwörter stimmen nicht überein.'; return; } + status.textContent = 'Exportiere...'; + try { + const result = await window.api.exportBackup(pw); + if (result.canceled) { status.textContent = ''; return; } + if (result.ok) { + closeBackupModal(); + showCopyToast('Backup exportiert'); + } + } catch (err) { + status.textContent = err.message || 'Export fehlgeschlagen'; + } + } else { + status.textContent = 'Importiere...'; + try { + const result = await window.api.importBackup(pw); + if (result.canceled) { status.textContent = ''; return; } + if (result.ok) { + config = result.config; + hosterSettings = config.hosterSettings || {}; + closeBackupModal(); + renderSettingsPanel(); + renderAccountsList(); + loadHistory(); + showCopyToast('Backup importiert'); + } + } catch (err) { + status.textContent = err.message || 'Import fehlgeschlagen'; + } + } +} + document.addEventListener('click', (e) => { if (!e.target.closest('.context-menu')) hideContextMenu(); }); @@ -2186,6 +2255,23 @@ function setupListeners() { }); }); document.getElementById('saveSettingsBtn').addEventListener('click', saveSettings); + + // --- Backup export / import --- + document.getElementById('exportBackupBtn').addEventListener('click', () => openBackupModal('export')); + document.getElementById('importBackupBtn').addEventListener('click', () => openBackupModal('import')); + document.getElementById('closeBackupModalBtn').addEventListener('click', closeBackupModal); + document.getElementById('cancelBackupModalBtn').addEventListener('click', closeBackupModal); + document.getElementById('confirmBackupBtn').addEventListener('click', confirmBackupAction); + document.getElementById('backupPasswordModal').addEventListener('click', (e) => { + if (e.target.id === 'backupPasswordModal') closeBackupModal(); + }); + document.getElementById('backupPassword').addEventListener('keydown', (e) => { + if (e.key === 'Enter') confirmBackupAction(); + }); + document.getElementById('backupPasswordConfirm').addEventListener('keydown', (e) => { + if (e.key === 'Enter') confirmBackupAction(); + }); + document.getElementById('clearHistoryBtn').addEventListener('click', async () => { if (!confirm('Verlauf wirklich löschen?')) return; await window.api.clearHistory(); diff --git a/renderer/index.html b/renderer/index.html index fef09db..5478107 100644 --- a/renderer/index.html +++ b/renderer/index.html @@ -219,6 +219,14 @@ Änderungen werden automatisch gespeichert. +
+

Backup

+

Alle Accounts, Einstellungen und den Upload-Verlauf verschlüsselt exportieren oder importieren.

+
+ + +
+
@@ -232,6 +240,30 @@ + +