fix: preserve explicit Doodstream web login and reuse OTP sessions
CI / verify (push) Canceled after 0s
CI / verify (push) Canceled after 0s
This commit is contained in:
+22
-3
@@ -20,6 +20,10 @@ const { createHash } = require('node:crypto');
|
||||
function selectUploadAuth(hoster, account) {
|
||||
if (!account || typeof account !== 'object') return {};
|
||||
|
||||
if (account.authType === 'login' && account.username && account.password) {
|
||||
return { username: account.username, password: account.password };
|
||||
}
|
||||
|
||||
if (hoster === 'doodstream.com' && account.apiKey) {
|
||||
return { apiKey: account.apiKey };
|
||||
}
|
||||
@@ -72,6 +76,7 @@ function createDoodstreamOtpCoordinator(options = {}) {
|
||||
const key = credentialKey(username, password);
|
||||
const existing = activeState(key);
|
||||
if (existing?.inFlight) return existing.inFlight;
|
||||
if (existing?.ready && input.requestNewChallenge !== true) return existing.result;
|
||||
if (otp && !existing?.pending) {
|
||||
return {
|
||||
status: 'otp_required',
|
||||
@@ -87,8 +92,11 @@ function createDoodstreamOtpCoordinator(options = {}) {
|
||||
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' };
|
||||
const result = { status: 'ok', message: 'Login erfolgreich' };
|
||||
if (states.get(key)?.operationId === operationId) {
|
||||
storeState(key, { operationId, uploader, ready: true, expiresAt: now() + challengeTtlMs, result, inFlight: null });
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (error?.otpRequired === true) {
|
||||
const result = { status: 'otp_required', message: error.message || 'OTP erforderlich' };
|
||||
@@ -137,7 +145,18 @@ function createDoodstreamOtpCoordinator(options = {}) {
|
||||
return operation;
|
||||
}
|
||||
|
||||
return { check };
|
||||
async function acquire(input) {
|
||||
const result = await check(input);
|
||||
const session = activeState(credentialKey(input.username, input.password));
|
||||
if (result.status !== 'ok' || !session?.ready) {
|
||||
const error = new Error(result.message || 'OTP erforderlich');
|
||||
error.otpRequired = result.status === 'otp_required';
|
||||
throw error;
|
||||
}
|
||||
return session.uploader.cloneSession();
|
||||
}
|
||||
|
||||
return { check, acquire };
|
||||
}
|
||||
|
||||
module.exports = { createDoodstreamOtpCoordinator, selectUploadAuth };
|
||||
|
||||
@@ -30,8 +30,8 @@ function _doodstreamLogPath() {
|
||||
let _debugVerbose = false;
|
||||
function setDebugVerbose(v) { _debugVerbose = !!v; }
|
||||
|
||||
function _debugLog(msg) {
|
||||
if (!_debugVerbose) return;
|
||||
function _debugLog(msg, force = false) {
|
||||
if (!_debugVerbose && !force) return;
|
||||
try {
|
||||
const logPath = _doodstreamLogPath();
|
||||
maybeRotateLogFile(logPath, _DOODSTREAM_LOG_MAX_BYTES, _DOODSTREAM_LOG_MAX_BACKUPS);
|
||||
@@ -53,6 +53,20 @@ class DoodstreamUploader {
|
||||
.join('; ');
|
||||
}
|
||||
|
||||
cloneSession() {
|
||||
const uploader = new DoodstreamUploader();
|
||||
uploader.cookies = new Map(this.cookies);
|
||||
uploader.sessId = this.sessId;
|
||||
return uploader;
|
||||
}
|
||||
|
||||
_isAuthenticatedPage(html) {
|
||||
return /href=["']\/settings["']/i.test(html)
|
||||
&& /href=["']\/videos["']/i.test(html)
|
||||
&& /href=["'][^"']*(?:op=logout|\/logout)["']/i.test(html)
|
||||
&& !/<input\b[^>]*\bname=["'](?:login|password|loginotp)["']/i.test(html);
|
||||
}
|
||||
|
||||
_parseCookiesFromHeaders(headers) {
|
||||
let setCookies;
|
||||
if (typeof headers.getSetCookie === 'function') {
|
||||
@@ -175,8 +189,9 @@ class DoodstreamUploader {
|
||||
throw err;
|
||||
} else if (json && json.status === 'fail') {
|
||||
throw new Error(`Doodstream Login: ${json.message || 'Login fehlgeschlagen'}`);
|
||||
} else if (body.includes('Dashboard')) {
|
||||
// Got dashboard HTML directly — login worked
|
||||
} else if (res.status === 200 && this._isAuthenticatedPage(body)) {
|
||||
this.sessId = this._findSessId(body);
|
||||
return;
|
||||
} else {
|
||||
const msg = (json && json.message) || 'Login fehlgeschlagen';
|
||||
throw new Error(`Doodstream Login: ${msg}`);
|
||||
@@ -190,8 +205,9 @@ class DoodstreamUploader {
|
||||
}
|
||||
const landing = await this._fetch(target.href, { allowedOrigin: BASE_URL });
|
||||
const landingHtml = await landing.text();
|
||||
this._diagnoseSessionPage('landing', landing, landingHtml);
|
||||
const sessId = this._findSessId(landingHtml);
|
||||
if (landing.status === 200 && sessId) {
|
||||
if (landing.status === 200 && (sessId || this._isAuthenticatedPage(landingHtml))) {
|
||||
this.sessId = sessId;
|
||||
return;
|
||||
}
|
||||
@@ -203,6 +219,7 @@ class DoodstreamUploader {
|
||||
async _extractSessId() {
|
||||
const res = await this._fetch(BASE_URL + '/?op=upload');
|
||||
const html = await res.text();
|
||||
this._diagnoseSessionPage('upload', res, html);
|
||||
const sessId = this._findSessId(html);
|
||||
if (res.status === 200 && sessId) {
|
||||
this.sessId = sessId;
|
||||
@@ -210,7 +227,8 @@ class DoodstreamUploader {
|
||||
}
|
||||
this.sessId = '';
|
||||
const guest = /utype\s*:\s*['"]anon['"]/.test(html);
|
||||
throw new Error(`Doodstream: sess_id nicht gefunden nach Login (HTTP ${res.status}; guest=${guest}; sessionField=${/sess_id/.test(html)}; cookies=${this.cookies.size})`);
|
||||
_debugLog(`session-missing HTTP=${res.status} guest=${guest} cookies=${this.cookies.size}`, true);
|
||||
throw new Error('Doodstream: sess_id nicht gefunden nach Login');
|
||||
}
|
||||
|
||||
_findSessId(html) {
|
||||
@@ -225,6 +243,32 @@ class DoodstreamUploader {
|
||||
return match ? match[2].trim() : '';
|
||||
}
|
||||
|
||||
_diagnoseSessionPage(stage, response, html) {
|
||||
const routes = new Set();
|
||||
for (const match of html.matchAll(/\bhref=["']([^"']+)["']/gi)) {
|
||||
try {
|
||||
const url = new URL(match[1].replace(/&/g, '&'), BASE_URL);
|
||||
if (url.origin !== BASE_URL) continue;
|
||||
const op = url.searchParams.get('op') || '';
|
||||
if (/^[a-z_]+$/i.test(op)) routes.add(`op=${op}`);
|
||||
if (/^\/[a-z_/-]*$/i.test(url.pathname)) routes.add(url.pathname);
|
||||
} catch {}
|
||||
}
|
||||
const fields = [...html.matchAll(/<(?:input|textarea)\b[^>]*\bname=["']([a-z_]+)["']/gi)].map(match => match[1]);
|
||||
const components = [...html.matchAll(/<([a-z]+-[a-z-]+)\b/g)].map(match => match[1]);
|
||||
const scripts = [...html.matchAll(/<script\b[^>]*\bsrc=["']([^"']+)["']/gi)]
|
||||
.map(match => match[1].split('?')[0].split('/').pop())
|
||||
.filter(name => /^[a-z_.-]+\.js$/i.test(name));
|
||||
_debugLog(`session-page ${JSON.stringify({
|
||||
stage, status: response.status, bytes: html.length,
|
||||
challenge: /cf-chl-|challenge-platform|Just a moment|Checking your browser/i.test(html),
|
||||
sessionField: /sess_id/.test(html),
|
||||
cookies: [...this.cookies.keys()],
|
||||
routes: [...routes].slice(0, 60), fields: [...new Set(fields)],
|
||||
components: [...new Set(components)], scripts: [...new Set(scripts)]
|
||||
})}`, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get upload server URL from web interface
|
||||
*/
|
||||
@@ -242,6 +286,12 @@ class DoodstreamUploader {
|
||||
let json;
|
||||
try { json = JSON.parse(text); } catch { json = null; }
|
||||
|
||||
if (json && (json.status === 'fail' || json.success === false)) {
|
||||
const error = new Error(`Doodstream Upload: ${json.message || json.msg || 'No servers available for uploads'}`);
|
||||
error.hosterTransient = /no servers|unavailable|temporar/i.test(error.message);
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (json && json.result && /^https?:\/\//i.test(json.result)) {
|
||||
return json.result;
|
||||
}
|
||||
@@ -337,6 +387,7 @@ class DoodstreamUploader {
|
||||
|
||||
// Get upload server
|
||||
const uploadUrl = await this._getUploadServer();
|
||||
if (!this.sessId) await this._extractSessId();
|
||||
// Remember which CDN node handled this upload so a later parse failure can
|
||||
// report it — failures sometimes correlate with a specific node.
|
||||
this._lastUploadUrl = uploadUrl;
|
||||
@@ -702,10 +753,11 @@ class DoodstreamUploader {
|
||||
async deriveApiKey() {
|
||||
if (this.apiKey) return this.apiKey;
|
||||
let html = '';
|
||||
for (const page of ['/?op=my_account', '/settings', '/?op=profile']) {
|
||||
for (const page of ['/settings', '/?op=my_account', '/?op=profile']) {
|
||||
try {
|
||||
const res = await this._fetch(BASE_URL + page);
|
||||
const text = await res.text();
|
||||
this._diagnoseSessionPage('settings', res, text);
|
||||
if (text && /api[\s_-]?key/i.test(text)) { html = text; break; }
|
||||
if (text && !html) html = text;
|
||||
} catch { /* try next page */ }
|
||||
@@ -715,11 +767,11 @@ class DoodstreamUploader {
|
||||
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})`);
|
||||
_debugLog('api-key derive: account API verification succeeded', true);
|
||||
return key;
|
||||
}
|
||||
}
|
||||
_debugLog(`api-key derive: ${candidates.length} candidate(s), none validated. settings html(2500)=${(html || '').slice(0, 2500)}`);
|
||||
_debugLog(`api-key derive: ${candidates.length} candidate(s), none validated`, true);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
+6
-16
@@ -25,8 +25,9 @@ const DEFAULT_SETTINGS = {
|
||||
};
|
||||
|
||||
class UploadManager extends EventEmitter {
|
||||
constructor(hosterSettings, globalSettings, accountPools) {
|
||||
constructor(hosterSettings, globalSettings, accountPools, options = {}) {
|
||||
super();
|
||||
this.acquireDoodstreamSession = options.acquireDoodstreamSession || null;
|
||||
this.hosterSettings = hosterSettings || {};
|
||||
this.globalSettings = globalSettings || {};
|
||||
this.accountPools = accountPools || {};
|
||||
@@ -1309,22 +1310,11 @@ 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, {
|
||||
doodBaseline: await this._getBaseline('doodstream.com', apiKey, signal)
|
||||
});
|
||||
}
|
||||
this._rotLog('doodstream-via-web', { accountId: task.accountId, fileName: path.basename(task.file) });
|
||||
const dood = new DoodstreamUploader();
|
||||
await dood.login(task.username, task.password);
|
||||
const dood = this.acquireDoodstreamSession
|
||||
? await this.acquireDoodstreamSession(task)
|
||||
: new DoodstreamUploader();
|
||||
if (!this.acquireDoodstreamSession) await dood.login(task.username, task.password);
|
||||
return dood.upload(task.file, progressCb, signal, throttle);
|
||||
} else if (task.hoster === 'clouddrop.cc') {
|
||||
const clouddrop = new ClouddropUploader(task.apiKey);
|
||||
|
||||
Reference in New Issue
Block a user