Harden cross-auth upload recovery claims

Route VOE API and login uploads through one batch-scoped account claim registry so direct identities and uncertain outcomes are shared across authentication paths.

Fail closed for ambiguous Doodstream web uploads after POST, use canonical account identities across web and API paths, and singleflight derived API-key resolution outside upload semaphore admission.

Preserve distinct symbol-only recovery titles with a stable code-point fallback while matching Unicode-equivalent presentation forms. Add deterministic regression coverage for duplicate identities, uncertain successors, concurrent key resolution, and mixed auth paths.
This commit is contained in:
Sucukdeluxe
2026-08-13 23:31:04 +02:00
parent c800cbe02f
commit 3767a8b81f
4 changed files with 350 additions and 24 deletions
+6 -1
View File
@@ -575,12 +575,17 @@ async function _fetchByseFileList(apiKey, signal, phase = 'recovery-poll') {
}
function _normalizeFileTitle(s) {
return String(s || '')
const normalized = String(s || '')
.normalize('NFKD')
.toLowerCase()
.replace(/\.[\p{Letter}\p{Number}]+$/u, '')
.replace(/\p{Variation_Selector}+/gu, '');
const alphanumeric = normalized
.replace(/\p{Mark}+/gu, '')
.replace(/[^\p{Letter}\p{Number}]+/gu, '');
if (alphanumeric) return alphanumeric;
const codePoints = Array.from(normalized, value => value.codePointAt(0).toString(16)).join('-');
return `symbols:${codePoints}`;
}
function _normalizeRecoveryHoster(value) {
+54 -22
View File
@@ -1310,9 +1310,7 @@ class UploadManager extends EventEmitter {
async _createRecoveryContext(task) {
const fileName = path.basename(task.file);
if ((task.hoster === 'vidmoly.me' || task.hoster === 'voe.sx') && task.username) {
const accountIdentity = task.accountId !== null && task.accountId !== undefined
? task.accountId
: String(task.username || '').trim().toLowerCase();
const accountIdentity = this._recoveryAccountIdentity(task);
return {
recoveryClaim: this._recoveryClaims.forUpload(task.hoster, accountIdentity, fileName),
doodApiKey: null
@@ -1320,23 +1318,31 @@ class UploadManager extends EventEmitter {
}
if (task.hoster === 'doodstream.com' && task.username) {
const doodApiKey = await this._resolveDoodstreamApiKey(task);
const accountIdentity = doodApiKey || (task.accountId !== null && task.accountId !== undefined
? task.accountId
: String(task.username || '').trim().toLowerCase());
const accountIdentity = this._recoveryAccountIdentity(task, doodApiKey);
return {
recoveryClaim: this._recoveryClaims.forUpload(task.hoster, accountIdentity, fileName),
doodApiKey
};
}
if (task.hoster === 'byse.sx' || task.hoster === 'doodstream.com') {
if (task.hoster === 'byse.sx' || task.hoster === 'doodstream.com' || task.hoster === 'voe.sx') {
const accountIdentity = task.hoster === 'byse.sx'
? task.apiKey
: this._recoveryAccountIdentity(task);
return {
recoveryClaim: this._recoveryClaims.forUpload(task.hoster, task.apiKey, fileName),
recoveryClaim: this._recoveryClaims.forUpload(task.hoster, accountIdentity, fileName),
doodApiKey: null
};
}
return { recoveryClaim: null, doodApiKey: null };
}
_recoveryAccountIdentity(task, fallbackIdentity = null) {
for (const value of [task.accountId, task.apiKey, fallbackIdentity]) {
if (value !== null && value !== undefined && String(value).trim()) return value;
}
return String(task.username || '').normalize('NFKC').trim().toLowerCase();
}
async _executeUploadUnchecked(task, progressCb, signal, throttle, fileProbe, context) {
if (task.hoster === 'vidmoly.me' && task.username) {
return this._executeRecoveryAwareLoginUpload(task, VidmolyUploader, progressCb, signal, throttle, context.recoveryClaim);
@@ -1357,7 +1363,15 @@ class UploadManager extends EventEmitter {
this._rotLog('doodstream-via-web', { accountId: task.accountId, fileName: path.basename(task.file) });
const dood = new DoodstreamUploader();
await dood.login(task.username, task.password);
const result = await dood.upload(task.file, progressCb, signal, throttle);
let result;
try {
result = await dood.upload(task.file, progressCb, signal, throttle);
} catch (err) {
if (context.recoveryClaim && this._isDoodstreamRemoteCommitUncertain(err)) {
throw context.recoveryClaim.markUncertain(err);
}
throw err;
}
if (result && result.file_code && !context.recoveryClaim.reserve(result.file_code)) {
const error = new Error('Doodstream Upload lieferte eine bereits zugeordnete Remote-Identität');
error.remoteIdentityClaimed = true;
@@ -1368,7 +1382,7 @@ class UploadManager extends EventEmitter {
const clouddrop = new ClouddropUploader(task.apiKey);
return clouddrop.upload(task.file, progressCb, signal, throttle);
} else {
if (task.hoster === 'byse.sx' || task.hoster === 'doodstream.com') {
if (task.hoster === 'byse.sx' || task.hoster === 'doodstream.com' || task.hoster === 'voe.sx') {
return this._executeRecoveryAwareApiUpload(task.hoster, task.file, task.apiKey, progressCb, signal, throttle, fileProbe, context.recoveryClaim);
}
return uploadFile(task.hoster, task.file, task.apiKey, progressCb, signal, throttle, {});
@@ -1386,12 +1400,22 @@ class UploadManager extends EventEmitter {
if (hosterName === 'byse.sx') {
options.byseBaseline = await this._getBaseline(hosterName, apiKey, signal);
if (fileProbe && fileProbe.ok !== false) options.probeIsVideoLike = fileProbe.isVideoLike === true;
} else {
} else if (hosterName === 'doodstream.com') {
options.doodBaseline = await this._getBaseline(hosterName, apiKey, signal);
}
return uploadFile(hosterName, filePath, apiKey, progressCb, signal, throttle, options);
}
_isDoodstreamRemoteCommitUncertain(error) {
if (!error || typeof error !== 'object') return false;
if (error.remoteCommitUncertain === true) return true;
if (error.accountError === true || error.fileRejected === true) return false;
const phase = error.diagnostic && error.diagnostic.phase;
if (phase === 'upload-request') return true;
return (phase === 'upload-response' || phase === 'upload-result-submit' || phase === 'upload-result')
&& (error.hosterTransient === true || error.transientNetwork === true);
}
_getBaseline(hosterName, apiKey, signal) {
if (!apiKey) return Promise.resolve(null);
const key = `${hosterName}:${apiKey}`;
@@ -1408,19 +1432,27 @@ class UploadManager extends EventEmitter {
// so a 40-file batch logs in + derives ONCE, not per file). The empty-string
// sentinel distinguishes "tried, none" from "not yet tried" (undefined).
async _resolveDoodstreamApiKey(task) {
const cacheKey = task.accountId || task.username;
const accountId = task.accountId !== null && task.accountId !== undefined
? String(task.accountId).normalize('NFKC').trim()
: '';
const cacheKey = accountId
? `account:${accountId}`
: `username:${String(task.username || '').normalize('NFKC').trim().toLowerCase()}`;
const cached = this._doodApiKeyCache.get(cacheKey);
if (cached !== undefined) return cached || null;
if (cached !== undefined) return (await cached) || null;
let key = '';
try {
const probe = new DoodstreamUploader();
await probe.login(task.username, task.password);
key = (await probe.deriveApiKey()) || '';
} catch {
key = '';
}
this._doodApiKeyCache.set(cacheKey, key);
const pending = (async () => {
try {
const probe = new DoodstreamUploader();
await probe.login(task.username, task.password);
return (await probe.deriveApiKey()) || '';
} catch {
return '';
}
})();
this._doodApiKeyCache.set(cacheKey, pending);
const key = await pending;
if (this._doodApiKeyCache.get(cacheKey) === pending) this._doodApiKeyCache.set(cacheKey, key);
return key || null;
}