release: Multi-Hoster-Upload 2.1.7
This commit is contained in:
+4
-1
@@ -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;
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user