fix: preserve explicit Doodstream web login and reuse OTP sessions
CI / verify (push) Canceled after 0s

This commit is contained in:
Sucukdeluxe
2026-09-12 14:38:10 +02:00
parent fd4bee35eb
commit 0e4a9613fb
9 changed files with 188 additions and 34 deletions
+22 -3
View File
@@ -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 };
+61 -9
View File
@@ -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(/&amp;/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
View File
@@ -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);