Harden cross-auth recovery and upload admission
Use stable Doodstream API identities across separate Web and API profiles, serialize ambiguous VOE and Doodstream mixed-auth recovery paths, and fail closed after uncertain post-upload response failures. Enforce host upload intervals only after recovery and concurrency admission, and clear batch-scoped recovery caches when the batch settles.
This commit is contained in:
@@ -393,7 +393,7 @@ class DoodstreamUploader {
|
||||
|
||||
let uploadRes;
|
||||
try {
|
||||
uploadRes = await request(uploadUrl, {
|
||||
uploadRes = await this._requestUpload(uploadUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
||||
@@ -412,7 +412,8 @@ class DoodstreamUploader {
|
||||
phase: 'upload-request',
|
||||
endpoint: uploadUrl,
|
||||
retryable: true,
|
||||
transientNetwork: true
|
||||
transientNetwork: true,
|
||||
remoteCommitUncertain: true
|
||||
});
|
||||
}
|
||||
|
||||
@@ -429,7 +430,18 @@ class DoodstreamUploader {
|
||||
}
|
||||
}
|
||||
|
||||
const resText = await uploadRes.body.text();
|
||||
let resText;
|
||||
try {
|
||||
resText = await uploadRes.body.text();
|
||||
} catch {
|
||||
throw createTransportError('Doodstream Upload-Antwort konnte nicht gelesen werden', {
|
||||
phase: 'upload-response-read',
|
||||
endpoint: uploadUrl,
|
||||
retryable: true,
|
||||
transientNetwork: true,
|
||||
remoteCommitUncertain: true
|
||||
});
|
||||
}
|
||||
const uploadContentType = uploadRes.headers && uploadRes.headers['content-type'];
|
||||
_debugLog(`Upload response: ${summarizeResponse(resText, uploadContentType)}`);
|
||||
|
||||
@@ -451,13 +463,34 @@ class DoodstreamUploader {
|
||||
return this._parseUploadResponse(resText);
|
||||
}
|
||||
|
||||
_requestUpload(url, options) {
|
||||
return request(url, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Follow a redirect URL from upload server and extract filecode
|
||||
*/
|
||||
async _handleUploadResult(url) {
|
||||
_debugLog(`Following upload result URL: ${safeEndpoint(url) || 'unknown endpoint'}`);
|
||||
const res = await this._fetch(url);
|
||||
const html = await res.text();
|
||||
let res;
|
||||
try {
|
||||
res = await this._fetch(url);
|
||||
} catch (error) {
|
||||
if (error && typeof error === 'object') error.remoteCommitUncertain = true;
|
||||
throw error;
|
||||
}
|
||||
let html;
|
||||
try {
|
||||
html = await res.text();
|
||||
} catch {
|
||||
throw createTransportError('Doodstream Ergebnis-Antwort konnte nicht gelesen werden', {
|
||||
phase: 'upload-response-read',
|
||||
endpoint: url,
|
||||
retryable: true,
|
||||
transientNetwork: true,
|
||||
remoteCommitUncertain: true
|
||||
});
|
||||
}
|
||||
const contentType = res.headers && typeof res.headers.get === 'function' ? res.headers.get('content-type') : '';
|
||||
_debugLog(`Result page: ${summarizeResponse(html, contentType)}`);
|
||||
return this._parseUploadResponse(html);
|
||||
|
||||
@@ -79,6 +79,7 @@ function createTransportError(message, options = {}) {
|
||||
if (options.hosterTransient === true) error.hosterTransient = true;
|
||||
if (options.accountError === true) error.accountError = true;
|
||||
if (options.fileRejected === true) error.fileRejected = true;
|
||||
if (options.remoteCommitUncertain === true) error.remoteCommitUncertain = true;
|
||||
return error;
|
||||
}
|
||||
|
||||
|
||||
+62
-12
@@ -3,7 +3,7 @@ const path = require('path');
|
||||
const { assertUploadConfirmation } = require('./upload-confirmation');
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
const { uploadFile, prefetchBaseline, createRecoveryClaimRegistry } = require('./hosters');
|
||||
const { uploadFile, prefetchBaseline, createRecoveryClaimRegistry, normalizeRecoveryTitle } = require('./hosters');
|
||||
const VidmolyUploader = require('./vidmoly-upload');
|
||||
const VoeUploader = require('./voe-upload');
|
||||
const DoodstreamUploader = require('./doodstream-upload');
|
||||
@@ -53,6 +53,7 @@ class UploadManager extends EventEmitter {
|
||||
this._doodApiKeyCache = new Map(); // accountId/username -> derived doodstream API key ('' = tried, none)
|
||||
this._baselineCache = new Map(); // hoster:apiKey -> Promise<Set<file_code>> (one fetch shared across all jobs in batch)
|
||||
this._recoveryClaims = createRecoveryClaimRegistry();
|
||||
this._recoveryAuthModes = new Map();
|
||||
this._batchJobIds = new Set();
|
||||
this._batchTotal = 0;
|
||||
}
|
||||
@@ -72,6 +73,7 @@ class UploadManager extends EventEmitter {
|
||||
this._doodApiKeyCache.clear();
|
||||
this._baselineCache.clear();
|
||||
if (!this.running) this._recoveryClaims.clear();
|
||||
if (!this.running) this._recoveryAuthModes.clear();
|
||||
}
|
||||
|
||||
switchAccount(hoster, fallbackAccount) {
|
||||
@@ -392,6 +394,7 @@ class UploadManager extends EventEmitter {
|
||||
this._baselineCache.clear(); // re-fetch baselines per batch (a long batch could outlast remote-side relevance)
|
||||
this._recoveryClaims.clear();
|
||||
this._recoveryClaims = createRecoveryClaimRegistry();
|
||||
this._recoveryAuthModes.clear();
|
||||
this.semaphores = {};
|
||||
this.globalSemaphore = null;
|
||||
this.globalThrottle = null;
|
||||
@@ -478,6 +481,9 @@ class UploadManager extends EventEmitter {
|
||||
};
|
||||
|
||||
this._recoveryClaims.clear();
|
||||
this._recoveryAuthModes.clear();
|
||||
this._doodApiKeyCache.clear();
|
||||
this._baselineCache.clear();
|
||||
this.emit('batch-done', summary);
|
||||
}
|
||||
|
||||
@@ -597,10 +603,6 @@ class UploadManager extends EventEmitter {
|
||||
headHex: fileProbe && fileProbe.headHex ? fileProbe.headHex.slice(0, 32) : null
|
||||
});
|
||||
|
||||
if (settings.timeIntervalSec > 0) {
|
||||
await this._waitForInterval(task.hoster, settings.timeIntervalSec * 1000, signal);
|
||||
}
|
||||
|
||||
// Pre-job-swap: if this account was marked failed WHILE this task was
|
||||
// waiting in the semaphore queue, jump straight to the override instead
|
||||
// of burning a guaranteed-to-fail upload attempt. Critical at scale:
|
||||
@@ -1273,6 +1275,10 @@ class UploadManager extends EventEmitter {
|
||||
retryAdmission = true;
|
||||
return null;
|
||||
}
|
||||
const settings = this._getSettings(task.hoster);
|
||||
if (settings.timeIntervalSec > 0) {
|
||||
await this._waitForInterval(task.hoster, settings.timeIntervalSec * 1000, signal);
|
||||
}
|
||||
try {
|
||||
return await this._executeUpload(task, progressCb, signal, throttle, fileProbe, context);
|
||||
} catch (err) {
|
||||
@@ -1312,24 +1318,24 @@ class UploadManager extends EventEmitter {
|
||||
if ((task.hoster === 'vidmoly.me' || task.hoster === 'voe.sx') && task.username) {
|
||||
const accountIdentity = this._recoveryAccountIdentity(task);
|
||||
return {
|
||||
recoveryClaim: this._recoveryClaims.forUpload(task.hoster, accountIdentity, fileName),
|
||||
recoveryClaim: this._createRecoveryClaim(task, accountIdentity, fileName),
|
||||
doodApiKey: null
|
||||
};
|
||||
}
|
||||
if (task.hoster === 'doodstream.com' && task.username) {
|
||||
const doodApiKey = await this._resolveDoodstreamApiKey(task);
|
||||
const accountIdentity = this._recoveryAccountIdentity(task, doodApiKey);
|
||||
const accountIdentity = doodApiKey || this._recoveryAccountIdentity(task);
|
||||
return {
|
||||
recoveryClaim: this._recoveryClaims.forUpload(task.hoster, accountIdentity, fileName),
|
||||
recoveryClaim: this._createRecoveryClaim(task, accountIdentity, fileName),
|
||||
doodApiKey
|
||||
};
|
||||
}
|
||||
if (task.hoster === 'byse.sx' || task.hoster === 'doodstream.com' || task.hoster === 'voe.sx') {
|
||||
const accountIdentity = task.hoster === 'byse.sx'
|
||||
? task.apiKey
|
||||
: this._recoveryAccountIdentity(task);
|
||||
: (task.hoster === 'doodstream.com' ? task.apiKey : this._recoveryAccountIdentity(task));
|
||||
return {
|
||||
recoveryClaim: this._recoveryClaims.forUpload(task.hoster, accountIdentity, fileName),
|
||||
recoveryClaim: this._createRecoveryClaim(task, accountIdentity, fileName),
|
||||
doodApiKey: null
|
||||
};
|
||||
}
|
||||
@@ -1337,10 +1343,54 @@ class UploadManager extends EventEmitter {
|
||||
}
|
||||
|
||||
_recoveryAccountIdentity(task, fallbackIdentity = null) {
|
||||
for (const value of [task.accountId, task.apiKey, fallbackIdentity]) {
|
||||
for (const value of [task.accountId, task.apiKey, fallbackIdentity, task.username]) {
|
||||
if (value !== null && value !== undefined && String(value).trim()) return value;
|
||||
}
|
||||
return String(task.username || '').normalize('NFKC').trim().toLowerCase();
|
||||
return '';
|
||||
}
|
||||
|
||||
_createRecoveryClaim(task, accountIdentity, fileName) {
|
||||
const accountClaim = this._recoveryClaims.forUpload(task.hoster, accountIdentity, fileName);
|
||||
if (task.hoster !== 'doodstream.com' && task.hoster !== 'voe.sx') return accountClaim;
|
||||
const hosterClaim = this._recoveryClaims.forUpload(task.hoster, 'mixed-auth-recovery-boundary', fileName);
|
||||
const modeKey = `${task.hoster}\0${normalizeRecoveryTitle(fileName)}`;
|
||||
let modeState = this._recoveryAuthModes.get(modeKey);
|
||||
if (!modeState) {
|
||||
modeState = new Set();
|
||||
this._recoveryAuthModes.set(modeKey, modeState);
|
||||
}
|
||||
const authMode = task.username ? 'login' : 'api';
|
||||
return {
|
||||
has(code) {
|
||||
return accountClaim.has(code);
|
||||
},
|
||||
reserve(code) {
|
||||
return accountClaim.reserve(code);
|
||||
},
|
||||
markUncertain(error) {
|
||||
hosterClaim.markUncertain(error);
|
||||
return accountClaim.markUncertain(error);
|
||||
},
|
||||
isUncertain() {
|
||||
return hosterClaim.isUncertain() || accountClaim.isUncertain();
|
||||
},
|
||||
runExclusive(operation, signal) {
|
||||
return hosterClaim.runExclusive(
|
||||
async () => {
|
||||
if (Array.from(modeState).some(mode => mode !== authMode)) {
|
||||
const error = new Error('Gemischte Upload-Anmeldungen für denselben Remote-Titel wurden sicher blockiert');
|
||||
error.remoteCommitUncertain = true;
|
||||
error.hosterTransient = true;
|
||||
throw error;
|
||||
}
|
||||
const result = await accountClaim.runExclusive(operation, signal);
|
||||
if (result !== null && result !== undefined) modeState.add(authMode);
|
||||
return result;
|
||||
},
|
||||
signal
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async _executeUploadUnchecked(task, progressCb, signal, throttle, fileProbe, context) {
|
||||
|
||||
Reference in New Issue
Block a user