From 40ce83d41330820c326b2e33f81d352747f5885b Mon Sep 17 00:00:00 2001 From: Sucukdeluxe <259325684+Sucukdeluxe@users.noreply.github.com> Date: Mon, 31 Aug 2026 18:45:37 +0200 Subject: [PATCH] fix: prevent repeated Doodstream OTP requests 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. --- lib/account-auth.js | 109 ++++++++++++++++++++++- lib/doodstream-upload.js | 7 +- main.js | 53 +++++------- renderer/app.js | 6 +- renderer/i18n.js | 2 + scripts/verify-public-release.mjs | 1 + tests/account-auth.test.js | 119 +++++++++++++++++++++++++- tests/doodstream-upload.test.js | 37 ++++++++ tests/i18n.test.js | 2 + tests/public-release-verifier.test.js | 2 +- 10 files changed, 300 insertions(+), 38 deletions(-) diff --git a/lib/account-auth.js b/lib/account-auth.js index 1fd0a4a..c82c549 100644 --- a/lib/account-auth.js +++ b/lib/account-auth.js @@ -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 }; diff --git a/lib/doodstream-upload.js b/lib/doodstream-upload.js index 4cc45d1..51afebc 100644 --- a/lib/doodstream-upload.js +++ b/lib/doodstream-upload.js @@ -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({ diff --git a/main.js b/main.js index 38c6480..3cff553 100644 --- a/main.js +++ b/main.js @@ -14,7 +14,7 @@ const { HOSTER_CONFIGS } = require('./lib/hosters'); const VidmolyUploader = require('./lib/vidmoly-upload'); const VoeUploader = require('./lib/voe-upload'); const DoodstreamUploader = require('./lib/doodstream-upload'); -const { selectUploadAuth } = require('./lib/account-auth'); +const { createDoodstreamOtpCoordinator, selectUploadAuth } = require('./lib/account-auth'); const { createAccountCooldownController, createAccountPicker } = require('./lib/account-rotation'); const ClouddropUploader = require('./lib/clouddrop-upload'); const { checkForUpdate, prepareUpdate, launchPreparedUpdate, abortUpdate, createUpdateAnnouncementState } = require('./lib/updater'); @@ -413,6 +413,9 @@ let captureWindow = null; let captureWindowReady = false; let signalingQueue = []; const HEALTH_CHECK_TIMEOUT = 25000; +const doodstreamHealthCoordinator = createDoodstreamOtpCoordinator({ + createUploader: () => new DoodstreamUploader() +}); // --- Debug logging (writes to upload-debug.log next to the app) --- function getDebugLogPath() { @@ -1268,33 +1271,19 @@ async function registerAutomationCompletionJobs(manager, jobs) { await Promise.all(Array.from({ length: Math.min(16, candidates.length) }, worker)); } -async function checkDoodstreamHealth(hosterConfig, otp) { - const username = hosterConfig && hosterConfig.username - ? String(hosterConfig.username).trim() - : ''; - const password = hosterConfig && hosterConfig.password - ? String(hosterConfig.password).trim() - : ''; +async function checkDoodstreamHealth(hosterConfig, otp, options = {}) { + const auth = selectUploadAuth('doodstream.com', hosterConfig); + const apiKey = auth.apiKey ? String(auth.apiKey).trim() : ''; - // Login-based check (preferred) - if (username && password) { - const uploader = new DoodstreamUploader(); - try { - await uploader.login(username, password, otp || undefined); - } catch (err) { - if (err.otpRequired) { - return { status: 'otp_required', message: err.message || 'OTP erforderlich' }; - } - throw err; - } - return { status: 'ok', message: 'Login ok, Upload-Seite bereit' }; + if (!apiKey && auth.username && auth.password) { + return doodstreamHealthCoordinator.check({ + username: String(auth.username).trim(), + password: String(auth.password).trim(), + otp, + requestNewChallenge: options.requestNewOtp === true + }); } - // Fall back to API key check - const apiKey = hosterConfig && hosterConfig.apiKey - ? String(hosterConfig.apiKey).trim() - : ''; - if (!apiKey) { return { status: 'error', message: 'Login oder API Key fehlt' }; } @@ -1517,7 +1506,7 @@ async function runHosterHealthCheck(config, requestedChecks) { checks = cleaned; } - const runOne = async ({ hoster, accountId, otp, _invalid }) => { + const runOne = async ({ hoster, accountId, otp, requestNewOtp, _invalid }) => { if (_invalid) { return { hoster, accountId, status: 'error', message: 'Account-ID fehlt im Check-Payload' }; } @@ -1527,7 +1516,9 @@ async function runHosterHealthCheck(config, requestedChecks) { const accounts = config.hosters[hoster]; const hosterConfig = Array.isArray(accounts) ? accounts.find(a => a.id === accountId) : null; try { - const result = await _dispatchHealthCheck(hoster, hosterConfig, otp || ''); + const result = await _dispatchHealthCheck(hoster, hosterConfig, otp || '', { + requestNewOtp: requestNewOtp === true + }); return { hoster, accountId, ...result }; } catch (err) { return { hoster, accountId, status: 'error', message: err && err.message ? err.message : 'Health-Check fehlgeschlagen' }; @@ -2003,18 +1994,20 @@ ipcMain.handle('validate-credentials', async (_event, payload) => { enabled: true }; try { - return await _dispatchHealthCheck(payload.hoster, ephemeralHosterConfig, payload.otp || ''); + return await _dispatchHealthCheck(payload.hoster, ephemeralHosterConfig, payload.otp || '', { + requestNewOtp: payload.requestNewOtp === true + }); } catch (err) { return { status: 'error', message: err && err.message ? err.message : 'Validierung fehlgeschlagen' }; } }); -async function _dispatchHealthCheck(hoster, hosterConfig, otp) { +async function _dispatchHealthCheck(hoster, hosterConfig, otp, options = {}) { // Mirrors the per-hoster switch in runHosterHealthCheck so both code paths // (batch check by accountId and ephemeral validate) go through identical // checkers + timeout wrappers and surface identical result shapes. if (hoster === 'doodstream.com') { - return withTimeout(checkDoodstreamHealth(hosterConfig, otp), HEALTH_CHECK_TIMEOUT, 'Doodstream-Check'); + return withTimeout(checkDoodstreamHealth(hosterConfig, otp, options), HEALTH_CHECK_TIMEOUT, 'Doodstream-Check'); } if (hoster === 'vidmoly.me') { return withTimeout(checkVidmolyHealth(hosterConfig), HEALTH_CHECK_TIMEOUT, 'Vidmoly-Check'); diff --git a/renderer/app.js b/renderer/app.js index 5b3151b..416bfda 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -7355,6 +7355,7 @@ function _buildAccountCardHtml(name, account, idx) { : ''; const toggleLabel = isDisabled ? 'Aktivieren' : 'Deaktivieren'; const priorityLabel = idx === 0 ? 'Primär' : `Fallback #${idx}`; + const checkLabel = statusPresentation.requiresOtp ? 'Neuen Code anfordern' : 'Prüfen'; const sessionPauseKey = `${name}:${account.id}`; const sessionPause = _sessionFailedAccountStates.get(sessionPauseKey) @@ -7384,7 +7385,7 @@ function _buildAccountCardHtml(name, account, idx) {