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() {
|
constructor() {
|
||||||
this.cookies = new Map();
|
this.cookies = new Map();
|
||||||
this.sessId = '';
|
this.sessId = '';
|
||||||
|
this.apiKey = ''; // optionally derived from the logged-in session (deriveApiKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
_cookieHeader() {
|
_cookieHeader() {
|
||||||
@ -577,6 +578,85 @@ class DoodstreamUploader {
|
|||||||
file_code: fileCode
|
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;
|
module.exports = DoodstreamUploader;
|
||||||
|
|||||||
@ -40,6 +40,7 @@ class UploadManager extends EventEmitter {
|
|||||||
this.globalThrottle = null;
|
this.globalThrottle = null;
|
||||||
this._failedAccounts = new Map(); // hoster -> Set of failed accountIds
|
this._failedAccounts = new Map(); // hoster -> Set of failed accountIds
|
||||||
this._accountOverrides = new Map(); // hoster -> fallback account object
|
this._accountOverrides = new Map(); // hoster -> fallback account object
|
||||||
|
this._doodApiKeyCache = new Map(); // accountId/username -> derived doodstream API key ('' = tried, none)
|
||||||
}
|
}
|
||||||
|
|
||||||
switchAccount(hoster, fallbackAccount) {
|
switchAccount(hoster, fallbackAccount) {
|
||||||
@ -265,6 +266,7 @@ class UploadManager extends EventEmitter {
|
|||||||
this.activeJobs.clear();
|
this.activeJobs.clear();
|
||||||
this.jobAbortControllers.clear();
|
this.jobAbortControllers.clear();
|
||||||
this.cancelledJobIds.clear();
|
this.cancelledJobIds.clear();
|
||||||
|
this._doodApiKeyCache.clear(); // re-derive doodstream keys fresh each batch
|
||||||
this.semaphores = {};
|
this.semaphores = {};
|
||||||
this.globalSemaphore = null;
|
this.globalSemaphore = null;
|
||||||
this.globalThrottle = null;
|
this.globalThrottle = null;
|
||||||
@ -871,6 +873,18 @@ class UploadManager extends EventEmitter {
|
|||||||
await voe.login(task.username, task.password);
|
await voe.login(task.username, task.password);
|
||||||
return voe.upload(task.file, progressCb, signal, throttle);
|
return voe.upload(task.file, progressCb, signal, throttle);
|
||||||
} else if (task.hoster === 'doodstream.com' && task.username) {
|
} 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();
|
const dood = new DoodstreamUploader();
|
||||||
await dood.login(task.username, task.password);
|
await dood.login(task.username, task.password);
|
||||||
return dood.upload(task.file, progressCb, signal, throttle);
|
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) {
|
_emitProgress(uploadId, fileName, hoster, data) {
|
||||||
this.emit('progress', { 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');
|
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 ---
|
// --- _fetch: transient network blips on the small requests self-heal ---
|
||||||
test('_fetch retries a transient network failure then succeeds', async () => {
|
test('_fetch retries a transient network failure then succeeds', async () => {
|
||||||
const up = new DoodstreamUploader();
|
const up = new DoodstreamUploader();
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user