diff --git a/PROJECT_MEMORY.md b/PROJECT_MEMORY.md index c1480ae..1f97a3e 100644 --- a/PROJECT_MEMORY.md +++ b/PROJECT_MEMORY.md @@ -26,7 +26,8 @@ Multi-Hoster-Upload ist eine Electron-Desktopanwendung für Windows, die große - Ein nicht vorhandener Ordnerüberwachungspfad bleibt nach dem Import sichtbar gespeichert, die Überwachung wird aber deaktiviert und der Nutzer erhält eine Warnung. Ein nicht vorhandener Log-Ordner wird ebenfalls gemeldet, ohne den konfigurierten Pfad still zu löschen. - 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. -- Version `2.1.44` ist als GitHub- und Forgejo-Release veröffentlicht; Backup-API `2.0.4` blieb bei diesem reinen Desktop-Release unverändert aktiv. +- 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. +- 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. - 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/master` besitzt eine getrennte ältere Historie. Die aktuelle GitHub-Arbeitslinie wird deshalb zerstörungsfrei unter `forgejo/sync/github-master` gespiegelt. @@ -62,12 +63,13 @@ npm audit --omit=dev ## Zuletzt verifiziert -Stand: 07.09.2026 +Stand: 12.09.2026 - Lint: erfolgreich, 0 Warnungen und 0 Fehler. -- Haupttests: 807 erfolgreich, 0 fehlgeschlagen. +- Haupttests: 808 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 ö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. - 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. - Produktionsabhängigkeiten: `npm audit --omit=dev` meldet 0 Schwachstellen. diff --git a/lib/doodstream-upload.js b/lib/doodstream-upload.js index 51afebc..4798bb9 100644 --- a/lib/doodstream-upload.js +++ b/lib/doodstream-upload.js @@ -127,9 +127,8 @@ class DoodstreamUploader { await homeRes.text(); } - // POST login via AJAX (op in body, XHR header required for JSON response) + // GET login via AJAX (XHR header required for JSON response) const loginData = new URLSearchParams({ - op: 'login_ajax', login: username, password: password, loginotp: otp || '' @@ -137,7 +136,6 @@ class DoodstreamUploader { // Use raw fetch with redirect: 'manual' to detect success redirects const headers = { - 'Content-Type': 'application/x-www-form-urlencoded', 'Referer': BASE_URL + '/', 'X-Requested-With': 'XMLHttpRequest', 'User-Agent': USER_AGENT @@ -146,9 +144,8 @@ class DoodstreamUploader { headers['Cookie'] = this._cookieHeader(); } - const res = await fetch(BASE_URL + '/', { - method: 'POST', - body: loginData.toString(), + const res = await fetch(`${BASE_URL}/?op=login_ajax&${loginData.toString()}`, { + method: 'GET', headers, redirect: 'manual' }); @@ -164,11 +161,11 @@ class DoodstreamUploader { let json; try { json = JSON.parse(body); } catch { json = null; } - if (json && json.status === 'success') { + if (json && ['success', 'redirect'].includes(json.status)) { // Explicit success response - } else if (json && json.message && /otp/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 - const err = new Error(`Doodstream Login: ${json.message}`); + const err = new Error(`Doodstream Login: ${json.message || 'OTP erforderlich'}`); err.otpRequired = true; throw err; } else if (json && json.status === 'fail') { @@ -188,46 +185,53 @@ class DoodstreamUploader { async _extractSessId() { const res = await this._fetch(BASE_URL + '/?op=upload'); const html = await res.text(); + const sessId = this._findSessId(html); - // Hidden input: - const hiddenMatch = html.match(/name=["']sess_id["'][^>]*value=["']([a-zA-Z0-9]+)["']/); - if (hiddenMatch) { - this.sessId = hiddenMatch[1]; - return; - } - - // Vue component prop or JS: sess_id: "xxx" or sess_id="xxx" - const sessMatch = html.match(/sess_id['":\s]+['"]([a-zA-Z0-9]+)['"]/); - if (sessMatch) { - this.sessId = sessMatch[1]; - return; - } - - // Assignment: sess_id = 'xxx' - const altMatch = html.match(/sess_id\s*=\s*['"]([a-zA-Z0-9]+)['"]/); - if (altMatch) { - this.sessId = altMatch[1]; + if (sessId) { + this.sessId = sessId; return; } throw new Error('Doodstream: sess_id nicht gefunden nach Login'); } + _findSessId(html) { + if (!html) return ''; + const fields = this._extractHiddenFields(html); + if (fields.sess_id) return String(fields.sess_id).trim(); + const decoded = String(html) + .replace(/"|"|"/gi, '"') + .replace(/'|'|'/gi, "'") + .replace(/&/gi, '&'); + const match = decoded.match(/(?:["'])?\bsess_id\b(?:["'])?\s*[:=]\s*(["'])([^"']+)\1/i); + return match ? match[2].trim() : ''; + } + /** * Get upload server URL from web interface */ async _getUploadServer() { // Use the standard upload server endpoint - const res = await this._fetch(BASE_URL + '/?op=upload_server'); + const res = await this._fetch(BASE_URL + '/?op=upload_get_srv', { + headers: { + 'Referer': BASE_URL + '/?op=upload', + 'X-Requested-With': 'XMLHttpRequest' + } + }); const text = await res.text(); const ctype = (res.headers && res.headers.get) ? (res.headers.get('content-type') || '') : ''; - _debugLog(`upload_server: status=${res.status} ctype=${ctype} body(800)=${(text || '').slice(0, 800)}`); + _debugLog(`upload_get_srv: status=${res.status} ctype=${ctype} body(800)=${(text || '').slice(0, 800)}`); let json; try { json = JSON.parse(text); } catch { json = null; } if (json && json.result && /^https?:\/\//i.test(json.result)) { return json.result; } + if (json && json.server && /^https?:\/\//i.test(json.server.srv_url || '') && json.server.disk_id !== undefined) { + const server = String(json.server.srv_url).replace(/\/$/, ''); + const diskId = encodeURIComponent(String(json.server.disk_id)); + return `${server}/upload/${diskId}?t=${Date.now()}`; + } // Fallback: try fetching from upload page HTML const pageRes = await this._fetch(BASE_URL + '/?op=upload'); @@ -246,9 +250,9 @@ class DoodstreamUploader { const actionMatch = html.match(/action=["'](https?:\/\/[^"']+\/upload\/[^"']*)["']/i); if (actionMatch) { const url = actionMatch[1].replace(/&/g, '&'); // un-escape HTML entities in query - const freshSess = html.match(/name=["']sess_id["'][^>]*value=["']([a-zA-Z0-9]+)["']/); + const freshSess = this._findSessId(html); if (freshSess) { - this.sessId = freshSess[1]; + this.sessId = freshSess; } else { _debugLog('upload_server: form action found but no sess_id on page; keeping existing sessId'); } @@ -272,7 +276,7 @@ class DoodstreamUploader { _debugLog(`upload_server: NO SERVER. upload-page html(2000)=${(html || '').slice(0, 2000)}`); throw new Error( `Doodstream: konnte Upload-Server nicht ermitteln (Endpoint geändert?). ` + - `op=upload_server status=${res.status} ctype=${ctype} body=${(text || '').slice(0, 300)} ` + + `op=upload_get_srv status=${res.status} ctype=${ctype} body=${(text || '').slice(0, 300)} ` + `| upload-page URL-Treffer: ${urlHints || 'keine'}` ); } diff --git a/tests/doodstream-upload.test.js b/tests/doodstream-upload.test.js index f710fae..7f9d9b9 100644 --- a/tests/doodstream-upload.test.js +++ b/tests/doodstream-upload.test.js @@ -168,24 +168,27 @@ test('OTP verification keeps the challenged cookie session without another boots let loginCalls = 0; up._fetch = async () => { bootstrapCalls++; - return fakeRes(bootstrapCalls === 1 ? 'ok' : ''); + return fakeRes(bootstrapCalls === 1 ? 'ok' : ``); }; - globalThis.fetch = async (_url, options) => { + globalThis.fetch = async (url, options) => { loginCalls++; + assert.match(url, /\?op=login_ajax&/u); + assert.equal(options.method, 'GET'); + assert.equal(options.body, undefined); if (loginCalls === 1) { assert.equal(options.headers.Cookie, undefined); return { status: 200, headers: { getSetCookie: () => ['otp_session=SESSION123; Path=/'], get: () => null }, - text: async () => JSON.stringify({ status: 'fail', message: 'OTP required' }) + text: async () => JSON.stringify({ status: 'otp_sent', message: 'Verification code has been sent' }) }; } assert.equal(options.headers.Cookie, 'otp_session=SESSION123'); - assert.match(options.body, /loginotp=123456/u); + assert.match(url, /loginotp=123456/u); return { - status: 302, - headers: { getSetCookie: () => [], get: () => '/dashboard' }, - text: async () => '' + status: 200, + headers: { getSetCookie: () => [], get: () => null }, + text: async () => JSON.stringify({ status: 'redirect', message: '/?op=my_account' }) }; }; try { @@ -193,24 +196,38 @@ test('OTP verification keeps the challenged cookie session without another boots await up.login('user', 'secret', '123456'); assert.equal(bootstrapCalls, 2); assert.equal(loginCalls, 2); + assert.equal(up.sessId, 'SESSION_456-X'); } finally { globalThis.fetch = originalFetch; } }); -test('getUploadServer: returns JSON result when present', async () => { +test('_findSessId accepts the current Vue upload data and URL-safe tokens', () => { + const up = new DoodstreamUploader(); + assert.equal( + up._findSessId(``), + 'abc_DEF-123' + ); + assert.equal( + up._findSessId(''), + 'abc_DEF-456' + ); + assert.equal(up._findSessId(``), ''); +}); + +test('getUploadServer: parses the current upload_get_srv response', async () => { const up = new DoodstreamUploader(); up._fetch = async (url) => { - assert.match(url, /op=upload_server/); - return fakeRes(JSON.stringify({ result: 'https://node42.cloudatacdn.com/upload/01' }), { ctype: 'application/json' }); + assert.match(url, /op=upload_get_srv/); + return fakeRes(JSON.stringify({ success: true, server: { srv_url: 'https://node42.cloudatacdn.com', disk_id: '01' } }), { ctype: 'application/json' }); }; - assert.equal(await up._getUploadServer(), 'https://node42.cloudatacdn.com/upload/01'); + assert.match(await up._getUploadServer(), /^https:\/\/node42\.cloudatacdn\.com\/upload\/01\?t=\d+$/u); }); test('getUploadServer: falls back to srv_url in upload-page HTML', async () => { const up = new DoodstreamUploader(); up._fetch = async (url) => { - if (/op=upload_server/.test(url)) return fakeRes('not json'); + if (/op=upload_get_srv/.test(url)) return fakeRes('not json'); return fakeRes(''); }; assert.equal(await up._getUploadServer(), 'https://node7.cloudatacdn.com/upload/01'); @@ -220,18 +237,18 @@ test('getUploadServer: parses current form-action node and refreshes sess_id fro const up = new DoodstreamUploader(); up.sessId = 'stale-from-login'; up._fetch = async (url) => { - if (/op=upload_server/.test(url)) return fakeRes('not json'); - return fakeRes('
'); + if (/op=upload_get_srv/.test(url)) return fakeRes('not json'); + return fakeRes('
'); }; const url = await up._getUploadServer(); - assert.equal(url, 'https://n9.cloudatacdn.com/upload/01?FRESH123'); - assert.equal(up.sessId, 'FRESH123'); // critical: form-field token must match the node URL token + assert.equal(url, 'https://n9.cloudatacdn.com/upload/01?FRESH_123-X'); + assert.equal(up.sessId, 'FRESH_123-X'); // critical: form-field token must match the node URL token }); test('getUploadServer: un-escapes & in the form-action query string', async () => { const up = new DoodstreamUploader(); up._fetch = async (url) => { - if (/op=upload_server/.test(url)) return fakeRes('not json'); + if (/op=upload_get_srv/.test(url)) return fakeRes('not json'); return fakeRes('
'); }; assert.equal(await up._getUploadServer(), 'https://n9.cloudatacdn.com/upload/01?a=1&b=2');