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:
+5
-3
@@ -18,7 +18,7 @@ Multi-Hoster-Upload ist eine Electron-Desktopanwendung für Windows, die große
|
|||||||
- Die jüngste Automatik schützt erfolgreiche Uploads mit einem atomar gespeicherten Abschlussnachweis aus vollständigem Pfad, Hoster, Dateigröße und Änderungszeit.
|
- Die jüngste Automatik schützt erfolgreiche Uploads mit einem atomar gespeicherten Abschlussnachweis aus vollständigem Pfad, Hoster, Dateigröße und Änderungszeit.
|
||||||
- Schlägt dieser Nachweis fehl, bleibt die Queue erhalten und der Fehler wird als lokale Persistenzstörung behandelt, damit kein stiller Doppel-Upload entsteht.
|
- Schlägt dieser Nachweis fehl, bleibt die Queue erhalten und der Fehler wird als lokale Persistenzstörung behandelt, damit kein stiller Doppel-Upload entsteht.
|
||||||
- DoodStream-OTP-Prüfungen verwenden dieselbe Cookie-Sitzung weiter, fassen identische oder parallele Checks zusammen und fordern einen neuen Code nur nach einer ausdrücklichen Aktion mit mindestens 60 Sekunden Abstand an.
|
- DoodStream-OTP-Prüfungen verwenden dieselbe Cookie-Sitzung weiter, fassen identische oder parallele Checks zusammen und fordern einen neuen Code nur nach einer ausdrücklichen Aktion mit mindestens 60 Sekunden Abstand an.
|
||||||
- DoodStream-Accounts mit API-Key werden auch beim Health-Check über die API geprüft und lösen keinen Web-OTP aus.
|
- DoodStream respektiert die ausdrückliche Auswahl `authType=login`: Weblogin und Webupload werden nicht durch einen zusätzlich gespeicherten API-Key übersteuert. API-Accounts und ältere Accounts ohne ausdrückliche Login-Auswahl behalten den API-Weg.
|
||||||
- Sammelchecks melden jedes Account-Ergebnis einzeln an den Renderer, sodass fertige Karten sofort grün, rot oder als OTP-pflichtig erscheinen, während die übrigen Accounts weiter geprüft werden.
|
- Sammelchecks melden jedes Account-Ergebnis einzeln an den Renderer, sodass fertige Karten sofort grün, rot oder als OTP-pflichtig erscheinen, während die übrigen Accounts weiter geprüft werden.
|
||||||
- Neue Online-Backups unterstützen `24 Stunden`, `3 Tage`, `7 Tage` (Standard), `31 Tage` und `Unbegrenzt`. Endliche Schlüssel werden lokal aus dem verschlüsselten Schlüsselbund entfernt und serverseitig ab Ablauf nicht mehr wiederhergestellt; der Dienst räumt abgelaufene Datensätze bei Zugriff oder der nächsten Speicherung auf.
|
- Neue Online-Backups unterstützen `24 Stunden`, `3 Tage`, `7 Tage` (Standard), `31 Tage` und `Unbegrenzt`. Endliche Schlüssel werden lokal aus dem verschlüsselten Schlüsselbund entfernt und serverseitig ab Ablauf nicht mehr wiederhergestellt; der Dienst räumt abgelaufene Datensätze bei Zugriff oder der nächsten Speicherung auf.
|
||||||
- Vorhandene Online-Backups und alte Upload-Payloads ohne Ablaufangabe bleiben zur Abwärtskompatibilität unbegrenzt gültig.
|
- Vorhandene Online-Backups und alte Upload-Payloads ohne Ablaufangabe bleiben zur Abwärtskompatibilität unbegrenzt gültig.
|
||||||
@@ -27,6 +27,7 @@ Multi-Hoster-Upload ist eine Electron-Desktopanwendung für Windows, die große
|
|||||||
- Upload-Status-Badges und ihre Textlabels sind nicht markierbar; kopierbare Fehlerdetails, Logs und Eingabefelder behalten ihre Textauswahl.
|
- Upload-Status-Badges und ihre Textlabels sind nicht markierbar; kopierbare Fehlerdetails, Logs und Eingabefelder behalten ihre Textauswahl.
|
||||||
- VOE-Fehler mit der Meldung `Maximum storage space of the account used up.` gelten als temporärer Accountfehler. Die Retry-Schleife bricht auch nach einem bereits erfolgten Account-Wechsel sofort ab und setzt die Fallback-Kette Account für Account fort, bis ein Upload gelingt oder kein weiterer Account verfügbar ist.
|
- VOE-Fehler mit der Meldung `Maximum storage space of the account used up.` gelten als temporärer Accountfehler. Die Retry-Schleife bricht auch nach einem bereits erfolgten Account-Wechsel sofort ab und setzt die Fallback-Kette Account für Account fort, bis ein Upload gelingt oder kein weiterer Account verfügbar ist.
|
||||||
- Der DoodStream-Weblogin folgt dem aktuellen Browservertrag über `GET /?op=login_ajax`, behandelt `otp_sent` und `redirect` ausdrücklich und übernimmt `sess_id` auch aus den aktuellen Vue-Daten mit URL-sicheren Sonderzeichen. Die Upload-Server-Ermittlung verwendet `/?op=upload_get_srv` und versteht dessen `server.srv_url`-/`server.disk_id`-Antwort.
|
- Der DoodStream-Weblogin folgt dem aktuellen Browservertrag über `GET /?op=login_ajax`, behandelt `otp_sent` und `redirect` ausdrücklich und übernimmt `sess_id` auch aus den aktuellen Vue-Daten mit URL-sicheren Sonderzeichen. Die Upload-Server-Ermittlung verwendet `/?op=upload_get_srv` und versteht dessen `server.srv_url`-/`server.disk_id`-Antwort.
|
||||||
|
- Eine bestätigte DoodStream-Dashboard-Sitzung benötigt beim Account-Check kein Upload-Sessionfeld. Uploads übernehmen unabhängige Kopien der bestätigten Cookie-Sitzung aus dem OTP-Koordinator. Ein fehlender Upload-Server ist vom Login getrennt; bei Web-Accounts findet keine automatische API-Key-Ableitung für Uploads statt.
|
||||||
- Version `2.1.44` ist als GitHub- und Forgejo-Release veröffentlicht; Backup-API `2.0.4` blieb bei dieser reinen Veröffentlichung der Desktopanwendung unverändert aktiv.
|
- Version `2.1.44` ist als GitHub- und Forgejo-Release veröffentlicht; Backup-API `2.0.4` blieb bei dieser reinen Veröffentlichung der Desktopanwendung unverändert aktiv.
|
||||||
- Der eingebaute Updater liest Releases und Binärdateien von Forgejo; GitHub liefert ergänzend die öffentlichen Release Notes. Ein Release ist deshalb erst vollständig, wenn die vier Assets auch im Forgejo-Release vorhanden sind.
|
- Der eingebaute Updater liest Releases und Binärdateien von Forgejo; GitHub liefert ergänzend die öffentlichen Release Notes. Ein Release ist deshalb erst vollständig, wenn die vier Assets auch im Forgejo-Release vorhanden sind.
|
||||||
- Forgejo bewahrt Leerzeichen in Asset-Namen, GitHub normalisiert sie zu Punkten. Das Forgejo-`latest.yml` und der Release-Plan verwenden Namen wie `Multi-Hoster-Upload Setup 2.1.44.exe`; das GitHub-Manifest muss auf den dort tatsächlich veröffentlichten Punktnamen zeigen.
|
- Forgejo bewahrt Leerzeichen in Asset-Namen, GitHub normalisiert sie zu Punkten. Das Forgejo-`latest.yml` und der Release-Plan verwenden Namen wie `Multi-Hoster-Upload Setup 2.1.44.exe`; das GitHub-Manifest muss auf den dort tatsächlich veröffentlichten Punktnamen zeigen.
|
||||||
@@ -58,7 +59,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.
|
- DoodStream: Am 12.09.2026 wurde das authentifizierte Dashboard ohne `sess_id` live bestätigt. Der alte Upload-Aufruf lieferte eine andere Seite ohne Upload-Felder. Ein zwischenzeitlich getesteter API-Ausweichweg bestätigte zwar den Account, wurde auf Nutzerwunsch wieder entfernt; dessen Uploadversuch scheiterte serverseitig mit `No servers available for uploads`. Der aktuelle Web-Upload muss noch live auf Serververfügbarkeit und erfolgreichen Dateitransfer geprüft werden. Die lokale Seitendiagnose protokolliert ausschließlich Strukturmerkmale ohne Formularwerte, OTP oder Cookie-Werte.
|
||||||
- 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.
|
||||||
|
|
||||||
@@ -67,10 +68,11 @@ npm audit --omit=dev
|
|||||||
Stand: 12.09.2026
|
Stand: 12.09.2026
|
||||||
|
|
||||||
- Lint: erfolgreich, 0 Warnungen und 0 Fehler.
|
- Lint: erfolgreich, 0 Warnungen und 0 Fehler.
|
||||||
- Haupttests: 808 erfolgreich, 0 fehlgeschlagen.
|
- Haupttests: vollständiger Lauf erfolgreich, 0 fehlgeschlagen.
|
||||||
- Backup-API-Tests: 17 erfolgreich, 0 fehlgeschlagen.
|
- Backup-API-Tests: 17 erfolgreich, 0 fehlgeschlagen.
|
||||||
- Der Regressionstest für die VOE-Fallback-Kette bestätigt bei deaktivierter normaler Rotation genau einen Versuch auf jedem vollen Account und anschließend den erfolgreichen Wechsel auf den vierten Account.
|
- Der Regressionstest für die VOE-Fallback-Kette bestätigt bei deaktivierter normaler Rotation genau einen Versuch auf jedem vollen Account und anschließend den erfolgreichen Wechsel auf den vierten Account.
|
||||||
- Der öffentliche DoodStream-Webablauf wurde am 12.09.2026 direkt gegen die Startseite und deren aktuelle Browser-Skripte geprüft. Regressionstests bilden den neuen GET-Login, `otp_sent`, `redirect`, Vue-Sessiontokens mit `_`/`-` und die aktuelle `upload_get_srv`-Antwort nach.
|
- Der öffentliche DoodStream-Webablauf wurde am 12.09.2026 direkt gegen die Startseite und deren aktuelle Browser-Skripte geprüft. Regressionstests bilden den neuen GET-Login, `otp_sent`, `redirect`, Vue-Sessiontokens mit `_`/`-` und die aktuelle `upload_get_srv`-Antwort nach.
|
||||||
|
- Der lokale Web-Account-Check um 14:35:27 bestätigte das authentifizierte Dashboard ohne Upload-Sessionfeld. Regressionen prüfen zusätzlich explizite Web-Auswahl trotz gespeichertem API-Key, Wiederverwendung der OTP-Sitzung, getrennte Cookie-Kopien für parallele Uploads und Web-Serverausfälle. Der Upload-Aufruf verwendet nachweislich weder API-Ableitung noch einen zweiten Login.
|
||||||
- Das Support-Bundle vom 07.09.2026 bestätigt als Ursache der gemeldeten Datei: Wechsel vom Primäraccount auf `Fallback #1`, dort vier unnötige Versuche, anschließend `skip-account-pause` und Abbruch mit `override-same-as-current` statt Weiterschaltung.
|
- Das Support-Bundle vom 07.09.2026 bestätigt als Ursache der gemeldeten Datei: Wechsel vom Primäraccount auf `Fallback #1`, dort vier unnötige Versuche, anschließend `skip-account-pause` und Abbruch mit `override-same-as-current` statt Weiterschaltung.
|
||||||
- Der vollständige opt-in UI-Smoke bestätigte zusätzlich, dass Upload-Status-Badges und deren Labels nicht markierbar sind; die 16 bekannten themenfremden Abweichungen blieben unverändert.
|
- Der vollständige opt-in UI-Smoke bestätigte zusätzlich, dass Upload-Status-Badges und deren Labels nicht markierbar sind; die 16 bekannten themenfremden Abweichungen blieben unverändert.
|
||||||
- Produktionsabhängigkeiten: `npm audit --omit=dev` meldet 0 Schwachstellen.
|
- Produktionsabhängigkeiten: `npm audit --omit=dev` meldet 0 Schwachstellen.
|
||||||
|
|||||||
+22
-3
@@ -20,6 +20,10 @@ const { createHash } = require('node:crypto');
|
|||||||
function selectUploadAuth(hoster, account) {
|
function selectUploadAuth(hoster, account) {
|
||||||
if (!account || typeof account !== 'object') return {};
|
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) {
|
if (hoster === 'doodstream.com' && account.apiKey) {
|
||||||
return { apiKey: account.apiKey };
|
return { apiKey: account.apiKey };
|
||||||
}
|
}
|
||||||
@@ -72,6 +76,7 @@ function createDoodstreamOtpCoordinator(options = {}) {
|
|||||||
const key = credentialKey(username, password);
|
const key = credentialKey(username, password);
|
||||||
const existing = activeState(key);
|
const existing = activeState(key);
|
||||||
if (existing?.inFlight) return existing.inFlight;
|
if (existing?.inFlight) return existing.inFlight;
|
||||||
|
if (existing?.ready && input.requestNewChallenge !== true) return existing.result;
|
||||||
if (otp && !existing?.pending) {
|
if (otp && !existing?.pending) {
|
||||||
return {
|
return {
|
||||||
status: 'otp_required',
|
status: 'otp_required',
|
||||||
@@ -87,8 +92,11 @@ function createDoodstreamOtpCoordinator(options = {}) {
|
|||||||
const operation = (async () => {
|
const operation = (async () => {
|
||||||
try {
|
try {
|
||||||
await uploader.login(username, password, otp || undefined);
|
await uploader.login(username, password, otp || undefined);
|
||||||
if (states.get(key)?.operationId === operationId) states.delete(key);
|
const result = { status: 'ok', message: 'Login erfolgreich' };
|
||||||
return { status: 'ok', message: 'Login ok, Upload-Seite bereit' };
|
if (states.get(key)?.operationId === operationId) {
|
||||||
|
storeState(key, { operationId, uploader, ready: true, expiresAt: now() + challengeTtlMs, result, inFlight: null });
|
||||||
|
}
|
||||||
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error?.otpRequired === true) {
|
if (error?.otpRequired === true) {
|
||||||
const result = { status: 'otp_required', message: error.message || 'OTP erforderlich' };
|
const result = { status: 'otp_required', message: error.message || 'OTP erforderlich' };
|
||||||
@@ -137,7 +145,18 @@ function createDoodstreamOtpCoordinator(options = {}) {
|
|||||||
return operation;
|
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 };
|
module.exports = { createDoodstreamOtpCoordinator, selectUploadAuth };
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ function _doodstreamLogPath() {
|
|||||||
let _debugVerbose = false;
|
let _debugVerbose = false;
|
||||||
function setDebugVerbose(v) { _debugVerbose = !!v; }
|
function setDebugVerbose(v) { _debugVerbose = !!v; }
|
||||||
|
|
||||||
function _debugLog(msg) {
|
function _debugLog(msg, force = false) {
|
||||||
if (!_debugVerbose) return;
|
if (!_debugVerbose && !force) return;
|
||||||
try {
|
try {
|
||||||
const logPath = _doodstreamLogPath();
|
const logPath = _doodstreamLogPath();
|
||||||
maybeRotateLogFile(logPath, _DOODSTREAM_LOG_MAX_BYTES, _DOODSTREAM_LOG_MAX_BACKUPS);
|
maybeRotateLogFile(logPath, _DOODSTREAM_LOG_MAX_BYTES, _DOODSTREAM_LOG_MAX_BACKUPS);
|
||||||
@@ -53,6 +53,20 @@ class DoodstreamUploader {
|
|||||||
.join('; ');
|
.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) {
|
_parseCookiesFromHeaders(headers) {
|
||||||
let setCookies;
|
let setCookies;
|
||||||
if (typeof headers.getSetCookie === 'function') {
|
if (typeof headers.getSetCookie === 'function') {
|
||||||
@@ -175,8 +189,9 @@ class DoodstreamUploader {
|
|||||||
throw err;
|
throw err;
|
||||||
} else if (json && json.status === 'fail') {
|
} else if (json && json.status === 'fail') {
|
||||||
throw new Error(`Doodstream Login: ${json.message || 'Login fehlgeschlagen'}`);
|
throw new Error(`Doodstream Login: ${json.message || 'Login fehlgeschlagen'}`);
|
||||||
} else if (body.includes('Dashboard')) {
|
} else if (res.status === 200 && this._isAuthenticatedPage(body)) {
|
||||||
// Got dashboard HTML directly — login worked
|
this.sessId = this._findSessId(body);
|
||||||
|
return;
|
||||||
} else {
|
} else {
|
||||||
const msg = (json && json.message) || 'Login fehlgeschlagen';
|
const msg = (json && json.message) || 'Login fehlgeschlagen';
|
||||||
throw new Error(`Doodstream Login: ${msg}`);
|
throw new Error(`Doodstream Login: ${msg}`);
|
||||||
@@ -190,8 +205,9 @@ class DoodstreamUploader {
|
|||||||
}
|
}
|
||||||
const landing = await this._fetch(target.href, { allowedOrigin: BASE_URL });
|
const landing = await this._fetch(target.href, { allowedOrigin: BASE_URL });
|
||||||
const landingHtml = await landing.text();
|
const landingHtml = await landing.text();
|
||||||
|
this._diagnoseSessionPage('landing', landing, landingHtml);
|
||||||
const sessId = this._findSessId(landingHtml);
|
const sessId = this._findSessId(landingHtml);
|
||||||
if (landing.status === 200 && sessId) {
|
if (landing.status === 200 && (sessId || this._isAuthenticatedPage(landingHtml))) {
|
||||||
this.sessId = sessId;
|
this.sessId = sessId;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -203,6 +219,7 @@ class DoodstreamUploader {
|
|||||||
async _extractSessId() {
|
async _extractSessId() {
|
||||||
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();
|
||||||
|
this._diagnoseSessionPage('upload', res, html);
|
||||||
const sessId = this._findSessId(html);
|
const sessId = this._findSessId(html);
|
||||||
if (res.status === 200 && sessId) {
|
if (res.status === 200 && sessId) {
|
||||||
this.sessId = sessId;
|
this.sessId = sessId;
|
||||||
@@ -210,7 +227,8 @@ class DoodstreamUploader {
|
|||||||
}
|
}
|
||||||
this.sessId = '';
|
this.sessId = '';
|
||||||
const guest = /utype\s*:\s*['"]anon['"]/.test(html);
|
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) {
|
_findSessId(html) {
|
||||||
@@ -225,6 +243,32 @@ class DoodstreamUploader {
|
|||||||
return match ? match[2].trim() : '';
|
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
|
* Get upload server URL from web interface
|
||||||
*/
|
*/
|
||||||
@@ -242,6 +286,12 @@ class DoodstreamUploader {
|
|||||||
let json;
|
let json;
|
||||||
try { json = JSON.parse(text); } catch { json = null; }
|
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)) {
|
if (json && json.result && /^https?:\/\//i.test(json.result)) {
|
||||||
return json.result;
|
return json.result;
|
||||||
}
|
}
|
||||||
@@ -337,6 +387,7 @@ class DoodstreamUploader {
|
|||||||
|
|
||||||
// Get upload server
|
// Get upload server
|
||||||
const uploadUrl = await this._getUploadServer();
|
const uploadUrl = await this._getUploadServer();
|
||||||
|
if (!this.sessId) await this._extractSessId();
|
||||||
// Remember which CDN node handled this upload so a later parse failure can
|
// Remember which CDN node handled this upload so a later parse failure can
|
||||||
// report it — failures sometimes correlate with a specific node.
|
// report it — failures sometimes correlate with a specific node.
|
||||||
this._lastUploadUrl = uploadUrl;
|
this._lastUploadUrl = uploadUrl;
|
||||||
@@ -702,10 +753,11 @@ class DoodstreamUploader {
|
|||||||
async deriveApiKey() {
|
async deriveApiKey() {
|
||||||
if (this.apiKey) return this.apiKey;
|
if (this.apiKey) return this.apiKey;
|
||||||
let html = '';
|
let html = '';
|
||||||
for (const page of ['/?op=my_account', '/settings', '/?op=profile']) {
|
for (const page of ['/settings', '/?op=my_account', '/?op=profile']) {
|
||||||
try {
|
try {
|
||||||
const res = await this._fetch(BASE_URL + page);
|
const res = await this._fetch(BASE_URL + page);
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
|
this._diagnoseSessionPage('settings', res, text);
|
||||||
if (text && /api[\s_-]?key/i.test(text)) { html = text; break; }
|
if (text && /api[\s_-]?key/i.test(text)) { html = text; break; }
|
||||||
if (text && !html) html = text;
|
if (text && !html) html = text;
|
||||||
} catch { /* try next page */ }
|
} catch { /* try next page */ }
|
||||||
@@ -715,11 +767,11 @@ class DoodstreamUploader {
|
|||||||
for (const key of candidates.slice(0, 15)) {
|
for (const key of candidates.slice(0, 15)) {
|
||||||
if (await this._validateApiKey(key)) {
|
if (await this._validateApiKey(key)) {
|
||||||
this.apiKey = key;
|
this.apiKey = key;
|
||||||
_debugLog(`api-key derive: validated key (len ${key.length})`);
|
_debugLog('api-key derive: account API verification succeeded', true);
|
||||||
return key;
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-16
@@ -25,8 +25,9 @@ const DEFAULT_SETTINGS = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
class UploadManager extends EventEmitter {
|
class UploadManager extends EventEmitter {
|
||||||
constructor(hosterSettings, globalSettings, accountPools) {
|
constructor(hosterSettings, globalSettings, accountPools, options = {}) {
|
||||||
super();
|
super();
|
||||||
|
this.acquireDoodstreamSession = options.acquireDoodstreamSession || null;
|
||||||
this.hosterSettings = hosterSettings || {};
|
this.hosterSettings = hosterSettings || {};
|
||||||
this.globalSettings = globalSettings || {};
|
this.globalSettings = globalSettings || {};
|
||||||
this.accountPools = accountPools || {};
|
this.accountPools = accountPools || {};
|
||||||
@@ -1309,22 +1310,11 @@ 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, {
|
|
||||||
doodBaseline: await this._getBaseline('doodstream.com', apiKey, signal)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
this._rotLog('doodstream-via-web', { accountId: task.accountId, fileName: path.basename(task.file) });
|
this._rotLog('doodstream-via-web', { accountId: task.accountId, fileName: path.basename(task.file) });
|
||||||
const dood = new DoodstreamUploader();
|
const dood = this.acquireDoodstreamSession
|
||||||
await dood.login(task.username, task.password);
|
? await this.acquireDoodstreamSession(task)
|
||||||
|
: new DoodstreamUploader();
|
||||||
|
if (!this.acquireDoodstreamSession) await dood.login(task.username, task.password);
|
||||||
return dood.upload(task.file, progressCb, signal, throttle);
|
return dood.upload(task.file, progressCb, signal, throttle);
|
||||||
} else if (task.hoster === 'clouddrop.cc') {
|
} else if (task.hoster === 'clouddrop.cc') {
|
||||||
const clouddrop = new ClouddropUploader(task.apiKey);
|
const clouddrop = new ClouddropUploader(task.apiKey);
|
||||||
|
|||||||
@@ -2343,7 +2343,9 @@ ipcMain.handle('start-upload', async (_event, payload) => {
|
|||||||
return { started: true, taskCount: 0, skippedJobs };
|
return { started: true, taskCount: 0, skippedJobs };
|
||||||
}
|
}
|
||||||
|
|
||||||
uploadManager = new UploadManager(config.hosterSettings || {}, config.globalSettings || {}, buildAccountPools(config));
|
uploadManager = new UploadManager(config.hosterSettings || {}, config.globalSettings || {}, buildAccountPools(config), {
|
||||||
|
acquireDoodstreamSession: (task) => doodstreamHealthCoordinator.acquire({ username: task.username, password: task.password })
|
||||||
|
});
|
||||||
globalThis._mhuUploadManagerRef = uploadManager;
|
globalThis._mhuUploadManagerRef = uploadManager;
|
||||||
const _thisManager = uploadManager;
|
const _thisManager = uploadManager;
|
||||||
await registerAutomationCompletionJobs(_thisManager, jobs);
|
await registerAutomationCompletionJobs(_thisManager, jobs);
|
||||||
|
|||||||
@@ -564,6 +564,7 @@
|
|||||||
['Einstellungen konnten vor dem Update nicht gespeichert werden', 'Settings could not be saved before the update'],
|
['Einstellungen konnten vor dem Update nicht gespeichert werden', 'Settings could not be saved before the update'],
|
||||||
['Das Update wurde nicht gestartet, weil die Einstellungen vor dem Beenden nicht gespeichert werden konnten', 'The update was not started because the settings could not be saved before quitting'],
|
['Das Update wurde nicht gestartet, weil die Einstellungen vor dem Beenden nicht gespeichert werden konnten', 'The update was not started because the settings could not be saved before quitting'],
|
||||||
['Login ok, Upload-Seite bereit', 'Login successful, upload page ready'],
|
['Login ok, Upload-Seite bereit', 'Login successful, upload page ready'],
|
||||||
|
['Login erfolgreich', 'Login successful'],
|
||||||
['Login oder API Key fehlt', 'Login or API key is missing'],
|
['Login oder API Key fehlt', 'Login or API key is missing'],
|
||||||
['Account-Check lieferte kein gültiges JSON', 'Account check did not return valid JSON'],
|
['Account-Check lieferte kein gültiges JSON', 'Account check did not return valid JSON'],
|
||||||
['Account-Check fehlgeschlagen', 'Account check failed'],
|
['Account-Check fehlgeschlagen', 'Account check failed'],
|
||||||
|
|||||||
@@ -49,6 +49,22 @@ function otpRequired(message = 'OTP erforderlich') {
|
|||||||
return error;
|
return error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test('explicit web login is not overridden by a stored API key', () => {
|
||||||
|
assert.deepEqual(selectUploadAuth('doodstream.com', { authType: 'login', username: 'user', password: 'secret', apiKey: 'KEY' }), { username: 'user', password: 'secret' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('upload reuses the confirmed OTP session without another login', async () => {
|
||||||
|
let logins = 0;
|
||||||
|
const uploadSession = {};
|
||||||
|
const coordinator = createDoodstreamOtpCoordinator({
|
||||||
|
createUploader: () => ({ async login() { logins++; }, cloneSession: () => uploadSession })
|
||||||
|
});
|
||||||
|
const health = await coordinator.check({ username: 'user', password: 'secret' });
|
||||||
|
assert.equal(health.status, 'ok');
|
||||||
|
assert.equal(await coordinator.acquire({ username: 'user', password: 'secret' }), uploadSession);
|
||||||
|
assert.equal(logins, 1);
|
||||||
|
});
|
||||||
|
|
||||||
test('concurrent and repeated Doodstream checks request only one OTP', async () => {
|
test('concurrent and repeated Doodstream checks request only one OTP', async () => {
|
||||||
let loginCalls = 0;
|
let loginCalls = 0;
|
||||||
let releaseLogin;
|
let releaseLogin;
|
||||||
@@ -87,7 +103,7 @@ test('Doodstream OTP verification reuses the challenged uploader session', async
|
|||||||
assert.equal((await coordinator.check({ username: 'user', password: 'secret' })).status, 'otp_required');
|
assert.equal((await coordinator.check({ username: 'user', password: 'secret' })).status, 'otp_required');
|
||||||
assert.deepEqual(await coordinator.check({ username: 'user', password: 'secret', otp: '123456' }), {
|
assert.deepEqual(await coordinator.check({ username: 'user', password: 'secret', otp: '123456' }), {
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
message: 'Login ok, Upload-Seite bereit'
|
message: 'Login erfolgreich'
|
||||||
});
|
});
|
||||||
assert.equal(created, 1);
|
assert.equal(created, 1);
|
||||||
assert.deepEqual(calls, ['', '123456']);
|
assert.deepEqual(calls, ['', '123456']);
|
||||||
|
|||||||
@@ -246,7 +246,7 @@ test('login cannot report success when the redirected session is still a guest',
|
|||||||
? JSON.stringify({ status: 'redirect', message: '/dashboard' })
|
? JSON.stringify({ status: 'redirect', message: '/dashboard' })
|
||||||
: `<home-upload :upload="{ utype: 'anon', sess_id: '' }"></home-upload>`
|
: `<home-upload :upload="{ utype: 'anon', sess_id: '' }"></home-upload>`
|
||||||
));
|
));
|
||||||
await assert.rejects(up.login('user', 'password'), /guest=true; sessionField=true; cookies=0/);
|
await assert.rejects(up.login('user', 'password'), /sess_id nicht gefunden/);
|
||||||
assert.equal(up.sessId, '');
|
assert.equal(up.sessId, '');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -261,6 +261,56 @@ test('login redirect chains cannot forward session cookies to another origin', a
|
|||||||
assert.equal(calls, 1);
|
assert.equal(calls, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('authenticated dashboard verifies web login without an upload token or API calls', async (t) => {
|
||||||
|
const up = new DoodstreamUploader();
|
||||||
|
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||||
|
const target = new URL(url);
|
||||||
|
assert.equal(target.hostname, 'doodstream.com');
|
||||||
|
if (target.searchParams.get('op') === 'login_ajax') {
|
||||||
|
return new Response(JSON.stringify({ status: 'redirect', message: '/dashboard' }), {
|
||||||
|
headers: { 'set-cookie': 'xfsts=SESSION; Path=/' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new Response('<a href="/settings">Settings</a><a href="/videos">Videos</a><a href="/?op=logout">Logout</a>');
|
||||||
|
});
|
||||||
|
await up.login('user', 'secret', '123456');
|
||||||
|
assert.equal(up.apiKey, '');
|
||||||
|
assert.equal(up.sessId, '');
|
||||||
|
assert.equal(up.cookies.get('xfsts'), 'SESSION');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an unverified settings token cannot make a missing-session login pass', async (t) => {
|
||||||
|
const up = new DoodstreamUploader();
|
||||||
|
t.mock.method(globalThis, 'fetch', async url => {
|
||||||
|
const target = new URL(url);
|
||||||
|
if (target.hostname === 'doodapi.co') return new Response(JSON.stringify({ status: 403 }));
|
||||||
|
if (target.searchParams.get('op') === 'login_ajax') return new Response(JSON.stringify({ status: 'success' }));
|
||||||
|
if (target.pathname === '/settings') return new Response('<label>API Key</label><input value="INVALIDKEY12345678901234567890">');
|
||||||
|
return new Response('');
|
||||||
|
});
|
||||||
|
await assert.rejects(up.login('user', 'secret'), /sess_id nicht gefunden/);
|
||||||
|
assert.equal(up.apiKey, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('web server unavailability is a hoster failure rather than a missing login session', async () => {
|
||||||
|
const up = new DoodstreamUploader();
|
||||||
|
up._fetch = async () => new Response(JSON.stringify({ success: false, message: 'No servers available for uploads' }));
|
||||||
|
await assert.rejects(up._getUploadServer(), error => error.hosterTransient === true && /No servers available/.test(error.message));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parallel web uploads receive independent cookie and upload-session state', () => {
|
||||||
|
const up = new DoodstreamUploader();
|
||||||
|
up.cookies.set('xfsts', 'AUTH_SESSION');
|
||||||
|
up.sessId = 'UPLOAD_SESSION';
|
||||||
|
const first = up.cloneSession();
|
||||||
|
const second = up.cloneSession();
|
||||||
|
first.cookies.set('xfsts', 'CHANGED');
|
||||||
|
first.sessId = 'CHANGED';
|
||||||
|
assert.equal(second.cookies.get('xfsts'), 'AUTH_SESSION');
|
||||||
|
assert.equal(second.sessId, 'UPLOAD_SESSION');
|
||||||
|
assert.equal(up.cookies.get('xfsts'), 'AUTH_SESSION');
|
||||||
|
});
|
||||||
|
|
||||||
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) => {
|
||||||
|
|||||||
@@ -70,6 +70,28 @@ describe('UploadManager', () => {
|
|||||||
assert.ok(events.length > 0, 'should emit at least one progress event');
|
assert.ok(events.length > 0, 'should emit at least one progress event');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('web-login uploads use the confirmed web session without API discovery or a second login', async () => {
|
||||||
|
let uploads = 0;
|
||||||
|
const mgr = new UploadManager({}, {}, {}, {
|
||||||
|
acquireDoodstreamSession: async task => {
|
||||||
|
assert.equal(task.username, 'user');
|
||||||
|
return {
|
||||||
|
async login() { throw new Error('must not request another OTP'); },
|
||||||
|
async upload(file) {
|
||||||
|
assert.equal(file, '/test/video.mp4');
|
||||||
|
uploads++;
|
||||||
|
return { file_code: 'WEBFILE123', download_url: 'https://doodstream.com/d/WEBFILE123' };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
mgr._resolveDoodstreamApiKey = async () => { throw new Error('must not change web login to API'); };
|
||||||
|
const result = await mgr._executeUpload({ hoster: 'doodstream.com', file: '/test/video.mp4', username: 'user', password: 'secret' });
|
||||||
|
assert.equal(result.file_code, 'WEBFILE123');
|
||||||
|
assert.equal(uploads, 1);
|
||||||
|
assert.equal(mockUploadFile.mock.callCount(), 0);
|
||||||
|
});
|
||||||
|
|
||||||
it('emits job-settled after releasing job resources', async () => {
|
it('emits job-settled after releasing job resources', async () => {
|
||||||
const mgr = new UploadManager({});
|
const mgr = new UploadManager({});
|
||||||
let settled;
|
let settled;
|
||||||
|
|||||||
Reference in New Issue
Block a user