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.
This commit is contained in:
Sucukdeluxe
2026-08-13 14:31:46 +02:00
parent 59cead80c6
commit bfb3a39fed
20 changed files with 733 additions and 101 deletions
+2 -1
View File
@@ -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
+9 -5
View File
@@ -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 {}
+15 -9
View File
@@ -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 };
+13 -7
View File
@@ -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<redacted-path>$2')
.replace(/\b[A-Za-z]:(?:\\+|\/+)[^\r\n"'<>|]*?(?=\s+(?:trigger|error|outcome|hoster|attempt|status|code)=|\r?\n|$|["'])/gi, '<redacted-path>')
.replace(/\\{2,}[A-Za-z0-9._$-]+\\+[^\r\n"'<>|]*?(?=\s+(?:trigger|error|outcome|hoster|attempt|status|code)=|\r?\n|$|["'])/g, '<redacted-path>')
.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<no path configured>\n\n`;
let stat;
try { stat = fs.statSync(filePath); }
catch (err) {
if (err && err.code === 'ENOENT') return `=== ${label} (${filePath}) ===\n<file does not exist yet>\n\n`;
return `=== ${label} (${filePath}) ===\n<stat error: ${err.message}>\n\n`;
const context = includePath ? ` (${filePath})` : '';
if (err && err.code === 'ENOENT') return `=== ${label}${context} ===\n<file does not exist yet>\n\n`;
return `=== ${label}${context} ===\n<stat error: ${err.message}>\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 = `<read error: ${err.message}>`;
}
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('');
}
+58
View File
@@ -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 };
+46 -1
View File
@@ -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);
+31 -2
View File
@@ -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()) {
+63 -36
View File
@@ -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.
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);
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) {
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 }
]
});
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -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": {
+41 -12
View File
@@ -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 = '<span class="hint">Pfade nicht verfügbar.</span>'; 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();
+2 -1
View File
@@ -622,7 +622,8 @@
<div class="ctx-item" role="menuitem" tabindex="-1" data-action="copy-all-links">Alle Links kopieren</div>
<div class="ctx-separator"></div>
<div class="ctx-item" role="menuitem" tabindex="-1" data-action="delete-selected">Entfernen</div>
<div class="ctx-item" role="menuitem" tabindex="-1" data-action="delete-all">Alle entfernen</div>
<div class="ctx-separator"></div>
<div class="ctx-item ctx-item-danger" role="menuitem" tabindex="-1" data-action="delete-all">Alle entfernen</div>
<div class="ctx-submenu ctx-hoster-delete-submenu" style="display:none">
<div class="ctx-item ctx-item-danger" role="menuitem" tabindex="-1">Hoster entfernen &#9656;</div>
<div class="ctx-submenu-items ctx-hoster-delete-items"></div>
+27 -5
View File
@@ -2861,7 +2861,11 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
background: rgba(255, 255, 255, .035);
}
.queue-row.selected,
.queue-row.selected {
background: rgba(186, 208, 252, .18) !important;
box-shadow: inset 3px 0 0 var(--accent);
}
.recent-file-row.selected,
.history-row.selected {
background: rgba(186, 208, 252, .1) !important;
@@ -2951,20 +2955,30 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
min-height: 34px;
}
.btn-primary,
.btn-success {
.btn-primary {
border-color: transparent;
background: var(--accent);
color: var(--accent-ink);
}
.btn-primary:hover,
.btn-success:hover {
.btn-primary:hover {
background: var(--accent-end);
filter: none;
transform: none;
}
.btn-success {
border-color: transparent;
background: var(--success);
color: #082616;
}
.btn-success:hover {
background: var(--success-end);
filter: none;
transform: none;
}
.btn-secondary {
border-color: var(--border);
background: var(--bg-raised);
@@ -3348,6 +3362,14 @@ input[type="checkbox"] {
user-select: text;
}
#upload-view {
user-select: none;
}
#upload-view :is(input, textarea, [contenteditable="true"]) {
user-select: text;
}
.settings-header h2 {
font-size: 18px;
}
+2
View File
@@ -58,6 +58,7 @@ const sourceFiles = [
'lib/throttle.js',
'lib/throttled-cache.js',
'lib/updater.js',
'lib/upload-audit.js',
'lib/upload-log.js',
'lib/upload-confirmation.js',
'lib/upload-diagnostics.js',
@@ -146,6 +147,7 @@ const sourceFiles = [
'tests/ui-network-safety.test.js',
'tests/ui-smoke.js',
'tests/updater-version.test.js',
'tests/upload-audit.test.js',
'tests/upload-log.test.js',
'tests/upload-confirmation.test.js',
'tests/upload-diagnostics.test.js',
+17
View File
@@ -18,6 +18,7 @@ function makeFixture() {
const fixtureZeta = ['WBHOOK', 'SECRET', 'TOKEN'].join('');
const paths = {
fileuploader: path.join(dir, 'fileuploader.log'),
uploadAudit: path.join(dir, 'upload-audit.log'),
debug: path.join(dir, 'debug.log'),
accountRotation: path.join(dir, 'account-rotation.log'),
doodstreamDebug: path.join(dir, 'doodstream-debug.log'),
@@ -25,6 +26,7 @@ function makeFixture() {
logDir: dir
};
fs.writeFileSync(paths.debug, `boot ok\nuploading file with token ${fixtureAlpha} inline\nAuthorization: Bearer ${fixtureBeta}\n`);
fs.writeFileSync(paths.uploadAudit, `# SOURCE-CLEANUP {"token":"${fixtureAlpha}"}\n`);
fs.writeFileSync(paths.doodstreamDebug, `api_key=${fixtureGamma} sess=abc\n`);
fs.writeFileSync(paths.crashLog, 'CRASH at 12:00\n');
const config = {
@@ -88,13 +90,28 @@ test('getHistory falls back to loadConfig().history when loadHistory is absent (
test('readLog redacts a planted token and a Bearer line; doodstream is NOT readable; unknown name rejected', () => {
const { collectors } = makeFixture();
const dbg = collectors.readLog({ name: 'debug', tailKb: 64 });
const audit = collectors.readLog({ name: 'uploadAudit', tailKb: 64 });
assert.ok(!dbg.content.includes('SECRETTOKEN123456'), 'value-scrub removes the live diag token from logs');
assert.ok(!/Bearer abcdef123456/.test(dbg.content), 'pattern-scrub removes Authorization Bearer');
assert.equal(audit.name, 'uploadAudit');
assert.ok(!audit.content.includes('SECRETTOKEN123456'), 'source cleanup audit is readable only through the redacted diagnostics path');
assert.equal(collectors.readLog({ name: 'doodstreamDebug' }).ok, false, 'doodstream-debug.log is not in the readable allowlist');
assert.equal(collectors.readLog({ name: '../../etc/passwd' }).ok, false, 'arbitrary names are rejected (no path traversal)');
assert.equal(collectors.readLog({ name: 'crash' }).name, 'crash');
});
test('rotated audit backups are listed and readable with the rotation naming convention', () => {
const { collectors, paths, fixtureAlpha } = makeFixture();
const backupPath = path.join(path.dirname(paths.uploadAudit), 'upload-audit.1.log');
fs.writeFileSync(backupPath, `# SOURCE-CLEANUP {"token":"${fixtureAlpha}"}\n`);
const listed = collectors.listLogs().files.find(file => file.name === 'uploadAudit');
assert.ok(listed.variants.some(variant => variant.backup === 1));
assert.ok(!collectors.listLogs().otherLogs.some(file => file.name === 'upload-audit.1.log'));
const backup = collectors.readLog({ name: 'uploadAudit', backup: 1, tailKb: 64 });
assert.equal(backup.path, backupPath);
assert.ok(!backup.content.includes(fixtureAlpha));
});
test('readLog grep is case-insensitive substring with | alternation, and is ReDoS-safe', () => {
const { paths } = makeFixture();
const fs2 = require('fs');
+47
View File
@@ -85,6 +85,24 @@ test('redactLogText leaves a normal "session" word in prose alone', () => {
assert.equal(redactLogText(benign, []), benign);
});
test('redactLogText removes complete local paths from structured and free-form log text', () => {
const profilePath = ['C:', 'Users', 'ProfileFixture', 'Private Folder', 'episode.mkv'].join('\\');
const drivePath = ['D:', 'Archive', 'Private Folder', 'source.mkv'].join('\\');
const stagedPath = ['E:', 'Staging', 'source.pending-delete'].join('\\');
const uncPath = ['', '', 'fileserver', 'private-share', 'secret.bin'].join('\\');
const input = [
`source ${profilePath}`,
`failed at ${drivePath}`,
JSON.stringify({ stagedFile: stagedPath }),
`network source ${uncPath}`
].join('\n');
const out = redactLogText(input, []);
for (const value of ['ProfileFixture', 'episode.mkv', 'Private Folder', 'source.mkv', 'source.pending-delete', 'fileserver', 'private-share', 'secret.bin']) {
assert.ok(!out.includes(value), `private path fragment survived: ${value}`);
}
assert.ok((out.match(/<redacted-path>/g) || []).length >= 4);
});
test('sanitizeConfig does not mutate input', () => {
const input = { hosters: { 'voe.sx': [{ password: 'secret' }] } };
const clone = JSON.parse(JSON.stringify(input));
@@ -153,3 +171,32 @@ test('buildSupportBundleText handles empty file list and missing header', () =>
assert.match(text, /=== Multi-Hoster-Upload Support Bundle ===/);
assert.match(text, /=== Config/);
});
test('buildSupportBundleText redacts configured and pattern-detected secrets from included logs', () => {
const tmp = path.join(os.tmpdir(), `mhu-bundle-secrets-${Date.now()}.log`);
const configuredSecret = ['configured', 'Secret', '123456'].join('');
const bearerSecret = ['opaque', 'Bearer', '987654321'].join('');
const cookieSecret = ['session', 'Cookie', '1122334455'].join('');
const querySecret = ['query', 'Secret', '6677889900'].join('');
const privatePath = ['C:', 'Users', 'ProfileFixture', 'Private', 'episode.mkv'].join('\\');
const stagedPath = ['D:', 'Private', 'episode.pending-delete'].join('\\');
fs.writeFileSync(tmp, `# SOURCE-CLEANUP ${JSON.stringify({ file: privatePath, stagedFile: stagedPath })}\ntoken=${configuredSecret}\nAuthorization: Bearer ${bearerSecret}\nCookie: sid=${cookieSecret}\nhttps://example.invalid/upload?api_key=${querySecret}\n`);
try {
const text = buildSupportBundleText({
sanitizedConfig: { globalSettings: { logFilePath: privatePath, pendingQueue: { selectedFiles: [{ path: privatePath }] } } },
secrets: [configuredSecret],
files: [{ label: 'upload-audit.log', path: tmp }]
});
assert.ok(!text.includes(configuredSecret));
assert.ok(!text.includes(bearerSecret));
assert.ok(!text.includes(cookieSecret));
assert.ok(!text.includes(querySecret));
assert.ok(!text.includes('ProfileFixture'));
assert.ok(!text.includes('episode.mkv'));
assert.ok(!text.includes('episode.pending-delete'));
assert.ok(!text.includes(tmp));
assert.match(text, /<redacted>/);
} finally {
fs.unlinkSync(tmp);
}
});
+70 -9
View File
@@ -203,8 +203,8 @@ setTimeout(async () => {
await captureVisual('00-language-picker.png');
await wc.executeJavaScript('document.getElementById("upload-tab").click()');
const unchangedValues = await wc.executeJavaScript('(() => { setUiLanguage("de"); const nodes = []; const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); let node = walker.nextNode(); while (node) { if (node.nodeValue.trim()) nodes.push({ node, source: node.nodeValue.trim() }); node = walker.nextNode(); } const attributes = [...document.querySelectorAll("[title],[aria-label],[placeholder],[data-tooltip]")].flatMap(element => ["title", "aria-label", "placeholder", "data-tooltip"].filter(name => element.hasAttribute(name)).map(name => ({ element, name, source: element.getAttribute(name).trim() }))); setUiLanguage("en"); const unchanged = nodes.filter(entry => entry.source === entry.node.nodeValue.trim()).map(entry => entry.source); unchanged.push(...attributes.filter(entry => entry.source === entry.element.getAttribute(entry.name).trim()).map(entry => entry.source)); return [...new Set(unchanged.filter(value => /[A-Za-zÄÖÜäöüß]{2}/.test(value)))].sort(); })()');
const neutralUiValues = new Set(['0 kB/s', 'Accounts', 'BBCode', 'CSV', 'Changelog', 'ETA', 'ETA --:--', 'FileUploader Log', 'HTML', 'JSON', 'Label (optional)', 'Link', 'Log', 'Logs & Support', 'MB/s', 'MHU2-…', 'MULTI HOSTER UPLOADER', 'Markdown', 'Multi Hoster Uploader', 'OK', 'Plaintext', 'Port', 'Server', 'Status', 'Update', 'Upload', 'Uploads', 'Verbose Logging', 'Webhook', 'account-rotation.log', 'debug.log', 'doodstream-debug.log', 'fileuploader.log', 'upload-debug.log', 'mp4,mkv,avi']);
const neutralUiPathBasenames = new Set(['account-rotation.log', 'doodstream-debug.log', 'fileuploader.log', 'upload-debug.log']);
const neutralUiValues = new Set(['0 kB/s', 'Accounts', 'BBCode', 'CSV', 'Changelog', 'ETA', 'ETA --:--', 'FileUploader Log', 'HTML', 'JSON', 'Label (optional)', 'Link', 'Log', 'Logs & Support', 'MB/s', 'MHU2-…', 'MULTI HOSTER UPLOADER', 'Markdown', 'Multi Hoster Uploader', 'OK', 'Plaintext', 'Port', 'Server', 'Status', 'Update', 'Upload', 'Uploads', 'Verbose Logging', 'Webhook', 'account-rotation.log', 'debug.log', 'doodstream-debug.log', 'fileuploader.log', 'upload-audit.log', 'upload-debug.log', 'mp4,mkv,avi']);
const neutralUiPathBasenames = new Set(['account-rotation.log', 'doodstream-debug.log', 'fileuploader.log', 'upload-audit.log', 'upload-debug.log']);
const unexpectedUnchangedValues = unchangedValues.filter(value => !neutralUiValues.has(value) && !neutralUiPathBasenames.has(path.basename(value)) && !value.includes('Multi-Hoster-Uploader'));
if (process.env.AUDIT_I18N_UNCHANGED === '1' || unexpectedUnchangedValues.length) console.log('Unchanged i18n values: ' + JSON.stringify(unchangedValues, null, 2));
check('Every mounted human-facing value is translated or explicitly language-neutral', unexpectedUnchangedValues.length === 0);
@@ -243,8 +243,8 @@ setTimeout(async () => {
check('Language changes redraw stable telemetry values with the active locale', localizedStableMetric.german.join('|') === '1.234|1.234' && localizedStableMetric.english.join('|') === '1,234|1,234');
const germanSidebarHeadings = await wc.executeJavaScript('[...document.querySelectorAll("#upload-view, #accounts-view, #history-view")].map(view => [view.querySelector(".view-sidebar-kicker")?.textContent?.trim(), view.querySelector(".view-sidebar-title")?.textContent?.trim()].join("|"))');
check('German sidebar hierarchy uses distinct localized kickers', germanSidebarHeadings.join('::') === 'Arbeitsbereich|Uploads::Accounts verwalten|Accounts::Archiv|Verlauf');
const saveAfterLanguageChange = await wc.executeJavaScript('(() => { const button = document.getElementById("saveSettingsBtn"); return [button.disabled, button.classList.contains("btn-success")].join("|"); })()');
check('Changing language enables the green save action', saveAfterLanguageChange === 'false|true');
const saveAfterLanguageChange = await wc.executeJavaScript('(() => { const button = document.getElementById("saveSettingsBtn"); const channels = getComputedStyle(button).backgroundColor.match(/[0-9.]+/g)?.map(Number) || []; return { disabled: button.disabled, success: button.classList.contains("btn-success"), green: channels.length >= 3 && channels[1] > channels[0] * 1.25 && channels[1] > channels[2] * 1.2 }; })()');
check('Changing language enables a visibly green save action', saveAfterLanguageChange.disabled === false && saveAfterLanguageChange.success && saveAfterLanguageChange.green);
await wc.executeJavaScript('document.getElementById("saveSettingsBtn").click()');
await waitUntil(() => wc.executeJavaScript('document.getElementById("saveSettingsBtn").disabled'));
const saveAfterCommit = await wc.executeJavaScript('(() => { const button = document.getElementById("saveSettingsBtn"); return [button.disabled, button.classList.contains("btn-secondary")].join("|"); })()');
@@ -722,6 +722,68 @@ setTimeout(async () => {
check('Status changes drop selections that leave the upload filter', uploadSelectionScope.statusChangeSelected.length === 0 && uploadSelectionScope.statusChangeVisible.join('|') === 'scope-active-z');
check('Selected upload actions ignore hidden stale selections', uploadSelectionScope.hiddenAction.selected.length === 0 && uploadSelectionScope.hiddenAction.retryDisabled && uploadSelectionScope.hiddenAction.moveDisabled);
const queueSelectionAnchor = await wc.executeJavaScript(\`(() => {
queueJobs = ['a', 'b', 'c', 'd'].map(id => ({ id: 'anchor-' + id, file: 'C:/ui/anchor-' + id + '.bin', fileName: 'anchor-' + id + '.bin', hoster: 'byse.sx', status: 'queued', bytesUploaded: 0, bytesTotal: 100, progress: 0 }));
selectedJobIds.clear();
rebuildJobIndex();
renderQueueTable();
const row = id => document.querySelector('[data-job-id="anchor-' + id + '"]');
handleRowClick({ ctrlKey: false, metaKey: false, shiftKey: false }, row('a'));
handleRowClick({ ctrlKey: true, metaKey: false, shiftKey: false }, row('c'));
handleRowClick({ ctrlKey: false, metaKey: false, shiftKey: true }, row('d'));
const selected = [...selectedJobIds].sort();
const aria = Object.fromEntries(['a', 'b', 'c', 'd'].map(id => [id, row(id).getAttribute('aria-selected')]));
queueJobs = [];
selectedJobIds.clear();
rebuildJobIndex();
renderQueueTable();
return { selected, aria };
})()\`);
check('Shift selection starts from the last clicked row and keeps ARIA state synchronized', queueSelectionAnchor.selected.join('|') === 'anchor-a|anchor-c|anchor-d' && queueSelectionAnchor.aria.a === 'true' && queueSelectionAnchor.aria.b === 'false' && queueSelectionAnchor.aria.c === 'true' && queueSelectionAnchor.aria.d === 'true');
const queueSelectionVisual = await wc.executeJavaScript('(() => { queueJobs = [{ id: "ui-selection-visual", file: "C:/ui/selection.bin", fileName: "selection.bin", hoster: "byse.sx", status: "queued", bytesUploaded: 0, bytesTotal: 100, progress: 0 }]; selectedJobIds.clear(); selectedJobIds.add("ui-selection-visual"); rebuildJobIndex(); renderQueueTable(); const row = document.querySelector(".queue-row.selected"); const style = getComputedStyle(row); const channels = style.backgroundColor.match(/[0-9.]+/g)?.map(Number) || []; const result = { userSelect: getComputedStyle(row.querySelector(".col-filename")).userSelect, alpha: channels[3] ?? 1, marker: style.boxShadow !== "none" }; queueJobs = []; selectedJobIds.clear(); rebuildJobIndex(); renderQueueTable(); return result; })()');
check('Upload rows prevent accidental text selection and expose a strong selected state', queueSelectionVisual.userSelect === 'none' && queueSelectionVisual.alpha >= 0.16 && queueSelectionVisual.marker);
const removedAnchorState = await wc.executeJavaScript('(() => { queueJobs = ["a", "b"].map(id => ({ id: "ui-anchor-remove-" + id, file: "C:/ui/anchor-remove-" + id + ".bin", fileName: "anchor-remove-" + id + ".bin", hoster: "byse.sx", status: "queued", bytesUploaded: 0, bytesTotal: 100, progress: 0 })); selectedJobIds.clear(); rebuildJobIndex(); renderQueueTable(); const first = document.querySelector("[data-job-id=ui-anchor-remove-a]"); handleRowClick({ ctrlKey: false, metaKey: false, shiftKey: false }, first); const removed = queueJobs.shift(); removeJobFromIndex(removed, true); selectedJobIds.delete(removed.id); renderQueueTable(); const second = document.querySelector("[data-job-id=ui-anchor-remove-b]"); handleRowClick({ ctrlKey: false, metaKey: false, shiftKey: true }, second); const result = { anchor: selectionAnchorJobId, selected: [...selectedJobIds] }; queueJobs = []; selectedJobIds.clear(); selectionAnchorJobId = null; rebuildJobIndex(); renderQueueTable(); return result; })()');
check('Removing the selected anchor leaves the next Shift click usable', removedAnchorState.anchor === 'ui-anchor-remove-b' && removedAnchorState.selected.join('|') === 'ui-anchor-remove-b');
const removeAllDanger = await wc.executeJavaScript('(() => { const item = document.querySelector("#contextMenu [data-action=delete-all]"); const channels = getComputedStyle(item).color.match(/[0-9.]+/g)?.map(Number) || []; return Boolean(item && channels.length >= 3 && channels[0] > channels[1] * 1.2 && channels[0] > channels[2] * 1.15); })()');
check('Remove all is visually marked as a destructive queue action', removeAllDanger === true);
let releaseSelectedQueueCancel = null;
ipcMain.removeHandler('cancel-selected-jobs');
ipcMain.handle('cancel-selected-jobs', () => new Promise(resolve => { releaseSelectedQueueCancel = () => resolve(true); }));
await wc.executeJavaScript('(() => { queueJobs = [{ id: "ui-delete-selected", file: "C:/ui/delete-selected.bin", fileName: "delete-selected.bin", hoster: "byse.sx", status: "queued", bytesUploaded: 0, bytesTotal: 100, progress: 0 }]; selectedJobIds.clear(); selectedJobIds.add("ui-delete-selected"); rebuildJobIndex(); renderQueueTable(); window.__uiDeleteSelectedPromise = handleContextAction("delete-selected"); return true; })()');
await waitUntil(() => wc.executeJavaScript('document.getElementById("appAlertModal").style.display === "flex"'));
await wc.executeJavaScript('document.getElementById("appAlertConfirmBtn").click()');
await waitUntil(() => releaseSelectedQueueCancel);
const selectedQueueStillPresent = await wc.executeJavaScript('queueJobs.length');
releaseSelectedQueueCancel();
const selectedQueueAfterCancel = await wc.executeJavaScript('window.__uiDeleteSelectedPromise.then(() => { delete window.__uiDeleteSelectedPromise; return queueJobs.length; })');
check('Removing selected uploads waits for the main-process cancellation acknowledgement', selectedQueueStillPresent === 1 && selectedQueueAfterCancel === 0);
restoreInitialIpcHandler('cancel-selected-jobs');
let releaseFullQueueCancel = null;
let fullQueueCancelCalls = 0;
let selectedQueueCancelCalls = 0;
ipcMain.removeHandler('cancel-upload');
ipcMain.handle('cancel-upload', () => {
fullQueueCancelCalls++;
return new Promise(resolve => { releaseFullQueueCancel = () => resolve(true); });
});
ipcMain.removeHandler('cancel-selected-jobs');
ipcMain.handle('cancel-selected-jobs', () => { selectedQueueCancelCalls++; return true; });
await wc.executeJavaScript('(() => { uploading = true; queueJobs = ["a", "b", "c"].map(id => ({ id: "ui-delete-all-" + id, file: "C:/ui/delete-all-" + id + ".bin", fileName: "delete-all-" + id + ".bin", hoster: "byse.sx", status: "queued", bytesUploaded: 0, bytesTotal: 100, progress: 0 })); selectedJobIds.clear(); rebuildJobIndex(); renderQueueTable(); window.__uiDeleteAllPromise = handleContextAction("delete-all"); return true; })()');
await waitUntil(() => wc.executeJavaScript('document.getElementById("appAlertModal").style.display === "flex"'));
await wc.executeJavaScript('document.getElementById("appAlertConfirmBtn").click()');
await waitUntil(() => releaseFullQueueCancel);
const fullQueueStillPresent = await wc.executeJavaScript('queueJobs.length');
releaseFullQueueCancel();
const fullQueueAfterCancel = await wc.executeJavaScript('window.__uiDeleteAllPromise.then(() => { delete window.__uiDeleteAllPromise; return { length: queueJobs.length, uploading }; })');
check('Remove all awaits one batch cancellation instead of issuing one cancellation per queued job', fullQueueStillPresent === 3 && fullQueueAfterCancel.length === 0 && fullQueueAfterCancel.uploading === false && fullQueueCancelCalls === 1 && selectedQueueCancelCalls === 0);
restoreInitialIpcHandler('cancel-upload');
restoreInitialIpcHandler('cancel-selected-jobs');
const keyboardTab = await wc.executeJavaScript('document.getElementById("upload-tab").focus(); document.getElementById("upload-tab").dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })); document.querySelector(".tab.active")?.textContent?.trim() + "|" + document.activeElement?.id');
check('Arrow keys move and activate main tabs', keyboardTab === 'Accounts|accounts-tab');
@@ -1195,7 +1257,7 @@ setTimeout(async () => {
await wc.executeJavaScript('document.querySelector("[data-settings-page=\\'logs\\']")?.click()');
await new Promise(resolve => setTimeout(resolve, 100));
const logPathLayout = await wc.executeJavaScript('(() => { const block = document.getElementById("logPathsBlock")?.getBoundingClientRect(); const rows = [...document.querySelectorAll("#logPathsList > div")]; const visible = rows.length > 0 && rows.every(row => { const rect = row.getBoundingClientRect(); const code = row.querySelector("code")?.getBoundingClientRect(); const button = row.querySelector("button")?.getBoundingClientRect(); return block && rect.right <= block.right + 1 && code && button && code.right <= button.left - 6 && button.right <= block.right + 1; }); return [rows.length, visible].join("|"); })()');
check('Log file rows keep paths and buttons inside the Diagnose panel', logPathLayout === '4|true');
check('Log file rows keep paths and buttons inside the Diagnose panel', logPathLayout === '5|true');
await wc.executeJavaScript('document.querySelector("[data-settings-page=\\'remote\\']")?.click()');
const remoteSettingsSpacing = await wc.executeJavaScript('(() => { const grid = document.querySelector("[data-subpage=remote] .settings-grid-mini")?.getBoundingClientRect(); const port = document.getElementById("remotePortInput")?.closest(".settings-row")?.getBoundingClientRect(); return grid && port ? Math.round(port.top - grid.bottom) : -1; })()');
@@ -1438,14 +1500,13 @@ setTimeout(async () => {
releaseBlockedWrite?.();
for (let attempt = 0; attempt < 100 && !finalImportQueueStarted; attempt++) await new Promise(resolve => setTimeout(resolve, 10));
const importStateDuringFinalQueuePersist = await wc.executeJavaScript('({ gateClosed: configImportInProgress, webhookUrl: config.globalSettings.webhookUrl, accountId: config.hosters["byse.sx"]?.[0]?.id })');
const staleImportSettings = wc.executeJavaScript('saveGlobalSettingsTracked({ ...(config.globalSettings || {}), webhookUrl: "https://import-epoch.invalid/stale-after-commit" }).then(() => ({ ok: true }), error => ({ ok: false, code: error.code }))');
const staleImportAccounts = wc.executeJavaScript('saveConfigTracked({ hosters: { ...(config.hosters || {}), "byse.sx": [{ id: "ui-stale-account", enabled: true, authType: "api", apiKey: "stale" }] } }).then(() => ({ ok: true }), error => ({ ok: false, code: error.code }))');
const staleImportWriteStart = await wc.executeJavaScript('(() => { const settings = saveGlobalSettingsTracked({ ...(config.globalSettings || {}), webhookUrl: "https://import-epoch.invalid/stale-after-commit" }).then(() => ({ ok: true }), error => ({ ok: false, code: error.code })); const accounts = saveConfigTracked({ hosters: { ...(config.hosters || {}), "byse.sx": [{ id: "ui-stale-account", enabled: true, authType: "api", apiKey: "stale" }] } }).then(() => ({ ok: true }), error => ({ ok: false, code: error.code })); window.__uiStaleImportWrites = Promise.all([settings, accounts]); return { gateClosed: configImportInProgress }; })()');
releaseFinalImportQueue?.();
const [staleImportSettingsResult, staleImportAccountsResult] = await Promise.all([staleImportSettings, staleImportAccounts]);
const [staleImportSettingsResult, staleImportAccountsResult] = await wc.executeJavaScript('window.__uiStaleImportWrites.then(results => { delete window.__uiStaleImportWrites; return results; })');
await pendingImportEpoch;
const configAfterImportEpoch = await wc.executeJavaScript('window.api.getConfig()');
check('Import keeps its gate closed through apply and final queue persistence', importStateDuringFinalQueuePersist.gateClosed === true && importStateDuringFinalQueuePersist.webhookUrl === 'https://import-epoch.invalid/imported' && importStateDuringFinalQueuePersist.accountId === 'ui-import-epoch-account');
check('Import rejects stale settings and account writes until the full transition finishes', staleImportSettingsResult.code === 'CONFIG_WRITE_SUPERSEDED' && staleImportAccountsResult.code === 'CONFIG_WRITE_SUPERSEDED' && configAfterImportEpoch.hosters['byse.sx']?.[0]?.id === 'ui-import-epoch-account' && configAfterImportEpoch.globalSettings.webhookUrl === 'https://import-epoch.invalid/imported');
check('Import rejects stale settings and account writes until the full transition finishes', staleImportWriteStart.gateClosed === true && staleImportSettingsResult.code === 'CONFIG_WRITE_SUPERSEDED' && staleImportAccountsResult.code === 'CONFIG_WRITE_SUPERSEDED' && configAfterImportEpoch.hosters['byse.sx']?.[0]?.id === 'ui-import-epoch-account' && configAfterImportEpoch.globalSettings.webhookUrl === 'https://import-epoch.invalid/imported');
restoreInitialIpcHandler('save-pending-queue');
const importPersistFailureConfig = structuredClone(configAfterImportEpoch);
+64
View File
@@ -0,0 +1,64 @@
const { test } = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
test('internal audit records never contaminate the MDU session link log', async () => {
let createUploadAuditWriter;
try {
({ createUploadAuditWriter } = require('../lib/upload-audit'));
} catch {}
assert.equal(typeof createUploadAuditWriter, 'function');
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-upload-audit-'));
const sessionLog = path.join(directory, '13-08-2026-mdu-session-14-20-123456.log');
const writer = createUploadAuditWriter({
fs,
path,
resolveUploadLogTarget: () => ({ path: sessionLog, isFallback: false }),
rotateLogFile: () => {},
invalidateUploadLogTarget: () => {},
reportError: () => {},
retryDelays: [0]
});
await writer.append('# SOURCE-CLEANUP {"outcome":"deleted"}\r\n', 'source-cleanup');
await writer.append('# UPLOAD-PLAN {"plannedUploadCount":4}\r\n', 'upload-plan');
const auditLog = path.join(directory, 'upload-audit.log');
assert.equal(fs.existsSync(sessionLog), false);
assert.equal(fs.readFileSync(auditLog, 'utf8'), '# SOURCE-CLEANUP {"outcome":"deleted"}\r\n# UPLOAD-PLAN {"plannedUploadCount":4}\r\n');
fs.rmSync(directory, { recursive: true, force: true });
});
test('audit writer reports the actual fallback file after a failed primary write', async () => {
const { createUploadAuditWriter } = require('../lib/upload-audit');
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-upload-audit-fallback-'));
const blockedParent = path.join(directory, 'blocked');
const fallbackDirectory = path.join(directory, 'fallback');
fs.writeFileSync(blockedParent, 'not a directory');
let attempts = 0;
const persistedFallbacks = [];
const writer = createUploadAuditWriter({
fs,
path,
resolveUploadLogTarget: () => ({
path: attempts++ === 0
? path.join(blockedParent, 'fileuploader.log')
: path.join(fallbackDirectory, 'fileuploader.log'),
isFallback: attempts > 1
}),
rotateLogFile: () => {},
invalidateUploadLogTarget: () => {},
persistFallbackLogPath: async targetPath => { persistedFallbacks.push(targetPath); },
reportError: () => {},
retryDelays: [0, 0]
});
assert.equal(await writer.append('# UPLOAD-PLAN {}\r\n', 'upload-plan'), true);
assert.equal(writer.getActivePath(), path.join(fallbackDirectory, 'upload-audit.log'));
assert.deepEqual(persistedFallbacks, [path.join(fallbackDirectory, 'fileuploader.log')]);
assert.equal(fs.readFileSync(writer.getActivePath(), 'utf8'), '# UPLOAD-PLAN {}\r\n');
fs.rmSync(directory, { recursive: true, force: true });
});
+59 -1
View File
@@ -1,6 +1,11 @@
const { test } = require('node:test');
const assert = require('node:assert');
const { formatUploadLogLine, parseUploadLogLine } = require('../lib/upload-log');
const {
formatUploadLogLine,
parseUploadLogLine,
summarizeBatchPlan,
formatUploadPlanLogLine
} = require('../lib/upload-log');
const { partitionRestoredJobsByLog } = require('../lib/queue-dedup');
function previewJob(fileName, hoster) {
@@ -16,6 +21,59 @@ test('writer -> reader round trip: parsed ts is the same epoch frame as the sour
assert.equal(parsed.ts, d.getTime(), 'parser ts must equal the writer Date epoch (no tz shift)');
});
test('batch plan records unique sources, destinations, and requested upload count without file paths', () => {
const jobs = [];
for (const file of ['C:/private/a.mkv', 'C:/private/b.mkv', 'C:/private/c.mkv']) {
for (const hoster of ['doodstream.com', 'voe.sx', 'vidmoly.me', 'byse.sx']) {
jobs.push({ file, hoster });
}
}
const plan = summarizeBatchPlan({ jobs });
const line = formatUploadPlanLogLine(new Date('2026-08-13T12:00:00.000Z'), plan, 'start');
assert.deepEqual(plan, {
fileCount: 3,
destinationCount: 4,
plannedUploadCount: 12
});
assert.equal(line.startsWith('# UPLOAD-PLAN '), true);
assert.equal(line.includes('C:/private'), false);
assert.equal(line.includes('a.mkv'), false);
assert.equal(line.includes('doodstream.com'), false);
assert.deepEqual(JSON.parse(line.slice('# UPLOAD-PLAN '.length)), {
timestamp: '2026-08-13T12:00:00.000Z',
mode: 'start',
fileCount: 3,
destinationCount: 4,
plannedUploadCount: 12
});
assert.equal(parseUploadLogLine(line), null);
});
test('batch plan supports the legacy files and hosters payload', () => {
assert.deepEqual(summarizeBatchPlan({
files: ['C:/private/a.mkv', 'C:/private/b.mkv'],
hosters: ['voe.sx', 'doodstream.com']
}), {
fileCount: 2,
destinationCount: 2,
plannedUploadCount: 4
});
});
test('batch plan preserves a sparse requested job count instead of multiplying dimensions', () => {
assert.deepEqual(summarizeBatchPlan({ jobs: [
{ file: 'C:/private/a.mkv', hoster: 'voe.sx' },
{ file: 'C:/private/a.mkv', hoster: 'byse.sx' },
{ file: 'C:/private/b.mkv', hoster: 'voe.sx' }
] }), {
fileCount: 2,
destinationCount: 2,
plannedUploadCount: 3
});
});
test('SEAM: a real appendUploadLog-format line drops a preview ghost vs a savedAt taken BEFORE completion', () => {
const completion = new Date(2026, 5, 19, 12, 0, 30);
const line = formatUploadLogLine(completion, 'voe.sx', 'link', 'a.mkv');
+155
View File
@@ -1,7 +1,10 @@
const { describe, it, mock, beforeEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('path');
const { EventEmitter } = require('events');
const { createSourceFileCleanup } = require('../lib/source-file-cleanup');
// We need to mock fs.statSync and the hoster upload functions before requiring upload-manager
// Use node:test mock.module (available in Node 22+)
@@ -399,6 +402,158 @@ describe('UploadManager', () => {
assert.ok(statuses.some((entry) => entry.jobId === 'selected-job' && entry.status === 'aborted'));
});
it('cancelJobs prevents a not-yet-spawned job from starting', async () => {
const mgr = new UploadManager({
'doodstream.com': { retries: 0, parallelCount: 1, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 }
});
const settled = new Map();
mgr.on('job-settled', (event) => settled.set(event.jobId, event.status));
const tasks = Array.from({ length: 101 }, (_, index) => ({
jobId: index === 100 ? 'late-cancelled-job' : `early-job-${index}`,
file: `/test/chunk-${index}.mp4`,
hoster: 'doodstream.com',
apiKey: 'key1',
sourceCleanupToken: `cleanup-${index}`
}));
const batchPromise = mgr.startBatch(tasks);
mgr.cancelJobs(['late-cancelled-job']);
await batchPromise;
const lateCalls = mockUploadFile.mock.calls.filter((call) => call.arguments[1] === '/test/chunk-100.mp4');
assert.equal(lateCalls.length, 0);
assert.equal(settled.get('late-cancelled-job'), 'aborted');
});
it('cancel before startBatch prevents the reserved batch from uploading', async () => {
const mgr = new UploadManager({});
let summary = null;
mgr.on('batch-done', (value) => { summary = value; });
mgr.cancel();
await mgr.startBatch([{
jobId: 'prestart-cancelled-job',
file: '/test/prestart-cancelled.mp4',
hoster: 'doodstream.com',
apiKey: 'key1',
sourceCleanupToken: 'cleanup-prestart'
}]);
assert.equal(mockUploadFile.mock.calls.length, 0);
assert.equal(summary.succeeded, 0);
});
it('cancelJobs before startBatch prevents the reserved job from uploading', async () => {
const mgr = new UploadManager({});
let summary = null;
const settled = [];
mgr.on('batch-done', (value) => { summary = value; });
mgr.on('job-settled', (value) => settled.push(value));
mgr.cancelJobs(['prestart-selected-job']);
await mgr.startBatch([{
jobId: 'prestart-selected-job',
file: '/test/prestart-selected.mp4',
hoster: 'doodstream.com',
apiKey: 'key1',
sourceCleanupToken: 'cleanup-prestart-selected'
}]);
assert.equal(mockUploadFile.mock.calls.length, 0);
assert.equal(summary.succeeded, 0);
assert.equal(settled.at(-1).status, 'aborted');
});
it('cancelJobs rejects a late success from an uploader that ignores abort', async () => {
let releaseUpload;
mockUploadFile.mock.mockImplementation(async () => new Promise((resolve) => {
releaseUpload = () => resolve({ download_url: 'https://doodstream.com/d/late', embed_url: null, file_code: 'late' });
}));
const mgr = new UploadManager({});
const settled = [];
const progress = [];
let summary = null;
mgr.on('job-settled', (event) => settled.push(event));
mgr.on('progress', (event) => progress.push(event));
mgr.on('batch-done', (value) => { summary = value; });
const batchPromise = mgr.startBatch([{
jobId: 'late-success-job',
file: '/test/late-success.mp4',
hoster: 'doodstream.com',
apiKey: 'key1',
sourceCleanupToken: 'cleanup-late-success'
}]);
for (let index = 0; index < 50 && !releaseUpload; index++) {
await new Promise((resolve) => setTimeout(resolve, 5));
}
mgr.cancelJobs(['late-success-job']);
releaseUpload();
await batchPromise;
assert.equal(settled.at(-1).status, 'aborted');
assert.equal(progress.some(event => event.status === 'done'), false);
assert.equal(summary.succeeded, 0);
});
it('late success after cancellation stays blocked by the real source cleanup gate', async (t) => {
const directory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'mhu-manager-cleanup-'));
const file = path.join(directory, 'source.bin');
await fs.promises.writeFile(file, Buffer.from('source-data'));
t.after(() => fs.promises.rm(directory, { recursive: true, force: true }));
let releaseUpload;
mockUploadFile.mock.mockImplementation(async () => new Promise((resolve) => {
releaseUpload = () => resolve({ download_url: 'https://doodstream.com/d/late-cleanup', embed_url: null, file_code: 'late-cleanup' });
}));
const audits = [];
const cleanup = createSourceFileCleanup({
fs,
path,
platform: process.platform,
isEnabled: () => true,
audit: (event) => audits.push(event),
journal: { plan: async () => {}, clear: async () => {} }
});
await cleanup.registerGroups([{
token: 'cleanup-late-seam',
file,
requiredHosters: ['doodstream.com'],
completedHosters: [],
jobs: [{ jobId: 'late-cleanup-job', file, hoster: 'doodstream.com', status: 'pending' }]
}]);
const mgr = new UploadManager({});
let settleChain = Promise.resolve();
let summary = null;
mgr.on('job-settled', (event) => {
settleChain = settleChain.then(() => cleanup.settle(event));
});
mgr.on('batch-done', (value) => { summary = value; });
const batchPromise = mgr.startBatch([{
jobId: 'late-cleanup-job',
file,
hoster: 'doodstream.com',
apiKey: 'key1',
sourceCleanupToken: 'cleanup-late-seam'
}]);
for (let index = 0; index < 50 && !releaseUpload; index++) {
await new Promise((resolve) => setTimeout(resolve, 5));
}
mgr.cancelJobs(['late-cleanup-job']);
releaseUpload();
await batchPromise;
await settleChain;
const outcomes = await cleanup.finishBatch({ historyPersisted: true, queuePersisted: true });
assert.equal(summary.succeeded, 0);
assert.deepEqual(outcomes, ['blocked']);
assert.equal(audits.at(-1).outcome, 'blocked');
await fs.promises.access(file);
});
it('addJobs returns duplicate info and still runs newly queued jobs', async () => {
let releaseFirst = null;