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). 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 ## 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. - 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. - Reorder selected jobs, start selected jobs, retry finished jobs, or stop active work.
- Copy completed links individually or together. - Copy completed links individually or together.
- Keep formatted link logs separate from source-cleanup and upload-plan audit records.
### Accounts and automation ### Accounts and automation
+9 -5
View File
@@ -1,9 +1,11 @@
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const { getRotatedLogPath } = require('./log-rotation');
const READABLE_LOGS = { const READABLE_LOGS = {
debug: 'debug', debug: 'debug',
fileuploader: 'fileuploader', fileuploader: 'fileuploader',
uploadAudit: 'uploadAudit',
accountRotation: 'accountRotation', accountRotation: 'accountRotation',
crash: 'crashLog' crash: 'crashLog'
}; };
@@ -38,7 +40,7 @@ function createCollectors(deps) {
const paths = getAllLogPaths(); const paths = getAllLogPaths();
let p = paths[key]; let p = paths[key];
if (!p) return null; if (!p) return null;
if (backup === 1 || backup === 2) p = `${p}.${backup}`; if (backup === 1 || backup === 2) p = getRotatedLogPath(p, backup);
return p; return p;
} }
@@ -67,15 +69,17 @@ function createCollectors(deps) {
const paths = getAllLogPaths(); const paths = getAllLogPaths();
const dir = paths.logDir; const dir = paths.logDir;
const files = []; const files = [];
const readableNames = new Set();
for (const [name, key] of Object.entries(READABLE_LOGS)) { for (const [name, key] of Object.entries(READABLE_LOGS)) {
const base = paths[key]; const base = paths[key];
if (!base) continue; if (!base) continue;
const variants = []; const variants = [];
for (const suffix of ['', '.1', '.2']) { for (const backup of [0, 1, 2]) {
const fp = base + suffix; const fp = backup === 0 ? base : getRotatedLogPath(base, backup);
readableNames.add(path.basename(fp));
try { try {
const st = fs.statSync(fp); 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 {} } catch {}
} }
files.push({ name, path: base, readable: true, present: variants.length > 0, variants }); files.push({ name, path: base, readable: true, present: variants.length > 0, variants });
@@ -84,7 +88,7 @@ function createCollectors(deps) {
try { try {
siblings = fs.readdirSync(dir) siblings = fs.readdirSync(dir)
.filter(f => /\.log(\.\d+)?$/i.test(f)) .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 => { siblings = siblings.map(f => {
let size = 0, mtime = null; let size = 0, mtime = null;
try { const st = fs.statSync(path.join(dir, f)); size = st.size; mtime = st.mtime.toISOString(); } catch {} 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 fs = require('fs');
const path = require('path'); 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 = () => {}) { function maybeRotateLogFile(filePath, maxBytes, maxBackups = 3, log = () => {}) {
if (!filePath || !Number.isFinite(maxBytes) || maxBytes <= 0) return false; if (!filePath || !Number.isFinite(maxBytes) || maxBytes <= 0) return false;
let size = 0; let size = 0;
@@ -29,24 +37,22 @@ function maybeRotateLogFile(filePath, maxBytes, maxBackups = 3, log = () => {})
} }
if (size <= maxBytes) return false; 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 // Drop the oldest backup if it exists, then shift each numbered backup up
// one slot. Errors are ignored: missing intermediate backups are normal, // one slot. Errors are ignored: missing intermediate backups are normal,
// failed renames just mean we'll rotate again next time. // 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--) { 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 { try {
fs.renameSync(filePath, `${base}.1${ext}`); const backupPath = getRotatedLogPath(filePath, 1);
log(`logRotation: rotated ${filePath} (${(size / 1024 / 1024).toFixed(1)} MB) → ${base}.1${ext}`); fs.renameSync(filePath, backupPath);
log(`logRotation: rotated ${filePath} (${(size / 1024 / 1024).toFixed(1)} MB) → ${backupPath}`);
return true; return true;
} catch (err) { } catch (err) {
log(`logRotation: rename ${filePath}${base}.1${ext} failed: ${err.message}`); log(`logRotation: rename ${filePath}${getRotatedLogPath(filePath, 1)} failed: ${err.message}`);
return false; return false;
} }
} }
module.exports = { maybeRotateLogFile }; module.exports = { getRotatedLogPath, maybeRotateLogFile };
+13 -7
View File
@@ -42,6 +42,9 @@ function redactLogText(text, secrets) {
} }
} }
out = out 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(/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(/(\/\/[^\s/:@]+:)[^\s/@]+(@)/g, '$1' + REDACTED + '$2')
.replace(/(authorization:\s*(?:bearer|basic)\s+)\S+/gi, '$1' + REDACTED) .replace(/(authorization:\s*(?:bearer|basic)\s+)\S+/gi, '$1' + REDACTED)
@@ -66,13 +69,15 @@ function valueScrub(value, secrets) {
return JSON.parse(scrubbed); 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`; if (!filePath) return `=== ${label} ===\n<no path configured>\n\n`;
let stat; let stat;
try { stat = fs.statSync(filePath); } try { stat = fs.statSync(filePath); }
catch (err) { catch (err) {
if (err && err.code === 'ENOENT') return `=== ${label} (${filePath}) ===\n<file does not exist yet>\n\n`; const context = includePath ? ` (${filePath})` : '';
return `=== ${label} (${filePath}) ===\n<stat error: ${err.message}>\n\n`; 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; const cap = Number.isFinite(maxBytes) && maxBytes > 0 ? maxBytes : 5 * 1024 * 1024;
let content; let content;
@@ -90,10 +95,11 @@ function collectFile(filePath, label, maxBytes) {
} catch (err) { } catch (err) {
content = `<read error: ${err.message}>`; 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 = []; const parts = [];
parts.push('=== Multi-Hoster-Upload Support Bundle ===\n'); parts.push('=== Multi-Hoster-Upload Support Bundle ===\n');
if (header && typeof header === 'object') { if (header && typeof header === 'object') {
@@ -101,10 +107,10 @@ function buildSupportBundleText({ header, sanitizedConfig, files }) {
} }
parts.push('\n'); parts.push('\n');
parts.push('=== Config (sanitized — password/apiKey/token/cookie/sessionId redacted) ===\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'); parts.push('\n\n');
for (const f of (files || [])) { 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(''); 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 }; 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; if (typeof module !== 'undefined' && module.exports) module.exports = api;
else if (root) root.UploadLog = api; else if (root) root.UploadLog = api;
})(typeof window !== 'undefined' ? window : this); })(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.activeJobs = new Map(); // uploadId -> { jobId, speedKbs, bytesUploaded, hoster }
this.jobAbortControllers = new Map(); // jobId -> AbortController this.jobAbortControllers = new Map(); // jobId -> AbortController
this.cancelledJobIds = new Set(); this.cancelledJobIds = new Set();
this.pendingCancelledJobIds = new Set();
this.pendingCancelAll = false;
this.sessionBytes = 0; this.sessionBytes = 0;
this._transientErrorTotal = 0; this._transientErrorTotal = 0;
this.lastStartTime = {}; // hoster -> timestamp of last upload start this.lastStartTime = {}; // hoster -> timestamp of last upload start
@@ -330,14 +332,20 @@ class UploadManager extends EventEmitter {
} }
async startBatch(tasks, opts = {}) { async startBatch(tasks, opts = {}) {
const pendingCancelledJobIds = new Set(this.pendingCancelledJobIds);
const pendingCancelAll = this.pendingCancelAll;
this.pendingCancelledJobIds.clear();
this.pendingCancelAll = false;
this.running = true; this.running = true;
this.stopAfterActive = false; this.stopAfterActive = pendingCancelAll;
this.abortController = new AbortController(); this.abortController = new AbortController();
if (pendingCancelAll) this.abortController.abort();
this.startTime = Date.now(); this.startTime = Date.now();
this.sessionBytes = 0; this.sessionBytes = 0;
this.activeJobs.clear(); this.activeJobs.clear();
this.jobAbortControllers.clear(); this.jobAbortControllers.clear();
this.cancelledJobIds.clear(); this.cancelledJobIds.clear();
for (const jobId of pendingCancelledJobIds) this.cancelledJobIds.add(jobId);
this._doodApiKeyCache.clear(); // re-derive doodstream keys fresh each batch 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._baselineCache.clear(); // re-fetch baselines per batch (a long batch could outlast remote-side relevance)
this.semaphores = {}; this.semaphores = {};
@@ -447,6 +455,7 @@ class UploadManager extends EventEmitter {
const maxAttempts = Math.max(1, (settings.retries || 0) + 1); const maxAttempts = Math.max(1, (settings.retries || 0) + 1);
const jobAbortController = new AbortController(); const jobAbortController = new AbortController();
if (this.cancelledJobIds.has(jobId)) jobAbortController.abort();
const { signal, cleanup: cleanupSignals } = this._combineSignals(batchSignal, jobAbortController.signal); const { signal, cleanup: cleanupSignals } = this._combineSignals(batchSignal, jobAbortController.signal);
this.jobAbortControllers.set(jobId, jobAbortController); this.jobAbortControllers.set(jobId, jobAbortController);
@@ -500,6 +509,12 @@ class UploadManager extends EventEmitter {
}; };
try { try {
if (signal.aborted || this.cancelledJobIds.has(jobId)) {
const error = 'Abgebrochen';
emitFinalStatus('aborted', { error, attempt: 0 });
recordFinalResult('aborted', { error });
return;
}
if (fileNotFound) { if (fileNotFound) {
const error = 'Datei nicht gefunden'; const error = 'Datei nicht gefunden';
emitFinalStatus('skipped', { error, attempt: 0 }); emitFinalStatus('skipped', { error, attempt: 0 });
@@ -714,6 +729,8 @@ class UploadManager extends EventEmitter {
const result = await this._executeUpload(task, progressCb, uploadSignalBundle.signal, throttle, fileProbe); 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); const elapsed = Math.round((Date.now() - jobStart) / 1000);
this.sessionBytes += fileSize; this.sessionBytes += fileSize;
this.activeJobs.delete(uploadId); this.activeJobs.delete(uploadId);
@@ -847,6 +864,12 @@ class UploadManager extends EventEmitter {
this._noteSuspectReject(task.hoster, task.accountId, fileSize); this._noteSuspectReject(task.hoster, task.accountId, fileSize);
const alt = await this._trySuspectRejectAlternates(task, { uploadId, jobId, fileName, fileSize, settings, signal, fileProbe }); const alt = await this._trySuspectRejectAlternates(task, { uploadId, jobId, fileName, fileSize, settings, signal, fileProbe });
if (alt) { 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 }); emitFinalStatus('done', { result: alt.result, speedKbs: alt.speedKbs, elapsed: alt.elapsed, attempt: 1 });
recordFinalResult('done', { result: alt.result }); recordFinalResult('done', { result: alt.result });
return; return;
@@ -1017,6 +1040,7 @@ class UploadManager extends EventEmitter {
: hosterThrottle || globalThrottle; : hosterThrottle || globalThrottle;
const result = await this._executeUpload(task, progressCb, signal, throttle, fileProbe); 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.activeJobs.delete(uploadId);
this.sessionBytes += fileSize; this.sessionBytes += fileSize;
emitFinalStatus('done', { result, speedKbs: currentSpeedKbs, elapsed: Math.round((Date.now() - jobStart) / 1000), attempt }); emitFinalStatus('done', { result, speedKbs: currentSpeedKbs, elapsed: Math.round((Date.now() - jobStart) / 1000), attempt });
@@ -1159,6 +1183,7 @@ class UploadManager extends EventEmitter {
: hosterThrottle || globalThrottle; : hosterThrottle || globalThrottle;
try { try {
const result = await this._executeUpload(task, progressCb, signal, throttle, fileProbe); 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.activeJobs.delete(uploadId);
this.sessionBytes += fileSize; this.sessionBytes += fileSize;
this._suspectGoodAccounts.set(task.hoster, account.id); this._suspectGoodAccounts.set(task.hoster, account.id);
@@ -1442,6 +1467,7 @@ class UploadManager extends EventEmitter {
cancelJobs(jobIds) { cancelJobs(jobIds) {
for (const jobId of jobIds || []) { for (const jobId of jobIds || []) {
if (!jobId) continue; if (!jobId) continue;
if (!this.running) this.pendingCancelledJobIds.add(jobId);
this.cancelledJobIds.add(jobId); this.cancelledJobIds.add(jobId);
const controller = this.jobAbortControllers.get(jobId); const controller = this.jobAbortControllers.get(jobId);
if (controller && !controller.signal.aborted) { if (controller && !controller.signal.aborted) {
@@ -1455,7 +1481,10 @@ class UploadManager extends EventEmitter {
} }
cancel() { cancel() {
if (!this.running) return; if (!this.running) {
this.pendingCancelAll = true;
return;
}
this.abortController.abort(); this.abortController.abort();
this.stopAfterActive = true; this.stopAfterActive = true;
for (const controller of this.jobAbortControllers.values()) { for (const controller of this.jobAbortControllers.values()) {
+62 -35
View File
@@ -27,7 +27,8 @@ const { walkFolderAsync } = require('./lib/file-discovery');
const RemoteServer = require('./lib/remote-server'); const RemoteServer = require('./lib/remote-server');
const { maybeRotateLogFile } = require('./lib/log-rotation'); const { maybeRotateLogFile } = require('./lib/log-rotation');
const { hosterLogToFileEnabled } = require('./lib/log-policy'); 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 { selectOrphanTmps } = require('./lib/orphan-tmp');
const { sanitizeConfig, buildSupportBundleText, collectSecretValues, redactLogText, valueScrub, collectFile, REDACTED } = require('./lib/support-bundle'); const { sanitizeConfig, buildSupportBundleText, collectSecretValues, redactLogText, valueScrub, collectFile, REDACTED } = require('./lib/support-bundle');
const { buildWebhookRequest, isAllAborted } = require('./lib/webhook-notify'); const { buildWebhookRequest, isAllAborted } = require('./lib/webhook-notify');
@@ -495,12 +496,15 @@ function _flushRotLog() {
} }
function getAllLogPaths() { function getAllLogPaths() {
const upload = getLogFilePath(); const configuredUpload = getLogFilePath();
const uploadTarget = _resolveUploadLogTarget();
const upload = uploadTarget ? uploadTarget.path : configuredUpload;
const debugPath = getDebugLogPath(); const debugPath = getDebugLogPath();
const rot = getRotLogPath(); const rot = getRotLogPath();
const dir = path.dirname(debugPath); const dir = path.dirname(debugPath);
return { return {
fileuploader: upload, fileuploader: upload,
uploadAudit: _uploadAuditWriter.getActivePath() || getUploadAuditLogPath(upload),
debug: debugPath, debug: debugPath,
accountRotation: rot, accountRotation: rot,
doodstreamDebug: path.join(dir, 'doodstream-debug.log'), doodstreamDebug: path.join(dir, 'doodstream-debug.log'),
@@ -774,13 +778,13 @@ function _invalidateUploadLogTargetCache() {
_cachedUploadLogKey = ''; _cachedUploadLogKey = '';
} }
function _resolveUploadLogTarget() { function _resolveUploadLogTarget(excludedPath) {
const primary = getLogFilePath(); const primary = getLogFilePath();
// The primary path already encodes the mode + date/session, so it changes // 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 // when the user toggles mode, daily rolls at midnight, or this is a new
// process — cache invalidates naturally on path change. // process — cache invalidates naturally on path change.
const key = primary; const key = primary;
if (_cachedUploadLogKey === key && _cachedUploadLogTarget) return _cachedUploadLogTarget; if (_cachedUploadLogKey === key && _cachedUploadLogTarget && _cachedUploadLogTarget.path !== excludedPath) return _cachedUploadLogTarget;
const commit = (t) => { const commit = (t) => {
_cachedUploadLogTarget = t; _cachedUploadLogTarget = t;
@@ -789,22 +793,27 @@ function _resolveUploadLogTarget() {
}; };
// Try primary → desktop → userData, mirror the original fallback ladder. // Try primary → desktop → userData, mirror the original fallback ladder.
if (primary !== excludedPath) {
try { try {
fs.mkdirSync(path.dirname(primary), { recursive: true }); fs.mkdirSync(path.dirname(primary), { recursive: true });
return commit({ path: primary, isFallback: false }); return commit({ path: primary, isFallback: false });
} catch (err) { } catch (err) {
debugLog(`uploadLog primary dir unavailable (${err.message})`); debugLog(`uploadLog primary dir unavailable (${err.message})`);
} }
}
const desktop = getSafeDesktopDir(); const desktop = getSafeDesktopDir();
if (desktop) { if (desktop) {
try { try {
const p = buildFallbackLogName(desktop); const p = buildFallbackLogName(desktop);
if (p !== excludedPath) {
fs.mkdirSync(path.dirname(p), { recursive: true }); fs.mkdirSync(path.dirname(p), { recursive: true });
return commit({ path: p, isFallback: true }); return commit({ path: p, isFallback: true });
}
} catch {} } catch {}
} }
try { try {
const p = buildFallbackLogName(app.getPath('userData')); const p = buildFallbackLogName(app.getPath('userData'));
if (p === excludedPath) return null;
fs.mkdirSync(path.dirname(p), { recursive: true }); fs.mkdirSync(path.dirname(p), { recursive: true });
return commit({ path: p, isFallback: true }); return commit({ path: p, isFallback: true });
} catch (err) { } catch (err) {
@@ -818,6 +827,15 @@ function _resolveUploadLogTarget() {
// the disk. 50 MB ≈ ~600k log lines, plenty for human inspection. // the disk. 50 MB ≈ ~600k log lines, plenty for human inspection.
const UPLOAD_LOG_MAX_BYTES = 50 * 1024 * 1024; const UPLOAD_LOG_MAX_BYTES = 50 * 1024 * 1024;
const UPLOAD_LOG_MAX_BACKUPS = 3; 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() { function _flushUploadLog() {
if (_uploadLogWriting || _uploadLogBuffer.length === 0) return; if (_uploadLogWriting || _uploadLogBuffer.length === 0) return;
@@ -862,9 +880,9 @@ function _flushUploadLog() {
}); });
} }
function _persistFallbackLogPath(workingPath) { async function _persistFallbackLogPath(workingPath) {
try { try {
if (!settingsImportGate.canStartUpload()) return; if (!settingsImportGate.canStartUpload()) return false;
const cfg = configStore.load(); const cfg = configStore.load();
const gs = cfg.globalSettings || {}; const gs = cfg.globalSettings || {};
const mode = gs.logMode || 'single'; const mode = gs.logMode || 'single';
@@ -880,15 +898,17 @@ function _persistFallbackLogPath(workingPath) {
const base = path.basename(workingPath); const base = path.basename(workingPath);
toSave = path.join(dir, stripModeStampFromFileName(base)); toSave = path.join(dir, stripModeStampFromFileName(base));
} }
if (gs.logFilePath === toSave) return; if (gs.logFilePath === toSave) return true;
gs.logFilePath = toSave; gs.logFilePath = toSave;
cfg.globalSettings = gs; cfg.globalSettings = gs;
configStore.save({ globalSettings: gs }).catch(() => {}); await configStore.save({ globalSettings: gs });
_invalidateUploadLogTargetCache(); _invalidateUploadLogTargetCache();
_invalidateLogSettings(); _invalidateLogSettings();
safeSend('log-path-auto-updated', { logFilePath: toSave }); safeSend('log-path-auto-updated', { logFilePath: toSave });
return true;
} catch (err) { } catch (err) {
debugLog(`persist fallback logpath failed: ${err.message}`); 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) { async function appendSourceCleanupAudit(event) {
debugLog(`source-cleanup: ${event.outcome} ${event.file} trigger=${event.trigger || '-'}`); debugLog(`source-cleanup: ${event.outcome} ${event.file} trigger=${event.trigger || '-'}`);
const line = `# SOURCE-CLEANUP ${JSON.stringify(event)}\r\n`; return appendUploadAuditLine(`# SOURCE-CLEANUP ${JSON.stringify(event)}\r\n`, 'source-cleanup');
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; 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) { function flattenHistoryForExport(history) {
@@ -2008,11 +2021,12 @@ ipcMain.handle('start-upload', async (_event, payload) => {
const jobs = payload && Array.isArray(payload.jobs) ? payload.jobs : []; const jobs = payload && Array.isArray(payload.jobs) ? payload.jobs : [];
const isAutoRetry = !!(payload && payload.isAutoRetry); const isAutoRetry = !!(payload && payload.isAutoRetry);
const sourceCleanupGroups = payload && Array.isArray(payload.sourceCleanupGroups) ? payload.sourceCleanupGroups : []; 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 // At 500+ jobs JSON.stringify blew up the debug log with MB-sized lines
// per start-upload and added noticeable delay — log counts only. // per start-upload and added noticeable delay — log counts only.
logMarker('BATCH START', { files: files.length, hosters: hosters.length, jobs: jobs.length }); logMarker('BATCH START', batchPlan);
debugLog(`start-upload: files=${files.length}, hosters=${hosters.length}, jobs=${jobs.length}`); debugLog(`start-upload: files=${batchPlan.fileCount}, hosters=${batchPlan.destinationCount}, jobs=${batchPlan.plannedUploadCount}`);
const pick = makeAccountPicker(config); const pick = makeAccountPicker(config);
const tasks = jobs.length > 0 const tasks = jobs.length > 0
@@ -2037,6 +2051,7 @@ ipcMain.handle('start-upload', async (_event, payload) => {
debugLog(` tasks built: ${tasks.length}`); debugLog(` tasks built: ${tasks.length}`);
if (tasks.length === 0) { if (tasks.length === 0) {
await appendUploadPlanAudit(batchPlan, 'start');
const skippedSummary = stats.mergeSkippedIntoSummary({ const skippedSummary = stats.mergeSkippedIntoSummary({
id: `skipped-${Date.now()}`, id: `skipped-${Date.now()}`,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
@@ -2053,6 +2068,12 @@ ipcMain.handle('start-upload', async (_event, payload) => {
return { started: true, taskCount: 0, skippedJobs }; 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 = { const recovery = {
id: `upload-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`, id: `upload-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
startedAt: new Date().toISOString(), startedAt: new Date().toISOString(),
@@ -2085,10 +2106,6 @@ ipcMain.handle('start-upload', async (_event, payload) => {
// new upload; addJobs during a running batch keeps them). // new upload; addJobs during a running batch keeps them).
_jobLogCollector.clear(); _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({ const sourceCleanup = createSourceFileCleanup({
fs, fs,
path, path,
@@ -2101,8 +2118,10 @@ ipcMain.handle('start-upload', async (_event, payload) => {
try { try {
sourceCleanupFingerprints = await sourceCleanup.registerGroups(sourceCleanupGroups); sourceCleanupFingerprints = await sourceCleanup.registerGroups(sourceCleanupGroups);
} catch (error) { } catch (error) {
if (uploadManager === _thisManager) {
uploadManager = null; uploadManager = null;
globalThis._mhuUploadManagerRef = null; globalThis._mhuUploadManagerRef = null;
}
return { error: `Quelldatei-Schutz konnte nicht vorbereitet werden: ${error.message}` }; return { error: `Quelldatei-Schutz konnte nicht vorbereitet werden: ${error.message}` };
} }
for (const skipped of skippedJobs) sourceCleanup.markSkipped(skipped.jobId); 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) { if (!uploadManager || !uploadManager.running) {
return { error: 'Kein Upload aktiv' }; return { error: 'Kein Upload aktiv' };
} }
const batchManager = uploadManager;
const config = configStore.load(); const config = configStore.load();
const jobs = payload && Array.isArray(payload.jobs) ? payload.jobs : []; const jobs = payload && Array.isArray(payload.jobs) ? payload.jobs : [];
const sourceCleanupGroups = payload && Array.isArray(payload.sourceCleanupGroups) ? payload.sourceCleanupGroups : []; 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 const skippedJobs = jobs
.filter(j => j && j.id && !taskJobIds.has(j.id)) .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' })); .map(j => ({ jobId: j.id, hoster: j.hoster, reason: 'Kein gültiger Account für diesen Hoster' }));
const sourceCleanupFingerprints = uploadManager.sourceFileCleanup const sourceCleanupFingerprints = batchManager.sourceFileCleanup
? await uploadManager.sourceFileCleanup.registerGroups(sourceCleanupGroups) ? await batchManager.sourceFileCleanup.registerGroups(sourceCleanupGroups)
: {}; : {};
if (uploadManager.sourceFileCleanup) { if (uploadManager !== batchManager || !batchManager.running) {
for (const skipped of skippedJobs) uploadManager.sourceFileCleanup.markSkipped(skipped.jobId); return { error: 'Kein Upload aktiv' };
}
if (batchManager.sourceFileCleanup) {
for (const skipped of skippedJobs) batchManager.sourceFileCleanup.markSkipped(skipped.jobId);
} }
if (tasks.length === 0) { if (tasks.length === 0) {
debugLog(`add-jobs-to-batch: 0 tasks built (${skippedJobs.length} skipped: no account)`); 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 }; 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 added = typeof addResult === 'number' ? addResult : (addResult && addResult.added) || 0;
const alreadyInBatchJobIds = (addResult && Array.isArray(addResult.alreadyInBatchJobIds)) const alreadyInBatchJobIds = (addResult && Array.isArray(addResult.alreadyInBatchJobIds))
? addResult.alreadyInBatchJobIds ? addResult.alreadyInBatchJobIds
@@ -2382,6 +2406,7 @@ ipcMain.handle('add-jobs-to-batch', async (_event, payload) => {
debugLog( debugLog(
`add-jobs-to-batch: ${added} of ${tasks.length} tasks added (${alreadyInBatchJobIds.length} already in batch, ${skippedJobs.length} skipped)` `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 }; return { added, skippedJobs, alreadyInBatchJobIds, sourceCleanupFingerprints };
}); });
@@ -2534,11 +2559,13 @@ ipcMain.handle('create-support-bundle', async () => {
CreatedAt: new Date().toISOString() CreatedAt: new Date().toISOString()
}, },
sanitizedConfig: sanitizeConfig(cfg), sanitizedConfig: sanitizeConfig(cfg),
secrets: collectSecretValues(cfg),
files: [ files: [
{ label: 'debug.log (last 5 MB)', path: paths.debug, maxBytes: 5 * 1024 * 1024 }, { 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: '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: '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: '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 } { 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", "name": "multi-hoster-uploader",
"version": "2.1.18", "version": "2.1.19",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "multi-hoster-uploader", "name": "multi-hoster-uploader",
"version": "2.1.18", "version": "2.1.19",
"dependencies": { "dependencies": {
"chokidar": "^3.6.0", "chokidar": "^3.6.0",
"undici": "^7.29.0", "undici": "^7.29.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "multi-hoster-uploader", "name": "multi-hoster-uploader",
"version": "2.1.18", "version": "2.1.19",
"description": "Upload files to doodstream, voe, vidmoly, byse simultaneously", "description": "Upload files to doodstream, voe, vidmoly, byse simultaneously",
"main": "main.js", "main": "main.js",
"scripts": { "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 _jobIndexById = new Map(); // id -> job (O(1) lookup)
const _jobIndexByUploadId = new Map(); // uploadId -> job const _jobIndexByUploadId = new Map(); // uploadId -> job
const selectedJobIds = new Set(); const selectedJobIds = new Set();
let selectionAnchorJobId = null;
let _sessionTotalBytes = 0; // Total bytes ever added to queue this session let _sessionTotalBytes = 0; // Total bytes ever added to queue this session
let _sessionUploadedBytes = 0; // Bytes fully uploaded this session (done jobs) let _sessionUploadedBytes = 0; // Bytes fully uploaded this session (done jobs)
const _sessionTrackedJobs = new Set(); // Job IDs already counted for totalBytes const _sessionTrackedJobs = new Set(); // Job IDs already counted for totalBytes
@@ -1710,6 +1711,7 @@ function indexJob(job) {
function removeJobFromIndex(job, keepCompletedKey) { function removeJobFromIndex(job, keepCompletedKey) {
_jobIndexById.delete(job.id); _jobIndexById.delete(job.id);
if (job.uploadId) _jobIndexByUploadId.delete(job.uploadId); 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 // Track deletion so handleProgress() won't re-create this job from stale callbacks
_deletedJobIds.add(job.id); _deletedJobIds.add(job.id);
if (job.uploadId) _deletedJobIds.add(job.uploadId); if (job.uploadId) _deletedJobIds.add(job.uploadId);
@@ -1755,7 +1757,9 @@ function applyQueueSelectionClasses() {
const rows = tbody.getElementsByClassName('queue-row'); const rows = tbody.getElementsByClassName('queue-row');
for (let i = 0; i < rows.length; i++) { for (let i = 0; i < rows.length; i++) {
const tr = rows[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()) { 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)); const visibleIds = new Set(visibleJobs.map(job => job.id));
let changed = false; let changed = false;
for (const id of selectedJobIds) { for (const id of selectedJobIds) {
@@ -1972,6 +1979,9 @@ function _normalizeQueueSelectionToVisible(visibleJobs = _getVisibleQueueJobs())
changed = true; changed = true;
} }
} }
if (selectionAnchorJobId && !visibleIds.has(selectionAnchorJobId)) {
selectionAnchorJobId = selectedJobIds.values().next().value || null;
}
return changed; return changed;
} }
@@ -2230,10 +2240,13 @@ function handleRowClick(e, row) {
if (e.ctrlKey || e.metaKey) { if (e.ctrlKey || e.metaKey) {
if (selectedJobIds.has(jobId)) selectedJobIds.delete(jobId); if (selectedJobIds.has(jobId)) selectedJobIds.delete(jobId);
else selectedJobIds.add(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 // Use sorted jobs cache for correct shift-click with virtual scrolling
const sortedIds = _sortedJobsCache.map(j => j.id); 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); const curIdx = sortedIds.indexOf(jobId);
if (lastIdx >= 0 && curIdx >= 0) { if (lastIdx >= 0 && curIdx >= 0) {
const from = Math.min(lastIdx, curIdx); const from = Math.min(lastIdx, curIdx);
@@ -2243,6 +2256,7 @@ function handleRowClick(e, row) {
} else { } else {
selectedJobIds.clear(); selectedJobIds.clear();
selectedJobIds.add(jobId); selectedJobIds.add(jobId);
selectionAnchorJobId = jobId;
// Single click on done job -> copy link // Single click on done job -> copy link
const job = _jobIndexById.get(jobId); const job = _jobIndexById.get(jobId);
if (job && job.status === 'done' && job.result) { if (job && job.status === 'done' && job.result) {
@@ -2284,6 +2298,7 @@ function handleRowContextMenu(e, row) {
if (!selectedJobIds.has(jobId)) { if (!selectedJobIds.has(jobId)) {
selectedJobIds.clear(); selectedJobIds.clear();
selectedJobIds.add(jobId); selectedJobIds.add(jobId);
selectionAnchorJobId = jobId;
applyQueueSelectionClasses(); applyQueueSelectionClasses();
updateQueueActionButtons(); updateQueueActionButtons();
} }
@@ -2756,6 +2771,7 @@ document.addEventListener('keydown', (e) => {
const visibleJobs = _getVisibleQueueJobs(); const visibleJobs = _getVisibleQueueJobs();
selectedJobIds.clear(); selectedJobIds.clear();
visibleJobs.forEach(j => selectedJobIds.add(j.id)); visibleJobs.forEach(j => selectedJobIds.add(j.id));
selectionAnchorJobId = visibleJobs[0]?.id || null;
renderQueueTable(); renderQueueTable();
} }
} }
@@ -2801,7 +2817,7 @@ async function handleContextAction(action) {
const j = _jobIndexById.get(id); const j = _jobIndexById.get(id);
return j && (j.status === 'uploading' || j.status === 'queued' || j.status === 'retrying' || j.status === 'getting-server'); 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 = []; const _deletedKeys = [];
queueJobs = queueJobs.filter(j => { queueJobs = queueJobs.filter(j => {
if (selectedJobIds.has(j.id)) { if (selectedJobIds.has(j.id)) {
@@ -2812,6 +2828,7 @@ async function handleContextAction(action) {
return true; return true;
}); });
selectedJobIds.clear(); selectedJobIds.clear();
selectionAnchorJobId = null;
syncSelectedFilesFromQueue(); syncSelectedFilesFromQueue();
suppressPreviewKeysStillSelected(_deletedKeys); suppressPreviewKeysStillSelected(_deletedKeys);
renderQueueTable(); renderQueueTable();
@@ -2822,13 +2839,16 @@ async function handleContextAction(action) {
copyAllLinks(); copyAllLinks();
} else if (action === 'delete-all') { } 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; 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 const hasActiveJobs = uploading || queueJobs.some(j => j.status === 'uploading' || j.status === 'queued' || j.status === 'retrying' || j.status === 'getting-server');
.filter(j => j.status === 'uploading' || j.status === 'queued' || j.status === 'retrying' || j.status === 'getting-server') if (hasActiveJobs) {
.map(j => j.id); _cancelAutoRetry(true);
if (activeIds.length > 0) window.api.cancelSelectedJobs(activeIds); await window.api.cancelUpload();
uploading = false;
}
queueJobs.forEach(j => removeJobFromIndex(j)); queueJobs.forEach(j => removeJobFromIndex(j));
queueJobs = []; queueJobs = [];
selectedJobIds.clear(); selectedJobIds.clear();
selectionAnchorJobId = null;
selectedFiles = []; selectedFiles = [];
syncSelectedFilesFromQueue(); syncSelectedFilesFromQueue();
renderQueueTable(); renderQueueTable();
@@ -2853,6 +2873,7 @@ async function handleContextAction(action) {
return true; return true;
}); });
selectedJobIds.clear(); selectedJobIds.clear();
selectionAnchorJobId = null;
syncSelectedFilesFromQueue(); syncSelectedFilesFromQueue();
renderQueueTable(); renderQueueTable();
if (queueJobs.length === 0) { selectedFiles = []; updateUploadView(); } if (queueJobs.length === 0) { selectedFiles = []; updateUploadView(); }
@@ -3564,6 +3585,7 @@ async function retrySelectedJobs() {
// jobs the double render freezes the UI for multiple seconds. // jobs the double render freezes the UI for multiple seconds.
selectedJobIds.clear(); selectedJobIds.clear();
retryJobs.forEach(j => selectedJobIds.add(j.id)); retryJobs.forEach(j => selectedJobIds.add(j.id));
selectionAnchorJobId = retryJobs[0]?.id || null;
persistQueueStateSoon(); persistQueueStateSoon();
await startSelectedUpload(retryJobs); await startSelectedUpload(retryJobs);
} }
@@ -3575,13 +3597,16 @@ async function abortSelectedJobs() {
queueJobs.forEach((job) => { queueJobs.forEach((job) => {
if (!selectedJobIds.has(job.id)) return; if (!selectedJobIds.has(job.id)) return;
if (['preview', 'queued'].includes(job.status)) { if (job.status === 'preview') {
job.status = 'aborted'; job.status = 'aborted';
job.error = 'Abgebrochen'; job.error = 'Abgebrochen';
job.progress = 0; job.progress = 0;
job.uploadId = null; 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); activeJobIds.push(job.id);
job.status = 'aborted';
job.error = 'Abgebrochen';
job.progress = 0;
} }
}); });
@@ -3590,6 +3615,7 @@ async function abortSelectedJobs() {
} }
selectedJobIds.clear(); selectedJobIds.clear();
selectionAnchorJobId = null;
syncSelectedFilesFromQueue(); syncSelectedFilesFromQueue();
renderQueueTable(); renderQueueTable();
updateQueueActionButtons(); 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; } if (!paths || typeof paths !== 'object') { el.innerHTML = '<span class="hint">Pfade nicht verfügbar.</span>'; return; }
const entries = [ const entries = [
['fileuploader', 'fileuploader.log'], ['fileuploader', 'fileuploader.log'],
['uploadAudit', 'upload-audit.log'],
['debug', 'debug.log'], ['debug', 'debug.log'],
['accountRotation', 'account-rotation.log'], ['accountRotation', 'account-rotation.log'],
['doodstreamDebug', 'doodstream-debug.log'] ['doodstreamDebug', 'doodstream-debug.log']
@@ -6629,7 +6656,7 @@ function renderRecentUploadsPanel(_appendOnly = false) {
return; return;
} }
// Clear queue selection when clicking in recent panel — class-toggle only. // 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); const id = parseInt(tr.dataset.order, 10);
if (e.ctrlKey || e.metaKey) { if (e.ctrlKey || e.metaKey) {
if (selectedRecentIds.has(id)) selectedRecentIds.delete(id); 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 (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) { if (selectedJobIds.size > 0) {
selectedJobIds.clear(); selectedJobIds.clear();
selectionAnchorJobId = null;
renderQueueTable(); renderQueueTable();
updateQueueActionButtons(); updateQueueActionButtons();
} }
@@ -7658,6 +7686,7 @@ async function importUploadLog() {
if (removed > 0) { if (removed > 0) {
selectedJobIds.clear(); selectedJobIds.clear();
selectionAnchorJobId = null;
syncSelectedFilesFromQueue(); syncSelectedFilesFromQueue();
rebuildJobIndex(); rebuildJobIndex();
renderQueueTable(); 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-item" role="menuitem" tabindex="-1" data-action="copy-all-links">Alle Links kopieren</div>
<div class="ctx-separator"></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-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-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-item ctx-item-danger" role="menuitem" tabindex="-1">Hoster entfernen &#9656;</div>
<div class="ctx-submenu-items ctx-hoster-delete-items"></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); 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, .recent-file-row.selected,
.history-row.selected { .history-row.selected {
background: rgba(186, 208, 252, .1) !important; 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; min-height: 34px;
} }
.btn-primary, .btn-primary {
.btn-success {
border-color: transparent; border-color: transparent;
background: var(--accent); background: var(--accent);
color: var(--accent-ink); color: var(--accent-ink);
} }
.btn-primary:hover, .btn-primary:hover {
.btn-success:hover {
background: var(--accent-end); background: var(--accent-end);
filter: none; filter: none;
transform: 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 { .btn-secondary {
border-color: var(--border); border-color: var(--border);
background: var(--bg-raised); background: var(--bg-raised);
@@ -3348,6 +3362,14 @@ input[type="checkbox"] {
user-select: text; user-select: text;
} }
#upload-view {
user-select: none;
}
#upload-view :is(input, textarea, [contenteditable="true"]) {
user-select: text;
}
.settings-header h2 { .settings-header h2 {
font-size: 18px; font-size: 18px;
} }
+2
View File
@@ -58,6 +58,7 @@ const sourceFiles = [
'lib/throttle.js', 'lib/throttle.js',
'lib/throttled-cache.js', 'lib/throttled-cache.js',
'lib/updater.js', 'lib/updater.js',
'lib/upload-audit.js',
'lib/upload-log.js', 'lib/upload-log.js',
'lib/upload-confirmation.js', 'lib/upload-confirmation.js',
'lib/upload-diagnostics.js', 'lib/upload-diagnostics.js',
@@ -146,6 +147,7 @@ const sourceFiles = [
'tests/ui-network-safety.test.js', 'tests/ui-network-safety.test.js',
'tests/ui-smoke.js', 'tests/ui-smoke.js',
'tests/updater-version.test.js', 'tests/updater-version.test.js',
'tests/upload-audit.test.js',
'tests/upload-log.test.js', 'tests/upload-log.test.js',
'tests/upload-confirmation.test.js', 'tests/upload-confirmation.test.js',
'tests/upload-diagnostics.test.js', 'tests/upload-diagnostics.test.js',
+17
View File
@@ -18,6 +18,7 @@ function makeFixture() {
const fixtureZeta = ['WBHOOK', 'SECRET', 'TOKEN'].join(''); const fixtureZeta = ['WBHOOK', 'SECRET', 'TOKEN'].join('');
const paths = { const paths = {
fileuploader: path.join(dir, 'fileuploader.log'), fileuploader: path.join(dir, 'fileuploader.log'),
uploadAudit: path.join(dir, 'upload-audit.log'),
debug: path.join(dir, 'debug.log'), debug: path.join(dir, 'debug.log'),
accountRotation: path.join(dir, 'account-rotation.log'), accountRotation: path.join(dir, 'account-rotation.log'),
doodstreamDebug: path.join(dir, 'doodstream-debug.log'), doodstreamDebug: path.join(dir, 'doodstream-debug.log'),
@@ -25,6 +26,7 @@ function makeFixture() {
logDir: dir logDir: dir
}; };
fs.writeFileSync(paths.debug, `boot ok\nuploading file with token ${fixtureAlpha} inline\nAuthorization: Bearer ${fixtureBeta}\n`); 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.doodstreamDebug, `api_key=${fixtureGamma} sess=abc\n`);
fs.writeFileSync(paths.crashLog, 'CRASH at 12:00\n'); fs.writeFileSync(paths.crashLog, 'CRASH at 12:00\n');
const config = { 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', () => { test('readLog redacts a planted token and a Bearer line; doodstream is NOT readable; unknown name rejected', () => {
const { collectors } = makeFixture(); const { collectors } = makeFixture();
const dbg = collectors.readLog({ name: 'debug', tailKb: 64 }); 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(!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.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: '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: '../../etc/passwd' }).ok, false, 'arbitrary names are rejected (no path traversal)');
assert.equal(collectors.readLog({ name: 'crash' }).name, 'crash'); 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', () => { test('readLog grep is case-insensitive substring with | alternation, and is ReDoS-safe', () => {
const { paths } = makeFixture(); const { paths } = makeFixture();
const fs2 = require('fs'); 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); 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', () => { test('sanitizeConfig does not mutate input', () => {
const input = { hosters: { 'voe.sx': [{ password: 'secret' }] } }; const input = { hosters: { 'voe.sx': [{ password: 'secret' }] } };
const clone = JSON.parse(JSON.stringify(input)); 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, /=== Multi-Hoster-Upload Support Bundle ===/);
assert.match(text, /=== Config/); 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 captureVisual('00-language-picker.png');
await wc.executeJavaScript('document.getElementById("upload-tab").click()'); 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 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 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-debug.log']); 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')); 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)); 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); 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'); 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("|"))'); 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'); 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("|"); })()'); 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 the green save action', saveAfterLanguageChange === 'false|true'); check('Changing language enables a visibly green save action', saveAfterLanguageChange.disabled === false && saveAfterLanguageChange.success && saveAfterLanguageChange.green);
await wc.executeJavaScript('document.getElementById("saveSettingsBtn").click()'); await wc.executeJavaScript('document.getElementById("saveSettingsBtn").click()');
await waitUntil(() => wc.executeJavaScript('document.getElementById("saveSettingsBtn").disabled')); 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("|"); })()'); 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('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); 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'); 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'); 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 wc.executeJavaScript('document.querySelector("[data-settings-page=\\'logs\\']")?.click()');
await new Promise(resolve => setTimeout(resolve, 100)); 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("|"); })()'); 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()'); 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; })()'); 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?.(); releaseBlockedWrite?.();
for (let attempt = 0; attempt < 100 && !finalImportQueueStarted; attempt++) await new Promise(resolve => setTimeout(resolve, 10)); 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 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 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 }; })()');
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 }))');
releaseFinalImportQueue?.(); 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; await pendingImportEpoch;
const configAfterImportEpoch = await wc.executeJavaScript('window.api.getConfig()'); 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 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'); restoreInitialIpcHandler('save-pending-queue');
const importPersistFailureConfig = structuredClone(configAfterImportEpoch); 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 { test } = require('node:test');
const assert = require('node:assert'); 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'); const { partitionRestoredJobsByLog } = require('../lib/queue-dedup');
function previewJob(fileName, hoster) { 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)'); 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', () => { 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 completion = new Date(2026, 5, 19, 12, 0, 30);
const line = formatUploadLogLine(completion, 'voe.sx', 'link', 'a.mkv'); 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 { describe, it, mock, beforeEach } = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('path'); const path = require('path');
const { EventEmitter } = require('events'); 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 // We need to mock fs.statSync and the hoster upload functions before requiring upload-manager
// Use node:test mock.module (available in Node 22+) // 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')); 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 () => { it('addJobs returns duplicate info and still runs newly queued jobs', async () => {
let releaseFirst = null; let releaseFirst = null;