release: v2.1.12 reliability and security hardening

Verify update artifacts with exact metadata and SHA-512, require host-confirmed upload completion before cleanup, harden credentials and backups, improve queue recovery and skipped-state reporting, expand Windows path coverage, and add CI packaging checks.
This commit is contained in:
Sucukdeluxe
2026-08-11 22:36:20 +02:00
parent 7eec990811
commit 26dcf9c698
47 changed files with 2069 additions and 1192 deletions
+17 -10
View File
@@ -1,6 +1,7 @@
const crypto = require('crypto');
const MAGIC = Buffer.from('MHU1');
const MAGIC_V1 = Buffer.from('MHU1');
const MAGIC_V2 = Buffer.from('MHU2');
const SALT_LEN = 16;
const IV_LEN = 12;
const TAG_LEN = 16;
@@ -22,11 +23,15 @@ function deriveKey(passphrase, salt) {
* Encrypt a config object.
* Returns a Buffer: MHU1 | salt(16) | iv(12) | tag(16) | ciphertext
*/
function encrypt(config) {
function encrypt(config, userPassword) {
if (userPassword !== undefined && (typeof userPassword !== 'string' || !userPassword.trim())) {
throw new Error('Das Backup-Passwort darf nicht leer sein');
}
const plaintext = Buffer.from(JSON.stringify(config), 'utf-8');
const salt = crypto.randomBytes(SALT_LEN);
const iv = crypto.randomBytes(IV_LEN);
const key = deriveKey(APP_PASSPHRASE, salt);
const passwordProtected = typeof userPassword === 'string';
const key = deriveKey(passwordProtected ? userPassword : APP_PASSPHRASE, salt);
const cipher = crypto.createCipheriv(ALGO, key, iv);
const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
@@ -34,7 +39,7 @@ function encrypt(config) {
plaintext.fill(0);
key.fill(0);
return Buffer.concat([MAGIC, salt, iv, tag, encrypted]);
return Buffer.concat([passwordProtected ? MAGIC_V2 : MAGIC_V1, salt, iv, tag, encrypted]);
}
/**
@@ -45,16 +50,17 @@ function encrypt(config) {
* given, so callers can prompt the user for the legacy password.
*/
function decrypt(buffer, userPassword) {
if (buffer.length < MAGIC.length + SALT_LEN + IV_LEN + TAG_LEN + 1) {
if (buffer.length < MAGIC_V1.length + SALT_LEN + IV_LEN + TAG_LEN + 1) {
throw new Error('Ungültiges Backup-Format');
}
const magic = buffer.subarray(0, 4);
if (!magic.equals(MAGIC)) {
const passwordProtected = magic.equals(MAGIC_V2);
if (!magic.equals(MAGIC_V1) && !passwordProtected) {
throw new Error('Keine gültige .mhu Backup-Datei');
}
let offset = MAGIC.length;
let offset = MAGIC_V1.length;
const salt = buffer.subarray(offset, offset += SALT_LEN);
const iv = buffer.subarray(offset, offset += IV_LEN);
const tag = buffer.subarray(offset, offset += TAG_LEN);
@@ -76,9 +82,10 @@ function decrypt(buffer, userPassword) {
}
};
// 1) Try the app-internal key (new format, no password required).
const fromApp = tryPassphrase(APP_PASSPHRASE);
if (fromApp) return fromApp;
if (!passwordProtected) {
const fromApp = tryPassphrase(APP_PASSPHRASE);
if (fromApp) return fromApp;
}
// 2) Legacy format: user had set their own password.
if (userPassword) {
+10 -3
View File
@@ -72,6 +72,8 @@ const DEFAULTS = {
// erase) the legacy sessionLog:true → "daily" migration. normalizeLogMode in
// load() sets logMode after the merge, looking at the saved-only data.
resumeQueueOnLaunch: true,
autoStartRestoredQueue: false,
allowPlaintextCredentialStorage: false,
parallelUploadCount: 0, // 0 = use per-hoster limits only
scaleParallelUploads: false,
lastBrowseDirectory: '',
@@ -91,6 +93,7 @@ const DEFAULTS = {
enabled: false,
folderPath: '',
recursive: false,
includeExisting: false,
filterMode: 'include', // 'include' | 'exclude'
extensions: '', // comma-separated: 'mp4,mkv,avi'
skipDuplicates: true,
@@ -179,7 +182,7 @@ function applyHistoryRetention(history, retention, nowMs) {
}
class ConfigStore {
constructor(app) {
constructor(app, options = {}) {
const useUserDataDir = app && (
app.isPackaged ||
(app.commandLine && typeof app.commandLine.hasSwitch === 'function' && app.commandLine.hasSwitch('user-data-dir'))
@@ -198,6 +201,7 @@ class ConfigStore {
this._cacheKey = '';
this._perfLog = null;
this._wqDepth = 0;
this._allowPlaintextCredentialStorage = options.allowPlaintextCredentialStorage === true;
// Migrate config from old location if current doesn't exist
if (!fs.existsSync(this.filePath) && app && app.isPackaged) {
@@ -468,7 +472,8 @@ class ConfigStore {
this._cacheKey = statKey;
}
return this._clone(result);
} catch {
} catch (error) {
if (error instanceof secretStore.SecretStoreError) throw error;
const fresh = JSON.parse(JSON.stringify(DEFAULTS));
fresh.globalSettings.logMode = normalizeLogMode(fresh.globalSettings);
return fresh;
@@ -482,7 +487,9 @@ class ConfigStore {
// on every write was a primary long-running main-thread stall.
_serializeForDisk(config) {
const hosters = this._clone(config.hosters || {});
secretStore.encryptCredentials({ hosters });
secretStore.encryptCredentials({ hosters }, {
allowPlaintext: this._allowPlaintextCredentialStorage || config.globalSettings?.allowPlaintextCredentialStorage === true
});
return JSON.stringify({ ...config, hosters }, null, 2);
}
+39
View File
@@ -0,0 +1,39 @@
const fs = require('fs');
const path = require('path');
async function walkFolderAsync(rootDir, options = {}) {
const fsPromises = options.fsPromises || fs.promises;
const pathImpl = options.pathImpl || path;
const yieldFn = options.yieldFn || (() => new Promise(setImmediate));
const files = [];
const stack = [rootDir];
let scanned = 0;
while (stack.length > 0) {
const dir = stack.pop();
let entries;
try {
entries = await fsPromises.readdir(dir, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
const fullPath = pathImpl.join(dir, entry.name);
if (entry.isDirectory()) {
stack.push(fullPath);
} else if (entry.isFile()) {
let size = 0;
try {
size = (await fsPromises.stat(fullPath)).size;
} catch {}
files.push({ path: fullPath, name: entry.name, size });
}
}
scanned++;
if (scanned % 8 === 0) await yieldFn();
}
return files;
}
module.exports = { walkFolderAsync };
+18 -3
View File
@@ -3,13 +3,15 @@ const path = require('path');
const chokidar = require('chokidar');
class FolderMonitor extends EventEmitter {
constructor() {
constructor({ watch = chokidar.watch } = {}) {
super();
this._watch = watch;
this._watcher = null;
this._settings = null;
this._seenFiles = new Set();
this._batchBuffer = [];
this._batchTimer = null;
this._initialScopes = new Set();
}
get running() {
@@ -23,9 +25,18 @@ class FolderMonitor extends EventEmitter {
const folderPath = String(settings.folderPath || '').trim();
if (!folderPath) throw new Error('Kein Ordnerpfad angegeben');
const scope = JSON.stringify([
path.resolve(folderPath).toLowerCase(),
!!settings.recursive,
String(settings.filterMode || 'include'),
String(settings.extensions || '').trim().toLowerCase()
]);
const includeInitial = !!settings.includeExisting && !this._initialScopes.has(scope);
if (includeInitial) this._initialScopes.add(scope);
const watchOptions = {
persistent: true,
ignoreInitial: true,
ignoreInitial: !includeInitial,
depth: settings.recursive ? undefined : 0,
awaitWriteFinish: {
stabilityThreshold: Math.max(1000, (settings.delaySec || 3) * 1000),
@@ -33,7 +44,10 @@ class FolderMonitor extends EventEmitter {
}
};
this._watcher = chokidar.watch(folderPath, watchOptions);
this._watcher = this._watch(folderPath, watchOptions);
if (includeInitial) {
this._watcher.once('ready', () => this.emit('initial-scan-complete'));
}
this._watcher.on('add', (filePath) => this._onNewFile(filePath));
this._watcher.on('unlink', (filePath) => {
// Allow re-added files (e.g. re-encoded) to be detected again
@@ -41,6 +55,7 @@ class FolderMonitor extends EventEmitter {
this._seenFiles.delete(normalized);
});
this._watcher.on('error', (err) => this.emit('error', err));
return { includesExisting: includeInitial };
}
stop() {
-2
View File
@@ -90,9 +90,7 @@
// Order matters: session first (longer, more specific) before daily.
// Both regexes are anchored to $ with no nested/ambiguous quantifiers, so
// matching is linear — the eslint security warning is precautionary.
// eslint-disable-next-line security/detect-unsafe-regex
const sessionRe = /-session-\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}(?:-\d+)?(\.[^.]+)?$/;
// eslint-disable-next-line security/detect-unsafe-regex
const dailyRe = /-\d{4}-\d{2}-\d{2}(\.[^.]+)?$/;
let out = fileName.replace(sessionRe, (m, ext) => ext || '');
out = out.replace(dailyRe, (m, ext) => ext || '');
+2 -2
View File
@@ -3,8 +3,8 @@ const crypto = require('crypto');
const { evaluateClientAllowed } = require('./ip-allowlist');
function timingSafeEqualStr(a, b) {
const x = Buffer.from(String(a == null ? '' : a));
const y = Buffer.from(String(b == null ? '' : b));
const x = Buffer.from(String(a === null || a === undefined ? '' : a));
const y = Buffer.from(String(b === null || b === undefined ? '' : b));
return x.length === y.length && crypto.timingSafeEqual(x, y);
}
+38 -9
View File
@@ -10,6 +10,15 @@
const SENTINEL = 'enc:v1:';
const CRED_FIELDS = ['password', 'apiKey'];
class SecretStoreError extends Error {
constructor(code, message, cause) {
super(message);
this.name = 'SecretStoreError';
this.code = code;
if (cause !== undefined) this.cause = cause;
}
}
let _safeStorageCache = undefined;
function getSafeStorage() {
if (_safeStorageCache !== undefined) return _safeStorageCache;
@@ -29,16 +38,24 @@ function isEncrypted(value) {
return typeof value === 'string' && value.startsWith(SENTINEL);
}
function encryptField(value) {
function getAvailabilityStatus() {
return getSafeStorage() ? 'available' : 'unavailable';
}
function encryptField(value, options = {}) {
if (!value || typeof value !== 'string') return value;
if (isEncrypted(value)) return value;
const ss = getSafeStorage();
if (!ss) return value;
if (!ss) {
if (options.allowPlaintext === true) return value;
throw new SecretStoreError('SECRET_STORE_UNAVAILABLE', 'Sicherer Zugangsdaten-Speicher ist nicht verfügbar');
}
try {
const buf = ss.encryptString(value);
return SENTINEL + buf.toString('base64');
} catch {
return value;
} catch (cause) {
if (options.allowPlaintext === true) return value;
throw new SecretStoreError('SECRET_STORE_ENCRYPT_FAILED', 'Zugangsdaten konnten nicht sicher verschlüsselt werden', cause);
}
}
@@ -46,12 +63,14 @@ function decryptField(value) {
if (!value || typeof value !== 'string') return value;
if (!isEncrypted(value)) return value;
const ss = getSafeStorage();
if (!ss) return '';
if (!ss) {
throw new SecretStoreError('SECRET_STORE_UNAVAILABLE', 'Sicherer Zugangsdaten-Speicher ist nicht verfügbar');
}
try {
const buf = Buffer.from(value.slice(SENTINEL.length), 'base64');
return ss.decryptString(buf);
} catch {
return '';
} catch (cause) {
throw new SecretStoreError('SECRET_STORE_DECRYPT_FAILED', 'Gespeicherte Zugangsdaten konnten nicht entschlüsselt werden', cause);
}
}
@@ -69,7 +88,17 @@ function mapHosterAccounts(config, fn) {
return config;
}
function encryptCredentials(config) { return mapHosterAccounts(config, encryptField); }
function encryptCredentials(config, options = {}) {
return mapHosterAccounts(config, value => encryptField(value, options));
}
function decryptCredentials(config) { return mapHosterAccounts(config, decryptField); }
module.exports = { encryptField, decryptField, encryptCredentials, decryptCredentials, isEncrypted };
module.exports = {
SecretStoreError,
getAvailabilityStatus,
encryptField,
decryptField,
encryptCredentials,
decryptCredentials,
isEncrypted
};
+48 -3
View File
@@ -23,16 +23,18 @@
if (!file || !Array.isArray(file.results)) continue;
for (const r of file.results) {
if (!r || !r.hoster) continue;
const bucket = out[r.hoster] || (out[r.hoster] = { ok: 0, fail: 0, total: 0 });
const bucket = out[r.hoster] || (out[r.hoster] = { ok: 0, fail: 0, skipped: 0, total: 0 });
bucket.total++;
if (r.status === 'done') bucket.ok++;
else if (r.status === 'skipped') bucket.skipped++;
else bucket.fail++;
}
}
}
for (const h of Object.keys(out)) {
const b = out[h];
b.rate = b.total > 0 ? b.ok / b.total : null;
const attempted = b.ok + b.fail;
b.rate = attempted > 0 ? b.ok / attempted : null;
}
return out;
}
@@ -61,7 +63,7 @@
for (const f of batchSummary.files) {
if (!f || !Array.isArray(f.results)) continue;
for (const r of f.results) {
if (!r || r.status === 'done') continue;
if (!r || r.status === 'done' || r.status === 'skipped') continue;
const cat = classifyErrorCategory(r.error);
buckets[cat].push({
fileName: f.name || f.fileName || '',
@@ -74,6 +76,48 @@
return buckets;
}
function mergeSkippedIntoSummary(summary, skippedJobs) {
const source = summary && typeof summary === 'object' ? summary : {};
const merged = {
...source,
files: Array.isArray(source.files)
? source.files.map(file => ({ ...file, results: Array.isArray(file.results) ? [...file.results] : [] }))
: []
};
const existingJobIds = new Set();
const filesByName = new Map();
for (const file of merged.files) {
filesByName.set(String(file.name || file.fileName || ''), file);
for (const result of file.results) {
if (result?.jobId) existingJobIds.add(result.jobId);
}
}
let added = 0;
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() || '';
let file = filesByName.get(fileName);
if (!file) {
file = { name: fileName, size: Number(skipped.size) || 0, results: [] };
merged.files.push(file);
filesByName.set(fileName, file);
}
file.results.push({
jobId: skipped.jobId || null,
hoster: skipped.hoster || '',
status: 'skipped',
error: skipped.reason || 'Übersprungen'
});
if (skipped.jobId) existingJobIds.add(skipped.jobId);
added++;
}
merged.total = (Number(source.total) || 0) + added;
merged.succeeded = Number(source.succeeded) || 0;
merged.failed = Number(source.failed) || 0;
merged.skipped = (Number(source.skipped) || 0) + added;
return merged;
}
const RETRYABLE_CATEGORIES = new Set(['hoster-transient', 'network', 'unknown']);
function isRetryableCategory(cat) {
return RETRYABLE_CATEGORIES.has(cat);
@@ -128,6 +172,7 @@
summarizePerHoster,
classifyErrorCategory,
summarizeBatchErrors,
mergeSkippedIntoSummary,
isRetryableCategory,
RETRYABLE_CATEGORIES,
CATEGORY_LABELS,
+1 -1
View File
@@ -55,7 +55,7 @@ function redactLogText(text, secrets) {
}
function valueScrub(value, secrets) {
if (value == null) return value;
if (value === null || value === undefined) return value;
const json = JSON.stringify(value);
let scrubbed = json;
if (Array.isArray(secrets)) {
+60 -29
View File
@@ -47,15 +47,13 @@ function resolveReleaseVersion(release) {
return '';
}
function pickSetupAsset(assets) {
function pickSetupAsset(assets, remoteVersion = '') {
if (!Array.isArray(assets)) return null;
// Prefer asset with "setup" in the name (case-insensitive)
const setup = assets.find(a =>
/setup/i.test(a.name) && /\.exe$/i.test(a.name)
);
if (setup) return setup;
// Fallback: any .exe
return assets.find(a => /\.exe$/i.test(a.name)) || null;
const candidates = assets.filter(asset => {
const name = String(asset?.name || '');
return /setup/i.test(name) && /\.exe$/i.test(name) && (!remoteVersion || name.includes(remoteVersion));
});
return candidates.length === 1 ? candidates[0] : null;
}
function findLatestYml(assets) {
@@ -137,7 +135,7 @@ async function checkForUpdate() {
return cachedCheck;
}
const setupAsset = pickSetupAsset(release.assets);
const setupAsset = pickSetupAsset(release.assets, remoteVersion);
const latestYml = findLatestYml(release.assets);
if (!setupAsset) {
@@ -161,16 +159,37 @@ async function checkForUpdate() {
return cachedCheck;
}
async function parseLatestYml(url, fetchImpl = fetch) {
if (!url) return null;
async function parseLatestYml(url, expected = {}, fetchImpl = fetch) {
if (!url) throw new Error('Prüfsummen-Metadaten fehlen');
try {
const res = await fetchImpl(url, { redirect: 'follow' });
if (!res.ok) throw new Error(`Prüfsummen-Metadaten konnten nicht geladen werden: HTTP ${res.status}`);
const text = await res.text();
// Extract sha512 from latest.yml
const match = text.match(/sha512:\s*([A-Za-z0-9+/=]+)/);
return match ? match[1] : null;
} catch {
return null;
const version = text.match(/^version:\s*([^\r\n]+)$/m)?.[1]?.trim() || '';
const assetPath = text.match(/^path:\s*([^\r\n]+)$/m)?.[1]?.trim() || '';
const sha512 = text.match(/^sha512:\s*([A-Za-z0-9+/=]+)$/m)?.[1] || '';
const sizeText = text.match(/^\s*size:\s*(\d+)\s*$/m)?.[1] || '';
const size = Number(sizeText);
if (!version || !assetPath || !sha512 || !Number.isSafeInteger(size) || size <= 0) {
throw new Error('Prüfsummen-Metadaten sind unvollständig');
}
const decodedSha = Buffer.from(sha512, 'base64');
if (decodedSha.length !== 64 || decodedSha.toString('base64') !== sha512) {
throw new Error('Prüfsummen-Metadaten enthalten keine gültige SHA-512-Prüfsumme');
}
if (expected.version && version !== expected.version) {
throw new Error('Prüfsummen-Metadaten gehören zu einer anderen Version');
}
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)) {
throw new Error('Prüfsummen-Metadaten enthalten eine abweichende Dateigröße');
}
return { version, path: assetPath, size, sha512 };
} catch (error) {
if (error && /Prüfsummen-Metadaten/.test(error.message)) throw error;
throw new Error(`Prüfsummen-Metadaten konnten nicht geladen werden: ${error.message}`);
}
}
@@ -185,6 +204,7 @@ async function prepareUpdate(onProgress, options = {}) {
activeAbort = new AbortController();
const signal = activeAbort.signal;
const fetchImpl = options.fetchImpl || fetch;
let stagedInstallerPath = '';
try {
// Stage: starting
@@ -201,6 +221,15 @@ async function prepareUpdate(onProgress, options = {}) {
if (!check.assetUrl || !check.assetName) {
throw new Error('Update-Asset unvollständig (URL oder Name fehlt)');
}
if (!check.latestYmlUrl) {
throw new Error('Prüfsummen-Metadaten fehlen');
}
const manifest = await parseLatestYml(check.latestYmlUrl, {
version: check.remoteVersion,
assetName: check.assetName,
assetSize: check.assetSize
}, fetchImpl);
const expectedSha = manifest.sha512;
// Stage: downloading
const tmpDir = options.tempDir || app.getPath('temp');
@@ -255,6 +284,9 @@ async function prepareUpdate(onProgress, options = {}) {
}
const fileBuffer = Buffer.concat(chunks);
if (fileBuffer.length !== manifest.size) {
throw new Error('Heruntergeladene Datei hat eine abweichende Größe');
}
// Stage: verifying
if (onProgress) onProgress({ stage: 'verifying', percent: 0 });
@@ -263,21 +295,19 @@ async function prepareUpdate(onProgress, options = {}) {
throw new Error('Heruntergeladene Datei ist keine gültige EXE');
}
// Optional SHA-512 verification from latest.yml
const expectedSha = await parseLatestYml(check.latestYmlUrl, fetchImpl);
if (expectedSha) {
const actualSha = crypto.createHash('sha512').update(fileBuffer).digest('base64');
if (actualSha !== expectedSha) {
// Try hex comparison
const actualHex = crypto.createHash('sha512').update(fileBuffer).digest('hex');
if (actualHex !== expectedSha.toLowerCase()) {
throw new Error('SHA-512 Prüfung fehlgeschlagen');
}
const actualSha = crypto.createHash('sha512').update(fileBuffer).digest('base64');
if (actualSha !== expectedSha) {
const actualHex = crypto.createHash('sha512').update(fileBuffer).digest('hex');
if (actualHex !== expectedSha.toLowerCase()) {
throw new Error('SHA-512 Prüfung fehlgeschlagen');
}
}
// Write to disk
fs.writeFileSync(installerPath, fileBuffer);
stagedInstallerPath = path.join(tmpDir, `${path.basename(check.assetName)}.${crypto.randomUUID()}.download`);
fs.writeFileSync(stagedInstallerPath, fileBuffer);
fs.rmSync(installerPath, { force: true });
fs.renameSync(stagedInstallerPath, installerPath);
stagedInstallerPath = '';
const prepared = {
installerPath,
@@ -289,6 +319,7 @@ async function prepareUpdate(onProgress, options = {}) {
return prepared;
} catch (err) {
if (stagedInstallerPath) fs.rmSync(stagedInstallerPath, { force: true });
if (onProgress) onProgress({ stage: 'error', error: err.message });
throw err;
} finally {
@@ -322,4 +353,4 @@ function abortUpdate() {
}
}
module.exports = { checkForUpdate, fetchGithubReleaseNotes, prepareUpdate, launchPreparedUpdate, abortUpdate, isNewer, resolveReleaseVersion };
module.exports = { checkForUpdate, fetchGithubReleaseNotes, prepareUpdate, launchPreparedUpdate, abortUpdate, isNewer, resolveReleaseVersion, pickSetupAsset, parseLatestYml };
+39
View File
@@ -0,0 +1,39 @@
const SUPPORTED_HOSTERS = new Set([
'doodstream.com',
'voe.sx',
'vidmoly.me',
'byse.sx',
'clouddrop.cc'
]);
const FILE_CODE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{2,127}$/;
function isExpectedHostUrl(value, expectedHost) {
if (typeof value !== 'string' || value.trim() === '') return false;
try {
const url = new URL(value);
const hostname = url.hostname.toLowerCase();
return (url.protocol === 'http:' || url.protocol === 'https:')
&& (hostname === expectedHost || hostname.endsWith(`.${expectedHost}`));
} catch {
return false;
}
}
function assertUploadConfirmation(result, hoster) {
const expectedHost = typeof hoster === 'string' ? hoster.trim().toLowerCase() : '';
const fileCode = typeof result?.file_code === 'string' ? result.file_code.trim() : '';
const urls = [result?.download_url, result?.embed_url].filter(value => (
value !== null
&& value !== undefined
&& !(typeof value === 'string' && value.trim() === '')
));
if (SUPPORTED_HOSTERS.has(expectedHost)
&& FILE_CODE_PATTERN.test(fileCode)
&& urls.every(value => isExpectedHostUrl(value, expectedHost))) {
return result;
}
throw new Error(`Upload zu ${hoster || 'unbekanntem Hoster'} wurde nicht bestätigt`);
}
module.exports = { assertUploadConfirmation };
+14 -5
View File
@@ -1,5 +1,6 @@
const { EventEmitter } = require('events');
const path = require('path');
const { assertUploadConfirmation } = require('./upload-confirmation');
const fs = require('fs');
const crypto = require('crypto');
const { uploadFile, prefetchBaseline } = require('./hosters');
@@ -410,13 +411,15 @@ class UploadManager extends EventEmitter {
const files = Array.from(results.values());
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);
const summary = {
id: batchId,
timestamp: new Date().toISOString(),
total,
succeeded,
failed: total - succeeded,
failed: total - succeeded - skipped,
skipped,
files
};
@@ -488,19 +491,19 @@ class UploadManager extends EventEmitter {
if (fileNotFound) {
const error = 'Datei nicht gefunden';
emitFinalStatus('skipped', { error, attempt: 0 });
recordFinalResult('error', { error });
recordFinalResult('skipped', { error });
return;
}
if (fileSize <= 0) {
const error = 'Datei ist leer (0 Bytes)';
emitFinalStatus('skipped', { error, attempt: 0 });
recordFinalResult('error', { error });
recordFinalResult('skipped', { error });
return;
}
if (settings.maxSizeMb > 0 && fileSize > settings.maxSizeMb * 1024 * 1024) {
const error = `Datei zu groß (Max: ${settings.maxSizeMb} MB)`;
emitFinalStatus('skipped', { error, attempt: 0 });
recordFinalResult('error', { error });
recordFinalResult('skipped', { error });
return;
}
@@ -576,7 +579,8 @@ class UploadManager extends EventEmitter {
});
}
for (let attempt = 1; attempt <= maxAttempts && !memoSuspect; attempt++) {
const attemptsAllowed = memoSuspect ? 0 : maxAttempts;
for (let attempt = 1; attempt <= attemptsAllowed; attempt++) {
if (signal.aborted || this.stopAfterActive) break;
if (attempt > 1) {
@@ -1189,6 +1193,11 @@ class UploadManager extends EventEmitter {
}
async _executeUpload(task, progressCb, signal, throttle, fileProbe) {
const result = await this._executeUploadUnchecked(task, progressCb, signal, throttle, fileProbe);
return assertUploadConfirmation(result, task.hoster);
}
async _executeUploadUnchecked(task, progressCb, signal, throttle, fileProbe) {
if (task.hoster === 'vidmoly.me' && task.username) {
const vidmoly = new VidmolyUploader();
await vidmoly.login(task.username, task.password);
+6 -3
View File
@@ -27,8 +27,9 @@ function summarizePerHosterFromBatch(summary) {
if (!f || !Array.isArray(f.results)) continue;
for (const r of f.results) {
if (!r || !r.hoster) continue;
const b = out[r.hoster] || (out[r.hoster] = { ok: 0, fail: 0 });
const b = out[r.hoster] || (out[r.hoster] = { ok: 0, fail: 0, skipped: 0 });
if (r.status === 'done') b.ok++;
else if (r.status === 'skipped') b.skipped++;
else b.fail++;
}
}
@@ -61,6 +62,7 @@ function buildWebhookRequest(url, summary, meta) {
const total = Number(summary && summary.total) || 0;
const succeeded = Number(summary && summary.succeeded) || 0;
const failed = Number(summary && summary.failed) || 0;
const skipped = Number(summary && summary.skipped) || 0;
const perHoster = summarizePerHosterFromBatch(summary);
const duration = formatDurationShort(m.durationSec);
@@ -78,8 +80,8 @@ function buildWebhookRequest(url, summary, meta) {
const lines = [
`**Multi-Hoster-Upload — ${headline}**${m.machineName ? ` (${m.machineName})` : ''}`,
language === 'de'
? `${succeeded} ok · ❌ ${failed} Fehler · 📦 ${total} gesamt · ⏱ ${duration}`
: `${succeeded} succeeded · ❌ ${failed} failed · 📦 ${total} total · ⏱ ${duration}`
? `${succeeded} ok · ❌ ${failed} Fehler${skipped > 0 ? ` · ⏭ ${skipped} übersprungen` : ''} · 📦 ${total} gesamt · ⏱ ${duration}`
: `${succeeded} succeeded · ❌ ${failed} failed${skipped > 0 ? ` · ⏭ ${skipped} skipped` : ''} · 📦 ${total} total · ⏱ ${duration}`
];
if (hosterLines) lines.push(hosterLines);
const mention = resolveDiscordMention(m.mention);
@@ -96,6 +98,7 @@ function buildWebhookRequest(url, summary, meta) {
total,
succeeded,
failed,
skipped,
durationSec: Math.round(Number(m.durationSec) || 0),
aborted: !!m.aborted,
perHoster,