From 84c48ad7d6a8e2538a1d3b082e8fbaa65c843149 Mon Sep 17 00:00:00 2001 From: Administrator Date: Thu, 28 May 2026 22:05:20 +0200 Subject: [PATCH] =?UTF-8?q?fix(doodstream):=20login=20path=20auto-derives?= =?UTF-8?q?=20the=20API=20key=20=E2=86=92=20uploads=20via=20reliable=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The user uploads with username/password (login), so 3.3.30's "use API when a key is configured" did nothing for them — and the web-form upload keeps failing with empty forms on large files. Fix the LOGIN path itself: after logging in, pull the account's API key out of the logged-in session and upload via the official doodapi API (which returns result[0].filecode directly, no empty form). The user keeps using login and configures nothing. How the key is derived without knowing doodstream's (cookie-gated, unseen) settings DOM: brute-force candidate extraction + API validation. - DoodstreamUploader.deriveApiKey(): fetch the logged-in settings page (?op=my_account / /settings), pull every plausible long token from form-field values + element contents (ranked: tokens near an "api" mention first), and validate each against doodapi.co/api/account/info — only the account's real key returns status 200. A wrong guess is therefore harmless (fails validation → web fallback). Logs the raw settings HTML when nothing validates, so the scrape can be refined from a real capture if doodstream's markup differs. - upload-manager: doodstream login-path now resolves the key ONCE per batch (cached by accountId; '' = tried-none) and routes to the API when found, else the existing web-form upload. Keyless accounts: one extra probe-login per batch, then unchanged. - Tests: candidate extraction (value/textarea/api_key shapes, api-context ranking), validate-then-pick, null→web-fallback, preset short-circuit. 178/178. If derivation works the login path now uploads via the API. It does NOT change doodstream's backend; the server run confirms. Falls back safely if no key. Co-Authored-By: Claude Opus 4.7 --- lib/doodstream-upload.js | 80 +++++++++++++++++++++++++++++++++ lib/upload-manager.js | 36 +++++++++++++++ tests/doodstream-upload.test.js | 48 ++++++++++++++++++++ 3 files changed, 164 insertions(+) diff --git a/lib/doodstream-upload.js b/lib/doodstream-upload.js index b4731a0..cbfeee6 100644 --- a/lib/doodstream-upload.js +++ b/lib/doodstream-upload.js @@ -40,6 +40,7 @@ class DoodstreamUploader { constructor() { this.cookies = new Map(); this.sessId = ''; + this.apiKey = ''; // optionally derived from the logged-in session (deriveApiKey) } _cookieHeader() { @@ -577,6 +578,85 @@ class DoodstreamUploader { file_code: fileCode }; } + + /** + * Pull candidate API-key tokens out of a logged-in settings page. We do NOT + * rely on knowing doodstream's exact (cookie-gated, unseen) settings DOM — + * instead we gather every plausible long token from form-field values and + * element contents, ranked so tokens near an "api" mention are tried first. + * The caller validates each against the official API, so a wrong guess is + * harmless (it just fails validation). Returned newest-/most-likely-first. + */ + _extractApiKeyCandidates(html) { + if (!html) return []; + const cands = new Set(); + const patterns = [ + /value=["']([A-Za-z0-9]{20,})["']/gi, // + /<(?:textarea|code|span|pre|input)[^>]*>\s*([A-Za-z0-9]{20,})\s*KEY + /\b(?:api[_-]?key|apikey)\b["':\s=>]*["']?([A-Za-z0-9]{20,})/gi // api_key: "KEY" + ]; + for (const re of patterns) { + let m; + while ((m = re.exec(html)) !== null) cands.add(m[1]); + } + // Rank tokens whose preceding context mentions "api" ahead of the rest. + return [...cands] + .map(t => { + const idx = html.indexOf(t); + const ctx = html.slice(Math.max(0, idx - 160), idx).toLowerCase(); + return { t, near: /api/.test(ctx) ? 0 : 1 }; + }) + .sort((a, b) => a.near - b.near) + .map(s => s.t); + } + + /** + * Validate a candidate key against the official API. Only the account's real + * key returns status 200, so this is what makes the brute-force extraction + * safe regardless of the settings-page markup. + */ + async _validateApiKey(key) { + try { + const res = await fetch(`https://doodapi.co/api/account/info?key=${encodeURIComponent(key)}`, { + method: 'GET', redirect: 'follow', signal: AbortSignal.timeout(15000) + }); + const json = await res.json().catch(() => null); + return !!(json && Number(json.status) === 200); + } catch { + return false; + } + } + + /** + * Derive the account's doodapi API key from the logged-in web session, so a + * login-only account can upload via the reliable JSON API (which returns the + * filecode directly) instead of the fragile web upload form. Best-effort: + * returns null if no valid key can be found, and the caller falls back to the + * web-form upload. Requires login() to have run first (needs the cookies). + */ + async deriveApiKey() { + if (this.apiKey) return this.apiKey; + let html = ''; + for (const page of ['/?op=my_account', '/settings', '/?op=profile']) { + try { + const res = await this._fetch(BASE_URL + page); + const text = await res.text(); + if (text && /api[\s_-]?key/i.test(text)) { html = text; break; } + if (text && !html) html = text; + } catch { /* try next page */ } + } + const candidates = this._extractApiKeyCandidates(html); + // Cap validation calls (rate limit 10/s; settings page yields few tokens). + for (const key of candidates.slice(0, 15)) { + if (await this._validateApiKey(key)) { + this.apiKey = key; + _debugLog(`api-key derive: validated key (len ${key.length})`); + return key; + } + } + _debugLog(`api-key derive: ${candidates.length} candidate(s), none validated. settings html(2500)=${(html || '').slice(0, 2500)}`); + return null; + } } module.exports = DoodstreamUploader; diff --git a/lib/upload-manager.js b/lib/upload-manager.js index 15a3116..25bb33e 100644 --- a/lib/upload-manager.js +++ b/lib/upload-manager.js @@ -40,6 +40,7 @@ class UploadManager extends EventEmitter { this.globalThrottle = null; this._failedAccounts = new Map(); // hoster -> Set of failed accountIds this._accountOverrides = new Map(); // hoster -> fallback account object + this._doodApiKeyCache = new Map(); // accountId/username -> derived doodstream API key ('' = tried, none) } switchAccount(hoster, fallbackAccount) { @@ -265,6 +266,7 @@ class UploadManager extends EventEmitter { this.activeJobs.clear(); this.jobAbortControllers.clear(); this.cancelledJobIds.clear(); + this._doodApiKeyCache.clear(); // re-derive doodstream keys fresh each batch this.semaphores = {}; this.globalSemaphore = null; this.globalThrottle = null; @@ -871,6 +873,18 @@ class UploadManager extends EventEmitter { await voe.login(task.username, task.password); return voe.upload(task.file, progressCb, signal, throttle); } else if (task.hoster === 'doodstream.com' && task.username) { + // Login-path reliability fix: the web-form upload returns the filecode in + // an HTML form that comes back empty for large files (doodstream backend + // registration timeout). Derive the account's API key from the logged-in + // session ONCE per batch and upload via the official API instead — it + // returns result[0].filecode directly and has no empty-form failure mode. + // Falls back to the web-form upload if no valid key can be derived. + const apiKey = await this._resolveDoodstreamApiKey(task); + if (apiKey) { + this._rotLog('doodstream-via-api', { accountId: task.accountId, fileName: path.basename(task.file) }); + return uploadFile('doodstream.com', task.file, apiKey, progressCb, signal, throttle); + } + this._rotLog('doodstream-via-web', { accountId: task.accountId, fileName: path.basename(task.file) }); const dood = new DoodstreamUploader(); await dood.login(task.username, task.password); return dood.upload(task.file, progressCb, signal, throttle); @@ -882,6 +896,28 @@ class UploadManager extends EventEmitter { } } + // Resolve (and cache per batch) the doodstream API key for a login-only + // account by logging in once and scraping+validating it from the session. + // Returns the key string, or '' when none could be derived (cached either way + // 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 cached = this._doodApiKeyCache.get(cacheKey); + if (cached !== undefined) return 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); + return key || null; + } + _emitProgress(uploadId, fileName, hoster, data) { this.emit('progress', { uploadId, fileName, hoster, ...data }); } diff --git a/tests/doodstream-upload.test.js b/tests/doodstream-upload.test.js index 84ddf36..0349d19 100644 --- a/tests/doodstream-upload.test.js +++ b/tests/doodstream-upload.test.js @@ -64,6 +64,54 @@ test('happy path: link in result page wins', async () => { assert.equal(res.file_code, 'jjsuhr931ds9'); }); +// --- deriveApiKey: pull + validate the account API key from the web session --- +test('_extractApiKeyCandidates finds the key in an input value and ranks api-context first', () => { + const up = new DoodstreamUploader(); + const html = ` + +
API Key
+ `; + const cands = up._extractApiKeyCandidates(html); + // The token whose preceding context mentions "API" must rank first. + assert.equal(cands[0], 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'); + assert.ok(cands.includes('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa')); +}); + +test('_extractApiKeyCandidates handles textarea + api_key: "x" shapes and empty input', () => { + const up = new DoodstreamUploader(); + assert.deepEqual(up._extractApiKeyCandidates(''), []); + const ta = up._extractApiKeyCandidates(''); + assert.ok(ta.includes('cccccccccccccccccccccccccccccccc')); + const js = up._extractApiKeyCandidates('var x = {"api_key":"dddddddddddddddddddddddddddddddd"};'); + assert.ok(js.includes('dddddddddddddddddddddddddddddddd')); +}); + +test('deriveApiKey returns the candidate that validates against the API', async () => { + const up = new DoodstreamUploader(); + up._fetch = async () => ({ text: async () => '
API Key
' }); + up._validateApiKey = async (key) => key === 'REALKEY1234567890abcdefGHIJK'; + const key = await up.deriveApiKey(); + assert.equal(key, 'REALKEY1234567890abcdefGHIJK'); + assert.equal(up.apiKey, 'REALKEY1234567890abcdefGHIJK'); // cached on the instance +}); + +test('deriveApiKey returns null when no candidate validates (→ caller uses web fallback)', async () => { + const up = new DoodstreamUploader(); + up._fetch = async () => ({ text: async () => '' }); + up._validateApiKey = async () => false; + assert.equal(await up.deriveApiKey(), null); + assert.equal(up.apiKey, ''); +}); + +test('deriveApiKey short-circuits when a key is already set', async () => { + const up = new DoodstreamUploader(); + up.apiKey = 'PRESET'; + let fetched = false; + up._fetch = async () => { fetched = true; return { text: async () => '' }; }; + assert.equal(await up.deriveApiKey(), 'PRESET'); + assert.equal(fetched, false); +}); + // --- _fetch: transient network blips on the small requests self-heal --- test('_fetch retries a transient network failure then succeeds', async () => { const up = new DoodstreamUploader();