Coalesce concurrent and repeated Doodstream login checks by credential identity, keep the challenged cookie session for OTP verification, and rate-limit explicit code resends. Prefer an existing Doodstream API key during health checks and add regression coverage for imports with duplicate accounts, concurrent checks, expired challenges, and session reuse.
This commit is contained in:
+108
-1
@@ -1,3 +1,5 @@
|
||||
const { createHash } = require('node:crypto');
|
||||
|
||||
// Decides which credential an upload task should use for a given hoster.
|
||||
// Extracted from main.js buildTaskFromAccount so the routing can be unit-tested
|
||||
// without Electron.
|
||||
@@ -33,4 +35,109 @@ function selectUploadAuth(hoster, account) {
|
||||
return {};
|
||||
}
|
||||
|
||||
module.exports = { selectUploadAuth };
|
||||
function createDoodstreamOtpCoordinator(options = {}) {
|
||||
if (typeof options.createUploader !== 'function') throw new TypeError('createUploader is required');
|
||||
const now = typeof options.now === 'function' ? options.now : Date.now;
|
||||
const challengeTtlMs = Number.isFinite(Number(options.challengeTtlMs)) ? Math.max(1000, Number(options.challengeTtlMs)) : 10 * 60 * 1000;
|
||||
const resendCooldownMs = Number.isFinite(Number(options.resendCooldownMs)) ? Math.max(1000, Number(options.resendCooldownMs)) : 60 * 1000;
|
||||
const maxEntries = Number.isFinite(Number(options.maxEntries)) ? Math.max(1, Math.floor(Number(options.maxEntries))) : 1000;
|
||||
const states = new Map();
|
||||
|
||||
function credentialKey(username, password) {
|
||||
return createHash('sha256')
|
||||
.update(String(username || ''))
|
||||
.update('\0')
|
||||
.update(String(password || ''))
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
function storeState(key, state) {
|
||||
states.delete(key);
|
||||
states.set(key, state);
|
||||
while (states.size > maxEntries) states.delete(states.keys().next().value);
|
||||
}
|
||||
|
||||
function activeState(key) {
|
||||
const state = states.get(key);
|
||||
if (!state || state.inFlight) return state || null;
|
||||
if (state.expiresAt > now()) return state;
|
||||
states.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
async function check(input = {}) {
|
||||
const username = String(input.username || '');
|
||||
const password = String(input.password || '');
|
||||
const otp = String(input.otp || '').trim();
|
||||
const key = credentialKey(username, password);
|
||||
const existing = activeState(key);
|
||||
if (existing?.inFlight) return existing.inFlight;
|
||||
if (otp && !existing?.pending) {
|
||||
return {
|
||||
status: 'otp_required',
|
||||
message: 'OTP-Anfrage ist abgelaufen. Bitte einen neuen Code anfordern.'
|
||||
};
|
||||
}
|
||||
if (!otp && existing?.pending && input.requestNewChallenge !== true) return existing.result;
|
||||
if (!otp && existing?.pending && now() - existing.requestedAt < resendCooldownMs) return existing.result;
|
||||
|
||||
const uploader = otp ? existing.uploader : options.createUploader();
|
||||
const requestedAt = otp ? existing.requestedAt : now();
|
||||
const operationId = Symbol('doodstream-otp-check');
|
||||
const operation = (async () => {
|
||||
try {
|
||||
await uploader.login(username, password, otp || undefined);
|
||||
if (states.get(key)?.operationId === operationId) states.delete(key);
|
||||
return { status: 'ok', message: 'Login ok, Upload-Seite bereit' };
|
||||
} catch (error) {
|
||||
if (error?.otpRequired === true) {
|
||||
const result = { status: 'otp_required', message: error.message || 'OTP erforderlich' };
|
||||
if (states.get(key)?.operationId === operationId) {
|
||||
storeState(key, {
|
||||
operationId,
|
||||
uploader,
|
||||
pending: true,
|
||||
requestedAt: otp ? existing.requestedAt : requestedAt,
|
||||
expiresAt: now() + challengeTtlMs,
|
||||
result,
|
||||
inFlight: null
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
if (otp && existing?.pending) {
|
||||
const result = {
|
||||
status: 'otp_required',
|
||||
message: error?.message || 'OTP konnte nicht bestätigt werden'
|
||||
};
|
||||
if (states.get(key)?.operationId === operationId) {
|
||||
storeState(key, {
|
||||
...existing,
|
||||
operationId,
|
||||
uploader,
|
||||
result,
|
||||
inFlight: null
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
if (states.get(key)?.operationId === operationId) states.delete(key);
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
storeState(key, {
|
||||
operationId,
|
||||
uploader,
|
||||
pending: existing?.pending === true,
|
||||
requestedAt,
|
||||
expiresAt: existing?.expiresAt || (requestedAt + challengeTtlMs),
|
||||
result: existing?.result || null,
|
||||
inFlight: operation
|
||||
});
|
||||
return operation;
|
||||
}
|
||||
|
||||
return { check };
|
||||
}
|
||||
|
||||
module.exports = { createDoodstreamOtpCoordinator, selectUploadAuth };
|
||||
|
||||
@@ -122,9 +122,10 @@ class DoodstreamUploader {
|
||||
* Login to DoodStream via web form
|
||||
*/
|
||||
async login(username, password, otp) {
|
||||
// GET homepage first to collect cookies
|
||||
const homeRes = await this._fetch(BASE_URL);
|
||||
await homeRes.text();
|
||||
if (!otp || this.cookies.size === 0) {
|
||||
const homeRes = await this._fetch(BASE_URL);
|
||||
await homeRes.text();
|
||||
}
|
||||
|
||||
// POST login via AJAX (op in body, XHR header required for JSON response)
|
||||
const loginData = new URLSearchParams({
|
||||
|
||||
Reference in New Issue
Block a user