release: restore v2.1.19 baseline for v2.1.24
CI / verify (push) Has been cancelled

Restore the v2.1.19 application baseline and retain only the focused import preflight summary with duplicate, unavailable, destination, job, and size-limit visibility.
This commit is contained in:
Sucukdeluxe
2026-08-17 04:25:22 +02:00
parent 9a213a7395
commit d7c9f287e4
87 changed files with 2185 additions and 16125 deletions
-188
View File
@@ -1,188 +0,0 @@
const { classifyErrorCategory } = require('./stats');
const { redactLogText } = require('./support-bundle');
function number(value) {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
}
function integer(value) {
return Math.max(0, Math.trunc(number(value)));
}
function iso(value, fallback) {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? fallback : date.toISOString();
}
function text(value, secrets, limit = 500) {
const source = value instanceof Error ? value.message : String(value ?? '');
return String(redactLogText(source, secrets) || '').slice(0, limit);
}
function redactPosixPaths(value) {
let output = '';
let index = 0;
while (index < value.length) {
const previous = value[index - 1] || '';
if (value[index] !== '/' || (index > 0 && !/[\s=:([{]/.test(previous))) {
output += value[index++];
continue;
}
let end = index + 1;
while (end < value.length && !/[\s"'<>|]/.test(value[end])) end++;
const candidate = value.slice(index, end);
if (candidate.slice(1).includes('/')) {
output += '<redacted-path>';
index = end;
continue;
}
output += value[index++];
}
return output;
}
function errorText(value, secrets) {
return redactPosixPaths(text(value, secrets)
.replace(/https?:\/\/[^\s"'<>]+/gi, '<redacted-url>'))
.replace(/\b[A-Za-z0-9_-]{24,}\b/g, '<redacted>');
}
function fileName(value, secrets) {
const name = String(value ?? '').split(/[\\/]/).pop() || '';
return text(name, secrets, 260);
}
function createJobTotals() {
return { total: 0, succeeded: 0, failed: 0, skipped: 0, aborted: 0 };
}
function createHostTotals() {
return { ...createJobTotals(), successfulBytes: 0 };
}
function addStatus(target, status) {
target.total++;
if (status === 'done') target.succeeded++;
else if (status === 'skipped') target.skipped++;
else if (status === 'aborted') target.aborted++;
else target.failed++;
}
function buildCleanupTotals(outcomes) {
const totals = { requested: 0, deleted: 0, blocked: 0, failed: 0 };
for (const value of Array.isArray(outcomes) ? outcomes : []) {
const outcome = String(value || 'failed');
if (outcome === 'setting-disabled') continue;
totals.requested++;
if (outcome === 'deleted') totals.deleted++;
else if (outcome === 'blocked' || outcome === 'source-changed' || outcome === 'source-missing' || outcome === 'unsafe-source-type') totals.blocked++;
else totals.failed++;
}
return totals;
}
function deepFreeze(value) {
if (!value || typeof value !== 'object' || Object.isFrozen(value)) return value;
Object.values(value).forEach(deepFreeze);
return Object.freeze(value);
}
function buildBatchCompletionReport(input = {}) {
const summary = input.summary && typeof input.summary === 'object' ? input.summary : {};
const secrets = Array.isArray(input.secrets) ? input.secrets : [];
const completedAt = iso(input.completedAt, new Date().toISOString());
const startedAt = iso(input.startedAt ?? summary.timestamp, completedAt);
const durationSec = Math.max(0, (new Date(completedAt).getTime() - new Date(startedAt).getTime()) / 1000);
const files = { total: 0, fullySucceeded: 0, partiallySucceeded: 0, failed: 0 };
const jobs = createJobTotals();
const hostMap = new Map();
const errors = [];
let successfulBytes = 0;
for (const file of Array.isArray(summary.files) ? summary.files : []) {
const results = Array.isArray(file?.results) ? file.results : [];
if (results.length === 0) continue;
files.total++;
const size = number(file?.size);
const successful = results.filter(result => result?.status === 'done').length;
if (successful === results.length) files.fullySucceeded++;
else if (successful > 0) files.partiallySucceeded++;
else files.failed++;
const safeFileName = fileName(file?.name ?? file?.fileName, secrets);
for (const result of results) {
const status = String(result?.status || 'error');
const hoster = text(result?.hoster || 'unknown', secrets, 120) || 'unknown';
if (!hostMap.has(hoster)) hostMap.set(hoster, createHostTotals());
const host = hostMap.get(hoster);
addStatus(jobs, status);
addStatus(host, status);
if (status === 'done') {
successfulBytes += size;
host.successfulBytes += size;
}
if (status === 'error' || result?.remoteCommitUncertain === true) {
const message = errorText(result?.error || 'Unknown error', secrets);
errors.push({
jobId: text(result?.jobId, secrets, 160),
fileName: safeFileName,
hoster,
status,
category: classifyErrorCategory(message),
attempt: integer(result?.attempt),
maxAttempts: integer(result?.maxAttempts),
remoteCommitUncertain: result?.remoteCommitUncertain === true,
message
});
}
}
}
const hosters = Object.fromEntries([...hostMap.entries()].sort(([left], [right]) => left.localeCompare(right)));
const batchId = text(summary.id, secrets, 160);
const report = {
reportId: text(input.reportId || `report-${batchId || completedAt}`, secrets, 200),
batchId,
startedAt,
completedAt,
generatedAt: completedAt,
durationSec,
files,
jobs,
cleanup: buildCleanupTotals(input.cleanupOutcomes),
transfer: {
successfulBytes,
averageBytesPerSecond: durationSec > 0 ? successfulBytes / durationSec : 0
},
hosters,
errors
};
return deepFreeze(report);
}
function csvCell(value) {
let output = value === null || value === undefined ? '' : String(value);
if (/^[\u0000-\u0020]*[=+\-@]/.test(output)) output = `'${output}`;
return /[",\r\n]/.test(output) ? `"${output.replace(/"/g, '""')}"` : output;
}
function buildBatchErrorCsv(report) {
const rows = [['Job ID', 'File name', 'Host', 'Status', 'Category', 'Attempt', 'Max attempts', 'Remote commit uncertain', 'Message']];
for (const error of Array.isArray(report?.errors) ? report.errors : []) {
rows.push([
error.jobId,
error.fileName,
error.hoster,
error.status,
error.category,
integer(error.attempt),
integer(error.maxAttempts),
error.remoteCommitUncertain === true ? 'true' : 'false',
error.message
]);
}
return `${rows.map(row => row.map(csvCell).join(',')).join('\n')}\n`;
}
module.exports = { buildBatchCompletionReport, buildBatchErrorCsv };
-55
View File
@@ -1,55 +0,0 @@
function createBatchMutationGate() {
let activeLeaseCount = 0;
let sealed = false;
let activeAtSeal = false;
let drainPromise = null;
let resolveDrain = null;
function acquire() {
if (sealed) return null;
activeLeaseCount += 1;
let open = true;
return Object.freeze({
finish() {
if (!open) return false;
open = false;
activeLeaseCount -= 1;
if (sealed && activeLeaseCount === 0 && resolveDrain) {
const resolve = resolveDrain;
resolveDrain = null;
resolve(activeAtSeal);
}
return true;
},
isOpen() {
return open;
}
});
}
function sealAndDrain() {
if (drainPromise) return drainPromise;
sealed = true;
activeAtSeal = activeLeaseCount > 0;
if (!activeAtSeal) {
drainPromise = Promise.resolve(false);
return drainPromise;
}
drainPromise = new Promise((resolve) => {
resolveDrain = resolve;
});
return drainPromise;
}
return Object.freeze({ acquire, sealAndDrain });
}
module.exports = { createBatchMutationGate };
+22 -157
View File
@@ -2,7 +2,6 @@ const fs = require('fs');
const path = require('path');
const secretStore = require('./secret-store');
const { normalizeLogMode } = require('./log-mode');
const { normalizeUploadSchedule } = require('./upload-schedule');
const HOSTER_SETTINGS_DEFAULTS = {
retries: 3,
@@ -79,18 +78,6 @@ const DEFAULTS = {
lastBrowseDirectory: '',
removeFromQueueOnDone: false,
deleteSourceAfterSuccessfulUpload: false,
filenameFilter: {
enabled: false,
action: 'include',
matchMode: 'all',
conditions: []
},
uploadSchedule: {
enabled: false,
weekdays: [1, 2, 3, 4, 5, 6, 0],
start: '00:00',
end: '23:59'
},
showDropTarget: false,
globalMaxSpeedKbs: 0, // 0 = unlimited global speed
pendingQueue: null,
@@ -145,20 +132,6 @@ const HISTORY_RETENTION_OPTIONS = [
{ value: '100', label: 'Letzte 100 Uploads' }
];
const DIAGNOSTIC_ERROR_MESSAGES = Object.freeze({
DIAGNOSTIC_CONFIG_READ_FAILED: 'Die Diagnosekonfiguration konnte nicht gelesen werden',
DIAGNOSTIC_CONFIG_INVALID: 'Die Diagnosekonfiguration ist ungültig',
DIAGNOSTIC_HISTORY_NOT_FOUND: 'Die Diagnoseverlaufsdatei wurde nicht gefunden',
DIAGNOSTIC_HISTORY_READ_FAILED: 'Die Diagnoseverlaufsdatei konnte nicht gelesen werden',
DIAGNOSTIC_HISTORY_INVALID: 'Die Diagnoseverlaufsdatei ist ungültig'
});
function diagnosticStoreError(code) {
const error = new Error(DIAGNOSTIC_ERROR_MESSAGES[code]);
error.code = code;
return error;
}
function batchTimestampMs(batch) {
const raw = batch && batch.timestamp;
if (raw === null || raw === undefined || raw === '') return null;
@@ -251,30 +224,6 @@ class ConfigStore {
}
}
_readHistoryFileStrict() {
let raw;
try {
raw = fs.readFileSync(this.historyPath, 'utf-8');
} catch (error) {
if (error && error.code === 'ENOENT') {
throw diagnosticStoreError('DIAGNOSTIC_HISTORY_NOT_FOUND');
}
throw diagnosticStoreError('DIAGNOSTIC_HISTORY_READ_FAILED');
}
if (!raw || raw.trim().length < 2) {
throw diagnosticStoreError('DIAGNOSTIC_HISTORY_INVALID');
}
let parsed;
try {
parsed = JSON.parse(raw);
} catch {
throw diagnosticStoreError('DIAGNOSTIC_HISTORY_INVALID');
}
if (Array.isArray(parsed)) return parsed;
if (parsed && Array.isArray(parsed.history)) return parsed.history;
throw diagnosticStoreError('DIAGNOSTIC_HISTORY_INVALID');
}
_writeHistoryFileDurable(arr) {
const tmp = this.historyPath + '.tmp';
const fd = fs.openSync(tmp, 'w');
@@ -287,35 +236,15 @@ class ConfigStore {
fs.renameSync(tmp, this.historyPath);
}
async _writeHistoryFileAtomic(arr) {
const tmp = this.historyPath + '.tmp';
let handle;
let operationError;
try {
handle = await fs.promises.open(tmp, 'w');
await handle.writeFile(JSON.stringify(arr), 'utf-8');
await handle.sync();
} catch (error) {
operationError = error;
}
if (handle) {
try {
await handle.close();
} catch (error) {
if (!operationError) operationError = error;
}
}
if (operationError) throw operationError;
await fs.promises.rename(tmp, this.historyPath);
let directoryHandle;
try {
directoryHandle = await fs.promises.open(path.dirname(this.historyPath), 'r');
await directoryHandle.sync();
} catch (error) {
if (!['EINVAL', 'EISDIR', 'EPERM', 'ENOTSUP'].includes(error.code)) throw error;
} finally {
if (directoryHandle) await directoryHandle.close();
}
_writeHistoryFileAtomic(arr) {
return new Promise((resolve, reject) => {
const tmp = this.historyPath + '.tmp';
fs.writeFile(tmp, JSON.stringify(arr), 'utf-8', (err) => {
if (err) return reject(err);
try { fs.renameSync(tmp, this.historyPath); } catch (e) { return reject(e); }
resolve();
});
});
}
_quiescedWriteError() {
@@ -393,31 +322,6 @@ class ConfigStore {
return JSON.parse(raw);
}
_readConfigFileStrict() {
let raw;
try {
raw = fs.readFileSync(this.filePath, 'utf-8');
} catch {
throw diagnosticStoreError('DIAGNOSTIC_CONFIG_READ_FAILED');
}
if (!raw || raw.trim().length < 2) {
throw diagnosticStoreError('DIAGNOSTIC_CONFIG_INVALID');
}
let data;
try {
data = JSON.parse(raw);
} catch {
throw diagnosticStoreError('DIAGNOSTIC_CONFIG_INVALID');
}
if (!data || typeof data !== 'object' || Array.isArray(data) ||
!data.hosters || typeof data.hosters !== 'object' || Array.isArray(data.hosters) ||
!data.globalSettings || typeof data.globalSettings !== 'object' || Array.isArray(data.globalSettings) ||
(data.history !== undefined && !Array.isArray(data.history))) {
throw diagnosticStoreError('DIAGNOSTIC_CONFIG_INVALID');
}
return data;
}
_clone(obj) {
try { return structuredClone(obj); }
catch { return JSON.parse(JSON.stringify(obj)); }
@@ -456,11 +360,7 @@ class ConfigStore {
return r;
}
loadDiagnosticsConfig() {
return this._loadImpl(true);
}
_loadImpl(strict = false) {
_loadImpl() {
try {
// In-memory cache keyed on the file's mtime+size. The processed config
// (merged + credential-decrypted) is reparsed/re-decrypted from disk ONLY
@@ -472,27 +372,21 @@ class ConfigStore {
// long-running main-thread drag. load() always returns a CLONE so callers
// can mutate the result without corrupting the cache.
let stat = null;
if (!strict) {
try { stat = fs.statSync(this.filePath); } catch {}
}
try { stat = fs.statSync(this.filePath); } catch {}
const statKey = stat ? `${stat.mtimeMs}:${stat.size}` : '';
if (!strict && stat && this._cache && this._cacheKey === statKey) {
if (stat && this._cache && this._cacheKey === statKey) {
return this._clone(this._cache);
}
let data = null;
if (strict) {
data = this._readConfigFileStrict();
} else {
// Try main config
try { data = this._readAndParse(this.filePath); } catch {}
// Fallback to backup if main is empty/corrupt
if (!data) {
try { data = this._readAndParse(this.filePath + '.bak'); } catch {}
}
if (!data) {
try { data = this._readAndParse(this.filePath + '.pre-history-split.bak'); } catch {}
}
// Try main config
try { data = this._readAndParse(this.filePath); } catch {}
// Fallback to backup if main is empty/corrupt
if (!data) {
try { data = this._readAndParse(this.filePath + '.bak'); } catch {}
}
if (!data) {
try { data = this._readAndParse(this.filePath + '.pre-history-split.bak'); } catch {}
}
if (!data) {
const fresh = JSON.parse(JSON.stringify(DEFAULTS));
@@ -566,7 +460,6 @@ class ConfigStore {
// Downstream readers consume logMode only and must NOT derive from
// sessionLog at call sites.
globalSettings.logMode = normalizeLogMode(globalSettings);
globalSettings.uploadSchedule = normalizeUploadSchedule(globalSettings.uploadSchedule);
const rotationCursors = (data.rotationCursors && typeof data.rotationCursors === 'object' && !Array.isArray(data.rotationCursors))
? data.rotationCursors
: {};
@@ -574,13 +467,13 @@ class ConfigStore {
// Decrypt credentials stored with safeStorage so the rest of the app
// keeps working with plaintext in memory.
secretStore.decryptCredentials(result);
if (!strict && stat) {
if (stat) {
this._cache = result;
this._cacheKey = statKey;
}
return this._clone(result);
} catch (error) {
if (strict || error instanceof secretStore.SecretStoreError) throw error;
if (error instanceof secretStore.SecretStoreError) throw error;
const fresh = JSON.parse(JSON.stringify(DEFAULTS));
fresh.globalSettings.logMode = normalizeLogMode(fresh.globalSettings);
return fresh;
@@ -596,7 +489,6 @@ class ConfigStore {
const hosters = this._clone(config.hosters || {});
const globalSettings = this._clone(config.globalSettings || {});
delete globalSettings.allowPlaintextCredentialStorage;
globalSettings.uploadSchedule = normalizeUploadSchedule(globalSettings.uploadSchedule);
secretStore.encryptCredentials({ hosters });
return JSON.stringify({ ...config, globalSettings, hosters }, null, 2);
}
@@ -719,19 +611,6 @@ class ConfigStore {
});
}
saveFallbackLogPath(logFilePath) {
const snapshot = String(logFilePath || '').trim();
return this._enqueueWrite(() => {
const current = this.load();
current.globalSettings = {
...(current.globalSettings || {}),
logFilePath: snapshot
};
this._guardHosters(current, false);
return this._commit(current);
});
}
saveRendererGlobalSettings(globalSettings) {
const snapshot = this._clone(globalSettings || {});
return this._enqueueWrite(() => {
@@ -795,20 +674,6 @@ class ConfigStore {
return config.history || [];
}
loadDiagnosticsHistory() {
if (this._historyMigrated) return this._readHistoryFileStrict();
try {
return this._readHistoryFileStrict();
} catch (error) {
if (!error || error.code !== 'DIAGNOSTIC_HISTORY_NOT_FOUND') throw error;
}
const config = this.loadDiagnosticsConfig();
if (!Array.isArray(config.history)) {
throw diagnosticStoreError('DIAGNOSTIC_HISTORY_INVALID');
}
return config.history;
}
_atomicWrite(data) {
return new Promise((resolve, reject) => {
const tmpPath = this.filePath + '.tmp';
+4 -19
View File
@@ -1,5 +1,3 @@
const { valueScrub } = require('./support-bundle');
function createAgent(collectors) {
const OPS = {
get_system_info: (a) => collectors.getSystemInfo(a),
@@ -16,28 +14,15 @@ function createAgent(collectors) {
get_health: () => collectors.getHealth()
};
function redactResponse(value) {
try {
const redacted = typeof collectors.redactResponse === 'function'
? collectors.redactResponse(value)
: valueScrub(value, []);
const response = valueScrub(redacted, []);
if (!response || typeof response !== 'object' || Array.isArray(response)) throw new Error('invalid redaction result');
return response;
} catch {
return { ok: false, error: 'diagnostic response could not be safely returned' };
}
}
function handle(op, args) {
const fn = (typeof op === 'string' && Object.prototype.hasOwnProperty.call(OPS, op)) ? OPS[op] : null;
if (typeof fn !== 'function') return redactResponse({ ok: false, error: `unknown or non-readonly op: ${op}` });
if (typeof fn !== 'function') return { ok: false, error: `unknown or non-readonly op: ${op}` };
try {
const data = fn(args || {});
if (data && data.ok === false) return redactResponse(data);
return redactResponse({ ok: true, data });
if (data && data.ok === false) return data;
return { ok: true, data };
} catch (e) {
return redactResponse({ ok: false, error: String((e && e.message) || e) });
return { ok: false, error: String((e && e.message) || e) };
}
}
+33 -46
View File
@@ -16,30 +16,22 @@ function createCollectors(deps) {
const { loadConfig, loadHistory, getAllLogPaths, support, stats, appInfo, systemInfo, agentInfo } = deps;
function _secrets() {
return support.collectSecretValues(loadConfig());
}
function _currentHistory() {
if (typeof loadHistory === 'function') {
const history = loadHistory();
if (!Array.isArray(history)) throw new Error('History reader returned invalid data');
return history;
}
const cfg = loadConfig();
return Array.isArray(cfg && cfg.history) ? cfg.history : [];
}
function _historyContext() {
const secrets = _secrets();
return { history: _currentHistory(), secrets };
try { return support.collectSecretValues(loadConfig()); } catch { return []; }
}
function _deepRedact(value, secrets) {
return support.valueScrub(value, secrets || _secrets());
}
function redactResponse(value) {
return _deepRedact(value);
const s = secrets || _secrets();
const walk = (v) => {
if (typeof v === 'string') return support.redactLogText(v, s);
if (Array.isArray(v)) return v.map(walk);
if (v && typeof v === 'object') {
const o = {};
for (const k of Object.keys(v)) o[k] = walk(v[k]);
return o;
}
return v;
};
try { return walk(value); } catch { return value; }
}
function _resolveLogPath(name, backup) {
@@ -87,10 +79,10 @@ function createCollectors(deps) {
readableNames.add(path.basename(fp));
try {
const st = fs.statSync(fp);
variants.push({ id: backup === 0 ? name : `${name}:${backup}`, backup, fileName: path.basename(fp), sizeBytes: st.size, mtime: st.mtime.toISOString() });
variants.push({ backup, sizeBytes: st.size, mtime: st.mtime.toISOString() });
} catch {}
}
files.push({ id: name, name, fileName: path.basename(base), readable: true, present: variants.length > 0, variants });
files.push({ name, path: base, readable: true, present: variants.length > 0, variants });
}
let siblings = [];
try {
@@ -103,16 +95,16 @@ function createCollectors(deps) {
return { name: f, readable: false, sizeBytes: size, mtime };
});
} catch {}
return { files, otherLogs: siblings };
return { dir, files, otherLogs: siblings };
}
function readLog(args) {
const a = args || {};
const name = a.name;
const p = _resolveLogPath(name, a.backup);
if (!p) return { ok: false, error: 'unknown or non-readable log identifier' };
if (!p) return { ok: false, error: `unknown or non-readable log: ${name}` };
const tailKb = Math.min(Math.max(Number(a.tailKb) || 256, 1), 1024);
const raw = support.collectFile(p, name, tailKb * 1024, { includePath: false });
const raw = support.collectFile(p, name, tailKb * 1024);
let content = support.redactLogText(raw, _secrets());
let matchedLines;
if (a.grep && typeof a.grep === 'string' && a.grep.length <= 200) {
@@ -128,7 +120,7 @@ function createCollectors(deps) {
}
let sizeBytes = null;
try { sizeBytes = fs.statSync(p).size; } catch {}
return { id: name, name, fileName: path.basename(p), sizeBytes, returnedBytes: Buffer.byteLength(content), tailKb, matchedLines, content };
return { name, path: p, sizeBytes, returnedBytes: Buffer.byteLength(content), tailKb, matchedLines, content };
}
function getAppEvents(args) {
@@ -145,9 +137,10 @@ function createCollectors(deps) {
return { events: out.slice(-limit), truncated: out.length > limit };
}
function _historyErrors(history, opts, secrets) {
function _historyErrors(history, opts) {
const o = opts || {};
const sinceMs = Number.isFinite(o.sinceMs) ? o.sinceMs : null;
const secrets = _secrets();
const errors = [];
const byCategory = {};
for (const batch of (Array.isArray(history) ? history : [])) {
@@ -176,19 +169,15 @@ function createCollectors(deps) {
return { errors, byCategory };
}
function _listErrors(args, history, secrets) {
function listErrors(args) {
const a = args || {};
const { errors, byCategory } = _historyErrors(history, a, secrets);
const cfg = loadConfig();
const { errors, byCategory } = _historyErrors(cfg.history, a);
const limit = Math.min(Math.max(Number(a.limit) || 100, 1), 1000);
const window = Number.isFinite(a.sinceMs) ? `since ${new Date(a.sinceMs).toISOString()}` : 'all history';
return { window, total: errors.length, byCategory, errors: errors.slice(-limit) };
}
function listErrors(args) {
const { history, secrets } = _historyContext();
return _listErrors(args, history, secrets);
}
function getQueueState(args) {
const a = args || {};
const cfg = loadConfig();
@@ -218,11 +207,15 @@ function createCollectors(deps) {
return result;
}
function _getHistory(args, history, secrets) {
function getHistory(args) {
const a = args || {};
const history = typeof loadHistory === 'function'
? (loadHistory() || [])
: (Array.isArray(loadConfig().history) ? loadConfig().history : []);
const limit = Math.min(Math.max(Number(a.limit) || 20, 1), 200);
const perHoster = stats.summarizePerHoster([...history]);
const perHoster = stats.summarizePerHoster(history);
const recent = [...history].slice(-limit).reverse();
const secrets = _secrets();
const batches = recent.map(b => {
const out = { timestamp: b.timestamp || null, fileCount: Array.isArray(b.files) ? b.files.length : 0 };
if (a.includeFiles) {
@@ -241,11 +234,6 @@ function createCollectors(deps) {
return { totalBatches: history.length, returned: batches.length, perHoster, batches };
}
function getHistory(args) {
const { history, secrets } = _historyContext();
return _getHistory(args, history, secrets);
}
function getRotationState() {
const cfg = loadConfig();
return { rotationCursors: _deepRedact(cfg.rotationCursors || {}) };
@@ -265,10 +253,9 @@ function createCollectors(deps) {
const a = args || {};
const errorLimit = Math.min(Math.max(Number(a.errorLimit) || 20, 1), 200);
const errArgs = Number.isFinite(a.errorSinceMs) ? { sinceMs: a.errorSinceMs, limit: errorLimit } : { limit: errorLimit };
const { history: currentHistory, secrets } = _historyContext();
const errors = _listErrors(errArgs, currentHistory, secrets);
const errors = listErrors(errArgs);
const queue = getQueueState({ includeJobs: false });
const history = _getHistory({ limit: 5 }, currentHistory, secrets);
const history = getHistory({ limit: 5 });
const warnings = [];
if (queue.source === 'persisted' && queue.stale) warnings.push('queue state is from the persisted snapshot (may lag live state; UploadManager not introspected in this build).');
if (errors.total > 0) warnings.push(`${errors.total} non-success result(s) in the error window.`);
@@ -287,7 +274,7 @@ function createCollectors(deps) {
return {
getSystemInfo, getConfigRedacted, listLogs, readLog, getAppEvents,
listErrors, getQueueState, getHistory, getRotationState, getHealth, serverHealth,
redactResponse, READABLE_LOGS
READABLE_LOGS
};
}
+70 -209
View File
@@ -2,12 +2,6 @@ const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { request } = require('undici');
const {
createTransportError,
safeEndpoint,
sanitizeRemoteText,
summarizeResponse
} = require('./hoster-transport-error');
const BASE_URL = 'https://doodstream.com';
const USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
@@ -103,15 +97,8 @@ class DoodstreamUploader {
break;
} catch (err) {
if (opts.signal && opts.signal.aborted) throw err; // caller abort: don't retry
if (attempt >= 3) {
throw createTransportError('Doodstream: Webanfrage fehlgeschlagen', {
phase: 'web-request',
endpoint: url,
retryable: true,
transientNetwork: true
});
}
_debugLog(`_fetch transient (${attempt}/3) ${safeEndpoint(url) || 'unknown endpoint'}: ${err && err.name ? err.name : 'network error'}; retry`);
if (attempt >= 3) throw err;
_debugLog(`_fetch transient (${attempt}/3) ${url}: ${err && err.message}; retry`);
await new Promise(r => setTimeout(r, 400 * attempt));
}
}
@@ -180,35 +167,16 @@ class DoodstreamUploader {
// Explicit success response
} else if (json && json.message && /otp/i.test(json.message)) {
// OTP required — signal caller to collect OTP from user
const err = createTransportError(`Doodstream Login: ${sanitizeRemoteText(json.message)}`, {
phase: 'login',
endpoint: BASE_URL,
httpStatus: res.status,
contentType: res.headers && res.headers.get ? res.headers.get('content-type') : null,
body
});
const err = new Error(`Doodstream Login: ${json.message}`);
err.otpRequired = true;
throw err;
} else if (json && json.status === 'fail') {
throw createTransportError(`Doodstream Login: ${sanitizeRemoteText(json.message) || 'Login fehlgeschlagen'}`, {
phase: 'login',
endpoint: BASE_URL,
httpStatus: res.status,
contentType: res.headers && res.headers.get ? res.headers.get('content-type') : null,
body,
accountError: true
});
throw new Error(`Doodstream Login: ${json.message || 'Login fehlgeschlagen'}`);
} else if (body.includes('Dashboard')) {
// Got dashboard HTML directly — login worked
} else {
const msg = sanitizeRemoteText(json && json.message) || 'Login fehlgeschlagen';
throw createTransportError(`Doodstream Login: ${msg}`, {
phase: 'login',
endpoint: BASE_URL,
httpStatus: res.status,
contentType: res.headers && res.headers.get ? res.headers.get('content-type') : null,
body
});
const msg = (json && json.message) || 'Login fehlgeschlagen';
throw new Error(`Doodstream Login: ${msg}`);
}
}
@@ -252,7 +220,7 @@ class DoodstreamUploader {
const res = await this._fetch(BASE_URL + '/?op=upload_server');
const text = await res.text();
const ctype = (res.headers && res.headers.get) ? (res.headers.get('content-type') || '') : '';
_debugLog(`upload_server: status=${res.status} ctype=${ctype} response=${summarizeResponse(text, ctype)}`);
_debugLog(`upload_server: status=${res.status} ctype=${ctype} body(800)=${(text || '').slice(0, 800)}`);
let json;
try { json = JSON.parse(text); } catch { json = null; }
@@ -286,7 +254,7 @@ class DoodstreamUploader {
// Capture the form's real fields so upload() submits exactly what the
// browser would (file_title, submit_btn, …) instead of stale hardcoded ones.
this._uploadFormFields = this._parseUploadFormFields(html);
_debugLog(`upload_server: using form action node=${safeEndpoint(url)} sessLength=${this.sessId.length} fields=${Object.keys(this._uploadFormFields).join(',')}`);
_debugLog(`upload_server: using form action node=${url} sess=${this.sessId} fields=${Object.keys(this._uploadFormFields).join(',')}`);
return url;
}
@@ -297,19 +265,15 @@ class DoodstreamUploader {
// No upload server could be extracted. We MUST NOT silently fall back to a
// hardcoded node: that node is stale and accepts the bytes but returns an
// empty form (no filecode) — so the user wastes ~90s uploading 95 MB into a
// dead end and gets a cryptic "kein Filecode" 90s later. Fail fast with
// safe structured diagnostics.
_debugLog(`upload_server: no server response=${summarizeResponse(text, ctype)} page=${summarizeResponse(html, pageRes.headers && pageRes.headers.get ? pageRes.headers.get('content-type') : '')}`);
throw createTransportError('Doodstream: konnte Upload-Server nicht ermitteln', {
phase: 'upload-server',
endpoint: BASE_URL + '/?op=upload_server',
httpStatus: res.status,
contentType: ctype,
body: text,
retryable: res.status >= 500,
transientNetwork: res.status >= 500,
hosterTransient: res.status >= 500
});
// dead end and gets a cryptic "kein Filecode" 90s later. Fail fast and put
// the raw responses in the error so the real format change is diagnosable.
const urlHints = (html.match(/https?:\/\/[^'">\s]+/g) || []).slice(0, 4).join(' , ');
_debugLog(`upload_server: NO SERVER. upload-page html(2000)=${(html || '').slice(0, 2000)}`);
throw new Error(
`Doodstream: konnte Upload-Server nicht ermitteln (Endpoint geändert?). ` +
`op=upload_server status=${res.status} ctype=${ctype} body=${(text || '').slice(0, 300)} ` +
`| upload-page URL-Treffer: ${urlHints || 'keine'}`
);
}
/**
@@ -393,7 +357,7 @@ class DoodstreamUploader {
let uploadRes;
try {
uploadRes = await this._requestUpload(uploadUrl, {
uploadRes = await request(uploadUrl, {
method: 'POST',
headers: {
'Content-Type': `multipart/form-data; boundary=${boundary}`,
@@ -406,15 +370,15 @@ class DoodstreamUploader {
bodyTimeout: UPLOAD_TIMEOUT,
headersTimeout: 60000
});
} catch {
} catch (err) {
// Label which phase failed so a future "fetch failed"/"terminated" is
// attributable to the big upload POST vs the small bookend requests. The
// original message is preserved as a substring so upload-manager's
// transient classification still matches. NOTE: undici may surface
// "terminated"/"other side closed", which are not yet in that transient
// list — revisit if logs show them.
const mb = Math.round(bytesRead / 1048576);
throw createTransportError(`Doodstream Upload-POST nach ${mb} MB fehlgeschlagen`, {
phase: 'upload-request',
endpoint: uploadUrl,
retryable: true,
transientNetwork: true,
remoteCommitUncertain: true
});
throw new Error(`Doodstream Upload-POST (${mb} MB an ${uploadUrl}): ${err && err.message ? err.message : err}`);
}
const statusCode = uploadRes.statusCode;
@@ -430,69 +394,27 @@ class DoodstreamUploader {
}
}
let resText;
try {
resText = await uploadRes.body.text();
} catch {
throw createTransportError('Doodstream Upload-Antwort konnte nicht gelesen werden', {
phase: 'upload-response-read',
endpoint: uploadUrl,
retryable: true,
transientNetwork: true,
remoteCommitUncertain: true
});
}
const uploadContentType = uploadRes.headers && uploadRes.headers['content-type'];
_debugLog(`Upload response: ${summarizeResponse(resText, uploadContentType)}`);
const resText = await uploadRes.body.text();
_debugLog(`Upload response body (first 500): ${resText.slice(0, 500)}`);
if (statusCode >= 400) {
let payload;
try { payload = JSON.parse(resText); } catch {}
const msg = payload && payload.msg ? sanitizeRemoteText(payload.msg) : '';
throw createTransportError(`Doodstream Upload fehlgeschlagen${msg ? `: ${msg}` : ''}`, {
phase: 'upload-response',
endpoint: uploadUrl,
httpStatus: statusCode,
contentType: uploadContentType,
body: resText,
retryable: statusCode === 429 || statusCode >= 500,
transientNetwork: statusCode >= 500
});
const msg = payload && payload.msg ? payload.msg : resText.slice(0, 200);
throw new Error(`Doodstream Upload HTTP ${statusCode}: ${msg}`);
}
return this._parseUploadResponse(resText);
}
_requestUpload(url, options) {
return request(url, options);
}
/**
* Follow a redirect URL from upload server and extract filecode
*/
async _handleUploadResult(url) {
_debugLog(`Following upload result URL: ${safeEndpoint(url) || 'unknown endpoint'}`);
let res;
try {
res = await this._fetch(url);
} catch (error) {
if (error && typeof error === 'object') error.remoteCommitUncertain = true;
throw error;
}
let html;
try {
html = await res.text();
} catch {
throw createTransportError('Doodstream Ergebnis-Antwort konnte nicht gelesen werden', {
phase: 'upload-response-read',
endpoint: url,
retryable: true,
transientNetwork: true,
remoteCommitUncertain: true
});
}
const contentType = res.headers && typeof res.headers.get === 'function' ? res.headers.get('content-type') : '';
_debugLog(`Result page: ${summarizeResponse(html, contentType)}`);
_debugLog(`Following upload result URL: ${url}`);
const res = await this._fetch(url);
const html = await res.text();
_debugLog(`Result page (first 500): ${html.slice(0, 500)}`);
return this._parseUploadResponse(html);
}
@@ -536,12 +458,12 @@ class DoodstreamUploader {
// 3. Parse HTML form (XFileSharing two-step upload)
const hiddenFields = this._extractHiddenFields(resText);
_debugLog(`Hidden fields: ${Object.keys(hiddenFields).join(',')}`);
_debugLog(`Hidden fields: ${JSON.stringify(hiddenFields)}`);
// Check if filecode is already in hidden fields
const fnCode = hiddenFields.fn || hiddenFields.filecode || hiddenFields.file_code;
if (fnCode && fnCode.length >= 8) {
_debugLog(`Filecode from hidden field 'fn': length ${fnCode.length}`);
_debugLog(`Filecode from hidden field 'fn': ${fnCode}`);
// We still need to submit the form so doodstream registers the file
// But the filecode is the 'fn' value
}
@@ -552,7 +474,7 @@ class DoodstreamUploader {
// Ensure op=upload_result is set
if (!hiddenFields.op) hiddenFields.op = 'upload_result';
_debugLog(`Submitting upload_result fields: ${Object.keys(hiddenFields).join(',')}`);
_debugLog(`Submitting upload_result to ${BASE_URL}/ with fields: ${JSON.stringify(hiddenFields)}`);
const formData = new URLSearchParams(hiddenFields);
let followText = '';
try {
@@ -565,23 +487,18 @@ class DoodstreamUploader {
body: formData.toString()
});
followText = await followRes.text();
} catch {
} catch (err) {
// The file already uploaded to the CDN; this POST only registers it on
// doodstream's side. If it fails transiently (even after _fetch's own
// retries) but we already hold the filecode, the upload succeeded from
// the user's view — return it rather than discarding a done upload.
if (fnCode && fnCode.length >= 8) {
_debugLog(`upload_result submit failed; using existing filecode length ${fnCode.length}`);
_debugLog(`upload_result submit failed (${err && err.message}); using fn ${fnCode}`);
return this._buildResult(fnCode);
}
throw createTransportError('Doodstream Upload: Ergebnis konnte nicht registriert werden', {
phase: 'upload-result-submit',
endpoint: BASE_URL,
retryable: true,
transientNetwork: true
});
throw err;
}
_debugLog(`upload_result response: ${summarizeResponse(followText, '')}`);
_debugLog(`upload_result response (first 500): ${followText.slice(0, 500)}`);
// Try to find filecode in result page
const resultCode = this._findFilecodeInHtml(followText);
@@ -606,17 +523,11 @@ class DoodstreamUploader {
// download link being empty while the page structure is unchanged points
// at doodstream's backend, not at a parsing bug on our side.
const st = hiddenFields.st || '';
const safeStatus = sanitizeRemoteText(st, 100);
const fnInfo = fnCode ? `vorhanden(len ${fnCode.length})` : 'fehlt/leer';
const node = safeEndpoint(this._lastUploadUrl) || 'unbekannt';
_debugLog(`No filecode. st=${safeStatus || '?'} fn=${fnInfo} node=${node} response=${summarizeResponse(resText, 'text/html')}`);
const fnInfo = fnCode ? `"${fnCode}"(len ${fnCode.length})` : 'fehlt/leer';
const node = this._lastUploadUrl || '?';
_debugLog(`No filecode. st=${st} fn=${fnInfo} node=${node} CDN-body=${(resText || '').slice(0, 400)}`);
if (st && st !== 'OK') {
throw createTransportError(`Doodstream lehnt Datei ab (Server-Status: ${safeStatus || 'unbekannt'})`, {
phase: 'upload-result',
endpoint: this._lastUploadUrl || BASE_URL,
contentType: 'text/html',
body: resText
});
throw new Error(`Doodstream lehnt Datei ab (Server-Status: ${st}). CDN=${node}`);
}
// Empty form (no fn, no st) is a doodstream-side processing flake — same
// account + same file works on a later attempt. Tag it explicitly so the
@@ -625,42 +536,26 @@ class DoodstreamUploader {
// session and later batches hit `pre-job-swap-blocked` for no fault of
// the account). The flag is the primary signal; the message text is a
// belt-and-suspenders regex fallback in the classifier.
throw createTransportError(`Doodstream Upload: kein Filecode (st=${safeStatus || '?'}, fn=${fnInfo}, CDN=${node})`, {
phase: 'upload-result',
endpoint: this._lastUploadUrl || BASE_URL,
contentType: 'text/html',
body: resText,
retryable: true,
hosterTransient: true
});
const emptyLinkErr = new Error(`Doodstream Upload: kein Filecode — Server gab leeren Link zurück (st=${st || '?'}, fn=${fnInfo}, CDN=${node}). CDN-Antwort: ${(resText || '').slice(0, 200)}`);
emptyLinkErr.hosterTransient = true;
throw emptyLinkErr;
}
// 4. Fallback: follow form action as-is (for non-XFS forms)
const formAction = resText.match(/<form[^>]*action=['"]([^'"]+)['"]/i);
if (formAction) {
_debugLog(`Fallback: following form action ${safeEndpoint(formAction[1]) || 'unknown endpoint'}`);
_debugLog(`Fallback: following form action ${formAction[1]}`);
const formData = new URLSearchParams(hiddenFields);
let followText;
try {
const followRes = await this._fetch(formAction[1], {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Referer': BASE_URL + '/'
},
body: formData.toString()
});
followText = await followRes.text();
} catch {
throw createTransportError('Doodstream Upload: Redirect-Antwort konnte nicht gelesen werden', {
phase: 'upload-result-submit',
endpoint: formAction[1],
retryable: true,
transientNetwork: true,
remoteCommitUncertain: true
});
}
_debugLog(`Fallback response: ${summarizeResponse(followText, '')}`);
const followRes = await this._fetch(formAction[1], {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Referer': BASE_URL + '/'
},
body: formData.toString()
});
const followText = await followRes.text();
_debugLog(`Fallback response (first 500): ${followText.slice(0, 500)}`);
const fallbackCode = this._findFilecodeInHtml(followText);
if (fallbackCode) return this._buildResult(fallbackCode);
@@ -668,23 +563,10 @@ class DoodstreamUploader {
// Check if fn was in original hidden fields
if (fnCode && fnCode.length >= 8) return this._buildResult(fnCode);
throw createTransportError('Doodstream Upload: Redirect-Antwort ungültig', {
phase: 'upload-result',
endpoint: formAction[1],
body: followText,
hosterTransient: true,
retryable: true
});
throw new Error(`Doodstream Upload: Redirect-Antwort ungültig (${followText.slice(0, 150)})`);
}
throw createTransportError('Doodstream Upload: Keine gültige Antwort', {
phase: 'upload-result',
endpoint: this._lastUploadUrl || BASE_URL,
contentType: /<\s*(?:!doctype|html|body|form|input)\b/i.test(resText) ? 'text/html' : 'text/plain',
body: resText,
hosterTransient: true,
retryable: true
});
throw new Error(`Doodstream Upload: Keine gültige Antwort (Body: ${resText.slice(0, 150)})`);
}
/**
@@ -708,15 +590,7 @@ class DoodstreamUploader {
*/
_extractFromJson(payload) {
if (payload.status && Number(payload.status) !== 200 && payload.msg) {
throw createTransportError(`Doodstream Upload: ${sanitizeRemoteText(payload.msg) || 'Antwort wurde abgelehnt'}`, {
phase: 'upload-result',
endpoint: this._lastUploadUrl || BASE_URL,
httpStatus: Number(payload.status),
contentType: 'application/json',
body: JSON.stringify(payload),
retryable: Number(payload.status) === 429 || Number(payload.status) >= 500,
transientNetwork: Number(payload.status) >= 500
});
throw new Error(`Doodstream Upload: ${payload.msg}`);
}
let item = null;
@@ -728,28 +602,15 @@ class DoodstreamUploader {
}
if (!item) {
throw createTransportError('Doodstream Upload: Antwort enthielt kein Ergebnis', {
phase: 'upload-result',
endpoint: this._lastUploadUrl || BASE_URL,
contentType: 'application/json',
body: JSON.stringify(payload),
hosterTransient: true,
retryable: true
});
throw new Error(`Doodstream Upload fehlgeschlagen: ${payload.msg || JSON.stringify(payload).slice(0, 150)}`);
}
const fileCode = String(item.filecode || item.file_code || '').trim();
if (!fileCode) {
throw createTransportError('Doodstream Upload: Antwort enthielt keinen Filecode', {
phase: 'upload-result',
endpoint: this._lastUploadUrl || BASE_URL,
contentType: 'application/json',
body: JSON.stringify(payload),
hosterTransient: true,
retryable: true
});
}
return this._buildResult(fileCode);
const fileCode = item.filecode || item.file_code || '';
return {
download_url: item.download_url || item.protected_dl || (fileCode ? `https://doodstream.com/d/${fileCode}` : null),
embed_url: item.protected_embed || (fileCode ? `https://doodstream.com/e/${fileCode}` : null),
file_code: fileCode
};
}
_buildResult(fileCode) {
@@ -835,7 +696,7 @@ class DoodstreamUploader {
return key;
}
}
_debugLog(`api-key derive: ${candidates.length} candidate(s), none validated. response=${summarizeResponse(html, 'text/html')}`);
_debugLog(`api-key derive: ${candidates.length} candidate(s), none validated. settings html(2500)=${(html || '').slice(0, 2500)}`);
return null;
}
}
-70
View File
@@ -1,70 +0,0 @@
(function initFilenameFilter(root, factory) {
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
if (root) root.FilenameFilter = api;
})(typeof window !== 'undefined' ? window : globalThis, function createFilenameFilter() {
function normalizeFilenameFilter(value) {
const source = value && typeof value === 'object' ? value : {};
const conditions = Array.isArray(source.conditions)
? source.conditions.flatMap(condition => {
if (!condition || typeof condition !== 'object') return [];
const text = String(condition.value ?? '').trim();
if (!text) return [];
return [{
operator: condition.operator === 'notContains' ? 'notContains' : 'contains',
value: text
}];
})
: [];
return {
enabled: source.enabled === true,
action: source.action === 'exclude' ? 'exclude' : 'include',
matchMode: source.matchMode === 'any' ? 'any' : 'all',
conditions
};
}
function getFilename(entry) {
if (entry && typeof entry === 'object' && entry.name) return String(entry.name);
const source = entry && typeof entry === 'object' ? entry.path : entry;
return String(source ?? '').split(/[\\/]/).pop() || '';
}
function evaluateFilenameFilter(filename, value) {
const filter = normalizeFilenameFilter(value);
const active = filter.enabled && filter.conditions.length > 0;
if (!active) return { accepted: true, matched: false, active, filter };
const normalizedName = String(filename ?? '').toLowerCase();
const results = filter.conditions.map(condition => {
const contains = normalizedName.includes(condition.value.toLowerCase());
return condition.operator === 'notContains' ? !contains : contains;
});
const matched = filter.matchMode === 'any' ? results.some(Boolean) : results.every(Boolean);
const accepted = filter.action === 'exclude' ? !matched : matched;
return { accepted, matched, active, filter };
}
function applyFilenameFilter(entries, value) {
const filter = normalizeFilenameFilter(value);
const accepted = [];
const excluded = [];
for (const entry of Array.isArray(entries) ? entries : []) {
const evaluation = evaluateFilenameFilter(getFilename(entry), filter);
if (evaluation.accepted) accepted.push(entry);
else excluded.push(entry);
}
return {
total: accepted.length + excluded.length,
accepted,
excluded,
active: filter.enabled && filter.conditions.length > 0,
filter
};
}
return {
normalizeFilenameFilter,
evaluateFilenameFilter,
applyFilenameFilter
};
});
-91
View File
@@ -1,91 +0,0 @@
function normalizeContentType(value) {
const contentType = String(value || '').trim().slice(0, 120);
const parts = contentType.split(';').map(part => part.trim());
if (parts.length < 1 || parts.length > 2) return null;
const slashIndex = parts[0].indexOf('/');
if (slashIndex <= 0 || slashIndex === parts[0].length - 1) return null;
const tokenCharacters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789.+-_';
const validToken = token => Array.from(token).every(char => tokenCharacters.includes(char));
if (!validToken(parts[0].slice(0, slashIndex)) || !validToken(parts[0].slice(slashIndex + 1))) return null;
if (parts.length === 2) {
const charsetPrefix = 'charset=';
if (!parts[1].toLowerCase().startsWith(charsetPrefix)) return null;
const charset = parts[1].slice(charsetPrefix.length);
if (!charset || !validToken(charset)) return null;
}
return contentType;
}
function safeEndpoint(value) {
try {
const url = new URL(String(value || ''));
return `${url.hostname.toLowerCase()}${url.pathname}`;
} catch {
return null;
}
}
function safeEndpointHost(value) {
try {
return new URL(String(value || '')).hostname.toLowerCase();
} catch {
return null;
}
}
function responseKind(body, contentType) {
const text = String(body || '').trim();
if (!text) return 'empty';
const type = String(contentType || '').toLowerCase();
if (type.includes('json') || /^[\[{]/.test(text)) return 'json';
if (type.includes('html') || /<\s*(?:!doctype|html|body|form|input)\b/i.test(text)) return 'html';
return 'text';
}
function summarizeResponse(body, contentType) {
const text = String(body || '');
const kind = responseKind(text, contentType);
return `${kind} response (${Buffer.byteLength(text, 'utf8')} bytes)`;
}
function sanitizeRemoteText(value, limit = 180) {
let text = String(value || '').replace(/[\r\n\t]+/g, ' ').replace(/\s+/g, ' ').trim();
text = text.replace(/https?:\/\/[^\s"'<>]+/gi, (raw) => safeEndpoint(raw) || '[URL]');
text = text.replace(/\b(?:authorization|proxy-authorization|cookie|set-cookie)\s*[:=]\s*[^,]+/gi, '[redacted]');
text = text.replace(/((?:api[_-]?key|token|password|secret|session|sess[_-]?id|csrf)["']?\s*[:=]\s*["']?)[^\s,;"'<>]+/gi, '$1[redacted]');
text = text.replace(/(<(?:input|textarea)[^>]*(?:name|id)=["'][^"']*(?:key|token|password|secret|session|sess|csrf)[^"']*["'][^>]*(?:value=["']))[^"']*(["'])/gi, '$1[redacted]$2');
text = text.replace(/\b[A-Za-z0-9_-]{20,}\b/g, '[redacted]');
return text.slice(0, limit);
}
function createTransportError(message, options = {}) {
const httpStatus = Number(options.httpStatus);
const hasHttpStatus = Number.isInteger(httpStatus) && httpStatus >= 100 && httpStatus <= 599;
const contentType = normalizeContentType(options.contentType);
const endpointHost = safeEndpointHost(options.endpoint);
const kind = responseKind(options.body, contentType);
const suffix = hasHttpStatus ? ` (HTTP ${httpStatus})` : '';
const error = new Error(`${sanitizeRemoteText(message, 220)}${suffix}`);
error.diagnostic = {
phase: String(options.phase || 'transport').slice(0, 80),
http: hasHttpStatus ? httpStatus : null,
contentType,
safeEndpointHost: endpointHost,
responseKind: kind,
retryable: options.retryable === true,
payloadSnippet: summarizeResponse(options.body, contentType)
};
if (options.transientNetwork === true) error.transientNetwork = true;
if (options.hosterTransient === true) error.hosterTransient = true;
if (options.accountError === true) error.accountError = true;
if (options.fileRejected === true) error.fileRejected = true;
if (options.remoteCommitUncertain === true) error.remoteCommitUncertain = true;
return error;
}
module.exports = {
createTransportError,
safeEndpoint,
sanitizeRemoteText,
summarizeResponse
};
+133 -516
View File
@@ -2,7 +2,6 @@ const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { request } = require('undici');
const { createTransportError, sanitizeRemoteText } = require('./hoster-transport-error');
const UPLOAD_TIMEOUT = 1800000; // 30 minutes
const API_TIMEOUT = 45000; // 45 seconds
@@ -173,11 +172,10 @@ function parseDoodstreamResult(payload) {
item = result;
}
const fileCode = item.filecode || item.file_code || null;
return {
download_url: fileCode ? `https://doodstream.com/d/${fileCode}` : null,
embed_url: fileCode ? `https://doodstream.com/e/${fileCode}` : null,
file_code: fileCode
download_url: item.download_url || item.protected_dl || null,
embed_url: item.protected_embed || null,
file_code: item.filecode || item.file_code || null
};
}
@@ -236,7 +234,7 @@ function parseByseResult(payload) {
// wall, so we must rotate. File-specific rejections (Duplicate, wrong
// format, too small/large) ARE per-file and rotation is pointless.
const accountLevel = /(not enough (disk )?(space|storage)|insufficient (disk )?space|disk (space )?full|storage (exhausted|full|voll|limit)|quota (exceeded|voll|überschritten)|account (full|voll|suspended|banned))/i.test(perFileError);
const err = new Error(`Byse lehnte Datei ab: ${sanitizeRemoteText(perFileError)}`);
const err = new Error(`Byse lehnte Datei ab: ${perFileError}`);
if (accountLevel) {
err.accountError = true;
} else {
@@ -310,64 +308,32 @@ function createUploadBody(filePath, formFields, onProgress, throttle, signal) {
// --- API helper using built-in fetch (follows redirects automatically) ---
async function apiGet(url, signal, hosterName) {
async function apiGet(url, signal) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), API_TIMEOUT);
const onAbort = () => controller.abort();
if (signal) signal.addEventListener('abort', onAbort);
try {
let res;
try {
res = await fetch(url, {
method: 'GET',
signal: controller.signal,
redirect: 'follow'
});
} catch (err) {
if (signal && signal.aborted) throw err;
throw createTransportError(`${hosterName}: Upload-Server-Abfrage fehlgeschlagen`, {
phase: 'upload-server',
endpoint: url,
retryable: true,
transientNetwork: true
});
}
const res = await fetch(url, {
method: 'GET',
signal: controller.signal,
redirect: 'follow'
});
const text = await res.text();
const contentType = res.headers && typeof res.headers.get === 'function'
? res.headers.get('content-type')
: null;
let data;
try {
data = JSON.parse(text);
} catch {
throw createTransportError(`${hosterName}: Upload-Server-Antwort war kein JSON`, {
phase: 'upload-server',
endpoint: url,
httpStatus: res.status,
contentType,
body: text,
retryable: res.status >= 500,
transientNetwork: res.status >= 500
});
const err = new Error(`API-Antwort war kein JSON (HTTP ${res.status}): ${(text || '').slice(0, 200)}`);
if (res.status >= 500) err.transientNetwork = true;
throw err;
}
const apiStatus = Number(data && data.status);
const effectiveStatus = res.status < 200 || res.status >= 300
? res.status
: (apiStatus >= 400 ? apiStatus : null);
if (effectiveStatus) {
const retryable = effectiveStatus === 429 || effectiveStatus >= 500;
throw createTransportError(`${hosterName}: Upload-Server-Abfrage wurde abgelehnt`, {
phase: 'upload-server',
endpoint: url,
httpStatus: effectiveStatus,
contentType,
body: text,
retryable,
transientNetwork: effectiveStatus >= 500,
accountError: effectiveStatus === 401 || effectiveStatus === 403
});
if (data.status && [401, 403, 429, 500].includes(data.status)) {
const err = new Error(data.msg || data.message || JSON.stringify(data));
if (data.status === 500) err.transientNetwork = true;
throw err;
}
return data;
} finally {
@@ -381,14 +347,12 @@ async function apiGet(url, signal, hosterName) {
async function getUploadServer(hosterName, hosterConfig, apiKey, signal) {
let lastMessage = '';
let lastTransient = false;
let lastError = null;
for (let attempt = 1; attempt <= SERVER_RETRY_ATTEMPTS; attempt++) {
for (const endpoint of hosterConfig.serverEndpoints) {
const url = `${hosterConfig.apiBase}${endpoint}?key=${encodeURIComponent(apiKey)}`;
try {
const data = await apiGet(url, signal, hosterName);
lastError = null;
const data = await apiGet(url, signal);
const uploadUrl = extractUploadServerUrl(data, hosterConfig.apiBase);
if (uploadUrl) {
LAST_UPLOAD_SERVERS.set(hosterName, uploadUrl);
@@ -401,16 +365,12 @@ async function getUploadServer(hosterName, hosterConfig, apiKey, signal) {
if (apiMessage) lastMessage = apiMessage;
} catch (err) {
if (err.name === 'AbortError') throw err;
lastError = err;
if (err.message) lastMessage = err.message;
if (err.transientNetwork === true) lastTransient = true;
}
}
const retryable = lastError && lastError.diagnostic
? lastError.diagnostic.retryable === true
: shouldRetryServerLookup(lastMessage);
if (attempt < SERVER_RETRY_ATTEMPTS && retryable) {
if (attempt < SERVER_RETRY_ATTEMPTS && shouldRetryServerLookup(lastMessage)) {
await sleep(SERVER_RETRY_DELAY_MS, signal);
continue;
}
@@ -419,14 +379,11 @@ async function getUploadServer(hosterName, hosterConfig, apiKey, signal) {
}
const cachedServer = LAST_UPLOAD_SERVERS.get(hosterName);
const retryable = lastError && lastError.diagnostic
? lastError.diagnostic.retryable === true
: shouldRetryServerLookup(lastMessage);
if (cachedServer && retryable) {
if (cachedServer && shouldRetryServerLookup(lastMessage)) {
return cachedServer;
}
if (retryable && Array.isArray(hosterConfig.fallbackUploadServers)) {
if (shouldRetryServerLookup(lastMessage) && Array.isArray(hosterConfig.fallbackUploadServers)) {
for (const fallback of hosterConfig.fallbackUploadServers) {
const normalized = normalizeAbsoluteUrl(fallback, hosterConfig.apiBase);
if (normalized) {
@@ -437,291 +394,50 @@ async function getUploadServer(hosterName, hosterConfig, apiKey, signal) {
}
if (lastMessage) {
const e = lastError || createTransportError(`Kein Upload-Server für ${hosterName} erhalten`, {
phase: 'upload-server',
endpoint: hosterConfig.apiBase,
retryable
});
if (retryable) e.hosterTransient = true;
const e = new Error(`Kein Upload-Server erhalten: ${lastMessage}`);
// "no servers available" / busy / try-again is a transient hoster-side
// condition, not an account fault — tag it so the account isn't blacklisted.
// Genuine auth failures (invalid key / unauthorized / forbidden) make
// shouldRetryServerLookup return false and stay classified as account errors.
if (shouldRetryServerLookup(lastMessage)) e.hosterTransient = true;
if (lastTransient) e.transientNetwork = true;
throw e;
}
throw createTransportError(`Kein Upload-Server für ${hosterName} erhalten`, {
phase: 'upload-server',
endpoint: hosterConfig.apiBase
});
throw new Error('Kein Upload-Server erhalten. API-Key prüfen.');
}
async function _requestFileList(url, signal, phase, hosterName) {
let response;
try {
response = await request(url, {
method: 'GET', signal,
headers: { 'Accept': 'application/json', 'User-Agent': 'multi-hoster-uploader/1.1' },
headersTimeout: 30_000, bodyTimeout: 30_000
});
} catch (err) {
if (signal && signal.aborted) throw err;
throw createTransportError(`${hosterName}: Dateiliste konnte nicht geladen werden`, {
phase,
endpoint: url,
retryable: true,
transientNetwork: true
});
}
const contentType = response.headers && response.headers['content-type'];
let text;
try {
text = await response.body.text();
} catch (err) {
if (signal && signal.aborted) throw err;
throw createTransportError(`${hosterName}: Dateiliste konnte nicht gelesen werden`, {
phase,
endpoint: url,
httpStatus: response.statusCode,
contentType,
retryable: true,
transientNetwork: true
});
}
if (response.statusCode < 200 || response.statusCode >= 300) {
const retryable = response.statusCode === 429 || response.statusCode >= 500;
throw createTransportError(`${hosterName}: Dateiliste konnte nicht geladen werden`, {
phase,
endpoint: url,
httpStatus: response.statusCode,
contentType,
body: text,
retryable,
transientNetwork: response.statusCode >= 500
});
}
let data;
try {
data = JSON.parse(text);
} catch {
throw createTransportError(`${hosterName}: Dateiliste war kein JSON`, {
phase,
endpoint: url,
httpStatus: response.statusCode,
contentType,
body: text
});
}
if (!data || typeof data !== 'object') {
throw createTransportError(`${hosterName}: Dateiliste hatte ein ungültiges Format`, {
phase,
endpoint: url,
httpStatus: response.statusCode,
contentType,
body: text
});
}
const apiStatus = Number(data && data.status);
const statusText = typeof data.status === 'string' ? data.status.trim().toLowerCase() : '';
const semanticFailure = data.success === false
|| data.ok === false
|| data.status === false
|| /^(?:error|failed|failure|denied|invalid|rejected)$/.test(statusText);
if (apiStatus >= 400 || semanticFailure) {
const retryable = apiStatus === 429 || apiStatus >= 500;
throw createTransportError(`${hosterName}: Dateiliste wurde abgelehnt`, {
phase,
endpoint: url,
httpStatus: apiStatus >= 100 ? apiStatus : response.statusCode,
contentType,
body: text,
retryable,
transientNetwork: apiStatus >= 500
});
}
return data;
}
function _requireFileList(data, candidates, phase, hosterName, url) {
for (const candidate of candidates) {
if (Array.isArray(candidate)) return candidate;
}
throw createTransportError(`${hosterName}: Dateiliste hatte ein ungültiges Format`, {
phase,
endpoint: url,
httpStatus: 200,
contentType: 'application/json'
});
}
async function _fetchByseFileList(apiKey, signal, phase = 'recovery-poll') {
async function _fetchByseFileList(apiKey, signal) {
// Byse's file-list endpoint. Returns up to 100 most-recent files — enough
// to match the upload we just did against what the server has. The API
// shape is typical XFS: { status, msg, result: { files: [...] } } or
// { status, msg, files: [...] }.
const url = `https://api.byse.sx/file/list?key=${encodeURIComponent(apiKey)}&per_page=100&sort=date&order=desc`;
const data = await _requestFileList(url, signal, phase, 'Byse');
const src = _requireFileList(data, [
data.files,
data.result && data.result.files,
data.result
], phase, 'Byse', url);
return src.map(f => ({
file_code: String(f.file_code || f.filecode || '').trim(),
file_name: String(f.title || f.name || f.file_name || '').trim()
})).filter(f => f.file_code);
try {
const { body, statusCode } = await request(url, {
method: 'GET', signal,
headers: { 'Accept': 'application/json', 'User-Agent': 'multi-hoster-uploader/1.1' },
headersTimeout: 30_000, bodyTimeout: 30_000
});
const text = await body.text();
if (statusCode < 200 || statusCode >= 300) return [];
const data = JSON.parse(text);
const src = Array.isArray(data.files) ? data.files
: (data.result && Array.isArray(data.result.files) ? data.result.files
: (Array.isArray(data.result) ? data.result : []));
return src.map(f => ({
file_code: String(f.file_code || f.filecode || '').trim(),
file_name: String(f.title || f.name || f.file_name || '').trim()
})).filter(f => f.file_code);
} catch {
return [];
}
}
function _normalizeFileTitle(s) {
const normalized = String(s || '')
.normalize('NFKD')
.toLowerCase()
.replace(/\.[\p{Letter}\p{Number}]+$/u, '')
.replace(/\p{Variation_Selector}+/gu, '');
const alphanumeric = normalized
.replace(/\p{Mark}+/gu, '')
.replace(/[^\p{Letter}\p{Number}]+/gu, '');
if (alphanumeric) return alphanumeric;
const codePoints = Array.from(normalized, value => value.codePointAt(0).toString(16)).join('-');
return `symbols:${codePoints}`;
return String(s || '').toLowerCase().replace(/\.[a-z0-9]+$/i, '').replace(/[^a-z0-9]+/g, '');
}
function _normalizeRecoveryHoster(value) {
return String(value || '').normalize('NFKC').trim().toLowerCase();
}
function _normalizeRecoveryAccount(value) {
return String(value || '').normalize('NFKC').trim();
}
function _createRecoveryUncertainError() {
const error = new Error('Upload-Ergebnis für diesen Titel ist wegen eines möglichen Remote-Commits unsicher');
error.remoteCommitUncertain = true;
error.hosterTransient = true;
return error;
}
function _markRecoveryUncertain(recoveryClaim, error) {
if (!recoveryClaim) return error;
if (typeof recoveryClaim.markUncertain === 'function') {
return recoveryClaim.markUncertain(error);
}
const uncertainError = error && typeof error === 'object'
? error
: _createRecoveryUncertainError();
uncertainError.remoteCommitUncertain = true;
uncertainError.hosterTransient = true;
return uncertainError;
}
function _createAbortError() {
const error = new Error('Operation aborted');
error.name = 'AbortError';
return error;
}
function _waitForRecoveryTurn(predecessor, signal) {
if (!signal) return predecessor;
if (signal.aborted) return Promise.reject(_createAbortError());
return new Promise((resolve, reject) => {
const onAbort = () => {
signal.removeEventListener('abort', onAbort);
reject(_createAbortError());
};
signal.addEventListener('abort', onAbort, { once: true });
predecessor.then(
() => {
signal.removeEventListener('abort', onAbort);
resolve();
},
error => {
signal.removeEventListener('abort', onAbort);
reject(error);
}
);
});
}
function createRecoveryClaimRegistry() {
const accounts = new Map();
let nextClaimId = 1;
return {
forUpload(hosterName, apiKey, fileName) {
const accountIdentity = crypto.createHash('sha256')
.update(`${_normalizeRecoveryHoster(hosterName)}\0${_normalizeRecoveryAccount(apiKey)}`)
.digest('hex');
let account = accounts.get(accountIdentity);
if (!account) {
account = {
codes: new Map(),
titles: new Map()
};
accounts.set(accountIdentity, account);
}
const titleIdentity = _normalizeFileTitle(fileName);
let title = account.titles.get(titleIdentity);
if (!title) {
title = {
tail: Promise.resolve(),
uncertain: false
};
account.titles.set(titleIdentity, title);
}
const claimId = nextClaimId++;
return {
has(code) {
return account.codes.has(String(code || '').trim());
},
reserve(code) {
const normalized = String(code || '').trim();
if (!normalized) return false;
if (account.codes.has(normalized)) {
return account.codes.get(normalized) === claimId;
}
account.codes.set(normalized, claimId);
return true;
},
markUncertain(error) {
title.uncertain = true;
const uncertainError = error && typeof error === 'object'
? error
: _createRecoveryUncertainError();
uncertainError.remoteCommitUncertain = true;
uncertainError.hosterTransient = true;
return uncertainError;
},
isUncertain() {
return title.uncertain;
},
async runExclusive(operation, signal) {
const predecessor = title.tail;
let release;
const current = new Promise(resolve => {
release = resolve;
});
title.tail = predecessor.then(() => current, () => current);
try {
await _waitForRecoveryTurn(predecessor, signal);
if (title.uncertain) throw _createRecoveryUncertainError();
const result = await operation();
if (title.uncertain) throw _createRecoveryUncertainError();
return result;
} finally {
release();
}
}
};
},
clear() {
accounts.clear();
}
};
}
async function _resolveByseUploadByName(apiKey, fileName, baselineCodes, signal, recoveryClaim) {
if (!(baselineCodes instanceof Set)) return null;
async function _resolveByseUploadByName(apiKey, fileName, baselineCodes, signal) {
const expected = _normalizeFileTitle(fileName);
const POLL_ATTEMPTS = 15;
const POLL_DELAY_MS = 2000;
@@ -734,13 +450,8 @@ async function _resolveByseUploadByName(apiKey, fileName, baselineCodes, signal,
// poller could claim job B's newly appeared file and return the wrong
// URL. At the cost of a few false-negatives when byse mangles the
// filename beyond our normalizer, correctness for parallel uploads wins.
const matches = newFiles
.filter(f => _normalizeFileTitle(f.file_name) === expected)
.filter(f => !recoveryClaim || typeof recoveryClaim.has !== 'function' || !recoveryClaim.has(f.file_code));
if (matches.length > 1) return null;
if (matches.length === 1) {
const match = matches[0];
if (recoveryClaim && typeof recoveryClaim.reserve === 'function' && !recoveryClaim.reserve(match.file_code)) return null;
const match = newFiles.find(f => _normalizeFileTitle(f.file_name) === expected);
if (match) {
return {
download_url: `https://byse.sx/d/${match.file_code}`,
embed_url: `https://byse.sx/e/${match.file_code}`,
@@ -758,24 +469,34 @@ async function _resolveByseUploadByName(apiKey, fileName, baselineCodes, signal,
return null;
}
async function _fetchDoodstreamFileList(apiKey, signal, phase = 'recovery-poll') {
async function _fetchDoodstreamFileList(apiKey, signal) {
// doodapi.co file list: { msg, status:200, result: { files: [{ file_code, title, uploaded, ... }] } }
// sort=created&order=desc forces newest-first — VERIFIED against a real 90k-file
// account, where a single page without it could miss a just-uploaded file. The
// recovery only needs the most recent uploads, so page 1 newest-first suffices.
const url = `https://doodapi.co/api/file/list?key=${encodeURIComponent(apiKey)}&per_page=200&sort=created&order=desc`;
const data = await _requestFileList(url, signal, phase, 'Doodstream');
const files = _requireFileList(data, [data && data.result && data.result.files], phase, 'Doodstream', url);
return files.map(f => ({
file_code: String(f.file_code || f.filecode || '').trim(),
file_name: String(f.title || f.file_name || f.name || '').trim()
})).filter(f => f.file_code);
try {
const { body, statusCode } = await request(url, {
method: 'GET', signal,
headers: { 'Accept': 'application/json', 'User-Agent': 'multi-hoster-uploader/1.1' },
headersTimeout: 30_000, bodyTimeout: 30_000
});
const text = await body.text();
if (statusCode < 200 || statusCode >= 300) return [];
const data = JSON.parse(text);
const files = data && data.result && Array.isArray(data.result.files) ? data.result.files : [];
return files.map(f => ({
file_code: String(f.file_code || f.filecode || '').trim(),
file_name: String(f.title || f.file_name || f.name || '').trim()
})).filter(f => f.file_code);
} catch {
return [];
}
}
const DOODSTREAM_POLL = { attempts: 12, delayMs: 2500 }; // test-tunable via __test
async function _resolveDoodstreamUploadByName(apiKey, fileName, baselineCodes, signal, recoveryClaim) {
if (!(baselineCodes instanceof Set)) return null;
async function _resolveDoodstreamUploadByName(apiKey, fileName, baselineCodes, signal) {
// Same recovery byse uses: the upload POST returned no filecode, but the file
// may register in the account a little later. Poll the list for a NEW file
// whose normalized title matches what we uploaded. Exact-name match only
@@ -788,13 +509,8 @@ async function _resolveDoodstreamUploadByName(apiKey, fileName, baselineCodes, s
if (signal && signal.aborted) return null;
const list = await _fetchDoodstreamFileList(apiKey, signal);
const fresh = list.filter(f => !baselineCodes.has(f.file_code));
const matches = fresh
.filter(f => _normalizeFileTitle(f.file_name) === expected)
.filter(f => !recoveryClaim || typeof recoveryClaim.has !== 'function' || !recoveryClaim.has(f.file_code));
if (matches.length > 1) return null;
if (matches.length === 1) {
const match = matches[0];
if (recoveryClaim && typeof recoveryClaim.reserve === 'function' && !recoveryClaim.reserve(match.file_code)) return null;
const match = fresh.find(f => _normalizeFileTitle(f.file_name) === expected);
if (match) {
return {
download_url: `https://doodstream.com/d/${match.file_code}`,
embed_url: `https://doodstream.com/e/${match.file_code}`,
@@ -817,33 +533,21 @@ async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, thro
if (!config) throw new Error(`Unbekannter Hoster: ${hosterName}`);
let byseBaseline = null;
let byseBaselineError = null;
if (hosterName === 'byse.sx') {
if (opts && opts.byseBaseline instanceof Set) {
byseBaseline = opts.byseBaseline;
} else {
try {
const baseline = await _fetchByseFileList(apiKey, signal, 'recovery-baseline');
byseBaseline = new Set(baseline.map(f => f.file_code));
} catch (err) {
if (signal && signal.aborted) throw err;
byseBaselineError = err;
}
const baseline = await _fetchByseFileList(apiKey, signal);
byseBaseline = new Set(baseline.map(f => f.file_code));
}
}
let doodBaseline = null;
let doodBaselineError = null;
if (hosterName === 'doodstream.com') {
if (opts && opts.doodBaseline instanceof Set) {
doodBaseline = opts.doodBaseline;
} else {
try {
const baseline = await _fetchDoodstreamFileList(apiKey, signal, 'recovery-baseline');
doodBaseline = new Set(baseline.map(f => f.file_code));
} catch (err) {
if (signal && signal.aborted) throw err;
doodBaselineError = err;
}
const baseline = await _fetchDoodstreamFileList(apiKey, signal);
doodBaseline = new Set(baseline.map(f => f.file_code));
}
}
@@ -856,52 +560,31 @@ async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, thro
const { iterable, boundary, totalSize } = createUploadBody(filePath, formFields, onProgress, throttle, signal);
let uploadResponse;
try {
uploadResponse = await request(targetUrl, {
method: 'POST',
body: iterable,
signal,
headers: {
'Content-Type': `multipart/form-data; boundary=${boundary}`,
'Content-Length': String(totalSize),
'Accept': 'application/json, text/plain;q=0.9, */*;q=0.8',
'User-Agent': 'multi-hoster-uploader/1.1'
},
headersTimeout: UPLOAD_TIMEOUT,
bodyTimeout: UPLOAD_TIMEOUT
});
} catch (err) {
const error = signal && signal.aborted ? err : createTransportError(`Upload zu ${hosterName} konnte nicht übertragen werden`, {
phase: 'upload-request',
endpoint: targetUrl,
retryable: true,
transientNetwork: true
});
throw _markRecoveryUncertain(opts && opts.recoveryClaim, error);
}
const { body, statusCode, headers } = await request(targetUrl, {
method: 'POST',
body: iterable,
signal,
headers: {
'Content-Type': `multipart/form-data; boundary=${boundary}`,
'Content-Length': String(totalSize),
'Accept': 'application/json, text/plain;q=0.9, */*;q=0.8',
'User-Agent': 'multi-hoster-uploader/1.1'
},
headersTimeout: UPLOAD_TIMEOUT,
bodyTimeout: UPLOAD_TIMEOUT
});
const { body, statusCode, headers } = uploadResponse;
let rawBody;
try {
rawBody = await body.text();
} catch (err) {
throw _markRecoveryUncertain(opts && opts.recoveryClaim, err);
}
const rawBody = await body.text();
let payload = null;
try {
payload = rawBody ? JSON.parse(rawBody) : {};
} catch {
throw _markRecoveryUncertain(opts && opts.recoveryClaim, createTransportError(`Upload-Antwort von ${hosterName} war kein JSON`, {
phase: 'upload-response',
endpoint: targetUrl,
httpStatus: statusCode,
contentType: headers && headers['content-type'],
body: rawBody,
retryable: statusCode >= 500,
transientNetwork: statusCode >= 500
}));
const snippet = rawBody ? rawBody.slice(0, 240).replace(/\s+/g, ' ').trim() : '';
const err = new Error(
`Upload-Antwort von ${hosterName} war kein JSON (HTTP ${statusCode}${snippet ? `): ${snippet}` : ')'}`
);
if (statusCode >= 500) err.transientNetwork = true;
throw err;
}
// Normalize valid-but-not-object JSON (JSON.parse('null') → null;
// JSON.parse('"foo"') → string; JSON.parse('[1]') → array). Without this
@@ -915,33 +598,19 @@ async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, thro
}
if (statusCode < 200 || statusCode >= 300) {
const error = createTransportError(`Upload zu ${hosterName} fehlgeschlagen`, {
phase: 'upload-response',
endpoint: targetUrl,
httpStatus: statusCode,
contentType: headers && headers['content-type'],
body: rawBody,
retryable: statusCode === 429 || statusCode >= 500,
transientNetwork: statusCode >= 500
});
throw statusCode >= 500
? _markRecoveryUncertain(opts && opts.recoveryClaim, error)
: error;
const err = new Error(
payload.msg
|| payload.message
|| `Upload fehlgeschlagen (HTTP ${statusCode}${headers?.['content-type'] ? `, ${headers['content-type']}` : ''})`
);
if (statusCode >= 500) err.transientNetwork = true;
throw err;
}
if (payload.status && [401, 403, 429, 500].includes(payload.status)) {
const error = createTransportError(`Upload zu ${hosterName} wurde abgelehnt`, {
phase: 'upload-response',
endpoint: targetUrl,
httpStatus: Number(payload.status),
contentType: headers && headers['content-type'],
body: rawBody,
retryable: Number(payload.status) === 429 || Number(payload.status) >= 500,
transientNetwork: Number(payload.status) >= 500
});
throw Number(payload.status) >= 500
? _markRecoveryUncertain(opts && opts.recoveryClaim, error)
: error;
const err = new Error(payload.msg || payload.message || JSON.stringify(payload));
if (payload.status === 500) err.transientNetwork = true;
throw err;
}
let result = null;
@@ -950,32 +619,19 @@ async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, thro
result = config.parseResult(payload);
} catch (err) {
if (err && typeof err === 'object' && !err.diagnostic) {
err.diagnostic = createTransportError(`Upload zu ${hosterName} konnte nicht ausgewertet werden`, {
phase: 'upload-result',
endpoint: targetUrl,
httpStatus: statusCode,
contentType: headers && headers['content-type'],
body: rawBody
}).diagnostic;
try {
err.diagnostic = {
hoster: hosterName,
http: statusCode,
contentType: (headers && headers['content-type']) || null,
payloadSnippet: JSON.stringify(payload).slice(0, 1000),
uploadUrl: targetUrl
};
} catch { /* JSON cycle — skip diagnostic */ }
}
parseErr = err;
}
if (result && (result.file_code || result.download_url || result.embed_url)) {
if (result.file_code && opts && opts.recoveryClaim && typeof opts.recoveryClaim.reserve === 'function') {
if (!opts.recoveryClaim.reserve(result.file_code)) {
const error = createTransportError(`Upload zu ${hosterName} lieferte eine bereits zugeordnete file_code-Antwort`, {
phase: 'upload-result',
endpoint: targetUrl,
httpStatus: statusCode,
contentType: headers && headers['content-type'],
body: rawBody,
retryable: true,
hosterTransient: true
});
error.remoteIdentityClaimed = true;
throw _markRecoveryUncertain(opts.recoveryClaim, error);
}
}
return result;
}
@@ -1001,12 +657,8 @@ async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, thro
// even after our uploader gave up.
if (hosterName === 'byse.sx' && byseBaseline && !explicitlyRejected) {
const fileName = path.basename(filePath);
try {
const polled = await _resolveByseUploadByName(apiKey, fileName, byseBaseline, signal, opts && opts.recoveryClaim);
if (polled) return polled;
} catch (err) {
throw _markRecoveryUncertain(opts && opts.recoveryClaim, err);
}
const polled = await _resolveByseUploadByName(apiKey, fileName, byseBaseline, signal);
if (polled) return polled;
}
// Doodstream: the doodapi upload POST returned no filecode (the same backend
@@ -1014,47 +666,24 @@ async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, thro
// the file did register, claim its code instead of failing the upload.
if (hosterName === 'doodstream.com' && doodBaseline && !explicitlyRejected) {
const fileName = path.basename(filePath);
try {
const polled = await _resolveDoodstreamUploadByName(apiKey, fileName, doodBaseline, signal, opts && opts.recoveryClaim);
if (polled) return polled;
} catch (err) {
throw _markRecoveryUncertain(opts && opts.recoveryClaim, err);
}
const polled = await _resolveDoodstreamUploadByName(apiKey, fileName, doodBaseline, signal);
if (polled) return polled;
}
if (hosterName === 'byse.sx' && byseBaselineError && !explicitlyRejected) {
byseBaselineError.hosterTransient = true;
throw _markRecoveryUncertain(opts && opts.recoveryClaim, byseBaselineError);
}
if (hosterName === 'doodstream.com' && doodBaselineError && !explicitlyRejected) {
doodBaselineError.hosterTransient = true;
throw _markRecoveryUncertain(opts && opts.recoveryClaim, doodBaselineError);
}
if (parseErr) {
throw explicitlyRejected
? parseErr
: _markRecoveryUncertain(opts && opts.recoveryClaim, parseErr);
}
if (parseErr) throw parseErr;
if (payload.success === false) {
throw createTransportError(`Upload zu ${hosterName} wurde vom Server abgelehnt`, {
phase: 'upload-result',
endpoint: targetUrl,
httpStatus: statusCode,
contentType: headers && headers['content-type'],
body: rawBody
});
throw new Error(payload.msg || payload.message || `Upload zu ${hosterName} wurde vom Server abgelehnt.`);
}
// Avoid throwing a bare "OK" / "SUCCESS" as the error message — that happens
// when the server says "msg: OK" but ships no file_code anywhere we know
// about, typically an API change. Surface safe structured response metadata
// so future logs show what kind of response the server returned.
// about, typically an API change. Surface the full (trimmed) payload so
// future logs actually show what the server returned.
const msg = String(payload.msg || payload.message || '').trim();
const isOkishNoPayload = /^(ok|success|done|accepted)$/i.test(msg);
if (isOkishNoPayload || !msg) {
const snippet = JSON.stringify(payload).slice(0, 400);
// 2xx with no filecode: the hoster accepted the upload (bytes sent, status
// OK) but returned no usable link. For doodstream this is the API-path
// analog of the web empty-form — the backend file-registration timing out
@@ -1062,33 +691,23 @@ async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, thro
// so tag it hosterTransient: the upload-manager then fails this file WITHOUT
// blacklisting the account (same protection the web path got in 3.3.29) and
// the account stays usable for the next retry/batch.
throw _markRecoveryUncertain(opts && opts.recoveryClaim, createTransportError(`Upload zu ${hosterName} lieferte keine file_code-Antwort`, {
phase: 'upload-result',
endpoint: targetUrl,
httpStatus: statusCode,
contentType: headers && headers['content-type'],
body: rawBody,
retryable: true,
hosterTransient: true
}));
const err = new Error(
`Upload zu ${hosterName} lieferte keine file_code-Antwort (Payload: ${snippet})`
);
err.hosterTransient = true;
throw err;
}
throw createTransportError(`Upload zu ${hosterName} wurde abgelehnt`, {
phase: 'upload-result',
endpoint: targetUrl,
httpStatus: statusCode,
contentType: headers && headers['content-type'],
body: rawBody
});
throw new Error(msg);
}
async function prefetchBaseline(hosterName, apiKey, signal) {
try {
if (hosterName === 'byse.sx') {
const baseline = await _fetchByseFileList(apiKey, signal, 'recovery-baseline');
const baseline = await _fetchByseFileList(apiKey, signal);
return new Set(baseline.map(f => f.file_code));
}
if (hosterName === 'doodstream.com') {
const baseline = await _fetchDoodstreamFileList(apiKey, signal, 'recovery-baseline');
const baseline = await _fetchDoodstreamFileList(apiKey, signal);
return new Set(baseline.map(f => f.file_code));
}
} catch { /* leave caller to fall back to per-job fetch */ }
@@ -1098,8 +717,6 @@ async function prefetchBaseline(hosterName, apiKey, signal) {
module.exports = {
uploadFile,
prefetchBaseline,
createRecoveryClaimRegistry,
normalizeRecoveryTitle: _normalizeFileTitle,
HOSTER_CONFIGS,
__test: {
extractUploadServerUrl,
+158 -167
View File
@@ -1,185 +1,176 @@
(function initImportPreflight(root, factory) {
const api = typeof module === 'object' && module.exports
? factory(require('path'), require('./filename-filter'))
? factory(require('path'))
: factory({
normalize: value => String(value).replace(/[\\/]+/g, '/'),
basename: value => String(value).split(/[\\/]/).pop() || ''
}, root.FilenameFilter);
});
if (typeof module === 'object' && module.exports) module.exports = api;
if (root) root.ImportPreflight = api;
})(typeof window !== 'undefined' ? window : globalThis, function createImportPreflight(path, filenameFilter) {
const { applyFilenameFilter } = filenameFilter;
function normalizePathValue(value) {
let text = String(value ?? '').trim();
if (!text) return '';
const uncNamespace = text.match(/^[\\/]{2}\?[\\/]UNC[\\/]/i);
if (uncNamespace) text = `\\\\${text.slice(uncNamespace[0].length)}`;
else {
const driveNamespace = text.match(/^[\\/]{2}\?[\\/](?=[A-Za-z]:[\\/])/);
if (driveNamespace) text = text.slice(driveNamespace[0].length);
}
return path.normalize(text);
}
function normalizeEntry(value) {
const source = value && typeof value === 'object' ? value : {};
const filePath = normalizePathValue(typeof value === 'string' ? value : source.path);
const sourceName = typeof value === 'string' ? '' : String(source.name ?? '').trim();
return {
path: filePath,
name: sourceName || path.basename(filePath),
size: Number.isFinite(Number(source.size)) ? Number(source.size) : null
};
}
function createPathKey(value, caseInsensitive) {
const normalized = normalizePathValue(value);
return caseInsensitive ? normalized.toLocaleLowerCase('en-US') : normalized;
}
async function mapWithConcurrency(items, concurrency, operation) {
const results = new Array(items.length);
let cursor = 0;
async function worker() {
while (cursor < items.length) {
const index = cursor++;
results[index] = await operation(items[index], index);
})(typeof window !== 'undefined' ? window : globalThis, function createImportPreflight(path) {
function normalizePathValue(value) {
let text = String(value ?? '').trim();
if (!text) return '';
const uncNamespace = text.match(/^[\\/]{2}\?[\\/]UNC[\\/]/i);
if (uncNamespace) text = `\\\\${text.slice(uncNamespace[0].length)}`;
else {
const driveNamespace = text.match(/^[\\/]{2}\?[\\/](?=[A-Za-z]:[\\/])/);
if (driveNamespace) text = text.slice(driveNamespace[0].length);
}
}
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker));
return results;
}
function unavailableReason(result) {
if (!result || result.exists === false) return 'missing';
if (result.readable === false) return 'unreadable';
const size = Number(result.size);
if (!Number.isFinite(size) || size <= 0) return 'empty';
return '';
}
async function inspectReadableImportPath(filePath, openPath) {
let fileHandle = null;
try {
fileHandle = await openPath(filePath, 'r');
const fileStat = await fileHandle.stat();
if (!fileStat.isFile()) return { exists: true, readable: false, size: fileStat.size };
return { exists: true, readable: true, size: fileStat.size };
} catch (error) {
if (error && (error.code === 'ENOENT' || error.code === 'ENOTDIR')) return { exists: false };
return { exists: true, readable: false };
} finally {
if (fileHandle) {
try {
await fileHandle.close();
} catch {}
}
}
}
async function inspectImportEntries(entries, options = {}) {
const input = Array.isArray(entries) ? entries : [];
const caseInsensitive = options.caseInsensitive ?? (typeof process === 'object' ? process.platform === 'win32' : true);
const existing = new Set((Array.isArray(options.existingPaths) ? options.existingPaths : [])
.map(value => createPathKey(value && typeof value === 'object' ? value.path : value, caseInsensitive))
.filter(Boolean));
const duplicates = [];
const unavailable = [];
const unique = [];
for (const value of input) {
const entry = normalizeEntry(value);
if (!entry.path) {
unavailable.push({ ...entry, reason: 'missing' });
continue;
}
const key = createPathKey(entry.path, caseInsensitive);
if (existing.has(key)) {
duplicates.push(entry);
continue;
}
existing.add(key);
unique.push(entry);
return path.normalize(text);
}
const filtered = applyFilenameFilter(unique, options.filenameFilter);
const concurrency = Math.max(1, Math.min(32, Math.trunc(Number(options.concurrency)) || 8));
const inspectPath = typeof options.inspectPath === 'function'
? options.inspectPath
: async (_entryPath, entry) => ({ exists: true, readable: true, size: entry.size });
const inspected = await mapWithConcurrency(filtered.accepted, concurrency, async entry => {
function normalizeEntry(value) {
const source = value && typeof value === 'object' ? value : {};
const filePath = normalizePathValue(typeof value === 'string' ? value : source.path);
const sourceName = typeof value === 'string' ? '' : String(source.name ?? '').trim();
return {
path: filePath,
name: sourceName || path.basename(filePath),
size: Number.isFinite(Number(source.size)) ? Number(source.size) : null
};
}
function createPathKey(value, caseInsensitive) {
const normalized = normalizePathValue(value);
return caseInsensitive ? normalized.toLocaleLowerCase('en-US') : normalized;
}
async function mapWithConcurrency(items, concurrency, operation) {
const results = new Array(items.length);
let cursor = 0;
async function worker() {
while (cursor < items.length) {
const index = cursor++;
results[index] = await operation(items[index], index);
}
}
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker));
return results;
}
function unavailableReason(result) {
if (!result || result.exists === false) return 'missing';
if (result.readable === false) return 'unreadable';
const size = Number(result.size);
if (!Number.isFinite(size) || size <= 0) return 'empty';
return '';
}
async function inspectReadableImportPath(filePath, openPath) {
let fileHandle = null;
try {
const result = await inspectPath(entry.path, entry);
const reason = unavailableReason(result);
if (reason) return { entry: { ...entry, size: Number(result?.size) || 0 }, reason };
return { entry: { ...entry, size: Number(result.size) }, reason: '' };
fileHandle = await openPath(filePath, 'r');
const fileStat = await fileHandle.stat();
if (!fileStat.isFile()) return { exists: true, readable: false, size: fileStat.size };
return { exists: true, readable: true, size: fileStat.size };
} catch (error) {
return { entry, reason: error && error.code === 'ENOENT' ? 'missing' : 'unreadable' };
if (error && (error.code === 'ENOENT' || error.code === 'ENOTDIR')) return { exists: false };
return { exists: true, readable: false };
} finally {
if (fileHandle) {
try {
await fileHandle.close();
} catch {}
}
}
});
const accepted = [];
for (const result of inspected) {
if (result.reason) unavailable.push({ ...result.entry, reason: result.reason });
else accepted.push(result.entry);
}
async function inspectImportEntries(entries, options = {}) {
const input = Array.isArray(entries) ? entries : [];
const caseInsensitive = options.caseInsensitive ?? (typeof process === 'object' ? process.platform === 'win32' : true);
const existing = new Set((Array.isArray(options.existingPaths) ? options.existingPaths : [])
.map(value => createPathKey(value && typeof value === 'object' ? value.path : value, caseInsensitive))
.filter(Boolean));
const duplicates = [];
const unavailable = [];
const unique = [];
for (const value of input) {
const entry = normalizeEntry(value);
if (!entry.path) {
unavailable.push({ ...entry, reason: 'missing' });
continue;
}
const key = createPathKey(entry.path, caseInsensitive);
if (existing.has(key)) {
duplicates.push(entry);
continue;
}
existing.add(key);
unique.push(entry);
}
const concurrency = Math.max(1, Math.min(32, Math.trunc(Number(options.concurrency)) || 8));
const inspectPath = typeof options.inspectPath === 'function'
? options.inspectPath
: async (_entryPath, entry) => ({ exists: true, readable: true, size: entry.size });
const inspected = await mapWithConcurrency(unique, concurrency, async entry => {
try {
const result = await inspectPath(entry.path, entry);
const reason = unavailableReason(result);
if (reason) return { entry: { ...entry, size: Number(result?.size) || 0 }, reason };
return { entry: { ...entry, size: Number(result.size) }, reason: '' };
} catch (error) {
return { entry, reason: error && error.code === 'ENOENT' ? 'missing' : 'unreadable' };
}
});
const accepted = [];
for (const result of inspected) {
if (result.reason) unavailable.push({ ...result.entry, reason: result.reason });
else accepted.push(result.entry);
}
return {
candidateCount: input.length,
duplicateCount: duplicates.length,
unavailableCount: unavailable.length,
acceptedCount: accepted.length,
accepted,
duplicates,
unavailable
};
}
function normalizeSelectedHosters(values) {
return Array.from(new Set((Array.isArray(values) ? values : [])
.map(value => String(value ?? '').trim())
.filter(Boolean)));
}
function isImportPairEligible(file, hoster, hosterSettings = {}) {
const maxSizeMb = Number(hosterSettings?.[hoster]?.maxSizeMb);
return !(maxSizeMb > 0 && Number(file?.size) > maxSizeMb * 1024 * 1024);
}
function getEligibleImportHosters(file, selectedHosters, hosterSettings = {}) {
return normalizeSelectedHosters(selectedHosters)
.filter(hoster => isImportPairEligible(file, hoster, hosterSettings));
}
function summarizeImportPlan(input = {}) {
const inspection = input.inspection && typeof input.inspection === 'object' ? input.inspection : {};
const accepted = Array.isArray(inspection.accepted) ? inspection.accepted : [];
const selectedHosters = normalizeSelectedHosters(input.selectedHosters);
const settings = input.hosterSettings && typeof input.hosterSettings === 'object' ? input.hosterSettings : {};
let jobCount = 0;
for (const file of accepted) jobCount += getEligibleImportHosters(file, selectedHosters, settings).length;
return {
candidateCount: Number(inspection.candidateCount) || 0,
duplicateCount: Number(inspection.duplicateCount) || 0,
unavailableCount: Number(inspection.unavailableCount) || 0,
acceptedCount: accepted.length,
targetCount: selectedHosters.length,
jobCount,
sizeLimitedJobCount: accepted.length * selectedHosters.length - jobCount
};
}
return {
candidateCount: input.length,
duplicateCount: duplicates.length,
filteredCount: filtered.excluded.length,
unavailableCount: unavailable.length,
acceptedCount: accepted.length,
accepted,
duplicates,
filtered: filtered.excluded,
unavailable
getEligibleImportHosters,
inspectImportEntries,
inspectReadableImportPath,
isImportPairEligible,
summarizeImportPlan
};
}
function normalizeSelectedHosters(values) {
return Array.from(new Set((Array.isArray(values) ? values : [])
.map(value => String(value ?? '').trim())
.filter(Boolean)));
}
function isImportPairEligible(file, hoster, hosterSettings = {}) {
const maxSizeMb = Number(hosterSettings?.[hoster]?.maxSizeMb);
return !(maxSizeMb > 0 && Number(file?.size) > maxSizeMb * 1024 * 1024);
}
function getEligibleImportHosters(file, selectedHosters, hosterSettings = {}) {
return normalizeSelectedHosters(selectedHosters)
.filter(hoster => isImportPairEligible(file, hoster, hosterSettings));
}
function summarizeImportPlan(input = {}) {
const inspection = input.inspection && typeof input.inspection === 'object' ? input.inspection : {};
const accepted = Array.isArray(inspection.accepted) ? inspection.accepted : [];
const selectedHosters = normalizeSelectedHosters(input.selectedHosters);
const settings = input.hosterSettings && typeof input.hosterSettings === 'object' ? input.hosterSettings : {};
let jobCount = 0;
for (const file of accepted) {
jobCount += getEligibleImportHosters(file, selectedHosters, settings).length;
}
const sizeLimitedJobCount = accepted.length * selectedHosters.length - jobCount;
return {
candidateCount: Number(inspection.candidateCount) || 0,
duplicateCount: Number(inspection.duplicateCount) || 0,
filteredCount: Number(inspection.filteredCount) || 0,
unavailableCount: Number(inspection.unavailableCount) || 0,
acceptedCount: accepted.length,
targetCount: selectedHosters.length,
jobCount,
sizeLimitedJobCount
};
}
return {
getEligibleImportHosters,
inspectImportEntries,
inspectReadableImportPath,
isImportPairEligible,
summarizeImportPlan
};
});
+1 -2
View File
@@ -76,8 +76,7 @@
const keyUnambiguous = k !== null && filesPerKey.get(k).size <= 1;
const uploadedAfterSnapshot = savedAtFloor !== null && k !== null && keyUnambiguous
&& logMaxTs.has(k) && logMaxTs.get(k) >= savedAtFloor;
const hasTerminalResult = job && job.status === 'done' && job.result && typeof job.result === 'object';
if (!hasTerminalResult && (doneInLog || uploadedAfterSnapshot)) {
if (doneInLog || uploadedAfterSnapshot) {
removed.push(job);
} else {
kept.push(job);
+2 -17
View File
@@ -21,12 +21,8 @@ class RemoteServer {
return new Promise((resolve, reject) => {
this._config = opts;
const host = opts.host || '127.0.0.1';
if (host !== '127.0.0.1' && host !== '::1') {
reject(new Error('Remote server requires a loopback host'));
return;
}
const wssOpts = { port: opts.port, host, maxPayload: 256 * 1024 };
const wssOpts = { port: opts.port, maxPayload: 256 * 1024 };
if (opts.host) wssOpts.host = opts.host;
this._wss = new WebSocketServer(wssOpts, () => {
resolve();
});
@@ -98,17 +94,6 @@ class RemoteServer {
const client = this._clients.get(ws);
if (!client) return;
if (!msg || typeof msg !== 'object' || Array.isArray(msg)) {
if (!client.authenticated) {
authReceived = true;
clearTimeout(authTimeout);
this._recordFailedAttempt(ip);
ws.close(4002, 'Invalid token');
this._clients.delete(ws);
}
return;
}
if (!client.authenticated) {
authReceived = true;
clearTimeout(authTimeout);
+23 -125
View File
@@ -2,7 +2,7 @@
'use strict';
const pathApi = typeof require === 'function' ? require('path') : null;
const metadataVersion = 2;
const protectedStatuses = new Set(['done', 'error', 'aborted', 'skipped']);
function normalizeFile(file, platform) {
const value = typeof file === 'string' ? file.trim() : '';
@@ -59,39 +59,18 @@
return uniqueHosters(values);
}
function confirmedHosters(jobs, requiredHosters) {
function completedHosters(jobs, requiredHosters) {
const values = [];
for (const job of jobs) {
if (job.sourceCleanupMetadataVersion === metadataVersion && Array.isArray(job.sourceCleanupConfirmedHosters)) {
values.push(...job.sourceCleanupConfirmedHosters);
if (Array.isArray(job.sourceCleanupCompletedHosters)) {
values.push(...job.sourceCleanupCompletedHosters);
}
}
const confirmed = new Set(uniqueHosters(values));
return requiredHosters.filter((hoster) => confirmed.has(hoster));
}
function provisionalHosters(jobs, requiredHosters) {
const values = [];
for (const job of jobs) {
if (job.sourceCleanupMetadataVersion === metadataVersion && Array.isArray(job.sourceCleanupProvisionalHosters)) {
values.push(...job.sourceCleanupProvisionalHosters);
}
if (job.status === 'done') values.push(job.hoster);
}
const provisional = new Set(uniqueHosters(values));
return requiredHosters.filter((hoster) => provisional.has(hoster));
}
function startedHosters(jobs, requiredHosters) {
const values = [];
let legacyMetadata = false;
for (const job of jobs) {
if (job.sourceCleanupMetadataVersion !== metadataVersion) continue;
if (!Array.isArray(job.sourceCleanupStartedHosters)) legacyMetadata = true;
else values.push(...job.sourceCleanupStartedHosters);
}
if (legacyMetadata) return [...requiredHosters];
const started = new Set(uniqueHosters(values));
return requiredHosters.filter((hoster) => started.has(hoster));
const completed = new Set(uniqueHosters(values));
return requiredHosters.filter((hoster) => completed.has(hoster));
}
function storedToken(jobs) {
@@ -111,16 +90,12 @@
return null;
}
function assignMetadata(jobs, token, requiredHosters, confirmed, provisional, started, fingerprint, touchedJobs, touchedSet) {
function assignMetadata(jobs, token, requiredHosters, completed, fingerprint, touchedJobs, touchedSet) {
for (const job of jobs) {
job.sourceCleanupMetadataVersion = metadataVersion;
job.sourceCleanupToken = token;
job.sourceCleanupRequiredHosters = [...requiredHosters];
job.sourceCleanupConfirmedHosters = [...confirmed];
job.sourceCleanupProvisionalHosters = [...provisional];
job.sourceCleanupStartedHosters = [...started];
job.sourceCleanupCompletedHosters = [...completed];
job.sourceCleanupFingerprint = cloneFingerprint(fingerprint);
delete job.sourceCleanupCompletedHosters;
if (!touchedSet.has(job)) {
touchedSet.add(job);
touchedJobs.push(job);
@@ -133,10 +108,7 @@
const touchedJobs = [];
const touchedSet = new Set();
const preparedFiles = new Set();
const revokedHosters = [];
const revokedSet = new Set();
if (!Array.isArray(queueJobs) || !Array.isArray(jobsToStart)) return { groups, touchedJobs, revokedHosters };
const currentRoundJobs = new Set(jobsToStart);
if (!Array.isArray(queueJobs) || !Array.isArray(jobsToStart)) return { groups, touchedJobs };
for (const selectedJob of jobsToStart) {
const file = normalizeFile(selectedJob && selectedJob.file, platform);
@@ -152,28 +124,14 @@
...persistedRequired,
...siblings.map((job) => job.hoster)
]);
const currentStartedHosters = new Set(uniqueHosters(
siblings.filter((job) => currentRoundJobs.has(job)).map((job) => job.hoster)
));
const started = uniqueHosters([...startedHosters(siblings, requiredHosters), ...currentStartedHosters]);
const storedConfirmed = confirmedHosters(siblings, requiredHosters);
const confirmed = storedConfirmed.filter((hoster) => !currentStartedHosters.has(hoster));
const provisional = provisionalHosters(siblings, requiredHosters)
.filter((hoster) => !currentStartedHosters.has(hoster));
for (const hoster of storedConfirmed) {
if (!currentStartedHosters.has(hoster) || revokedSet.has(hoster)) continue;
revokedSet.add(hoster);
revokedHosters.push(hoster);
}
const completed = completedHosters(siblings, requiredHosters);
const fingerprint = storedFingerprint(siblings);
assignMetadata(
siblings,
token,
requiredHosters,
confirmed,
provisional,
started,
completed,
fingerprint,
touchedJobs,
touchedSet
@@ -183,107 +141,48 @@
token,
file: selectedJob.file,
requiredHosters: [...requiredHosters],
confirmedHosters: [...confirmed],
completedHosters: [...completed],
fingerprint: cloneFingerprint(fingerprint),
jobs: siblings.map((job) => ({
jobId: job.id,
hoster: normalizeHoster(job.hoster),
status: job.status,
currentRound: currentRoundJobs.has(job)
status: job.status
}))
});
}
return { groups, touchedJobs, revokedHosters };
return { groups, touchedJobs };
}
function markCompleted(queueJobs, job, platform) {
const siblings = relatedJobs(queueJobs, job, platform);
if (siblings.length === 0) return [];
const requiredHosters = storedRequiredHosters(siblings);
const provisional = new Set(provisionalHosters(siblings, requiredHosters));
const completed = new Set(completedHosters(siblings, requiredHosters));
const hoster = normalizeHoster(job.hoster);
if (requiredHosters.includes(hoster)) provisional.add(hoster);
const orderedConfirmed = confirmedHosters(siblings, requiredHosters);
const orderedProvisional = requiredHosters.filter((required) => provisional.has(required));
if (requiredHosters.includes(hoster)) completed.add(hoster);
const orderedCompleted = requiredHosters.filter((required) => completed.has(required));
for (const sibling of siblings) {
sibling.sourceCleanupMetadataVersion = metadataVersion;
sibling.sourceCleanupConfirmedHosters = [...orderedConfirmed];
sibling.sourceCleanupProvisionalHosters = [...orderedProvisional];
delete sibling.sourceCleanupCompletedHosters;
sibling.sourceCleanupCompletedHosters = [...orderedCompleted];
}
return siblings;
}
async function persistRoundCompletions(queueJobs, options = {}) {
if (!Array.isArray(queueJobs) || typeof options.persist !== 'function') return false;
const historyPersisted = options.historyPersisted === true;
const groupsByToken = new Map();
for (const job of queueJobs) {
if (!job || typeof job.sourceCleanupToken !== 'string' || !job.sourceCleanupToken) continue;
if (!groupsByToken.has(job.sourceCleanupToken)) groupsByToken.set(job.sourceCleanupToken, []);
groupsByToken.get(job.sourceCleanupToken).push(job);
}
const snapshots = [];
for (const jobs of groupsByToken.values()) {
const requiredHosters = storedRequiredHosters(jobs);
const confirmed = confirmedHosters(jobs, requiredHosters);
const provisional = provisionalHosters(jobs, requiredHosters);
const promoted = historyPersisted
? uniqueHosters([...confirmed, ...provisional])
: confirmed;
const orderedPromoted = requiredHosters.filter((hoster) => promoted.includes(hoster));
for (const job of jobs) {
snapshots.push({
job,
confirmedHosters: job.sourceCleanupMetadataVersion === metadataVersion
? uniqueHosters(job.sourceCleanupConfirmedHosters)
: []
});
job.sourceCleanupMetadataVersion = metadataVersion;
job.sourceCleanupConfirmedHosters = [...orderedPromoted];
job.sourceCleanupProvisionalHosters = [];
delete job.sourceCleanupCompletedHosters;
}
}
let persisted = false;
try {
persisted = (await options.persist()) === true;
} catch {}
if (!persisted) {
for (const snapshot of snapshots) {
snapshot.job.sourceCleanupMetadataVersion = metadataVersion;
snapshot.job.sourceCleanupConfirmedHosters = [...snapshot.confirmedHosters];
snapshot.job.sourceCleanupProvisionalHosters = [];
delete snapshot.job.sourceCleanupCompletedHosters;
}
}
return persisted;
}
function removeRequirement(queueJobs, job, platform) {
if (!job || job.status !== 'preview' || job.interrupted) return [];
if (!job || protectedStatuses.has(job.status)) return [];
const siblings = relatedJobs(queueJobs, job, platform);
const removedHoster = normalizeHoster(job.hoster);
if (startedHosters(siblings, storedRequiredHosters(siblings)).includes(removedHoster)) return [];
for (const sibling of siblings) {
const required = Array.isArray(sibling.sourceCleanupRequiredHosters)
? sibling.sourceCleanupRequiredHosters
: [];
const confirmed = sibling.sourceCleanupMetadataVersion === metadataVersion && Array.isArray(sibling.sourceCleanupConfirmedHosters)
? sibling.sourceCleanupConfirmedHosters
const completed = Array.isArray(sibling.sourceCleanupCompletedHosters)
? sibling.sourceCleanupCompletedHosters
: [];
const provisional = sibling.sourceCleanupMetadataVersion === metadataVersion && Array.isArray(sibling.sourceCleanupProvisionalHosters)
? sibling.sourceCleanupProvisionalHosters
: [];
sibling.sourceCleanupMetadataVersion = metadataVersion;
sibling.sourceCleanupRequiredHosters = uniqueHosters(required)
.filter((hoster) => hoster !== removedHoster);
sibling.sourceCleanupConfirmedHosters = uniqueHosters(confirmed)
sibling.sourceCleanupCompletedHosters = uniqueHosters(completed)
.filter((hoster) => hoster !== removedHoster);
sibling.sourceCleanupProvisionalHosters = uniqueHosters(provisional)
.filter((hoster) => hoster !== removedHoster);
delete sibling.sourceCleanupCompletedHosters;
}
return siblings;
}
@@ -312,7 +211,6 @@
const api = {
prepareGroups,
markCompleted,
persistRoundCompletions,
removeRequirement,
applyFingerprints
};
+23 -43
View File
@@ -83,29 +83,30 @@ function createSourceFileCleanup(options) {
function createManifest(input, canonicalFile) {
const requiredHosters = uniqueStrings(input.requiredHosters);
const confirmedHosters = new Set(
uniqueStrings(input.confirmedHosters).filter((hoster) => requiredHosters.includes(hoster))
);
const completedHosters = new Set(uniqueStrings(input.completedHosters));
const jobs = new Map();
for (const job of Array.isArray(input.jobs) ? input.jobs : []) {
if (!job || typeof job.jobId !== 'string' || typeof job.hoster !== 'string') continue;
const currentRound = job.currentRound !== false;
const status = currentRound ? 'pending' : normalizeStatus(job.status);
const status = completedHosters.has(job.hoster) ? 'done' : normalizeStatus(job.status);
jobs.set(job.jobId, Object.freeze({
jobId: job.jobId,
hoster: job.hoster,
status,
currentRound
status
}));
}
for (const hoster of completedHosters) {
if (requiredHosters.includes(hoster)) continue;
completedHosters.delete(hoster);
}
return {
token: input.token || input.sourceCleanupToken,
file: path.resolve(input.file),
canonicalFile,
requiredHosters: Object.freeze(requiredHosters),
confirmedHosters,
completedHosters,
jobs,
suppliedFingerprint: isFingerprint(input.fingerprint) ? cloneFingerprint(input.fingerprint) : null,
fingerprint: null,
@@ -151,32 +152,16 @@ function createSourceFileCleanup(options) {
...existing.requiredHosters,
...input.requiredHosters
]));
const incomingJobs = Array.isArray(input.jobs) ? input.jobs : [];
const currentRoundHosters = new Set(
incomingJobs
.filter((job) => job && typeof job.hoster === 'string' && job.currentRound !== false)
.map((job) => job.hoster)
);
for (const hoster of currentRoundHosters) existing.confirmedHosters.delete(hoster);
for (const hoster of uniqueStrings(input.confirmedHosters)) {
if (existing.requiredHosters.includes(hoster) && !currentRoundHosters.has(hoster)) {
existing.confirmedHosters.add(hoster);
}
for (const hoster of uniqueStrings(input.completedHosters)) {
if (existing.requiredHosters.includes(hoster)) existing.completedHosters.add(hoster);
}
for (const job of incomingJobs) {
for (const job of Array.isArray(input.jobs) ? input.jobs : []) {
if (!job || typeof job.jobId !== 'string' || typeof job.hoster !== 'string') continue;
const previous = existing.jobs.get(job.jobId);
const incomingCurrentRound = job.currentRound !== false;
const currentRound = Boolean((previous && previous.currentRound) || incomingCurrentRound);
const status = incomingCurrentRound
? 'pending'
const status = existing.completedHosters.has(job.hoster)
? 'done'
: normalizeStatus(previous ? previous.status : job.status);
existing.jobs.set(job.jobId, Object.freeze({
jobId: job.jobId,
hoster: job.hoster,
status,
currentRound
}));
existing.jobs.set(job.jobId, Object.freeze({ jobId: job.jobId, hoster: job.hoster, status }));
}
fingerprints[token] = cloneFingerprint(existing.fingerprint);
continue;
@@ -209,9 +194,10 @@ function createSourceFileCleanup(options) {
const manifest = groups.get(token);
if (!manifest || manifest.finalizationPromise) return false;
const job = manifest.jobs.get(event.jobId);
if (!job || !job.currentRound || job.hoster !== event.hoster) return false;
if (!job || job.hoster !== event.hoster) return false;
if (typeof event.file === 'string' && canonicalize(event.file) !== manifest.canonicalFile) return false;
manifest.jobs.set(event.jobId, Object.freeze({ ...job, status: event.status }));
if (event.status === 'done') manifest.completedHosters.add(event.hoster);
return true;
}
@@ -220,7 +206,7 @@ function createSourceFileCleanup(options) {
for (const manifest of groups.values()) {
if (manifest.finalizationPromise) continue;
const job = manifest.jobs.get(jobId);
if (!job || !job.currentRound) continue;
if (!job) continue;
manifest.jobs.set(jobId, Object.freeze({ ...job, status: 'skipped' }));
changed = true;
}
@@ -230,17 +216,11 @@ function createSourceFileCleanup(options) {
function blockingStatuses(manifest) {
const blocking = [];
for (const hoster of manifest.requiredHosters) {
const jobs = [...manifest.jobs.values()].filter((job) => job.hoster === hoster);
const currentJobs = jobs.filter((job) => job.currentRound);
if (currentJobs.length > 0) {
const currentBlocker = currentJobs.find((job) => job.status !== 'done');
if (!currentBlocker) continue;
const status = currentJobs.map((job) => job.status).find((value) => value !== 'pending') || 'pending';
blocking.push({ hoster, status });
continue;
}
if (manifest.confirmedHosters.has(hoster)) continue;
const statuses = jobs.map((job) => job.status).filter((status) => status !== 'done');
if (manifest.completedHosters.has(hoster)) continue;
const statuses = [...manifest.jobs.values()]
.filter((job) => job.hoster === hoster)
.map((job) => job.status);
if (statuses.includes('done')) continue;
const status = statuses.find((value) => value !== 'pending') || 'pending';
blocking.push({ hoster, status });
}
+5 -528
View File
@@ -1,10 +1,6 @@
function configureStartupRenderer(app, env = process.env, platform = process.platform, argv = process.argv) {
function configureStartupRenderer(app, env = process.env, platform = process.platform) {
const sessionName = String(env && env.SESSIONNAME || '');
if (platform === 'win32' && /^RDP-/i.test(sessionName)) app.disableHardwareAcceleration();
if (Array.isArray(argv) && argv.includes('--dev')) {
app.commandLine.appendSwitch('force-prefers-no-reduced-motion');
app.commandLine.appendSwitch('user-data-dir', app.getPath('userData'));
}
return app;
}
@@ -12,518 +8,10 @@ function resolveStartupLanguage(config) {
return config && config.globalSettings && config.globalSettings.language === 'de' ? 'de' : 'en';
}
function createStartupFailureDocument(language) {
const german = language === 'de';
const title = german ? 'Oberfläche konnte nicht geladen werden' : 'The interface could not load';
const detail = german
? 'Multi Hoster Uploader konnte die Oberfläche nach einem sicheren Wiederherstellungsversuch nicht laden.'
: 'Multi Hoster Uploader could not load the interface after a safe recovery attempt.';
const close = german ? 'Schließen' : 'Close';
return `<!doctype html><html lang="${german ? 'de' : 'en'}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Multi Hoster Uploader</title><style>html,body{height:100%;margin:0;background:#1f1f1f;color:#f4f4f4;font:15px system-ui,sans-serif}body{display:grid;place-items:center}.card{width:min(520px,calc(100% - 48px));padding:28px;border:1px solid #444;border-radius:12px;background:#292929;box-shadow:0 18px 50px #0008}h1{margin:0 0 12px;font-size:22px}p{margin:0 0 22px;color:#c8c8c8;line-height:1.5}button{min-height:38px;padding:0 18px;border:1px solid #555;border-radius:7px;background:#363636;color:#fff;font-weight:650;cursor:pointer}button:hover{background:#414141}</style></head><body><main class="card"><h1>${title}</h1><p>${detail}</p><button type="button" onclick="window.close()">${close}</button></main></body></html>`;
}
function createStartupNavigationLoader(window, target, options = {}) {
let startupDocument = 0;
return function loadStartupDocument() {
startupDocument++;
return window.loadFile(target, {
...options,
query: {
...options.query,
startupDocument: String(startupDocument)
}
});
};
}
function createStartupRevealGate(window, { onBlock } = {}) {
let blocked = true;
let activationPending = false;
function hasWindow() {
return !!window && (typeof window.isDestroyed !== 'function' || !window.isDestroyed());
}
function showAuthorizedSurface(activate) {
if (!hasWindow()) return false;
if (activate && window.isMinimized()) window.restore();
if (!window.isVisible()) window.show();
if (activate) window.focus();
activationPending = false;
return true;
}
const block = () => {
blocked = true;
if (typeof onBlock === 'function') onBlock();
return true;
};
const request = () => {
if (!hasWindow()) return false;
activationPending = true;
if (!blocked) showAuthorizedSurface(true);
return true;
};
const reveal = () => {
blocked = false;
return showAuthorizedSurface(activationPending);
};
const navigate = (operation, ...args) => {
block();
return operation(...args);
};
return { block, navigate, request, reveal };
}
function createStartupExternalRevealBindings({
getWindow,
getRevealGate,
sendDroppedFiles,
maxPendingDropPayloads = 32
}) {
const pendingDropPayloads = [];
const pendingDropLimit = Number.isSafeInteger(maxPendingDropPayloads) && maxPendingDropPayloads > 0
? maxPendingDropPayloads
: 32;
let pendingWindow = null;
let readyWindow = null;
function clearPendingDropPayloads(window) {
if (window && pendingWindow !== window) return false;
pendingDropPayloads.length = 0;
pendingWindow = null;
return true;
}
function clearWindowState(window) {
clearPendingDropPayloads(window);
if (!window || readyWindow === window) readyWindow = null;
}
function getActiveWindow() {
const window = getWindow();
if (!window || (typeof window.isDestroyed === 'function' && window.isDestroyed())) {
clearWindowState();
return null;
}
if (pendingWindow && pendingWindow !== window) clearPendingDropPayloads();
if (readyWindow && readyWindow !== window) readyWindow = null;
return window;
}
function requestReveal() {
const activeWindow = getActiveWindow();
if (!activeWindow) return false;
const revealGate = getRevealGate();
if (!revealGate || typeof revealGate.request !== 'function') return false;
revealGate.request();
return true;
}
function queueDropPayload(window, paths) {
if (pendingWindow && pendingWindow !== window) clearPendingDropPayloads();
pendingWindow = window;
if (pendingDropPayloads.length >= pendingDropLimit) pendingDropPayloads.shift();
pendingDropPayloads.push(paths);
}
function handleDropTargetFiles(_event, paths) {
const window = getActiveWindow();
if (!window) return false;
if (!window.isVisible() || window.isMinimized()) requestReveal();
if (readyWindow === window) sendDroppedFiles(paths);
else queueDropPayload(window, paths);
return true;
}
function rendererBlocked(window) {
const activeWindow = getActiveWindow();
if (!activeWindow || activeWindow !== window) {
clearWindowState(window);
return false;
}
readyWindow = null;
return true;
}
function rendererReady(window) {
const activeWindow = getActiveWindow();
if (!activeWindow || activeWindow !== window) {
clearWindowState(window);
return false;
}
readyWindow = window;
if (pendingWindow !== window) return true;
const payloads = pendingDropPayloads.splice(0);
pendingWindow = null;
for (const paths of payloads) sendDroppedFiles(paths);
return true;
}
function windowClosed(window) {
clearWindowState(window);
}
return {
bindSecondInstance(app) {
app.on('second-instance', requestReveal);
},
bindTrayClick(tray) {
tray.on('click', requestReveal);
},
createTrayMenuItem(label) {
return { label, click: requestReveal };
},
bindDropTargetFiles(ipcMain) {
ipcMain.on('drop-target:files', handleDropTargetFiles);
},
rendererBlocked,
rendererReady,
windowClosed
};
}
function createStartupCloseHandler({ window, shouldPrepareClose, requestClosePreparation }) {
return function handleStartupClose(event) {
if (!shouldPrepareClose() || window.webContents.isDestroyed()) return false;
event.preventDefault();
requestClosePreparation();
return true;
};
}
function createStartupRecoveryCoordinator({
load,
reload,
reveal,
showFailure,
close,
readyTimeoutMs = 15000,
scheduleReadyDeadline = setTimeout,
cancelReadyDeadline = clearTimeout
}) {
let initialLoad;
let initialLoadPending = false;
let navigation;
let recoveryNavigations = 0;
let terminalFailure;
let recovery;
let queuedRecovery;
let readyDeadline;
let rendererGeneration = 0;
let awaitingGeneration = null;
let stopped = false;
function clearRendererDeadline() {
if (readyDeadline !== undefined) cancelReadyDeadline(readyDeadline);
readyDeadline = undefined;
}
function clearRendererDocument() {
awaitingGeneration = null;
clearRendererDeadline();
}
function acceptRendererDocument(generation) {
if (!Number.isInteger(generation) || awaitingGeneration !== generation) return false;
clearRendererDocument();
return true;
}
async function runNavigation(operation, args = []) {
if (navigation) return navigation;
const currentNavigation = Promise.resolve().then(() => operation(...args));
navigation = currentNavigation;
try {
return await currentNavigation;
} finally {
if (navigation === currentNavigation) navigation = undefined;
}
}
function endWithFailure(failure) {
if (stopped) return Promise.resolve(false);
if (!terminalFailure) {
clearRendererDocument();
terminalFailure = Promise.resolve().then(async () => {
if (typeof showFailure !== 'function') {
await close(failure);
return;
}
try {
await showFailure(failure);
reveal();
} catch (surfaceError) {
await close({ ...failure, surfaceError });
}
});
}
return terminalFailure;
}
function trackRecovery(currentRecovery) {
recovery = currentRecovery;
currentRecovery.then(
() => {
if (recovery === currentRecovery) recovery = undefined;
},
() => {
if (recovery === currentRecovery) recovery = undefined;
}
);
return currentRecovery;
}
async function performRecovery(phase, details) {
if (stopped) return false;
if (terminalFailure) return terminalFailure;
if (recoveryNavigations >= 1) {
return endWithFailure({ phase, attempt: recoveryNavigations + 1, details });
}
recoveryNavigations++;
try {
await runNavigation(reload);
return true;
} catch (error) {
if (stopped) return false;
return endWithFailure({ phase: 'renderer-reload', attempt: recoveryNavigations, details, error });
}
}
function recoverRenderer(phase, details) {
if (stopped) return Promise.resolve(false);
if (terminalFailure) return terminalFailure;
clearRendererDocument();
if (initialLoadPending) {
queuedRecovery = { phase, details, recoveryNavigations };
if (recovery) return recovery;
const currentRecovery = Promise.resolve(initialLoad).then(() => {
const pendingRecovery = queuedRecovery;
queuedRecovery = undefined;
if (stopped) return false;
if (terminalFailure) return terminalFailure;
if (!pendingRecovery || recoveryNavigations > pendingRecovery.recoveryNavigations) return true;
return performRecovery(pendingRecovery.phase, pendingRecovery.details);
});
return trackRecovery(currentRecovery);
}
if (recovery) return recovery;
return trackRecovery(performRecovery(phase, details));
}
return {
loadInitial(...args) {
if (stopped) return Promise.resolve(false);
if (terminalFailure) return terminalFailure;
if (!initialLoad) {
initialLoadPending = true;
const currentInitialLoad = (async () => {
for (let attempt = 1; attempt <= 2; attempt++) {
if (stopped) return false;
if (attempt === 2) recoveryNavigations = Math.max(recoveryNavigations, 1);
try {
return await runNavigation(load, args);
} catch (error) {
if (stopped) return false;
if (attempt === 2) {
await endWithFailure({ phase: 'initial-load', attempt, error });
}
}
}
})();
initialLoad = currentInitialLoad;
currentInitialLoad.then(
() => {
initialLoadPending = false;
},
() => {
initialLoadPending = false;
}
);
}
return initialLoad;
},
rendererCrashed(details) {
return recoverRenderer('renderer-crash', details);
},
rendererInitializationFailed(details) {
return recoverRenderer('renderer-initialization', details);
},
rendererLoadStarted() {
if (stopped || terminalFailure) return false;
clearRendererDocument();
rendererGeneration++;
awaitingGeneration = rendererGeneration;
return rendererGeneration;
},
rendererLoaded(generation) {
if (stopped || terminalFailure || awaitingGeneration !== generation) return false;
clearRendererDeadline();
readyDeadline = scheduleReadyDeadline(() => {
if (stopped || terminalFailure || awaitingGeneration !== generation) return false;
readyDeadline = undefined;
return recoverRenderer('renderer-ready-timeout', { timeoutMs: readyTimeoutMs });
}, readyTimeoutMs);
if (readyDeadline && typeof readyDeadline.unref === 'function') readyDeadline.unref();
return true;
},
rendererReady(generation) {
if (stopped || terminalFailure || !acceptRendererDocument(generation)) return false;
recoveryNavigations = 0;
reveal();
return true;
},
dispose() {
if (stopped) return false;
stopped = true;
queuedRecovery = undefined;
clearRendererDocument();
return true;
}
};
}
function createStartupRendererHandlers({
window,
ipcMain,
coordinator,
onDocumentLoadStarted,
onRendererCrashed,
onReady,
onInitializationFailed
}) {
let disposed = false;
let generation = null;
let activeFrame = null;
let pendingDocumentUrl = null;
let pendingFrameAddress = null;
const webContents = window && window.webContents;
function frameAddress(frame) {
if (!frame || frame.detached) return null;
if (typeof frame.isDestroyed === 'function' && frame.isDestroyed()) return null;
if (!Number.isInteger(frame.processId) || typeof frame.frameToken !== 'string' || !frame.frameToken) return null;
return `${frame.processId}:${frame.frameToken}`;
}
function frameIdentity(frame) {
const address = frameAddress(frame);
if (!address) return null;
if (typeof frame.url !== 'string' || !frame.url) return null;
return `${address}:${frame.url}`;
}
function startDocument(url, frame) {
if (disposed) return false;
generation = coordinator.rendererLoadStarted();
activeFrame = null;
pendingDocumentUrl = typeof url === 'string' && url ? url : null;
pendingFrameAddress = frameAddress(frame);
if (generation !== false && typeof onDocumentLoadStarted === 'function') onDocumentLoadStarted();
return generation;
}
function finishDocument() {
if (disposed || generation === null) return false;
activeFrame = frameIdentity(webContents && webContents.mainFrame);
return coordinator.rendererLoaded(generation);
}
function resolveEventFrame(event) {
if (disposed || !window || window.isDestroyed() || !event || event.sender !== webContents) return null;
if (!frameIdentity(event.senderFrame)) return null;
return event.senderFrame;
}
function resolveReadyGeneration(event) {
const senderFrame = resolveEventFrame(event);
if (!senderFrame || !Number.isInteger(generation) || !activeFrame || frameIdentity(senderFrame) !== activeFrame) return null;
return generation;
}
function resolveInitializationGeneration(event) {
const senderFrame = resolveEventFrame(event);
if (!senderFrame || !Number.isInteger(generation)) return null;
if (activeFrame) return frameIdentity(senderFrame) === activeFrame ? generation : null;
if (!pendingDocumentUrl || !pendingFrameAddress) return null;
if (senderFrame.url !== pendingDocumentUrl || frameAddress(senderFrame) !== pendingFrameAddress) return null;
return generation;
}
function handleNavigation(details, _url, isInPlace, isMainFrame) {
const sameDocument = details && typeof details.isSameDocument === 'boolean' ? details.isSameDocument : isInPlace;
const mainFrame = details && typeof details.isMainFrame === 'boolean' ? details.isMainFrame : isMainFrame;
if (sameDocument || mainFrame === false) return false;
const url = details && typeof details.url === 'string' ? details.url : _url;
const frame = details && details.frame;
return startDocument(url, frame);
}
function handleRendererCrash(_event, details) {
if (disposed) return false;
if (typeof onRendererCrashed === 'function') onRendererCrashed(details);
return coordinator.rendererCrashed(details);
}
function handleRendererInitializationFailed(event, details) {
if (resolveInitializationGeneration(event) === null) return false;
if (typeof onInitializationFailed === 'function') onInitializationFailed(details);
return coordinator.rendererInitializationFailed(details);
}
function handleRendererReady(event) {
const eventGeneration = resolveReadyGeneration(event);
if (eventGeneration === null) return false;
const ready = coordinator.rendererReady(eventGeneration);
if (ready && typeof onReady === 'function') onReady();
return ready;
}
if (webContents && typeof webContents.on === 'function') {
webContents.on('did-start-navigation', handleNavigation);
webContents.on('did-finish-load', finishDocument);
webContents.on('render-process-gone', handleRendererCrash);
}
if (ipcMain && typeof ipcMain.on === 'function') {
ipcMain.on('app:close-handshake-ready', handleRendererReady);
ipcMain.on('app:renderer-initialization-failed', handleRendererInitializationFailed);
}
return {
documentLoadStarted: startDocument,
documentLoaded: finishDocument,
rendererCrashed(details) {
if (disposed) return false;
return coordinator.rendererCrashed(details);
},
rendererInitializationFailed: handleRendererInitializationFailed,
rendererReady: handleRendererReady,
dispose() {
if (disposed) return false;
disposed = true;
if (webContents && typeof webContents.removeListener === 'function') {
webContents.removeListener('did-start-navigation', handleNavigation);
webContents.removeListener('did-finish-load', finishDocument);
webContents.removeListener('render-process-gone', handleRendererCrash);
}
if (ipcMain && typeof ipcMain.removeListener === 'function') {
ipcMain.removeListener('app:close-handshake-ready', handleRendererReady);
ipcMain.removeListener('app:renderer-initialization-failed', handleRendererInitializationFailed);
}
return coordinator.dispose();
}
};
}
function createStartupWindow(BrowserWindow, options) {
const window = new BrowserWindow({
...options,
show: false,
disableAutoHideCursor: true
const window = new BrowserWindow({ ...options, show: false });
window.once('ready-to-show', () => {
window.show();
});
return {
@@ -534,15 +22,4 @@ function createStartupWindow(BrowserWindow, options) {
};
}
module.exports = {
configureStartupRenderer,
createStartupCloseHandler,
createStartupExternalRevealBindings,
createStartupFailureDocument,
createStartupNavigationLoader,
createStartupRecoveryCoordinator,
createStartupRevealGate,
createStartupRendererHandlers,
createStartupWindow,
resolveStartupLanguage
};
module.exports = { configureStartupRenderer, createStartupWindow, resolveStartupLanguage };
+1 -117
View File
@@ -39,116 +39,6 @@
return out;
}
function summarizeHosterHealth(history, options = {}) {
const out = {};
const hosters = options.hosters && typeof options.hosters === 'object' ? options.hosters : {};
const accountStatuses = options.accountStatuses && typeof options.accountStatuses === 'object' ? options.accountStatuses : {};
const failedKeys = new Set(options.sessionFailedKeys instanceof Set
? options.sessionFailedKeys
: (Array.isArray(options.sessionFailedKeys) ? options.sessionFailedKeys : []));
const nowCandidate = options.now instanceof Date
? options.now.getTime()
: (Number.isFinite(options.now) ? Number(options.now) : Date.parse(options.now));
const nowMs = Number.isFinite(nowCandidate) ? nowCandidate : Date.now();
const recentCutoff = nowMs - 7 * 24 * 60 * 60 * 1000;
const ensure = (name) => out[name] || (out[name] = {
sampleSize: 0,
successful: 0,
failed: 0,
skipped: 0,
successRate: null,
effectiveBytes: 0,
effectiveDurationSec: 0,
effectiveBytesPerSecond: null,
lastSuccessAt: null,
failuresLast7Days: 0,
configuredAccounts: 0,
accountProblems: 0,
uncheckedAccounts: 0,
checkingAccounts: 0
});
const hasCredentials = (account) => {
if (!account || typeof account !== 'object' || !account.id) return false;
if (account.authType === 'api') return Boolean(String(account.apiKey || '').trim());
if (account.authType === 'login') return Boolean(String(account.username || '').trim() && String(account.password || '').trim());
return Boolean(String(account.apiKey || '').trim() || (String(account.username || '').trim() && String(account.password || '').trim()));
};
for (const [name, accountsValue] of Object.entries(hosters)) {
const bucket = ensure(name);
const accounts = Array.isArray(accountsValue) ? accountsValue : [];
bucket.configuredAccounts = accounts.length;
for (const account of accounts) {
if (account?.enabled === false) continue;
const status = accountStatuses[account?.id]?.status || 'unchecked';
const unavailable = !hasCredentials(account);
const problem = unavailable || failedKeys.has(`${name}:${account?.id || ''}`) || ['error', 'warn', 'otp_required'].includes(status);
if (problem) bucket.accountProblems++;
if (!unavailable && status === 'unchecked') bucket.uncheckedAccounts++;
if (!unavailable && status === 'checking') bucket.checkingAccounts++;
}
}
const validBatches = (Array.isArray(history) ? history : [])
.map((batch, index) => {
const timestampMs = batch?.timestamp ? Date.parse(batch.timestamp) : NaN;
return { batch, index, timestampMs };
})
.filter(({ timestampMs }) => Number.isFinite(timestampMs) && timestampMs <= nowMs);
const batches = [...validBatches]
.sort((a, b) => b.timestampMs - a.timestampMs || b.index - a.index)
.slice(0, 50);
for (const { batch, timestampMs } of batches) {
if (!batch || !Array.isArray(batch.files)) continue;
for (const file of batch.files) {
if (!file || !Array.isArray(file.results)) continue;
const fileSize = Number(file.size);
for (const result of file.results) {
if (!result?.hoster) continue;
const bucket = ensure(result.hoster);
bucket.sampleSize++;
if (result.status === 'done') {
bucket.successful++;
const previous = bucket.lastSuccessAt ? Date.parse(bucket.lastSuccessAt) : -Infinity;
if (timestampMs > previous) bucket.lastSuccessAt = new Date(timestampMs).toISOString();
const durationSec = Number(result.durationSec);
if (Number.isFinite(fileSize) && fileSize > 0 && Number.isFinite(durationSec) && durationSec > 0) {
bucket.effectiveBytes += fileSize;
bucket.effectiveDurationSec += durationSec;
}
} else if (result.status === 'skipped') {
bucket.skipped++;
} else {
bucket.failed++;
}
}
}
}
for (const { batch, timestampMs } of validBatches) {
if (timestampMs < recentCutoff || !Array.isArray(batch?.files)) continue;
for (const file of batch.files) {
if (!Array.isArray(file?.results)) continue;
for (const result of file.results) {
if (!result?.hoster || result.status === 'done' || result.status === 'skipped') continue;
ensure(result.hoster).failuresLast7Days++;
}
}
}
for (const bucket of Object.values(out)) {
const attempted = bucket.successful + bucket.failed;
bucket.successRate = attempted > 0 ? bucket.successful / attempted : null;
bucket.effectiveBytesPerSecond = bucket.effectiveDurationSec > 0
? bucket.effectiveBytes / bucket.effectiveDurationSec
: null;
}
return out;
}
function classifyErrorCategory(err) {
if (!err || typeof err !== 'string') return 'unknown';
const s = err.toLowerCase();
@@ -196,10 +86,8 @@
};
const existingJobIds = new Set();
const filesByName = new Map();
const filesByKey = new Map();
for (const file of merged.files) {
filesByName.set(String(file.name || file.fileName || ''), file);
if (file.fileKey) filesByKey.set(String(file.fileKey), file);
for (const result of file.results) {
if (result?.jobId) existingJobIds.add(result.jobId);
}
@@ -208,14 +96,11 @@
for (const skipped of Array.isArray(skippedJobs) ? skippedJobs : []) {
if (!skipped || (skipped.jobId && existingJobIds.has(skipped.jobId))) continue;
const fileName = String(skipped.fileName || skipped.file || '').split(/[\\/]/).pop() || '';
const fileKey = String(skipped.fileKey || '');
let file = fileKey ? filesByKey.get(fileKey) : filesByName.get(fileName);
let file = filesByName.get(fileName);
if (!file) {
file = { name: fileName, size: Number(skipped.size) || 0, results: [] };
if (fileKey) file.fileKey = fileKey;
merged.files.push(file);
filesByName.set(fileName, file);
if (fileKey) filesByKey.set(fileKey, file);
}
file.results.push({
jobId: skipped.jobId || null,
@@ -285,7 +170,6 @@
const api = {
summarizePerHoster,
summarizeHosterHealth,
classifyErrorCategory,
summarizeBatchErrors,
mergeSkippedIntoSummary,
+28 -128
View File
@@ -26,147 +26,47 @@ function collectSecretValues(config) {
if (typeof o !== 'object') return;
for (const k of Object.keys(o)) {
const v = o[k];
if (CRED_KEYS.has(k) && typeof v === 'string' && v.length > 0) out.add(v);
if (CRED_KEYS.has(k) && typeof v === 'string' && v.length >= 6) out.add(v);
else walk(v);
}
})(config);
return Array.from(out);
}
function redactConfiguredSecrets(text, secrets) {
if (!Array.isArray(secrets)) return text;
const values = Array.from(new Set(secrets
.filter(value => typeof value === 'string' && value.length > 0)
.flatMap(value => {
const variants = [value];
for (let index = 0; index < 3; index++) {
const escaped = JSON.stringify(variants[variants.length - 1]).slice(1, -1);
if (escaped === variants[variants.length - 1]) break;
variants.push(escaped);
}
return variants;
})))
.sort((a, b) => b.length - a.length);
let out = text;
for (const value of values) {
let offset = 0;
while (offset < out.length) {
const index = out.indexOf(value, offset);
if (index < 0) break;
const before = index > 0 ? out[index - 1] : '';
const after = index + value.length < out.length ? out[index + value.length] : '';
const continuation = character => /[A-Za-z0-9_.]/.test(character);
if (!continuation(before) && !continuation(after)) {
out = `${out.slice(0, index)}${REDACTED}${out.slice(index + value.length)}`;
offset = index + REDACTED.length;
} else {
offset = index + value.length;
}
}
}
return out;
}
function redactHtmlCredentialFields(text) {
let out = '';
let offset = 0;
const lower = text.toLowerCase();
while (offset < text.length) {
const start = lower.indexOf('<input', offset);
if (start < 0) {
out += text.slice(offset);
break;
}
out += text.slice(offset, start);
let quote = '';
let end = start + 6;
for (; end < text.length; end++) {
const character = text[end];
if (quote) {
if (character === quote) quote = '';
} else if (character === '"' || character === "'") {
quote = character;
} else if (character === '>') {
end++;
break;
}
}
const input = text.slice(start, end);
const sensitive = /\btype\s*=\s*["']?password\b/i.test(input)
|| /\b(?:name|id)\s*=\s*["']?(?:password|passwd|api[_-]?(?:key|token)|token|secret|authorization|cookie|session(?:[_-]?id)?)\b/i.test(input);
out += sensitive
? input
.replace(/(\bvalue\s*=\s*)(["'])([\s\S]*?)\2/gi, `$1$2${REDACTED}$2`)
.replace(/(\bvalue\s*=\s*)(?!["'])([^\s>]+)/gi, `$1${REDACTED}`)
: input;
offset = end;
}
return out;
}
function redactAbsolutePaths(text) {
const isDriveStart = (value, index) => /[A-Za-z]/.test(value[index] || '')
&& !/[A-Za-z0-9]/.test(value[index - 1] || '')
&& value[index + 1] === ':'
&& /[\\/]/.test(value[index + 2] || '');
const isBackslashUncStart = (value, index) => {
if (value[index] !== '\\' || value[index + 1] !== '\\' || value[index - 1] === '\\') return false;
let cursor = index + 2;
while (value[cursor] === '\\') cursor++;
if (value[cursor] === '?') return true;
const separator = value.indexOf('\\', cursor);
return separator > cursor;
};
const isSlashUncStart = (value, index) => value[index] === '/'
&& value[index + 1] === '/'
&& !/[:/]/.test(value[index - 1] || '')
&& !/[\/]/.test(value[index + 2] || '')
&& value.indexOf('/', index + 2) > index + 2;
let out = '';
let index = 0;
while (index < text.length) {
if (!isDriveStart(text, index) && !isBackslashUncStart(text, index) && !isSlashUncStart(text, index)) {
out += text[index];
index++;
continue;
}
let end = index;
while (end < text.length && !/[\r\n"'<>|]/.test(text[end])) end++;
const candidate = text.slice(index, end).replace(/\s+(?:trigger|error|outcome|hoster|attempt|status|code)=.*$/i, '');
out += '<redacted-path>';
index += candidate.length;
}
return out;
}
function redactLogText(text, secrets) {
if (typeof text !== 'string' || !text) return text;
let out = redactConfiguredSecrets(text, secrets);
out = redactHtmlCredentialFields(out)
.replace(/("(?:file|fileName|stagedFile|sourceFile|targetFile|path|[A-Za-z0-9_]*Path)"\s*:\s*")[^"]*(")/gi, '$1<redacted-path>$2');
out = redactAbsolutePaths(out)
let out = text;
if (Array.isArray(secrets)) {
for (const s of secrets) {
if (typeof s === 'string' && s.length >= 6) out = out.split(s).join(REDACTED);
}
}
out = out
.replace(/("(?:file|fileName|stagedFile|sourceFile|targetFile|path|[A-Za-z0-9_]*Path)"\s*:\s*")[^"]*(")/gi, '$1<redacted-path>$2')
.replace(/\b[A-Za-z]:(?:\\+|\/+)[^\r\n"'<>|]*?(?=\s+(?:trigger|error|outcome|hoster|attempt|status|code)=|\r?\n|$|["'])/gi, '<redacted-path>')
.replace(/\\{2,}[A-Za-z0-9._$-]+\\+[^\r\n"'<>|]*?(?=\s+(?:trigger|error|outcome|hoster|attempt|status|code)=|\r?\n|$|["'])/g, '<redacted-path>')
.replace(/https?:\/\/(?:ptb\.|canary\.)?discord(?:app)?\.com\/api\/webhooks\/\d+\/[\w-]+/gi, 'https://discord.com/api/webhooks/' + REDACTED)
.replace(/(\/\/[^\s/:@]+:)[^\s/@]+(@)/g, '$1' + REDACTED + '$2')
.replace(/(\b(?:proxy-)?authorization\s*:\s*)[^\r\n]*/gi, '$1' + REDACTED)
.replace(/(\b(?:set-cookie|cookie)\s*:\s*)[^\r\n]*/gi, '$1' + REDACTED)
.replace(/(\b(?:bearer|basic)\s+)[A-Za-z0-9._~+\-/=]+/gi, '$1' + REDACTED)
.replace(/(authorization:\s*(?:bearer|basic)\s+)\S+/gi, '$1' + REDACTED)
.replace(/\bbearer\s+[A-Za-z0-9._\-/+]{16,}/gi, 'bearer ' + REDACTED)
.replace(/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{6,}/g, REDACTED)
.replace(/([?&](?:api[_-]?key|key|token|access[_-]?token|refresh[_-]?token|auth|authorization|password|pass|cookie|session(?:[_-]?id)?)=)[^\s&#"'`]+/gi, '$1' + REDACTED)
.replace(/("?\b(?:api[_-]?key|apikey|password|passwd|secret|authorization|cookie|(?:access|refresh|auth|session)[_-]?token|token|session[_-]?id|sessionid|session|sess[_-]?id|sessid|sess)"?\s*[:=]\s*)(["'])(.*?)\2/gi, `$1$2${REDACTED}$2`)
.replace(/("?\b(?:api[_-]?key|apikey|password|passwd|secret|authorization|cookie|(?:access|refresh|auth|session)[_-]?token|token|session[_-]?id|sessionid|session|sess[_-]?id|sessid|sess)"?\s*[:=]\s*)(?!["'])([^\s,;}\]\r\n]+)/gi, '$1' + REDACTED);
.replace(/([?&](?:api_?key|key|token|access_token|password|pass)=)[^\s&"'`]+/gi, '$1' + REDACTED)
.replace(/("?\b(?:api[_-]?key|apikey|password|passwd|secret|(?:access|refresh|auth|session)[_-]?token|token|sessionid|session)"?\s*[:=]\s*"?)[A-Za-z0-9._\-/+]{8,}/gi, '$1' + REDACTED)
.replace(/(\bset-cookie:|\bcookie:)\s*\S[^\n]*/gi, '$1 ' + REDACTED)
.replace(/(\bsess(?:_?id)?\b["'=:\s]+)[A-Za-z0-9._\-]{8,}/gi, '$1' + REDACTED);
return out;
}
function valueScrub(value, secrets) {
if (value === null || value === undefined) return value;
if (typeof value === 'string') return redactLogText(value, secrets);
if (Array.isArray(value)) return value.map(entry => valueScrub(entry, secrets));
if (typeof value === 'object') {
const out = {};
for (const [key, entry] of Object.entries(value)) out[redactLogText(key, secrets)] = valueScrub(entry, secrets);
return out;
const json = JSON.stringify(value);
let scrubbed = json;
if (Array.isArray(secrets)) {
for (const s of secrets) {
if (typeof s === 'string' && s.length >= 6) scrubbed = scrubbed.split(s).join(REDACTED);
}
}
return value;
return JSON.parse(scrubbed);
}
function collectFile(filePath, label, maxBytes, options) {
@@ -201,18 +101,18 @@ function collectFile(filePath, label, maxBytes, options) {
function buildSupportBundleText({ header, sanitizedConfig, files, secrets }) {
const parts = [];
parts.push('=== Multi Hoster Uploader Support Bundle ===\n');
parts.push('=== Multi-Hoster-Upload Support Bundle ===\n');
if (header && typeof header === 'object') {
for (const [k, v] of Object.entries(header)) parts.push(`${k}: ${v}\n`);
}
parts.push('\n');
parts.push('=== Config (sanitized — password/apiKey/token/cookie/sessionId redacted) ===\n');
parts.push(JSON.stringify(sanitizedConfig, null, 2));
parts.push(redactLogText(JSON.stringify(sanitizedConfig, null, 2), secrets));
parts.push('\n\n');
for (const f of (files || [])) {
parts.push(collectFile(f.path, f.label || 'log', f.maxBytes, { includePath: false }));
parts.push(redactLogText(collectFile(f.path, f.label || f.path, f.maxBytes, { includePath: false }), secrets));
}
return redactLogText(parts.join(''), secrets);
return parts.join('');
}
module.exports = { sanitizeConfig, collectSecretValues, redactLogText, valueScrub, collectFile, buildSupportBundleText, CRED_KEYS, REDACTED };
+2 -6
View File
@@ -61,10 +61,6 @@ function findLatestYml(assets) {
return assets.find(a => /^latest\.yml$/i.test(a.name)) || null;
}
function normalizeInstallerName(value) {
return path.basename(String(value || '')).toLowerCase().replace(/[ ._]+/g, '');
}
async function fetchJson(url, signal) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), CHECK_TIMEOUT);
@@ -102,7 +98,7 @@ async function fetchGithubReleaseNotes(remoteVersion, fallback = '', fetchImpl =
headers: {
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': 'Multi-Hoster-Uploader'
'User-Agent': 'Multi-Hoster-Upload'
}
});
if (!response.ok) return String(fallback || '');
@@ -184,7 +180,7 @@ async function parseLatestYml(url, expected = {}, fetchImpl = fetch) {
if (expected.version && version !== expected.version) {
throw new Error('Prüfsummen-Metadaten gehören zu einer anderen Version');
}
if (expected.assetName && normalizeInstallerName(assetPath) !== normalizeInstallerName(expected.assetName)) {
if (expected.assetName && path.basename(assetPath) !== path.basename(expected.assetName)) {
throw new Error('Prüfsummen-Metadaten gehören nicht zum ausgewählten Installer');
}
if (expected.assetSize && size !== Number(expected.assetSize)) {
+10 -62
View File
@@ -1,31 +1,10 @@
const nodePath = require('path');
const { formatUploadPlanLogLine } = require('./upload-log');
function getUploadAuditLogPath(uploadLogPath, pathApi = nodePath) {
if (typeof uploadLogPath !== 'string' || !uploadLogPath.trim()) return null;
return pathApi.join(pathApi.dirname(uploadLogPath), 'upload-audit.log');
}
async function appendDurably(fs, targetPath, line) {
const handle = await fs.promises.open(targetPath, 'a');
let appendError = null;
let closeError = null;
try {
await handle.appendFile(line, 'utf-8');
await handle.sync();
} catch (error) {
appendError = error;
}
try {
await handle.close();
} catch (error) {
closeError = error;
}
if (appendError && closeError) throw new AggregateError([appendError, closeError], 'Audit append and close failed');
if (appendError) throw appendError;
if (closeError) throw closeError;
}
function createUploadAuditWriter(options) {
const source = options && typeof options === 'object' ? options : {};
const fs = source.fs;
@@ -33,47 +12,39 @@ function createUploadAuditWriter(options) {
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 () => false;
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.open !== 'function' || typeof resolveUploadLogTarget !== 'function') {
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) {
const excludedPaths = new Set();
let excludedPath = null;
for (const delay of retryDelays) {
if (delay) await new Promise(resolve => setTimeout(resolve, delay));
const uploadTarget = resolveUploadLogTarget(excludedPaths);
if (!uploadTarget || excludedPaths.has(uploadTarget.path)) continue;
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) {
let persisted = false;
try {
persisted = await persistFallbackLogPath(uploadTarget.path);
await persistFallbackLogPath(uploadTarget.path);
} catch (error) {
reportError('audit-fallback-persist', error);
}
if (persisted !== true) {
excludedPaths.add(uploadTarget.path);
invalidateUploadLogTarget();
reportError('audit-fallback-persist', new Error('Fallback log path could not be persisted'));
continue;
}
}
rotateLogFile(targetPath, maxBytes, maxBackups);
await appendDurably(fs, targetPath, line);
activePath = targetPath;
return true;
} catch (error) {
excludedPaths.add(uploadTarget.path);
excludedPath = uploadTarget.path;
invalidateUploadLogTarget();
reportError(label, error);
}
@@ -84,27 +55,4 @@ function createUploadAuditWriter(options) {
return { append, getActivePath: () => activePath };
}
function createUploadAuditEvents(writer, now = () => new Date()) {
if (!writer || typeof writer.append !== 'function') throw new TypeError('createUploadAuditEvents requires an audit writer');
return {
appendSourceCleanup: event => writer.append(`# SOURCE-CLEANUP ${JSON.stringify(event)}\r\n`, 'source-cleanup'),
appendUploadPlan: (plan, mode) => writer.append(formatUploadPlanLogLine(now(), plan, mode), 'upload-plan')
};
}
async function runAfterDurableAudit(audit, action) {
let persisted = false;
try {
persisted = await audit();
} catch {}
if (persisted !== true) return { ok: false };
return { ok: true, value: await action() };
}
function getUploadAuditFailureMessage(language) {
return language === 'de'
? 'Der Uploadplan konnte nicht dauerhaft protokolliert werden. Bitte prüfe den Log-Pfad und versuche es erneut.'
: 'The upload plan could not be recorded durably. Check the log path and try again.';
}
module.exports = { getUploadAuditLogPath, createUploadAuditWriter, createUploadAuditEvents, runAfterDurableAudit, getUploadAuditFailureMessage };
module.exports = { getUploadAuditLogPath, createUploadAuditWriter };
+26 -24
View File
@@ -11,13 +11,13 @@ const HOSTER_RESULT_DOMAINS = {
'doodstream.com': ['doodstream.com', 'dood.to', 'dood.la', 'dood.so', 'dsvplay.com']
};
function isExpectedHostUrl(value, expectedHost, allowHttp = false) {
function isExpectedHostUrl(value, expectedHost) {
if (typeof value !== 'string' || value.trim() === '') return false;
try {
const url = new URL(value);
const hostname = url.hostname.toLowerCase();
const acceptedDomains = HOSTER_RESULT_DOMAINS[expectedHost] || [expectedHost];
return (url.protocol === 'https:' || (allowHttp && url.protocol === 'http:'))
return (url.protocol === 'http:' || url.protocol === 'https:')
&& acceptedDomains.some(domain => hostname === domain || hostname.endsWith(`.${domain}`));
} catch {
return false;
@@ -32,15 +32,25 @@ function getUrlHost(value) {
}
}
function selectPublicUploadUrl(result) {
for (const value of [result?.download_url, result?.embed_url]) {
if (typeof value !== 'string' || value.trim() === '') continue;
try {
const url = new URL(value.trim());
if (url.protocol === 'https:') return url.href;
} catch {}
}
return '';
function normalizeDoodstreamUrl(value) {
if (typeof value !== 'string' || value.trim() === '') return value;
try {
const url = new URL(value);
const hostname = url.hostname.toLowerCase();
if (hostname !== 'doodstream.com' && HOSTER_RESULT_DOMAINS['doodstream.com'].includes(hostname)) {
url.hostname = 'doodstream.com';
return url.toString();
}
} catch {}
return value;
}
function normalizeConfirmedResult(result, hoster) {
if (hoster !== 'doodstream.com') return result;
const downloadUrl = normalizeDoodstreamUrl(result.download_url);
const embedUrl = normalizeDoodstreamUrl(result.embed_url);
if (downloadUrl === result.download_url && embedUrl === result.embed_url) return result;
return { ...result, download_url: downloadUrl, embed_url: embedUrl };
}
function assertUploadConfirmation(result, hoster) {
@@ -51,18 +61,10 @@ function assertUploadConfirmation(result, hoster) {
&& value !== undefined
&& !(typeof value === 'string' && value.trim() === '')
));
if (SUPPORTED_HOSTERS.has(expectedHost) && FILE_CODE_PATTERN.test(fileCode)) {
if (expectedHost === 'doodstream.com' && urls.every(value => isExpectedHostUrl(value, expectedHost, true))) {
return {
...result,
file_code: fileCode,
download_url: `https://doodstream.com/d/${fileCode}`,
embed_url: `https://doodstream.com/e/${fileCode}`
};
}
if (urls.length > 0 && urls.every(value => isExpectedHostUrl(value, expectedHost))) {
return fileCode === result.file_code ? result : { ...result, file_code: fileCode };
}
if (SUPPORTED_HOSTERS.has(expectedHost)
&& FILE_CODE_PATTERN.test(fileCode)
&& urls.every(value => isExpectedHostUrl(value, expectedHost))) {
return normalizeConfirmedResult(result, expectedHost);
}
const error = new Error(`Upload zu ${hoster || 'unbekanntem Hoster'} wurde nicht bestätigt`);
error.diagnostic = {
@@ -74,4 +76,4 @@ function assertUploadConfirmation(result, hoster) {
throw error;
}
module.exports = { assertUploadConfirmation, selectPublicUploadUrl };
module.exports = { assertUploadConfirmation };
-24
View File
@@ -9,28 +9,13 @@ function cleanText(value, limit = 320) {
return '[URL]';
}
});
text = text.replace(/\b(authorization|proxy-authorization|cookie|set-cookie)\s*[:=]\s*[^,]+/gi, '$1=[redacted]');
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]';
}
text = text.replace(/\b[A-Za-z0-9_-]{20,}\b/g, '[redacted]');
return text.slice(0, limit);
}
function normalizeEndpointHost(value) {
const host = String(value || '').trim().toLowerCase();
if (!host || host.length > 253 || !/^[a-z0-9.-]+$/.test(host)) return '';
if (host.startsWith('.') || host.endsWith('.') || host.includes('..')) return '';
return host;
}
function normalizePhase(value) {
const phase = String(value || '').trim();
if (/^[a-z0-9._:-]{1,80}$/i.test(phase)) return phase;
return cleanText(phase, 80);
}
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
@@ -38,18 +23,9 @@ function normalizeFailureDetails(diagnostic) {
: null;
const contentType = cleanText(diagnostic.contentType, 120);
const responseSnippet = cleanText(diagnostic.payloadSnippet, 320);
const phase = normalizePhase(diagnostic.phase);
const endpointHost = normalizeEndpointHost(diagnostic.safeEndpointHost);
const responseKind = ['empty', 'json', 'html', 'text'].includes(diagnostic.responseKind)
? diagnostic.responseKind
: '';
const details = {};
if (phase) details.phase = phase;
if (httpStatus !== null) details.httpStatus = httpStatus;
if (contentType) details.contentType = contentType;
if (endpointHost) details.endpointHost = endpointHost;
if (responseKind) details.responseKind = responseKind;
if (typeof diagnostic.retryable === 'boolean') details.retryable = diagnostic.retryable;
if (responseSnippet) details.responseSnippet = responseSnippet;
return Object.keys(details).length > 0 ? details : null;
}
+101 -510
View File
@@ -3,7 +3,7 @@ const path = require('path');
const { assertUploadConfirmation } = require('./upload-confirmation');
const fs = require('fs');
const crypto = require('crypto');
const { uploadFile, prefetchBaseline, createRecoveryClaimRegistry, normalizeRecoveryTitle } = require('./hosters');
const { uploadFile, prefetchBaseline } = require('./hosters');
const VidmolyUploader = require('./vidmoly-upload');
const VoeUploader = require('./voe-upload');
const DoodstreamUploader = require('./doodstream-upload');
@@ -12,7 +12,6 @@ const Semaphore = require('./semaphore');
const Throttle = require('./throttle');
const { probeFileHead } = require('./file-probe');
const { normalizeFailureDetails } = require('./upload-diagnostics');
const { createUploadScheduleGate } = require('./upload-schedule');
const DEFAULT_SETTINGS = {
retries: 3,
@@ -29,7 +28,6 @@ class UploadManager extends EventEmitter {
super();
this.hosterSettings = hosterSettings || {};
this.globalSettings = globalSettings || {};
this.uploadScheduleGate = createUploadScheduleGate(this.globalSettings.uploadSchedule);
this.accountPools = accountPools || {};
this.semaphores = {};
this.globalSemaphore = null;
@@ -54,11 +52,6 @@ class UploadManager extends EventEmitter {
this._suspectGoodAccounts = new Map(); // hoster -> accountId that accepted a suspect-class file
this._doodApiKeyCache = new Map(); // accountId/username -> derived doodstream API key ('' = tried, none)
this._baselineCache = new Map(); // hoster:apiKey -> Promise<Set<file_code>> (one fetch shared across all jobs in batch)
this._recoveryClaims = createRecoveryClaimRegistry();
this._recoveryAuthModes = new Map();
this._suspectResolutionGates = new Map();
this._batchJobIds = new Set();
this._batchTotal = 0;
}
updateAccountPools(accountPools) {
@@ -75,9 +68,6 @@ class UploadManager extends EventEmitter {
this._suspectGoodAccounts.clear();
this._doodApiKeyCache.clear();
this._baselineCache.clear();
if (!this.running) this._recoveryClaims.clear();
if (!this.running) this._recoveryAuthModes.clear();
if (!this.running) this._clearSuspectResolutionGates();
}
switchAccount(hoster, fallbackAccount) {
@@ -140,41 +130,6 @@ class UploadManager extends EventEmitter {
return true;
}
_swapFailedAccount(task, jobId, fileName) {
if (!task.accountId || !this._failedAccounts.has(task.hoster + ':' + task.accountId)) return false;
const override = this._accountOverrides.get(task.hoster);
if (override && !this._failedAccounts.has(task.hoster + ':' + override.id)) {
this._rotLog('pre-job-swap', {
jobId, hoster: task.hoster, fileName, fromAccountId: task.accountId, toAccountId: override.id
});
task.accountId = override.id;
task.username = override.username;
task.password = override.password;
task.apiKey = override.apiKey;
return true;
}
this._rotLog('pre-job-swap-blocked', {
jobId, hoster: task.hoster, fileName, accountId: task.accountId,
hasOverride: !!override,
overrideAlsoFailed: override ? this._failedAccounts.has(task.hoster + ':' + override.id) : false
});
return false;
}
async _coordinateAccountFailure(task, err, signal, jobId) {
if (!task.accountId || (err && err.remoteCommitUncertain === true)) return;
if (!this._shouldSkipRetryOnAccountError(err)) return;
const key = task.hoster + ':' + task.accountId;
if (this._failedAccounts.has(key)) return;
this._failedAccounts.set(key, true);
this._rotLog('mark-failed', {
jobId, hoster: task.hoster, fileName: path.basename(task.file),
accountId: task.accountId, lastError: err && err.message ? err.message : String(err)
});
this.emit('account-failed', { hoster: task.hoster, accountId: task.accountId });
await this._sleep(800, signal);
}
_rotLog(event, data) {
this.emit('rot-log', { ts: Date.now(), event, ...data });
}
@@ -202,7 +157,6 @@ class UploadManager extends EventEmitter {
// which takes priority in _shouldSkipRetryOnAccountError.
_isFileRejectedError(err) {
if (!err) return false;
if (err.remoteCommitUncertain === true) return false;
if (err.transientNetwork === true) return false;
if (err.accountError === true) return false; // explicit account-level wins
if (err.fileRejected === true) return true;
@@ -307,7 +261,6 @@ class UploadManager extends EventEmitter {
updateSettings(hosterSettings, globalSettings) {
this.hosterSettings = hosterSettings || this.hosterSettings;
this.globalSettings = globalSettings || this.globalSettings;
this.uploadScheduleGate.update(this.globalSettings.uploadSchedule);
// Live-update semaphores for running uploads
for (const [hoster, sem] of Object.entries(this.semaphores)) {
const settings = this._getSettings(hoster);
@@ -381,8 +334,6 @@ class UploadManager extends EventEmitter {
async startBatch(tasks, opts = {}) {
const pendingCancelledJobIds = new Set(this.pendingCancelledJobIds);
const pendingCancelAll = this.pendingCancelAll;
this._batchJobIds = new Set(tasks.map((task) => task.jobId).filter(Boolean));
this._batchTotal = tasks.length;
this.pendingCancelledJobIds.clear();
this.pendingCancelAll = false;
this.running = true;
@@ -397,10 +348,6 @@ class UploadManager extends EventEmitter {
for (const jobId of pendingCancelledJobIds) this.cancelledJobIds.add(jobId);
this._doodApiKeyCache.clear(); // re-derive doodstream keys fresh each batch
this._baselineCache.clear(); // re-fetch baselines per batch (a long batch could outlast remote-side relevance)
this._recoveryClaims.clear();
this._recoveryClaims = createRecoveryClaimRegistry();
this._recoveryAuthModes.clear();
this._clearSuspectResolutionGates();
this.semaphores = {};
this.globalSemaphore = null;
this.globalThrottle = null;
@@ -441,7 +388,7 @@ class UploadManager extends EventEmitter {
for (let j = i; j < end; j++) {
const task = tasks[j];
if (!results.has(task.file)) {
results.set(task.file, { name: path.basename(task.file), fileKey: task.fileKey || null, size: 0, results: [] });
results.set(task.file, { name: path.basename(task.file), size: 0, results: [] });
toStat.push(task.file);
}
}
@@ -472,7 +419,7 @@ class UploadManager extends EventEmitter {
this._emitStats();
const files = Array.from(results.values());
const total = this._batchTotal;
const total = tasks.length;
const succeeded = files.reduce((count, file) => count + file.results.filter((result) => result.status === 'done').length, 0);
const skipped = files.reduce((count, file) => count + file.results.filter((result) => result.status === 'skipped').length, 0);
@@ -481,21 +428,18 @@ class UploadManager extends EventEmitter {
timestamp: new Date().toISOString(),
total,
succeeded,
failed: Math.max(0, total - succeeded - skipped),
failed: total - succeeded - skipped,
skipped,
files
};
this._recoveryClaims.clear();
this._recoveryAuthModes.clear();
this._clearSuspectResolutionGates();
this._doodApiKeyCache.clear();
this._baselineCache.clear();
this.emit('batch-done', summary);
}
async _runJob(task, results, batchSignal) {
const settings = this._getSettings(task.hoster);
const hosterSemaphore = this._getSemaphore(task.hoster);
const globalSemaphore = this._getGlobalSemaphore();
const uploadId = crypto.randomBytes(8).toString('hex');
const jobId = task.jobId || uploadId;
const fileName = path.basename(task.file);
@@ -515,6 +459,8 @@ class UploadManager extends EventEmitter {
const { signal, cleanup: cleanupSignals } = this._combineSignals(batchSignal, jobAbortController.signal);
this.jobAbortControllers.set(jobId, jobAbortController);
let hosterSlotAcquired = false;
let globalSlotAcquired = false;
let finalResultRecorded = false;
let finalStatus = 'error';
let lastError = null;
@@ -527,7 +473,6 @@ class UploadManager extends EventEmitter {
finalStatus = status;
const result = {
jobId,
hoster: task.hoster,
status,
error: payload.error || null,
@@ -540,16 +485,12 @@ class UploadManager extends EventEmitter {
embed_url: payload.result ? payload.result.embed_url || null : null,
file_code: payload.result ? payload.result.file_code || null : null
};
if (payload.remoteCommitUncertain === true || payload.error?.remoteCommitUncertain === true || lastError?.remoteCommitUncertain === true) {
result.remoteCommitUncertain = true;
}
results.get(task.file).results.push(result);
};
const emitFinalStatus = (status, payload = {}) => {
if (status === 'aborted' && this.abortController.signal.aborted) return;
const remoteCommitUncertain = payload.remoteCommitUncertain === true || payload.error?.remoteCommitUncertain === true || lastError?.remoteCommitUncertain === true;
this._emitProgress(uploadId, fileName, task.hoster, { accountId: task.accountId,
jobId,
status,
@@ -563,8 +504,7 @@ class UploadManager extends EventEmitter {
failureDetails: payload.failureDetails || lastFailureDetails,
result: payload.result || null,
attempt: payload.attempt || maxAttempts,
maxAttempts,
remoteCommitUncertain
maxAttempts
});
};
@@ -601,6 +541,9 @@ class UploadManager extends EventEmitter {
// queueJobs array; the first event it actually needs from main is the
// 'getting-server' / 'uploading' transition for the jobs that the
// semaphore lets through.
await hosterSemaphore.acquire(signal);
hosterSlotAcquired = true;
let fileProbe = null;
try {
fileProbe = await probeFileHead(task.file, 512);
@@ -615,19 +558,48 @@ class UploadManager extends EventEmitter {
headHex: fileProbe && fileProbe.headHex ? fileProbe.headHex.slice(0, 32) : null
});
if (globalSemaphore) {
await globalSemaphore.acquire(signal);
globalSlotAcquired = true;
}
if (settings.timeIntervalSec > 0) {
await this._waitForInterval(task.hoster, settings.timeIntervalSec * 1000, signal);
}
// Pre-job-swap: if this account was marked failed WHILE this task was
// waiting in the semaphore queue, jump straight to the override instead
// of burning a guaranteed-to-fail upload attempt. Critical at scale:
// with 500 queued jobs and 1 parallel slot, without this check every
// job still hits the original dead account first.
this._swapFailedAccount(task, jobId, fileName);
if (task.accountId && this._failedAccounts.has(task.hoster + ':' + task.accountId)) {
const override = this._accountOverrides.get(task.hoster);
if (override && !this._failedAccounts.has(task.hoster + ':' + override.id)) {
this._rotLog('pre-job-swap', {
jobId, hoster: task.hoster, fileName, fromAccountId: task.accountId, toAccountId: override.id
});
task.accountId = override.id;
task.username = override.username;
task.password = override.password;
task.apiKey = override.apiKey;
} else {
this._rotLog('pre-job-swap-blocked', {
jobId, hoster: task.hoster, fileName, accountId: task.accountId,
hasOverride: !!override,
overrideAlsoFailed: override ? this._failedAccounts.has(task.hoster + ':' + override.id) : false
});
}
}
// A previous file of at least this size already got a suspect rejection
// on this exact account — skip the guaranteed-to-fail multi-GB upload
// and go straight to the alternate-account walk below.
let memoSuspect = this._createSuspectMemoError(task, fileProbe, fileSize);
if (memoSuspect) {
this._beginSuspectResolution(task.hoster, jobId);
let memoSuspect = null;
if (fileProbe && fileProbe.isVideoLike === true && task.accountId
&& this._suspectMemoBlocks(task.hoster, task.accountId, fileSize)) {
memoSuspect = new Error('Bekanntes Größen-Limit auf diesem Account (frühere verdächtige Ablehnung)');
memoSuspect.fileRejected = true;
memoSuspect.suspectReject = true;
lastError = memoSuspect;
this._rotLog('suspect-memo-skip', {
jobId, hoster: task.hoster, fileName, accountId: task.accountId, fileSize
@@ -636,8 +608,6 @@ class UploadManager extends EventEmitter {
const attemptsAllowed = memoSuspect ? 0 : maxAttempts;
for (let attempt = 1; attempt <= attemptsAllowed; attempt++) {
await this._waitForUploadSchedule(signal);
this._throwIfUploadStartBlocked(signal);
finalAttempt = attempt;
if (signal.aborted || this.stopAfterActive) break;
@@ -757,7 +727,9 @@ class UploadManager extends EventEmitter {
} catch { /* progress callbacks must never throw — swallowing is correct, the stream keeps going */ }
};
const result = await this._executeUploadWithAdmission(task, progressCb, uploadSignalBundle.signal, throttle, fileProbe, fileSize, true, jobId);
const result = await this._executeUpload(task, progressCb, uploadSignalBundle.signal, throttle, fileProbe);
if (signal.aborted || this.cancelledJobIds.has(jobId)) throw new Error('Aborted');
const elapsed = Math.round((Date.now() - jobStart) / 1000);
this.sessionBytes += fileSize;
@@ -794,11 +766,6 @@ class UploadManager extends EventEmitter {
payloadSnippet: lastFailureDetails ? lastFailureDetails.responseSnippet || null : null
});
}
if (err && err.remoteCommitUncertain === true) {
lastError = err;
break;
}
if (signal.aborted) {
lastError = new Error('Abgebrochen');
break;
@@ -864,16 +831,6 @@ class UploadManager extends EventEmitter {
const wasStopped = this.stopAfterActive && !signal.aborted;
const wasAborted = signal.aborted || this.cancelledJobIds.has(jobId);
if (lastError && lastError.remoteCommitUncertain === true) {
const cancelledUncertain = lastError.cancelledAfterUploadStart === true;
const status = cancelledUncertain ? 'aborted' : 'error';
const error = cancelledUncertain
? 'Abgebrochen; Remote-Status konnte nicht bestätigt werden'
: (lastError.message || 'Remote-Upload konnte nicht eindeutig bestätigt werden');
emitFinalStatus(status, { error, remoteCommitUncertain: true });
recordFinalResult(status, { error, remoteCommitUncertain: true });
return;
}
if (wasStopped || wasAborted) {
const error = wasStopped ? 'Warteschlange angehalten' : 'Abgebrochen';
emitFinalStatus('aborted', { error });
@@ -907,6 +864,12 @@ class UploadManager extends EventEmitter {
this._noteSuspectReject(task.hoster, task.accountId, fileSize);
const alt = await this._trySuspectRejectAlternates(task, { uploadId, jobId, fileName, fileSize, settings, signal, fileProbe });
if (alt) {
if (signal.aborted || this.cancelledJobIds.has(jobId)) {
const error = 'Abgebrochen';
emitFinalStatus('aborted', { error });
recordFinalResult('aborted', { error });
return;
}
emitFinalStatus('done', { result: alt.result, speedKbs: alt.speedKbs, elapsed: alt.elapsed, attempt: 1 });
recordFinalResult('done', { result: alt.result });
return;
@@ -1028,8 +991,6 @@ 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++) {
await this._waitForUploadSchedule(signal);
this._throwIfUploadStartBlocked(signal);
finalAttempt = attempt;
if (signal.aborted || this.stopAfterActive) break;
if (attempt > 1) {
@@ -1078,7 +1039,8 @@ class UploadManager extends EventEmitter {
? { consume: async (bytes, sig) => { await hosterThrottle.consume(bytes, sig); await globalThrottle.consume(bytes, sig); } }
: hosterThrottle || globalThrottle;
const result = await this._executeUploadWithAdmission(task, progressCb, signal, throttle, fileProbe, fileSize, true, jobId);
const result = await this._executeUpload(task, progressCb, signal, throttle, fileProbe);
if (signal.aborted || this.cancelledJobIds.has(jobId)) throw new Error('Aborted');
this.activeJobs.delete(uploadId);
this.sessionBytes += fileSize;
emitFinalStatus('done', { result, speedKbs: currentSpeedKbs, elapsed: Math.round((Date.now() - jobStart) / 1000), attempt });
@@ -1099,7 +1061,6 @@ class UploadManager extends EventEmitter {
rotationRetry: true
});
}
if (err && err.remoteCommitUncertain === true) break;
if (signal.aborted || this.stopAfterActive) break;
if (this._isFileRejectedError(err)) break;
if (this._isHosterTransientError(err)) break;
@@ -1111,16 +1072,6 @@ class UploadManager extends EventEmitter {
const stoppedLate = this.stopAfterActive && !signal.aborted;
const abortedLate = signal.aborted || this.cancelledJobIds.has(jobId);
if (lastError && lastError.remoteCommitUncertain === true) {
const cancelledUncertain = lastError.cancelledAfterUploadStart === true;
const status = cancelledUncertain ? 'aborted' : 'error';
const error = cancelledUncertain
? 'Abgebrochen; Remote-Status konnte nicht bestätigt werden'
: (lastError.message || 'Remote-Upload konnte nicht eindeutig bestätigt werden');
emitFinalStatus(status, { error, remoteCommitUncertain: true });
recordFinalResult(status, { error, remoteCommitUncertain: true });
return;
}
if (stoppedLate || abortedLate) {
const error = stoppedLate ? 'Warteschlange angehalten' : 'Abgebrochen';
emitFinalStatus('aborted', { error });
@@ -1134,25 +1085,20 @@ class UploadManager extends EventEmitter {
emitFinalStatus('error', { error });
recordFinalResult('error', { error });
} catch (err) {
if (err && err.remoteCommitUncertain === true) lastError = err;
const wasStopped = this.stopAfterActive && !signal.aborted;
const remoteCommitUncertain = !!(err && err.remoteCommitUncertain === true);
const cancelledUncertain = remoteCommitUncertain && err.cancelledAfterUploadStart === true;
const error = remoteCommitUncertain
? (cancelledUncertain ? 'Abgebrochen; Remote-Status konnte nicht bestätigt werden' : (err.message || 'Remote-Upload konnte nicht eindeutig bestätigt werden'))
: wasStopped
const error = wasStopped
? 'Warteschlange angehalten'
: (signal.aborted || this.cancelledJobIds.has(jobId) ? 'Abgebrochen' : (err && err.message ? err.message : 'Unbekannter Fehler'));
const status = remoteCommitUncertain
? (cancelledUncertain ? 'aborted' : 'error')
: (signal.aborted || this.cancelledJobIds.has(jobId) || wasStopped ? 'aborted' : 'error');
emitFinalStatus(status, { error, remoteCommitUncertain });
recordFinalResult(status, { error, remoteCommitUncertain });
const status = signal.aborted || this.cancelledJobIds.has(jobId) || wasStopped ? 'aborted' : 'error';
emitFinalStatus(status, { error });
recordFinalResult(status === 'error' ? 'error' : 'aborted', { error });
} finally {
this._endSuspectResolution(task.hoster, jobId);
this.activeJobs.delete(uploadId);
this.jobAbortControllers.delete(jobId);
cleanupSignals();
// Release in reverse order of acquire (global first, then hoster)
if (globalSlotAcquired && globalSemaphore) globalSemaphore.release();
if (hosterSlotAcquired) hosterSemaphore.release();
this.emit('job-settled', {
jobId,
sourceCleanupToken: task.sourceCleanupToken || null,
@@ -1188,8 +1134,6 @@ class UploadManager extends EventEmitter {
});
continue;
}
await this._waitForUploadSchedule(signal);
this._throwIfUploadStartBlocked(signal);
attempted += 1;
this._rotLog('suspect-reject-alt', {
jobId, hoster: task.hoster, fileName, fromAccountId: task.accountId, toAccountId: account.id
@@ -1238,7 +1182,8 @@ class UploadManager extends EventEmitter {
? { consume: async (bytes, sig) => { await hosterThrottle.consume(bytes, sig); await globalThrottle.consume(bytes, sig); } }
: hosterThrottle || globalThrottle;
try {
const result = await this._executeUploadWithAdmission(task, progressCb, signal, throttle, fileProbe, fileSize, false, jobId);
const result = await this._executeUpload(task, progressCb, signal, throttle, fileProbe);
if (signal.aborted || this.cancelledJobIds.has(jobId)) throw new Error('Aborted');
this.activeJobs.delete(uploadId);
this.sessionBytes += fileSize;
this._suspectGoodAccounts.set(task.hoster, account.id);
@@ -1256,7 +1201,6 @@ class UploadManager extends EventEmitter {
suspectAlternate: true
});
}
if (err && err.remoteCommitUncertain === true) throw err;
if (signal.aborted || this.stopAfterActive) break;
if (err && err.suspectReject === true) {
this._noteSuspectReject(task.hoster, account.id, fileSize);
@@ -1289,325 +1233,20 @@ class UploadManager extends EventEmitter {
return null;
}
async _executeUploadWithAdmission(task, progressCb, signal, throttle, fileProbe, fileSize, coordinateAccountFailure = true, jobId = task.jobId) {
while (true) {
await this._waitForUploadSchedule(signal);
this._throwIfUploadStartBlocked(signal);
const context = await this._createRecoveryContext(task);
this._throwIfUploadStartBlocked(signal);
let retryAdmission = false;
const operation = async () => {
this._throwIfUploadStartBlocked(signal);
const hosterSemaphore = this._getSemaphore(task.hoster);
const globalSemaphore = this._getGlobalSemaphore();
let hosterSlotAcquired = false;
let globalSlotAcquired = false;
const releaseSlots = () => {
if (globalSlotAcquired && globalSemaphore) globalSemaphore.release();
if (hosterSlotAcquired) hosterSemaphore.release();
globalSlotAcquired = false;
hosterSlotAcquired = false;
};
const acquireSlots = async () => {
await hosterSemaphore.acquire(signal);
hosterSlotAcquired = true;
this._throwIfUploadStartBlocked(signal);
if (globalSemaphore) {
await globalSemaphore.acquire(signal);
globalSlotAcquired = true;
this._throwIfUploadStartBlocked(signal);
}
};
try {
await this._waitForSuspectResolution(task.hoster, jobId, signal);
this._throwIfUploadStartBlocked(signal);
const accountFailed = !!task.accountId && this._failedAccounts.has(task.hoster + ':' + task.accountId);
if (accountFailed) {
if (this._swapFailedAccount(task, jobId, path.basename(task.file))) {
retryAdmission = true;
return null;
}
if (!coordinateAccountFailure) {
const error = new Error('Account became unavailable before upload');
error.accountUnavailable = true;
throw error;
}
}
const settings = this._getSettings(task.hoster);
if (settings.timeIntervalSec > 0) {
await this._waitForInterval(task.hoster, settings.timeIntervalSec * 1000, signal, acquireSlots);
} else {
await acquireSlots();
}
this._throwIfUploadStartBlocked(signal);
if (this._hasForeignSuspectResolution(task.hoster, jobId)) {
releaseSlots();
await this._waitForSuspectResolution(task.hoster, jobId, signal);
this._throwIfUploadStartBlocked(signal);
retryAdmission = true;
return null;
}
const lateMemoSuspect = coordinateAccountFailure
? this._createSuspectMemoError(task, fileProbe, fileSize)
: null;
if (lateMemoSuspect) {
this._rotLog('suspect-memo-skip', {
jobId,
hoster: task.hoster,
fileName: path.basename(task.file),
accountId: task.accountId,
fileSize
});
this._beginSuspectResolution(task.hoster, jobId);
throw lateMemoSuspect;
}
if (!this.uploadScheduleGate.evaluate().allowed) {
releaseSlots();
await this._waitForUploadSchedule(signal);
this._throwIfUploadStartBlocked(signal);
retryAdmission = true;
return null;
}
try {
this._throwIfUploadStartBlocked(signal);
return await this._executeUpload(task, progressCb, signal, throttle, fileProbe, context);
} catch (err) {
if (coordinateAccountFailure && err && err.suspectReject === true) {
this._beginSuspectResolution(task.hoster, jobId);
}
if (err && err.remoteCommitUncertain === true) {
if (context.recoveryClaim) throw context.recoveryClaim.markUncertain(err);
throw err;
}
if (coordinateAccountFailure) await this._coordinateAccountFailure(task, err, signal, jobId);
this._throwIfUploadStartBlocked(signal);
throw err;
}
} finally {
releaseSlots();
}
};
const result = context.recoveryClaim
? await context.recoveryClaim.runExclusive(operation, signal)
: await operation();
if (retryAdmission) {
this._throwIfUploadStartBlocked(signal);
continue;
}
return result;
}
async _executeUpload(task, progressCb, signal, throttle, fileProbe) {
const result = await this._executeUploadUnchecked(task, progressCb, signal, throttle, fileProbe);
return assertUploadConfirmation(result, task.hoster);
}
_throwIfUploadStartBlocked(signal) {
if (signal && signal.aborted) {
const error = new Error('Aborted');
error.name = 'AbortError';
throw error;
}
if (this.stopAfterActive) {
const error = new Error('Warteschlange angehalten');
error.stopAfterActive = true;
throw error;
}
}
_waitForUploadSchedule(signal) {
return this.uploadScheduleGate.wait(signal, () => this._throwIfUploadStartBlocked(signal));
}
_createSuspectMemoError(task, fileProbe, fileSize) {
if (!fileProbe || fileProbe.isVideoLike !== true || !task.accountId) return null;
if (!this._suspectMemoBlocks(task.hoster, task.accountId, fileSize)) return null;
const error = new Error('Bekanntes Größen-Limit auf diesem Account (frühere verdächtige Ablehnung)');
error.fileRejected = true;
error.suspectReject = true;
return error;
}
_beginSuspectResolution(hoster, jobId) {
const existing = this._suspectResolutionGates.get(hoster);
if (existing) return existing.ownerJobId === jobId;
let release;
const promise = new Promise(resolve => {
release = resolve;
});
this._suspectResolutionGates.set(hoster, { ownerJobId: jobId, promise, release });
return true;
}
_hasForeignSuspectResolution(hoster, jobId) {
const gate = this._suspectResolutionGates.get(hoster);
return !!gate && gate.ownerJobId !== jobId;
}
_waitForSuspectResolution(hoster, jobId, signal) {
const gate = this._suspectResolutionGates.get(hoster);
if (!gate || gate.ownerJobId === jobId) return Promise.resolve();
if (!signal) return gate.promise;
if (signal.aborted) {
const error = new Error('Aborted');
error.name = 'AbortError';
return Promise.reject(error);
}
return new Promise((resolve, reject) => {
const onAbort = () => {
signal.removeEventListener('abort', onAbort);
const error = new Error('Aborted');
error.name = 'AbortError';
reject(error);
};
signal.addEventListener('abort', onAbort, { once: true });
gate.promise.then(() => {
signal.removeEventListener('abort', onAbort);
resolve();
});
});
}
_endSuspectResolution(hoster, jobId) {
const gate = this._suspectResolutionGates.get(hoster);
if (!gate || gate.ownerJobId !== jobId) return false;
this._suspectResolutionGates.delete(hoster);
gate.release();
return true;
}
_clearSuspectResolutionGates() {
for (const gate of this._suspectResolutionGates.values()) gate.release();
this._suspectResolutionGates.clear();
}
async _executeUpload(task, progressCb, signal, throttle, fileProbe, context) {
const result = await this._executeUploadUnchecked(task, progressCb, signal, throttle, fileProbe, context);
let confirmed;
try {
confirmed = assertUploadConfirmation(result, task.hoster);
} catch (err) {
if (context.recoveryClaim) throw context.recoveryClaim.markUncertain(err);
throw err;
}
return confirmed;
}
async _awaitUploadResult(resultPromise, signal) {
if (!signal) return resultPromise;
const createAbortUncertainty = () => {
const error = new Error('Abgebrochen; Remote-Status konnte nicht bestätigt werden');
error.name = 'AbortError';
error.remoteCommitUncertain = true;
error.cancelledAfterUploadStart = true;
return error;
};
if (signal.aborted) {
void Promise.resolve(resultPromise).catch(() => {});
throw createAbortUncertainty();
}
let onAbort;
const abortPromise = new Promise((resolve, reject) => {
onAbort = () => {
reject(createAbortUncertainty());
};
signal.addEventListener('abort', onAbort, { once: true });
});
try {
return await Promise.race([resultPromise, abortPromise]);
} catch (error) {
if (!signal.aborted) throw error;
if (error && error.remoteCommitUncertain === true) {
error.cancelledAfterUploadStart = true;
throw error;
}
throw createAbortUncertainty();
} finally {
signal.removeEventListener('abort', onAbort);
}
}
async _createRecoveryContext(task) {
const fileName = path.basename(task.file);
if ((task.hoster === 'vidmoly.me' || task.hoster === 'voe.sx') && task.username) {
const accountIdentity = this._recoveryAccountIdentity(task);
return {
recoveryClaim: this._createRecoveryClaim(task, accountIdentity, fileName),
doodApiKey: null
};
}
if (task.hoster === 'doodstream.com' && task.username) {
const doodApiKey = await this._resolveDoodstreamApiKey(task);
const accountIdentity = doodApiKey || this._recoveryAccountIdentity(task);
return {
recoveryClaim: this._createRecoveryClaim(task, accountIdentity, fileName),
doodApiKey
};
}
if (task.hoster === 'byse.sx' || task.hoster === 'doodstream.com' || task.hoster === 'voe.sx') {
const accountIdentity = task.hoster === 'byse.sx'
? task.apiKey
: (task.hoster === 'doodstream.com' ? task.apiKey : this._recoveryAccountIdentity(task));
return {
recoveryClaim: this._createRecoveryClaim(task, accountIdentity, fileName),
doodApiKey: null
};
}
return { recoveryClaim: null, doodApiKey: null };
}
_recoveryAccountIdentity(task, fallbackIdentity = null) {
for (const value of [task.accountId, task.apiKey, fallbackIdentity, task.username]) {
if (value !== null && value !== undefined && String(value).trim()) return value;
}
return '';
}
_createRecoveryClaim(task, accountIdentity, fileName) {
const accountClaim = this._recoveryClaims.forUpload(task.hoster, accountIdentity, fileName);
if (task.hoster !== 'doodstream.com' && task.hoster !== 'voe.sx') return accountClaim;
const hosterClaim = this._recoveryClaims.forUpload(task.hoster, 'mixed-auth-recovery-boundary', fileName);
const modeKey = `${task.hoster}\0${normalizeRecoveryTitle(fileName)}`;
let modeState = this._recoveryAuthModes.get(modeKey);
if (!modeState) {
modeState = new Set();
this._recoveryAuthModes.set(modeKey, modeState);
}
const authMode = task.username ? 'login' : 'api';
return {
has(code) {
return accountClaim.has(code);
},
reserve(code) {
return accountClaim.reserve(code);
},
markUncertain(error) {
hosterClaim.markUncertain(error);
return accountClaim.markUncertain(error);
},
isUncertain() {
return hosterClaim.isUncertain() || accountClaim.isUncertain();
},
runExclusive(operation, signal) {
return hosterClaim.runExclusive(
async () => {
if (Array.from(modeState).some(mode => mode !== authMode)) {
const error = new Error('Gemischte Upload-Anmeldungen für denselben Remote-Titel wurden sicher blockiert');
error.remoteCommitUncertain = true;
error.hosterTransient = true;
throw error;
}
const result = await accountClaim.runExclusive(operation, signal);
if (result !== null && result !== undefined) modeState.add(authMode);
return result;
},
signal
);
}
};
}
async _executeUploadUnchecked(task, progressCb, signal, throttle, fileProbe, context) {
async _executeUploadUnchecked(task, progressCb, signal, throttle, fileProbe) {
if (task.hoster === 'vidmoly.me' && task.username) {
return this._executeRecoveryAwareLoginUpload(task, VidmolyUploader, progressCb, signal, throttle, context.recoveryClaim);
const vidmoly = new VidmolyUploader();
await vidmoly.login(task.username, task.password);
return vidmoly.upload(task.file, progressCb, signal, throttle);
} else if (task.hoster === 'voe.sx' && task.username) {
return this._executeRecoveryAwareLoginUpload(task, VoeUploader, progressCb, signal, throttle, context.recoveryClaim);
const voe = new VoeUploader();
await voe.login(task.username, task.password);
return voe.upload(task.file, progressCb, signal, throttle);
} else if (task.hoster === 'doodstream.com' && task.username) {
// Login-path reliability fix: the web-form upload returns the filecode in
// an HTML form that comes back empty for large files (doodstream backend
@@ -1615,67 +1254,31 @@ class UploadManager extends EventEmitter {
// session ONCE per batch and upload via the official API instead — it
// returns result[0].filecode directly and has no empty-form failure mode.
// Falls back to the web-form upload if no valid key can be derived.
const apiKey = context.doodApiKey;
const apiKey = await this._resolveDoodstreamApiKey(task);
if (apiKey) {
this._rotLog('doodstream-via-api', { accountId: task.accountId, fileName: path.basename(task.file) });
return this._executeRecoveryAwareApiUpload('doodstream.com', task.file, apiKey, progressCb, signal, throttle, fileProbe, context.recoveryClaim);
return uploadFile('doodstream.com', task.file, apiKey, progressCb, signal, throttle, {
doodBaseline: await this._getBaseline('doodstream.com', apiKey, signal)
});
}
this._rotLog('doodstream-via-web', { accountId: task.accountId, fileName: path.basename(task.file) });
const dood = new DoodstreamUploader();
await dood.login(task.username, task.password);
let result;
try {
result = await this._awaitUploadResult(dood.upload(task.file, progressCb, signal, throttle), signal);
} catch (err) {
if (context.recoveryClaim && this._isDoodstreamRemoteCommitUncertain(err)) {
throw context.recoveryClaim.markUncertain(err);
}
throw err;
}
if (result && result.file_code && !context.recoveryClaim.reserve(result.file_code)) {
const error = new Error('Doodstream Upload lieferte eine bereits zugeordnete Remote-Identität');
error.remoteIdentityClaimed = true;
throw context.recoveryClaim.markUncertain(error);
}
return result;
return dood.upload(task.file, progressCb, signal, throttle);
} else if (task.hoster === 'clouddrop.cc') {
const clouddrop = new ClouddropUploader(task.apiKey);
return this._awaitUploadResult(clouddrop.upload(task.file, progressCb, signal, throttle), signal);
return clouddrop.upload(task.file, progressCb, signal, throttle);
} else {
if (task.hoster === 'byse.sx' || task.hoster === 'doodstream.com' || task.hoster === 'voe.sx') {
return this._executeRecoveryAwareApiUpload(task.hoster, task.file, task.apiKey, progressCb, signal, throttle, fileProbe, context.recoveryClaim);
const baselineOpts = {};
if (task.hoster === 'byse.sx') {
baselineOpts.byseBaseline = await this._getBaseline('byse.sx', task.apiKey, signal);
if (fileProbe && fileProbe.ok !== false) baselineOpts.probeIsVideoLike = fileProbe.isVideoLike === true;
}
return this._awaitUploadResult(uploadFile(task.hoster, task.file, task.apiKey, progressCb, signal, throttle, {}), signal);
if (task.hoster === 'doodstream.com') baselineOpts.doodBaseline = await this._getBaseline('doodstream.com', task.apiKey, signal);
return uploadFile(task.hoster, task.file, task.apiKey, progressCb, signal, throttle, baselineOpts);
}
}
async _executeRecoveryAwareLoginUpload(task, UploaderClass, progressCb, signal, throttle, recoveryClaim) {
const uploader = new UploaderClass(recoveryClaim);
await uploader.login(task.username, task.password);
return this._awaitUploadResult(uploader.upload(task.file, progressCb, signal, throttle), signal);
}
async _executeRecoveryAwareApiUpload(hosterName, filePath, apiKey, progressCb, signal, throttle, fileProbe, recoveryClaim) {
const options = { recoveryClaim };
if (hosterName === 'byse.sx') {
options.byseBaseline = await this._getBaseline(hosterName, apiKey, signal);
if (fileProbe && fileProbe.ok !== false) options.probeIsVideoLike = fileProbe.isVideoLike === true;
} else if (hosterName === 'doodstream.com') {
options.doodBaseline = await this._getBaseline(hosterName, apiKey, signal);
}
return this._awaitUploadResult(uploadFile(hosterName, filePath, apiKey, progressCb, signal, throttle, options), signal);
}
_isDoodstreamRemoteCommitUncertain(error) {
if (!error || typeof error !== 'object') return false;
if (error.remoteCommitUncertain === true) return true;
if (error.accountError === true || error.fileRejected === true) return false;
const phase = error.diagnostic && error.diagnostic.phase;
if (phase === 'upload-request') return true;
return (phase === 'upload-response' || phase === 'upload-result-submit' || phase === 'upload-result')
&& (error.hosterTransient === true || error.transientNetwork === true);
}
_getBaseline(hosterName, apiKey, signal) {
if (!apiKey) return Promise.resolve(null);
const key = `${hosterName}:${apiKey}`;
@@ -1692,27 +1295,19 @@ class UploadManager extends EventEmitter {
// so a 40-file batch logs in + derives ONCE, not per file). The empty-string
// sentinel distinguishes "tried, none" from "not yet tried" (undefined).
async _resolveDoodstreamApiKey(task) {
const accountId = task.accountId !== null && task.accountId !== undefined
? String(task.accountId).normalize('NFKC').trim()
: '';
const cacheKey = accountId
? `account:${accountId}`
: `username:${String(task.username || '').normalize('NFKC').trim().toLowerCase()}`;
const cacheKey = task.accountId || task.username;
const cached = this._doodApiKeyCache.get(cacheKey);
if (cached !== undefined) return (await cached) || null;
if (cached !== undefined) return cached || null;
const pending = (async () => {
try {
const probe = new DoodstreamUploader();
await probe.login(task.username, task.password);
return (await probe.deriveApiKey()) || '';
} catch {
return '';
}
})();
this._doodApiKeyCache.set(cacheKey, pending);
const key = await pending;
if (this._doodApiKeyCache.get(cacheKey) === pending) this._doodApiKeyCache.set(cacheKey, key);
let key = '';
try {
const probe = new DoodstreamUploader();
await probe.login(task.username, task.password);
key = (await probe.deriveApiKey()) || '';
} catch {
key = '';
}
this._doodApiKeyCache.set(cacheKey, key);
return key || null;
}
@@ -1828,7 +1423,7 @@ class UploadManager extends EventEmitter {
});
}
_waitForInterval(hoster, intervalMs, signal, acquireSlots) {
_waitForInterval(hoster, intervalMs, signal) {
// Serialize interval waits per hoster so concurrent jobs queue up properly
const prev = this.intervalLocks[hoster] || Promise.resolve();
const next = prev.then(async () => {
@@ -1838,7 +1433,6 @@ class UploadManager extends EventEmitter {
if (elapsed < intervalMs) {
await this._sleep(intervalMs - elapsed, signal);
}
await acquireSlots();
this.lastStartTime[hoster] = Date.now();
});
this.intervalLocks[hoster] = next.catch(() => {});
@@ -1854,18 +1448,16 @@ class UploadManager extends EventEmitter {
const addResult = { added: 0, alreadyInBatchJobIds: [] };
for (const task of tasks) {
// Skip if this job is already being processed (prevent duplicates)
if (task.jobId && this._batchJobIds.has(task.jobId)) {
if (task.jobId && this.jobAbortControllers.has(task.jobId)) {
addResult.alreadyInBatchJobIds.push(task.jobId);
continue;
}
if (task.jobId) this._batchJobIds.add(task.jobId);
const fileName = path.basename(task.file);
if (!results.has(task.file)) {
let size = 0;
try { size = fs.statSync(task.file).size; } catch {}
results.set(task.file, { name: fileName, fileKey: task.fileKey || null, size, results: [] });
results.set(task.file, { name: fileName, size, results: [] });
}
this._batchTotal++;
this._additionalPromises.push(this._runJob(task, results, signal));
addResult.added++;
}
@@ -1886,7 +1478,6 @@ class UploadManager extends EventEmitter {
finishAfterActive() {
this.stopAfterActive = true;
this.uploadScheduleGate.wake();
}
cancel() {
-101
View File
@@ -1,101 +0,0 @@
(function exposeUploadRecovery(root, factory) {
const api = factory();
if (typeof module !== 'undefined' && module.exports) module.exports = api;
if (root) root.UploadRecovery = api;
})(typeof window !== 'undefined' ? window : globalThis, () => {
const terminalStatuses = new Set(['done', 'error', 'skipped', 'aborted']);
function buildTerminalJobSnapshots(summary) {
const snapshots = new Map();
for (const file of Array.isArray(summary?.files) ? summary.files : []) {
for (const result of Array.isArray(file?.results) ? file.results : []) {
const jobId = typeof result?.jobId === 'string' ? result.jobId : '';
const status = typeof result?.status === 'string' ? result.status : '';
if (!jobId || !terminalStatuses.has(status)) continue;
const uploadResult = result.download_url || result.embed_url || result.file_code
? {
download_url: result.download_url || null,
embed_url: result.embed_url || null,
file_code: result.file_code || null
}
: null;
snapshots.set(jobId, {
jobId,
status,
error: result.error || null,
failureDetails: result.failureDetails || null,
...(result.remoteCommitUncertain === true ? { remoteCommitUncertain: true } : {}),
result: uploadResult
});
}
}
return Array.from(snapshots.values());
}
function buildFailedUploadSummary(tasks, message, now = Date.now()) {
const files = new Map();
for (const [index, task] of (Array.isArray(tasks) ? tasks : []).entries()) {
if (!task || typeof task !== 'object') continue;
const filePath = typeof task.file === 'string' ? task.file : '';
const fileName = filePath.split(/[\\/]/).pop() || `upload-${index + 1}`;
const key = filePath || `${fileName}\0${index}`;
if (!files.has(key)) files.set(key, {
name: fileName,
...(typeof task.fileKey === 'string' && task.fileKey ? { fileKey: task.fileKey } : {}),
size: 0,
results: []
});
files.get(key).results.push({
jobId: typeof task.jobId === 'string' ? task.jobId : '',
hoster: typeof task.hoster === 'string' ? task.hoster : '',
status: 'error',
error: message,
failureDetails: null,
download_url: null,
embed_url: null,
file_code: null
});
}
const grouped = Array.from(files.values());
const failed = grouped.reduce((count, file) => count + file.results.length, 0);
return {
id: `start-error-${now}`,
timestamp: new Date(now).toISOString(),
total: failed,
succeeded: 0,
failed,
skipped: 0,
files: grouped,
error: message
};
}
function getRecoveryOutcome(job, recovery) {
const status = typeof job?.status === 'string' ? job.status : 'preview';
const jobId = typeof job?.id === 'string' ? job.id : '';
const terminal = Array.isArray(recovery?.terminalJobs)
? recovery.terminalJobs.find(entry => entry?.jobId === jobId && terminalStatuses.has(entry.status))
: null;
if (terminal) {
return {
status: terminal.status,
error: terminal.error || null,
failureDetails: terminal.failureDetails || null,
...(terminal.remoteCommitUncertain === true ? { remoteCommitUncertain: true } : {}),
result: terminal.result || null,
...(recovery?.historyPending === true ? { historyPending: true } : {}),
interrupted: false
};
}
const interruptedIds = new Set(Array.isArray(recovery?.jobIds) ? recovery.jobIds.filter(Boolean) : []);
return { status, interrupted: interruptedIds.has(jobId) && !terminalStatuses.has(status) };
}
function resolveRemoteCommitUncertainty(previous, progress) {
if (progress?.remoteCommitUncertain === true) return true;
if (progress?.status === 'done') return false;
return previous === true;
}
return { buildFailedUploadSummary, buildTerminalJobSnapshots, getRecoveryOutcome, resolveRemoteCommitUncertainty };
});
-167
View File
@@ -1,167 +0,0 @@
;(function initUploadSchedule(root, factory) {
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
if (root) root.UploadSchedule = api;
})(typeof window !== 'undefined' ? window : globalThis, function createUploadSchedule() {
const WEEKDAY_ORDER = Object.freeze([1, 2, 3, 4, 5, 6, 0]);
const DEFAULT_UPLOAD_SCHEDULE = Object.freeze({
enabled: false,
weekdays: Object.freeze([...WEEKDAY_ORDER]),
start: '00:00',
end: '23:59'
});
function normalizeTime(value) {
const text = typeof value === 'string' ? value.trim() : '';
return /^(?:[01]\d|2[0-3]):[0-5]\d$/.test(text) ? text : '';
}
function timeMinutes(value) {
const normalized = normalizeTime(value);
if (!normalized) return null;
const [hours, minutes] = normalized.split(':').map(Number);
return hours * 60 + minutes;
}
function normalizeUploadSchedule(value) {
const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
const rawWeekdays = Array.isArray(source.weekdays) ? source.weekdays : DEFAULT_UPLOAD_SCHEDULE.weekdays;
const selected = new Set(rawWeekdays.map(Number).filter(day => Number.isInteger(day) && day >= 0 && day <= 6));
return {
enabled: source.enabled === true,
weekdays: WEEKDAY_ORDER.filter(day => selected.has(day)),
start: normalizeTime(source.start ?? DEFAULT_UPLOAD_SCHEDULE.start),
end: normalizeTime(source.end ?? DEFAULT_UPLOAD_SCHEDULE.end)
};
}
function scheduleValidity(schedule) {
const startMinutes = timeMinutes(schedule.start);
const endMinutes = timeMinutes(schedule.end);
if (schedule.weekdays.length === 0) return { valid: false, reason: 'weekdays', startMinutes, endMinutes };
if (startMinutes === null || endMinutes === null) return { valid: false, reason: 'time', startMinutes, endMinutes };
if (startMinutes === endMinutes) return { valid: false, reason: 'equal-times', startMinutes, endMinutes };
return { valid: true, reason: null, startMinutes, endMinutes };
}
function nextStartDate(schedule, now, startMinutes) {
const selected = new Set(schedule.weekdays);
for (let dayOffset = 0; dayOffset <= 7; dayOffset++) {
const candidate = new Date(now.getFullYear(), now.getMonth(), now.getDate() + dayOffset, 0, 0, 0, 0);
if (!selected.has(candidate.getDay())) continue;
candidate.setMinutes(startMinutes);
if (candidate.getTime() > now.getTime()) return candidate;
}
return null;
}
function evaluateUploadSchedule(value, now = new Date()) {
const schedule = normalizeUploadSchedule(value);
const current = now instanceof Date ? new Date(now.getTime()) : new Date(now);
if (Number.isNaN(current.getTime())) throw new TypeError('Invalid schedule evaluation date');
if (!schedule.enabled) {
return { schedule, enabled: false, valid: true, allowed: true, reason: null, nextStart: null };
}
const validity = scheduleValidity(schedule);
if (!validity.valid) {
return { schedule, enabled: true, valid: false, allowed: false, reason: validity.reason, nextStart: null };
}
const currentMinutes = current.getHours() * 60 + current.getMinutes();
const currentDay = current.getDay();
const selected = new Set(schedule.weekdays);
const { startMinutes, endMinutes } = validity;
const overnight = startMinutes > endMinutes;
const previousDay = (currentDay + 6) % 7;
const allowed = overnight
? (selected.has(currentDay) && currentMinutes >= startMinutes) || (selected.has(previousDay) && currentMinutes < endMinutes)
: selected.has(currentDay) && currentMinutes >= startMinutes && currentMinutes < endMinutes;
return {
schedule,
enabled: true,
valid: true,
allowed,
reason: allowed ? null : 'closed',
nextStart: allowed ? null : nextStartDate(schedule, current, startMinutes)
};
}
function createAbortError() {
const error = new Error('Aborted');
error.name = 'AbortError';
return error;
}
function createUploadScheduleGate(initial, options = {}) {
const now = typeof options.now === 'function' ? options.now : () => new Date();
const setTimer = typeof options.setTimeout === 'function' ? options.setTimeout : setTimeout;
const clearTimer = typeof options.clearTimeout === 'function' ? options.clearTimeout : clearTimeout;
let schedule = normalizeUploadSchedule(initial);
let disposed = false;
const waiters = new Set();
const wake = () => {
for (const waiter of [...waiters]) waiter();
};
const waitForWake = (state, signal) => new Promise((resolve, reject) => {
let settled = false;
let timer = null;
const finish = (error) => {
if (settled) return;
settled = true;
if (timer !== null) clearTimer(timer);
waiters.delete(onWake);
signal?.removeEventListener('abort', onAbort);
if (error) reject(error);
else resolve();
};
const onWake = () => finish();
const onAbort = () => finish(createAbortError());
if (disposed || signal?.aborted) {
finish(createAbortError());
return;
}
waiters.add(onWake);
signal?.addEventListener('abort', onAbort, { once: true });
if (state.nextStart) {
const delay = Math.max(1, Math.min(2147483647, state.nextStart.getTime() - now().getTime()));
timer = setTimer(onWake, delay);
}
});
return Object.freeze({
evaluate() {
return evaluateUploadSchedule(schedule, now());
},
update(value) {
schedule = normalizeUploadSchedule(value);
wake();
return this.evaluate();
},
async wait(signal, check) {
while (true) {
if (typeof check === 'function') check();
if (disposed || signal?.aborted) throw createAbortError();
const state = evaluateUploadSchedule(schedule, now());
if (state.allowed) return state;
await waitForWake(state, signal);
}
},
wake,
dispose() {
disposed = true;
wake();
},
get schedule() {
return normalizeUploadSchedule(schedule);
}
});
}
return {
DEFAULT_UPLOAD_SCHEDULE,
normalizeUploadSchedule,
evaluateUploadSchedule,
createUploadScheduleGate
};
});
-28
View File
@@ -1,28 +0,0 @@
function createUploadStartReservation() {
let active = null;
return {
acquire() {
if (active) return null;
const state = { cancelled: false, released: false };
const lease = {
isCancelled: () => state.cancelled,
release() {
if (state.released) return;
state.released = true;
if (active && active.lease === lease) active = null;
}
};
active = { lease, state };
return lease;
},
cancel() {
if (!active) return false;
active.state.cancelled = true;
return true;
},
isActive: () => active !== null
};
}
module.exports = { createUploadStartReservation };
+99 -224
View File
@@ -2,8 +2,6 @@ const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { request } = require('undici');
const { createTransportError, sanitizeRemoteText } = require('./hoster-transport-error');
const { normalizeRecoveryTitle } = require('./hosters');
const BASE_URL = 'https://vidmoly.me';
const USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
@@ -15,9 +13,8 @@ const RESULT_POLL_DELAY_MS = 2000;
* XFileSharing-based upload for Vidmoly (login + form upload)
*/
class VidmolyUploader {
constructor(recoveryClaim = null) {
constructor() {
this.cookies = new Map();
this.recoveryClaim = recoveryClaim;
}
_cookieHeader() {
@@ -133,51 +130,14 @@ class VidmolyUploader {
* removed. Returns an XFS-style session token + a transit-server URL.
*/
async getUploadParams() {
const endpoint = `${BASE_URL}/api/upload/config`;
let res;
try {
res = await this._fetch(endpoint);
} catch {
throw createTransportError('Vidmoly: Upload-Konfiguration konnte nicht geladen werden', {
phase: 'upload-config',
endpoint,
retryable: true,
transientNetwork: true
});
}
const res = await this._fetch(`${BASE_URL}/api/upload/config`);
const body = await res.text();
const contentType = res.headers && typeof res.headers.get === 'function'
? res.headers.get('content-type')
: null;
if (res.status < 200 || res.status >= 300) {
throw createTransportError('Vidmoly: Upload-Konfiguration konnte nicht geladen werden', {
phase: 'upload-config',
endpoint,
httpStatus: res.status,
contentType,
body,
retryable: res.status === 429 || res.status >= 500,
transientNetwork: res.status >= 500
});
}
let payload = null;
try { payload = JSON.parse(body); } catch {
throw createTransportError('Vidmoly: Upload-Konfiguration war kein JSON', {
phase: 'upload-config',
endpoint,
httpStatus: res.status,
contentType,
body
});
throw new Error('Vidmoly: /api/upload/config lieferte kein JSON — evtl. nicht eingeloggt?');
}
if (!payload || !payload.sess_id || !payload.upload_url) {
throw createTransportError('Vidmoly: Upload-Konfiguration war unvollständig', {
phase: 'upload-config',
endpoint,
httpStatus: res.status,
contentType,
body
});
throw new Error('Vidmoly: /api/upload/config unvollständig (sess_id/upload_url fehlt)');
}
return {
uploadUrl: payload.upload_url,
@@ -194,14 +154,7 @@ class VidmolyUploader {
async upload(filePath, onProgress, signal, throttle) {
const fileName = path.basename(filePath);
const fileSize = fs.statSync(filePath).size;
let baselineCodes = null;
let baselineError = null;
try {
baselineCodes = await this._captureVmFileCodes();
} catch (err) {
if (signal && signal.aborted) throw err;
baselineError = err;
}
const baselineCodes = await this._captureVmFileCodes();
const { uploadUrl, params, fileFieldName } = await this.getUploadParams();
@@ -258,34 +211,21 @@ class VidmolyUploader {
const targetUrl = uploadUrl + (uploadUrl.includes('?') ? '&' : '?') + 'X-Progress-ID=' + progressId;
// Browsers don't send vidmoly.me cookies across origins, so we don't either.
let uploadResponse;
try {
uploadResponse = await request(targetUrl, {
method: 'POST',
body: generate(),
signal,
headers: {
'User-Agent': USER_AGENT,
'Accept': '*/*',
'Origin': BASE_URL,
'Referer': `${BASE_URL}/`,
'Content-Type': `multipart/form-data; boundary=${boundary}`,
'Content-Length': String(totalSize)
},
headersTimeout: UPLOAD_TIMEOUT,
bodyTimeout: UPLOAD_TIMEOUT
});
} catch (err) {
const error = signal && signal.aborted ? err : createTransportError('Vidmoly Upload konnte nicht übertragen werden', {
phase: 'upload-request',
endpoint: targetUrl,
retryable: true,
transientNetwork: true
});
throw this._markRemoteCommitUncertain(error);
}
const { body, statusCode, headers } = uploadResponse;
const { body, statusCode, headers } = await request(targetUrl, {
method: 'POST',
body: generate(),
signal,
headers: {
'User-Agent': USER_AGENT,
'Accept': '*/*',
'Origin': BASE_URL,
'Referer': `${BASE_URL}/`,
'Content-Type': `multipart/form-data; boundary=${boundary}`,
'Content-Length': String(totalSize)
},
headersTimeout: UPLOAD_TIMEOUT,
bodyTimeout: UPLOAD_TIMEOUT
});
this._parseCookiesFromHeaders(headers || {});
@@ -296,34 +236,13 @@ class VidmolyUploader {
// Always drain the original body to prevent connection leak
try { await body.text(); } catch {}
if (location) {
try {
const resultRes = await this._fetch(new URL(location, uploadUrl).href);
resultHtml = await resultRes.text();
} catch (err) {
throw this._markRemoteCommitUncertain(err);
}
const resultRes = await this._fetch(new URL(location, uploadUrl).href);
resultHtml = await resultRes.text();
} else {
resultHtml = '';
}
} else {
try {
resultHtml = await body.text();
} catch (err) {
throw this._markRemoteCommitUncertain(err);
}
}
if (statusCode >= 400) {
const error = createTransportError('Vidmoly Upload fehlgeschlagen', {
phase: 'upload-response',
endpoint: targetUrl,
httpStatus: statusCode,
contentType: headers && headers['content-type'],
body: resultHtml,
retryable: statusCode === 429 || statusCode >= 500,
transientNetwork: statusCode >= 500
});
throw statusCode >= 500 ? this._markRemoteCommitUncertain(error) : error;
resultHtml = await body.text();
}
// Try JSON first. The current transit server returns
@@ -348,69 +267,43 @@ class VidmolyUploader {
if (urls) return urls;
}
if (json.status && !/ok/i.test(json.status) && json.msg) {
throw createTransportError(`Vidmoly Upload abgelehnt: ${sanitizeRemoteText(json.msg)}`, {
phase: 'upload-result',
endpoint: targetUrl,
httpStatus: statusCode,
contentType: 'application/json',
body: resultHtml
});
throw new Error(`Vidmoly Upload abgelehnt: ${json.msg}`);
}
} catch (err) {
if (err && err.diagnostic) throw err;
if (err && /Vidmoly Upload abgelehnt/.test(err.message)) throw err;
}
try {
return this._parseUploadResult(resultHtml);
} catch (primaryErr) {
if (primaryErr && primaryErr.remoteIdentityClaimed === true) throw primaryErr;
if (baselineCodes) {
try {
const fallback = await this._resolveUploadedFileFromVmApi(fileName, baselineCodes, signal);
if (fallback) return fallback;
} catch (err) {
throw this._markRemoteCommitUncertain(err);
}
}
if (baselineError) {
baselineError.hosterTransient = true;
throw this._markRemoteCommitUncertain(baselineError);
}
throw this._markRemoteCommitUncertain(primaryErr);
const fallback = await this._resolveUploadedFileFromVmApi(fileName, baselineCodes, signal);
if (fallback) return fallback;
throw primaryErr;
}
}
_normalizeTitle(value) {
return normalizeRecoveryTitle(value);
return String(value || '')
.toLowerCase()
.normalize('NFKD')
.replace(/[^a-z0-9]+/g, '');
}
_markRemoteCommitUncertain(error) {
if (this.recoveryClaim && typeof this.recoveryClaim.markUncertain === 'function') {
return this.recoveryClaim.markUncertain(error);
}
const uncertainError = error && typeof error === 'object'
? error
: new Error('Vidmoly Upload-Ergebnis ist unsicher');
uncertainError.remoteCommitUncertain = true;
uncertainError.hosterTransient = true;
return uncertainError;
_scoreVmCandidate(file, expectedTitle) {
if (!file || !file.file_code) return -1;
if (!expectedTitle) return 0;
const title = this._normalizeTitle(file.full_title || file.title_txt || '');
if (!title) return -1;
if (title === expectedTitle) return 120;
if (title.startsWith(expectedTitle) || expectedTitle.startsWith(title)) return 90;
if (title.includes(expectedTitle) || expectedTitle.includes(title)) return 70;
return 0;
}
_buildUrlsFromCode(fileCode, phase = 'upload-result') {
_buildUrlsFromCode(fileCode) {
const code = String(fileCode || '').trim();
if (!code) return null;
if (this.recoveryClaim
&& typeof this.recoveryClaim.reserve === 'function'
&& !this.recoveryClaim.reserve(code)) {
const error = createTransportError('Vidmoly Upload-Ergebnis ist bereits einem anderen Upload zugeordnet', {
phase,
endpoint: BASE_URL,
retryable: true,
hosterTransient: true
});
error.remoteIdentityClaimed = true;
throw this._markRemoteCommitUncertain(error);
}
return {
download_url: `${BASE_URL}/w/${code}`,
@@ -420,15 +313,19 @@ class VidmolyUploader {
}
async _captureVmFileCodes() {
const files = await this._fetchVmList('recovery-baseline');
return new Set(
files
.map((f) => String(f.file_code || '').trim())
.filter(Boolean)
);
try {
const files = await this._fetchVmList();
return new Set(
files
.map((f) => String(f.file_code || '').trim())
.filter(Boolean)
);
} catch {
return new Set();
}
}
async _fetchVmList(phase = 'recovery-poll') {
async _fetchVmList() {
const params = new URLSearchParams({
op: 'vm',
api: 'list',
@@ -439,46 +336,14 @@ class VidmolyUploader {
fld_id: '0'
});
const endpoint = `${BASE_URL}/?${params.toString()}`;
let res;
try {
res = await this._fetch(endpoint);
} catch {
throw createTransportError('Vidmoly: Dateiliste konnte nicht geladen werden', {
phase,
endpoint,
retryable: true,
transientNetwork: true
});
}
const res = await this._fetch(`${BASE_URL}/?${params.toString()}`);
const body = await res.text();
const contentType = res.headers && typeof res.headers.get === 'function'
? res.headers.get('content-type')
: null;
if (res.status < 200 || res.status >= 300) {
throw createTransportError('Vidmoly: Dateiliste konnte nicht geladen werden', {
phase,
endpoint,
httpStatus: res.status,
contentType,
body,
retryable: res.status === 429 || res.status >= 500,
transientNetwork: res.status >= 500
});
}
let payload;
try {
payload = JSON.parse(body);
} catch {
throw createTransportError('Vidmoly: Dateiliste war kein JSON', {
phase,
endpoint,
httpStatus: res.status,
contentType,
body
});
throw new Error('Vidmoly VM API lieferte kein JSON');
}
if (!payload || !Array.isArray(payload.files)) return [];
@@ -486,10 +351,7 @@ class VidmolyUploader {
}
async _resolveUploadedFileFromVmApi(fileName, baselineCodes, signal) {
if (!(baselineCodes instanceof Set)) return null;
const expectedTitle = this._normalizeTitle(path.parse(fileName).name);
let lastPollError = null;
let successfulPoll = false;
for (let attempt = 0; attempt < RESULT_POLL_ATTEMPTS; attempt++) {
if (signal && signal.aborted) {
@@ -500,27 +362,46 @@ class VidmolyUploader {
let files = [];
try {
files = await this._fetchVmList('recovery-poll');
successfulPoll = true;
} catch (err) {
if (err && err.name === 'AbortError') throw err;
lastPollError = err;
files = await this._fetchVmList();
} catch {
files = [];
}
const withCode = files.filter((f) => f && typeof f.file_code === 'string' && f.file_code.trim());
const newFiles = withCode.filter((f) => !baselineCodes.has(f.file_code.trim()));
const matches = newFiles
.filter((file) => {
const title = this._normalizeTitle(file.full_title || file.title_txt || '');
return expectedTitle && title === expectedTitle;
})
.filter((file) => !this.recoveryClaim
|| typeof this.recoveryClaim.has !== 'function'
|| !this.recoveryClaim.has(file.file_code.trim()));
const newFiles = withCode.filter((f) => !baselineCodes.has(f.file_code));
if (matches.length > 1) return null;
if (matches.length === 1) {
return this._buildUrlsFromCode(matches[0].file_code, 'recovery-poll');
if (newFiles.length > 0) {
let best = null;
let bestScore = -1;
for (const file of newFiles) {
const score = this._scoreVmCandidate(file, expectedTitle);
if (score > bestScore) {
bestScore = score;
best = file;
}
}
if (best && bestScore > 0) {
return this._buildUrlsFromCode(best.file_code);
}
}
if (expectedTitle) {
let bestMatch = null;
let bestScore = -1;
for (const file of withCode) {
const score = this._scoreVmCandidate(file, expectedTitle);
if (score > bestScore) {
bestScore = score;
bestMatch = file;
}
}
if (bestMatch && bestScore >= 90) {
return this._buildUrlsFromCode(bestMatch.file_code);
}
}
if (attempt < RESULT_POLL_ATTEMPTS - 1) {
@@ -528,7 +409,6 @@ class VidmolyUploader {
}
}
if (!successfulPoll && lastPollError) throw lastPollError;
return null;
}
@@ -617,23 +497,18 @@ class VidmolyUploader {
if (codeInPage) file_code = codeInPage[1];
}
if (file_code) {
const urls = this._buildUrlsFromCode(file_code);
if (!download_url) download_url = urls.download_url;
if (!embed_url) embed_url = urls.embed_url;
// Build URLs from file_code
if (file_code && !download_url) {
download_url = `${BASE_URL}/w/${file_code}`;
}
if (file_code && !embed_url) {
embed_url = `${BASE_URL}/embed-${file_code}.html`;
}
if (!download_url && !file_code) {
const errMatch = html.match(/class=["']err["'][^>]*>([^<]+)/i);
const errMsg = errMatch ? errMatch[1].trim() : 'Kein Download-Link gefunden';
throw createTransportError(`Vidmoly Upload-Ergebnis: ${sanitizeRemoteText(errMsg)}`, {
phase: 'upload-result',
endpoint: BASE_URL,
contentType: 'text/html',
body: html,
hosterTransient: true,
retryable: true
});
throw new Error(`Vidmoly Upload-Ergebnis: ${errMsg}`);
}
return { download_url, embed_url, file_code };
+82 -225
View File
@@ -2,8 +2,6 @@ const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { request } = require('undici');
const { createTransportError, sanitizeRemoteText } = require('./hoster-transport-error');
const { normalizeRecoveryTitle } = require('./hosters');
const BASE_URL = 'https://voe.sx';
const USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
@@ -16,9 +14,8 @@ const RESULT_POLL_DELAY_MS = 2000;
* Fallback when API-based upload fails or is unavailable.
*/
class VoeUploader {
constructor(recoveryClaim = null) {
constructor() {
this.cookies = new Map();
this.recoveryClaim = recoveryClaim;
}
_cookieHeader() {
@@ -163,58 +160,21 @@ class VoeUploader {
* Returns { server: "https://cdn-xxx.edgeon-bandwidth.com/node/u/01", session_id: "..." }
*/
async _getDeliveryNode(csrfToken) {
const endpoint = `${BASE_URL}/engine/delivery-node`;
let res;
try {
res = await this._fetch(endpoint, {
headers: {
'X-CSRF-TOKEN': csrfToken,
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json'
}
});
} catch {
throw createTransportError('VOE: Upload-Server konnte nicht geladen werden', {
phase: 'upload-server',
endpoint,
retryable: true,
transientNetwork: true
});
}
const res = await this._fetch(`${BASE_URL}/engine/delivery-node`, {
headers: {
'X-CSRF-TOKEN': csrfToken,
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json'
}
});
const body = await res.text();
const contentType = res.headers && typeof res.headers.get === 'function'
? res.headers.get('content-type')
: null;
if (res.status < 200 || res.status >= 300) {
throw createTransportError('VOE: Upload-Server konnte nicht geladen werden', {
phase: 'upload-server',
endpoint,
httpStatus: res.status,
contentType,
body,
retryable: res.status === 429 || res.status >= 500,
transientNetwork: res.status >= 500
});
}
let data;
try { data = JSON.parse(body); } catch {
throw createTransportError('VOE: Upload-Server Antwort war kein JSON', {
phase: 'upload-server',
endpoint,
httpStatus: res.status,
contentType,
body
});
throw new Error(`VOE: Upload-Server Antwort war kein JSON: ${body.slice(0, 200)}`);
}
if (!data || !data.success || !data.server) {
throw createTransportError('VOE: Kein Upload-Server erhalten von delivery-node', {
phase: 'upload-server',
endpoint,
httpStatus: res.status,
contentType,
body
});
throw new Error('VOE: Kein Upload-Server erhalten von delivery-node');
}
return { uploadServer: data.server, sessionId: data.session_id || '' };
@@ -223,54 +183,26 @@ class VoeUploader {
/**
* List current files via VOE API (for result polling fallback)
*/
async _fetchFileList(phase = 'recovery-poll') {
const endpoint = `${BASE_URL}/api2/my-files?sort=date&order=dsc&page=1&per_page=50`;
let res;
async _fetchFileList() {
try {
res = await this._fetch(endpoint);
const res = await this._fetch(`${BASE_URL}/api2/my-files?sort=date&order=dsc&page=1&per_page=50`);
const body = await res.text();
const data = JSON.parse(body);
if (data && Array.isArray(data.data)) return data.data;
if (data && Array.isArray(data.files)) return data.files;
return [];
} catch {
throw createTransportError('VOE: Dateiliste konnte nicht geladen werden', {
phase,
endpoint,
retryable: true,
transientNetwork: true
});
return [];
}
const body = await res.text();
const contentType = res.headers && typeof res.headers.get === 'function'
? res.headers.get('content-type')
: null;
if (res.status < 200 || res.status >= 300) {
throw createTransportError('VOE: Dateiliste konnte nicht geladen werden', {
phase,
endpoint,
httpStatus: res.status,
contentType,
body,
retryable: res.status === 429 || res.status >= 500,
transientNetwork: res.status >= 500
});
}
let data;
try {
data = JSON.parse(body);
} catch {
throw createTransportError('VOE: Dateiliste war kein JSON', {
phase,
endpoint,
httpStatus: res.status,
contentType,
body
});
}
if (data && Array.isArray(data.data)) return data.data;
if (data && Array.isArray(data.files)) return data.files;
return [];
}
async _captureFileCodes() {
const files = await this._fetchFileList('recovery-baseline');
return new Set(files.map(f => String(f.file_code || f.slug || '').trim()).filter(Boolean));
try {
const files = await this._fetchFileList();
return new Set(files.map(f => String(f.file_code || f.slug || '').trim()).filter(Boolean));
} catch {
return new Set();
}
}
/**
@@ -280,14 +212,7 @@ class VoeUploader {
async upload(filePath, onProgress, signal, throttle) {
const fileName = path.basename(filePath);
const fileSize = fs.statSync(filePath).size;
let baselineCodes = null;
let baselineError = null;
try {
baselineCodes = await this._captureFileCodes();
} catch (err) {
if (signal && signal.aborted) throw err;
baselineError = err;
}
const baselineCodes = await this._captureFileCodes();
// Step 1: Get CSRF token from upload page
const { csrfToken } = await this._getUploadParams();
@@ -333,57 +258,27 @@ class VoeUploader {
}
// Step 3: POST file to CDN upload server
let uploadResponse;
try {
uploadResponse = await request(uploadServer, {
method: 'POST',
body: generate(),
signal,
headers: {
'User-Agent': USER_AGENT,
'Cookie': this._cookieHeader(),
'Content-Type': `multipart/form-data; boundary=${boundary}`,
'Content-Length': String(totalSize),
'X-CSRF-TOKEN': csrfToken,
'X-Requested-With': 'XMLHttpRequest',
'Referer': `${BASE_URL}/file-upload`,
'Origin': BASE_URL
},
headersTimeout: UPLOAD_TIMEOUT,
bodyTimeout: UPLOAD_TIMEOUT
});
} catch (err) {
const error = signal && signal.aborted ? err : createTransportError('VOE Upload konnte nicht übertragen werden', {
phase: 'upload-request',
endpoint: uploadServer,
retryable: true,
transientNetwork: true
});
throw this._markRemoteCommitUncertain(error);
}
const { body, headers, statusCode } = uploadResponse;
const { body, headers } = await request(uploadServer, {
method: 'POST',
body: generate(),
signal,
headers: {
'User-Agent': USER_AGENT,
'Cookie': this._cookieHeader(),
'Content-Type': `multipart/form-data; boundary=${boundary}`,
'Content-Length': String(totalSize),
'X-CSRF-TOKEN': csrfToken,
'X-Requested-With': 'XMLHttpRequest',
'Referer': `${BASE_URL}/file-upload`,
'Origin': BASE_URL
},
headersTimeout: UPLOAD_TIMEOUT,
bodyTimeout: UPLOAD_TIMEOUT
});
this._parseCookiesFromHeaders(headers || {});
let rawBody;
try {
rawBody = await body.text();
} catch (err) {
throw this._markRemoteCommitUncertain(err);
}
if (statusCode < 200 || statusCode >= 300) {
const error = createTransportError('VOE Upload fehlgeschlagen', {
phase: 'upload-response',
endpoint: uploadServer,
httpStatus: statusCode,
contentType: headers && headers['content-type'],
body: rawBody,
retryable: statusCode === 429 || statusCode >= 500,
transientNetwork: statusCode >= 500
});
throw statusCode >= 500 ? this._markRemoteCommitUncertain(error) : error;
}
const rawBody = await body.text();
// Try JSON response
try {
@@ -400,48 +295,22 @@ class VoeUploader {
// Check for error
if (json.error || json.message) {
throw createTransportError(`VOE Upload-Fehler: ${sanitizeRemoteText(json.error || json.message)}`, {
phase: 'upload-result',
endpoint: uploadServer,
contentType: 'application/json',
body: rawBody
});
throw new Error(`VOE Upload-Fehler: ${json.error || json.message}`);
}
} catch (parseErr) {
if (parseErr && parseErr.diagnostic) throw parseErr;
if (parseErr.message.startsWith('VOE Upload-Fehler')) throw parseErr;
// Not JSON - might be a redirect or HTML response
}
// Fallback: poll the file list to find the newly uploaded file
if (baselineCodes) {
try {
const result = await this._resolveUploadedFile(fileName, baselineCodes, signal);
if (result) return result;
} catch (err) {
throw this._markRemoteCommitUncertain(err);
}
}
const result = await this._resolveUploadedFile(fileName, baselineCodes, signal);
if (result) return result;
if (baselineError) {
baselineError.hosterTransient = true;
throw this._markRemoteCommitUncertain(baselineError);
}
throw this._markRemoteCommitUncertain(createTransportError('VOE Upload: Kein file_code in der Antwort gefunden', {
phase: 'upload-result',
endpoint: uploadServer,
contentType: headers && headers['content-type'],
body: rawBody,
hosterTransient: true,
retryable: true
}));
throw new Error('VOE Upload: Kein file_code in der Antwort gefunden');
}
async _resolveUploadedFile(fileName, baselineCodes, signal) {
if (!(baselineCodes instanceof Set)) return null;
const expectedTitle = this._normalizeTitle(path.parse(fileName).name);
let lastPollError = null;
let successfulPoll = false;
for (let attempt = 0; attempt < RESULT_POLL_ATTEMPTS; attempt++) {
if (signal && signal.aborted) {
@@ -452,31 +321,29 @@ class VoeUploader {
let files = [];
try {
files = await this._fetchFileList('recovery-poll');
successfulPoll = true;
} catch (err) {
if (err && err.name === 'AbortError') throw err;
lastPollError = err;
}
files = await this._fetchFileList();
} catch { files = []; }
const withCode = files.filter(f => f && (f.file_code || f.slug));
const newFiles = withCode.filter(f => !baselineCodes.has(String(f.file_code || f.slug || '').trim()));
const matches = newFiles
.filter(file => {
const title = this._normalizeTitle(file.title || file.name || '');
return expectedTitle && title === expectedTitle;
})
.filter(file => {
const code = String(file.file_code || file.slug || '').trim();
return !this.recoveryClaim
|| typeof this.recoveryClaim.has !== 'function'
|| !this.recoveryClaim.has(code);
});
if (matches.length > 1) return null;
if (matches.length === 1) {
const code = matches[0].file_code || matches[0].slug;
return this._buildUrls(code, 'recovery-poll');
if (newFiles.length > 0) {
// Try to match by title
let best = null;
let bestScore = -1;
for (const file of newFiles) {
const score = this._scoreCandidate(file, expectedTitle);
if (score > bestScore) {
bestScore = score;
best = file;
}
}
if (best && (bestScore > 0 || newFiles.length === 1)) {
const code = best.file_code || best.slug;
return this._buildUrls(code);
}
}
if (attempt < RESULT_POLL_ATTEMPTS - 1) {
@@ -484,41 +351,31 @@ class VoeUploader {
}
}
if (!successfulPoll && lastPollError) throw lastPollError;
return null;
}
_normalizeTitle(value) {
return normalizeRecoveryTitle(value);
return String(value || '')
.toLowerCase()
.normalize('NFKD')
.replace(/[^a-z0-9]+/g, '');
}
_markRemoteCommitUncertain(error) {
if (this.recoveryClaim && typeof this.recoveryClaim.markUncertain === 'function') {
return this.recoveryClaim.markUncertain(error);
}
const uncertainError = error && typeof error === 'object'
? error
: new Error('VOE Upload-Ergebnis ist unsicher');
uncertainError.remoteCommitUncertain = true;
uncertainError.hosterTransient = true;
return uncertainError;
_scoreCandidate(file, expectedTitle) {
if (!file || !(file.file_code || file.slug)) return -1;
if (!expectedTitle) return 0;
const title = this._normalizeTitle(file.title || file.name || '');
if (!title) return -1;
if (title === expectedTitle) return 120;
if (title.startsWith(expectedTitle) || expectedTitle.startsWith(title)) return 90;
if (title.includes(expectedTitle) || expectedTitle.includes(title)) return 70;
return 0;
}
_buildUrls(fileCode, phase = 'upload-result') {
_buildUrls(fileCode) {
const code = String(fileCode || '').trim();
if (!code) return null;
if (this.recoveryClaim
&& typeof this.recoveryClaim.reserve === 'function'
&& !this.recoveryClaim.reserve(code)) {
const error = createTransportError('VOE Upload-Ergebnis ist bereits einem anderen Upload zugeordnet', {
phase,
endpoint: BASE_URL,
retryable: true,
hosterTransient: true
});
error.remoteIdentityClaimed = true;
throw this._markRemoteCommitUncertain(error);
}
return {
download_url: `${BASE_URL}/${code}`,
embed_url: `${BASE_URL}/e/${code}`,
+2 -2
View File
@@ -78,7 +78,7 @@ function buildWebhookRequest(url, summary, meta) {
.join(' · ');
if (hosterEntries.length > MAX_HOSTER_LINES) hosterLines += ` · …+${hosterEntries.length - MAX_HOSTER_LINES}`;
const lines = [
`**Multi Hoster Uploader${headline}**${m.machineName ? ` (${m.machineName})` : ''}`,
`**Multi-Hoster-Upload — ${headline}**${m.machineName ? ` (${m.machineName})` : ''}`,
language === 'de'
? `${succeeded} ok · ❌ ${failed} Fehler${skipped > 0 ? ` · ⏭ ${skipped} übersprungen` : ''} · 📦 ${total} gesamt · ⏱ ${duration}`
: `${succeeded} succeeded · ❌ ${failed} failed${skipped > 0 ? ` · ⏭ ${skipped} skipped` : ''} · 📦 ${total} total · ⏱ ${duration}`
@@ -92,7 +92,7 @@ function buildWebhookRequest(url, summary, meta) {
} else {
body = JSON.stringify({
event: 'batch-done',
app: 'multi-hoster-uploader',
app: 'multi-hoster-upload',
version: m.appVersion || null,
machine: m.machineName || null,
total,