release: v2.1.16

Add queue search and filters, safe upload diagnostics, session report exports, account check visibility, and explicit interrupted-upload recovery.\n\nSanitize diagnostic response snippets in persisted results and rotation logs, protect CSV exports against formula injection, and add regression coverage for diagnostics and reports.
This commit is contained in:
Sucukdeluxe
2026-08-12 12:04:47 +02:00
parent c843b4e623
commit a299fc204b
16 changed files with 361 additions and 17 deletions
+16
View File
@@ -81,6 +81,7 @@ const DEFAULTS = {
showDropTarget: false,
globalMaxSpeedKbs: 0, // 0 = unlimited global speed
pendingQueue: null,
uploadRecovery: null,
scramble: {
active: false,
prefix: '',
@@ -584,6 +585,19 @@ class ConfigStore {
}, options);
}
saveUploadRecovery(uploadRecovery, options = {}) {
const snapshot = uploadRecovery === null || uploadRecovery === undefined ? null : this._clone(uploadRecovery);
return this._enqueueWrite(() => {
const current = this.load();
current.globalSettings = {
...(current.globalSettings || {}),
uploadRecovery: snapshot
};
this._guardHosters(current, false);
return this._commit(current);
}, options);
}
saveLastBrowseDirectory(directory) {
const snapshot = String(directory || '').trim();
return this._enqueueWrite(() => {
@@ -607,6 +621,7 @@ class ConfigStore {
current.globalSettings = {
...snapshot,
pendingQueue: currentGlobalSettings.pendingQueue ?? null,
uploadRecovery: currentGlobalSettings.uploadRecovery ?? null,
lastBrowseDirectory: currentGlobalSettings.lastBrowseDirectory || '',
diagnostics: this._clone(currentGlobalSettings.diagnostics || {}),
historyRetention: currentGlobalSettings.historyRetention || 'all',
@@ -640,6 +655,7 @@ class ConfigStore {
const current = this.load();
const globalSettings = this._clone(config.globalSettings);
globalSettings.pendingQueue = current.globalSettings.pendingQueue ?? null;
globalSettings.uploadRecovery = current.globalSettings.uploadRecovery ?? null;
return this._commit({
hosters: this._clone(config.hosters),
hosterSettings: this._clone(config.hosterSettings),
+73
View File
@@ -0,0 +1,73 @@
function normalNumber(value) {
const number = Number(value);
return Number.isFinite(number) && number > 0 ? number : 0;
}
const { normalizeFailureDetails } = require('./upload-diagnostics');
function safeError(value) {
const text = normalizeFailureDetails({ payloadSnippet: value })?.responseSnippet || '';
return text || 'Unbekannter Fehler';
}
function createHosterSummary() {
return { total: 0, succeeded: 0, failed: 0, skipped: 0, aborted: 0, bytes: 0, durationSec: 0, attempts: 0, errors: {} };
}
function buildSessionReport(summary) {
const hosters = {};
const totals = createHosterSummary();
for (const file of Array.isArray(summary && summary.files) ? summary.files : []) {
const size = normalNumber(file && file.size);
for (const result of Array.isArray(file && file.results) ? file.results : []) {
const hoster = String(result && result.hoster || 'Unbekannt');
const bucket = hosters[hoster] || (hosters[hoster] = createHosterSummary());
const status = String(result && result.status || 'error');
for (const target of [bucket, totals]) {
target.total++;
target.bytes += size;
target.durationSec += normalNumber(result && result.durationSec);
target.attempts += normalNumber(result && result.attempt);
if (status === 'done') target.succeeded++;
else if (status === 'skipped') target.skipped++;
else if (status === 'aborted') target.aborted++;
else target.failed++;
if (status !== 'done' && result && result.error) {
const error = safeError(result.error);
target.errors[error] = (target.errors[error] || 0) + 1;
}
}
}
}
for (const target of [totals, ...Object.values(hosters)]) {
target.successRate = target.total ? target.succeeded / target.total : 0;
}
return {
generatedAt: new Date().toISOString(),
batchId: summary && summary.id ? String(summary.id) : '',
batchTimestamp: summary && summary.timestamp ? String(summary.timestamp) : '',
totals,
hosters
};
}
function csvCell(value) {
let text = value === null || value === undefined ? '' : String(value);
if (/^[=+\-@]/.test(text)) text = `'${text}`;
return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
}
function buildSessionReportCsv(report) {
const header = ['Hoster', 'Gesamt', 'Erfolgreich', 'Fehler', 'Übersprungen', 'Abgebrochen', 'Erfolgsrate', 'Bytes', 'Dauer Sekunden', 'Versuche', 'Fehlerdetails'];
const lines = [header.join(',')];
for (const [hoster, row] of Object.entries(report && report.hosters || {})) {
lines.push([
hoster, row.total, row.succeeded, row.failed, row.skipped, row.aborted,
`${Math.round((row.successRate || 0) * 100)}%`, row.bytes, row.durationSec, row.attempts,
Object.entries(row.errors || {}).map(([error, count]) => `${count}× ${error}`).join(' | ')
].map(csvCell).join(','));
}
return `${lines.join('\n')}\n`;
}
module.exports = { buildSessionReport, buildSessionReportCsv };
+33
View File
@@ -0,0 +1,33 @@
function cleanText(value, limit = 320) {
let text = String(value || '').replace(/[\r\n\t]+/g, ' ').replace(/\s+/g, ' ').trim();
if (!text) return '';
text = text.replace(/https?:\/\/[^\s"'<>]+/gi, (raw) => {
try {
const url = new URL(raw);
return `${url.host}${url.pathname}`;
} catch {
return '[URL]';
}
});
text = text.replace(/(["']?(?:api[_-]?key|token|password|cookie|authorization|session)["']?\s*[:=]\s*)[^,;\s}"']+/gi, '$1[redacted]');
if (/^(?:bearer|basic)\s+/i.test(text)) {
text = '[redacted authorization]';
}
return text.slice(0, limit);
}
function normalizeFailureDetails(diagnostic) {
if (!diagnostic || typeof diagnostic !== 'object') return null;
const httpStatus = Number.isInteger(Number(diagnostic.http)) && Number(diagnostic.http) >= 100 && Number(diagnostic.http) <= 599
? Number(diagnostic.http)
: null;
const contentType = cleanText(diagnostic.contentType, 120);
const responseSnippet = cleanText(diagnostic.payloadSnippet, 320);
const details = {};
if (httpStatus !== null) details.httpStatus = httpStatus;
if (contentType) details.contentType = contentType;
if (responseSnippet) details.responseSnippet = responseSnippet;
return Object.keys(details).length > 0 ? details : null;
}
module.exports = { normalizeFailureDetails };
+15 -1
View File
@@ -11,6 +11,7 @@ const ClouddropUploader = require('./clouddrop-upload');
const Semaphore = require('./semaphore');
const Throttle = require('./throttle');
const { probeFileHead } = require('./file-probe');
const { normalizeFailureDetails } = require('./upload-diagnostics');
const DEFAULT_SETTINGS = {
retries: 3,
@@ -433,6 +434,7 @@ class UploadManager extends EventEmitter {
const uploadId = crypto.randomBytes(8).toString('hex');
const jobId = task.jobId || uploadId;
const fileName = path.basename(task.file);
const jobStartedAt = Date.now();
let fileSize = 0;
let fileNotFound = false;
const cachedResult = results && results.get(task.file);
@@ -452,6 +454,8 @@ class UploadManager extends EventEmitter {
let finalResultRecorded = false;
let finalStatus = 'error';
let lastError = null;
let lastFailureDetails = null;
let finalAttempt = 0;
const recordFinalResult = (status, payload = {}) => {
if (finalResultRecorded) return;
@@ -462,6 +466,11 @@ class UploadManager extends EventEmitter {
hoster: task.hoster,
status,
error: payload.error || null,
accountId: task.accountId || null,
attempt: payload.attempt || finalAttempt,
maxAttempts,
durationSec: Number.isFinite(payload.elapsed) ? payload.elapsed : Math.round((Date.now() - jobStartedAt) / 1000),
failureDetails: payload.failureDetails || lastFailureDetails,
download_url: payload.result ? payload.result.download_url || null : null,
embed_url: payload.result ? payload.result.embed_url || null : null,
file_code: payload.result ? payload.result.file_code || null : null
@@ -482,6 +491,7 @@ class UploadManager extends EventEmitter {
elapsed: payload.elapsed || 0,
remaining: 0,
error: payload.error || null,
failureDetails: payload.failureDetails || lastFailureDetails,
result: payload.result || null,
attempt: payload.attempt || maxAttempts,
maxAttempts
@@ -582,6 +592,7 @@ class UploadManager extends EventEmitter {
const attemptsAllowed = memoSuspect ? 0 : maxAttempts;
for (let attempt = 1; attempt <= attemptsAllowed; attempt++) {
finalAttempt = attempt;
if (signal.aborted || this.stopAfterActive) break;
if (attempt > 1) {
@@ -721,6 +732,7 @@ class UploadManager extends EventEmitter {
const isSpeedRestart = speedAbort && speedAbort.signal.aborted && !signal.aborted;
if (!signal.aborted && !isSpeedRestart) {
const diag = (err && typeof err === 'object' && err.diagnostic) || {};
lastFailureDetails = normalizeFailureDetails(diag);
this._rotLog('upload-failure', {
jobId, hoster: task.hoster, accountId: task.accountId, fileName,
attempt,
@@ -733,7 +745,7 @@ class UploadManager extends EventEmitter {
detectedKind: (typeof fileProbe !== 'undefined' && fileProbe && fileProbe.kind) ? fileProbe.kind : null,
isVideoLike: !!(typeof fileProbe !== 'undefined' && fileProbe && fileProbe.isVideoLike),
headHex: (typeof fileProbe !== 'undefined' && fileProbe && fileProbe.headHex) ? fileProbe.headHex.slice(0, 32) : null,
payloadSnippet: diag.payloadSnippet || null
payloadSnippet: lastFailureDetails ? lastFailureDetails.responseSnippet || null : null
});
}
if (signal.aborted) {
@@ -955,6 +967,7 @@ class UploadManager extends EventEmitter {
// loop iterates: marks this account failed too, asks main for the next
// fallback, and so on.
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
finalAttempt = attempt;
if (signal.aborted || this.stopAfterActive) break;
if (attempt > 1) {
this._emitProgress(uploadId, fileName, task.hoster, { accountId: task.accountId,
@@ -1011,6 +1024,7 @@ class UploadManager extends EventEmitter {
} catch (err) {
this.activeJobs.delete(uploadId);
lastError = err;
lastFailureDetails = normalizeFailureDetails(err && err.diagnostic);
if (!signal.aborted) {
this._rotLog('upload-failure', {
jobId, hoster: task.hoster, accountId: task.accountId, fileName,