fix(doodstream): login path auto-derives the API key → uploads via reliable API
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 <noreply@anthropic.com>
This commit is contained in:
parent
329f768e2b
commit
84c48ad7d6
@ -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, // <input value="KEY">
|
||||
/<(?:textarea|code|span|pre|input)[^>]*>\s*([A-Za-z0-9]{20,})\s*</gi, // <textarea>KEY</textarea>
|
||||
/\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;
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
|
||||
@ -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 = `
|
||||
<input type="text" name="csrf" value="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa">
|
||||
<div class="panel">API Key <input readonly value="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"></div>
|
||||
`;
|
||||
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('<textarea id="k">cccccccccccccccccccccccccccccccc</textarea>');
|
||||
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 () => '<div>API Key <input value="REALKEY1234567890abcdefGHIJK"></div><input value="notthekey000000000000000000">' });
|
||||
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 () => '<input value="bogustoken0000000000000000000">' });
|
||||
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();
|
||||
|
||||
Loading…
Reference in New Issue
Block a user