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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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('');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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()) {
|
||||||
|
|||||||
@@ -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));
|
async function appendUploadPlanAudit(plan, mode) {
|
||||||
const target = _resolveUploadLogTarget();
|
debugLog(`upload-plan: mode=${mode} files=${plan.fileCount} destinations=${plan.destinationCount} uploads=${plan.plannedUploadCount}`);
|
||||||
if (!target) continue;
|
return appendUploadAuditLine(formatUploadPlanLogLine(new Date(), plan, mode), 'upload-plan');
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 }
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|||||||
Generated
+2
-2
@@ -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
@@ -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
@@ -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
@@ -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 ▸</div>
|
<div class="ctx-item ctx-item-danger" role="menuitem" tabindex="-1">Hoster entfernen ▸</div>
|
||||||
<div class="ctx-submenu-items ctx-hoster-delete-items"></div>
|
<div class="ctx-submenu-items ctx-hoster-delete-items"></div>
|
||||||
|
|||||||
+27
-5
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user