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(/