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:
@@ -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),
|
||||
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user