release: Multi-Hoster-Upload 2.1.7

This commit is contained in:
Sucukdeluxe
2026-08-11 16:10:14 +02:00
parent f6b20a8bc8
commit 622460d844
28 changed files with 2368 additions and 80 deletions
+4 -1
View File
@@ -76,6 +76,7 @@ const DEFAULTS = {
scaleParallelUploads: false,
lastBrowseDirectory: '',
removeFromQueueOnDone: false,
deleteSourceAfterSuccessfulUpload: false,
showDropTarget: false,
globalMaxSpeedKbs: 0, // 0 = unlimited global speed
pendingQueue: null,
@@ -689,7 +690,9 @@ class ConfigStore {
if (this._historyMigrated) {
return this._enqueueHistoryWrite(() => {
const cur = this._readHistoryFile();
if (cur === null && fs.existsSync(this.historyPath)) return;
if (cur === null && fs.existsSync(this.historyPath)) {
throw new Error('Die Verlaufsdatei ist beschädigt und wurde nicht verändert');
}
const arr = cur || [];
arr.push(entry);
const gs = this.load().globalSettings;
+4 -4
View File
@@ -270,7 +270,7 @@ class DoodstreamUploader {
const urlHints = (html.match(/https?:\/\/[^'">\s]+/g) || []).slice(0, 4).join(' , ');
_debugLog(`upload_server: NO SERVER. upload-page html(2000)=${(html || '').slice(0, 2000)}`);
throw new Error(
`Doodstream: konnte Upload-Server nicht ermitteln (Endpoint geaendert?). ` +
`Doodstream: konnte Upload-Server nicht ermitteln (Endpoint geändert?). ` +
`op=upload_server status=${res.status} ctype=${ctype} body=${(text || '').slice(0, 300)} ` +
`| upload-page URL-Treffer: ${urlHints || 'keine'}`
);
@@ -536,7 +536,7 @@ class DoodstreamUploader {
// session and later batches hit `pre-job-swap-blocked` for no fault of
// the account). The flag is the primary signal; the message text is a
// belt-and-suspenders regex fallback in the classifier.
const emptyLinkErr = new Error(`Doodstream Upload: kein Filecode — Server gab leeren Link zurueck (st=${st || '?'}, fn=${fnInfo}, CDN=${node}). CDN-Antwort: ${(resText || '').slice(0, 200)}`);
const emptyLinkErr = new Error(`Doodstream Upload: kein Filecode — Server gab leeren Link zurück (st=${st || '?'}, fn=${fnInfo}, CDN=${node}). CDN-Antwort: ${(resText || '').slice(0, 200)}`);
emptyLinkErr.hosterTransient = true;
throw emptyLinkErr;
}
@@ -563,10 +563,10 @@ class DoodstreamUploader {
// Check if fn was in original hidden fields
if (fnCode && fnCode.length >= 8) return this._buildResult(fnCode);
throw new Error(`Doodstream Upload: Redirect-Antwort ungueltig (${followText.slice(0, 150)})`);
throw new Error(`Doodstream Upload: Redirect-Antwort ungültig (${followText.slice(0, 150)})`);
}
throw new Error(`Doodstream Upload: Keine gueltige Antwort (Body: ${resText.slice(0, 150)})`);
throw new Error(`Doodstream Upload: Keine gültige Antwort (Body: ${resText.slice(0, 150)})`);
}
/**
+1 -1
View File
@@ -403,7 +403,7 @@ async function getUploadServer(hosterName, hosterConfig, apiKey, signal) {
if (lastTransient) e.transientNetwork = true;
throw e;
}
throw new Error('Kein Upload-Server erhalten. API-Key pruefen.');
throw new Error('Kein Upload-Server erhalten. API-Key prüfen.');
}
async function _fetchByseFileList(apiKey, signal) {
+223
View File
@@ -0,0 +1,223 @@
(function (root) {
'use strict';
const pathApi = typeof require === 'function' ? require('path') : null;
const protectedStatuses = new Set(['done', 'error', 'aborted', 'skipped']);
function normalizeFile(file, platform) {
const value = typeof file === 'string' ? file.trim() : '';
if (!value) return '';
if (platform === 'win32') {
if (pathApi) return pathApi.win32.resolve(value);
return value.replace(/\//g, '\\');
}
if (pathApi) return pathApi.resolve(value);
return value;
}
function normalizeHoster(hoster) {
return typeof hoster === 'string' ? hoster.trim().toLowerCase() : '';
}
function uniqueHosters(values) {
const hosters = [];
const seen = new Set();
for (const value of values) {
const hoster = normalizeHoster(value);
if (!hoster || seen.has(hoster)) continue;
seen.add(hoster);
hosters.push(hoster);
}
return hosters;
}
function cloneFingerprint(fingerprint) {
if (!fingerprint || typeof fingerprint !== 'object' || Array.isArray(fingerprint)) return null;
return { ...fingerprint };
}
function relatedJobs(queueJobs, job, platform) {
if (!Array.isArray(queueJobs) || !job) return [];
const file = normalizeFile(job.file, platform);
const token = typeof job.sourceCleanupToken === 'string' && job.sourceCleanupToken
? job.sourceCleanupToken
: null;
return queueJobs.filter((candidate) => {
if (!candidate) return false;
if (file && normalizeFile(candidate.file, platform) === file) return true;
return token !== null && candidate.sourceCleanupToken === token;
});
}
function storedRequiredHosters(jobs) {
const values = [];
for (const job of jobs) {
if (Array.isArray(job.sourceCleanupRequiredHosters)) {
values.push(...job.sourceCleanupRequiredHosters);
}
}
return uniqueHosters(values);
}
function completedHosters(jobs, requiredHosters) {
const values = [];
for (const job of jobs) {
if (Array.isArray(job.sourceCleanupCompletedHosters)) {
values.push(...job.sourceCleanupCompletedHosters);
}
}
for (const job of jobs) {
if (job.status === 'done') values.push(job.hoster);
}
const completed = new Set(uniqueHosters(values));
return requiredHosters.filter((hoster) => completed.has(hoster));
}
function storedToken(jobs) {
for (const job of jobs) {
if (typeof job.sourceCleanupToken === 'string' && job.sourceCleanupToken) {
return job.sourceCleanupToken;
}
}
return null;
}
function storedFingerprint(jobs) {
for (const job of jobs) {
const fingerprint = cloneFingerprint(job.sourceCleanupFingerprint);
if (fingerprint) return fingerprint;
}
return null;
}
function assignMetadata(jobs, token, requiredHosters, completed, fingerprint, touchedJobs, touchedSet) {
for (const job of jobs) {
job.sourceCleanupToken = token;
job.sourceCleanupRequiredHosters = [...requiredHosters];
job.sourceCleanupCompletedHosters = [...completed];
job.sourceCleanupFingerprint = cloneFingerprint(fingerprint);
if (!touchedSet.has(job)) {
touchedSet.add(job);
touchedJobs.push(job);
}
}
}
function prepareGroups(queueJobs, jobsToStart, createToken, platform) {
const groups = [];
const touchedJobs = [];
const touchedSet = new Set();
const preparedFiles = new Set();
if (!Array.isArray(queueJobs) || !Array.isArray(jobsToStart)) return { groups, touchedJobs };
for (const selectedJob of jobsToStart) {
const file = normalizeFile(selectedJob && selectedJob.file, platform);
if (!file || preparedFiles.has(file)) continue;
const siblings = relatedJobs(queueJobs, selectedJob, platform);
if (siblings.length === 0) continue;
preparedFiles.add(file);
const token = storedToken(siblings) || (typeof createToken === 'function' ? createToken(file) : null);
if (typeof token !== 'string' || !token) continue;
const persistedRequired = storedRequiredHosters(siblings);
const requiredHosters = uniqueHosters([
...persistedRequired,
...siblings.map((job) => job.hoster)
]);
const completed = completedHosters(siblings, requiredHosters);
const fingerprint = storedFingerprint(siblings);
assignMetadata(
siblings,
token,
requiredHosters,
completed,
fingerprint,
touchedJobs,
touchedSet
);
groups.push({
token,
file: selectedJob.file,
requiredHosters: [...requiredHosters],
completedHosters: [...completed],
fingerprint: cloneFingerprint(fingerprint),
jobs: siblings.map((job) => ({
jobId: job.id,
hoster: normalizeHoster(job.hoster),
status: job.status
}))
});
}
return { groups, touchedJobs };
}
function markCompleted(queueJobs, job, platform) {
const siblings = relatedJobs(queueJobs, job, platform);
if (siblings.length === 0) return [];
const requiredHosters = storedRequiredHosters(siblings);
const completed = new Set(completedHosters(siblings, requiredHosters));
const hoster = normalizeHoster(job.hoster);
if (requiredHosters.includes(hoster)) completed.add(hoster);
const orderedCompleted = requiredHosters.filter((required) => completed.has(required));
for (const sibling of siblings) {
sibling.sourceCleanupCompletedHosters = [...orderedCompleted];
}
return siblings;
}
function removeRequirement(queueJobs, job, platform) {
if (!job || protectedStatuses.has(job.status)) return [];
const siblings = relatedJobs(queueJobs, job, platform);
const removedHoster = normalizeHoster(job.hoster);
for (const sibling of siblings) {
const required = Array.isArray(sibling.sourceCleanupRequiredHosters)
? sibling.sourceCleanupRequiredHosters
: [];
const completed = Array.isArray(sibling.sourceCleanupCompletedHosters)
? sibling.sourceCleanupCompletedHosters
: [];
sibling.sourceCleanupRequiredHosters = uniqueHosters(required)
.filter((hoster) => hoster !== removedHoster);
sibling.sourceCleanupCompletedHosters = uniqueHosters(completed)
.filter((hoster) => hoster !== removedHoster);
}
return siblings;
}
function fingerprintFor(fingerprints, token) {
if (fingerprints instanceof Map) return fingerprints.get(token);
if (!fingerprints || typeof fingerprints !== 'object') return undefined;
return Object.prototype.hasOwnProperty.call(fingerprints, token)
? fingerprints[token]
: undefined;
}
function applyFingerprints(queueJobs, fingerprints) {
if (!Array.isArray(queueJobs)) return [];
const touchedJobs = [];
for (const job of queueJobs) {
if (!job || typeof job.sourceCleanupToken !== 'string' || !job.sourceCleanupToken) continue;
const fingerprint = fingerprintFor(fingerprints, job.sourceCleanupToken);
if (fingerprint === undefined) continue;
job.sourceCleanupFingerprint = cloneFingerprint(fingerprint);
touchedJobs.push(job);
}
return touchedJobs;
}
const api = {
prepareGroups,
markCompleted,
removeRequirement,
applyFingerprints
};
if (typeof module !== 'undefined' && module.exports) {
module.exports = api;
} else if (root) {
root.SourceCleanupPolicy = api;
}
})(typeof window !== 'undefined' ? window : this);
+106
View File
@@ -0,0 +1,106 @@
const fs = require('node:fs');
const path = require('node:path');
class SourceDeleteJournal {
constructor(filePath, onEvent, options = {}) {
this.filePath = filePath;
this.onEvent = typeof onEvent === 'function' ? onEvent : () => {};
this.fs = options.fs || fs;
this.wait = typeof options.wait === 'function' ? options.wait : (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
this.pending = Promise.resolve();
}
append(entry) {
const operation = this.pending.then(async () => {
await this.fs.promises.mkdir(path.dirname(this.filePath), { recursive: true });
const handle = await this.fs.promises.open(this.filePath, 'a');
try {
await handle.writeFile(`${JSON.stringify(entry)}\n`, 'utf-8');
await handle.sync();
} finally {
await handle.close();
}
});
this.pending = operation.catch(() => {});
return operation;
}
plan(entry) {
return this.append({ action: 'plan', timestamp: new Date().toISOString(), ...entry });
}
clear(token) {
return this.append({ action: 'clear', timestamp: new Date().toISOString(), token });
}
async recover() {
await this.pending;
let text;
try {
text = await this.fs.promises.readFile(this.filePath, 'utf-8');
} catch (error) {
if (error && error.code === 'ENOENT') return [];
throw error;
}
const active = new Map();
for (const line of text.split(/\r?\n/)) {
if (!line.trim()) continue;
try {
const entry = JSON.parse(line);
if (!entry || typeof entry.token !== 'string') continue;
if (entry.action === 'plan') active.set(entry.token, entry);
if (entry.action === 'clear') active.delete(entry.token);
} catch {}
}
const outcomes = [];
for (const entry of active.values()) {
try {
const sourceExists = await this.exists(entry.file);
const stagedExists = await this.exists(entry.stagedFile);
if (!sourceExists && stagedExists) {
try {
await this.renameWithRetries(entry.stagedFile, entry.file);
outcomes.push({ token: entry.token, outcome: 'restored', file: entry.file });
await this.clear(entry.token);
} catch (error) {
outcomes.push({ token: entry.token, outcome: 'restore-failed', file: entry.file, error: error.message });
}
} else if (!stagedExists) {
outcomes.push({ token: entry.token, outcome: 'cleared', file: entry.file });
await this.clear(entry.token);
} else {
outcomes.push({ token: entry.token, outcome: 'conflict', file: entry.file, stagedFile: entry.stagedFile });
}
} catch (error) {
outcomes.push({ token: entry.token, outcome: 'recovery-failed', file: entry.file, error: error.message });
}
}
for (const outcome of outcomes) this.onEvent(outcome);
return outcomes;
}
async exists(target) {
try {
await this.fs.promises.lstat(target);
return true;
} catch (error) {
if (error && error.code === 'ENOENT') return false;
throw error;
}
}
async renameWithRetries(from, to) {
const delays = [100, 250, 500];
for (let attempt = 0; ; attempt += 1) {
try {
await this.fs.promises.rename(from, to);
return;
} catch (error) {
if (!error || !['EBUSY', 'EPERM'].includes(error.code) || attempt >= delays.length) throw error;
await this.wait(delays[attempt]);
}
}
}
}
module.exports = SourceDeleteJournal;
+455
View File
@@ -0,0 +1,455 @@
const RETRY_DELAYS = [100, 250, 500, 1000, 2000];
const TERMINAL_STATUSES = new Set(['done', 'error', 'aborted', 'skipped', 'failed', 'missing-account']);
const crypto = require('node:crypto');
function createSourceFileCleanup(options) {
if (!options || !options.fs || !options.path) {
throw new TypeError('createSourceFileCleanup requires fs and path');
}
const fs = options.fs;
const path = options.path;
const platform = options.platform || process.platform;
const isEnabled = typeof options.isEnabled === 'function' ? options.isEnabled : () => false;
const audit = typeof options.audit === 'function' ? options.audit : () => {};
const journal = options.journal || null;
const wait = typeof options.wait === 'function'
? options.wait
: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
const groups = new Map();
const leases = new Map();
function canonicalize(file) {
const resolved = path.resolve(file);
return platform === 'win32' ? resolved.toLowerCase() : resolved;
}
function cloneFingerprint(fingerprint) {
if (!fingerprint) return null;
return {
type: 'file',
size: fingerprint.size,
mtimeMs: fingerprint.mtimeMs,
birthtimeMs: fingerprint.birthtimeMs,
dev: fingerprint.dev,
ino: fingerprint.ino
};
}
function isFingerprint(fingerprint) {
return Boolean(
fingerprint &&
fingerprint.type === 'file' &&
Number.isFinite(fingerprint.size) &&
Number.isFinite(fingerprint.mtimeMs) &&
Number.isFinite(fingerprint.birthtimeMs) &&
Number.isFinite(fingerprint.dev) &&
Number.isFinite(fingerprint.ino)
);
}
function fingerprintFromStat(stat) {
if (!stat.isFile()) return null;
return {
type: 'file',
size: stat.size,
mtimeMs: stat.mtimeMs,
birthtimeMs: stat.birthtimeMs,
dev: stat.dev,
ino: stat.ino
};
}
function fingerprintsMatch(left, right) {
return Boolean(
left &&
right &&
left.type === right.type &&
left.size === right.size &&
left.mtimeMs === right.mtimeMs &&
left.birthtimeMs === right.birthtimeMs &&
left.dev === right.dev &&
left.ino === right.ino
);
}
function uniqueStrings(values) {
return [...new Set((Array.isArray(values) ? values : []).filter((value) => typeof value === 'string' && value.length > 0))];
}
function normalizeStatus(status) {
return TERMINAL_STATUSES.has(status) ? status : 'pending';
}
function createManifest(input, canonicalFile) {
const requiredHosters = uniqueStrings(input.requiredHosters);
const completedHosters = new Set(uniqueStrings(input.completedHosters));
const jobs = new Map();
for (const job of Array.isArray(input.jobs) ? input.jobs : []) {
if (!job || typeof job.jobId !== 'string' || typeof job.hoster !== 'string') continue;
const status = completedHosters.has(job.hoster) ? 'done' : normalizeStatus(job.status);
jobs.set(job.jobId, Object.freeze({
jobId: job.jobId,
hoster: job.hoster,
status
}));
}
for (const hoster of completedHosters) {
if (requiredHosters.includes(hoster)) continue;
completedHosters.delete(hoster);
}
return {
token: input.token || input.sourceCleanupToken,
file: path.resolve(input.file),
canonicalFile,
requiredHosters: Object.freeze(requiredHosters),
completedHosters,
jobs,
suppliedFingerprint: isFingerprint(input.fingerprint) ? cloneFingerprint(input.fingerprint) : null,
fingerprint: null,
registrationOutcome: null,
registrationError: null,
audited: false,
finalizationPromise: null
};
}
async function inspectRegistration(manifest) {
try {
const stat = await fs.promises.lstat(manifest.file);
const currentFingerprint = fingerprintFromStat(stat);
if (!currentFingerprint) {
manifest.registrationOutcome = 'unsafe-source-type';
return;
}
if (manifest.suppliedFingerprint && !fingerprintsMatch(manifest.suppliedFingerprint, currentFingerprint)) {
manifest.fingerprint = manifest.suppliedFingerprint;
manifest.registrationOutcome = 'source-changed';
return;
}
manifest.fingerprint = manifest.suppliedFingerprint || currentFingerprint;
} catch (error) {
manifest.registrationOutcome = error && error.code === 'ENOENT' ? 'source-missing' : 'failed';
manifest.registrationError = error;
}
}
async function registerGroups(inputGroups) {
const fingerprints = {};
for (const input of Array.isArray(inputGroups) ? inputGroups : []) {
const token = input && (input.token || input.sourceCleanupToken);
if (typeof token !== 'string' || token.length === 0 || typeof input.file !== 'string' || input.file.length === 0) {
throw new TypeError('source cleanup groups require token and file');
}
const existing = groups.get(token);
if (existing) {
if (existing.finalizationPromise) throw new Error('source cleanup group is already finalizing');
if (canonicalize(input.file) !== existing.canonicalFile) throw new Error('source cleanup token changed file');
existing.requiredHosters = Object.freeze(uniqueStrings([
...existing.requiredHosters,
...input.requiredHosters
]));
for (const hoster of uniqueStrings(input.completedHosters)) {
if (existing.requiredHosters.includes(hoster)) existing.completedHosters.add(hoster);
}
for (const job of Array.isArray(input.jobs) ? input.jobs : []) {
if (!job || typeof job.jobId !== 'string' || typeof job.hoster !== 'string') continue;
const previous = existing.jobs.get(job.jobId);
const status = existing.completedHosters.has(job.hoster)
? 'done'
: normalizeStatus(previous ? previous.status : job.status);
existing.jobs.set(job.jobId, Object.freeze({ jobId: job.jobId, hoster: job.hoster, status }));
}
fingerprints[token] = cloneFingerprint(existing.fingerprint);
continue;
}
const canonicalFile = canonicalize(input.file);
const manifest = createManifest(input, canonicalFile);
const leaseToken = leases.get(canonicalFile);
if (leaseToken && leaseToken !== token) {
manifest.registrationOutcome = 'blocked';
manifest.registrationError = new Error('active-source-lease');
const leaseOwner = groups.get(leaseToken);
if (leaseOwner) {
leaseOwner.registrationOutcome = 'blocked';
leaseOwner.registrationError = new Error('active-source-lease');
}
} else {
leases.set(canonicalFile, token);
await inspectRegistration(manifest);
}
groups.set(token, manifest);
fingerprints[token] = cloneFingerprint(manifest.fingerprint);
}
return fingerprints;
}
function settle(event) {
if (!event || !TERMINAL_STATUSES.has(event.status)) return false;
const token = event.token || event.sourceCleanupToken;
const manifest = groups.get(token);
if (!manifest || manifest.finalizationPromise) return false;
const job = manifest.jobs.get(event.jobId);
if (!job || job.hoster !== event.hoster) return false;
if (typeof event.file === 'string' && canonicalize(event.file) !== manifest.canonicalFile) return false;
manifest.jobs.set(event.jobId, Object.freeze({ ...job, status: event.status }));
if (event.status === 'done') manifest.completedHosters.add(event.hoster);
return true;
}
function markSkipped(jobId) {
let changed = false;
for (const manifest of groups.values()) {
if (manifest.finalizationPromise) continue;
const job = manifest.jobs.get(jobId);
if (!job) continue;
manifest.jobs.set(jobId, Object.freeze({ ...job, status: 'skipped' }));
changed = true;
}
return changed;
}
function blockingStatuses(manifest) {
const blocking = [];
for (const hoster of manifest.requiredHosters) {
if (manifest.completedHosters.has(hoster)) continue;
const statuses = [...manifest.jobs.values()]
.filter((job) => job.hoster === hoster)
.map((job) => job.status);
if (statuses.includes('done')) continue;
const status = statuses.find((value) => value !== 'pending') || 'pending';
blocking.push({ hoster, status });
}
return blocking;
}
async function emitAudit(manifest, outcome, details = {}) {
if (manifest.audited) return;
manifest.audited = true;
await writeAudit(manifest, outcome, details);
}
async function writeAudit(manifest, outcome, details = {}) {
const event = {
timestamp: new Date().toISOString(),
outcome,
file: manifest.file,
hosters: [...manifest.requiredHosters],
...details
};
if (event.error instanceof Error) event.error = event.error.message;
try {
return (await audit(event)) !== false;
} catch {
return false;
}
}
async function currentSourceOutcome(manifest) {
try {
const stat = await fs.promises.lstat(manifest.file);
const currentFingerprint = fingerprintFromStat(stat);
if (!currentFingerprint) return { outcome: 'unsafe-source-type', trigger: 'unsafe-source-type' };
if (!fingerprintsMatch(manifest.fingerprint, currentFingerprint)) {
return { outcome: 'source-changed', trigger: 'source-fingerprint-mismatch' };
}
return null;
} catch (error) {
if (error && error.code === 'ENOENT') return { outcome: 'source-missing', trigger: 'source-missing' };
return { outcome: 'failed', trigger: 'source-stat-failed', error };
}
}
async function unlinkWithRetries(file) {
let attempts = 0;
while (true) {
attempts += 1;
try {
await fs.promises.unlink(file);
return { attempts };
} catch (error) {
const retryable = platform === 'win32' && error && (error.code === 'EBUSY' || error.code === 'EPERM');
if (!retryable || attempts > RETRY_DELAYS.length) throw Object.assign(error, { cleanupAttempts: attempts });
await wait(RETRY_DELAYS[attempts - 1]);
}
}
}
async function restoreStagedFile(manifest, stagedFile) {
try {
await fs.promises.rename(stagedFile, manifest.file);
return null;
} catch (error) {
return error;
}
}
async function clearJournal(manifest) {
if (!journal || typeof journal.clear !== 'function') return;
try { await journal.clear(manifest.token); } catch {}
}
async function stageSource(manifest) {
const stagedFile = path.join(
path.dirname(manifest.file),
`.${path.basename(manifest.file)}.mhu-delete-${crypto.randomUUID()}`
);
if (!journal || typeof journal.plan !== 'function') {
return { outcome: 'blocked', trigger: 'delete-journal-unavailable' };
}
try {
await journal.plan({ token: manifest.token, file: manifest.file, stagedFile });
} catch (error) {
return { outcome: 'blocked', trigger: 'delete-journal-write-failed', error };
}
try {
await fs.promises.rename(manifest.file, stagedFile);
} catch (error) {
await clearJournal(manifest);
return {
outcome: error && error.code === 'ENOENT' ? 'source-missing' : 'failed',
trigger: error && error.code === 'ENOENT' ? 'source-missing' : 'source-stage-failed',
error
};
}
try {
const stat = await fs.promises.lstat(stagedFile);
const fingerprint = fingerprintFromStat(stat);
if (fingerprintsMatch(manifest.fingerprint, fingerprint)) return { stagedFile };
const restoreError = await restoreStagedFile(manifest, stagedFile);
if (!restoreError) await clearJournal(manifest);
return {
outcome: 'source-changed',
trigger: 'source-fingerprint-mismatch-after-stage',
error: restoreError,
stagedFile: restoreError ? stagedFile : undefined
};
} catch (error) {
const restoreError = await restoreStagedFile(manifest, stagedFile);
if (!restoreError) await clearJournal(manifest);
return {
outcome: 'failed',
trigger: 'staged-source-stat-failed',
error,
restoreError,
stagedFile: restoreError ? stagedFile : undefined
};
}
}
async function finalize(manifest, barriers) {
try {
let enabled;
try {
enabled = Boolean(await isEnabled());
} catch (error) {
await emitAudit(manifest, 'failed', { trigger: 'setting-check-failed', error });
return 'failed';
}
if (!enabled) {
await emitAudit(manifest, 'setting-disabled', { trigger: 'setting-disabled' });
return 'setting-disabled';
}
if (!barriers.historyPersisted || !barriers.queuePersisted) {
await emitAudit(manifest, 'blocked', {
trigger: 'persistence-barrier-incomplete',
historyPersisted: Boolean(barriers.historyPersisted),
queuePersisted: Boolean(barriers.queuePersisted)
});
return 'blocked';
}
if (manifest.registrationOutcome) {
await emitAudit(manifest, manifest.registrationOutcome, {
trigger: manifest.registrationOutcome === 'blocked' ? 'active-source-lease' : manifest.registrationOutcome,
error: manifest.registrationError
});
return manifest.registrationOutcome;
}
const blocking = blockingStatuses(manifest);
if (blocking.length > 0) {
await emitAudit(manifest, 'blocked', {
trigger: 'required-hoster-not-done',
blockingStatuses: blocking
});
return 'blocked';
}
const sourceOutcome = await currentSourceOutcome(manifest);
if (sourceOutcome) {
await emitAudit(manifest, sourceOutcome.outcome, sourceOutcome);
return sourceOutcome.outcome;
}
const auditPersisted = await writeAudit(manifest, 'delete-approved', {
trigger: 'all-selected-hosters-succeeded'
});
if (!auditPersisted) {
await emitAudit(manifest, 'blocked', { trigger: 'audit-write-failed' });
return 'blocked';
}
const staged = await stageSource(manifest);
if (!staged.stagedFile || staged.outcome) {
await emitAudit(manifest, staged.outcome || 'failed', staged);
return staged.outcome || 'failed';
}
const commitPersisted = await writeAudit(manifest, 'source-staged', {
trigger: 'verified-source-staged'
});
if (!commitPersisted) {
const restoreError = await restoreStagedFile(manifest, staged.stagedFile);
if (!restoreError) await clearJournal(manifest);
await emitAudit(manifest, 'blocked', {
trigger: 'audit-commit-write-failed',
restoreError,
stagedFile: restoreError ? staged.stagedFile : undefined
});
return 'blocked';
}
try {
const result = await unlinkWithRetries(staged.stagedFile);
await clearJournal(manifest);
await emitAudit(manifest, 'deleted', {
trigger: 'all-selected-hosters-succeeded',
attempts: result.attempts
});
return 'deleted';
} catch (error) {
const restoreError = await restoreStagedFile(manifest, staged.stagedFile);
if (!restoreError) await clearJournal(manifest);
await emitAudit(manifest, 'failed', {
trigger: 'unlink-failed',
attempts: error.cleanupAttempts || 1,
error,
restoreError,
stagedFile: restoreError ? staged.stagedFile : undefined
});
return 'failed';
}
} catch (error) {
await emitAudit(manifest, 'failed', { trigger: 'cleanup-failed', error });
return 'failed';
} finally {
if (leases.get(manifest.canonicalFile) === manifest.token) leases.delete(manifest.canonicalFile);
}
}
async function finishBatch(barriers = {}) {
const pending = [];
for (const manifest of groups.values()) {
if (!manifest.finalizationPromise) {
manifest.finalizationPromise = finalize(manifest, {
historyPersisted: barriers.historyPersisted === true,
queuePersisted: barriers.queuePersisted === true
});
}
pending.push(manifest.finalizationPromise);
}
return Promise.all(pending);
}
return Object.freeze({ registerGroups, settle, markSkipped, finishBatch });
}
module.exports = { createSourceFileCleanup };
+4 -4
View File
@@ -196,10 +196,10 @@ async function prepareUpdate(onProgress, options = {}) {
check = await checkForUpdate();
}
if (!check || !check.available) {
throw new Error('Kein Update verfuegbar');
throw new Error('Kein Update verfügbar');
}
if (!check.assetUrl || !check.assetName) {
throw new Error('Update-Asset unvollstaendig (URL oder Name fehlt)');
throw new Error('Update-Asset unvollständig (URL oder Name fehlt)');
}
// Stage: downloading
@@ -260,7 +260,7 @@ async function prepareUpdate(onProgress, options = {}) {
if (onProgress) onProgress({ stage: 'verifying', percent: 0 });
if (!verifyExeHeader(fileBuffer)) {
throw new Error('Heruntergeladene Datei ist keine gueltige EXE');
throw new Error('Heruntergeladene Datei ist keine gültige EXE');
}
// Optional SHA-512 verification from latest.yml
@@ -271,7 +271,7 @@ async function prepareUpdate(onProgress, options = {}) {
// Try hex comparison
const actualHex = crypto.createHash('sha512').update(fileBuffer).digest('hex');
if (actualHex !== expectedSha.toLowerCase()) {
throw new Error('SHA-512 Pruefung fehlgeschlagen');
throw new Error('SHA-512 Prüfung fehlgeschlagen');
}
}
}
+9
View File
@@ -447,11 +447,13 @@ class UploadManager extends EventEmitter {
let hosterSlotAcquired = false;
let globalSlotAcquired = false;
let finalResultRecorded = false;
let finalStatus = 'error';
let lastError = null;
const recordFinalResult = (status, payload = {}) => {
if (finalResultRecorded) return;
finalResultRecorded = true;
finalStatus = status;
const result = {
hoster: task.hoster,
@@ -1053,6 +1055,13 @@ class UploadManager extends EventEmitter {
// Release in reverse order of acquire (global first, then hoster)
if (globalSlotAcquired && globalSemaphore) globalSemaphore.release();
if (hosterSlotAcquired) hosterSemaphore.release();
this.emit('job-settled', {
jobId,
sourceCleanupToken: task.sourceCleanupToken || null,
file: task.file,
hoster: task.hoster,
status: finalStatus
});
}
}
+7 -2
View File
@@ -57,6 +57,7 @@ function resolveDiscordMention(raw) {
function buildWebhookRequest(url, summary, meta) {
const m = meta || {};
const language = String(m.language || 'en').toLowerCase() === 'de' ? 'de' : 'en';
const total = Number(summary && summary.total) || 0;
const succeeded = Number(summary && summary.succeeded) || 0;
const failed = Number(summary && summary.failed) || 0;
@@ -65,7 +66,9 @@ function buildWebhookRequest(url, summary, meta) {
let body;
if (isDiscordWebhook(url)) {
const headline = m.aborted ? 'Batch abgebrochen' : 'Batch fertig';
const headline = language === 'de'
? (m.aborted ? 'Batch abgebrochen' : 'Batch fertig')
: (m.aborted ? 'Batch canceled' : 'Batch completed');
const hosterEntries = Object.entries(perHoster);
const MAX_HOSTER_LINES = 12;
let hosterLines = hosterEntries.slice(0, MAX_HOSTER_LINES)
@@ -74,7 +77,9 @@ function buildWebhookRequest(url, summary, meta) {
if (hosterEntries.length > MAX_HOSTER_LINES) hosterLines += ` · …+${hosterEntries.length - MAX_HOSTER_LINES}`;
const lines = [
`**Multi-Hoster-Upload — ${headline}**${m.machineName ? ` (${m.machineName})` : ''}`,
`${succeeded} ok · ❌ ${failed} Fehler · 📦 ${total} gesamt · ⏱ ${duration}`
language === 'de'
? `${succeeded} ok · ❌ ${failed} Fehler · 📦 ${total} gesamt · ⏱ ${duration}`
: `${succeeded} succeeded · ❌ ${failed} failed · 📦 ${total} total · ⏱ ${duration}`
];
if (hosterLines) lines.push(hosterLines);
const mention = resolveDiscordMention(m.mention);