release: v2.1.12 reliability and security hardening
CI / verify (push) Waiting to run

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:21 +02:00
parent 2c124c848c
commit 2ba0106ef0
47 changed files with 2069 additions and 1192 deletions
+20
View File
@@ -0,0 +1,20 @@
name: CI
on:
push:
branches: ['**']
pull_request:
jobs:
verify:
runs-on: windows-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
- run: npm ci
- run: npm run verify
- run: npm run dist
+20
View File
@@ -0,0 +1,20 @@
name: CI
on:
push:
branches: ['**']
pull_request:
jobs:
verify:
runs-on: windows-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
- run: npm ci
- run: npm run verify
- run: npm run dist
+1 -1
View File
@@ -8,7 +8,7 @@ Multi-Hoster-Upload is a Windows desktop application for sending file batches to
Download the current Setup or Portable build from the [latest GitHub release](https://github.com/Sucukdeluxe/Multi-Hoster-Upload/releases/latest). Download the current Setup or Portable build from the [latest GitHub release](https://github.com/Sucukdeluxe/Multi-Hoster-Upload/releases/latest).
The latest public release is version 2.1.11. Use the release page for the executables and the full English changelog. The latest public release is version 2.1.12. Use the release page for the executables and the full English changelog.
## Features ## Features
+7 -1
View File
@@ -59,7 +59,7 @@ const nodeGlobals = {
}; };
export default [ export default [
{ ignores: ['**/node_modules/**', 'release/**', 'tests/**'] }, { ignores: ['**/node_modules/**', 'release/**', 'tests/**', '.artifacts/**', '.worktrees/**', 'test-artifacts/**'] },
{ {
files: ['**/*.js'], files: ['**/*.js'],
ignores: ['gateway/**'], ignores: ['gateway/**'],
@@ -101,5 +101,11 @@ export default [
globals: nodeGlobals globals: nodeGlobals
}, },
rules: sharedRules rules: sharedRules
},
{
files: ['lib/diagnostics-collectors.js', 'lib/log-mode.js', 'lib/support-bundle.js'],
rules: {
'security/detect-unsafe-regex': 'off'
}
} }
]; ];
+15 -8
View File
@@ -1,6 +1,7 @@
const crypto = require('crypto'); 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 SALT_LEN = 16;
const IV_LEN = 12; const IV_LEN = 12;
const TAG_LEN = 16; const TAG_LEN = 16;
@@ -22,11 +23,15 @@ function deriveKey(passphrase, salt) {
* Encrypt a config object. * Encrypt a config object.
* Returns a Buffer: MHU1 | salt(16) | iv(12) | tag(16) | ciphertext * 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 plaintext = Buffer.from(JSON.stringify(config), 'utf-8');
const salt = crypto.randomBytes(SALT_LEN); const salt = crypto.randomBytes(SALT_LEN);
const iv = crypto.randomBytes(IV_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 cipher = crypto.createCipheriv(ALGO, key, iv);
const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]); const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
@@ -34,7 +39,7 @@ function encrypt(config) {
plaintext.fill(0); plaintext.fill(0);
key.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. * given, so callers can prompt the user for the legacy password.
*/ */
function decrypt(buffer, userPassword) { 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'); throw new Error('Ungültiges Backup-Format');
} }
const magic = buffer.subarray(0, 4); 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'); 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 salt = buffer.subarray(offset, offset += SALT_LEN);
const iv = buffer.subarray(offset, offset += IV_LEN); const iv = buffer.subarray(offset, offset += IV_LEN);
const tag = buffer.subarray(offset, offset += TAG_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). if (!passwordProtected) {
const fromApp = tryPassphrase(APP_PASSPHRASE); const fromApp = tryPassphrase(APP_PASSPHRASE);
if (fromApp) return fromApp; if (fromApp) return fromApp;
}
// 2) Legacy format: user had set their own password. // 2) Legacy format: user had set their own password.
if (userPassword) { if (userPassword) {
+10 -3
View File
@@ -72,6 +72,8 @@ const DEFAULTS = {
// erase) the legacy sessionLog:true → "daily" migration. normalizeLogMode in // erase) the legacy sessionLog:true → "daily" migration. normalizeLogMode in
// load() sets logMode after the merge, looking at the saved-only data. // load() sets logMode after the merge, looking at the saved-only data.
resumeQueueOnLaunch: true, resumeQueueOnLaunch: true,
autoStartRestoredQueue: false,
allowPlaintextCredentialStorage: false,
parallelUploadCount: 0, // 0 = use per-hoster limits only parallelUploadCount: 0, // 0 = use per-hoster limits only
scaleParallelUploads: false, scaleParallelUploads: false,
lastBrowseDirectory: '', lastBrowseDirectory: '',
@@ -91,6 +93,7 @@ const DEFAULTS = {
enabled: false, enabled: false,
folderPath: '', folderPath: '',
recursive: false, recursive: false,
includeExisting: false,
filterMode: 'include', // 'include' | 'exclude' filterMode: 'include', // 'include' | 'exclude'
extensions: '', // comma-separated: 'mp4,mkv,avi' extensions: '', // comma-separated: 'mp4,mkv,avi'
skipDuplicates: true, skipDuplicates: true,
@@ -179,7 +182,7 @@ function applyHistoryRetention(history, retention, nowMs) {
} }
class ConfigStore { class ConfigStore {
constructor(app) { constructor(app, options = {}) {
const useUserDataDir = app && ( const useUserDataDir = app && (
app.isPackaged || app.isPackaged ||
(app.commandLine && typeof app.commandLine.hasSwitch === 'function' && app.commandLine.hasSwitch('user-data-dir')) (app.commandLine && typeof app.commandLine.hasSwitch === 'function' && app.commandLine.hasSwitch('user-data-dir'))
@@ -198,6 +201,7 @@ class ConfigStore {
this._cacheKey = ''; this._cacheKey = '';
this._perfLog = null; this._perfLog = null;
this._wqDepth = 0; this._wqDepth = 0;
this._allowPlaintextCredentialStorage = options.allowPlaintextCredentialStorage === true;
// Migrate config from old location if current doesn't exist // Migrate config from old location if current doesn't exist
if (!fs.existsSync(this.filePath) && app && app.isPackaged) { if (!fs.existsSync(this.filePath) && app && app.isPackaged) {
@@ -468,7 +472,8 @@ class ConfigStore {
this._cacheKey = statKey; this._cacheKey = statKey;
} }
return this._clone(result); return this._clone(result);
} catch { } catch (error) {
if (error instanceof secretStore.SecretStoreError) throw error;
const fresh = JSON.parse(JSON.stringify(DEFAULTS)); const fresh = JSON.parse(JSON.stringify(DEFAULTS));
fresh.globalSettings.logMode = normalizeLogMode(fresh.globalSettings); fresh.globalSettings.logMode = normalizeLogMode(fresh.globalSettings);
return fresh; return fresh;
@@ -482,7 +487,9 @@ class ConfigStore {
// on every write was a primary long-running main-thread stall. // on every write was a primary long-running main-thread stall.
_serializeForDisk(config) { _serializeForDisk(config) {
const hosters = this._clone(config.hosters || {}); 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); 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'); const chokidar = require('chokidar');
class FolderMonitor extends EventEmitter { class FolderMonitor extends EventEmitter {
constructor() { constructor({ watch = chokidar.watch } = {}) {
super(); super();
this._watch = watch;
this._watcher = null; this._watcher = null;
this._settings = null; this._settings = null;
this._seenFiles = new Set(); this._seenFiles = new Set();
this._batchBuffer = []; this._batchBuffer = [];
this._batchTimer = null; this._batchTimer = null;
this._initialScopes = new Set();
} }
get running() { get running() {
@@ -23,9 +25,18 @@ class FolderMonitor extends EventEmitter {
const folderPath = String(settings.folderPath || '').trim(); const folderPath = String(settings.folderPath || '').trim();
if (!folderPath) throw new Error('Kein Ordnerpfad angegeben'); 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 = { const watchOptions = {
persistent: true, persistent: true,
ignoreInitial: true, ignoreInitial: !includeInitial,
depth: settings.recursive ? undefined : 0, depth: settings.recursive ? undefined : 0,
awaitWriteFinish: { awaitWriteFinish: {
stabilityThreshold: Math.max(1000, (settings.delaySec || 3) * 1000), 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('add', (filePath) => this._onNewFile(filePath));
this._watcher.on('unlink', (filePath) => { this._watcher.on('unlink', (filePath) => {
// Allow re-added files (e.g. re-encoded) to be detected again // 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._seenFiles.delete(normalized);
}); });
this._watcher.on('error', (err) => this.emit('error', err)); this._watcher.on('error', (err) => this.emit('error', err));
return { includesExisting: includeInitial };
} }
stop() { stop() {
-2
View File
@@ -90,9 +90,7 @@
// Order matters: session first (longer, more specific) before daily. // Order matters: session first (longer, more specific) before daily.
// Both regexes are anchored to $ with no nested/ambiguous quantifiers, so // Both regexes are anchored to $ with no nested/ambiguous quantifiers, so
// matching is linear — the eslint security warning is precautionary. // 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+)?(\.[^.]+)?$/; 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}(\.[^.]+)?$/; const dailyRe = /-\d{4}-\d{2}-\d{2}(\.[^.]+)?$/;
let out = fileName.replace(sessionRe, (m, ext) => ext || ''); let out = fileName.replace(sessionRe, (m, ext) => ext || '');
out = out.replace(dailyRe, (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'); const { evaluateClientAllowed } = require('./ip-allowlist');
function timingSafeEqualStr(a, b) { function timingSafeEqualStr(a, b) {
const x = Buffer.from(String(a == null ? '' : a)); const x = Buffer.from(String(a === null || a === undefined ? '' : a));
const y = Buffer.from(String(b == null ? '' : b)); const y = Buffer.from(String(b === null || b === undefined ? '' : b));
return x.length === y.length && crypto.timingSafeEqual(x, y); return x.length === y.length && crypto.timingSafeEqual(x, y);
} }
+38 -9
View File
@@ -10,6 +10,15 @@
const SENTINEL = 'enc:v1:'; const SENTINEL = 'enc:v1:';
const CRED_FIELDS = ['password', 'apiKey']; 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; let _safeStorageCache = undefined;
function getSafeStorage() { function getSafeStorage() {
if (_safeStorageCache !== undefined) return _safeStorageCache; if (_safeStorageCache !== undefined) return _safeStorageCache;
@@ -29,16 +38,24 @@ function isEncrypted(value) {
return typeof value === 'string' && value.startsWith(SENTINEL); 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 (!value || typeof value !== 'string') return value;
if (isEncrypted(value)) return value; if (isEncrypted(value)) return value;
const ss = getSafeStorage(); 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 { try {
const buf = ss.encryptString(value); const buf = ss.encryptString(value);
return SENTINEL + buf.toString('base64'); return SENTINEL + buf.toString('base64');
} catch { } catch (cause) {
return value; 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 (!value || typeof value !== 'string') return value;
if (!isEncrypted(value)) return value; if (!isEncrypted(value)) return value;
const ss = getSafeStorage(); const ss = getSafeStorage();
if (!ss) return ''; if (!ss) {
throw new SecretStoreError('SECRET_STORE_UNAVAILABLE', 'Sicherer Zugangsdaten-Speicher ist nicht verfügbar');
}
try { try {
const buf = Buffer.from(value.slice(SENTINEL.length), 'base64'); const buf = Buffer.from(value.slice(SENTINEL.length), 'base64');
return ss.decryptString(buf); return ss.decryptString(buf);
} catch { } catch (cause) {
return ''; 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; 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); } 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; if (!file || !Array.isArray(file.results)) continue;
for (const r of file.results) { for (const r of file.results) {
if (!r || !r.hoster) continue; 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++; bucket.total++;
if (r.status === 'done') bucket.ok++; if (r.status === 'done') bucket.ok++;
else if (r.status === 'skipped') bucket.skipped++;
else bucket.fail++; else bucket.fail++;
} }
} }
} }
for (const h of Object.keys(out)) { for (const h of Object.keys(out)) {
const b = out[h]; 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; return out;
} }
@@ -61,7 +63,7 @@
for (const f of batchSummary.files) { for (const f of batchSummary.files) {
if (!f || !Array.isArray(f.results)) continue; if (!f || !Array.isArray(f.results)) continue;
for (const r of f.results) { 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); const cat = classifyErrorCategory(r.error);
buckets[cat].push({ buckets[cat].push({
fileName: f.name || f.fileName || '', fileName: f.name || f.fileName || '',
@@ -74,6 +76,48 @@
return buckets; 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']); const RETRYABLE_CATEGORIES = new Set(['hoster-transient', 'network', 'unknown']);
function isRetryableCategory(cat) { function isRetryableCategory(cat) {
return RETRYABLE_CATEGORIES.has(cat); return RETRYABLE_CATEGORIES.has(cat);
@@ -128,6 +172,7 @@
summarizePerHoster, summarizePerHoster,
classifyErrorCategory, classifyErrorCategory,
summarizeBatchErrors, summarizeBatchErrors,
mergeSkippedIntoSummary,
isRetryableCategory, isRetryableCategory,
RETRYABLE_CATEGORIES, RETRYABLE_CATEGORIES,
CATEGORY_LABELS, CATEGORY_LABELS,
+1 -1
View File
@@ -55,7 +55,7 @@ function redactLogText(text, secrets) {
} }
function valueScrub(value, secrets) { function valueScrub(value, secrets) {
if (value == null) return value; if (value === null || value === undefined) return value;
const json = JSON.stringify(value); const json = JSON.stringify(value);
let scrubbed = json; let scrubbed = json;
if (Array.isArray(secrets)) { if (Array.isArray(secrets)) {
+55 -24
View File
@@ -47,15 +47,13 @@ function resolveReleaseVersion(release) {
return ''; return '';
} }
function pickSetupAsset(assets) { function pickSetupAsset(assets, remoteVersion = '') {
if (!Array.isArray(assets)) return null; if (!Array.isArray(assets)) return null;
// Prefer asset with "setup" in the name (case-insensitive) const candidates = assets.filter(asset => {
const setup = assets.find(a => const name = String(asset?.name || '');
/setup/i.test(a.name) && /\.exe$/i.test(a.name) return /setup/i.test(name) && /\.exe$/i.test(name) && (!remoteVersion || name.includes(remoteVersion));
); });
if (setup) return setup; return candidates.length === 1 ? candidates[0] : null;
// Fallback: any .exe
return assets.find(a => /\.exe$/i.test(a.name)) || null;
} }
function findLatestYml(assets) { function findLatestYml(assets) {
@@ -137,7 +135,7 @@ async function checkForUpdate() {
return cachedCheck; return cachedCheck;
} }
const setupAsset = pickSetupAsset(release.assets); const setupAsset = pickSetupAsset(release.assets, remoteVersion);
const latestYml = findLatestYml(release.assets); const latestYml = findLatestYml(release.assets);
if (!setupAsset) { if (!setupAsset) {
@@ -161,16 +159,37 @@ async function checkForUpdate() {
return cachedCheck; return cachedCheck;
} }
async function parseLatestYml(url, fetchImpl = fetch) { async function parseLatestYml(url, expected = {}, fetchImpl = fetch) {
if (!url) return null; if (!url) throw new Error('Prüfsummen-Metadaten fehlen');
try { try {
const res = await fetchImpl(url, { redirect: 'follow' }); 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(); const text = await res.text();
// Extract sha512 from latest.yml const version = text.match(/^version:\s*([^\r\n]+)$/m)?.[1]?.trim() || '';
const match = text.match(/sha512:\s*([A-Za-z0-9+/=]+)/); const assetPath = text.match(/^path:\s*([^\r\n]+)$/m)?.[1]?.trim() || '';
return match ? match[1] : null; const sha512 = text.match(/^sha512:\s*([A-Za-z0-9+/=]+)$/m)?.[1] || '';
} catch { const sizeText = text.match(/^\s*size:\s*(\d+)\s*$/m)?.[1] || '';
return null; 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(); activeAbort = new AbortController();
const signal = activeAbort.signal; const signal = activeAbort.signal;
const fetchImpl = options.fetchImpl || fetch; const fetchImpl = options.fetchImpl || fetch;
let stagedInstallerPath = '';
try { try {
// Stage: starting // Stage: starting
@@ -201,6 +221,15 @@ async function prepareUpdate(onProgress, options = {}) {
if (!check.assetUrl || !check.assetName) { if (!check.assetUrl || !check.assetName) {
throw new Error('Update-Asset unvollständig (URL oder Name fehlt)'); 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 // Stage: downloading
const tmpDir = options.tempDir || app.getPath('temp'); const tmpDir = options.tempDir || app.getPath('temp');
@@ -255,6 +284,9 @@ async function prepareUpdate(onProgress, options = {}) {
} }
const fileBuffer = Buffer.concat(chunks); const fileBuffer = Buffer.concat(chunks);
if (fileBuffer.length !== manifest.size) {
throw new Error('Heruntergeladene Datei hat eine abweichende Größe');
}
// Stage: verifying // Stage: verifying
if (onProgress) onProgress({ stage: 'verifying', percent: 0 }); 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'); 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'); const actualSha = crypto.createHash('sha512').update(fileBuffer).digest('base64');
if (actualSha !== expectedSha) { if (actualSha !== expectedSha) {
// Try hex comparison
const actualHex = crypto.createHash('sha512').update(fileBuffer).digest('hex'); const actualHex = crypto.createHash('sha512').update(fileBuffer).digest('hex');
if (actualHex !== expectedSha.toLowerCase()) { if (actualHex !== expectedSha.toLowerCase()) {
throw new Error('SHA-512 Prüfung fehlgeschlagen'); throw new Error('SHA-512 Prüfung fehlgeschlagen');
} }
} }
}
// Write to disk stagedInstallerPath = path.join(tmpDir, `${path.basename(check.assetName)}.${crypto.randomUUID()}.download`);
fs.writeFileSync(installerPath, fileBuffer); fs.writeFileSync(stagedInstallerPath, fileBuffer);
fs.rmSync(installerPath, { force: true });
fs.renameSync(stagedInstallerPath, installerPath);
stagedInstallerPath = '';
const prepared = { const prepared = {
installerPath, installerPath,
@@ -289,6 +319,7 @@ async function prepareUpdate(onProgress, options = {}) {
return prepared; return prepared;
} catch (err) { } catch (err) {
if (stagedInstallerPath) fs.rmSync(stagedInstallerPath, { force: true });
if (onProgress) onProgress({ stage: 'error', error: err.message }); if (onProgress) onProgress({ stage: 'error', error: err.message });
throw err; throw err;
} finally { } 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 { EventEmitter } = require('events');
const path = require('path'); const path = require('path');
const { assertUploadConfirmation } = require('./upload-confirmation');
const fs = require('fs'); const fs = require('fs');
const crypto = require('crypto'); const crypto = require('crypto');
const { uploadFile, prefetchBaseline } = require('./hosters'); const { uploadFile, prefetchBaseline } = require('./hosters');
@@ -410,13 +411,15 @@ class UploadManager extends EventEmitter {
const files = Array.from(results.values()); const files = Array.from(results.values());
const total = tasks.length; const total = tasks.length;
const succeeded = files.reduce((count, file) => count + file.results.filter((result) => result.status === 'done').length, 0); 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 = { const summary = {
id: batchId, id: batchId,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
total, total,
succeeded, succeeded,
failed: total - succeeded, failed: total - succeeded - skipped,
skipped,
files files
}; };
@@ -488,19 +491,19 @@ class UploadManager extends EventEmitter {
if (fileNotFound) { if (fileNotFound) {
const error = 'Datei nicht gefunden'; const error = 'Datei nicht gefunden';
emitFinalStatus('skipped', { error, attempt: 0 }); emitFinalStatus('skipped', { error, attempt: 0 });
recordFinalResult('error', { error }); recordFinalResult('skipped', { error });
return; return;
} }
if (fileSize <= 0) { if (fileSize <= 0) {
const error = 'Datei ist leer (0 Bytes)'; const error = 'Datei ist leer (0 Bytes)';
emitFinalStatus('skipped', { error, attempt: 0 }); emitFinalStatus('skipped', { error, attempt: 0 });
recordFinalResult('error', { error }); recordFinalResult('skipped', { error });
return; return;
} }
if (settings.maxSizeMb > 0 && fileSize > settings.maxSizeMb * 1024 * 1024) { if (settings.maxSizeMb > 0 && fileSize > settings.maxSizeMb * 1024 * 1024) {
const error = `Datei zu groß (Max: ${settings.maxSizeMb} MB)`; const error = `Datei zu groß (Max: ${settings.maxSizeMb} MB)`;
emitFinalStatus('skipped', { error, attempt: 0 }); emitFinalStatus('skipped', { error, attempt: 0 });
recordFinalResult('error', { error }); recordFinalResult('skipped', { error });
return; 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 (signal.aborted || this.stopAfterActive) break;
if (attempt > 1) { if (attempt > 1) {
@@ -1189,6 +1193,11 @@ class UploadManager extends EventEmitter {
} }
async _executeUpload(task, progressCb, signal, throttle, fileProbe) { 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) { if (task.hoster === 'vidmoly.me' && task.username) {
const vidmoly = new VidmolyUploader(); const vidmoly = new VidmolyUploader();
await vidmoly.login(task.username, task.password); 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; if (!f || !Array.isArray(f.results)) continue;
for (const r of f.results) { for (const r of f.results) {
if (!r || !r.hoster) continue; 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++; if (r.status === 'done') b.ok++;
else if (r.status === 'skipped') b.skipped++;
else b.fail++; else b.fail++;
} }
} }
@@ -61,6 +62,7 @@ function buildWebhookRequest(url, summary, meta) {
const total = Number(summary && summary.total) || 0; const total = Number(summary && summary.total) || 0;
const succeeded = Number(summary && summary.succeeded) || 0; const succeeded = Number(summary && summary.succeeded) || 0;
const failed = Number(summary && summary.failed) || 0; const failed = Number(summary && summary.failed) || 0;
const skipped = Number(summary && summary.skipped) || 0;
const perHoster = summarizePerHosterFromBatch(summary); const perHoster = summarizePerHosterFromBatch(summary);
const duration = formatDurationShort(m.durationSec); const duration = formatDurationShort(m.durationSec);
@@ -78,8 +80,8 @@ function buildWebhookRequest(url, summary, meta) {
const lines = [ const lines = [
`**Multi-Hoster-Upload — ${headline}**${m.machineName ? ` (${m.machineName})` : ''}`, `**Multi-Hoster-Upload — ${headline}**${m.machineName ? ` (${m.machineName})` : ''}`,
language === 'de' language === 'de'
? `${succeeded} ok · ❌ ${failed} Fehler · 📦 ${total} gesamt · ⏱ ${duration}` ? `${succeeded} ok · ❌ ${failed} Fehler${skipped > 0 ? ` · ⏭ ${skipped} übersprungen` : ''} · 📦 ${total} gesamt · ⏱ ${duration}`
: `${succeeded} succeeded · ❌ ${failed} failed · 📦 ${total} total · ⏱ ${duration}` : `${succeeded} succeeded · ❌ ${failed} failed${skipped > 0 ? ` · ⏭ ${skipped} skipped` : ''} · 📦 ${total} total · ⏱ ${duration}`
]; ];
if (hosterLines) lines.push(hosterLines); if (hosterLines) lines.push(hosterLines);
const mention = resolveDiscordMention(m.mention); const mention = resolveDiscordMention(m.mention);
@@ -96,6 +98,7 @@ function buildWebhookRequest(url, summary, meta) {
total, total,
succeeded, succeeded,
failed, failed,
skipped,
durationSec: Math.round(Number(m.durationSec) || 0), durationSec: Math.round(Number(m.durationSec) || 0),
aborted: !!m.aborted, aborted: !!m.aborted,
perHoster, perHoster,
+58 -36
View File
@@ -7,6 +7,7 @@ nativeTheme.themeSource = 'dark';
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const ConfigStore = require('./lib/config-store'); const ConfigStore = require('./lib/config-store');
const secretStore = require('./lib/secret-store');
const UploadManager = require('./lib/upload-manager'); const UploadManager = require('./lib/upload-manager');
const { createSourceFileCleanup } = require('./lib/source-file-cleanup'); const { createSourceFileCleanup } = require('./lib/source-file-cleanup');
const SourceDeleteJournal = require('./lib/source-delete-journal'); const SourceDeleteJournal = require('./lib/source-delete-journal');
@@ -23,6 +24,7 @@ const { createOnlineBackup, downloadOnlineBackup, uploadOnlineBackup } = require
const { createPortableSettingsSnapshot, prepareImportedSettings } = require('./lib/settings-backup'); const { createPortableSettingsSnapshot, prepareImportedSettings } = require('./lib/settings-backup');
const { createSettingsImportGate } = require('./lib/settings-import-gate'); const { createSettingsImportGate } = require('./lib/settings-import-gate');
const FolderMonitor = require('./lib/folder-monitor'); const FolderMonitor = require('./lib/folder-monitor');
const { walkFolderAsync } = require('./lib/file-discovery');
const RemoteServer = require('./lib/remote-server'); const RemoteServer = require('./lib/remote-server');
const { maybeRotateLogFile } = require('./lib/log-rotation'); const { maybeRotateLogFile } = require('./lib/log-rotation');
const { hosterLogToFileEnabled } = require('./lib/log-policy'); const { hosterLogToFileEnabled } = require('./lib/log-policy');
@@ -1697,6 +1699,8 @@ ipcMain.handle('get-config', () => {
return configStore.load(); return configStore.load();
}); });
ipcMain.handle('secret-store:status', () => ({ status: secretStore.getAvailabilityStatus() }));
ipcMain.handle('save-config', async (_event, config) => { ipcMain.handle('save-config', async (_event, config) => {
assertConfigWriteAllowed(); assertConfigWriteAllowed();
await configStore.save(config); await configStore.save(config);
@@ -1942,7 +1946,7 @@ ipcMain.handle('select-folder', async () => {
await rememberUploadBrowseDirectory(result.filePaths[0], true); await rememberUploadBrowseDirectory(result.filePaths[0], true);
const files = []; const files = [];
for (const folder of result.filePaths) await walkFolderAsync(folder, files); for (const folder of result.filePaths) files.push(...await walkFolderAsync(folder));
return files.length > 0 ? files.map(f => f.path) : null; return files.length > 0 ? files.map(f => f.path) : null;
}); });
@@ -1955,36 +1959,12 @@ ipcMain.handle('select-folder-with-sizes', async () => {
await rememberUploadBrowseDirectory(result.filePaths[0], true); await rememberUploadBrowseDirectory(result.filePaths[0], true);
const files = []; const files = [];
for (const folder of result.filePaths) await walkFolderAsync(folder, files); for (const folder of result.filePaths) files.push(...await walkFolderAsync(folder));
return files.length > 0 ? files : null; return files.length > 0 ? files : null;
}); });
async function walkFolderAsync(rootDir, outFiles) {
const fsp = fs.promises;
const stack = [rootDir];
let scanned = 0;
while (stack.length > 0) {
const dir = stack.pop();
let entries;
try { entries = await fsp.readdir(dir, { withFileTypes: true }); }
catch { continue; }
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) stack.push(full);
else if (entry.isFile()) {
let size = 0;
try { size = (await fsp.stat(full)).size; } catch {}
outFiles.push({ path: full, name: entry.name, size });
}
}
if ((++scanned % 8) === 0) await new Promise(setImmediate);
}
}
ipcMain.handle('resolve-folder-files', async (_event, folderPath) => { ipcMain.handle('resolve-folder-files', async (_event, folderPath) => {
const files = []; return walkFolderAsync(folderPath);
await walkFolderAsync(folderPath, files);
return files;
}); });
ipcMain.handle('get-file-sizes', async (_event, paths) => { ipcMain.handle('get-file-sizes', async (_event, paths) => {
@@ -2024,7 +2004,12 @@ ipcMain.handle('start-upload', async (_event, payload) => {
// Identify jobs that were skipped (no account/credentials) // Identify jobs that were skipped (no account/credentials)
const taskJobIds = new Set(tasks.map(t => t.jobId).filter(Boolean)); const taskJobIds = new Set(tasks.map(t => t.jobId).filter(Boolean));
const skippedJobs = jobs.filter(j => j.id && !taskJobIds.has(j.id)).map(j => ({ const skippedJobs = jobs.filter(j => j.id && !taskJobIds.has(j.id)).map(j => ({
jobId: j.id, hoster: j.hoster, reason: 'Kein gültiger Account für diesen Hoster' jobId: j.id,
file: j.file,
fileName: j.fileName || path.basename(j.file || ''),
size: Number(j.bytesTotal) || 0,
hoster: j.hoster,
reason: 'Kein gültiger Account für diesen Hoster'
})); }));
if (skippedJobs.length > 0) { if (skippedJobs.length > 0) {
debugLog(` skipped ${skippedJobs.length} jobs: ${skippedJobs.map(s => s.hoster).join(', ')}`); debugLog(` skipped ${skippedJobs.length} jobs: ${skippedJobs.map(s => s.hoster).join(', ')}`);
@@ -2032,7 +2017,22 @@ ipcMain.handle('start-upload', async (_event, payload) => {
debugLog(` tasks built: ${tasks.length}`); debugLog(` tasks built: ${tasks.length}`);
if (tasks.length === 0) return { error: 'Keine gültigen Zugangsdaten für die gewählten Hoster.', skippedJobs }; if (tasks.length === 0) {
const skippedSummary = stats.mergeSkippedIntoSummary({
id: `skipped-${Date.now()}`,
timestamp: new Date().toISOString(),
total: 0,
succeeded: 0,
failed: 0,
skipped: 0,
files: []
}, skippedJobs);
try { await configStore.appendHistory(skippedSummary); } catch (error) {
debugLog(`appendHistory for skipped jobs failed: ${error.message}`);
}
setImmediate(() => safeSend('upload-batch-done', skippedSummary));
return { started: true, taskCount: 0, skippedJobs };
}
// Pre-resolve a fallback for every hoster that has one. Lets the upload // Pre-resolve a fallback for every hoster that has one. Lets the upload
// manager break out of the retry loop after a single generic failure and // manager break out of the retry loop after a single generic failure and
@@ -2210,6 +2210,7 @@ ipcMain.handle('start-upload', async (_event, payload) => {
// create a fresh manager which the trailing `uploadManager = null` then // create a fresh manager which the trailing `uploadManager = null` then
// orphans (cancel/addJobs see null, the new batch keeps running invisibly). // orphans (cancel/addJobs see null, the new batch keeps running invisibly).
uploadManager.on('batch-done', async (summary) => { uploadManager.on('batch-done', async (summary) => {
summary = stats.mergeSkippedIntoSummary(summary, skippedJobs);
debugLog(`batch-done: total=${summary.total} ok=${summary.succeeded} fail=${summary.failed}`); debugLog(`batch-done: total=${summary.total} ok=${summary.succeeded} fail=${summary.failed}`);
logMarker('BATCH END', { total: summary.total, ok: summary.succeeded, fail: summary.failed }); logMarker('BATCH END', { total: summary.total, ok: summary.succeeded, fail: summary.failed });
logMemorySnapshot('batch-done'); logMemorySnapshot('batch-done');
@@ -2625,7 +2626,11 @@ function readBackupFile(filePath) {
} }
// --- Backup export / import --- // --- Backup export / import ---
ipcMain.handle('export-backup', async () => { ipcMain.handle('export-backup', async (_event, options = {}) => {
const password = typeof options?.password === 'string' ? options.password : undefined;
if (password !== undefined && (password.length < 8 || password.length > 1024)) {
throw new Error('Das Backup-Passwort muss zwischen 8 und 1024 Zeichen lang sein');
}
const _bd = new Date(); const _bd = new Date();
const _bdate = `${String(_bd.getDate()).padStart(2, '0')}-${String(_bd.getMonth() + 1).padStart(2, '0')}-${_bd.getFullYear()}`; const _bdate = `${String(_bd.getDate()).padStart(2, '0')}-${String(_bd.getMonth() + 1).padStart(2, '0')}-${_bd.getFullYear()}`;
const { canceled, filePath } = await dialog.showSaveDialog(mainWindow, { const { canceled, filePath } = await dialog.showSaveDialog(mainWindow, {
@@ -2640,9 +2645,10 @@ ipcMain.handle('export-backup', async () => {
await waitForConfigStoreWrites(); await waitForConfigStoreWrites();
const config = createPortableSettingsSnapshot(configStore.load()); const config = createPortableSettingsSnapshot(configStore.load());
if (filePath.toLowerCase().endsWith('.json')) { if (filePath.toLowerCase().endsWith('.json')) {
if (typeof password === 'string') throw new Error('Passwortgeschützte Backups müssen als .mhu gespeichert werden');
fs.writeFileSync(filePath, JSON.stringify(config, null, 2), 'utf-8'); fs.writeFileSync(filePath, JSON.stringify(config, null, 2), 'utf-8');
} else { } else {
const encrypted = backupCrypto.encrypt(config); const encrypted = backupCrypto.encrypt(config, password);
fs.writeFileSync(filePath, encrypted); fs.writeFileSync(filePath, encrypted);
} }
return { ok: true, path: filePath }; return { ok: true, path: filePath };
@@ -2977,8 +2983,24 @@ function startFolderMonitor(settings) {
folderMonitor.on('error', (err) => { folderMonitor.on('error', (err) => {
debugLog(`folder-monitor error: ${err.message}`); debugLog(`folder-monitor error: ${err.message}`);
}); });
folderMonitor.start(settings); folderMonitor.on('initial-scan-complete', async () => {
try {
const latest = configStore.load();
const current = latest.globalSettings?.folderMonitor;
if (!current?.includeExisting || path.resolve(current.folderPath || '') !== path.resolve(settings.folderPath || '')) return;
await configStore.save({
globalSettings: {
...latest.globalSettings,
folderMonitor: { ...current, includeExisting: false }
}
});
} catch (error) {
debugLog(`folder-monitor initial scan state failed: ${error.message}`);
}
});
const result = folderMonitor.start(settings);
debugLog(`folder-monitor started: ${settings.folderPath}`); debugLog(`folder-monitor started: ${settings.folderPath}`);
return result;
} catch (err) { } catch (err) {
debugLog(`folder-monitor start failed: ${err.message}`); debugLog(`folder-monitor start failed: ${err.message}`);
throw err; throw err;
@@ -2986,8 +3008,8 @@ function startFolderMonitor(settings) {
} }
ipcMain.handle('folder-monitor:start', (_event, settings) => { ipcMain.handle('folder-monitor:start', (_event, settings) => {
startFolderMonitor(settings); const result = startFolderMonitor(settings);
return { ok: true }; return { ok: true, includesExisting: result?.includesExisting === true };
}); });
ipcMain.handle('folder-monitor:stop', () => { ipcMain.handle('folder-monitor:stop', () => {
@@ -3180,11 +3202,11 @@ ipcMain.handle('diagnostics:save-settings', async (_e, incoming) => {
enabled: !!(incoming && incoming.enabled), enabled: !!(incoming && incoming.enabled),
port: (incoming && Number(incoming.port)) || cur.port || 9110, port: (incoming && Number(incoming.port)) || cur.port || 9110,
bindMode: (incoming && incoming.bindMode === 'network') ? 'network' : 'local', bindMode: (incoming && incoming.bindMode === 'network') ? 'network' : 'local',
publicHost: (incoming && incoming.publicHost != null) ? String(incoming.publicHost).trim() : (cur.publicHost || ''), publicHost: (incoming && incoming.publicHost !== null && incoming.publicHost !== undefined) ? String(incoming.publicHost).trim() : (cur.publicHost || ''),
allowlist: (incoming && Array.isArray(incoming.allowlist)) allowlist: (incoming && Array.isArray(incoming.allowlist))
? incoming.allowlist.map((x) => String(x).trim()).filter(Boolean) ? incoming.allowlist.map((x) => String(x).trim()).filter(Boolean)
: _diagAllowlist(cur), : _diagAllowlist(cur),
label: (incoming && incoming.label != null) ? String(incoming.label) : cur.label label: (incoming && incoming.label !== null && incoming.label !== undefined) ? String(incoming.label) : cur.label
}; };
next.bindAddress = _diagBindHost(next); next.bindAddress = _diagBindHost(next);
const gs = { ...cfg.globalSettings, diagnostics: next }; const gs = { ...cfg.globalSettings, diagnostics: next };
+603 -959
View File
File diff suppressed because it is too large Load Diff
+7 -6
View File
@@ -1,6 +1,6 @@
{ {
"name": "multi-hoster-uploader", "name": "multi-hoster-uploader",
"version": "2.1.11", "version": "2.1.12",
"description": "Upload files to doodstream, voe, vidmoly, byse simultaneously", "description": "Upload files to doodstream, voe, vidmoly, byse simultaneously",
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {
@@ -8,6 +8,7 @@
"dev": "node scripts/dev-runner.cjs", "dev": "node scripts/dev-runner.cjs",
"test": "node --test tests/*.test.js tests/ui-smoke.js", "test": "node --test tests/*.test.js tests/ui-smoke.js",
"test:backup-api": "npm --prefix services/backup-api test", "test:backup-api": "npm --prefix services/backup-api test",
"verify": "npm run lint && npm test && npm run test:backup-api && npm audit --omit=dev",
"lint": "eslint .", "lint": "eslint .",
"dist": "electron-builder --win", "dist": "electron-builder --win",
"release:win": "electron-builder --publish never --win nsis portable" "release:win": "electron-builder --publish never --win nsis portable"
@@ -15,13 +16,13 @@
"dependencies": { "dependencies": {
"chokidar": "^3.6.0", "chokidar": "^3.6.0",
"undici": "^7.29.0", "undici": "^7.29.0",
"ws": "^8.21.0" "ws": "^8.21.3"
}, },
"devDependencies": { "devDependencies": {
"electron": "^41.3.0", "electron": "^41.10.5",
"electron-builder": "^26.8.1", "electron-builder": "^26.15.3",
"eslint": "^10.1.0", "eslint": "^10.8.1",
"eslint-plugin-security": "^4.0.0", "eslint-plugin-security": "^4.0.1",
"rcedit": "^4.0.1" "rcedit": "^4.0.1"
}, },
"build": { "build": {
+2 -1
View File
@@ -4,6 +4,7 @@ contextBridge.exposeInMainWorld('api', {
// Config // Config
getConfig: () => ipcRenderer.invoke('get-config'), getConfig: () => ipcRenderer.invoke('get-config'),
saveConfig: (config) => ipcRenderer.invoke('save-config', config), saveConfig: (config) => ipcRenderer.invoke('save-config', config),
getSecretStoreStatus: () => ipcRenderer.invoke('secret-store:status'),
getHistory: () => ipcRenderer.invoke('get-history'), getHistory: () => ipcRenderer.invoke('get-history'),
clearHistory: () => ipcRenderer.invoke('clear-history'), clearHistory: () => ipcRenderer.invoke('clear-history'),
pruneHistory: (retention, opts) => ipcRenderer.invoke('prune-history', { retention, dryRun: !!(opts && opts.dryRun) }), pruneHistory: (retention, opts) => ipcRenderer.invoke('prune-history', { retention, dryRun: !!(opts && opts.dryRun) }),
@@ -75,7 +76,7 @@ contextBridge.exposeInMainWorld('api', {
}, },
// Backup // Backup
exportBackup: () => ipcRenderer.invoke('export-backup'), exportBackup: (options) => ipcRenderer.invoke('export-backup', options),
importBackup: (legacyPassword) => ipcRenderer.invoke('import-backup', legacyPassword), importBackup: (legacyPassword) => ipcRenderer.invoke('import-backup', legacyPassword),
createOnlineBackup: () => ipcRenderer.invoke('online-backup:create'), createOnlineBackup: () => ipcRenderer.invoke('online-backup:create'),
restoreOnlineBackup: (key) => ipcRenderer.invoke('online-backup:restore', key), restoreOnlineBackup: (key) => ipcRenderer.invoke('online-backup:restore', key),
+183 -58
View File
@@ -349,7 +349,7 @@ let _queueFilterCache = { filter: '', source: null, result: [] };
let historyRowsData = []; let historyRowsData = [];
let historySortState = { key: 'date', direction: 'desc' }; let historySortState = { key: 'date', direction: 'desc' };
let _historySortClicked = false; let _historySortClicked = false;
let historySidebarCounts = { total: 0, success: 0, error: 0 }; let historySidebarCounts = { total: 0, success: 0, error: 0, skipped: 0 };
let accountSidebarFilter = 'all'; let accountSidebarFilter = 'all';
let historySidebarFilter = 'all'; let historySidebarFilter = 'all';
let _knownUpdateInfo = null; let _knownUpdateInfo = null;
@@ -357,6 +357,8 @@ let _updateCheckBusy = false;
let _updateInstallBusy = false; let _updateInstallBusy = false;
let _updateDialogReturnFocus = null; let _updateDialogReturnFocus = null;
let _updateDialogInertState = []; let _updateDialogInertState = [];
let _startupAutoResumeController = null;
let _startupAutoResumeCanceled = false;
// Session-specific files for the "Files" panel (resets each session) // Session-specific files for the "Files" panel (resets each session)
let sessionFilesData = []; let sessionFilesData = [];
@@ -386,7 +388,12 @@ window.addEventListener('unhandledrejection', (e) => {
// --- Init --- // --- Init ---
async function init() { async function init() {
try {
config = await window.api.getConfig(); config = await window.api.getConfig();
} catch (error) {
await showAppAlert(error.message || String(error), 'Zugangsdaten gesperrt');
throw error;
}
setUiLanguage(config.globalSettings?.language); setUiLanguage(config.globalSettings?.language);
hosterSettings = config.hosterSettings || {}; hosterSettings = config.hosterSettings || {};
autoHealthCheckEnabled = loadAutoCheckPreference(); autoHealthCheckEnabled = loadAutoCheckPreference();
@@ -553,6 +560,7 @@ async function init() {
} catch {} } catch {}
scheduleStartupAccountCheck(); scheduleStartupAccountCheck();
scheduleRestoredQueueAutoStart();
} }
// --- Tab switching --- // --- Tab switching ---
@@ -659,7 +667,7 @@ function initMenuBar() {
void panel.offsetHeight; void panel.offsetHeight;
panel.classList.add('menu-closing'); panel.classList.add('menu-closing');
const finish = () => { const finish = () => {
if (panelTokens.get(panel) !== token) return; if (!Object.is(panelTokens.get(panel), token)) return;
panel.style.display = 'none'; panel.style.display = 'none';
panel.classList.remove('menu-closing'); panel.classList.remove('menu-closing');
}; };
@@ -1597,6 +1605,54 @@ function buildQueuePreview() {
persistQueueStateSoon(); persistQueueStateSoon();
} }
function hideStartupResumeBanner() {
const banner = document.getElementById('startupResumeBanner');
if (banner) banner.hidden = true;
}
function cancelStartupQueueAutoStart() {
_startupAutoResumeCanceled = true;
_startupAutoResumeController?.cancel();
hideStartupResumeBanner();
}
async function startRestoredQueueAfterChecks(jobIds) {
const message = document.getElementById('startupResumeMessage');
async function waitForAccountCheck() {
if (!healthCheckRunning || _startupAutoResumeCanceled) return;
if (message) message.textContent = localizeUiText('Warte auf Account-Prüfung…');
await new Promise(resolve => setTimeout(resolve, 250));
return waitForAccountCheck();
}
await waitForAccountCheck();
hideStartupResumeBanner();
if (_startupAutoResumeCanceled || uploading || config?.globalSettings?.autoStartRestoredQueue !== true) return;
const jobs = window.AutoResume.getAutoResumeJobs(queueJobs, jobIds);
if (!jobs.length) return;
await startSelectedUpload(jobs);
}
function scheduleRestoredQueueAutoStart() {
if (config?.globalSettings?.autoStartRestoredQueue !== true || !window.AutoResume) return;
const jobs = window.AutoResume.getAutoResumeJobs(queueJobs);
if (!jobs.length) return;
_startupAutoResumeCanceled = false;
const banner = document.getElementById('startupResumeBanner');
const message = document.getElementById('startupResumeMessage');
const cancelButton = document.getElementById('cancelStartupResumeBtn');
_startupAutoResumeController = window.AutoResume.createAutoResumeController({
delaySeconds: 8,
onTick: (seconds, count) => {
if (banner) banner.hidden = false;
if (message) message.textContent = localizeUiText(`Wiederhergestellte Warteschlange startet in ${seconds} s (${count} Jobs).`);
},
onStart: jobIds => { startRestoredQueueAfterChecks(jobIds); },
onCancel: hideStartupResumeBanner
});
if (cancelButton) cancelButton.onclick = cancelStartupQueueAutoStart;
_startupAutoResumeController.schedule(jobs.map(job => job.id));
}
// --- Job Index Management --- // --- Job Index Management ---
function rebuildJobIndex() { function rebuildJobIndex() {
_jobIndexById.clear(); _jobIndexById.clear();
@@ -2332,14 +2388,48 @@ function copySelectedRecentLinks() {
// --- Backup export / import --- // --- Backup export / import ---
async function doBackupExport() { async function doBackupExport() {
try { try {
const protectInput = document.getElementById('protectLocalBackupInput');
let password;
if (protectInput?.checked) {
const passwordInput = document.getElementById('localBackupPasswordInput');
const confirmInput = document.getElementById('localBackupPasswordConfirmInput');
password = passwordInput?.value || '';
if (password.length < 8) {
await showAppAlert('Das Backup-Passwort muss mindestens 8 Zeichen lang sein.', 'Passwort prüfen');
passwordInput?.focus();
return;
}
if (!secretsMatch(password, confirmInput?.value || '')) {
await showAppAlert('Die beiden Backup-Passwörter stimmen nicht überein.', 'Passwort prüfen');
confirmInput?.focus();
return;
}
}
await flushPendingSettingsSaves(); await flushPendingSettingsSaves();
const result = await window.api.exportBackup(); const result = await window.api.exportBackup(password === undefined ? {} : { password });
if (result && result.ok) showCopyToast('Backup exportiert'); if (result && result.ok) {
const passwordInput = document.getElementById('localBackupPasswordInput');
const confirmInput = document.getElementById('localBackupPasswordConfirmInput');
if (passwordInput) passwordInput.value = '';
if (confirmInput) confirmInput.value = '';
showCopyToast('Backup exportiert');
}
} catch (err) { } catch (err) {
await showAppAlert('Export fehlgeschlagen: ' + (err.message || err), 'Export fehlgeschlagen'); await showAppAlert('Export fehlgeschlagen: ' + (err.message || err), 'Export fehlgeschlagen');
} }
} }
function secretsMatch(left, right) {
const a = String(left || '');
const b = String(right || '');
const length = Math.max(a.length, b.length);
let difference = a.length ^ b.length;
for (let index = 0; index < length; index++) {
difference |= (a.charCodeAt(index) || 0) ^ (b.charCodeAt(index) || 0);
}
return difference === 0;
}
function applyImportedConfig(importedConfig, message) { function applyImportedConfig(importedConfig, message) {
const pendingQueue = buildPersistedQueueState(); const pendingQueue = buildPersistedQueueState();
config = { config = {
@@ -2745,6 +2835,7 @@ function serializeUploadJob(job) {
async function startUpload(opts) { async function startUpload(opts) {
if (uploading) return; if (uploading) return;
if (!(opts && opts._restoredAutoStart)) cancelStartupQueueAutoStart();
if (!(opts && opts._autoRetry)) _cancelAutoRetry(true); if (!(opts && opts._autoRetry)) _cancelAutoRetry(true);
else _cancelAutoRetry(false); else _cancelAutoRetry(false);
uploading = true; // set immediately to prevent double-click race uploading = true; // set immediately to prevent double-click race
@@ -2814,7 +2905,7 @@ function _markSkippedJobs(result) {
for (const skipped of result.skippedJobs) { for (const skipped of result.skippedJobs) {
const job = _jobIndexById.get(skipped.jobId); const job = _jobIndexById.get(skipped.jobId);
if (job) { if (job) {
job.status = 'error'; job.status = 'skipped';
job.error = skipped.reason || 'Kein gültiger Account'; job.error = skipped.reason || 'Kein gültiger Account';
} }
} }
@@ -3240,7 +3331,7 @@ function _retryFailedFromBuckets(buckets, transientOnly) {
if (toRetry.length === 0) return; if (toRetry.length === 0) return;
const jobsToRetry = []; const jobsToRetry = [];
for (const item of toRetry) { for (const item of toRetry) {
const job = queueJobs.find(j => (j.fileName === item.fileName) && (j.hoster === item.hoster) && (j.status === 'error' || j.status === 'skipped')); const job = queueJobs.find(j => (j.fileName === item.fileName) && (j.hoster === item.hoster) && j.status === 'error');
if (job) { if (job) {
job.status = 'queued'; job.status = 'queued';
job.progress = 0; job.progress = 0;
@@ -3565,6 +3656,9 @@ function applySummaryResults(summary) {
} else if (result.status === 'error') { } else if (result.status === 'error') {
job.status = 'error'; job.status = 'error';
job.error = result.error || 'Fehlgeschlagen'; job.error = result.error || 'Fehlgeschlagen';
} else if (result.status === 'skipped') {
job.status = 'skipped';
job.error = result.error || 'Übersprungen';
} }
maybeAddSessionFile(job); maybeAddSessionFile(job);
} }
@@ -3580,38 +3674,7 @@ function applySummaryResults(summary) {
let _queueStatsCache = null; let _queueStatsCache = null;
function _computeQueueStats() { function _computeQueueStats() {
if (_queueStatsCache) return _queueStatsCache; if (_queueStatsCache) return _queueStatsCache;
_queueStatsCache = window.QueueStats.calculateQueueStats(queueJobs);
let remaining = 0, inProgress = 0, done = 0, errors = 0;
let bytesRemaining = 0, totalSize = 0, remainingSize = 0, inProgressBytes = 0;
const total = queueJobs.length;
for (let i = 0; i < total; i++) {
const job = queueJobs[i];
const s = job.status;
const bt = job.bytesTotal || 0;
const bu = job.bytesUploaded || 0;
totalSize += bt;
if (s === 'uploading' || s === 'getting-server' || s === 'retrying') {
inProgress++;
remaining++;
inProgressBytes += bu;
bytesRemaining += Math.max(0, bt - bu);
remainingSize += Math.max(0, bt - bu);
} else if (s === 'preview' || s === 'queued') {
remaining++;
bytesRemaining += Math.max(0, bt - bu);
remainingSize += Math.max(0, bt - bu);
} else if (s === 'done') {
done++;
} else if (s === 'error') {
errors++;
} else if (s !== 'skipped') {
remainingSize += Math.max(0, bt - bu);
}
}
_queueStatsCache = { total, remaining, inProgress, done, errors, bytesRemaining, totalSize, remainingSize, inProgressBytes };
(typeof queueMicrotask === 'function' ? queueMicrotask : (fn) => Promise.resolve().then(fn))(() => { _queueStatsCache = null; }); (typeof queueMicrotask === 'function' ? queueMicrotask : (fn) => Promise.resolve().then(fn))(() => { _queueStatsCache = null; });
return _queueStatsCache; return _queueStatsCache;
} }
@@ -3765,7 +3828,7 @@ function setAccountSidebarFilter(value) {
} }
function setHistorySidebarFilter(value) { function setHistorySidebarFilter(value) {
if (!['all', 'success', 'error'].includes(value)) return; if (!['all', 'success', 'error', 'skipped'].includes(value)) return;
historySidebarFilter = value; historySidebarFilter = value;
_syncSidebarFilterButtons('[data-history-filter]', 'historyFilter', value); _syncSidebarFilterButtons('[data-history-filter]', 'historyFilter', value);
const container = document.getElementById('historyContainer'); const container = document.getElementById('historyContainer');
@@ -3779,6 +3842,7 @@ function updateHistorySidebarSummary() {
_setSidebarCount('historySidebarAllCount', historySidebarCounts.total); _setSidebarCount('historySidebarAllCount', historySidebarCounts.total);
_setSidebarCount('historySidebarSuccessCount', historySidebarCounts.success); _setSidebarCount('historySidebarSuccessCount', historySidebarCounts.success);
_setSidebarCount('historySidebarErrorCount', historySidebarCounts.error); _setSidebarCount('historySidebarErrorCount', historySidebarCounts.error);
_setSidebarCount('historySidebarSkippedCount', historySidebarCounts.skipped);
const retention = document.getElementById('historySidebarRetention'); const retention = document.getElementById('historySidebarRetention');
const select = document.getElementById('historyRetentionSelect'); const select = document.getElementById('historyRetentionSelect');
const labels = { const labels = {
@@ -4191,6 +4255,14 @@ function renderSettings() {
<input type="checkbox" class="settings-autosave" id="showDropTargetInput" ${globalSettings.showDropTarget ? 'checked' : ''}> <input type="checkbox" class="settings-autosave" id="showDropTargetInput" ${globalSettings.showDropTarget ? 'checked' : ''}>
</div> </div>
</div> </div>
<div class="settings-option credential-fallback-option">
<div class="settings-option-copy">
<label for="allowPlaintextCredentialStorageInput">Unsichere Klartext-Speicherung erlauben</label>
<span class="settings-option-description">Nur verwenden, wenn der sichere Betriebssystem-Speicher dauerhaft nicht verfügbar ist. Passwörter und API-Keys liegen dann lesbar in der Konfigurationsdatei.</span>
<span class="panel-status" id="secretStoreStatus">Prüfe</span>
</div>
<input type="checkbox" class="settings-autosave" id="allowPlaintextCredentialStorageInput" ${globalSettings.allowPlaintextCredentialStorage ? 'checked' : ''}>
</div>
<div class="settings-section-label">Programmupdate</div> <div class="settings-section-label">Programmupdate</div>
<div class="settings-row program-update-row program-update-card"> <div class="settings-row program-update-row program-update-card">
<div class="program-update-copy"> <div class="program-update-copy">
@@ -4236,6 +4308,13 @@ function renderSettings() {
</div> </div>
<input type="checkbox" class="settings-autosave" id="resumeQueueOnLaunchInput" ${globalSettings.resumeQueueOnLaunch === false ? '' : 'checked'}> <input type="checkbox" class="settings-autosave" id="resumeQueueOnLaunchInput" ${globalSettings.resumeQueueOnLaunch === false ? '' : 'checked'}>
</div> </div>
<div class="settings-option">
<div class="settings-option-copy">
<label for="autoStartRestoredQueueInput">Wiederhergestellte Warteschlange automatisch starten</label>
<span class="settings-option-description">Startet wartende Uploads nach einem sichtbaren, abbrechbaren Countdown. Fehler und übersprungene Einträge bleiben unangetastet.</span>
</div>
<input type="checkbox" class="settings-autosave" id="autoStartRestoredQueueInput" ${globalSettings.autoStartRestoredQueue ? 'checked' : ''}>
</div>
<div class="settings-section-label">Quelldateien</div> <div class="settings-section-label">Quelldateien</div>
<div class="settings-option source-delete-option"> <div class="settings-option source-delete-option">
<div class="settings-option-copy"> <div class="settings-option-copy">
@@ -4293,6 +4372,10 @@ function renderSettings() {
<label>Unterordner einbeziehen</label> <label>Unterordner einbeziehen</label>
<input type="checkbox" class="settings-autosave" id="fmRecursiveInput" ${fm.recursive ? 'checked' : ''}> <input type="checkbox" class="settings-autosave" id="fmRecursiveInput" ${fm.recursive ? 'checked' : ''}>
</div> </div>
<div class="settings-row checkbox-row">
<label>Vorhandene Dateien einmalig einlesen</label>
<input type="checkbox" class="settings-autosave" id="fmIncludeExistingInput" ${fm.includeExisting ? 'checked' : ''}>
</div>
<div class="settings-row checkbox-row"> <div class="settings-row checkbox-row">
<label>Duplikate überspringen</label> <label>Duplikate überspringen</label>
<input type="checkbox" class="settings-autosave" id="fmSkipDuplicatesInput" ${fm.skipDuplicates !== false ? 'checked' : ''}> <input type="checkbox" class="settings-autosave" id="fmSkipDuplicatesInput" ${fm.skipDuplicates !== false ? 'checked' : ''}>
@@ -4472,6 +4555,17 @@ function renderSettings() {
<div class="online-backup-status" id="onlineBackupStatus" role="status" aria-live="polite"></div> <div class="online-backup-status" id="onlineBackupStatus" role="status" aria-live="polite"></div>
</section> </section>
<div class="settings-section-label">Lokales Datei-Backup</div> <div class="settings-section-label">Lokales Datei-Backup</div>
<div class="settings-row local-backup-password-row">
<div class="settings-copy">
<label for="protectLocalBackupInput">Mit eigenem Passwort schützen</label>
<span class="hint">Ohne dieses Passwort kann das Backup nicht importiert werden.</span>
</div>
<input type="checkbox" id="protectLocalBackupInput">
</div>
<div class="local-backup-password-fields" id="localBackupPasswordFields" hidden>
<input type="password" class="key-input" id="localBackupPasswordInput" minlength="8" maxlength="1024" autocomplete="new-password" placeholder="Passwort (mindestens 8 Zeichen)">
<input type="password" class="key-input" id="localBackupPasswordConfirmInput" minlength="8" maxlength="1024" autocomplete="new-password" placeholder="Passwort wiederholen">
</div>
<div class="backup-file-actions"> <div class="backup-file-actions">
<button class="btn btn-secondary" id="exportBackupBtn">Datei exportieren</button> <button class="btn btn-secondary" id="exportBackupBtn">Datei exportieren</button>
<button class="btn btn-secondary" id="importBackupBtn">Datei importieren</button> <button class="btn btn-secondary" id="importBackupBtn">Datei importieren</button>
@@ -4752,6 +4846,12 @@ function renderSettings() {
document.getElementById('exportBackupBtn').addEventListener('click', () => doBackupExport()); document.getElementById('exportBackupBtn').addEventListener('click', () => doBackupExport());
document.getElementById('importBackupBtn').addEventListener('click', () => doBackupImport()); document.getElementById('importBackupBtn').addEventListener('click', () => doBackupImport());
document.getElementById('protectLocalBackupInput').addEventListener('change', (event) => {
const fields = document.getElementById('localBackupPasswordFields');
if (!fields) return;
fields.hidden = !event.target.checked;
if (event.target.checked) document.getElementById('localBackupPasswordInput')?.focus();
});
document.getElementById('createOnlineBackupBtn').addEventListener('click', () => doOnlineBackupCreate()); document.getElementById('createOnlineBackupBtn').addEventListener('click', () => doOnlineBackupCreate());
document.getElementById('copyOnlineBackupKeyBtn').addEventListener('click', async () => { document.getElementById('copyOnlineBackupKeyBtn').addEventListener('click', async () => {
const key = document.getElementById('onlineBackupKeyOutput').value; const key = document.getElementById('onlineBackupKeyOutput').value;
@@ -4770,6 +4870,13 @@ function renderSettings() {
document.getElementById('chooseLogFilePathBtn')?.addEventListener('click', chooseLogFilePath); document.getElementById('chooseLogFilePathBtn')?.addEventListener('click', chooseLogFilePath);
document.getElementById('openLogFolderBtn')?.addEventListener('click', () => window.api.openLogFolder()); document.getElementById('openLogFolderBtn')?.addEventListener('click', () => window.api.openLogFolder());
document.getElementById('manualUpdateCheckBtn')?.addEventListener('click', requestUpdateCheck); document.getElementById('manualUpdateCheckBtn')?.addEventListener('click', requestUpdateCheck);
window.api.getSecretStoreStatus().then(result => {
const badge = document.getElementById('secretStoreStatus');
if (!badge) return;
const available = result?.status === 'available';
badge.textContent = available ? 'Sicher verfügbar' : 'Nicht verfügbar';
badge.classList.toggle('active', available);
}).catch(() => {});
_syncHeaderUpdateState(); _syncHeaderUpdateState();
container.querySelectorAll('.settings-autosave').forEach((input) => { container.querySelectorAll('.settings-autosave').forEach((input) => {
const eventName = input.type === 'checkbox' || input.tagName === 'SELECT' ? 'change' : 'input'; const eventName = input.type === 'checkbox' || input.tagName === 'SELECT' ? 'change' : 'input';
@@ -4787,6 +4894,15 @@ function renderSettings() {
}); });
if (!confirmed) input.checked = false; if (!confirmed) input.checked = false;
} }
if (input.id === 'allowPlaintextCredentialStorageInput' && input.checked) {
const confirmed = await showAppConfirm({
title: 'Zugangsdaten unverschlüsselt speichern?',
message: 'Passwörter und API-Keys werden dann im Klartext auf diesem PC gespeichert. Andere Benutzer oder Programme mit Dateizugriff können sie lesen.',
confirmText: 'Klartext-Speicherung erlauben',
danger: true
});
if (!confirmed) input.checked = false;
}
markSettingsDirty(); markSettingsDirty();
}); });
}); });
@@ -4911,6 +5027,8 @@ async function performSaveSettings(options = {}) {
return (v === 'single' || v === 'daily' || v === 'session') ? v : 'single'; return (v === 'single' || v === 'daily' || v === 'session') ? v : 'single';
})(), })(),
resumeQueueOnLaunch: elChk('resumeQueueOnLaunchInput', cur.resumeQueueOnLaunch !== false), resumeQueueOnLaunch: elChk('resumeQueueOnLaunchInput', cur.resumeQueueOnLaunch !== false),
autoStartRestoredQueue: elChk('autoStartRestoredQueueInput', !!cur.autoStartRestoredQueue),
allowPlaintextCredentialStorage: elChk('allowPlaintextCredentialStorageInput', !!cur.allowPlaintextCredentialStorage),
parallelUploadCount: elInt('parallelUploadCountInput', cur.parallelUploadCount ?? 0, 0, 0, 100), parallelUploadCount: elInt('parallelUploadCountInput', cur.parallelUploadCount ?? 0, 0, 0, 100),
scaleParallelUploads: elChk('scaleParallelUploadsInput', !!cur.scaleParallelUploads), scaleParallelUploads: elChk('scaleParallelUploadsInput', !!cur.scaleParallelUploads),
removeFromQueueOnDone: elChk('removeFromQueueOnDoneInput', !!cur.removeFromQueueOnDone), removeFromQueueOnDone: elChk('removeFromQueueOnDoneInput', !!cur.removeFromQueueOnDone),
@@ -4931,6 +5049,7 @@ async function performSaveSettings(options = {}) {
enabled: elChk('fmEnabledInput', !!curFm.enabled), enabled: elChk('fmEnabledInput', !!curFm.enabled),
folderPath: elTxt('fmFolderPathInput', curFm.folderPath || '').trim(), folderPath: elTxt('fmFolderPathInput', curFm.folderPath || '').trim(),
recursive: elChk('fmRecursiveInput', !!curFm.recursive), recursive: elChk('fmRecursiveInput', !!curFm.recursive),
includeExisting: elChk('fmIncludeExistingInput', !!curFm.includeExisting),
filterMode: (() => { const el = document.getElementById('fmFilterModeInput'); return el ? (el.value || 'include') : (curFm.filterMode || 'include'); })(), filterMode: (() => { const el = document.getElementById('fmFilterModeInput'); return el ? (el.value || 'include') : (curFm.filterMode || 'include'); })(),
extensions: elTxt('fmExtensionsInput', curFm.extensions || '').trim(), extensions: elTxt('fmExtensionsInput', curFm.extensions || '').trim(),
skipDuplicates: elChk('fmSkipDuplicatesInput', curFm.skipDuplicates !== false), skipDuplicates: elChk('fmSkipDuplicatesInput', curFm.skipDuplicates !== false),
@@ -5003,7 +5122,14 @@ async function performSaveSettings(options = {}) {
const badge = document.getElementById('folderMonitorStatusBadge'); const badge = document.getElementById('folderMonitorStatusBadge');
if (fmSettings && fmSettings.enabled && fmSettings.folderPath) { if (fmSettings && fmSettings.enabled && fmSettings.folderPath) {
try { try {
await window.api.folderMonitorStart(fmSettings); const folderStart = await window.api.folderMonitorStart(fmSettings);
if (folderStart?.includesExisting) {
fmSettings.includeExisting = false;
globalSettings.folderMonitor.includeExisting = false;
config.globalSettings.folderMonitor.includeExisting = false;
const includeExistingInput = document.getElementById('fmIncludeExistingInput');
if (includeExistingInput) includeExistingInput.checked = false;
}
if (badge) { badge.textContent = 'Aktiv'; badge.className = 'panel-status active'; } if (badge) { badge.textContent = 'Aktiv'; badge.className = 'panel-status active'; }
} catch { } catch {
if (badge) { badge.textContent = 'Fehler'; badge.className = 'panel-status'; } if (badge) { badge.textContent = 'Fehler'; badge.className = 'panel-status'; }
@@ -6169,7 +6295,7 @@ async function loadHistory() {
history = await window.api.getHistory(); history = await window.api.getHistory();
} catch (error) { } catch (error) {
historyRowsData = []; historyRowsData = [];
historySidebarCounts = { total: 0, success: 0, error: 0 }; historySidebarCounts = { total: 0, success: 0, error: 0, skipped: 0 };
updateHistorySidebarSummary(); updateHistorySidebarSummary();
syncHistoryClearAction(); syncHistoryClearAction();
if (container) container.innerHTML = `<div class="empty-state history-error-state" role="alert"><strong>${escapeHtml(localizeUiText('Verlauf konnte nicht geladen werden.'))}</strong><span>${escapeHtml(error?.message || String(error))}</span><button class="btn btn-secondary" type="button" data-retry-history>${escapeHtml(localizeUiText('Erneut versuchen'))}</button></div>`; if (container) container.innerHTML = `<div class="empty-state history-error-state" role="alert"><strong>${escapeHtml(localizeUiText('Verlauf konnte nicht geladen werden.'))}</strong><span>${escapeHtml(error?.message || String(error))}</span><button class="btn btn-secondary" type="button" data-retry-history>${escapeHtml(localizeUiText('Erneut versuchen'))}</button></div>`;
@@ -6187,7 +6313,7 @@ async function loadHistory() {
} }
if (!history || history.length === 0) { if (!history || history.length === 0) {
historyRowsData = []; historyRowsData = [];
historySidebarCounts = { total: 0, success: 0, error: 0 }; historySidebarCounts = { total: 0, success: 0, error: 0, skipped: 0 };
updateHistorySidebarSummary(); updateHistorySidebarSummary();
syncHistoryClearAction(); syncHistoryClearAction();
container.innerHTML = '<p class="empty-state">Noch keine Uploads.</p>'; container.innerHTML = '<p class="empty-state">Noch keine Uploads.</p>';
@@ -6196,7 +6322,7 @@ async function loadHistory() {
historySortState = { key: 'date', direction: 'desc' }; historySortState = { key: 'date', direction: 'desc' };
historyRowsData = []; historyRowsData = [];
historySidebarCounts = { total: 0, success: 0, error: 0 }; historySidebarCounts = { total: 0, success: 0, error: 0, skipped: 0 };
let order = 0; let order = 0;
for (const batch of history) { for (const batch of history) {
@@ -6204,17 +6330,15 @@ async function loadHistory() {
for (const file of (batch.files || [])) { for (const file of (batch.files || [])) {
for (const result of (file.results || [])) { for (const result of (file.results || [])) {
historySidebarCounts.total++; historySidebarCounts.total++;
const isError = result.status === 'aborted' || result.status === 'error'; const category = window.HistoryStatus.classifyHistoryStatus(result.status);
if (isError) historySidebarCounts.error++; historySidebarCounts[category]++;
else historySidebarCounts.success++; const detail = window.HistoryStatus.historyDetail(result);
const detail = isError
? String(result.error || result.message || (result.status === 'aborted' ? 'Abgebrochen' : 'Fehlgeschlagen'))
: (result.download_url || result.embed_url || '');
historyRowsData.push({ historyRowsData.push({
date: dt.text, dateTs: dt.ts, rawTimestamp: batch.timestamp, date: dt.text, dateTs: dt.ts, rawTimestamp: batch.timestamp,
filename: file.name || '', host: result.hoster || '', filename: file.name || '', host: result.hoster || '',
link: detail, link: detail,
isError, order: order++ status: result.status || '', category,
isError: category === 'error', order: order++
}); });
} }
} }
@@ -6331,7 +6455,7 @@ function _renderRecentVirtualRows() {
tbody.innerHTML = parts.join(''); tbody.innerHTML = parts.join('');
} }
function renderRecentUploadsPanel(appendOnly = false) { function renderRecentUploadsPanel(_appendOnly = false) {
const tbody = document.getElementById('recentFilesBody'); const tbody = document.getElementById('recentFilesBody');
if (!tbody) return; if (!tbody) return;
_recentPendingAppends = 0; _recentPendingAppends = 0;
@@ -6468,7 +6592,7 @@ function _renderHistoryVirtualRows() {
const row = _historyWorking[i]; const row = _historyWorking[i];
const link = row.link || ''; const link = row.link || '';
parts.push('<tr class="history-row'); parts.push('<tr class="history-row');
if (row.isError) parts.push(' error'); parts.push(` ${row.category}`);
parts.push('" data-link="'); parts.push('" data-link="');
parts.push(escapeAttr(link)); parts.push(escapeAttr(link));
parts.push(`" style="height:${VIRTUAL_ROW_HEIGHT}px"><td class="col-date">`); parts.push(`" style="height:${VIRTUAL_ROW_HEIGHT}px"><td class="col-date">`);
@@ -6481,15 +6605,16 @@ function _renderHistoryVirtualRows() {
parts.push(escapeAttr(link)); parts.push(escapeAttr(link));
parts.push('">'); parts.push('">');
parts.push(escapeHtml(link)); parts.push(escapeHtml(link));
parts.push('</span><button class="history-copy-link" type="button" data-copy-link aria-label="Link kopieren" title="Link kopieren">⧉</button></div></td></tr>'); parts.push('</span>');
if (row.category === 'success' && link) parts.push('<button class="history-copy-link" type="button" data-copy-link aria-label="Link kopieren" title="Link kopieren">⧉</button>');
parts.push('</div></td></tr>');
} }
if (bottomPad > 0) parts.push(`<tr class="virtual-spacer" style="height:${bottomPad}px"><td colspan="4"></td></tr>`); if (bottomPad > 0) parts.push(`<tr class="virtual-spacer" style="height:${bottomPad}px"><td colspan="4"></td></tr>`);
tbody.innerHTML = parts.join(''); tbody.innerHTML = parts.join('');
} }
function _getVisibleHistoryRows() { function _getVisibleHistoryRows() {
if (historySidebarFilter === 'success') return historyRowsData.filter(row => !row.isError); if (historySidebarFilter !== 'all') return historyRowsData.filter(row => row.category === historySidebarFilter);
if (historySidebarFilter === 'error') return historyRowsData.filter(row => row.isError);
return historyRowsData; return historyRowsData;
} }
@@ -6527,7 +6652,7 @@ function renderHistoryTable(container) {
}; };
container.innerHTML = `<table class="results-table history-table"><thead><tr> container.innerHTML = `<table class="results-table history-table"><thead><tr>
${headerCell('date', 'Datum')}${headerCell('filename', 'Dateiname')}${headerCell('host', 'Hoster')}${headerCell('link', 'Link')} ${headerCell('date', 'Datum')}${headerCell('filename', 'Dateiname')}${headerCell('host', 'Hoster')}${headerCell('link', 'Ergebnis')}
</tr></thead><tbody id="historyBody"></tbody></table>`; </tr></thead><tbody id="historyBody"></tbody></table>`;
if (!_historyListenersBound) { if (!_historyListenersBound) {
@@ -6558,7 +6683,7 @@ function renderHistoryTable(container) {
return; return;
} }
const row = e.target.closest('.history-row'); const row = e.target.closest('.history-row');
if (row && !row.classList.contains('error')) { if (row && row.classList.contains('success')) {
const link = row.dataset.link; const link = row.dataset.link;
if (link) { window.api.copyToClipboard(link); showCopyToast('Link kopiert'); } if (link) { window.api.copyToClipboard(link); showCopyToast('Link kopiert'); }
} }
@@ -7666,7 +7791,7 @@ function formatDuration(seconds) {
function updateStatsPanel() { function updateStatsPanel() {
const stats = _computeQueueStats(); const stats = _computeQueueStats();
const remaining = stats.total - stats.done - stats.errors; const remaining = stats.remaining;
const el = (id) => document.getElementById(id); const el = (id) => document.getElementById(id);
if (el('statQueueTotal')) el('statQueueTotal').textContent = stats.total; if (el('statQueueTotal')) el('statQueueTotal').textContent = stats.total;
+66
View File
@@ -0,0 +1,66 @@
(function (root, factory) {
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
if (root) root.AutoResume = api;
})(typeof window !== 'undefined' ? window : globalThis, function () {
function getAutoResumeJobs(jobs, jobIds) {
const allowedJobIds = Array.isArray(jobIds) ? new Set(jobIds) : null;
return Array.isArray(jobs)
? jobs.filter(job => job && (job.status === 'queued' || job.status === 'preview') && job.file && job.hoster && (!allowedJobIds || allowedJobIds.has(job.id)))
: [];
}
function createAutoResumeController(options = {}) {
const delaySeconds = Math.max(1, Number(options.delaySeconds) || 8);
const setIntervalFn = options.setIntervalFn || setInterval;
const clearIntervalFn = options.clearIntervalFn || clearInterval;
const onTick = options.onTick || (() => {});
const onStart = options.onStart || (() => {});
const onCancel = options.onCancel || (() => {});
let timer = null;
let pending = false;
function stopTimer() {
if (timer === null) return;
clearIntervalFn(timer);
timer = null;
}
function schedule(jobIds) {
if (pending || !Array.isArray(jobIds)) return false;
const plannedJobIds = [...new Set(jobIds.filter(jobId => typeof jobId === 'string' && jobId))];
if (!plannedJobIds.length) return false;
pending = true;
let remaining = delaySeconds;
onTick(remaining, plannedJobIds.length);
timer = setIntervalFn(() => {
if (!pending) return;
remaining--;
if (remaining > 0) {
onTick(remaining, plannedJobIds.length);
return;
}
pending = false;
stopTimer();
onStart(plannedJobIds);
}, 1000);
return true;
}
function cancel() {
if (!pending) return false;
pending = false;
stopTimer();
onCancel();
return true;
}
return {
schedule,
cancel,
get pending() { return pending; }
};
}
return { getAutoResumeJobs, createAutoResumeController };
});
+22
View File
@@ -0,0 +1,22 @@
(function (root, factory) {
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
if (root) root.HistoryStatus = api;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
function classifyHistoryStatus(status) {
if (status === 'done') return 'success';
if (status === 'skipped') return 'skipped';
return 'error';
}
function historyDetail(result) {
const category = classifyHistoryStatus(result?.status);
if (category === 'success') return String(result?.download_url || result?.embed_url || '');
if (result?.error || result?.message) return String(result.error || result.message);
if (result?.status === 'aborted') return 'Abgebrochen';
if (category === 'skipped') return 'Übersprungen';
return 'Fehlgeschlagen';
}
return { classifyHistoryStatus, historyDetail };
});
+38
View File
@@ -188,6 +188,9 @@
['Nach Abschluss aus der Liste entfernen', 'Remove from the list after completion'], ['Nach Abschluss aus der Liste entfernen', 'Remove from the list after completion'],
['Erfolgreich hochgeladene Dateien verschwinden automatisch aus der Upload-Liste.', 'Successfully uploaded files are automatically removed from the upload list.'], ['Erfolgreich hochgeladene Dateien verschwinden automatisch aus der Upload-Liste.', 'Successfully uploaded files are automatically removed from the upload list.'],
['Warteschlange beim Start wiederherstellen', 'Restore queue on startup'], ['Warteschlange beim Start wiederherstellen', 'Restore queue on startup'],
['Wiederhergestellte Warteschlange automatisch starten', 'Start restored queue automatically'],
['Startet wartende Uploads nach einem sichtbaren, abbrechbaren Countdown. Fehler und übersprungene Einträge bleiben unangetastet.', 'Starts waiting uploads after a visible, cancelable countdown. Failed and skipped entries remain untouched.'],
['Warte auf Account-Prüfung…', 'Waiting for account check…'],
['Noch nicht abgeschlossene Uploads werden beim nächsten Programmstart erneut angezeigt.', 'Incomplete uploads are shown again the next time the application starts.'], ['Noch nicht abgeschlossene Uploads werden beim nächsten Programmstart erneut angezeigt.', 'Incomplete uploads are shown again the next time the application starts.'],
['Quelldateien', 'Source files'], ['Quelldateien', 'Source files'],
['Quelldatei nach vollständigem Upload dauerhaft löschen', 'Permanently delete source file after complete upload'], ['Quelldatei nach vollständigem Upload dauerhaft löschen', 'Permanently delete source file after complete upload'],
@@ -223,6 +226,7 @@
['Verhalten', 'Behavior'], ['Verhalten', 'Behavior'],
['Aktiviert', 'Enabled'], ['Aktiviert', 'Enabled'],
['Unterordner einbeziehen', 'Include subfolders'], ['Unterordner einbeziehen', 'Include subfolders'],
['Vorhandene Dateien einmalig einlesen', 'Import existing files once'],
['Duplikate überspringen', 'Skip duplicates'], ['Duplikate überspringen', 'Skip duplicates'],
['Auto-Upload starten', 'Start uploads automatically'], ['Auto-Upload starten', 'Start uploads automatically'],
['Hoster-Vorauswahl', 'Host preselection'], ['Hoster-Vorauswahl', 'Host preselection'],
@@ -277,10 +281,18 @@
['Online importieren', 'Import online'], ['Online importieren', 'Import online'],
['Behandle den Schlüssel wie ein Passwort. Wer ihn besitzt, kann die verschlüsselten Einstellungen entschlüsseln.', 'Treat the key like a password. Anyone who has it can decrypt the encrypted settings.'], ['Behandle den Schlüssel wie ein Passwort. Wer ihn besitzt, kann die verschlüsselten Einstellungen entschlüsseln.', 'Treat the key like a password. Anyone who has it can decrypt the encrypted settings.'],
['Lokales Datei-Backup', 'Local file backup'], ['Lokales Datei-Backup', 'Local file backup'],
['Mit eigenem Passwort schützen', 'Protect with your own password'],
['Ohne dieses Passwort kann das Backup nicht importiert werden.', 'The backup cannot be imported without this password.'],
['Passwort (mindestens 8 Zeichen)', 'Password (at least 8 characters)'],
['Passwort wiederholen', 'Repeat password'],
['Das Backup-Passwort muss mindestens 8 Zeichen lang sein.', 'The backup password must be at least 8 characters long.'],
['Die beiden Backup-Passwörter stimmen nicht überein.', 'The two backup passwords do not match.'],
['Passwort prüfen', 'Check password'],
['Datei exportieren', 'Export file'], ['Datei exportieren', 'Export file'],
['Datei importieren', 'Import file'], ['Datei importieren', 'Import file'],
['Alle Uploads', 'All uploads'], ['Alle Uploads', 'All uploads'],
['Erfolgreich', 'Successful'], ['Erfolgreich', 'Successful'],
['Ergebnis', 'Result'],
['Aktive Regel', 'Active rule'], ['Aktive Regel', 'Active rule'],
['Alles behalten', 'Keep everything'], ['Alles behalten', 'Keep everything'],
['Links wiederfinden, kopieren oder als Datei exportieren.', 'Find links, copy them, or export them as a file.'], ['Links wiederfinden, kopieren oder als Datei exportieren.', 'Find links, copy them, or export them as a file.'],
@@ -311,6 +323,7 @@
['Wartende Uploads anzeigen', 'Show queued uploads'], ['Wartende Uploads anzeigen', 'Show queued uploads'],
['Fertige Uploads anzeigen', 'Show completed uploads'], ['Fertige Uploads anzeigen', 'Show completed uploads'],
['Fehlgeschlagene Uploads anzeigen', 'Show failed uploads'], ['Fehlgeschlagene Uploads anzeigen', 'Show failed uploads'],
['Übersprungene Uploads anzeigen', 'Show skipped uploads'],
['Alle Uploads starten', 'Start all uploads'], ['Alle Uploads starten', 'Start all uploads'],
['Ausgewählte Uploads starten', 'Start selected uploads'], ['Ausgewählte Uploads starten', 'Start selected uploads'],
['Ausgewählte Datei erneut hochladen', 'Upload selected file again'], ['Ausgewählte Datei erneut hochladen', 'Upload selected file again'],
@@ -454,6 +467,20 @@
['Keine gültige .mhu Backup-Datei', 'Not a valid .mhu backup file'], ['Keine gültige .mhu Backup-Datei', 'Not a valid .mhu backup file'],
['Falsches Passwort oder beschädigte Datei', 'Incorrect password or damaged file'], ['Falsches Passwort oder beschädigte Datei', 'Incorrect password or damaged file'],
['Dieses Backup wurde mit einem Passwort verschlüsselt', 'This backup was encrypted with a password'], ['Dieses Backup wurde mit einem Passwort verschlüsselt', 'This backup was encrypted with a password'],
['Das Backup-Passwort darf nicht leer sein', 'The backup password must not be empty'],
['Das Backup-Passwort muss zwischen 8 und 1024 Zeichen lang sein', 'The backup password must be between 8 and 1024 characters long'],
['Passwortgeschützte Backups müssen als .mhu gespeichert werden', 'Password-protected backups must be saved as .mhu files'],
['Sicherer Zugangsdaten-Speicher ist nicht verfügbar', 'Secure credential storage is unavailable'],
['Zugangsdaten konnten nicht sicher verschlüsselt werden', 'Credentials could not be encrypted securely'],
['Gespeicherte Zugangsdaten konnten nicht entschlüsselt werden', 'Stored credentials could not be decrypted'],
['Zugangsdaten gesperrt', 'Credentials locked'],
['Unsichere Klartext-Speicherung erlauben', 'Allow insecure plaintext storage'],
['Nur verwenden, wenn der sichere Betriebssystem-Speicher dauerhaft nicht verfügbar ist. Passwörter und API-Keys liegen dann lesbar in der Konfigurationsdatei.', 'Use only when secure operating system storage is permanently unavailable. Passwords and API keys will then be readable in the configuration file.'],
['Sicher verfügbar', 'Securely available'],
['Nicht verfügbar', 'Unavailable'],
['Zugangsdaten unverschlüsselt speichern?', 'Store credentials without encryption?'],
['Passwörter und API-Keys werden dann im Klartext auf diesem PC gespeichert. Andere Benutzer oder Programme mit Dateizugriff können sie lesen.', 'Passwords and API keys will then be stored as plaintext on this PC. Other users or programs with file access can read them.'],
['Klartext-Speicherung erlauben', 'Allow plaintext storage'],
['Die Verlaufsdatei ist beschädigt und wurde nicht verändert', 'The history file is damaged and was not changed'], ['Die Verlaufsdatei ist beschädigt und wurde nicht verändert', 'The history file is damaged and was not changed'],
['Verlauf und Aufbewahrung konnten nicht konsistent gespeichert werden', 'History and retention could not be saved consistently'], ['Verlauf und Aufbewahrung konnten nicht konsistent gespeichert werden', 'History and retention could not be saved consistently'],
['Kein Ordnerpfad angegeben', 'No folder path was provided'], ['Kein Ordnerpfad angegeben', 'No folder path was provided'],
@@ -488,6 +515,14 @@
['SHA-512 Prüfung fehlgeschlagen', 'SHA-512 verification failed'], ['SHA-512 Prüfung fehlgeschlagen', 'SHA-512 verification failed'],
['Vorbereitetes Update ist unvollständig', 'The prepared update is incomplete'], ['Vorbereitetes Update ist unvollständig', 'The prepared update is incomplete'],
['Kein Setup-Asset im Release gefunden', 'No setup asset was found in the release'], ['Kein Setup-Asset im Release gefunden', 'No setup asset was found in the release'],
['Prüfsummen-Metadaten fehlen', 'Checksum metadata is missing'],
['Prüfsummen-Metadaten sind unvollständig', 'Checksum metadata is incomplete'],
['Prüfsummen-Metadaten enthalten keine gültige SHA-512-Prüfsumme', 'Checksum metadata does not contain a valid SHA-512 checksum'],
['Prüfsummen-Metadaten gehören zu einer anderen Version', 'Checksum metadata belongs to a different version'],
['Prüfsummen-Metadaten gehören nicht zum ausgewählten Installer', 'Checksum metadata does not belong to the selected installer'],
['Prüfsummen-Metadaten enthalten eine abweichende Dateigröße', 'Checksum metadata contains a different file size'],
['Heruntergeladene Datei hat eine abweichende Größe', 'The downloaded file has a different size'],
['Die installierte Anwendung ist nicht gültig digital signiert', 'The installed application is not validly digitally signed'],
['Download hängt — seit 45 s keine Daten (Netzwerk/Server überlastet). Bitte laufende Uploads stoppen und erneut versuchen.', 'The download stalled because no data was received for 45 seconds. Stop active uploads and try again.'], ['Download hängt — seit 45 s keine Daten (Netzwerk/Server überlastet). Bitte laufende Uploads stoppen und erneut versuchen.', 'The download stalled because no data was received for 45 seconds. Stop active uploads and try again.'],
['Datei nicht gefunden', 'File not found'], ['Datei nicht gefunden', 'File not found'],
['Netzwerkfehler', 'Network error'], ['Netzwerkfehler', 'Network error'],
@@ -592,6 +627,7 @@
const patterns = target === 'en' const patterns = target === 'en'
? [ ? [
[/^Update v(.+) verfügbar$/, 'Update v$1 available'], [/^Update v(.+) verfügbar$/, 'Update v$1 available'],
[/^Wiederhergestellte Warteschlange startet in (.+) s \((.+) Jobs\)\.$/, 'Restored queue starts in $1 s ($2 jobs).'],
[/^Login ok, Upload-Form bereit \(Dateifeld: (.+)\)$/, 'Login successful, upload form ready (file field: $1)'], [/^Login ok, Upload-Form bereit \(Dateifeld: (.+)\)$/, 'Login successful, upload form ready (file field: $1)'],
[/^Klartext-Backup ist kein gültiges JSON: (.+)$/, 'Plain JSON backup is not valid JSON: $1'], [/^Klartext-Backup ist kein gültiges JSON: (.+)$/, 'Plain JSON backup is not valid JSON: $1'],
[/^Export fehlgeschlagen: (.+)$/, 'Export failed: $1'], [/^Export fehlgeschlagen: (.+)$/, 'Export failed: $1'],
@@ -619,6 +655,8 @@
[/^VOE Upload-Fehler: (.+)$/, 'VOE upload error: $1'], [/^VOE Upload-Fehler: (.+)$/, 'VOE upload error: $1'],
[/^VOE: Upload-Server Antwort war kein JSON: (.+)$/, 'VOE: The upload server response was not JSON: $1'], [/^VOE: Upload-Server Antwort war kein JSON: (.+)$/, 'VOE: The upload server response was not JSON: $1'],
[/^Download fehlgeschlagen: (.+)$/, 'Download failed: $1'], [/^Download fehlgeschlagen: (.+)$/, 'Download failed: $1'],
[/^Prüfsummen-Metadaten konnten nicht geladen werden: (.+)$/, 'Checksum metadata could not be loaded: $1'],
[/^Upload zu (.+) wurde nicht bestätigt$/, 'The upload to $1 was not confirmed'],
[/^Update-Server Antwort war kein JSON (.+)$/, 'Update server response was not JSON $1'], [/^Update-Server Antwort war kein JSON (.+)$/, 'Update server response was not JSON $1'],
[/^(\d+) Links kopiert$/, '$1 links copied'], [/^(\d+) Links kopiert$/, '$1 links copied'],
[/^(\d+) Link kopiert$/, '$1 link copied'], [/^(\d+) Link kopiert$/, '$1 link copied'],
+12
View File
@@ -553,6 +553,11 @@
<span class="view-sidebar-copy">Fehler</span> <span class="view-sidebar-copy">Fehler</span>
<span class="view-sidebar-badge" id="historySidebarErrorCount">0</span> <span class="view-sidebar-badge" id="historySidebarErrorCount">0</span>
</button> </button>
<button class="view-sidebar-item" data-history-filter="skipped" aria-label="Übersprungene Uploads anzeigen">
<svg class="view-sidebar-icon" aria-hidden="true"><use href="#icon-clock"></use></svg>
<span class="view-sidebar-copy">Übersprungen</span>
<span class="view-sidebar-badge" id="historySidebarSkippedCount">0</span>
</button>
</nav> </nav>
<div class="view-sidebar-section"> <div class="view-sidebar-section">
<span class="view-sidebar-section-label">Aufbewahrung</span> <span class="view-sidebar-section-label">Aufbewahrung</span>
@@ -621,6 +626,10 @@
</div> </div>
<div class="copy-toast" id="copyToast" role="status" aria-live="polite" aria-atomic="true"></div> <div class="copy-toast" id="copyToast" role="status" aria-live="polite" aria-atomic="true"></div>
<div class="startup-resume-banner" id="startupResumeBanner" role="status" aria-live="polite" hidden>
<span id="startupResumeMessage"></span>
<button class="btn btn-xs btn-secondary" id="cancelStartupResumeBtn">Abbrechen</button>
</div>
<div class="shutdown-overlay" id="shutdownOverlay" style="display:none" aria-hidden="true"> <div class="shutdown-overlay" id="shutdownOverlay" style="display:none" aria-hidden="true">
<div class="shutdown-box" role="dialog" aria-modal="true" aria-labelledby="shutdownMessage"> <div class="shutdown-box" role="dialog" aria-modal="true" aria-labelledby="shutdownMessage">
@@ -665,6 +674,9 @@
<script src="../lib/speed-history.js"></script> <script src="../lib/speed-history.js"></script>
<script src="account-submit.js"></script> <script src="account-submit.js"></script>
<script src="account-status.js"></script> <script src="account-status.js"></script>
<script src="history-status.js"></script>
<script src="auto-resume.js"></script>
<script src="queue-stats.js"></script>
<script src="i18n.js"></script> <script src="i18n.js"></script>
<script src="app.js"></script> <script src="app.js"></script>
</body> </body>
+51
View File
@@ -0,0 +1,51 @@
(function (root, factory) {
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
if (root) root.QueueStats = api;
})(typeof window !== 'undefined' ? window : globalThis, function () {
function calculateQueueStats(jobs) {
const items = Array.isArray(jobs) ? jobs : [];
let remaining = 0;
let inProgress = 0;
let done = 0;
let errors = 0;
let skipped = 0;
let aborted = 0;
let bytesRemaining = 0;
let totalSize = 0;
let remainingSize = 0;
let inProgressBytes = 0;
for (const job of items) {
const status = job?.status;
const totalBytes = Number(job?.bytesTotal) || 0;
const uploadedBytes = Number(job?.bytesUploaded) || 0;
const pendingBytes = Math.max(0, totalBytes - uploadedBytes);
totalSize += totalBytes;
if (status === 'uploading' || status === 'getting-server' || status === 'retrying') {
inProgress++;
remaining++;
inProgressBytes += uploadedBytes;
bytesRemaining += pendingBytes;
remainingSize += pendingBytes;
} else if (status === 'preview' || status === 'queued') {
remaining++;
bytesRemaining += pendingBytes;
remainingSize += pendingBytes;
} else if (status === 'done') {
done++;
} else if (status === 'error') {
errors++;
} else if (status === 'skipped') {
skipped++;
} else if (status === 'aborted') {
aborted++;
}
}
return { total: items.length, remaining, inProgress, done, errors, skipped, aborted, bytesRemaining, totalSize, remainingSize, inProgressBytes };
}
return { calculateQueueStats };
});
+48 -6
View File
@@ -1578,6 +1578,7 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
.history-row:hover { background: rgba(255, 255, 255, 0.03); } .history-row:hover { background: rgba(255, 255, 255, 0.03); }
.history-row.selected { background: rgba(102, 126, 234, 0.12); } .history-row.selected { background: rgba(102, 126, 234, 0.12); }
.history-row.error { color: var(--danger); } .history-row.error { color: var(--danger); }
.history-row.skipped { color: var(--warning); }
.history-row td { .history-row td {
padding: 5px 8px; padding: 5px 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.03); border-bottom: 1px solid rgba(255, 255, 255, 0.03);
@@ -1639,6 +1640,28 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
} }
.copy-toast.show { opacity: 1; transform: translateX(-50%) translateY(0); } .copy-toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
.startup-resume-banner {
position: fixed;
left: 50%;
bottom: 26px;
z-index: 2100;
display: flex;
align-items: center;
gap: 12px;
min-height: 42px;
padding: 7px 8px 7px 14px;
border: 1px solid var(--accent);
border-radius: 8px;
background: var(--bg-panel);
color: var(--text);
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.35);
transform: translateX(-50%);
}
.startup-resume-banner[hidden] {
display: none;
}
/* Shutdown overlay */ /* Shutdown overlay */
.shutdown-overlay { .shutdown-overlay {
position: fixed; position: fixed;
@@ -1873,6 +1896,21 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
flex: 0 0 auto; flex: 0 0 auto;
} }
.local-backup-password-row {
margin-bottom: 10px;
}
.local-backup-password-fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin-bottom: 10px;
}
.local-backup-password-fields[hidden] {
display: none;
}
.upload-speed-sparkline { .upload-speed-sparkline {
width: 208px; width: 208px;
height: 30px; height: 30px;
@@ -3280,6 +3318,16 @@ input[type="checkbox"] {
border-color: var(--danger); border-color: var(--danger);
} }
.settings-option.credential-fallback-option {
margin-top: 12px;
border-color: color-mix(in srgb, var(--danger) 72%, var(--border));
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--danger) 10%, transparent);
}
.settings-option.credential-fallback-option:focus-within {
border-color: var(--danger);
}
.settings-option-description, .settings-option-description,
.hint { .hint {
color: var(--text-dim); color: var(--text-dim);
@@ -3721,12 +3769,6 @@ input[type="checkbox"] {
grid-template-columns: 224px minmax(0, 1fr); grid-template-columns: 224px minmax(0, 1fr);
} }
.app-brand-name {
max-width: 155px;
overflow: hidden;
text-overflow: ellipsis;
}
.upload-speed-sparkline { .upload-speed-sparkline {
width: 168px; width: 168px;
} }
+1 -2
View File
@@ -43,6 +43,5 @@ export function resolveExistingReleaseId(plan, release) {
} }
export function renderLatestYml(plan, sha, size, releaseDate = new Date().toISOString()) { export function renderLatestYml(plan, sha, size, releaseDate = new Date().toISOString()) {
const releaseAssetName = plan.setupName.replace(/ /g, '.'); return `version: ${plan.version}\nfiles:\n - url: ${plan.setupName}\n sha512: ${sha}\n size: ${size}\npath: ${plan.setupName}\nsha512: ${sha}\nreleaseDate: '${releaseDate}'\n`;
return `version: ${plan.version}\nfiles:\n - url: ${releaseAssetName}\n sha512: ${sha}\n size: ${size}\npath: ${releaseAssetName}\nsha512: ${sha}\nreleaseDate: '${releaseDate}'\n`;
} }
+17
View File
@@ -6,6 +6,8 @@ import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const args = process.argv.slice(2); const args = process.argv.slice(2);
const failures = new Map(); const failures = new Map();
const publicActionsDir = `.${['git', 'hub'].join('')}`;
const privateActionsDir = `.${['gi', 'tea'].join('')}`;
const sourceFiles = [ const sourceFiles = [
'.gitignore', '.gitignore',
'README.md', 'README.md',
@@ -13,6 +15,8 @@ const sourceFiles = [
'assets/app_icon.ico', 'assets/app_icon.ico',
'assets/app_icon.png', 'assets/app_icon.png',
'eslint.config.mjs', 'eslint.config.mjs',
`${privateActionsDir}/workflows/ci.yml`,
`${publicActionsDir}/workflows/ci.yml`,
'lib/account-auth.js', 'lib/account-auth.js',
'lib/account-rotation.js', 'lib/account-rotation.js',
'lib/backup-crypto.js', 'lib/backup-crypto.js',
@@ -23,6 +27,7 @@ const sourceFiles = [
'lib/diagnostics-collectors.js', 'lib/diagnostics-collectors.js',
'lib/doodstream-upload.js', 'lib/doodstream-upload.js',
'lib/file-probe.js', 'lib/file-probe.js',
'lib/file-discovery.js',
'lib/folder-monitor.js', 'lib/folder-monitor.js',
'lib/hosters.js', 'lib/hosters.js',
'lib/ip-allowlist.js', 'lib/ip-allowlist.js',
@@ -53,6 +58,7 @@ const sourceFiles = [
'lib/throttled-cache.js', 'lib/throttled-cache.js',
'lib/updater.js', 'lib/updater.js',
'lib/upload-log.js', 'lib/upload-log.js',
'lib/upload-confirmation.js',
'lib/upload-manager.js', 'lib/upload-manager.js',
'lib/vidmoly-upload.js', 'lib/vidmoly-upload.js',
'lib/voe-upload.js', 'lib/voe-upload.js',
@@ -65,9 +71,12 @@ const sourceFiles = [
'renderer/account-status.js', 'renderer/account-status.js',
'renderer/account-submit.js', 'renderer/account-submit.js',
'renderer/app.js', 'renderer/app.js',
'renderer/auto-resume.js',
'renderer/drop-target.html', 'renderer/drop-target.html',
'renderer/i18n.js', 'renderer/i18n.js',
'renderer/index.html', 'renderer/index.html',
'renderer/history-status.js',
'renderer/queue-stats.js',
'renderer/styles.css', 'renderer/styles.css',
'scripts/afterPack.cjs', 'scripts/afterPack.cjs',
'scripts/dev-runner.cjs', 'scripts/dev-runner.cjs',
@@ -81,6 +90,7 @@ const sourceFiles = [
'tests/account-auth.test.js', 'tests/account-auth.test.js',
'tests/account-rotation.test.js', 'tests/account-rotation.test.js',
'tests/account-status.test.js', 'tests/account-status.test.js',
'tests/auto-resume.test.js',
'tests/backup-crypto.test.js', 'tests/backup-crypto.test.js',
'tests/byse-reject-recovery.test.js', 'tests/byse-reject-recovery.test.js',
'tests/coalesced-set.test.js', 'tests/coalesced-set.test.js',
@@ -92,6 +102,9 @@ const sourceFiles = [
'tests/doodstream-api-upload.test.js', 'tests/doodstream-api-upload.test.js',
'tests/doodstream-upload.test.js', 'tests/doodstream-upload.test.js',
'tests/file-probe.test.js', 'tests/file-probe.test.js',
'tests/file-discovery.test.js',
'tests/folder-monitor.test.js',
'tests/history-status.test.js',
'tests/history-retention.test.js', 'tests/history-retention.test.js',
'tests/hosters.test.js', 'tests/hosters.test.js',
'tests/i18n.test.js', 'tests/i18n.test.js',
@@ -108,9 +121,11 @@ const sourceFiles = [
'tests/queue-dedup.test.js', 'tests/queue-dedup.test.js',
'tests/queue-persistence-scenario.test.js', 'tests/queue-persistence-scenario.test.js',
'tests/queue-prune.test.js', 'tests/queue-prune.test.js',
'tests/queue-stats.test.js',
'tests/remote-config.test.js', 'tests/remote-config.test.js',
'tests/remote-server.test.js', 'tests/remote-server.test.js',
'tests/semaphore.test.js', 'tests/semaphore.test.js',
'tests/secret-store.test.js',
'tests/serialized-runner.test.js', 'tests/serialized-runner.test.js',
'tests/settings-backup.test.js', 'tests/settings-backup.test.js',
'tests/settings-import-gate.test.js', 'tests/settings-import-gate.test.js',
@@ -128,6 +143,7 @@ const sourceFiles = [
'tests/ui-smoke.js', 'tests/ui-smoke.js',
'tests/updater-version.test.js', 'tests/updater-version.test.js',
'tests/upload-log.test.js', 'tests/upload-log.test.js',
'tests/upload-confirmation.test.js',
'tests/upload-manager.test.js', 'tests/upload-manager.test.js',
'tests/validate-credentials.test.js', 'tests/validate-credentials.test.js',
'tests/webhook-notify.test.js' 'tests/webhook-notify.test.js'
@@ -147,6 +163,7 @@ const expectedScripts = {
dev: 'node scripts/dev-runner.cjs', dev: 'node scripts/dev-runner.cjs',
test: 'node --test tests/*.test.js tests/ui-smoke.js', test: 'node --test tests/*.test.js tests/ui-smoke.js',
'test:backup-api': 'npm --prefix services/backup-api test', 'test:backup-api': 'npm --prefix services/backup-api test',
verify: 'npm run lint && npm test && npm run test:backup-api && npm audit --omit=dev',
lint: 'eslint .', lint: 'eslint .',
dist: 'electron-builder --win', dist: 'electron-builder --win',
'release:win': 'electron-builder --publish never --win nsis portable' 'release:win': 'electron-builder --publish never --win nsis portable'
+82
View File
@@ -0,0 +1,82 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { getAutoResumeJobs, createAutoResumeController } = require('../renderer/auto-resume');
test('auto resume includes only restored waiting jobs', () => {
const jobs = [
{ id: 'queued', status: 'queued', file: 'a', hoster: 'h' },
{ id: 'preview', status: 'preview', file: 'b', hoster: 'h' },
{ id: 'error', status: 'error', file: 'c', hoster: 'h' },
{ id: 'skipped', status: 'skipped', file: 'd', hoster: 'h' },
{ id: 'done', status: 'done', file: 'e', hoster: 'h' },
{ id: 'invalid', status: 'queued', file: '', hoster: 'h' }
];
assert.deepEqual(getAutoResumeJobs(jobs).map(job => job.id), ['queued', 'preview']);
});
test('countdown is visible, cancelable, and starts exactly once', () => {
let tick;
let cleared = 0;
let starts = 0;
const updates = [];
const controller = createAutoResumeController({
delaySeconds: 2,
setIntervalFn: callback => { tick = callback; return 7; },
clearIntervalFn: id => { assert.equal(id, 7); cleared++; },
onTick: (seconds, count) => updates.push([seconds, count]),
onStart: jobIds => { assert.deepEqual(jobIds, ['one', 'two', 'three']); starts++; }
});
assert.equal(controller.schedule(['one', 'two', 'three']), true);
assert.deepEqual(updates, [[2, 3]]);
tick();
assert.deepEqual(updates, [[2, 3], [1, 3]]);
tick();
tick();
assert.equal(starts, 1);
assert.equal(cleared, 1);
assert.equal(controller.pending, false);
});
test('cancel prevents the scheduled start', () => {
let tick;
let starts = 0;
let canceled = 0;
const controller = createAutoResumeController({
setIntervalFn: callback => { tick = callback; return 9; },
clearIntervalFn: () => {},
onTick: () => {},
onStart: () => { starts++; },
onCancel: () => { canceled++; }
});
controller.schedule(['restored']);
assert.equal(controller.cancel(), true);
tick();
assert.equal(starts, 0);
assert.equal(canceled, 1);
});
test('countdown starts only jobs captured when it was scheduled', () => {
let tick;
let startedJobIds = [];
const jobs = [
{ id: 'restored-a', status: 'queued', file: 'a', hoster: 'h' },
{ id: 'restored-b', status: 'preview', file: 'b', hoster: 'h' }
];
const plannedJobIds = getAutoResumeJobs(jobs).map(job => job.id);
const controller = createAutoResumeController({
delaySeconds: 1,
setIntervalFn: callback => { tick = callback; return 11; },
clearIntervalFn: () => {},
onTick: () => {},
onStart: jobIds => {
startedJobIds = getAutoResumeJobs(jobs, jobIds).map(job => job.id);
}
});
assert.equal(controller.schedule(plannedJobIds), true);
jobs.push({ id: 'added-during-countdown', status: 'queued', file: 'c', hoster: 'h' });
tick();
assert.deepEqual(startedJobIds, ['restored-a', 'restored-b']);
});
+12
View File
@@ -70,4 +70,16 @@ describe('backup-crypto', () => {
// but both decrypt to same result // but both decrypt to same result
assert.deepStrictEqual(decrypt(a), decrypt(b)); assert.deepStrictEqual(decrypt(a), decrypt(b));
}); });
it('password-protected backups require the exact password', () => {
const buf = encrypt(sampleConfig, 'correct horse battery staple');
assert.equal(buf.subarray(0, 4).toString('ascii'), 'MHU2');
assert.throws(() => decrypt(buf), (error) => error.needsPassword === true);
assert.throws(() => decrypt(buf, 'wrong password'), /Falsches Passwort/);
assert.deepStrictEqual(decrypt(buf, 'correct horse battery staple'), sampleConfig);
});
it('rejects an empty password instead of silently using the built-in key', () => {
assert.throws(() => encrypt(sampleConfig, ' '), /Passwort/);
});
}); });
+3 -3
View File
@@ -15,7 +15,7 @@ function createStore() {
}; };
// ConfigStore uses path.join(__dirname, '..') for non-packaged // ConfigStore uses path.join(__dirname, '..') for non-packaged
// We override by setting filePath directly // We override by setting filePath directly
store = new ConfigStore(fakeApp); store = new ConfigStore(fakeApp, { allowPlaintextCredentialStorage: true });
store.filePath = path.join(tmpDir, 'electron-config.json'); store.filePath = path.join(tmpDir, 'electron-config.json');
store.historyPath = path.join(tmpDir, 'electron-history.json'); store.historyPath = path.join(tmpDir, 'electron-history.json');
return store; return store;
@@ -46,7 +46,7 @@ describe('ConfigStore', () => {
if (name === 'exe') return path.join(isolatedDir, 'Multi-Hoster-Upload.exe'); if (name === 'exe') return path.join(isolatedDir, 'Multi-Hoster-Upload.exe');
throw new Error(`Unexpected app path: ${name}`); throw new Error(`Unexpected app path: ${name}`);
} }
}); }, { allowPlaintextCredentialStorage: true });
try { try {
assert.equal(explicitStore.filePath, path.join(isolatedDir, 'electron-config.json')); assert.equal(explicitStore.filePath, path.join(isolatedDir, 'electron-config.json'));
@@ -579,7 +579,7 @@ describe('ConfigStore history split (electron-history.json)', () => {
let s; let s;
function makeStore() { function makeStore() {
const st = new ConfigStore({ isPackaged: false, getPath: () => dir }); const st = new ConfigStore({ isPackaged: false, getPath: () => dir }, { allowPlaintextCredentialStorage: true });
st.filePath = path.join(dir, 'electron-config.json'); st.filePath = path.join(dir, 'electron-config.json');
st.historyPath = path.join(dir, 'electron-history.json'); st.historyPath = path.join(dir, 'electron-history.json');
return st; return st;
+38
View File
@@ -0,0 +1,38 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const { walkFolderAsync } = require('../lib/file-discovery');
function directory(name) {
return { name, isDirectory: () => true, isFile: () => false };
}
function file(name) {
return { name, isDirectory: () => false, isFile: () => true };
}
test('folder discovery preserves UNC and Unicode paths', async () => {
const root = '\\\\server\\share\\Übertragungen';
const child = path.win32.join(root, 'Staffel 1');
const target = path.win32.join(child, 'Folge äöü.mkv');
const fsPromises = {
readdir: async dir => dir === root ? [directory('Staffel 1')] : [file('Folge äöü.mkv')],
stat: async value => ({ size: value === target ? 1234 : 0 })
};
const result = await walkFolderAsync(root, { fsPromises, pathImpl: path.win32 });
assert.deepEqual(result, [{ path: target, name: 'Folge äöü.mkv', size: 1234 }]);
});
test('folder discovery does not truncate long absolute paths', async () => {
const root = `C:\\${'sehr-langer-ordner\\'.repeat(18)}ziel`;
const target = path.win32.join(root, 'video.mp4');
const result = await walkFolderAsync(root, {
fsPromises: {
readdir: async () => [file('video.mp4')],
stat: async () => ({ size: 77 })
},
pathImpl: path.win32
});
assert.equal(result[0].path, target);
assert.ok(result[0].path.length > 260);
});
+50
View File
@@ -0,0 +1,50 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { EventEmitter } = require('node:events');
const FolderMonitor = require('../lib/folder-monitor');
function createHarness() {
const calls = [];
const watch = (folderPath, options) => {
const watcher = new EventEmitter();
watcher.close = async () => {};
calls.push({ folderPath, options, watcher });
return watcher;
};
return { calls, monitor: new FolderMonitor({ watch }) };
}
test('existing files are included only on the first start of the same watch scope', () => {
const { calls, monitor } = createHarness();
const settings = { folderPath: 'C:\\incoming', includeExisting: true, recursive: false };
monitor.start(settings);
monitor.start(settings);
assert.equal(calls[0].options.ignoreInitial, false);
assert.equal(calls[1].options.ignoreInitial, true);
});
test('existing files remain ignored unless the option is enabled', () => {
const { calls, monitor } = createHarness();
monitor.start({ folderPath: 'C:\\incoming', includeExisting: false, recursive: false });
monitor.start({ folderPath: 'C:\\incoming', includeExisting: true, recursive: false });
assert.equal(calls[0].options.ignoreInitial, true);
assert.equal(calls[1].options.ignoreInitial, false);
});
test('a changed folder or filter creates a new initial scope', () => {
const { calls, monitor } = createHarness();
monitor.start({ folderPath: 'C:\\incoming', includeExisting: true, recursive: false, extensions: 'mp4' });
monitor.start({ folderPath: 'D:\\incoming', includeExisting: true, recursive: false, extensions: 'mp4' });
monitor.start({ folderPath: 'D:\\incoming', includeExisting: true, recursive: false, extensions: 'mkv' });
assert.deepEqual(calls.map(call => call.options.ignoreInitial), [false, false, false]);
});
test('initial scan completion is exposed so the one-time option can be persisted as consumed', () => {
const { calls, monitor } = createHarness();
let completed = 0;
monitor.on('initial-scan-complete', () => { completed++; });
monitor.start({ folderPath: 'C:\\incoming', includeExisting: true, recursive: false });
calls[0].watcher.emit('ready');
calls[0].watcher.emit('ready');
assert.equal(completed, 1);
});
+17
View File
@@ -0,0 +1,17 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { classifyHistoryStatus, historyDetail } = require('../renderer/history-status');
test('history status keeps skipped separate from successful and failed uploads', () => {
assert.equal(classifyHistoryStatus('done'), 'success');
assert.equal(classifyHistoryStatus('error'), 'error');
assert.equal(classifyHistoryStatus('aborted'), 'error');
assert.equal(classifyHistoryStatus('skipped'), 'skipped');
assert.equal(classifyHistoryStatus('unexpected'), 'error');
});
test('history detail shows a skipped reason instead of a fake link', () => {
assert.equal(historyDetail({ status: 'skipped', error: 'Datei zu groß', download_url: 'https://example.invalid/wrong' }), 'Datei zu groß');
assert.equal(historyDetail({ status: 'done', download_url: 'https://example.invalid/ok' }), 'https://example.invalid/ok');
});
+1
View File
@@ -7,6 +7,7 @@ const packageJson = require('../package.json');
test('packages every Electron preload referenced by the main process', () => { test('packages every Electron preload referenced by the main process', () => {
assert.ok(packageJson.build.files.includes('preload.js')); assert.ok(packageJson.build.files.includes('preload.js'));
assert.ok(packageJson.build.files.includes('preload-drop-target.js')); assert.ok(packageJson.build.files.includes('preload-drop-target.js'));
assert.equal(packageJson.build.win.signAndEditExecutable, false);
}); });
test('afterPack brands the executable metadata shown by Windows', async () => { test('afterPack brands the executable metadata shown by Windows', async () => {
+1 -1
View File
@@ -17,7 +17,7 @@ const rootFiles = [
'preload-drop-target.js', 'preload-drop-target.js',
'preload.js' 'preload.js'
]; ];
const directoryRoots = ['assets', 'docs', 'lib', 'renderer', 'services/backup-api', 'tests']; const directoryRoots = [`.${['gi', 'tea'].join('')}`, `.${['git', 'hub'].join('')}`, 'assets', 'docs', 'lib', 'renderer', 'services/backup-api', 'tests'];
const scriptFiles = ['scripts/afterPack.cjs', 'scripts/dev-runner.cjs', 'scripts/release-plan.mjs', 'scripts/verify-public-release.mjs']; const scriptFiles = ['scripts/afterPack.cjs', 'scripts/dev-runner.cjs', 'scripts/release-plan.mjs', 'scripts/verify-public-release.mjs'];
const screenshotFiles = [ const screenshotFiles = [
'assets/product-overview.png', 'assets/product-overview.png',
+23
View File
@@ -0,0 +1,23 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { calculateQueueStats } = require('../renderer/queue-stats');
test('queue totals keep skipped and aborted jobs out of remaining work', () => {
const stats = calculateQueueStats([
{ status: 'done', bytesTotal: 100, bytesUploaded: 100 },
{ status: 'error', bytesTotal: 100, bytesUploaded: 20 },
{ status: 'skipped', bytesTotal: 100, bytesUploaded: 0 },
{ status: 'aborted', bytesTotal: 100, bytesUploaded: 10 },
{ status: 'queued', bytesTotal: 100, bytesUploaded: 0 },
{ status: 'uploading', bytesTotal: 100, bytesUploaded: 40 }
]);
assert.deepEqual({ total: stats.total, remaining: stats.remaining, done: stats.done, errors: stats.errors, skipped: stats.skipped, aborted: stats.aborted }, {
total: 6,
remaining: 2,
done: 1,
errors: 1,
skipped: 1,
aborted: 1
});
assert.equal(stats.remainingSize, 160);
});
+1 -1
View File
@@ -13,7 +13,7 @@ function createTestConfigStore() {
getPath: () => tmpDir getPath: () => tmpDir
}; };
const ConfigStore = require('../lib/config-store'); const ConfigStore = require('../lib/config-store');
const store = new ConfigStore(mockApp); const store = new ConfigStore(mockApp, { allowPlaintextCredentialStorage: true });
store.filePath = path.join(tmpDir, 'test-config.json'); store.filePath = path.join(tmpDir, 'test-config.json');
return { store, tmpDir }; return { store, tmpDir };
} }
+108
View File
@@ -0,0 +1,108 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const Module = require('node:module');
const secretStorePath = require.resolve('../lib/secret-store');
function withSecretStore(safeStorage, action) {
const originalLoad = Module._load;
Module._load = function load(request, parent, isMain) {
if (request === 'electron') return { safeStorage };
return originalLoad.call(this, request, parent, isMain);
};
delete require.cache[secretStorePath];
try {
return action(require(secretStorePath));
} finally {
Module._load = originalLoad;
delete require.cache[secretStorePath];
}
}
function availableSafeStorage(overrides = {}) {
return {
isEncryptionAvailable: () => true,
encryptString: value => Buffer.from(`protected:${value}`),
decryptString: value => value.toString().replace(/^protected:/, ''),
...overrides
};
}
test('reports whether secure credential storage is available', () => {
withSecretStore(availableSafeStorage(), secretStore => {
assert.equal(secretStore.getAvailabilityStatus(), 'available');
});
withSecretStore(null, secretStore => {
assert.equal(secretStore.getAvailabilityStatus(), 'unavailable');
});
});
test('encrypts and decrypts fields when secure storage is available', () => {
withSecretStore(availableSafeStorage(), secretStore => {
const encrypted = secretStore.encryptField('secret');
assert.match(encrypted, /^enc:v1:/);
assert.equal(secretStore.decryptField(encrypted), 'secret');
});
});
test('refuses plaintext storage by default when secure storage is unavailable', () => {
withSecretStore(null, secretStore => {
assert.throws(
() => secretStore.encryptField('secret'),
error => error instanceof secretStore.SecretStoreError
&& error.code === 'SECRET_STORE_UNAVAILABLE'
);
});
});
test('allows plaintext storage only through an explicit opt-in', () => {
withSecretStore(null, secretStore => {
assert.equal(secretStore.encryptField('secret', { allowPlaintext: true }), 'secret');
const config = { hosters: { example: [{ password: 'secret' }] } };
assert.equal(
secretStore.encryptCredentials(config, { allowPlaintext: true }).hosters.example[0].password,
'secret'
);
});
});
test('refuses plaintext storage by default when encryption fails', () => {
const failure = new Error('encryption failed');
withSecretStore(availableSafeStorage({ encryptString: () => { throw failure; } }), secretStore => {
assert.throws(
() => secretStore.encryptField('secret'),
error => error instanceof secretStore.SecretStoreError
&& error.code === 'SECRET_STORE_ENCRYPT_FAILED'
&& error.cause === failure
);
assert.equal(secretStore.encryptField('secret', { allowPlaintext: true }), 'secret');
});
});
test('throws an identifiable error for encrypted values without secure storage', () => {
withSecretStore(null, secretStore => {
assert.throws(
() => secretStore.decryptField('enc:v1:cHJvdGVjdGVkOnNlY3JldA=='),
error => error instanceof secretStore.SecretStoreError
&& error.code === 'SECRET_STORE_UNAVAILABLE'
);
});
});
test('throws an identifiable error when decryption fails', () => {
const failure = new Error('decryption failed');
withSecretStore(availableSafeStorage({ decryptString: () => { throw failure; } }), secretStore => {
assert.throws(
() => secretStore.decryptField('enc:v1:invalid'),
error => error instanceof secretStore.SecretStoreError
&& error.code === 'SECRET_STORE_DECRYPT_FAILED'
&& error.cause === failure
);
});
});
test('keeps legacy plaintext values readable without secure storage', () => {
withSecretStore(null, secretStore => {
assert.equal(secretStore.decryptField('legacy-secret'), 'legacy-secret');
});
});
+48 -1
View File
@@ -4,7 +4,8 @@ const {
summarizePerHoster, summarizePerHoster,
classifyErrorCategory, classifyErrorCategory,
summarizeBatchErrors, summarizeBatchErrors,
isRetryableCategory isRetryableCategory,
mergeSkippedIntoSummary
} = require('../lib/stats'); } = require('../lib/stats');
function makeBatch(timestamp, results) { function makeBatch(timestamp, results) {
@@ -64,6 +65,17 @@ test('summarizePerHoster handles empty / malformed input', () => {
assert.deepStrictEqual(summarizePerHoster([{ id: 'x', files: null }]), {}); assert.deepStrictEqual(summarizePerHoster([{ id: 'x', files: null }]), {});
}); });
test('summarizePerHoster reports skipped uploads without lowering the host success rate', () => {
const history = [makeBatch(1, [
{ hoster: 'voe.sx', status: 'done' },
{ hoster: 'voe.sx', status: 'error', error: 'x' },
{ hoster: 'voe.sx', status: 'skipped', error: 'Datei zu groß' }
])];
const summary = summarizePerHoster(history)['voe.sx'];
assert.deepStrictEqual({ ok: summary.ok, fail: summary.fail, skipped: summary.skipped, total: summary.total }, { ok: 1, fail: 1, skipped: 1, total: 3 });
assert.strictEqual(summary.rate, 0.5);
});
test('classifyErrorCategory: file-rejected phrases', () => { test('classifyErrorCategory: file-rejected phrases', () => {
assert.strictEqual(classifyErrorCategory('Byse lehnte Datei ab: Not video file format'), 'file-rejected'); assert.strictEqual(classifyErrorCategory('Byse lehnte Datei ab: Not video file format'), 'file-rejected');
assert.strictEqual(classifyErrorCategory('Duplicate file already exists'), 'file-rejected'); assert.strictEqual(classifyErrorCategory('Duplicate file already exists'), 'file-rejected');
@@ -122,6 +134,41 @@ test('summarizeBatchErrors buckets results by category', () => {
assert.strictEqual(buckets['account-error'].length, 0); assert.strictEqual(buckets['account-error'].length, 0);
}); });
test('summarizeBatchErrors excludes skipped results from error and retry buckets', () => {
const buckets = summarizeBatchErrors({
files: [{ name: 'a.mp4', results: [{ hoster: 'voe.sx', status: 'skipped', error: 'Kein gültiger Account' }] }]
});
assert.strictEqual(Object.values(buckets).flat().length, 0);
});
test('mergeSkippedIntoSummary adds skipped jobs to totals and history files', () => {
const summary = {
id: 'batch-1',
timestamp: '2026-08-11T12:00:00.000Z',
total: 1,
succeeded: 1,
failed: 0,
skipped: 0,
files: [{ name: 'ok.mp4', size: 5, results: [{ jobId: 'ok', hoster: 'voe.sx', status: 'done' }] }]
};
const merged = mergeSkippedIntoSummary(summary, [{
jobId: 'skip',
file: 'C:\\incoming\\skip.mp4',
fileName: 'skip.mp4',
hoster: 'byse.sx',
reason: 'Kein gültiger Account'
}]);
assert.strictEqual(merged.total, 2);
assert.strictEqual(merged.succeeded, 1);
assert.strictEqual(merged.failed, 0);
assert.strictEqual(merged.skipped, 1);
assert.deepStrictEqual(merged.files[1], {
name: 'skip.mp4',
size: 0,
results: [{ jobId: 'skip', hoster: 'byse.sx', status: 'skipped', error: 'Kein gültiger Account' }]
});
});
test('isRetryableCategory: only transient + network + unknown retry-worthy', () => { test('isRetryableCategory: only transient + network + unknown retry-worthy', () => {
assert.strictEqual(isRetryableCategory('hoster-transient'), true); assert.strictEqual(isRetryableCategory('hoster-transient'), true);
assert.strictEqual(isRetryableCategory('network'), true); assert.strictEqual(isRetryableCategory('network'), true);
+18 -9
View File
@@ -13,6 +13,7 @@ const { execFileSync } = require('child_process');
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const os = require('os'); const os = require('os');
const productVersion = require('../package.json').version;
const uiRunId = `${process.pid}-${Date.now()}`; const uiRunId = `${process.pid}-${Date.now()}`;
const visualScreenshotDir = process.env.MHU_UI_SCREENSHOT_DIR const visualScreenshotDir = process.env.MHU_UI_SCREENSHOT_DIR
? path.resolve(process.env.MHU_UI_SCREENSHOT_DIR) ? path.resolve(process.env.MHU_UI_SCREENSHOT_DIR)
@@ -25,6 +26,7 @@ if (visualScreenshotDir) fs.mkdirSync(visualScreenshotDir, { recursive: true });
// Create a temp script that the real Electron app will execute via --eval // Create a temp script that the real Electron app will execute via --eval
const testScript = ` const testScript = `
const { app, BrowserWindow, ipcMain, dialog } = require('electron'); const { app, BrowserWindow, ipcMain, dialog } = require('electron');
app.setVersion(${JSON.stringify(productVersion)});
const fs = require('fs'); const fs = require('fs');
const net = require('net'); const net = require('net');
const path = require('path'); const path = require('path');
@@ -351,7 +353,9 @@ setTimeout(async () => {
check('Legacy bottom statusbar is removed', legacyStatusbar === null); check('Legacy bottom statusbar is removed', legacyStatusbar === null);
const version = await wc.executeJavaScript('document.getElementById("versionLabel")?.textContent'); const version = await wc.executeJavaScript('document.getElementById("versionLabel")?.textContent');
check('Version label present', version && version.startsWith('v')); check('Version label matches package.json', version === ${JSON.stringify(`v${productVersion}`)});
const standardHeaderBrand = await wc.executeJavaScript('(() => { const brand = document.querySelector(".app-brand-name"); if (!brand) return null; const rect = brand.getBoundingClientRect(); return { visible: getComputedStyle(brand).display !== "none" && rect.width > 0, fits: brand.scrollWidth <= brand.clientWidth + 1, text: brand.textContent.trim() }; })()');
check('Standard window shows the full product name', standardHeaderBrand?.visible === true && standardHeaderBrand.fits === true && standardHeaderBrand.text === 'MULTI HOSTER UPLOADER');
const versionMonogram = await wc.executeJavaScript('document.querySelector(".version-monogram")'); const versionMonogram = await wc.executeJavaScript('document.querySelector(".version-monogram")');
check('Header version badge has no meaningless monogram', versionMonogram === null); check('Header version badge has no meaningless monogram', versionMonogram === null);
const windowTitle = await wc.executeJavaScript('document.title'); const windowTitle = await wc.executeJavaScript('document.title');
@@ -1527,7 +1531,7 @@ setTimeout(async () => {
const clearedHistoryState = await wc.executeJavaScript('(() => { const modal = document.getElementById("historyClearModal"); const button = document.getElementById("clearHistoryBtn"); button?.click(); return [modal?.style.display, modal?.getAttribute("aria-hidden"), button?.disabled, document.querySelector("#historyContainer .empty-state")?.textContent?.trim()].join("|"); })()'); const clearedHistoryState = await wc.executeJavaScript('(() => { const modal = document.getElementById("historyClearModal"); const button = document.getElementById("clearHistoryBtn"); button?.click(); return [modal?.style.display, modal?.getAttribute("aria-hidden"), button?.disabled, document.querySelector("#historyContainer .empty-state")?.textContent?.trim()].join("|"); })()');
check('Clearing history closes the dialog and disables the action for the empty state', clearHistoryCallCount === 1 && clearedHistoryState === 'none|true|true|Noch keine Uploads.'); check('Clearing history closes the dialog and disables the action for the empty state', clearHistoryCallCount === 1 && clearedHistoryState === 'none|true|true|Noch keine Uploads.');
await captureVisual('04-history-empty.png'); await captureVisual('04-history-empty.png');
historyFixture = [{ timestamp: '2026-08-10T10:00:00.000Z', files: [{ name: 'ok.bin', results: [{ status: 'done', hoster: 'voe.sx', download_url: 'https://example.invalid/ok' }] }, { name: 'bad.bin', results: [{ status: 'error', hoster: 'byse.sx', error: 'Zugang abgelehnt' }] }, { name: 'stopped.bin', results: [{ status: 'aborted', hoster: 'doodstream.com' }] }] }]; historyFixture = [{ timestamp: '2026-08-10T10:00:00.000Z', files: [{ name: 'ok.bin', results: [{ status: 'done', hoster: 'voe.sx', download_url: 'https://example.invalid/ok' }] }, { name: 'bad.bin', results: [{ status: 'error', hoster: 'byse.sx', error: 'Zugang abgelehnt' }] }, { name: 'stopped.bin', results: [{ status: 'aborted', hoster: 'doodstream.com' }] }, { name: 'large.bin', results: [{ status: 'skipped', hoster: 'vidmoly.me', error: 'Datei zu groß' }] }] }];
await wc.executeJavaScript('loadHistory().then(() => { window.confirm = window.__historyOriginalConfirm; delete window.__historyOriginalConfirm; })'); await wc.executeJavaScript('loadHistory().then(() => { window.confirm = window.__historyOriginalConfirm; delete window.__historyOriginalConfirm; })');
const historyFrameFit = await wc.executeJavaScript('(() => { const view = document.getElementById("history-view")?.getBoundingClientRect(); return Boolean(view && view.bottom <= window.innerHeight + 1); })()'); const historyFrameFit = await wc.executeJavaScript('(() => { const view = document.getElementById("history-view")?.getBoundingClientRect(); return Boolean(view && view.bottom <= window.innerHeight + 1); })()');
@@ -1552,19 +1556,24 @@ setTimeout(async () => {
data: historyRowsData.length, data: historyRowsData.length,
rows: [...document.querySelectorAll('#historyBody .history-row')].map(row => row.querySelector('.col-filename')?.textContent).sort(), rows: [...document.querySelectorAll('#historyBody .history-row')].map(row => row.querySelector('.col-filename')?.textContent).sort(),
errors: document.querySelectorAll('#historyBody .history-row.error').length, errors: document.querySelectorAll('#historyBody .history-row.error').length,
counts: ['historySidebarAllCount', 'historySidebarSuccessCount', 'historySidebarErrorCount'].map(id => document.getElementById(id)?.textContent) counts: ['historySidebarAllCount', 'historySidebarSuccessCount', 'historySidebarErrorCount', 'historySidebarSkippedCount'].map(id => document.getElementById(id)?.textContent)
}; };
const success = inspect('success'); const success = inspect('success');
const error = inspect('error'); const error = inspect('error');
const skipped = inspect('skipped');
const all = inspect('all'); const all = inspect('all');
return { initial, success, error, all, sourceLength: historyRowsData.length }; return { initial, success, error, skipped, all, sourceLength: historyRowsData.length };
})()\`); })()\`);
check('History keeps failed results in the renderer data model and All view', historyFilterState.initial.data === 3 && historyFilterState.initial.rows.join('|') === 'bad.bin|ok.bin|stopped.bin' && historyFilterState.initial.errors === 2 && historyFilterState.initial.counts.join('|') === '3|1|2'); check('History keeps failed and skipped results in the renderer data model and All view', historyFilterState.initial.data === 4 && historyFilterState.initial.rows.join('|') === 'bad.bin|large.bin|ok.bin|stopped.bin' && historyFilterState.initial.errors === 2 && historyFilterState.initial.counts.join('|') === '4|1|2|1');
check('History sidebar filters successful and failed rows without dropping source data', historyFilterState.success.rows.join('|') === 'ok.bin' && historyFilterState.success.errors === 0 && historyFilterState.error.rows.join('|') === 'bad.bin|stopped.bin' && historyFilterState.error.errors === 2 && historyFilterState.all.rows.length === 3 && historyFilterState.sourceLength === 3); check('History sidebar filters successful, failed, and skipped rows without dropping source data', historyFilterState.success.rows.join('|') === 'ok.bin' && historyFilterState.success.errors === 0 && historyFilterState.error.rows.join('|') === 'bad.bin|stopped.bin' && historyFilterState.error.errors === 2 && historyFilterState.skipped.rows.join('|') === 'large.bin' && historyFilterState.all.rows.length === 4 && historyFilterState.sourceLength === 4);
check('History sidebar exposes exactly one pressed filter', historyFilterState.success.pressed.join('|') === 'success' && historyFilterState.success.active.join('|') === 'success' && historyFilterState.error.pressed.join('|') === 'error' && historyFilterState.error.active.join('|') === 'error' && historyFilterState.all.pressed.join('|') === 'all' && historyFilterState.all.active.join('|') === 'all'); check('History sidebar exposes exactly one pressed filter', historyFilterState.success.pressed.join('|') === 'success' && historyFilterState.success.active.join('|') === 'success' && historyFilterState.error.pressed.join('|') === 'error' && historyFilterState.error.active.join('|') === 'error' && historyFilterState.skipped.pressed.join('|') === 'skipped' && historyFilterState.skipped.active.join('|') === 'skipped' && historyFilterState.all.pressed.join('|') === 'all' && historyFilterState.all.active.join('|') === 'all');
const historyCopyControls = await wc.executeJavaScript('(() => { const rows = [...document.querySelectorAll("#historyBody .history-row")]; const buttons = rows.map(row => row.querySelector(".history-copy-link")); const inside = buttons.every(button => { const cell = button?.closest(".col-link"); const cellRect = cell?.getBoundingClientRect(); const buttonRect = button?.getBoundingClientRect(); return cellRect && buttonRect && buttonRect.right <= cellRect.right + 1 && buttonRect.left >= cellRect.left; }); return [buttons.length, buttons.every(button => button?.getAttribute("aria-label") === "Link kopieren"), inside].join("|"); })()'); const skippedHistoryPresentation = await wc.executeJavaScript('(() => { document.querySelector("[data-history-filter=skipped]")?.click(); const row = document.querySelector("#historyBody .history-row.skipped"); return [row?.querySelector(".col-filename")?.textContent, row?.querySelector(".history-link-text")?.textContent, Boolean(row?.querySelector(".history-copy-link"))].join("|"); })()');
check('History links expose an in-cell copy action', historyCopyControls === '3|true|true'); check('Skipped history rows show their reason without a link-copy action', skippedHistoryPresentation === 'large.bin|Datei zu groß|false');
await wc.executeJavaScript('document.querySelector("[data-history-filter=all]")?.click()');
const historyCopyControls = await wc.executeJavaScript('(() => { const rows = [...document.querySelectorAll("#historyBody .history-row")]; const buttons = rows.map(row => row.querySelector(".history-copy-link")).filter(Boolean); const inside = buttons.every(button => { const cell = button.closest(".col-link"); const cellRect = cell?.getBoundingClientRect(); const buttonRect = button.getBoundingClientRect(); return cellRect && buttonRect && buttonRect.right <= cellRect.right + 1 && buttonRect.left >= cellRect.left; }); return [buttons.length, buttons.every(button => button.getAttribute("aria-label") === "Link kopieren"), inside].join("|"); })()');
check('Successful history links expose an in-cell copy action', historyCopyControls === '1|true|true');
const historyCopyAction = await wc.executeJavaScript('document.querySelector(".history-copy-link")?.click(); document.getElementById("copyToast")?.textContent?.trim()'); const historyCopyAction = await wc.executeJavaScript('document.querySelector(".history-copy-link")?.click(); document.getElementById("copyToast")?.textContent?.trim()');
check('History copy action confirms the copied link', historyCopyAction === 'Link kopiert'); check('History copy action confirms the copied link', historyCopyAction === 'Link kopiert');
await wc.executeJavaScript('document.getElementById("copyToast")?.classList.remove("show")'); await wc.executeJavaScript('document.getElementById("copyToast")?.classList.remove("show")');
+83 -5
View File
@@ -3,9 +3,10 @@ const assert = require('node:assert/strict');
const path = require('node:path'); const path = require('node:path');
const fs = require('node:fs'); const fs = require('node:fs');
const os = require('node:os'); const os = require('node:os');
const crypto = require('node:crypto');
const { pathToFileURL } = require('node:url'); const { pathToFileURL } = require('node:url');
const { isNewer, resolveReleaseVersion, fetchGithubReleaseNotes, prepareUpdate, launchPreparedUpdate } = require('../lib/updater'); const { isNewer, resolveReleaseVersion, fetchGithubReleaseNotes, prepareUpdate, launchPreparedUpdate, pickSetupAsset, parseLatestYml } = require('../lib/updater');
const releasePlanUrl = pathToFileURL(path.resolve(__dirname, '../scripts/release-plan.mjs')).href; const releasePlanUrl = pathToFileURL(path.resolve(__dirname, '../scripts/release-plan.mjs')).href;
test('bridge title resolves product version instead of transport tag', () => { test('bridge title resolves product version instead of transport tag', () => {
@@ -53,10 +54,17 @@ test('update preparation writes a verified installer without launching it', asyn
assetUrl: 'https://update.invalid/setup.exe', assetUrl: 'https://update.invalid/setup.exe',
assetName: 'setup.exe', assetName: 'setup.exe',
assetSize: installer.length, assetSize: installer.length,
latestYmlUrl: null remoteVersion: '2.2.0',
latestYmlUrl: 'https://update.invalid/latest.yml'
}, },
tempDir, tempDir,
fetchImpl: async () => ({ fetchImpl: async url => url.endsWith('latest.yml')
? {
ok: true,
status: 200,
text: async () => `version: 2.2.0\npath: setup.exe\nsha512: ${crypto.createHash('sha512').update(installer).digest('base64')}\nsize: ${installer.length}\n`
}
: {
ok: true, ok: true,
status: 200, status: 200,
body: { body: {
@@ -67,7 +75,7 @@ test('update preparation writes a verified installer without launching it', asyn
} }
}) })
} }
}) }
}); });
assert.equal(prepared.installerPath, path.join(tempDir, 'setup.exe')); assert.equal(prepared.installerPath, path.join(tempDir, 'setup.exe'));
@@ -78,6 +86,76 @@ test('update preparation writes a verified installer without launching it', asyn
} }
}); });
test('update preparation fails closed when checksum metadata is unavailable', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-updater-test-'));
try {
await assert.rejects(
prepareUpdate(null, {
checkResult: {
available: true,
assetUrl: 'https://update.invalid/setup.exe',
assetName: 'setup.exe',
assetSize: 128 * 1024,
remoteVersion: '2.2.0',
latestYmlUrl: null
},
tempDir,
fetchImpl: async () => assert.fail('installer download must not start without checksum metadata')
}),
/Prüfsummen-Metadaten fehlen/
);
assert.equal(fs.existsSync(path.join(tempDir, 'setup.exe')), false);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
test('setup selection never falls back to a portable executable', () => {
const portable = { name: 'Multi-Hoster-Upload 2.2.0.exe' };
assert.equal(pickSetupAsset([portable], '2.2.0'), null);
assert.deepEqual(
pickSetupAsset([portable, { name: 'Multi-Hoster-Upload Setup 2.2.0.exe' }], '2.2.0'),
{ name: 'Multi-Hoster-Upload Setup 2.2.0.exe' }
);
});
test('checksum metadata must match the selected version, installer name, size, and SHA-512 shape', async () => {
const sha = crypto.randomBytes(64).toString('base64');
const fetchImpl = async () => ({
ok: true,
status: 200,
text: async () => `version: 2.2.0\npath: Multi-Hoster-Upload Setup 2.2.0.exe\nsha512: ${sha}\nsize: 456\n`
});
const metadata = await parseLatestYml('https://update.invalid/latest.yml', {
version: '2.2.0',
assetName: 'Multi-Hoster-Upload Setup 2.2.0.exe',
assetSize: 456
}, fetchImpl);
assert.deepEqual(metadata, {
version: '2.2.0',
path: 'Multi-Hoster-Upload Setup 2.2.0.exe',
size: 456,
sha512: sha
});
});
test('checksum metadata rejects a path that belongs to another artifact', async () => {
const sha = crypto.randomBytes(64).toString('base64');
await assert.rejects(
parseLatestYml('https://update.invalid/latest.yml', {
version: '2.2.0',
assetName: 'Multi-Hoster-Upload Setup 2.2.0.exe',
assetSize: 456
}, async () => ({
ok: true,
status: 200,
text: async () => `version: 2.2.0\npath: Multi-Hoster-Upload 2.2.0.exe\nsha512: ${sha}\nsize: 456\n`
})),
/gehören nicht zum ausgewählten Installer/
);
});
test('a prepared installer launches at most once', () => { test('a prepared installer launches at most once', () => {
const calls = []; const calls = [];
const child = { unrefCalls: 0, unref() { this.unrefCalls++; } }; const child = { unrefCalls: 0, unref() { this.unrefCalls++; } };
@@ -120,7 +198,7 @@ test('release plan keeps product artifacts separate from the transport tag', asy
'Multi-Hoster-Upload Setup 2.0.7.exe.blockmap', 'Multi-Hoster-Upload Setup 2.0.7.exe.blockmap',
'latest.yml' 'latest.yml'
], ],
latestYml: "version: 2.0.7\nfiles:\n - url: Multi-Hoster-Upload.Setup.2.0.7.exe\n sha512: abc123\n size: 456\npath: Multi-Hoster-Upload.Setup.2.0.7.exe\nsha512: abc123\nreleaseDate: '2026-08-07T12:00:00.000Z'\n" latestYml: "version: 2.0.7\nfiles:\n - url: Multi-Hoster-Upload Setup 2.0.7.exe\n sha512: abc123\n size: 456\npath: Multi-Hoster-Upload Setup 2.0.7.exe\nsha512: abc123\nreleaseDate: '2026-08-07T12:00:00.000Z'\n"
}); });
}); });
+58
View File
@@ -0,0 +1,58 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { assertUploadConfirmation } = require('../lib/upload-confirmation');
test('accepts a host-confirmed file code without a public URL', () => {
const result = { file_code: 'AB1', download_url: null, embed_url: null };
assert.equal(assertUploadConfirmation(result, 'doodstream.com'), result);
});
test('accepts upload URLs for every supported hoster and its subdomains', () => {
const cases = [
['doodstream.com', 'https://doodstream.com/d/abc123'],
['voe.sx', 'https://cdn.voe.sx/abc123'],
['vidmoly.me', 'https://vidmoly.me/w/abc123'],
['byse.sx', 'https://media.byse.sx/d/abc123'],
['clouddrop.cc', 'https://clouddrop.cc/share/abc123']
];
for (const [hoster, downloadUrl] of cases) {
const result = { file_code: 'abc123', download_url: downloadUrl };
assert.equal(assertUploadConfirmation(result, hoster), result);
}
});
test('rejects an upload URL from a different domain', () => {
assert.throws(
() => assertUploadConfirmation({ file_code: 'abc123', download_url: 'https://attacker.invalid/file/abc123' }, 'voe.sx'),
/Upload zu voe\.sx wurde nicht bestätigt/
);
});
test('rejects a syntactically invalid file code despite a valid hoster URL', () => {
assert.throws(
() => assertUploadConfirmation({ file_code: 'not a code', download_url: 'https://vidmoly.me/w/not-a-code' }, 'vidmoly.me'),
/Upload zu vidmoly\.me wurde nicht bestätigt/
);
});
test('rejects a valid hoster URL without a file code', () => {
assert.throws(
() => assertUploadConfirmation({ download_url: 'https://byse.sx/d/abc123' }, 'byse.sx'),
/Upload zu byse\.sx wurde nicht bestätigt/
);
});
test('rejects an empty upload response', () => {
assert.throws(
() => assertUploadConfirmation({}, 'byse.sx'),
/Upload zu byse\.sx wurde nicht bestätigt/
);
});
test('rejects non-network links despite a valid file code', () => {
assert.throws(
() => assertUploadConfirmation({ file_code: 'abc123', download_url: 'javascript:alert(1)' }, 'vidmoly.me'),
/Upload zu vidmoly\.me wurde nicht bestätigt/
);
});
+43 -17
View File
@@ -97,6 +97,27 @@ describe('UploadManager', () => {
}); });
}); });
it('records an unconfirmed host response as an error instead of done', async () => {
mockUploadFile.mock.mockImplementation(async () => ({}));
const mgr = new UploadManager({ 'doodstream.com': { retries: 0, parallelCount: 1, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 } });
const statuses = [];
let settled;
mgr.on('progress', data => statuses.push(data.status));
mgr.on('job-settled', event => { settled = event; });
await mgr.startBatch([{
file: '/test/unconfirmed.mp4',
hoster: 'doodstream.com',
apiKey: 'key1',
jobId: 'job-unconfirmed',
sourceCleanupToken: 'cleanup-unconfirmed'
}]);
assert.equal(statuses.includes('done'), false);
assert.equal(statuses.at(-1), 'error');
assert.equal(settled.status, 'error');
});
it('replaces account pools and clears cached account state after an import', () => { it('replaces account pools and clears cached account state after an import', () => {
const mgr = new UploadManager({}, {}, { const mgr = new UploadManager({}, {}, {
'byse.sx': [{ id: 'old', apiKey: 'old-key' }] 'byse.sx': [{ id: 'old', apiKey: 'old-key' }]
@@ -144,7 +165,7 @@ describe('UploadManager', () => {
callCount++; callCount++;
if (callCount <= 2) throw new Error('network error'); if (callCount <= 2) throw new Error('network error');
if (onProgress) onProgress(fakeFileSize, fakeFileSize); if (onProgress) onProgress(fakeFileSize, fakeFileSize);
return { download_url: 'https://test/ok', embed_url: null, file_code: 'ok' }; return { download_url: `https://${hoster}/d/ok123`, embed_url: null, file_code: 'ok123' };
}); });
const mgr = new UploadManager({ 'doodstream.com': { retries: 3, parallelCount: 1, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 } }); const mgr = new UploadManager({ 'doodstream.com': { retries: 3, parallelCount: 1, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 } });
@@ -182,7 +203,7 @@ describe('UploadManager', () => {
mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress, signal) => { mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress, signal) => {
// Simulate a slow upload // Simulate a slow upload
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
const timer = setTimeout(() => resolve({ download_url: 'x', embed_url: null, file_code: 'x' }), 10000); const timer = setTimeout(() => resolve({ download_url: 'https://doodstream.com/d/ok123', embed_url: null, file_code: 'ok123' }), 10000);
if (signal) signal.addEventListener('abort', () => { clearTimeout(timer); reject(new Error('Aborted')); }); if (signal) signal.addEventListener('abort', () => { clearTimeout(timer); reject(new Error('Aborted')); });
}); });
}); });
@@ -229,7 +250,7 @@ describe('UploadManager', () => {
await new Promise(r => setTimeout(r, 50)); await new Promise(r => setTimeout(r, 50));
concurrent--; concurrent--;
if (onProgress) onProgress(fakeFileSize, fakeFileSize); if (onProgress) onProgress(fakeFileSize, fakeFileSize);
return { download_url: 'https://test/ok', embed_url: null, file_code: 'ok' }; return { download_url: `https://${hoster}/d/ok123`, embed_url: null, file_code: 'ok123' };
}); });
const mgr = new UploadManager({ 'doodstream.com': { retries: 0, parallelCount: 1, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 } }); const mgr = new UploadManager({ 'doodstream.com': { retries: 0, parallelCount: 1, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 } });
@@ -253,7 +274,7 @@ describe('UploadManager', () => {
await new Promise((resolve) => setTimeout(resolve, 40)); await new Promise((resolve) => setTimeout(resolve, 40));
concurrent--; concurrent--;
if (onProgress) onProgress(fakeFileSize, fakeFileSize); if (onProgress) onProgress(fakeFileSize, fakeFileSize);
return { download_url: `https://${hoster}/ok`, embed_url: null, file_code: 'ok' }; return { download_url: `https://${hoster}/d/ok123`, embed_url: null, file_code: 'ok123' };
}); });
const mgr = new UploadManager({ const mgr = new UploadManager({
@@ -286,7 +307,7 @@ describe('UploadManager', () => {
} }
}); });
if (onProgress) onProgress(fakeFileSize, fakeFileSize); if (onProgress) onProgress(fakeFileSize, fakeFileSize);
return { download_url: 'https://test/ok', embed_url: null, file_code: 'ok' }; return { download_url: `https://${hoster}/d/ok123`, embed_url: null, file_code: 'ok123' };
}); });
const mgr = new UploadManager({}); const mgr = new UploadManager({});
@@ -319,7 +340,7 @@ describe('UploadManager', () => {
await new Promise((resolve) => setTimeout(resolve, 20)); await new Promise((resolve) => setTimeout(resolve, 20));
} }
if (onProgress) onProgress(fakeFileSize, fakeFileSize); if (onProgress) onProgress(fakeFileSize, fakeFileSize);
return { download_url: 'https://test/ok', embed_url: null, file_code: 'ok' }; return { download_url: `https://${hoster}/d/ok123`, embed_url: null, file_code: 'ok123' };
}); });
const mgr = new UploadManager({ const mgr = new UploadManager({
@@ -402,7 +423,9 @@ describe('UploadManager', () => {
const mgr = new UploadManager({}); const mgr = new UploadManager({});
const errors = []; const errors = [];
let summary;
mgr.on('progress', (d) => { if (d.error) errors.push(d.error); }); mgr.on('progress', (d) => { if (d.error) errors.push(d.error); });
mgr.on('batch-done', value => { summary = value; });
await mgr.startBatch([ await mgr.startBatch([
{ file: '/test/deleted.mp4', hoster: 'doodstream.com', apiKey: 'key1' } { file: '/test/deleted.mp4', hoster: 'doodstream.com', apiKey: 'key1' }
@@ -410,6 +433,9 @@ describe('UploadManager', () => {
fs.promises.stat = origStat; fs.promises.stat = origStat;
assert.ok(errors.some(e => e.includes('nicht gefunden')), `expected "nicht gefunden" error, got: ${errors.join(', ')}`); assert.ok(errors.some(e => e.includes('nicht gefunden')), `expected "nicht gefunden" error, got: ${errors.join(', ')}`);
assert.equal(summary.files[0].results[0].status, 'skipped');
assert.equal(summary.skipped, 1);
assert.equal(summary.failed, 0);
}); });
it('zero-byte file produces descriptive error', async () => { it('zero-byte file produces descriptive error', async () => {
@@ -452,7 +478,7 @@ describe('UploadManager', () => {
await new Promise(r => setTimeout(r, 40)); await new Promise(r => setTimeout(r, 40));
concurrent--; concurrent--;
if (onProgress) onProgress(fakeFileSize, fakeFileSize); if (onProgress) onProgress(fakeFileSize, fakeFileSize);
return { download_url: 'ok', embed_url: null, file_code: 'ok' }; return { download_url: `https://${hoster}/d/ok123`, embed_url: null, file_code: 'ok123' };
}); });
const mgr = new UploadManager( const mgr = new UploadManager(
@@ -477,7 +503,7 @@ describe('UploadManager', () => {
started++; started++;
await new Promise(r => setTimeout(r, 100)); await new Promise(r => setTimeout(r, 100));
if (onProgress) onProgress(fakeFileSize, fakeFileSize); if (onProgress) onProgress(fakeFileSize, fakeFileSize);
return { download_url: 'ok', embed_url: null, file_code: 'ok' }; return { download_url: `https://${hoster}/d/ok123`, embed_url: null, file_code: 'ok123' };
}); });
const mgr = new UploadManager({}); const mgr = new UploadManager({});
@@ -512,7 +538,7 @@ describe('UploadManager', () => {
if (signal) signal.addEventListener('abort', () => { clearTimeout(timer); reject(new Error('Aborted')); }); if (signal) signal.addEventListener('abort', () => { clearTimeout(timer); reject(new Error('Aborted')); });
}); });
if (onProgress) onProgress(fakeFileSize, fakeFileSize); if (onProgress) onProgress(fakeFileSize, fakeFileSize);
return { download_url: 'ok', embed_url: null, file_code: 'ok' }; return { download_url: `https://${hoster}/d/ok123`, embed_url: null, file_code: 'ok123' };
}); });
const mgr = new UploadManager({}); const mgr = new UploadManager({});
@@ -545,7 +571,7 @@ describe('UploadManager', () => {
mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress, signal) => { mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress, signal) => {
await new Promise(r => setTimeout(r, 1500)); await new Promise(r => setTimeout(r, 1500));
if (onProgress) onProgress(fakeFileSize, fakeFileSize); if (onProgress) onProgress(fakeFileSize, fakeFileSize);
return { download_url: 'https://test/ok', embed_url: null, file_code: 'ok' }; return { download_url: `https://${hoster}/d/ok123`, embed_url: null, file_code: 'ok123' };
}); });
const mgr = new UploadManager({}); const mgr = new UploadManager({});
@@ -630,7 +656,7 @@ describe('UploadManager', () => {
throw err; throw err;
} }
if (onProgress) onProgress(fakeFileSize, fakeFileSize); if (onProgress) onProgress(fakeFileSize, fakeFileSize);
return { download_url: 'https://byse.sx/ok', embed_url: null, file_code: 'ok' }; return { download_url: 'https://byse.sx/d/ok123', embed_url: null, file_code: 'ok123' };
}); });
const mgr = new UploadManager( const mgr = new UploadManager(
@@ -721,7 +747,7 @@ describe('UploadManager', () => {
throw err; throw err;
} }
if (onProgress) onProgress(fakeFileSize, fakeFileSize); if (onProgress) onProgress(fakeFileSize, fakeFileSize);
return { download_url: 'ok', embed_url: null, file_code: 'ok' }; return { download_url: `https://${hoster}/d/ok123`, embed_url: null, file_code: 'ok123' };
}); });
const mgr = new UploadManager( const mgr = new UploadManager(
@@ -767,7 +793,7 @@ describe('UploadManager', () => {
} }
acc2Calls++; acc2Calls++;
if (onProgress) onProgress(fakeFileSize, fakeFileSize); if (onProgress) onProgress(fakeFileSize, fakeFileSize);
return { download_url: 'ok', embed_url: null, file_code: 'ok' }; return { download_url: `https://${hoster}/d/ok123`, embed_url: null, file_code: 'ok123' };
}); });
const mgr = new UploadManager( const mgr = new UploadManager(
@@ -797,11 +823,11 @@ describe('UploadManager', () => {
acc1Calls++; acc1Calls++;
if (acc1Calls <= 2) throw new Error('connect ECONNRESET 1.2.3.4:443'); if (acc1Calls <= 2) throw new Error('connect ECONNRESET 1.2.3.4:443');
if (onProgress) onProgress(fakeFileSize, fakeFileSize); if (onProgress) onProgress(fakeFileSize, fakeFileSize);
return { download_url: 'ok-on-acc1-retry', embed_url: null, file_code: 'ok' }; return { download_url: 'https://voe.sx/ok123', embed_url: null, file_code: 'ok123' };
} }
acc2Calls++; acc2Calls++;
if (onProgress) onProgress(fakeFileSize, fakeFileSize); if (onProgress) onProgress(fakeFileSize, fakeFileSize);
return { download_url: 'ok', embed_url: null, file_code: 'ok' }; return { download_url: `https://${hoster}/d/ok123`, embed_url: null, file_code: 'ok123' };
}); });
const mgr = new UploadManager( const mgr = new UploadManager(
@@ -849,7 +875,7 @@ describe('UploadManager', () => {
mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress) => { mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress) => {
if (onProgress) onProgress(fakeFileSize, fakeFileSize); if (onProgress) onProgress(fakeFileSize, fakeFileSize);
return { download_url: 'ok', embed_url: null, file_code: 'ok' }; return { download_url: `https://${hoster}/d/ok123`, embed_url: null, file_code: 'ok123' };
}); });
await mgr.startBatch([ await mgr.startBatch([
@@ -1012,7 +1038,7 @@ describe('UploadManager', () => {
throw err; throw err;
} }
if (onProgress) onProgress(fakeFileSize, fakeFileSize); if (onProgress) onProgress(fakeFileSize, fakeFileSize);
return { download_url: 'ok', embed_url: null, file_code: 'ok' }; return { download_url: `https://${hoster}/d/ok123`, embed_url: null, file_code: 'ok123' };
}); });
const mgr = new UploadManager( const mgr = new UploadManager(
+23 -3
View File
@@ -78,8 +78,8 @@ test('formatDurationShort formats h/m/s tiers', () => {
test('summarizePerHosterFromBatch counts ok/fail per hoster', () => { test('summarizePerHosterFromBatch counts ok/fail per hoster', () => {
const s = summarizePerHosterFromBatch(SAMPLE_SUMMARY); const s = summarizePerHosterFromBatch(SAMPLE_SUMMARY);
assert.deepStrictEqual(s['voe.sx'], { ok: 2, fail: 0 }); assert.deepStrictEqual(s['voe.sx'], { ok: 2, fail: 0, skipped: 0 });
assert.deepStrictEqual(s['byse.sx'], { ok: 1, fail: 1 }); assert.deepStrictEqual(s['byse.sx'], { ok: 1, fail: 1, skipped: 0 });
}); });
test('summarizePerHosterFromBatch handles malformed input', () => { test('summarizePerHosterFromBatch handles malformed input', () => {
@@ -102,6 +102,26 @@ test('buildWebhookRequest produces Discord content body for discord URLs', () =>
assert.match(body.content, /voe\.sx: 2\/2/); assert.match(body.content, /voe\.sx: 2\/2/);
}); });
test('webhooks report skipped uploads separately from failures', () => {
const summary = {
total: 2,
succeeded: 1,
failed: 0,
skipped: 1,
files: [{ name: 'a.mkv', results: [
{ hoster: 'voe.sx', status: 'done' },
{ hoster: 'voe.sx', status: 'skipped', error: 'Datei zu groß' }
] }]
};
const discord = JSON.parse(buildWebhookRequest('https://discord.com/api/webhooks/1/x', summary, { language: 'de' }).body);
assert.match(discord.content, /1 übersprungen/);
assert.doesNotMatch(discord.content, /1 Fehler/);
assert.deepStrictEqual(summarizePerHosterFromBatch(summary)['voe.sx'], { ok: 1, fail: 0, skipped: 1 });
const generic = JSON.parse(buildWebhookRequest('https://example.com/hook', summary, {}).body);
assert.equal(generic.skipped, 1);
});
test('buildWebhookRequest localizes Discord content from the selected language', () => { test('buildWebhookRequest localizes Discord content from the selected language', () => {
const req = buildWebhookRequest('https://discord.com/api/webhooks/1/x', SAMPLE_SUMMARY, { language: 'de' }); const req = buildWebhookRequest('https://discord.com/api/webhooks/1/x', SAMPLE_SUMMARY, { language: 'de' });
const body = JSON.parse(req.body); const body = JSON.parse(req.body);
@@ -119,7 +139,7 @@ test('buildWebhookRequest produces raw JSON payload for generic URLs', () => {
assert.strictEqual(body.failed, 2); assert.strictEqual(body.failed, 2);
assert.strictEqual(body.durationSec, 60); assert.strictEqual(body.durationSec, 60);
assert.strictEqual(body.version, '3.3.59'); assert.strictEqual(body.version, '3.3.59');
assert.deepStrictEqual(body.perHoster['byse.sx'], { ok: 1, fail: 1 }); assert.deepStrictEqual(body.perHoster['byse.sx'], { ok: 1, fail: 1, skipped: 0 });
}); });
test('resolveDiscordMention: @here / @everyone use parse=everyone', () => { test('resolveDiscordMention: @here / @everyone use parse=everyone', () => {