diff --git a/README.md b/README.md index 7609905..c5c790d 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ Multi-Hoster-Upload is a Windows desktop app for managing large file batches acr - Add files by drag and drop or file selection and monitor live queue progress. - Control per-hoster concurrency, bandwidth limits, retries, and folder monitoring. - Keep local upload history and copy completed links in bulk. +- Transfer accounts and settings with a 75-character encrypted online key while encryption and decryption stay on the client. ## Supported hosters @@ -32,7 +33,7 @@ Multi-Hoster-Upload is a Windows desktop app for managing large file batches acr ## Local data and credentials -Settings, queue state, and upload history are stored locally in the app's user-data directory. Hoster passwords and API keys are encrypted with Electron safeStorage before being written when operating-system encryption is available; on Windows this uses DPAPI for the current user profile. +Settings, queue state, and upload history are stored locally in the app's user-data directory. Hoster passwords and API keys are encrypted with Electron safeStorage before being written when operating-system encryption is available; on Windows this uses DPAPI for the current user profile. Online backups are optional, contain accounts and settings only, and are encrypted on the client before the server receives them. Upload history and queue state remain on the original device. ## Development diff --git a/lib/config-store.js b/lib/config-store.js index 566372f..c24bbf8 100644 --- a/lib/config-store.js +++ b/lib/config-store.js @@ -522,6 +522,21 @@ class ConfigStore { }); } + replaceSettings(config) { + return this._enqueueWrite(() => { + const current = this.load(); + const globalSettings = this._clone(config.globalSettings); + globalSettings.pendingQueue = current.globalSettings.pendingQueue ?? null; + return this._commit({ + hosters: this._clone(config.hosters), + hosterSettings: this._clone(config.hosterSettings), + globalSettings, + history: this._clone(current.history || []), + rotationCursors: {} + }); + }); + } + loadHistory() { if (this._historyMigrated) { return this._readHistoryFile() || []; diff --git a/lib/online-backup.js b/lib/online-backup.js new file mode 100644 index 0000000..363a0a0 --- /dev/null +++ b/lib/online-backup.js @@ -0,0 +1,251 @@ +const crypto = require('node:crypto'); +const zlib = require('node:zlib'); + +const ONLINE_BACKUP_API_URL = 'https://uploader.24-music.de/backup-api'; +const KEY_PREFIX = 'MHU2-'; +const KEY_BODY_LENGTH = 70; +const RECORD_ID_LENGTH = 16; +const MASTER_KEY_LENGTH = 32; +const CHECKSUM_LENGTH = 4; +const NONCE_LENGTH = 12; +const AUTH_TAG_LENGTH = 16; +const BLOB_VERSION = 1; +const MAX_BLOB_BYTES = 256 * 1024; +const MAX_RESPONSE_BYTES = 512 * 1024; +const MAX_PLAINTEXT_BYTES = 512 * 1024; +const REQUEST_TIMEOUT_MS = 12_000; +const KEY_CONTEXT = Buffer.from('MHU2-ONLINE-KEY-V1', 'utf8'); +const AAD_CONTEXT = Buffer.from('MHU-ONLINE-BACKUP-V1', 'utf8'); + +function checksum(idBytes, masterKey) { + return crypto.createHash('sha256').update(KEY_CONTEXT).update(idBytes).update(masterKey).digest().subarray(0, CHECKSUM_LENGTH); +} + +function deriveSecret(masterKey, idBytes, purpose) { + return Buffer.from(crypto.hkdfSync('sha256', masterKey, idBytes, Buffer.from(`MHU-ONLINE-${purpose}-V1`, 'utf8'), 32)); +} + +function deriveDeleteSecret(parsed) { + return deriveSecret(parsed.masterKey, parsed.idBytes, 'DELETE'); +} + +function aad(idBytes) { + return Buffer.concat([AAD_CONTEXT, idBytes]); +} + +function encodeKey(idBytes, masterKey) { + const body = Buffer.concat([idBytes, masterKey, checksum(idBytes, masterKey)]).toString('base64url'); + return `${KEY_PREFIX}${body}`; +} + +function validatePayload(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Online-Sicherung enthält keine gültigen Einstellungen'); + } + if ( + value.version !== 1 + || value.kind !== 'settings-only' + || typeof value.appVersion !== 'string' + || typeof value.exportedAt !== 'string' + || !value.settings + || typeof value.settings !== 'object' + || Array.isArray(value.settings) + || Object.prototype.hasOwnProperty.call(value, 'session') + || Object.prototype.hasOwnProperty.call(value, 'history') + ) { + throw new Error('Online-Sicherung enthält keine gültigen Einstellungen'); + } + return value; +} + +function endpoint(baseUrl, relativePath) { + const normalized = String(baseUrl || '').trim().replace(/\/+$/, ''); + const url = new URL(`${normalized}${relativePath}`); + if (url.protocol !== 'https:' && !['127.0.0.1', 'localhost', '::1'].includes(url.hostname)) { + throw new Error('Online-Sicherungen benötigen eine sichere HTTPS-Verbindung'); + } + return url.toString(); +} + +async function requestText(url, init, options = {}) { + const controller = new AbortController(); + const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : REQUEST_TIMEOUT_MS; + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await (options.fetchImpl || fetch)(url, { ...init, signal: controller.signal }); + const body = await readLimitedText(response); + return { response, body }; + } catch { + if (controller.signal.aborted) throw new Error('Online-Sicherungsdienst antwortet nicht'); + throw new Error('Online-Sicherungsdienst ist nicht erreichbar'); + } finally { + clearTimeout(timer); + } +} + +async function readLimitedText(response) { + const contentLength = Number(response.headers.get('content-length') || '0'); + if (Number.isFinite(contentLength) && contentLength > MAX_RESPONSE_BYTES) { + throw new Error('Antwort des Online-Sicherungsdienstes ist zu groß'); + } + if (!response.body) return ''; + const reader = response.body.getReader(); + const chunks = []; + let total = 0; + while (true) { + const result = await reader.read(); + if (result.done) break; + total += result.value.byteLength; + if (total > MAX_RESPONSE_BYTES) { + await reader.cancel(); + throw new Error('Antwort des Online-Sicherungsdienstes ist zu groß'); + } + chunks.push(Buffer.from(result.value)); + } + return Buffer.concat(chunks).toString('utf8'); +} + +function parseOnlineBackupKey(key) { + const normalized = String(key || '').trim(); + if (!new RegExp(`^${KEY_PREFIX}[A-Za-z0-9_-]{${KEY_BODY_LENGTH}}$`).test(normalized)) { + throw new Error('Online-Sicherungsschlüssel ist ungültig'); + } + const decoded = Buffer.from(normalized.slice(KEY_PREFIX.length), 'base64url'); + if (decoded.length !== RECORD_ID_LENGTH + MASTER_KEY_LENGTH + CHECKSUM_LENGTH) { + throw new Error('Online-Sicherungsschlüssel ist ungültig'); + } + if (decoded.toString('base64url') !== normalized.slice(KEY_PREFIX.length)) { + throw new Error('Online-Sicherungsschlüssel ist ungültig'); + } + const idBytes = decoded.subarray(0, RECORD_ID_LENGTH); + const masterKey = decoded.subarray(RECORD_ID_LENGTH, RECORD_ID_LENGTH + MASTER_KEY_LENGTH); + const actualChecksum = decoded.subarray(RECORD_ID_LENGTH + MASTER_KEY_LENGTH); + const expectedChecksum = checksum(idBytes, masterKey); + if (!crypto.timingSafeEqual(actualChecksum, expectedChecksum)) { + throw new Error('Online-Sicherungsschlüssel ist beschädigt'); + } + return { + id: idBytes.toString('base64url'), + idBytes: Buffer.from(idBytes), + masterKey: Buffer.from(masterKey) + }; +} + +function createOnlineBackup(settings, appVersion, exportedAt = new Date().toISOString()) { + const idBytes = crypto.randomBytes(RECORD_ID_LENGTH); + const masterKey = crypto.randomBytes(MASTER_KEY_LENGTH); + const key = encodeKey(idBytes, masterKey); + const encryptionKey = deriveSecret(masterKey, idBytes, 'ENCRYPTION'); + const nonce = crypto.randomBytes(NONCE_LENGTH); + const payload = { + version: 1, + kind: 'settings-only', + appVersion: String(appVersion || ''), + exportedAt, + settings: JSON.parse(JSON.stringify(settings)) + }; + const plaintext = Buffer.from(JSON.stringify(payload), 'utf8'); + if (plaintext.length > MAX_PLAINTEXT_BYTES) { + throw new Error('Einstellungen sind für eine Online-Sicherung zu groß'); + } + const compressed = zlib.gzipSync(plaintext, { level: 9 }); + const cipher = crypto.createCipheriv('aes-256-gcm', encryptionKey, nonce, { authTagLength: AUTH_TAG_LENGTH }); + cipher.setAAD(aad(idBytes)); + const ciphertext = Buffer.concat([cipher.update(compressed), cipher.final()]); + const blobBytes = Buffer.concat([Buffer.from([BLOB_VERSION]), nonce, cipher.getAuthTag(), ciphertext]); + if (blobBytes.length > MAX_BLOB_BYTES) { + throw new Error('Einstellungen sind für eine Online-Sicherung zu groß'); + } + const parsed = parseOnlineBackupKey(key); + const deleteVerifier = crypto.createHash('sha256').update(deriveDeleteSecret(parsed)).digest('base64url'); + return { + key, + record: { + id: parsed.id, + blob: blobBytes.toString('base64url'), + deleteVerifier + } + }; +} + +function restoreOnlineBackup(key, blob) { + const parsed = parseOnlineBackupKey(key); + if (typeof blob !== 'string' || !/^[A-Za-z0-9_-]+$/.test(blob) || blob.length > Math.ceil(MAX_BLOB_BYTES * 4 / 3) + 4) { + throw new Error('Online-Sicherung ist beschädigt'); + } + const bytes = Buffer.from(blob, 'base64url'); + if (bytes.toString('base64url') !== blob || bytes.length < 1 + NONCE_LENGTH + AUTH_TAG_LENGTH || bytes[0] !== BLOB_VERSION) { + throw new Error('Online-Sicherung ist beschädigt'); + } + const nonce = bytes.subarray(1, 1 + NONCE_LENGTH); + const tag = bytes.subarray(1 + NONCE_LENGTH, 1 + NONCE_LENGTH + AUTH_TAG_LENGTH); + const ciphertext = bytes.subarray(1 + NONCE_LENGTH + AUTH_TAG_LENGTH); + try { + const decipher = crypto.createDecipheriv( + 'aes-256-gcm', + deriveSecret(parsed.masterKey, parsed.idBytes, 'ENCRYPTION'), + nonce, + { authTagLength: AUTH_TAG_LENGTH } + ); + decipher.setAAD(aad(parsed.idBytes)); + decipher.setAuthTag(tag); + const compressed = Buffer.concat([decipher.update(ciphertext), decipher.final()]); + const plaintext = zlib.gunzipSync(compressed, { maxOutputLength: MAX_PLAINTEXT_BYTES }).toString('utf8'); + return validatePayload(JSON.parse(plaintext)); + } catch (error) { + if (error instanceof Error && /keine gültigen Einstellungen/.test(error.message)) throw error; + throw new Error('Online-Sicherung konnte nicht entschlüsselt werden oder ist beschädigt'); + } +} + +async function uploadOnlineBackup(record, baseUrl = ONLINE_BACKUP_API_URL, options) { + const { response } = await requestText(endpoint(baseUrl, '/v1/backups'), { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json' }, + body: JSON.stringify(record) + }, options); + if (response.status !== 201) throw new Error('Online-Sicherung konnte nicht gespeichert werden'); +} + +async function downloadOnlineBackup(key, baseUrl = ONLINE_BACKUP_API_URL, options) { + const parsed = parseOnlineBackupKey(key); + const { response, body } = await requestText(endpoint(baseUrl, '/v1/backups/restore'), { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json' }, + body: JSON.stringify({ id: parsed.id }) + }, options); + if (response.status !== 200) { + throw new Error(response.status === 404 ? 'Online-Sicherung wurde nicht gefunden' : 'Online-Sicherung konnte nicht geladen werden'); + } + let value; + try { + value = JSON.parse(body); + } catch { + throw new Error('Online-Sicherungsdienst hat ungültige Daten geliefert'); + } + if (typeof value?.blob !== 'string') throw new Error('Online-Sicherungsdienst hat ungültige Daten geliefert'); + return restoreOnlineBackup(key, value.blob); +} + +async function deleteOnlineBackup(key, baseUrl = ONLINE_BACKUP_API_URL, options) { + const parsed = parseOnlineBackupKey(key); + const deleteSecret = deriveDeleteSecret(parsed).toString('base64url'); + const { response } = await requestText(endpoint(baseUrl, '/v1/backups/delete'), { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json' }, + body: JSON.stringify({ id: parsed.id, deleteSecret }) + }, options); + if (response.status !== 204) { + throw new Error(response.status === 404 ? 'Online-Sicherung wurde nicht gefunden' : 'Online-Sicherung konnte nicht gelöscht werden'); + } +} + +module.exports = { + ONLINE_BACKUP_API_URL, + createOnlineBackup, + deleteOnlineBackup, + downloadOnlineBackup, + parseOnlineBackupKey, + restoreOnlineBackup, + uploadOnlineBackup +}; diff --git a/lib/serialized-runner.js b/lib/serialized-runner.js new file mode 100644 index 0000000..4bcfed6 --- /dev/null +++ b/lib/serialized-runner.js @@ -0,0 +1,22 @@ +(function (root) { + 'use strict'; + + function createSerializedRunner(task) { + if (typeof task !== 'function') throw new TypeError('task must be a function'); + let pending = Promise.resolve(); + return { + run(...args) { + const result = pending.catch(() => {}).then(() => task(...args)); + pending = result; + return result; + }, + flush() { + return pending; + } + }; + } + + const api = { createSerializedRunner }; + if (typeof module !== 'undefined' && module.exports) module.exports = api; + else if (root) root.SerializedRunner = api; +})(typeof window !== 'undefined' ? window : this); diff --git a/lib/settings-backup.js b/lib/settings-backup.js new file mode 100644 index 0000000..5a2165b --- /dev/null +++ b/lib/settings-backup.js @@ -0,0 +1,57 @@ +const fs = require('node:fs'); +const path = require('node:path'); + +function clone(value) { + return JSON.parse(JSON.stringify(value)); +} + +function validateSettings(value) { + if ( + !value + || typeof value !== 'object' + || Array.isArray(value) + || !value.hosters + || typeof value.hosters !== 'object' + || Array.isArray(value.hosters) + || !value.hosterSettings + || typeof value.hosterSettings !== 'object' + || Array.isArray(value.hosterSettings) + || !value.globalSettings + || typeof value.globalSettings !== 'object' + || Array.isArray(value.globalSettings) + ) { + throw new Error('Backup hat eine ungültige Struktur'); + } +} + +function createPortableSettingsSnapshot(config) { + validateSettings(config); + const snapshot = { + hosters: clone(config.hosters), + hosterSettings: clone(config.hosterSettings), + globalSettings: clone(config.globalSettings), + history: [] + }; + snapshot.globalSettings.pendingQueue = null; + return snapshot; +} + +function prepareImportedSettings(value, options = {}) { + validateSettings(value); + const imported = createPortableSettingsSnapshot(value); + const pathExists = options.pathExists || fs.existsSync; + const pathDirname = options.pathDirname || path.dirname; + const globalSettings = imported.globalSettings; + if (globalSettings.logFilePath && !pathExists(pathDirname(globalSettings.logFilePath))) { + globalSettings.logFilePath = ''; + } + if (globalSettings.folderMonitor && typeof globalSettings.folderMonitor === 'object') { + if (globalSettings.folderMonitor.folderPath && !pathExists(globalSettings.folderMonitor.folderPath)) { + globalSettings.folderMonitor.folderPath = ''; + globalSettings.folderMonitor.enabled = false; + } + } + return imported; +} + +module.exports = { createPortableSettingsSnapshot, prepareImportedSettings }; diff --git a/lib/settings-import-gate.js b/lib/settings-import-gate.js new file mode 100644 index 0000000..945920b --- /dev/null +++ b/lib/settings-import-gate.js @@ -0,0 +1,19 @@ +function createSettingsImportGate(isUploadRunning) { + if (typeof isUploadRunning !== 'function') throw new TypeError('isUploadRunning must be a function'); + let importing = false; + return { + begin() { + if (importing) throw new Error('Einstellungen werden bereits importiert'); + if (isUploadRunning()) throw new Error('Während laufender Uploads können keine Einstellungen importiert werden'); + importing = true; + }, + end() { + importing = false; + }, + canStartUpload() { + return !importing; + } + }; +} + +module.exports = { createSettingsImportGate }; diff --git a/lib/upload-manager.js b/lib/upload-manager.js index c95f1aa..3518164 100644 --- a/lib/upload-manager.js +++ b/lib/upload-manager.js @@ -56,6 +56,16 @@ class UploadManager extends EventEmitter { } } + replaceAccountPools(accountPools) { + this.accountPools = accountPools && typeof accountPools === 'object' ? accountPools : {}; + this._failedAccounts.clear(); + this._accountOverrides.clear(); + this._suspectSizeMemo.clear(); + this._suspectGoodAccounts.clear(); + this._doodApiKeyCache.clear(); + this._baselineCache.clear(); + } + switchAccount(hoster, fallbackAccount) { const prev = this._accountOverrides.get(hoster); this._accountOverrides.set(hoster, fallbackAccount); diff --git a/main.js b/main.js index 38df739..227bddd 100644 --- a/main.js +++ b/main.js @@ -17,6 +17,9 @@ const { createAccountPicker } = require('./lib/account-rotation'); const ClouddropUploader = require('./lib/clouddrop-upload'); const { checkForUpdate, installUpdate, abortUpdate } = require('./lib/updater'); const backupCrypto = require('./lib/backup-crypto'); +const { createOnlineBackup, downloadOnlineBackup, uploadOnlineBackup } = require('./lib/online-backup'); +const { createPortableSettingsSnapshot, prepareImportedSettings } = require('./lib/settings-backup'); +const { createSettingsImportGate } = require('./lib/settings-import-gate'); const FolderMonitor = require('./lib/folder-monitor'); const RemoteServer = require('./lib/remote-server'); const { maybeRotateLogFile } = require('./lib/log-rotation'); @@ -96,6 +99,7 @@ let tray = null; const configStore = new ConfigStore(app); configStore.setPerfLog((m) => { try { logInfo(m); } catch {} }); let uploadManager = null; +const settingsImportGate = createSettingsImportGate(() => !!(uploadManager && uploadManager.running)); let diagnosticAgent = null; let _diagHandler = null; @@ -1721,6 +1725,7 @@ ipcMain.handle('get-file-sizes', async (_event, paths) => { }); ipcMain.handle('start-upload', (_event, payload) => { + if (!settingsImportGate.canStartUpload()) return { error: 'Einstellungen werden gerade importiert' }; const config = configStore.load(); const files = payload && Array.isArray(payload.files) ? payload.files : []; const hosters = payload && Array.isArray(payload.hosters) ? payload.hosters : []; @@ -2207,6 +2212,83 @@ ipcMain.handle('clear-history', async () => { return true; }); +async function syncImportedRuntime(config) { + const warnings = []; + try { + setLogVerbose(!!config.globalSettings.logVerbose); + if (uploadManager) { + uploadManager.updateSettings(config.hosterSettings, config.globalSettings); + uploadManager.replaceAccountPools(buildAccountPools(config)); + } + if (mainWindow && !mainWindow.isDestroyed()) mainWindow.setAlwaysOnTop(!!config.globalSettings.alwaysOnTop); + } catch (error) { + debugLog(`backup runtime settings failed: ${error.message}`); + warnings.push('allgemeine Laufzeiteinstellungen'); + } + try { + folderMonitor.stop(); + const folderSettings = config.globalSettings.folderMonitor; + if (folderSettings && folderSettings.enabled && folderSettings.folderPath) startFolderMonitor(folderSettings); + } catch (error) { + debugLog(`backup folder monitor sync failed: ${error.message}`); + warnings.push('Ordnerüberwachung'); + } + try { + const remoteSettings = config.globalSettings.remote; + if (remoteSettings && remoteSettings.enabled) await startRemoteServer(); + else if (remoteServer) { + remoteServer.stop(); + remoteServer = null; + destroyCaptureWindow(); + } + } catch (error) { + debugLog(`backup remote sync failed: ${error.message}`); + warnings.push('Remote-Steuerung'); + } + try { + const diagnostics = config.globalSettings.diagnostics; + if (diagnostics && diagnostics.enabled) await startDiagnosticAgent(); + else stopDiagnosticAgent(); + } catch (error) { + debugLog(`backup diagnostics sync failed: ${error.message}`); + warnings.push('Diagnose'); + } + try { + if (config.globalSettings.showDropTarget) createDropTargetWindow(); + else destroyDropTargetWindow(); + } catch (error) { + debugLog(`backup drop target sync failed: ${error.message}`); + warnings.push('Drop-Target'); + } + return warnings; +} + +async function applyImportedSettings(imported) { + settingsImportGate.begin(); + try { + const prepared = prepareImportedSettings(imported); + const ts = new Date().toISOString().replace(/[:.]/g, '-'); + const preImportPath = configStore.filePath.replace('.json', `.pre-import-${ts}.json`); + try { fs.copyFileSync(configStore.filePath, preImportPath); } catch {} + await configStore.replaceSettings(prepared); + _rotationCursors = {}; + _sessionFailedAccounts.clear(); + _sessionAccountOverrides.clear(); + _invalidateLogSettings(); + const config = configStore.load(); + const warnings = await syncImportedRuntime(config); + return { config, warnings }; + } finally { + settingsImportGate.end(); + } +} + +function readBackupFile(filePath) { + const stat = fs.statSync(filePath); + if (!stat.isFile() || stat.size > 2 * 1024 * 1024) throw new Error('Backup-Datei ist zu groß oder ungültig'); + return fs.readFileSync(filePath); +} + // --- Backup export / import --- ipcMain.handle('export-backup', async () => { const _bd = new Date(); @@ -2220,8 +2302,7 @@ ipcMain.handle('export-backup', async () => { ] }); if (canceled || !filePath) return { ok: false, canceled: true }; - const config = configStore.load(); - config.history = []; + const config = createPortableSettingsSnapshot(configStore.load()); if (filePath.toLowerCase().endsWith('.json')) { fs.writeFileSync(filePath, JSON.stringify(config, null, 2), 'utf-8'); } else { @@ -2235,7 +2316,7 @@ ipcMain.handle('import-backup', async (_event, legacyPassword) => { let buffer; let sourcePath = _lastImportPath; if (legacyPassword && sourcePath) { - buffer = fs.readFileSync(sourcePath); + buffer = readBackupFile(sourcePath); } else { const { canceled, filePaths } = await dialog.showOpenDialog(mainWindow, { title: 'Backup importieren', @@ -2248,7 +2329,7 @@ ipcMain.handle('import-backup', async (_event, legacyPassword) => { }); if (canceled || !filePaths.length) return { ok: false, canceled: true }; sourcePath = filePaths[0]; - buffer = fs.readFileSync(sourcePath); + buffer = readBackupFile(sourcePath); _lastImportPath = sourcePath; } let imported; @@ -2273,40 +2354,33 @@ ipcMain.handle('import-backup', async (_event, legacyPassword) => { } } _lastImportPath = null; - // Validate imported data has required structure - if (!imported || typeof imported !== 'object' || !imported.hosters || !imported.hosterSettings || !imported.globalSettings) { - return { ok: false, error: 'Backup-Datei hat ungültige Struktur (hosters, hosterSettings oder globalSettings fehlt).' }; + try { + return { ok: true, ...await applyImportedSettings(imported) }; + } catch (error) { + return { ok: false, error: error.message || String(error) }; } - // Safety net: timestamped backup so multiple imports don't overwrite each other - const ts = new Date().toISOString().replace(/[:.]/g, '-'); - const preImportPath = configStore.filePath.replace('.json', `.pre-import-${ts}.json`); - try { fs.copyFileSync(configStore.filePath, preImportPath); } catch {} - // Strip machine-specific state: absolute paths from the source machine will - // not exist on this one (e.g. C:\Users\Administrator\... vs \bakeredwin318\...). - // Any path that does not resolve locally is cleared so the user can re-set it - // instead of hitting silent failures later. - const importedGlobal = imported.globalSettings || {}; - if (importedGlobal.logFilePath && !fs.existsSync(path.dirname(importedGlobal.logFilePath))) { - importedGlobal.logFilePath = ''; +}); + +ipcMain.handle('online-backup:create', async () => { + try { + const snapshot = createPortableSettingsSnapshot(configStore.load()); + const created = createOnlineBackup(snapshot, app.getVersion()); + await uploadOnlineBackup(created.record); + return { ok: true, key: created.key }; + } catch (error) { + return { ok: false, error: error.message || String(error) }; } - if (importedGlobal.folderMonitor && typeof importedGlobal.folderMonitor === 'object') { - const fm = importedGlobal.folderMonitor; - if (fm.folderPath && !fs.existsSync(fm.folderPath)) { - fm.folderPath = ''; - fm.enabled = false; - } +}); + +ipcMain.handle('online-backup:restore', async (_event, key) => { + try { + const normalized = String(key || '').trim(); + if (normalized.length > 128) throw new Error('Online-Sicherungsschlüssel ist ungültig'); + const payload = await downloadOnlineBackup(normalized); + return { ok: true, ...await applyImportedSettings(payload.settings) }; + } catch (error) { + return { ok: false, error: error.message || String(error) }; } - importedGlobal.pendingQueue = null; - // Single atomic write — no split state, no TOCTOU race - const merged = { - hosters: imported.hosters, - hosterSettings: imported.hosterSettings, - globalSettings: importedGlobal, - history: [] - }; - await configStore._atomicWrite(configStore._serializeForDisk(merged)); - _invalidateLogSettings(); - return { ok: true, config: configStore.load() }; }); ipcMain.handle('read-own-upload-log', () => { diff --git a/package-lock.json b/package-lock.json index c0b4b00..aa620dc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "multi-hoster-uploader", - "version": "2.0.2", + "version": "2.0.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "multi-hoster-uploader", - "version": "2.0.2", + "version": "2.0.3", "dependencies": { "chokidar": "^3.6.0", "undici": "^7.29.0", diff --git a/package.json b/package.json index c373980..65b4095 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,12 @@ { "name": "multi-hoster-uploader", - "version": "2.0.2", + "version": "2.0.3", "description": "Upload files to doodstream, voe, vidmoly, byse simultaneously", "main": "main.js", "scripts": { "start": "electron .", "test": "node --test tests/*.test.js tests/ui-smoke.js", + "test:backup-api": "npm --prefix services/backup-api test", "dist": "electron-builder --win", "release:win": "electron-builder --publish never --win nsis portable", "release:gitea": "node scripts/release_gitea.mjs" @@ -32,6 +33,7 @@ "files": [ "main.js", "preload.js", + "preload-drop-target.js", "lib/**/*", "renderer/**/*", "assets/app_icon.ico", diff --git a/preload.js b/preload.js index 9c0f0d4..2557a48 100644 --- a/preload.js +++ b/preload.js @@ -68,6 +68,8 @@ contextBridge.exposeInMainWorld('api', { // Backup exportBackup: () => ipcRenderer.invoke('export-backup'), importBackup: (legacyPassword) => ipcRenderer.invoke('import-backup', legacyPassword), + createOnlineBackup: () => ipcRenderer.invoke('online-backup:create'), + restoreOnlineBackup: (key) => ipcRenderer.invoke('online-backup:restore', key), // Folder Monitor folderMonitorStart: (settings) => ipcRenderer.invoke('folder-monitor:start', settings), diff --git a/renderer/app.js b/renderer/app.js index 51d679a..f986a60 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -104,6 +104,7 @@ const queuePersistThrottle = (window.ThrottleTimer && window.ThrottleTimer.makeT })(); let _restoredSnapshotSavedAt = null; let settingsSaveTimer = null; +const settingsSaveCoordinator = window.SerializedRunner.createSerializedRunner(performSaveSettings); let lastUploadStats = { state: 'idle', globalSpeedKbs: 0, totalBytes: 0, elapsed: 0, activeJobs: 0 }; const AUTO_CHECK_PREF_KEY = 'autoHealthCheckBeforeUpload'; const QUEUE_COL_WIDTHS_KEY = 'queueColumnWidthsPx'; @@ -453,6 +454,8 @@ async function _handleMenuAction(action) { case 'add-folder': document.getElementById('addFolderBtn')?.click(); break; case 'backup-export': doBackupExport(); break; case 'backup-import': doBackupImport(); break; + case 'online-backup-create': doOnlineBackupCreate(); break; + case 'online-backup-restore': openOnlineBackupRestore(); break; case 'restart': if (confirm('Anwendung neu starten?')) window.api.restartApp(); break; case 'quit': window.api.quitApp(); break; case 'open-settings': document.querySelector('.tab[data-view="settings"]')?.click(); break; @@ -1829,6 +1832,7 @@ function copySelectedRecentLinks() { // --- Backup export / import --- async function doBackupExport() { try { + await flushPendingSettingsSaves(); const result = await window.api.exportBackup(); if (result && result.ok) showCopyToast('Backup exportiert'); } catch (err) { @@ -1836,6 +1840,102 @@ async function doBackupExport() { } } +function applyImportedConfig(importedConfig, message) { + config = importedConfig; + hosterSettings = config.hosterSettings || {}; + ensureAccountStatusEntries(); + syncSelectedUploadHosters(); + alwaysOnTopState = !!(config.globalSettings && config.globalSettings.alwaysOnTop); + window.api.setAlwaysOnTop(alwaysOnTopState); + renderSettings(); + renderAccounts(); + renderHosterSummary(); + renderHosterModal(); + loadHistory(); + showCopyToast(message); +} + +function setOnlineBackupStatus(message, state = '') { + const status = document.getElementById('onlineBackupStatus'); + if (!status) return; + status.textContent = message; + status.dataset.state = state; +} + +function setOnlineBackupBusy(busy) { + const createButton = document.getElementById('createOnlineBackupBtn'); + const restoreButton = document.getElementById('restoreOnlineBackupBtn'); + if (createButton) createButton.disabled = busy; + if (restoreButton) restoreButton.disabled = busy || !/^MHU2-[A-Za-z0-9_-]{70}$/.test(document.getElementById('onlineBackupKeyInput')?.value.trim() || ''); +} + +async function doOnlineBackupCreate() { + if (doOnlineBackupCreate.busy) return; + doOnlineBackupCreate.busy = true; + try { + await flushPendingSettingsSaves(); + openOnlineBackupView(); + } catch (error) { + openOnlineBackupView(); + setOnlineBackupStatus(error.message || String(error), 'error'); + doOnlineBackupCreate.busy = false; + return; + } + setOnlineBackupBusy(true); + setOnlineBackupStatus('Verschlüssele und speichere Einstellungen…', 'busy'); + try { + const result = await window.api.createOnlineBackup(); + if (!result || !result.ok) throw new Error(result?.error || 'Online-Sicherung konnte nicht erstellt werden'); + const output = document.getElementById('onlineBackupKeyOutput'); + const copyButton = document.getElementById('copyOnlineBackupKeyBtn'); + if (output) output.value = result.key; + if (copyButton) copyButton.disabled = false; + setOnlineBackupStatus('Neuer Schlüssel erstellt. Ältere Schlüssel bleiben gültig.', 'success'); + showCopyToast('Online-Schlüssel erstellt'); + } catch (error) { + setOnlineBackupStatus(error.message || String(error), 'error'); + } finally { + setOnlineBackupBusy(false); + doOnlineBackupCreate.busy = false; + } +} + +function openOnlineBackupView(focusRestore = false) { + document.querySelector('.tab[data-view="settings"]')?.click(); + document.querySelector('[data-subtab="backup"]')?.click(); + if (focusRestore) document.getElementById('onlineBackupKeyInput')?.focus(); +} + +function openOnlineBackupRestore() { + openOnlineBackupView(true); +} + +async function doOnlineBackupRestore() { + const input = document.getElementById('onlineBackupKeyInput'); + const key = input?.value.trim() || ''; + if (!/^MHU2-[A-Za-z0-9_-]{70}$/.test(key)) { + setOnlineBackupStatus('Der Schlüssel muss mit MHU2- beginnen und exakt 75 Zeichen lang sein.', 'error'); + input?.focus(); + return; + } + setOnlineBackupBusy(true); + setOnlineBackupStatus('Speichere aktuelle Einstellungen…', 'busy'); + try { + await flushPendingSettingsSaves(); + setOnlineBackupStatus('Lade und entschlüssele Einstellungen…', 'busy'); + const result = await window.api.restoreOnlineBackup(key); + if (!result || !result.ok) throw new Error(result?.error || 'Online-Sicherung konnte nicht importiert werden'); + applyImportedConfig(result.config, 'Online-Backup importiert'); + const warnings = Array.isArray(result.warnings) ? result.warnings : []; + if (warnings.length) setOnlineBackupStatus(`Einstellungen übernommen. Bitte prüfen: ${warnings.join(', ')}.`, 'warning'); + else setOnlineBackupStatus('Alle Accounts und Einstellungen wurden übernommen.', 'success'); + } catch (error) { + setOnlineBackupStatus(error.message || String(error), 'error'); + } finally { + setOnlineBackupBusy(false); + } +} + function askLegacyBackupPassword(hint) { return new Promise((resolve) => { const overlay = document.createElement('div'); @@ -1906,6 +2006,7 @@ function askLegacyBackupPassword(hint) { async function doBackupImport(legacyPassword) { const pw = typeof legacyPassword === 'string' ? legacyPassword : undefined; try { + await flushPendingSettingsSaves(); const result = await window.api.importBackup(pw); if (!result || result.canceled) return; if (result.needsPassword) { @@ -1914,16 +2015,10 @@ async function doBackupImport(legacyPassword) { return; } if (result.ok) { - config = result.config; - hosterSettings = config.hosterSettings || {}; - alwaysOnTopState = !!(config.globalSettings && config.globalSettings.alwaysOnTop); - window.api.setAlwaysOnTop(alwaysOnTopState); - renderSettings(); - renderAccounts(); - renderHosterSummary(); - renderHosterModal(); - loadHistory(); - showCopyToast('Backup importiert'); + applyImportedConfig(result.config, 'Backup importiert'); + if (Array.isArray(result.warnings) && result.warnings.length) { + alert(`Backup importiert. Bitte prüfen: ${result.warnings.join(', ')}.`); + } } else if (result.error) { alert('Import fehlgeschlagen: ' + result.error); } @@ -3339,10 +3434,33 @@ function renderSettings() { `; pages.backup.innerHTML = ` -
Alle Accounts und Einstellungen exportieren oder importieren. Der Upload-Verlauf bleibt lokal und wird nicht übertragen; nach einem Import ist der Verlauf-Tab leer.
-Alle Accounts und Einstellungen exportieren oder importieren. Der Upload-Verlauf wird nicht übertragen und bleibt auf diesem Gerät.
+Die Verschlüsselung findet ausschließlich auf diesem Gerät statt. Der Server speichert nur verschlüsselte Daten.
+Behandle den Schlüssel wie ein Passwort. Wer ihn besitzt, kann die verschlüsselten Einstellungen entschlüsseln.
+ +