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:
+17
-10
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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 || '');
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user