fix: complete Doodstream login redirects before session checks
CI / verify (push) Canceled after 0s

This commit is contained in:
Sucukdeluxe
2026-09-12 14:26:03 +02:00
parent b40411a7b0
commit fd4bee35eb
3 changed files with 74 additions and 9 deletions
+1
View File
@@ -58,6 +58,7 @@ npm audit --omit=dev
## Offene nächste Schritte ## Offene nächste Schritte
- DoodStream: Der echte OTP-Test nach dem ersten Kompatibilitätsfix meldete weiterhin fehlendes `sess_id`. JSON- und HTTP-Weiterleitungen werden nun vor der Sessionprüfung aufgerufen, einschließlich der dort gesetzten Cookies. 35 gezielte Tests und Lint sind erfolgreich; ein erneuter echter OTP-Login steht aus. Die konkrete Ursache der Nutzersitzung ist noch nicht abschließend bestätigt. Fehlende Sessions liefern HTTP-Status, Gastseiten-/Sessionfeld-Erkennung und Cookie-Anzahl ohne Cookie-Werte oder Zugangsdaten.
- Keine offenen Schritte für Release `v2.1.44`; Rollback-Ziel ist Anwendungsversion `2.1.43`. - Keine offenen Schritte für Release `v2.1.44`; Rollback-Ziel ist Anwendungsversion `2.1.43`.
- Bei Bedarf einen Arbeitsweg ohne `&` im absoluten Pfad verwenden oder die npm-Aufrufe weiterhin direkt ausführen. - Bei Bedarf einen Arbeitsweg ohne `&` im absoluten Pfad verwenden oder die npm-Aufrufe weiterhin direkt ausführen.
+27 -9
View File
@@ -72,6 +72,9 @@ class DoodstreamUploader {
} }
async _fetch(url, opts = {}, _redirectCount = 0) { async _fetch(url, opts = {}, _redirectCount = 0) {
if (opts.allowedOrigin && new URL(url).origin !== opts.allowedOrigin) {
throw new Error('Doodstream Login: Unerwartetes Weiterleitungsziel');
}
const MAX_REDIRECTS = 10; const MAX_REDIRECTS = 10;
const headers = { const headers = {
'User-Agent': USER_AGENT, 'User-Agent': USER_AGENT,
@@ -98,7 +101,7 @@ class DoodstreamUploader {
} catch (err) { } catch (err) {
if (opts.signal && opts.signal.aborted) throw err; // caller abort: don't retry if (opts.signal && opts.signal.aborted) throw err; // caller abort: don't retry
if (attempt >= 3) throw err; if (attempt >= 3) throw err;
_debugLog(`_fetch transient (${attempt}/3) ${url}: ${err && err.message}; retry`); _debugLog(`_fetch transient (${attempt}/3) ${new URL(url).origin}: retry`);
await new Promise(r => setTimeout(r, 400 * attempt)); await new Promise(r => setTimeout(r, 400 * attempt));
} }
} }
@@ -147,22 +150,24 @@ class DoodstreamUploader {
const res = await fetch(`${BASE_URL}/?op=login_ajax&${loginData.toString()}`, { const res = await fetch(`${BASE_URL}/?op=login_ajax&${loginData.toString()}`, {
method: 'GET', method: 'GET',
headers, headers,
redirect: 'manual' redirect: 'manual',
signal: AbortSignal.timeout(20000)
}); });
this._parseCookiesFromHeaders(res.headers); this._parseCookiesFromHeaders(res.headers);
let loginRedirect = '';
// On successful login, server may redirect (3xx) to dashboard // On successful login, server may redirect (3xx) to dashboard
if ([301, 302, 303, 307, 308].includes(res.status)) { if ([301, 302, 303, 307, 308].includes(res.status)) {
try { await res.text(); } catch {} try { await res.text(); } catch {}
// Redirect means login succeeded loginRedirect = res.headers.get('location') || '';
} else { } else {
const body = await res.text(); const body = await res.text();
let json; let json;
try { json = JSON.parse(body); } catch { json = null; } try { json = JSON.parse(body); } catch { json = null; }
if (json && ['success', 'redirect'].includes(json.status)) { if (json && ['success', 'redirect'].includes(json.status)) {
// Explicit success response if (json.status === 'redirect') loginRedirect = json.message || '';
} else if (json && (json.status === 'otp_sent' || (json.message && /otp|verification code/i.test(json.message)))) { } else if (json && (json.status === 'otp_sent' || (json.message && /otp|verification code/i.test(json.message)))) {
// OTP required — signal caller to collect OTP from user // OTP required — signal caller to collect OTP from user
const err = new Error(`Doodstream Login: ${json.message || 'OTP erforderlich'}`); const err = new Error(`Doodstream Login: ${json.message || 'OTP erforderlich'}`);
@@ -178,7 +183,20 @@ class DoodstreamUploader {
} }
} }
// Extract sess_id from the upload page if (loginRedirect) {
const target = new URL(loginRedirect, BASE_URL);
if (target.origin !== BASE_URL || target.username || target.password) {
throw new Error('Doodstream Login: Unerwartetes Weiterleitungsziel');
}
const landing = await this._fetch(target.href, { allowedOrigin: BASE_URL });
const landingHtml = await landing.text();
const sessId = this._findSessId(landingHtml);
if (landing.status === 200 && sessId) {
this.sessId = sessId;
return;
}
}
await this._extractSessId(); await this._extractSessId();
} }
@@ -186,13 +204,13 @@ class DoodstreamUploader {
const res = await this._fetch(BASE_URL + '/?op=upload'); const res = await this._fetch(BASE_URL + '/?op=upload');
const html = await res.text(); const html = await res.text();
const sessId = this._findSessId(html); const sessId = this._findSessId(html);
if (res.status === 200 && sessId) {
if (sessId) {
this.sessId = sessId; this.sessId = sessId;
return; return;
} }
this.sessId = '';
throw new Error('Doodstream: sess_id nicht gefunden nach Login'); 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})`);
} }
_findSessId(html) { _findSessId(html) {
+46
View File
@@ -215,6 +215,52 @@ test('_findSessId accepts the current Vue upload data and URL-safe tokens', () =
assert.equal(up._findSessId(`<home-upload :upload="{ utype: 'anon', sess_id: '' }"></home-upload>`), ''); assert.equal(up._findSessId(`<home-upload :upload="{ utype: 'anon', sess_id: '' }"></home-upload>`), '');
}); });
test('login follows the session-establishing redirect before loading the upload page', async (t) => {
const up = new DoodstreamUploader();
const requests = [];
t.mock.method(globalThis, 'fetch', async (url, options) => {
const parsed = new URL(url);
const route = parsed.pathname + (parsed.searchParams.get('op') || '');
requests.push(route);
if (parsed.searchParams.get('op') === 'login_ajax') {
return new Response(JSON.stringify({ status: 'redirect', message: '/finish-login?ticket=TEST' }));
}
if (parsed.pathname === '/finish-login') {
return new Response('', { headers: { 'set-cookie': 'session=TEST; Path=/' } });
}
if (parsed.searchParams.get('op') === 'upload') {
assert.equal(options.headers.Cookie, 'session=TEST');
return new Response('<input name="sess_id" value="TEST_SESSION">');
}
return new Response('');
});
await up.login('user', 'password', '123456');
assert.deepEqual(requests, ['/', '/login_ajax', '/finish-login', '/upload']);
assert.equal(up.sessId, 'TEST_SESSION');
});
test('login cannot report success when the redirected session is still a guest', async (t) => {
const up = new DoodstreamUploader();
t.mock.method(globalThis, 'fetch', async url => new Response(
new URL(url).searchParams.get('op') === 'login_ajax'
? JSON.stringify({ status: 'redirect', message: '/dashboard' })
: `<home-upload :upload="{ utype: 'anon', sess_id: '' }"></home-upload>`
));
await assert.rejects(up.login('user', 'password'), /guest=true; sessionField=true; cookies=0/);
assert.equal(up.sessId, '');
});
test('login redirect chains cannot forward session cookies to another origin', async (t) => {
const up = new DoodstreamUploader();
let calls = 0;
t.mock.method(globalThis, 'fetch', async () => {
calls++;
return new Response('', { status: 302, headers: { location: 'https://other.example/' } });
});
await assert.rejects(up._fetch('https://doodstream.com/dashboard', { allowedOrigin: 'https://doodstream.com' }), /Weiterleitungsziel/);
assert.equal(calls, 1);
});
test('getUploadServer: parses the current upload_get_srv response', async () => { test('getUploadServer: parses the current upload_get_srv response', async () => {
const up = new DoodstreamUploader(); const up = new DoodstreamUploader();
up._fetch = async (url) => { up._fetch = async (url) => {