From b64cdd0ff35d0f2eab7efcf065f4644d7b5cbdf1 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe <259325684+Sucukdeluxe@users.noreply.github.com> Date: Thu, 13 Aug 2026 20:43:48 +0200 Subject: [PATCH] fix: harden hoster confirmation and recovery Require every successful upload to expose a validated HTTPS result and rebuild all Doodstream and DSVPlay output from the confirmed file code. Keep failed baselines distinct from empty accounts, reject stale, foreign, and ambiguous recovery candidates across Doodstream, Byse, VOE, and Vidmoly, and preserve exact filename recovery with normalized extensions. Emit bounded structured transport diagnostics without raw response bodies or tokenized URLs, and remove sensitive values from Doodstream debug traces. Tests: node --test tests/upload-confirmation.test.js tests/hosters.test.js tests/doodstream-api-upload.test.js tests/doodstream-upload.test.js tests/byse-reject-recovery.test.js tests/hoster-recovery-provenance.test.js tests/suspect-reject-alternates.test.js Lint: eslint lib/hoster-transport-error.js lib/hosters.js lib/doodstream-upload.js lib/voe-upload.js lib/vidmoly-upload.js lib/upload-confirmation.js --- lib/doodstream-upload.js | 209 ++++++++--- lib/hoster-transport-error.js | 90 +++++ lib/hosters.js | 424 ++++++++++++++++------- lib/upload-confirmation.js | 41 +-- lib/vidmoly-upload.js | 257 +++++++++----- lib/voe-upload.js | 258 ++++++++++---- tests/byse-reject-recovery.test.js | 88 +++++ tests/doodstream-api-upload.test.js | 94 ++++- tests/doodstream-upload.test.js | 56 +++ tests/hoster-recovery-provenance.test.js | 118 +++++++ tests/hosters.test.js | 7 +- tests/upload-confirmation.test.js | 56 ++- 12 files changed, 1320 insertions(+), 378 deletions(-) create mode 100644 lib/hoster-transport-error.js create mode 100644 tests/hoster-recovery-provenance.test.js diff --git a/lib/doodstream-upload.js b/lib/doodstream-upload.js index 4cc45d1..d6a56a0 100644 --- a/lib/doodstream-upload.js +++ b/lib/doodstream-upload.js @@ -2,6 +2,12 @@ const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const { request } = require('undici'); +const { + createTransportError, + safeEndpoint, + sanitizeRemoteText, + summarizeResponse +} = require('./hoster-transport-error'); const BASE_URL = 'https://doodstream.com'; const USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'; @@ -97,8 +103,15 @@ class DoodstreamUploader { break; } catch (err) { if (opts.signal && opts.signal.aborted) throw err; // caller abort: don't retry - if (attempt >= 3) throw err; - _debugLog(`_fetch transient (${attempt}/3) ${url}: ${err && err.message}; retry`); + if (attempt >= 3) { + throw createTransportError('Doodstream: Webanfrage fehlgeschlagen', { + phase: 'web-request', + endpoint: url, + retryable: true, + transientNetwork: true + }); + } + _debugLog(`_fetch transient (${attempt}/3) ${safeEndpoint(url) || 'unknown endpoint'}: ${err && err.name ? err.name : 'network error'}; retry`); await new Promise(r => setTimeout(r, 400 * attempt)); } } @@ -167,16 +180,35 @@ class DoodstreamUploader { // Explicit success response } else if (json && json.message && /otp/i.test(json.message)) { // OTP required — signal caller to collect OTP from user - const err = new Error(`Doodstream Login: ${json.message}`); + const err = createTransportError(`Doodstream Login: ${sanitizeRemoteText(json.message)}`, { + phase: 'login', + endpoint: BASE_URL, + httpStatus: res.status, + contentType: res.headers && res.headers.get ? res.headers.get('content-type') : null, + body + }); err.otpRequired = true; throw err; } else if (json && json.status === 'fail') { - throw new Error(`Doodstream Login: ${json.message || 'Login fehlgeschlagen'}`); + throw createTransportError(`Doodstream Login: ${sanitizeRemoteText(json.message) || 'Login fehlgeschlagen'}`, { + phase: 'login', + endpoint: BASE_URL, + httpStatus: res.status, + contentType: res.headers && res.headers.get ? res.headers.get('content-type') : null, + body, + accountError: true + }); } else if (body.includes('Dashboard')) { // Got dashboard HTML directly — login worked } else { - const msg = (json && json.message) || 'Login fehlgeschlagen'; - throw new Error(`Doodstream Login: ${msg}`); + const msg = sanitizeRemoteText(json && json.message) || 'Login fehlgeschlagen'; + throw createTransportError(`Doodstream Login: ${msg}`, { + phase: 'login', + endpoint: BASE_URL, + httpStatus: res.status, + contentType: res.headers && res.headers.get ? res.headers.get('content-type') : null, + body + }); } } @@ -220,7 +252,7 @@ class DoodstreamUploader { const res = await this._fetch(BASE_URL + '/?op=upload_server'); 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_server: status=${res.status} ctype=${ctype} response=${summarizeResponse(text, ctype)}`); let json; try { json = JSON.parse(text); } catch { json = null; } @@ -254,7 +286,7 @@ class DoodstreamUploader { // Capture the form's real fields so upload() submits exactly what the // browser would (file_title, submit_btn, …) instead of stale hardcoded ones. this._uploadFormFields = this._parseUploadFormFields(html); - _debugLog(`upload_server: using form action node=${url} sess=${this.sessId} fields=${Object.keys(this._uploadFormFields).join(',')}`); + _debugLog(`upload_server: using form action node=${safeEndpoint(url)} sessLength=${this.sessId.length} fields=${Object.keys(this._uploadFormFields).join(',')}`); return url; } @@ -265,15 +297,19 @@ class DoodstreamUploader { // No upload server could be extracted. We MUST NOT silently fall back to a // hardcoded node: that node is stale and accepts the bytes but returns an // empty form (no filecode) — so the user wastes ~90s uploading 95 MB into a - // dead end and gets a cryptic "kein Filecode" 90s later. Fail fast and put - // the raw responses in the error so the real format change is diagnosable. - const urlHints = (html.match(/https?:\/\/[^'">\s]+/g) || []).slice(0, 4).join(' , '); - _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)} ` + - `| upload-page URL-Treffer: ${urlHints || 'keine'}` - ); + // dead end and gets a cryptic "kein Filecode" 90s later. Fail fast with + // safe structured diagnostics. + _debugLog(`upload_server: no server response=${summarizeResponse(text, ctype)} page=${summarizeResponse(html, pageRes.headers && pageRes.headers.get ? pageRes.headers.get('content-type') : '')}`); + throw createTransportError('Doodstream: konnte Upload-Server nicht ermitteln', { + phase: 'upload-server', + endpoint: BASE_URL + '/?op=upload_server', + httpStatus: res.status, + contentType: ctype, + body: text, + retryable: res.status >= 500, + transientNetwork: res.status >= 500, + hosterTransient: res.status >= 500 + }); } /** @@ -370,15 +406,14 @@ class DoodstreamUploader { bodyTimeout: UPLOAD_TIMEOUT, headersTimeout: 60000 }); - } catch (err) { - // Label which phase failed so a future "fetch failed"/"terminated" is - // attributable to the big upload POST vs the small bookend requests. The - // original message is preserved as a substring so upload-manager's - // transient classification still matches. NOTE: undici may surface - // "terminated"/"other side closed", which are not yet in that transient - // list — revisit if logs show them. + } catch { const mb = Math.round(bytesRead / 1048576); - throw new Error(`Doodstream Upload-POST (${mb} MB an ${uploadUrl}): ${err && err.message ? err.message : err}`); + throw createTransportError(`Doodstream Upload-POST nach ${mb} MB fehlgeschlagen`, { + phase: 'upload-request', + endpoint: uploadUrl, + retryable: true, + transientNetwork: true + }); } const statusCode = uploadRes.statusCode; @@ -395,13 +430,22 @@ class DoodstreamUploader { } const resText = await uploadRes.body.text(); - _debugLog(`Upload response body (first 500): ${resText.slice(0, 500)}`); + const uploadContentType = uploadRes.headers && uploadRes.headers['content-type']; + _debugLog(`Upload response: ${summarizeResponse(resText, uploadContentType)}`); if (statusCode >= 400) { let payload; try { payload = JSON.parse(resText); } catch {} - const msg = payload && payload.msg ? payload.msg : resText.slice(0, 200); - throw new Error(`Doodstream Upload HTTP ${statusCode}: ${msg}`); + const msg = payload && payload.msg ? sanitizeRemoteText(payload.msg) : ''; + throw createTransportError(`Doodstream Upload fehlgeschlagen${msg ? `: ${msg}` : ''}`, { + phase: 'upload-response', + endpoint: uploadUrl, + httpStatus: statusCode, + contentType: uploadContentType, + body: resText, + retryable: statusCode === 429 || statusCode >= 500, + transientNetwork: statusCode >= 500 + }); } return this._parseUploadResponse(resText); @@ -411,10 +455,11 @@ class DoodstreamUploader { * Follow a redirect URL from upload server and extract filecode */ async _handleUploadResult(url) { - _debugLog(`Following upload result URL: ${url}`); + _debugLog(`Following upload result URL: ${safeEndpoint(url) || 'unknown endpoint'}`); const res = await this._fetch(url); const html = await res.text(); - _debugLog(`Result page (first 500): ${html.slice(0, 500)}`); + const contentType = res.headers && typeof res.headers.get === 'function' ? res.headers.get('content-type') : ''; + _debugLog(`Result page: ${summarizeResponse(html, contentType)}`); return this._parseUploadResponse(html); } @@ -458,12 +503,12 @@ class DoodstreamUploader { // 3. Parse HTML form (XFileSharing two-step upload) const hiddenFields = this._extractHiddenFields(resText); - _debugLog(`Hidden fields: ${JSON.stringify(hiddenFields)}`); + _debugLog(`Hidden fields: ${Object.keys(hiddenFields).join(',')}`); // Check if filecode is already in hidden fields const fnCode = hiddenFields.fn || hiddenFields.filecode || hiddenFields.file_code; if (fnCode && fnCode.length >= 8) { - _debugLog(`Filecode from hidden field 'fn': ${fnCode}`); + _debugLog(`Filecode from hidden field 'fn': length ${fnCode.length}`); // We still need to submit the form so doodstream registers the file // But the filecode is the 'fn' value } @@ -474,7 +519,7 @@ class DoodstreamUploader { // Ensure op=upload_result is set if (!hiddenFields.op) hiddenFields.op = 'upload_result'; - _debugLog(`Submitting upload_result to ${BASE_URL}/ with fields: ${JSON.stringify(hiddenFields)}`); + _debugLog(`Submitting upload_result fields: ${Object.keys(hiddenFields).join(',')}`); const formData = new URLSearchParams(hiddenFields); let followText = ''; try { @@ -487,18 +532,23 @@ class DoodstreamUploader { body: formData.toString() }); followText = await followRes.text(); - } catch (err) { + } catch { // The file already uploaded to the CDN; this POST only registers it on // doodstream's side. If it fails transiently (even after _fetch's own // retries) but we already hold the filecode, the upload succeeded from // the user's view — return it rather than discarding a done upload. if (fnCode && fnCode.length >= 8) { - _debugLog(`upload_result submit failed (${err && err.message}); using fn ${fnCode}`); + _debugLog(`upload_result submit failed; using existing filecode length ${fnCode.length}`); return this._buildResult(fnCode); } - throw err; + throw createTransportError('Doodstream Upload: Ergebnis konnte nicht registriert werden', { + phase: 'upload-result-submit', + endpoint: BASE_URL, + retryable: true, + transientNetwork: true + }); } - _debugLog(`upload_result response (first 500): ${followText.slice(0, 500)}`); + _debugLog(`upload_result response: ${summarizeResponse(followText, '')}`); // Try to find filecode in result page const resultCode = this._findFilecodeInHtml(followText); @@ -523,11 +573,17 @@ class DoodstreamUploader { // download link being empty while the page structure is unchanged points // at doodstream's backend, not at a parsing bug on our side. const st = hiddenFields.st || ''; - const fnInfo = fnCode ? `"${fnCode}"(len ${fnCode.length})` : 'fehlt/leer'; - const node = this._lastUploadUrl || '?'; - _debugLog(`No filecode. st=${st} fn=${fnInfo} node=${node} CDN-body=${(resText || '').slice(0, 400)}`); + const safeStatus = sanitizeRemoteText(st, 100); + const fnInfo = fnCode ? `vorhanden(len ${fnCode.length})` : 'fehlt/leer'; + const node = safeEndpoint(this._lastUploadUrl) || 'unbekannt'; + _debugLog(`No filecode. st=${safeStatus || '?'} fn=${fnInfo} node=${node} response=${summarizeResponse(resText, 'text/html')}`); if (st && st !== 'OK') { - throw new Error(`Doodstream lehnt Datei ab (Server-Status: ${st}). CDN=${node}`); + throw createTransportError(`Doodstream lehnt Datei ab (Server-Status: ${safeStatus || 'unbekannt'})`, { + phase: 'upload-result', + endpoint: this._lastUploadUrl || BASE_URL, + contentType: 'text/html', + body: resText + }); } // Empty form (no fn, no st) is a doodstream-side processing flake — same // account + same file works on a later attempt. Tag it explicitly so the @@ -536,15 +592,20 @@ class DoodstreamUploader { // session and later batches hit `pre-job-swap-blocked` for no fault of // the account). The flag is the primary signal; the message text is a // belt-and-suspenders regex fallback in the classifier. - const emptyLinkErr = new Error(`Doodstream Upload: kein Filecode — Server gab leeren Link zurück (st=${st || '?'}, fn=${fnInfo}, CDN=${node}). CDN-Antwort: ${(resText || '').slice(0, 200)}`); - emptyLinkErr.hosterTransient = true; - throw emptyLinkErr; + throw createTransportError(`Doodstream Upload: kein Filecode (st=${safeStatus || '?'}, fn=${fnInfo}, CDN=${node})`, { + phase: 'upload-result', + endpoint: this._lastUploadUrl || BASE_URL, + contentType: 'text/html', + body: resText, + retryable: true, + hosterTransient: true + }); } // 4. Fallback: follow form action as-is (for non-XFS forms) const formAction = resText.match(/