From bfb3a39fed85e78e9cac9a8aa0deaa3c2252095f Mon Sep 17 00:00:00 2001 From: Sucukdeluxe <259325684+Sucukdeluxe@users.noreply.github.com> Date: Thu, 13 Aug 2026 14:31:46 +0200 Subject: [PATCH] fix: harden upload cancellation and audit logging Preserve pre-start and batch cancellation requests, reject late upload success after cancellation, and wait for cancellation acknowledgements before removing queue entries. Separate formatted link logs from privacy-safe source cleanup and upload plan audits, persist audit fallback paths, redact support bundles, and expose audit diagnostics safely. Improve queue selection and destructive-action clarity, show the Settings save action only while changes are pending, and add regression coverage for all updated behavior. --- README.md | 3 +- lib/diagnostics-collectors.js | 14 ++- lib/log-rotation.js | 24 +++-- lib/support-bundle.js | 20 ++-- lib/upload-audit.js | 58 ++++++++++ lib/upload-log.js | 47 +++++++- lib/upload-manager.js | 33 +++++- main.js | 117 ++++++++++++-------- package-lock.json | 4 +- package.json | 2 +- renderer/app.js | 53 ++++++--- renderer/index.html | 3 +- renderer/styles.css | 32 +++++- scripts/verify-public-release.mjs | 2 + tests/diagnostics-collectors.test.js | 17 +++ tests/support-bundle.test.js | 47 ++++++++ tests/ui-smoke.js | 79 ++++++++++++-- tests/upload-audit.test.js | 64 +++++++++++ tests/upload-log.test.js | 60 ++++++++++- tests/upload-manager.test.js | 155 +++++++++++++++++++++++++++ 20 files changed, 733 insertions(+), 101 deletions(-) create mode 100644 lib/upload-audit.js create mode 100644 tests/upload-audit.test.js diff --git a/README.md b/README.md index e9df568..4be6255 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Multi-Hoster-Upload is a Windows desktop application for sending file batches to Download the current Setup or Portable build from the [latest GitHub release](https://github.com/Sucukdeluxe/Multi-Hoster-Upload/releases/latest). -The latest public release is version 2.1.18. Use the release page for the executables and the full English changelog. +The latest public release is version 2.1.19. Use the release page for the executables and the full English changelog. ## Features @@ -25,6 +25,7 @@ The latest public release is version 2.1.18. Use the release page for the execut - Follow current upload speed in the sidebar and the synchronized header graph. - Reorder selected jobs, start selected jobs, retry finished jobs, or stop active work. - Copy completed links individually or together. +- Keep formatted link logs separate from source-cleanup and upload-plan audit records. ### Accounts and automation diff --git a/lib/diagnostics-collectors.js b/lib/diagnostics-collectors.js index dcfaf23..41f890d 100644 --- a/lib/diagnostics-collectors.js +++ b/lib/diagnostics-collectors.js @@ -1,9 +1,11 @@ const fs = require('fs'); const path = require('path'); +const { getRotatedLogPath } = require('./log-rotation'); const READABLE_LOGS = { debug: 'debug', fileuploader: 'fileuploader', + uploadAudit: 'uploadAudit', accountRotation: 'accountRotation', crash: 'crashLog' }; @@ -38,7 +40,7 @@ function createCollectors(deps) { const paths = getAllLogPaths(); let p = paths[key]; if (!p) return null; - if (backup === 1 || backup === 2) p = `${p}.${backup}`; + if (backup === 1 || backup === 2) p = getRotatedLogPath(p, backup); return p; } @@ -67,15 +69,17 @@ function createCollectors(deps) { const paths = getAllLogPaths(); const dir = paths.logDir; const files = []; + const readableNames = new Set(); for (const [name, key] of Object.entries(READABLE_LOGS)) { const base = paths[key]; if (!base) continue; const variants = []; - for (const suffix of ['', '.1', '.2']) { - const fp = base + suffix; + for (const backup of [0, 1, 2]) { + const fp = backup === 0 ? base : getRotatedLogPath(base, backup); + readableNames.add(path.basename(fp)); try { const st = fs.statSync(fp); - variants.push({ backup: suffix === '' ? 0 : Number(suffix.slice(1)), sizeBytes: st.size, mtime: st.mtime.toISOString() }); + variants.push({ backup, sizeBytes: st.size, mtime: st.mtime.toISOString() }); } catch {} } files.push({ name, path: base, readable: true, present: variants.length > 0, variants }); @@ -84,7 +88,7 @@ function createCollectors(deps) { try { siblings = fs.readdirSync(dir) .filter(f => /\.log(\.\d+)?$/i.test(f)) - .filter(f => !files.some(x => path.basename(x.path) === f || f.startsWith(path.basename(x.path)))); + .filter(f => !readableNames.has(f)); siblings = siblings.map(f => { let size = 0, mtime = null; try { const st = fs.statSync(path.join(dir, f)); size = st.size; mtime = st.mtime.toISOString(); } catch {} diff --git a/lib/log-rotation.js b/lib/log-rotation.js index 0242848..a1e0b7d 100644 --- a/lib/log-rotation.js +++ b/lib/log-rotation.js @@ -14,6 +14,14 @@ const fs = require('fs'); const path = require('path'); +function getRotatedLogPath(filePath, backup) { + const index = Number(backup); + if (!Number.isInteger(index) || index < 1) return filePath; + const ext = path.extname(filePath); + const base = filePath.slice(0, filePath.length - ext.length); + return `${base}.${index}${ext}`; +} + function maybeRotateLogFile(filePath, maxBytes, maxBackups = 3, log = () => {}) { if (!filePath || !Number.isFinite(maxBytes) || maxBytes <= 0) return false; let size = 0; @@ -29,24 +37,22 @@ function maybeRotateLogFile(filePath, maxBytes, maxBackups = 3, log = () => {}) } if (size <= maxBytes) return false; - const ext = path.extname(filePath); - const base = filePath.slice(0, filePath.length - ext.length); - // Drop the oldest backup if it exists, then shift each numbered backup up // one slot. Errors are ignored: missing intermediate backups are normal, // failed renames just mean we'll rotate again next time. - try { fs.unlinkSync(`${base}.${maxBackups}${ext}`); } catch {} + try { fs.unlinkSync(getRotatedLogPath(filePath, maxBackups)); } catch {} for (let i = maxBackups - 1; i >= 1; i--) { - try { fs.renameSync(`${base}.${i}${ext}`, `${base}.${i + 1}${ext}`); } catch {} + try { fs.renameSync(getRotatedLogPath(filePath, i), getRotatedLogPath(filePath, i + 1)); } catch {} } try { - fs.renameSync(filePath, `${base}.1${ext}`); - log(`logRotation: rotated ${filePath} (${(size / 1024 / 1024).toFixed(1)} MB) → ${base}.1${ext}`); + const backupPath = getRotatedLogPath(filePath, 1); + fs.renameSync(filePath, backupPath); + log(`logRotation: rotated ${filePath} (${(size / 1024 / 1024).toFixed(1)} MB) → ${backupPath}`); return true; } catch (err) { - log(`logRotation: rename ${filePath} → ${base}.1${ext} failed: ${err.message}`); + log(`logRotation: rename ${filePath} → ${getRotatedLogPath(filePath, 1)} failed: ${err.message}`); return false; } } -module.exports = { maybeRotateLogFile }; +module.exports = { getRotatedLogPath, maybeRotateLogFile }; diff --git a/lib/support-bundle.js b/lib/support-bundle.js index b246f7a..468b7f3 100644 --- a/lib/support-bundle.js +++ b/lib/support-bundle.js @@ -42,6 +42,9 @@ function redactLogText(text, secrets) { } } out = out + .replace(/("(?:file|fileName|stagedFile|sourceFile|targetFile|path|[A-Za-z0-9_]*Path)"\s*:\s*")[^"]*(")/gi, '$1$2') + .replace(/\b[A-Za-z]:(?:\\+|\/+)[^\r\n"'<>|]*?(?=\s+(?:trigger|error|outcome|hoster|attempt|status|code)=|\r?\n|$|["'])/gi, '') + .replace(/\\{2,}[A-Za-z0-9._$-]+\\+[^\r\n"'<>|]*?(?=\s+(?:trigger|error|outcome|hoster|attempt|status|code)=|\r?\n|$|["'])/g, '') .replace(/https?:\/\/(?:ptb\.|canary\.)?discord(?:app)?\.com\/api\/webhooks\/\d+\/[\w-]+/gi, 'https://discord.com/api/webhooks/' + REDACTED) .replace(/(\/\/[^\s/:@]+:)[^\s/@]+(@)/g, '$1' + REDACTED + '$2') .replace(/(authorization:\s*(?:bearer|basic)\s+)\S+/gi, '$1' + REDACTED) @@ -66,13 +69,15 @@ function valueScrub(value, secrets) { return JSON.parse(scrubbed); } -function collectFile(filePath, label, maxBytes) { +function collectFile(filePath, label, maxBytes, options) { + const includePath = !options || options.includePath !== false; 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 context = includePath ? ` (${filePath})` : ''; + if (err && err.code === 'ENOENT') return `=== ${label}${context} ===\n\n\n`; + return `=== ${label}${context} ===\n\n\n`; } const cap = Number.isFinite(maxBytes) && maxBytes > 0 ? maxBytes : 5 * 1024 * 1024; let content; @@ -90,10 +95,11 @@ function collectFile(filePath, label, maxBytes) { } catch (err) { content = ``; } - return `=== ${label} (${filePath}, size=${stat.size} bytes) ===\n${content}\n\n`; + const metadata = includePath ? `${filePath}, size=${stat.size} bytes` : `size=${stat.size} bytes`; + return `=== ${label} (${metadata}) ===\n${content}\n\n`; } -function buildSupportBundleText({ header, sanitizedConfig, files }) { +function buildSupportBundleText({ header, sanitizedConfig, files, secrets }) { const parts = []; parts.push('=== Multi-Hoster-Upload Support Bundle ===\n'); if (header && typeof header === 'object') { @@ -101,10 +107,10 @@ function buildSupportBundleText({ header, sanitizedConfig, files }) { } parts.push('\n'); parts.push('=== Config (sanitized — password/apiKey/token/cookie/sessionId redacted) ===\n'); - parts.push(JSON.stringify(sanitizedConfig, null, 2)); + parts.push(redactLogText(JSON.stringify(sanitizedConfig, null, 2), secrets)); parts.push('\n\n'); for (const f of (files || [])) { - parts.push(collectFile(f.path, f.label || f.path, f.maxBytes)); + parts.push(redactLogText(collectFile(f.path, f.label || f.path, f.maxBytes, { includePath: false }), secrets)); } return parts.join(''); } diff --git a/lib/upload-audit.js b/lib/upload-audit.js new file mode 100644 index 0000000..7d96d38 --- /dev/null +++ b/lib/upload-audit.js @@ -0,0 +1,58 @@ +const nodePath = require('path'); + +function getUploadAuditLogPath(uploadLogPath, pathApi = nodePath) { + if (typeof uploadLogPath !== 'string' || !uploadLogPath.trim()) return null; + return pathApi.join(pathApi.dirname(uploadLogPath), 'upload-audit.log'); +} + +function createUploadAuditWriter(options) { + const source = options && typeof options === 'object' ? options : {}; + const fs = source.fs; + const path = source.path || nodePath; + const resolveUploadLogTarget = source.resolveUploadLogTarget; + const rotateLogFile = typeof source.rotateLogFile === 'function' ? source.rotateLogFile : () => {}; + const invalidateUploadLogTarget = typeof source.invalidateUploadLogTarget === 'function' ? source.invalidateUploadLogTarget : () => {}; + const persistFallbackLogPath = typeof source.persistFallbackLogPath === 'function' ? source.persistFallbackLogPath : async () => {}; + const reportError = typeof source.reportError === 'function' ? source.reportError : () => {}; + const retryDelays = Array.isArray(source.retryDelays) && source.retryDelays.length > 0 ? source.retryDelays : [0, 100, 250]; + const maxBytes = Number.isFinite(source.maxBytes) ? source.maxBytes : 10 * 1024 * 1024; + const maxBackups = Number.isFinite(source.maxBackups) ? source.maxBackups : 2; + let activePath = null; + + if (!fs || !fs.promises || typeof fs.promises.appendFile !== 'function' || typeof resolveUploadLogTarget !== 'function') { + throw new TypeError('createUploadAuditWriter requires fs and resolveUploadLogTarget'); + } + + async function append(line, label) { + let excludedPath = null; + for (const delay of retryDelays) { + if (delay) await new Promise(resolve => setTimeout(resolve, delay)); + const uploadTarget = resolveUploadLogTarget(excludedPath); + const targetPath = uploadTarget && getUploadAuditLogPath(uploadTarget.path, path); + if (!targetPath) continue; + try { + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + rotateLogFile(targetPath, maxBytes, maxBackups); + await fs.promises.appendFile(targetPath, line, 'utf-8'); + activePath = targetPath; + if (uploadTarget.isFallback) { + try { + await persistFallbackLogPath(uploadTarget.path); + } catch (error) { + reportError('audit-fallback-persist', error); + } + } + return true; + } catch (error) { + excludedPath = uploadTarget.path; + invalidateUploadLogTarget(); + reportError(label, error); + } + } + return false; + } + + return { append, getActivePath: () => activePath }; +} + +module.exports = { getUploadAuditLogPath, createUploadAuditWriter }; diff --git a/lib/upload-log.js b/lib/upload-log.js index 6025791..3f01ee9 100644 --- a/lib/upload-log.js +++ b/lib/upload-log.js @@ -28,7 +28,52 @@ return { hoster, fileName, ts }; } - const api = { formatUploadLogLine, parseUploadLogLine }; + function summarizeBatchPlan(payload) { + const source = payload && typeof payload === 'object' ? payload : {}; + const jobs = Array.isArray(source.jobs) ? source.jobs : []; + if (jobs.length > 0) { + const files = new Set(); + const destinations = new Set(); + let plannedUploadCount = 0; + for (const job of jobs) { + if (!job || typeof job !== 'object') continue; + const file = typeof job.file === 'string' ? job.file.trim() : ''; + const hoster = typeof job.hoster === 'string' ? job.hoster.trim() : ''; + if (!file || !hoster) continue; + files.add(file); + destinations.add(hoster); + plannedUploadCount++; + } + return { + fileCount: files.size, + destinationCount: destinations.size, + plannedUploadCount + }; + } + + const files = new Set((Array.isArray(source.files) ? source.files : []).filter(value => typeof value === 'string' && value.trim())); + const destinations = new Set((Array.isArray(source.hosters) ? source.hosters : []).filter(value => typeof value === 'string' && value.trim())); + return { + fileCount: files.size, + destinationCount: destinations.size, + plannedUploadCount: files.size * destinations.size + }; + } + + function formatUploadPlanLogLine(date, plan, mode) { + const inputDate = date instanceof Date && !Number.isNaN(date.getTime()) ? date : new Date(); + const source = plan && typeof plan === 'object' ? plan : {}; + const count = value => Number.isFinite(Number(value)) ? Math.max(0, Math.floor(Number(value))) : 0; + return `# UPLOAD-PLAN ${JSON.stringify({ + timestamp: inputDate.toISOString(), + mode: mode === 'add' ? 'add' : 'start', + fileCount: count(source.fileCount), + destinationCount: count(source.destinationCount), + plannedUploadCount: count(source.plannedUploadCount) + })}\r\n`; + } + + const api = { formatUploadLogLine, parseUploadLogLine, summarizeBatchPlan, formatUploadPlanLogLine }; if (typeof module !== 'undefined' && module.exports) module.exports = api; else if (root) root.UploadLog = api; })(typeof window !== 'undefined' ? window : this); diff --git a/lib/upload-manager.js b/lib/upload-manager.js index c1a9fc8..ee2b367 100644 --- a/lib/upload-manager.js +++ b/lib/upload-manager.js @@ -39,6 +39,8 @@ class UploadManager extends EventEmitter { this.activeJobs = new Map(); // uploadId -> { jobId, speedKbs, bytesUploaded, hoster } this.jobAbortControllers = new Map(); // jobId -> AbortController this.cancelledJobIds = new Set(); + this.pendingCancelledJobIds = new Set(); + this.pendingCancelAll = false; this.sessionBytes = 0; this._transientErrorTotal = 0; this.lastStartTime = {}; // hoster -> timestamp of last upload start @@ -330,14 +332,20 @@ class UploadManager extends EventEmitter { } async startBatch(tasks, opts = {}) { + const pendingCancelledJobIds = new Set(this.pendingCancelledJobIds); + const pendingCancelAll = this.pendingCancelAll; + this.pendingCancelledJobIds.clear(); + this.pendingCancelAll = false; this.running = true; - this.stopAfterActive = false; + this.stopAfterActive = pendingCancelAll; this.abortController = new AbortController(); + if (pendingCancelAll) this.abortController.abort(); this.startTime = Date.now(); this.sessionBytes = 0; this.activeJobs.clear(); this.jobAbortControllers.clear(); this.cancelledJobIds.clear(); + for (const jobId of pendingCancelledJobIds) this.cancelledJobIds.add(jobId); this._doodApiKeyCache.clear(); // re-derive doodstream keys fresh each batch this._baselineCache.clear(); // re-fetch baselines per batch (a long batch could outlast remote-side relevance) this.semaphores = {}; @@ -447,6 +455,7 @@ class UploadManager extends EventEmitter { const maxAttempts = Math.max(1, (settings.retries || 0) + 1); const jobAbortController = new AbortController(); + if (this.cancelledJobIds.has(jobId)) jobAbortController.abort(); const { signal, cleanup: cleanupSignals } = this._combineSignals(batchSignal, jobAbortController.signal); this.jobAbortControllers.set(jobId, jobAbortController); @@ -500,6 +509,12 @@ class UploadManager extends EventEmitter { }; try { + if (signal.aborted || this.cancelledJobIds.has(jobId)) { + const error = 'Abgebrochen'; + emitFinalStatus('aborted', { error, attempt: 0 }); + recordFinalResult('aborted', { error }); + return; + } if (fileNotFound) { const error = 'Datei nicht gefunden'; emitFinalStatus('skipped', { error, attempt: 0 }); @@ -714,6 +729,8 @@ class UploadManager extends EventEmitter { const result = await this._executeUpload(task, progressCb, uploadSignalBundle.signal, throttle, fileProbe); + if (signal.aborted || this.cancelledJobIds.has(jobId)) throw new Error('Aborted'); + const elapsed = Math.round((Date.now() - jobStart) / 1000); this.sessionBytes += fileSize; this.activeJobs.delete(uploadId); @@ -847,6 +864,12 @@ class UploadManager extends EventEmitter { this._noteSuspectReject(task.hoster, task.accountId, fileSize); const alt = await this._trySuspectRejectAlternates(task, { uploadId, jobId, fileName, fileSize, settings, signal, fileProbe }); if (alt) { + if (signal.aborted || this.cancelledJobIds.has(jobId)) { + const error = 'Abgebrochen'; + emitFinalStatus('aborted', { error }); + recordFinalResult('aborted', { error }); + return; + } emitFinalStatus('done', { result: alt.result, speedKbs: alt.speedKbs, elapsed: alt.elapsed, attempt: 1 }); recordFinalResult('done', { result: alt.result }); return; @@ -1017,6 +1040,7 @@ class UploadManager extends EventEmitter { : hosterThrottle || globalThrottle; const result = await this._executeUpload(task, progressCb, signal, throttle, fileProbe); + if (signal.aborted || this.cancelledJobIds.has(jobId)) throw new Error('Aborted'); this.activeJobs.delete(uploadId); this.sessionBytes += fileSize; emitFinalStatus('done', { result, speedKbs: currentSpeedKbs, elapsed: Math.round((Date.now() - jobStart) / 1000), attempt }); @@ -1159,6 +1183,7 @@ class UploadManager extends EventEmitter { : hosterThrottle || globalThrottle; try { const result = await this._executeUpload(task, progressCb, signal, throttle, fileProbe); + if (signal.aborted || this.cancelledJobIds.has(jobId)) throw new Error('Aborted'); this.activeJobs.delete(uploadId); this.sessionBytes += fileSize; this._suspectGoodAccounts.set(task.hoster, account.id); @@ -1442,6 +1467,7 @@ class UploadManager extends EventEmitter { cancelJobs(jobIds) { for (const jobId of jobIds || []) { if (!jobId) continue; + if (!this.running) this.pendingCancelledJobIds.add(jobId); this.cancelledJobIds.add(jobId); const controller = this.jobAbortControllers.get(jobId); if (controller && !controller.signal.aborted) { @@ -1455,7 +1481,10 @@ class UploadManager extends EventEmitter { } cancel() { - if (!this.running) return; + if (!this.running) { + this.pendingCancelAll = true; + return; + } this.abortController.abort(); this.stopAfterActive = true; for (const controller of this.jobAbortControllers.values()) { diff --git a/main.js b/main.js index 6c9b6f6..f67c50d 100644 --- a/main.js +++ b/main.js @@ -27,7 +27,8 @@ const { walkFolderAsync } = require('./lib/file-discovery'); const RemoteServer = require('./lib/remote-server'); const { maybeRotateLogFile } = require('./lib/log-rotation'); const { hosterLogToFileEnabled } = require('./lib/log-policy'); -const { formatUploadLogLine, parseUploadLogLine } = require('./lib/upload-log'); +const { formatUploadLogLine, parseUploadLogLine, summarizeBatchPlan, formatUploadPlanLogLine } = require('./lib/upload-log'); +const { getUploadAuditLogPath, createUploadAuditWriter } = require('./lib/upload-audit'); const { selectOrphanTmps } = require('./lib/orphan-tmp'); const { sanitizeConfig, buildSupportBundleText, collectSecretValues, redactLogText, valueScrub, collectFile, REDACTED } = require('./lib/support-bundle'); const { buildWebhookRequest, isAllAborted } = require('./lib/webhook-notify'); @@ -495,12 +496,15 @@ function _flushRotLog() { } function getAllLogPaths() { - const upload = getLogFilePath(); + const configuredUpload = getLogFilePath(); + const uploadTarget = _resolveUploadLogTarget(); + const upload = uploadTarget ? uploadTarget.path : configuredUpload; const debugPath = getDebugLogPath(); const rot = getRotLogPath(); const dir = path.dirname(debugPath); return { fileuploader: upload, + uploadAudit: _uploadAuditWriter.getActivePath() || getUploadAuditLogPath(upload), debug: debugPath, accountRotation: rot, doodstreamDebug: path.join(dir, 'doodstream-debug.log'), @@ -774,13 +778,13 @@ function _invalidateUploadLogTargetCache() { _cachedUploadLogKey = ''; } -function _resolveUploadLogTarget() { +function _resolveUploadLogTarget(excludedPath) { const primary = getLogFilePath(); // The primary path already encodes the mode + date/session, so it changes // when the user toggles mode, daily rolls at midnight, or this is a new // process — cache invalidates naturally on path change. const key = primary; - if (_cachedUploadLogKey === key && _cachedUploadLogTarget) return _cachedUploadLogTarget; + if (_cachedUploadLogKey === key && _cachedUploadLogTarget && _cachedUploadLogTarget.path !== excludedPath) return _cachedUploadLogTarget; const commit = (t) => { _cachedUploadLogTarget = t; @@ -789,22 +793,27 @@ function _resolveUploadLogTarget() { }; // Try primary → desktop → userData, mirror the original fallback ladder. - try { - fs.mkdirSync(path.dirname(primary), { recursive: true }); - return commit({ path: primary, isFallback: false }); - } catch (err) { - debugLog(`uploadLog primary dir unavailable (${err.message})`); + if (primary !== excludedPath) { + try { + fs.mkdirSync(path.dirname(primary), { recursive: true }); + return commit({ path: primary, isFallback: false }); + } catch (err) { + debugLog(`uploadLog primary dir unavailable (${err.message})`); + } } const desktop = getSafeDesktopDir(); if (desktop) { try { const p = buildFallbackLogName(desktop); - fs.mkdirSync(path.dirname(p), { recursive: true }); - return commit({ path: p, isFallback: true }); + if (p !== excludedPath) { + fs.mkdirSync(path.dirname(p), { recursive: true }); + return commit({ path: p, isFallback: true }); + } } catch {} } try { const p = buildFallbackLogName(app.getPath('userData')); + if (p === excludedPath) return null; fs.mkdirSync(path.dirname(p), { recursive: true }); return commit({ path: p, isFallback: true }); } catch (err) { @@ -818,6 +827,15 @@ function _resolveUploadLogTarget() { // the disk. 50 MB ≈ ~600k log lines, plenty for human inspection. const UPLOAD_LOG_MAX_BYTES = 50 * 1024 * 1024; const UPLOAD_LOG_MAX_BACKUPS = 3; +const _uploadAuditWriter = createUploadAuditWriter({ + fs, + path, + resolveUploadLogTarget: _resolveUploadLogTarget, + rotateLogFile: maybeRotateLogFile, + invalidateUploadLogTarget: _invalidateUploadLogTargetCache, + persistFallbackLogPath: _persistFallbackLogPath, + reportError: (label, error) => debugLog(`${label} audit append failed: ${error.message}`) +}); function _flushUploadLog() { if (_uploadLogWriting || _uploadLogBuffer.length === 0) return; @@ -862,9 +880,9 @@ function _flushUploadLog() { }); } -function _persistFallbackLogPath(workingPath) { +async function _persistFallbackLogPath(workingPath) { try { - if (!settingsImportGate.canStartUpload()) return; + if (!settingsImportGate.canStartUpload()) return false; const cfg = configStore.load(); const gs = cfg.globalSettings || {}; const mode = gs.logMode || 'single'; @@ -880,15 +898,17 @@ function _persistFallbackLogPath(workingPath) { const base = path.basename(workingPath); toSave = path.join(dir, stripModeStampFromFileName(base)); } - if (gs.logFilePath === toSave) return; + if (gs.logFilePath === toSave) return true; gs.logFilePath = toSave; cfg.globalSettings = gs; - configStore.save({ globalSettings: gs }).catch(() => {}); + await configStore.save({ globalSettings: gs }); _invalidateUploadLogTargetCache(); _invalidateLogSettings(); safeSend('log-path-auto-updated', { logFilePath: toSave }); + return true; } catch (err) { debugLog(`persist fallback logpath failed: ${err.message}`); + return false; } } @@ -916,25 +936,18 @@ function appendUploadLog(hoster, link, fileName) { } } +async function appendUploadAuditLine(line, label) { + return _uploadAuditWriter.append(line, label); +} + async function appendSourceCleanupAudit(event) { debugLog(`source-cleanup: ${event.outcome} ${event.file} trigger=${event.trigger || '-'}`); - const line = `# SOURCE-CLEANUP ${JSON.stringify(event)}\r\n`; - const delays = [0, 100, 250]; - for (const delay of delays) { - if (delay) await new Promise((resolve) => setTimeout(resolve, delay)); - const target = _resolveUploadLogTarget(); - if (!target) continue; - try { - fs.mkdirSync(path.dirname(target.path), { recursive: true }); - maybeRotateLogFile(target.path, UPLOAD_LOG_MAX_BYTES, UPLOAD_LOG_MAX_BACKUPS, debugLog); - await fs.promises.appendFile(target.path, line, 'utf-8'); - return true; - } catch (error) { - _invalidateUploadLogTargetCache(); - debugLog(`source-cleanup audit append failed: ${error.message}`); - } - } - return false; + return appendUploadAuditLine(`# SOURCE-CLEANUP ${JSON.stringify(event)}\r\n`, 'source-cleanup'); +} + +async function appendUploadPlanAudit(plan, mode) { + debugLog(`upload-plan: mode=${mode} files=${plan.fileCount} destinations=${plan.destinationCount} uploads=${plan.plannedUploadCount}`); + return appendUploadAuditLine(formatUploadPlanLogLine(new Date(), plan, mode), 'upload-plan'); } function flattenHistoryForExport(history) { @@ -2008,11 +2021,12 @@ ipcMain.handle('start-upload', async (_event, payload) => { const jobs = payload && Array.isArray(payload.jobs) ? payload.jobs : []; const isAutoRetry = !!(payload && payload.isAutoRetry); const sourceCleanupGroups = payload && Array.isArray(payload.sourceCleanupGroups) ? payload.sourceCleanupGroups : []; + const batchPlan = summarizeBatchPlan({ files, hosters, jobs }); // 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}`); + logMarker('BATCH START', batchPlan); + debugLog(`start-upload: files=${batchPlan.fileCount}, hosters=${batchPlan.destinationCount}, jobs=${batchPlan.plannedUploadCount}`); const pick = makeAccountPicker(config); const tasks = jobs.length > 0 @@ -2037,6 +2051,7 @@ ipcMain.handle('start-upload', async (_event, payload) => { debugLog(` tasks built: ${tasks.length}`); if (tasks.length === 0) { + await appendUploadPlanAudit(batchPlan, 'start'); const skippedSummary = stats.mergeSkippedIntoSummary({ id: `skipped-${Date.now()}`, timestamp: new Date().toISOString(), @@ -2053,6 +2068,12 @@ ipcMain.handle('start-upload', async (_event, payload) => { return { started: true, taskCount: 0, skippedJobs }; } + uploadManager = new UploadManager(config.hosterSettings || {}, config.globalSettings || {}, buildAccountPools(config)); + globalThis._mhuUploadManagerRef = uploadManager; + const _thisManager = uploadManager; + + await appendUploadPlanAudit(batchPlan, 'start'); + const recovery = { id: `upload-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`, startedAt: new Date().toISOString(), @@ -2085,10 +2106,6 @@ ipcMain.handle('start-upload', async (_event, payload) => { // new upload; addJobs during a running batch keeps them). _jobLogCollector.clear(); - // Pass hoster settings to the upload manager - uploadManager = new UploadManager(config.hosterSettings || {}, config.globalSettings || {}, buildAccountPools(config)); - globalThis._mhuUploadManagerRef = uploadManager; - const _thisManager = uploadManager; const sourceCleanup = createSourceFileCleanup({ fs, path, @@ -2101,8 +2118,10 @@ ipcMain.handle('start-upload', async (_event, payload) => { try { sourceCleanupFingerprints = await sourceCleanup.registerGroups(sourceCleanupGroups); } catch (error) { - uploadManager = null; - globalThis._mhuUploadManagerRef = null; + if (uploadManager === _thisManager) { + uploadManager = null; + globalThis._mhuUploadManagerRef = null; + } return { error: `Quelldatei-Schutz konnte nicht vorbereitet werden: ${error.message}` }; } for (const skipped of skippedJobs) sourceCleanup.markSkipped(skipped.jobId); @@ -2351,6 +2370,7 @@ ipcMain.handle('add-jobs-to-batch', async (_event, payload) => { if (!uploadManager || !uploadManager.running) { return { error: 'Kein Upload aktiv' }; } + const batchManager = uploadManager; const config = configStore.load(); const jobs = payload && Array.isArray(payload.jobs) ? payload.jobs : []; const sourceCleanupGroups = payload && Array.isArray(payload.sourceCleanupGroups) ? payload.sourceCleanupGroups : []; @@ -2361,19 +2381,23 @@ ipcMain.handle('add-jobs-to-batch', async (_event, payload) => { const skippedJobs = jobs .filter(j => j && j.id && !taskJobIds.has(j.id)) .map(j => ({ jobId: j.id, hoster: j.hoster, reason: 'Kein gültiger Account für diesen Hoster' })); - const sourceCleanupFingerprints = uploadManager.sourceFileCleanup - ? await uploadManager.sourceFileCleanup.registerGroups(sourceCleanupGroups) + const sourceCleanupFingerprints = batchManager.sourceFileCleanup + ? await batchManager.sourceFileCleanup.registerGroups(sourceCleanupGroups) : {}; - if (uploadManager.sourceFileCleanup) { - for (const skipped of skippedJobs) uploadManager.sourceFileCleanup.markSkipped(skipped.jobId); + if (uploadManager !== batchManager || !batchManager.running) { + return { error: 'Kein Upload aktiv' }; + } + if (batchManager.sourceFileCleanup) { + for (const skipped of skippedJobs) batchManager.sourceFileCleanup.markSkipped(skipped.jobId); } if (tasks.length === 0) { debugLog(`add-jobs-to-batch: 0 tasks built (${skippedJobs.length} skipped: no account)`); + if (jobs.length > 0) await appendUploadPlanAudit(summarizeBatchPlan({ jobs }), 'add'); return { added: 0, skippedJobs, alreadyInBatchJobIds: [], sourceCleanupFingerprints }; } - const addResult = uploadManager.addJobs(tasks); + const addResult = batchManager.addJobs(tasks); const added = typeof addResult === 'number' ? addResult : (addResult && addResult.added) || 0; const alreadyInBatchJobIds = (addResult && Array.isArray(addResult.alreadyInBatchJobIds)) ? addResult.alreadyInBatchJobIds @@ -2382,6 +2406,7 @@ ipcMain.handle('add-jobs-to-batch', async (_event, payload) => { debugLog( `add-jobs-to-batch: ${added} of ${tasks.length} tasks added (${alreadyInBatchJobIds.length} already in batch, ${skippedJobs.length} skipped)` ); + if (jobs.length > 0) await appendUploadPlanAudit(summarizeBatchPlan({ jobs }), 'add'); return { added, skippedJobs, alreadyInBatchJobIds, sourceCleanupFingerprints }; }); @@ -2534,11 +2559,13 @@ ipcMain.handle('create-support-bundle', async () => { CreatedAt: new Date().toISOString() }, sanitizedConfig: sanitizeConfig(cfg), + secrets: collectSecretValues(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: 'crash.log', path: path.join(paths.logDir || path.dirname(paths.debug), 'crash.log'), maxBytes: 1 * 1024 * 1024 }, + { label: 'upload-audit.log (last 2 MB)', path: paths.uploadAudit, maxBytes: 2 * 1024 * 1024 }, { label: 'fileuploader.log (last 1 MB)', path: paths.fileuploader, maxBytes: 1 * 1024 * 1024 } ] }); diff --git a/package-lock.json b/package-lock.json index d74ad67..0914626 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "multi-hoster-uploader", - "version": "2.1.18", + "version": "2.1.19", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "multi-hoster-uploader", - "version": "2.1.18", + "version": "2.1.19", "dependencies": { "chokidar": "^3.6.0", "undici": "^7.29.0", diff --git a/package.json b/package.json index 59ae3f5..0cb1dd5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "multi-hoster-uploader", - "version": "2.1.18", + "version": "2.1.19", "description": "Upload files to doodstream, voe, vidmoly, byse simultaneously", "main": "main.js", "scripts": { diff --git a/renderer/app.js b/renderer/app.js index 0604832..e95fee5 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -328,6 +328,7 @@ let queueJobs = []; // { id, file, fileName, hoster, status, bytesUploaded, byte const _jobIndexById = new Map(); // id -> job (O(1) lookup) const _jobIndexByUploadId = new Map(); // uploadId -> job const selectedJobIds = new Set(); +let selectionAnchorJobId = null; let _sessionTotalBytes = 0; // Total bytes ever added to queue this session let _sessionUploadedBytes = 0; // Bytes fully uploaded this session (done jobs) const _sessionTrackedJobs = new Set(); // Job IDs already counted for totalBytes @@ -1710,6 +1711,7 @@ function indexJob(job) { function removeJobFromIndex(job, keepCompletedKey) { _jobIndexById.delete(job.id); if (job.uploadId) _jobIndexByUploadId.delete(job.uploadId); + if (selectionAnchorJobId === job.id) selectionAnchorJobId = null; // Track deletion so handleProgress() won't re-create this job from stale callbacks _deletedJobIds.add(job.id); if (job.uploadId) _deletedJobIds.add(job.uploadId); @@ -1755,7 +1757,9 @@ function applyQueueSelectionClasses() { const rows = tbody.getElementsByClassName('queue-row'); for (let i = 0; i < rows.length; i++) { const tr = rows[i]; - tr.classList.toggle('selected', selectedJobIds.has(tr.dataset.jobId)); + const selected = selectedJobIds.has(tr.dataset.jobId); + tr.classList.toggle('selected', selected); + tr.setAttribute('aria-selected', String(selected)); } } @@ -1963,7 +1967,10 @@ function _getVisibleQueueJobs() { } function _normalizeQueueSelectionToVisible(visibleJobs = _getVisibleQueueJobs()) { - if (selectedJobIds.size === 0) return false; + if (selectedJobIds.size === 0) { + selectionAnchorJobId = null; + return false; + } const visibleIds = new Set(visibleJobs.map(job => job.id)); let changed = false; for (const id of selectedJobIds) { @@ -1972,6 +1979,9 @@ function _normalizeQueueSelectionToVisible(visibleJobs = _getVisibleQueueJobs()) changed = true; } } + if (selectionAnchorJobId && !visibleIds.has(selectionAnchorJobId)) { + selectionAnchorJobId = selectedJobIds.values().next().value || null; + } return changed; } @@ -2230,10 +2240,13 @@ function handleRowClick(e, row) { if (e.ctrlKey || e.metaKey) { if (selectedJobIds.has(jobId)) selectedJobIds.delete(jobId); else selectedJobIds.add(jobId); - } else if (e.shiftKey && selectedJobIds.size > 0) { + selectionAnchorJobId = jobId; + } else if (e.shiftKey && (selectionAnchorJobId || selectedJobIds.size > 0)) { // Use sorted jobs cache for correct shift-click with virtual scrolling const sortedIds = _sortedJobsCache.map(j => j.id); - const lastIdx = sortedIds.findIndex(id => selectedJobIds.has(id)); + const fallbackAnchor = selectedJobIds.values().next().value || null; + const anchorId = sortedIds.includes(selectionAnchorJobId) ? selectionAnchorJobId : fallbackAnchor; + const lastIdx = sortedIds.indexOf(anchorId); const curIdx = sortedIds.indexOf(jobId); if (lastIdx >= 0 && curIdx >= 0) { const from = Math.min(lastIdx, curIdx); @@ -2243,6 +2256,7 @@ function handleRowClick(e, row) { } else { selectedJobIds.clear(); selectedJobIds.add(jobId); + selectionAnchorJobId = jobId; // Single click on done job -> copy link const job = _jobIndexById.get(jobId); if (job && job.status === 'done' && job.result) { @@ -2284,6 +2298,7 @@ function handleRowContextMenu(e, row) { if (!selectedJobIds.has(jobId)) { selectedJobIds.clear(); selectedJobIds.add(jobId); + selectionAnchorJobId = jobId; applyQueueSelectionClasses(); updateQueueActionButtons(); } @@ -2756,6 +2771,7 @@ document.addEventListener('keydown', (e) => { const visibleJobs = _getVisibleQueueJobs(); selectedJobIds.clear(); visibleJobs.forEach(j => selectedJobIds.add(j.id)); + selectionAnchorJobId = visibleJobs[0]?.id || null; renderQueueTable(); } } @@ -2801,7 +2817,7 @@ async function handleContextAction(action) { const j = _jobIndexById.get(id); return j && (j.status === 'uploading' || j.status === 'queued' || j.status === 'retrying' || j.status === 'getting-server'); }); - if (activeIds.length > 0) window.api.cancelSelectedJobs(activeIds); + if (activeIds.length > 0) await window.api.cancelSelectedJobs(activeIds); const _deletedKeys = []; queueJobs = queueJobs.filter(j => { if (selectedJobIds.has(j.id)) { @@ -2812,6 +2828,7 @@ async function handleContextAction(action) { return true; }); selectedJobIds.clear(); + selectionAnchorJobId = null; syncSelectedFilesFromQueue(); suppressPreviewKeysStillSelected(_deletedKeys); renderQueueTable(); @@ -2822,13 +2839,16 @@ async function handleContextAction(action) { copyAllLinks(); } else if (action === 'delete-all') { if (!queueJobs.length || !await showAppConfirm({ title: 'Alle Uploads entfernen?', message: `${queueJobs.length} ${queueJobs.length === 1 ? 'Upload wird' : 'Uploads werden'} aus der Liste entfernt.`, confirmText: 'Alle entfernen', danger: true })) return; - const activeIds = queueJobs - .filter(j => j.status === 'uploading' || j.status === 'queued' || j.status === 'retrying' || j.status === 'getting-server') - .map(j => j.id); - if (activeIds.length > 0) window.api.cancelSelectedJobs(activeIds); + const hasActiveJobs = uploading || queueJobs.some(j => j.status === 'uploading' || j.status === 'queued' || j.status === 'retrying' || j.status === 'getting-server'); + if (hasActiveJobs) { + _cancelAutoRetry(true); + await window.api.cancelUpload(); + uploading = false; + } queueJobs.forEach(j => removeJobFromIndex(j)); queueJobs = []; selectedJobIds.clear(); + selectionAnchorJobId = null; selectedFiles = []; syncSelectedFilesFromQueue(); renderQueueTable(); @@ -2853,6 +2873,7 @@ async function handleContextAction(action) { return true; }); selectedJobIds.clear(); + selectionAnchorJobId = null; syncSelectedFilesFromQueue(); renderQueueTable(); if (queueJobs.length === 0) { selectedFiles = []; updateUploadView(); } @@ -3564,6 +3585,7 @@ async function retrySelectedJobs() { // jobs the double render freezes the UI for multiple seconds. selectedJobIds.clear(); retryJobs.forEach(j => selectedJobIds.add(j.id)); + selectionAnchorJobId = retryJobs[0]?.id || null; persistQueueStateSoon(); await startSelectedUpload(retryJobs); } @@ -3575,13 +3597,16 @@ async function abortSelectedJobs() { queueJobs.forEach((job) => { if (!selectedJobIds.has(job.id)) return; - if (['preview', 'queued'].includes(job.status)) { + if (job.status === 'preview') { job.status = 'aborted'; job.error = 'Abgebrochen'; job.progress = 0; job.uploadId = null; - } else if (['getting-server', 'uploading', 'retrying'].includes(job.status)) { + } else if (['queued', 'getting-server', 'uploading', 'retrying'].includes(job.status)) { activeJobIds.push(job.id); + job.status = 'aborted'; + job.error = 'Abgebrochen'; + job.progress = 0; } }); @@ -3590,6 +3615,7 @@ async function abortSelectedJobs() { } selectedJobIds.clear(); + selectionAnchorJobId = null; syncSelectedFilesFromQueue(); renderQueueTable(); updateQueueActionButtons(); @@ -4255,6 +4281,7 @@ async function _renderLogPathsList(el) { if (!paths || typeof paths !== 'object') { el.innerHTML = 'Pfade nicht verfügbar.'; return; } const entries = [ ['fileuploader', 'fileuploader.log'], + ['uploadAudit', 'upload-audit.log'], ['debug', 'debug.log'], ['accountRotation', 'account-rotation.log'], ['doodstreamDebug', 'doodstream-debug.log'] @@ -6629,7 +6656,7 @@ function renderRecentUploadsPanel(_appendOnly = false) { return; } // Clear queue selection when clicking in recent panel — class-toggle only. - if (selectedJobIds.size > 0) { selectedJobIds.clear(); applyQueueSelectionClasses(); updateQueueActionButtons(); } + if (selectedJobIds.size > 0) { selectedJobIds.clear(); selectionAnchorJobId = null; applyQueueSelectionClasses(); updateQueueActionButtons(); } const id = parseInt(tr.dataset.order, 10); if (e.ctrlKey || e.metaKey) { if (selectedRecentIds.has(id)) selectedRecentIds.delete(id); @@ -7249,6 +7276,7 @@ function setupListeners() { if (e.target.closest('.view-main') && !e.target.closest('.queue-row') && !e.target.closest('.btn') && !e.target.closest('.context-menu') && !e.target.closest('.recent-files-panel')) { if (selectedJobIds.size > 0) { selectedJobIds.clear(); + selectionAnchorJobId = null; renderQueueTable(); updateQueueActionButtons(); } @@ -7658,6 +7686,7 @@ async function importUploadLog() { if (removed > 0) { selectedJobIds.clear(); + selectionAnchorJobId = null; syncSelectedFilesFromQueue(); rebuildJobIndex(); renderQueueTable(); diff --git a/renderer/index.html b/renderer/index.html index 16c60af..1213fe5 100644 --- a/renderer/index.html +++ b/renderer/index.html @@ -622,7 +622,8 @@
- +
+