diff --git a/lib/config-store.js b/lib/config-store.js index 0b2760b..ca0be23 100644 --- a/lib/config-store.js +++ b/lib/config-store.js @@ -58,6 +58,7 @@ const DEFAULTS = { shutdownAfterFinish: 'nothing', // nothing | sleep | shutdown | restart logFilePath: '', sessionLog: false, // legacy boolean (kept for back-compat reads); normalized into logMode on load + logVerbose: false, // when true, [DEBUG] level entries are written to debug.log // NOTE: logMode is intentionally NOT in DEFAULTS. If it were, the deep-merge // would seed logMode='single' for every load, which would beat (and silently // erase) the legacy sessionLog:true → "daily" migration. normalizeLogMode in diff --git a/lib/support-bundle.js b/lib/support-bundle.js new file mode 100644 index 0000000..4dd3bad --- /dev/null +++ b/lib/support-bundle.js @@ -0,0 +1,64 @@ +const fs = require('fs'); + +const CRED_KEYS = new Set(['password', 'apiKey', 'token', 'cookie', 'sessionId']); +const REDACTED = ''; + +function sanitizeConfig(config) { + if (!config || typeof config !== 'object') return config; + const clone = JSON.parse(JSON.stringify(config)); + (function walk(o) { + if (!o) return; + if (Array.isArray(o)) { for (const e of o) walk(e); return; } + if (typeof o !== 'object') return; + for (const k of Object.keys(o)) { + if (CRED_KEYS.has(k) && typeof o[k] === 'string' && o[k]) o[k] = REDACTED; + else walk(o[k]); + } + })(clone); + return clone; +} + +function collectFile(filePath, label, maxBytes) { + if (!filePath) return `=== ${label} ===\n\n\n`; + let stat; + try { stat = fs.statSync(filePath); } + catch (err) { + if (err && err.code === 'ENOENT') return `=== ${label} (${filePath}) ===\n\n\n`; + return `=== ${label} (${filePath}) ===\n\n\n`; + } + const cap = Number.isFinite(maxBytes) && maxBytes > 0 ? maxBytes : 5 * 1024 * 1024; + let content; + try { + if (stat.size > cap) { + const fd = fs.openSync(filePath, 'r'); + const buf = Buffer.alloc(cap); + fs.readSync(fd, buf, 0, cap, stat.size - cap); + fs.closeSync(fd); + const skipped = stat.size - cap; + content = `\n` + buf.toString('utf-8'); + } else { + content = fs.readFileSync(filePath, 'utf-8'); + } + } catch (err) { + content = ``; + } + return `=== ${label} (${filePath}, size=${stat.size} bytes) ===\n${content}\n\n`; +} + +function buildSupportBundleText({ header, sanitizedConfig, files }) { + const parts = []; + parts.push('=== Multi-Hoster-Upload Support Bundle ===\n'); + if (header && typeof header === 'object') { + for (const [k, v] of Object.entries(header)) parts.push(`${k}: ${v}\n`); + } + parts.push('\n'); + parts.push('=== Config (sanitized — password/apiKey/token/cookie/sessionId redacted) ===\n'); + parts.push(JSON.stringify(sanitizedConfig, null, 2)); + parts.push('\n\n'); + for (const f of (files || [])) { + parts.push(collectFile(f.path, f.label || f.path, f.maxBytes)); + } + return parts.join(''); +} + +module.exports = { sanitizeConfig, collectFile, buildSupportBundleText, CRED_KEYS, REDACTED }; diff --git a/main.js b/main.js index 92fd80d..78f6833 100644 --- a/main.js +++ b/main.js @@ -16,6 +16,7 @@ const FolderMonitor = require('./lib/folder-monitor'); const RemoteServer = require('./lib/remote-server'); const { maybeRotateLogFile } = require('./lib/log-rotation'); const { hosterLogToFileEnabled } = require('./lib/log-policy'); +const { sanitizeConfig, buildSupportBundleText } = require('./lib/support-bundle'); let mainWindow; let _lastImportPath = null; @@ -115,6 +116,49 @@ function debugLog(msg) { } catch {} } +let _logVerbose = false; +function setLogVerbose(v) { _logVerbose = !!v; } +function _ctxTag(ctx) { + if (!ctx || typeof ctx !== 'object') return ''; + const tags = []; + if (ctx.batch) tags.push(`b:${String(ctx.batch).slice(0, 8)}`); + if (ctx.job) tags.push(`j:${String(ctx.job).slice(-8)}`); + if (ctx.hoster) tags.push(ctx.hoster); + if (ctx.attempt !== undefined && ctx.attempt !== null) tags.push(`a:${ctx.attempt}`); + return tags.length ? `[${tags.join(' ')}] ` : ''; +} +function _split(a, b) { + if (typeof a === 'string') return { ctx: null, msg: a, extra: b }; + return { ctx: a, msg: b, extra: arguments[2] }; +} +function logDebug(a, b) { + if (!_logVerbose) return; + const s = _split(a, b); + debugLog(`[DEBUG] ${_ctxTag(s.ctx)}${s.msg}`); +} +function logInfo(a, b) { + const s = _split(a, b); + debugLog(`[INFO ] ${_ctxTag(s.ctx)}${s.msg}`); +} +function logWarn(a, b) { + const s = _split(a, b); + debugLog(`[WARN ] ${_ctxTag(s.ctx)}${s.msg}`); +} +function logError(a, b, c) { + let ctx, msg, err; + if (typeof a === 'string') { ctx = null; msg = a; err = b; } + else { ctx = a; msg = b; err = c; } + const errStr = err ? ` :: ${err.stack || err.message || err}` : ''; + debugLog(`[ERROR] ${_ctxTag(ctx)}${msg}${errStr}`); +} +function logMarker(label, fields) { + let extra = ''; + if (fields && typeof fields === 'object') { + extra = ' ' + Object.entries(fields).map(([k, v]) => `${k}=${v}`).join(' '); + } + debugLog(`────── ${label}${extra} ──────`); +} + // Dedicated account-rotation log so users can trace fallback decisions // without wading through general debug output. Writes to account-rotation.log // in the same directory as fileuploader.log (honors user's configured path). @@ -154,6 +198,20 @@ function _flushRotLog() { write(0); } +function getAllLogPaths() { + const upload = getLogFilePath(); + const debugPath = getDebugLogPath(); + const rot = getRotLogPath(); + const dir = path.dirname(debugPath); + return { + fileuploader: upload, + debug: debugPath, + accountRotation: rot, + doodstreamDebug: path.join(dir, 'doodstream-debug.log'), + logDir: dir + }; +} + function rotLog(msg, ts) { try { const iso = new Date(ts || Date.now()).toISOString(); @@ -927,6 +985,19 @@ function updateTrayTooltip(text) { } app.whenReady().then(() => { + try { + const _bootCfg = configStore.load(); + setLogVerbose(!!(_bootCfg.globalSettings && _bootCfg.globalSettings.logVerbose)); + } catch {} + logMarker('APP START', { + version: app.getVersion(), + electron: process.versions.electron, + node: process.versions.node, + platform: process.platform, + arch: process.arch, + verbose: _logVerbose, + pid: process.pid + }); createWindow(); createTray(); @@ -943,7 +1014,7 @@ app.whenReady().then(() => { if (fs.existsSync(fm.folderPath)) { startFolderMonitor(fm); } else { - debugLog(`folder-monitor auto-start skipped: path not found (${fm.folderPath})`); + logWarn(`folder-monitor auto-start skipped: path not found (${fm.folderPath})`); // Persist the disable so the user gets a clean state on next launch const gs = { ...launchConfig.globalSettings, folderMonitor: { ...fm, enabled: false } }; configStore.save({ globalSettings: gs }).catch(() => {}); @@ -977,14 +1048,15 @@ app.whenReady().then(() => { // Auto-check for updates after 3 seconds setTimeout(async () => { try { - debugLog('update-check: starting'); + logInfo('update-check: starting'); const result = await checkForUpdate(); - debugLog(`update-check: available=${result && result.available}, remote=${result && result.remoteVersion}`); + logInfo(`update-check: available=${result && result.available}, remote=${result && result.remoteVersion}`); + logDebug(`update-check result: ${JSON.stringify(result)}`); if (result && result.available && mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('app:update-available', result); } } catch (err) { - debugLog(`update-check failed: ${err && err.message || err}`); + logError('update-check failed', err); } }, 3000); }); @@ -1037,6 +1109,11 @@ ipcMain.handle('get-config', () => { ipcMain.handle('save-config', async (_event, config) => { await configStore.save(config); + try { + if (config && config.globalSettings && Object.prototype.hasOwnProperty.call(config.globalSettings, 'logVerbose')) { + setLogVerbose(!!config.globalSettings.logVerbose); + } + } catch {} // If a batch is running and some accounts got marked failed before any // fallback existed, re-resolve now — the user may have just added one. // Without this re-probe, those accounts stay stuck with no override until @@ -1258,6 +1335,7 @@ ipcMain.handle('start-upload', (_event, payload) => { // At 500+ jobs JSON.stringify blew up the debug log with MB-sized lines // per start-upload and added noticeable delay — log counts only. + logMarker('BATCH START', { files: files.length, hosters: hosters.length, jobs: jobs.length }); debugLog(`start-upload: files=${files.length}, hosters=${hosters.length}, jobs=${jobs.length}`); const tasks = jobs.length > 0 @@ -1390,6 +1468,7 @@ ipcMain.handle('start-upload', (_event, payload) => { const _thisManager = uploadManager; uploadManager.on('batch-done', async (summary) => { debugLog(`batch-done: total=${summary.total} ok=${summary.succeeded} fail=${summary.failed}`); + logMarker('BATCH END', { total: summary.total, ok: summary.succeeded, fail: summary.failed }); logMemorySnapshot('batch-done'); try { await configStore.appendHistory(summary); } catch (err) { debugLog(`appendHistory failed: ${err.message}`); @@ -1502,6 +1581,102 @@ ipcMain.handle('get-job-log', (_event, jobId) => { return Array.isArray(arr) ? arr.slice() : []; }); +ipcMain.handle('get-log-paths', () => { + return getAllLogPaths(); +}); + +ipcMain.handle('get-app-info', () => { + return { + name: app.getName(), + version: app.getVersion(), + electron: process.versions.electron, + node: process.versions.node, + chrome: process.versions.chrome, + platform: process.platform, + arch: process.arch, + osRelease: require('os').release(), + pid: process.pid, + isPackaged: app.isPackaged, + logVerbose: _logVerbose + }; +}); + +ipcMain.handle('reveal-log-file', async (_event, target) => { + const { shell } = require('electron'); + const paths = getAllLogPaths(); + const file = (target && typeof target === 'string' && paths[target]) || null; + try { + if (file && fs.existsSync(file)) { + shell.showItemInFolder(file); + return { ok: true, path: file }; + } + const dir = paths.logDir; + if (dir) { + fs.mkdirSync(dir, { recursive: true }); + shell.openPath(dir); + return { ok: true, path: dir }; + } + return { ok: false, error: 'Kein Log-Pfad gefunden' }; + } catch (err) { + return { ok: false, error: err.message }; + } +}); + +ipcMain.handle('set-log-verbose', (_event, enabled) => { + setLogVerbose(enabled); + logMarker('VERBOSE TOGGLE', { enabled: _logVerbose }); + return { ok: true, verbose: _logVerbose }; +}); + +ipcMain.handle('create-support-bundle', async () => { + const { dialog } = require('electron'); + try { + if (_debugLogBuffer.length) { + try { fs.appendFileSync(getDebugLogPath(), _debugLogBuffer.join(''), 'utf-8'); _debugLogBuffer.length = 0; } catch {} + } + const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); + const defaultName = `multi-hoster-support-${stamp}.txt`; + const desktop = (() => { try { return app.getPath('desktop'); } catch { return app.getPath('userData'); } })(); + const res = await dialog.showSaveDialog(mainWindow || undefined, { + title: 'Diagnose-Paket speichern', + defaultPath: path.join(desktop, defaultName), + filters: [{ name: 'Text', extensions: ['txt'] }] + }); + if (res.canceled || !res.filePath) return { ok: false, canceled: true }; + const paths = getAllLogPaths(); + const cfg = configStore.load(); + const text = buildSupportBundleText({ + header: { + App: app.getName(), + Version: app.getVersion(), + Electron: process.versions.electron, + Node: process.versions.node, + Chrome: process.versions.chrome, + Platform: process.platform, + Arch: process.arch, + OS: `${require('os').type()} ${require('os').release()}`, + Packaged: app.isPackaged, + Verbose: _logVerbose, + PID: process.pid, + CreatedAt: new Date().toISOString() + }, + sanitizedConfig: sanitizeConfig(cfg), + files: [ + { label: 'debug.log (last 5 MB)', path: paths.debug, maxBytes: 5 * 1024 * 1024 }, + { label: 'account-rotation.log (last 2 MB)', path: paths.accountRotation, maxBytes: 2 * 1024 * 1024 }, + { label: 'doodstream-debug.log (last 2 MB)', path: paths.doodstreamDebug, maxBytes: 2 * 1024 * 1024 }, + { label: 'fileuploader.log (last 1 MB)', path: paths.fileuploader, maxBytes: 1 * 1024 * 1024 } + ] + }); + fs.writeFileSync(res.filePath, text, 'utf-8'); + logMarker('SUPPORT BUNDLE', { path: res.filePath, bytes: text.length }); + return { ok: true, path: res.filePath, bytes: text.length }; + } catch (err) { + debugLog(`create-support-bundle failed: ${err.message}`); + return { ok: false, error: err.message }; + } +}); + ipcMain.handle('open-log-folder', async () => { // Reveal the active log file (or its directory) in the OS file manager. // Prefers the configured log path, then the rotation log, then just the diff --git a/preload.js b/preload.js index ed16a3b..8eb10a6 100644 --- a/preload.js +++ b/preload.js @@ -110,6 +110,11 @@ contextBridge.exposeInMainWorld('api', { }, openLogFolder: () => ipcRenderer.invoke('open-log-folder'), getJobLog: (jobId) => ipcRenderer.invoke('get-job-log', jobId), + getLogPaths: () => ipcRenderer.invoke('get-log-paths'), + revealLogFile: (target) => ipcRenderer.invoke('reveal-log-file', target), + setLogVerbose: (enabled) => ipcRenderer.invoke('set-log-verbose', enabled), + createSupportBundle: () => ipcRenderer.invoke('create-support-bundle'), + getAppInfo: () => ipcRenderer.invoke('get-app-info'), onLogPathAutoUpdated: (callback) => { ipcRenderer.on('log-path-auto-updated', (_event, data) => callback(data)); }, diff --git a/renderer/app.js b/renderer/app.js index 49eea35..a2b112a 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -2480,6 +2480,36 @@ async function runHealthCheck(mode = 'manual', requestedHosters = null) { } // --- Settings --- +async function _renderLogPathsList(el) { + if (!el || !window.api || !window.api.getLogPaths) return; + try { + const paths = await window.api.getLogPaths(); + if (!paths || typeof paths !== 'object') { el.innerHTML = 'Pfade nicht verfügbar.'; return; } + const entries = [ + ['fileuploader', 'fileuploader.log'], + ['debug', 'debug.log'], + ['accountRotation', 'account-rotation.log'], + ['doodstreamDebug', 'doodstream-debug.log'] + ]; + el.innerHTML = entries.map(([key, label]) => { + const p = paths[key] || ''; + return `
+ ${escapeHtml(label)} + ${escapeHtml(p) || ''} + +
`; + }).join(''); + el.querySelectorAll('[data-reveal-log]').forEach(btn => { + btn.addEventListener('click', () => { + const target = btn.getAttribute('data-reveal-log'); + if (window.api && window.api.revealLogFile) window.api.revealLogFile(target).catch(() => {}); + }); + }); + } catch (err) { + el.innerHTML = `Fehler: ${escapeHtml(err.message || String(err))}`; + } +} + function renderSettings() { const container = document.getElementById('settingsHosters'); container.innerHTML = ''; @@ -2550,9 +2580,59 @@ function renderSettings() { Pro Session = neue Datei bei jedem App-Start; nach komplettem Schließen + erneutem Öffnen beginnt eine neue Session. +
+ + +
+ +
+ +
+ Wird geladen… +
+
+
+ + + Eine .txt mit Logs + sanitierter Config; Passwörter/API-Keys werden vor dem Speichern maskiert. +
`; container.appendChild(generalPanel); + _renderLogPathsList(generalPanel.querySelector('#logPathsList')); + const verboseInput = generalPanel.querySelector('#logVerboseInput'); + if (verboseInput) { + verboseInput.addEventListener('change', () => { + if (window.api && window.api.setLogVerbose) window.api.setLogVerbose(verboseInput.checked).catch(() => {}); + }); + } + const sbBtn = generalPanel.querySelector('#createSupportBundleBtn'); + if (sbBtn) { + sbBtn.addEventListener('click', async () => { + const hint = generalPanel.querySelector('#supportBundleHint'); + sbBtn.disabled = true; + const prevText = sbBtn.textContent; + sbBtn.textContent = 'Exportiere…'; + try { + const res = await window.api.createSupportBundle(); + if (res && res.ok) { + if (hint) hint.textContent = `Gespeichert: ${res.path} (${(res.bytes/1024).toFixed(1)} KB)`; + } else if (res && res.canceled) { + if (hint) hint.textContent = 'Abgebrochen.'; + } else { + if (hint) hint.textContent = `Fehler: ${(res && res.error) || 'unbekannt'}`; + } + } catch (err) { + if (hint) hint.textContent = `Fehler: ${err.message || err}`; + } finally { + sbBtn.disabled = false; + sbBtn.textContent = prevText; + } + }); + } // Toggle general panel generalPanel.querySelector('.hoster-panel-header').addEventListener('click', () => { diff --git a/tests/support-bundle.test.js b/tests/support-bundle.test.js new file mode 100644 index 0000000..8745301 --- /dev/null +++ b/tests/support-bundle.test.js @@ -0,0 +1,94 @@ +const test = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { sanitizeConfig, collectFile, buildSupportBundleText, REDACTED } = require('../lib/support-bundle'); + +test('sanitizeConfig redacts known credential keys at any nesting depth', () => { + const input = { + hosters: { + 'voe.sx': [{ username: 'u', password: 'p1', apiKey: 'k1', enabled: true }], + 'byse.sx': [{ apiKey: 'k2' }, { apiKey: 'k3', token: 't1', label: 'main' }] + }, + globalSettings: { remote: { token: 'remT' }, scramble: { active: false } } + }; + const out = sanitizeConfig(input); + assert.strictEqual(out.hosters['voe.sx'][0].password, REDACTED); + assert.strictEqual(out.hosters['voe.sx'][0].apiKey, REDACTED); + assert.strictEqual(out.hosters['voe.sx'][0].username, 'u'); + assert.strictEqual(out.hosters['voe.sx'][0].enabled, true); + assert.strictEqual(out.hosters['byse.sx'][1].apiKey, REDACTED); + assert.strictEqual(out.hosters['byse.sx'][1].token, REDACTED); + assert.strictEqual(out.hosters['byse.sx'][1].label, 'main'); + assert.strictEqual(out.globalSettings.remote.token, REDACTED); +}); + +test('sanitizeConfig does not mutate input', () => { + const input = { hosters: { 'voe.sx': [{ password: 'secret' }] } }; + const clone = JSON.parse(JSON.stringify(input)); + sanitizeConfig(input); + assert.deepStrictEqual(input, clone); +}); + +test('sanitizeConfig leaves empty/missing credentials alone', () => { + const input = { hosters: { 'voe.sx': [{ password: '', apiKey: null }] } }; + const out = sanitizeConfig(input); + assert.strictEqual(out.hosters['voe.sx'][0].password, ''); + assert.strictEqual(out.hosters['voe.sx'][0].apiKey, null); +}); + +test('sanitizeConfig handles null/undefined input', () => { + assert.strictEqual(sanitizeConfig(null), null); + assert.strictEqual(sanitizeConfig(undefined), undefined); +}); + +test('collectFile tails when file exceeds maxBytes', () => { + const tmp = path.join(os.tmpdir(), `mhu-bundle-${Date.now()}.log`); + const bigLine = 'x'.repeat(1000) + '\n'; + fs.writeFileSync(tmp, bigLine.repeat(100)); + try { + const section = collectFile(tmp, 'big.log', 5000); + assert.match(section, /truncated: skipped first \d+ bytes/); + assert.ok(section.length < bigLine.length * 100, 'section should be truncated'); + } finally { + fs.unlinkSync(tmp); + } +}); + +test('collectFile returns placeholder for missing file', () => { + const section = collectFile(path.join(os.tmpdir(), `does-not-exist-${Date.now()}.log`), 'missing'); + assert.match(section, //); +}); + +test('collectFile returns placeholder for null path', () => { + const section = collectFile(null, 'no-path'); + assert.match(section, //); +}); + +test('buildSupportBundleText produces structured output with header + config + file sections', () => { + const tmp = path.join(os.tmpdir(), `mhu-bundle-text-${Date.now()}.log`); + fs.writeFileSync(tmp, 'line one\nline two\n'); + try { + const text = buildSupportBundleText({ + header: { Version: '3.3.41', Platform: 'win32' }, + sanitizedConfig: { hosters: { 'voe.sx': [{ apiKey: '' }] } }, + files: [{ label: 'debug.log', path: tmp }] + }); + assert.match(text, /^=== Multi-Hoster-Upload Support Bundle ===/); + assert.match(text, /Version: 3\.3\.41/); + assert.match(text, /Platform: win32/); + assert.match(text, /=== Config \(sanitized/); + assert.match(text, /"apiKey": ""/); + assert.match(text, /=== debug\.log/); + assert.match(text, /line one\nline two/); + } finally { + fs.unlinkSync(tmp); + } +}); + +test('buildSupportBundleText handles empty file list and missing header', () => { + const text = buildSupportBundleText({ sanitizedConfig: {}, files: [] }); + assert.match(text, /=== Multi-Hoster-Upload Support Bundle ===/); + assert.match(text, /=== Config/); +});