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) {
- +
@@ -7906,13 +7907,14 @@ async function checkSingleAccount(accountId) { if (!accountId || healthCheckRunning) return; const found = findAccountById(accountId); if (!found) return; + const requestNewOtp = accountStatuses[accountId]?.status === 'otp_required'; const generation = _nextAccountStatusGeneration(accountId); healthCheckRunning = true; accountStatuses[accountId] = { ...(accountStatuses[accountId] || {}), status: 'checking', message: '' }; updateAccountCard(accountId); let nextStatus = null; try { - const result = await window.api.runHealthCheck({ hosters: [{ hoster: found.name, accountId }] }); + const result = await window.api.runHealthCheck({ hosters: [{ hoster: found.name, accountId, requestNewOtp }] }); const rows = result && Array.isArray(result.results) ? result.results : []; const row = rows.find(r => r.accountId === accountId); const checkedAt = result?.checkedAt || new Date().toISOString(); diff --git a/renderer/i18n.js b/renderer/i18n.js index 577c8e9..e33602f 100644 --- a/renderer/i18n.js +++ b/renderer/i18n.js @@ -704,6 +704,8 @@ ['Updateprüfung fehlgeschlagen', 'Update check failed'], ['Upload läuft...', 'Uploading...'], ['Upload-Log', 'Upload log'], + ['Neuen Code anfordern', 'Request new code'], + ['OTP-Anfrage ist abgelaufen. Bitte einen neuen Code anfordern.', 'The OTP request has expired. Request a new code.'], ['Automatik-Abschlussnachweis konnte nicht gespeichert werden', 'Automation completion evidence could not be saved'], ['Lokale Speicherung', 'Local persistence'], ['Automatik-Abschlussdatei ist ungültig', 'Automation completion file is invalid'], diff --git a/scripts/verify-public-release.mjs b/scripts/verify-public-release.mjs index 4ed1434..5b283bb 100644 --- a/scripts/verify-public-release.mjs +++ b/scripts/verify-public-release.mjs @@ -10,6 +10,7 @@ const publicActionsDir = `.${['git', 'hub'].join('')}`; const privateActionsDir = `.${['gi', 'tea'].join('')}`; const sourceFiles = [ '.gitignore', + 'PROJECT_MEMORY.md', 'README.md', 'SECURITY.md', 'assets/app_icon.ico', diff --git a/tests/account-auth.test.js b/tests/account-auth.test.js index 1fd5afc..3c2df71 100644 --- a/tests/account-auth.test.js +++ b/tests/account-auth.test.js @@ -1,6 +1,6 @@ const { test } = require('node:test'); const assert = require('node:assert'); -const { selectUploadAuth } = require('../lib/account-auth'); +const { createDoodstreamOtpCoordinator, selectUploadAuth } = require('../lib/account-auth'); test('doodstream prefers the API key even when username/password are also set', () => { const auth = selectUploadAuth('doodstream.com', { @@ -42,3 +42,120 @@ test('null / non-object account does not throw', () => { assert.deepEqual(selectUploadAuth('doodstream.com', null), {}); assert.deepEqual(selectUploadAuth('doodstream.com', undefined), {}); }); + +function otpRequired(message = 'OTP erforderlich') { + const error = new Error(message); + error.otpRequired = true; + return error; +} + +test('concurrent and repeated Doodstream checks request only one OTP', async () => { + let loginCalls = 0; + let releaseLogin; + const coordinator = createDoodstreamOtpCoordinator({ + createUploader: () => ({ + login: () => new Promise((resolve, reject) => { + loginCalls++; + releaseLogin = () => reject(otpRequired('OTP gesendet')); + }) + }) + }); + const first = coordinator.check({ username: 'user', password: 'secret' }); + const second = coordinator.check({ username: 'user', password: 'secret' }); + assert.equal(loginCalls, 1); + releaseLogin(); + assert.deepEqual(await first, { status: 'otp_required', message: 'OTP gesendet' }); + assert.deepEqual(await second, { status: 'otp_required', message: 'OTP gesendet' }); + assert.deepEqual(await coordinator.check({ username: 'user', password: 'secret' }), { status: 'otp_required', message: 'OTP gesendet' }); + assert.equal(loginCalls, 1); +}); + +test('Doodstream OTP verification reuses the challenged uploader session', async () => { + let created = 0; + const calls = []; + const coordinator = createDoodstreamOtpCoordinator({ + createUploader: () => { + created++; + return { + async login(_username, _password, otp) { + calls.push(otp || ''); + if (!otp) throw otpRequired('OTP gesendet'); + } + }; + } + }); + assert.equal((await coordinator.check({ username: 'user', password: 'secret' })).status, 'otp_required'); + assert.deepEqual(await coordinator.check({ username: 'user', password: 'secret', otp: '123456' }), { + status: 'ok', + message: 'Login ok, Upload-Seite bereit' + }); + assert.equal(created, 1); + assert.deepEqual(calls, ['', '123456']); +}); + +test('a rejected OTP remains pending without requesting another code', async () => { + let created = 0; + let calls = 0; + const coordinator = createDoodstreamOtpCoordinator({ + createUploader: () => { + created++; + return { + async login(_username, _password, otp) { + calls++; + if (!otp) throw otpRequired('OTP gesendet'); + throw new Error('Code ungültig'); + } + }; + } + }); + await coordinator.check({ username: 'user', password: 'secret' }); + assert.deepEqual(await coordinator.check({ username: 'user', password: 'secret', otp: '000000' }), { + status: 'otp_required', + message: 'Code ungültig' + }); + assert.deepEqual(await coordinator.check({ username: 'user', password: 'secret' }), { + status: 'otp_required', + message: 'Code ungültig' + }); + assert.equal(created, 1); + assert.equal(calls, 2); +}); + +test('explicit OTP resend is rate-limited and starts one fresh session', async () => { + let currentTime = 1000; + let created = 0; + const coordinator = createDoodstreamOtpCoordinator({ + now: () => currentTime, + resendCooldownMs: 60000, + createUploader: () => { + created++; + return { login: async () => { throw otpRequired('OTP gesendet'); } }; + } + }); + await coordinator.check({ username: 'user', password: 'secret' }); + await coordinator.check({ username: 'user', password: 'secret', requestNewChallenge: true }); + assert.equal(created, 1); + currentTime += 60000; + await coordinator.check({ username: 'user', password: 'secret', requestNewChallenge: true }); + assert.equal(created, 2); +}); + +test('expired OTP submission cannot create a replacement challenge implicitly', async () => { + let currentTime = 1000; + let created = 0; + const coordinator = createDoodstreamOtpCoordinator({ + now: () => currentTime, + challengeTtlMs: 1000, + createUploader: () => { + created++; + return { login: async () => { throw otpRequired('OTP gesendet'); } }; + } + }); + await coordinator.check({ username: 'user', password: 'secret' }); + currentTime += 1001; + assert.deepEqual(await coordinator.check({ username: 'user', password: 'secret', otp: '123456' }), { + status: 'otp_required', + message: 'OTP-Anfrage ist abgelaufen. Bitte einen neuen Code anfordern.' + }); + assert.equal(created, 1); +}); diff --git a/tests/doodstream-upload.test.js b/tests/doodstream-upload.test.js index f7cfcb5..f710fae 100644 --- a/tests/doodstream-upload.test.js +++ b/tests/doodstream-upload.test.js @@ -161,6 +161,43 @@ function fakeRes(body, { status = 200, ctype = 'text/html' } = {}) { return { status, headers: { get: (h) => (h.toLowerCase() === 'content-type' ? ctype : null) }, text: async () => body }; } +test('OTP verification keeps the challenged cookie session without another bootstrap request', async () => { + const up = new DoodstreamUploader(); + const originalFetch = globalThis.fetch; + let bootstrapCalls = 0; + let loginCalls = 0; + up._fetch = async () => { + bootstrapCalls++; + return fakeRes(bootstrapCalls === 1 ? 'ok' : ''); + }; + globalThis.fetch = async (_url, options) => { + loginCalls++; + if (loginCalls === 1) { + assert.equal(options.headers.Cookie, undefined); + return { + status: 200, + headers: { getSetCookie: () => ['otp_session=SESSION123; Path=/'], get: () => null }, + text: async () => JSON.stringify({ status: 'fail', message: 'OTP required' }) + }; + } + assert.equal(options.headers.Cookie, 'otp_session=SESSION123'); + assert.match(options.body, /loginotp=123456/u); + return { + status: 302, + headers: { getSetCookie: () => [], get: () => '/dashboard' }, + text: async () => '' + }; + }; + try { + await assert.rejects(() => up.login('user', 'secret'), error => error.otpRequired === true); + await up.login('user', 'secret', '123456'); + assert.equal(bootstrapCalls, 2); + assert.equal(loginCalls, 2); + } finally { + globalThis.fetch = originalFetch; + } +}); + test('getUploadServer: returns JSON result when present', async () => { const up = new DoodstreamUploader(); up._fetch = async (url) => { diff --git a/tests/i18n.test.js b/tests/i18n.test.js index 7bfae8d..6d9746a 100644 --- a/tests/i18n.test.js +++ b/tests/i18n.test.js @@ -51,6 +51,8 @@ test('translates managed online backup controls in both directions', () => { test('translates the account check timestamp label', () => { assert.equal(translateText('geprüft', 'en'), 'checked'); assert.equal(translateText('checked', 'de'), 'geprüft'); + assert.equal(translateText('Neuen Code anfordern', 'en'), 'Request new code'); + assert.equal(translateText('The OTP request has expired. Request a new code.', 'de'), 'OTP-Anfrage ist abgelaufen. Bitte einen neuen Code anfordern.'); }); test('translates settings search result labels in both directions', () => { diff --git a/tests/public-release-verifier.test.js b/tests/public-release-verifier.test.js index d0dc599..23428dc 100644 --- a/tests/public-release-verifier.test.js +++ b/tests/public-release-verifier.test.js @@ -45,7 +45,7 @@ test('public release verifier accepts only the exact source manifest and target assert.equal(baseline.status, 0, baseline.stderr); assert.equal( baseline.stdout, - `public-release-source-ok files=159 denied-paths=0 internal-terms=0 version=${currentVersion} scripts=8 build-files=7 layout=exact screenshot=deferred\n` + `public-release-source-ok files=160 denied-paths=0 internal-terms=0 version=${currentVersion} scripts=8 build-files=7 layout=exact screenshot=deferred\n` ); fs.writeFileSync(path.join(stage, 'tests', 'unexpected.json'), '{}');