const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const { request } = require('undici'); 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'; const UPLOAD_TIMEOUT = 1800000; // 30 min // Cap doodstream's per-hoster debug log alongside the main log files so // dev-mode sessions don't accumulate gigabytes of upload trace. const { maybeRotateLogFile } = require('./log-rotation'); const _DOODSTREAM_LOG_MAX_BYTES = 10 * 1024 * 1024; const _DOODSTREAM_LOG_MAX_BACKUPS = 1; // Resolve the log path at write-time. In a packaged build __dirname lives // inside app.asar (read-only) — writing there fails silently and we lose every // production trace. Prefer Electron's writable userData dir, fall back to the // repo root only when running outside Electron (tests / plain node). function _doodstreamLogPath() { try { const { app } = require('electron'); if (app && typeof app.getPath === 'function') { return path.join(app.getPath('userData'), 'doodstream-debug.log'); } } catch { /* not running under Electron */ } return path.join(__dirname, '..', 'doodstream-debug.log'); } function _debugLog(msg) { try { const logPath = _doodstreamLogPath(); maybeRotateLogFile(logPath, _DOODSTREAM_LOG_MAX_BYTES, _DOODSTREAM_LOG_MAX_BACKUPS); const ts = new Date().toISOString(); fs.appendFileSync(logPath, `[${ts}] ${msg}\n`); } catch {} } class DoodstreamUploader { constructor() { this.cookies = new Map(); this.sessId = ''; this.apiKey = ''; // optionally derived from the logged-in session (deriveApiKey) } _cookieHeader() { return Array.from(this.cookies.entries()) .map(([k, v]) => `${k}=${v}`) .join('; '); } _parseCookiesFromHeaders(headers) { let setCookies; if (typeof headers.getSetCookie === 'function') { setCookies = headers.getSetCookie(); } else if (headers['set-cookie']) { setCookies = Array.isArray(headers['set-cookie']) ? headers['set-cookie'] : [headers['set-cookie']]; } else { return; } for (const raw of setCookies) { const pair = raw.split(';')[0]; const eq = pair.indexOf('='); if (eq > 0) { this.cookies.set(pair.substring(0, eq).trim(), pair.substring(eq + 1).trim()); } } } async _fetch(url, opts = {}, _redirectCount = 0) { const MAX_REDIRECTS = 10; const headers = { 'User-Agent': USER_AGENT, ...(opts.headers || {}) }; if (this.cookies.size > 0) { headers['Cookie'] = this._cookieHeader(); } // The small discovery/result requests that bookend a multi-minute upload // occasionally hit a transient blip ("fetch failed", ECONNRESET, a hung TLS // handshake). A blip here shouldn't throw away the whole upload, so retry a // few times with short backoff. Each attempt gets its own 20s timeout — // Node's fetch has none by default, and a hung socket would otherwise stall // the attempt for minutes. The big file upload (undici) is retried at the // upload-manager level, not here. let res; for (let attempt = 1; ; attempt++) { const timeoutSignal = AbortSignal.timeout(20000); const signal = opts.signal ? AbortSignal.any([opts.signal, timeoutSignal]) : timeoutSignal; try { res = await fetch(url, { ...opts, headers, redirect: 'manual', signal }); 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`); await new Promise(r => setTimeout(r, 400 * attempt)); } } this._parseCookiesFromHeaders(res.headers); if ([301, 302, 303, 307, 308].includes(res.status)) { try { await res.text(); } catch {} if (_redirectCount >= MAX_REDIRECTS) throw new Error('Zu viele Redirects'); const location = res.headers.get('location'); if (location) { const nextUrl = new URL(location, url).href; return this._fetch(nextUrl, { ...opts, method: 'GET', body: undefined }, _redirectCount + 1); } } return res; } /** * Login to DoodStream via web form */ async login(username, password, otp) { // GET homepage first to collect cookies const homeRes = await this._fetch(BASE_URL); await homeRes.text(); // POST login via AJAX (op in body, XHR header required for JSON response) const loginData = new URLSearchParams({ op: 'login_ajax', login: username, password: password, loginotp: otp || '' }); // 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 }; if (this.cookies.size > 0) { headers['Cookie'] = this._cookieHeader(); } const res = await fetch(BASE_URL + '/', { method: 'POST', body: loginData.toString(), headers, redirect: 'manual' }); this._parseCookiesFromHeaders(res.headers); // On successful login, server may redirect (3xx) to dashboard if ([301, 302, 303, 307, 308].includes(res.status)) { try { await res.text(); } catch {} // Redirect means login succeeded } else { const body = await res.text(); let json; try { json = JSON.parse(body); } catch { json = null; } if (json && json.status === 'success') { // 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}`); err.otpRequired = true; throw err; } else if (json && json.status === 'fail') { throw new Error(`Doodstream Login: ${json.message || 'Login fehlgeschlagen'}`); } 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}`); } } // Extract sess_id from the upload page await this._extractSessId(); } async _extractSessId() { const res = await this._fetch(BASE_URL + '/?op=upload'); const html = await res.text(); // 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]; return; } throw new Error('Doodstream: sess_id nicht gefunden nach Login'); } /** * 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 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)}`); let json; try { json = JSON.parse(text); } catch { json = null; } if (json && json.result && /^https?:\/\//i.test(json.result)) { return json.result; } // Fallback: try fetching from upload page HTML const pageRes = await this._fetch(BASE_URL + '/?op=upload'); const html = await pageRes.text(); // Current doodstream format: the upload server is the action of the // multipart upload form, e.g. //
// // The node is assigned per page-load and the action carries a session token // in its query string that matches the page's hidden sess_id. We refresh // this.sessId from THIS page so the multipart sess_id field matches the node // URL — login-time and node tokens otherwise diverge and the upload comes // back with an empty filecode. 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]+)["']/); if (freshSess) { this.sessId = freshSess[1]; } else { _debugLog('upload_server: form action found but no sess_id on page; keeping existing sessId'); } _debugLog(`upload_server: using form action node=${url} sess=${this.sessId}`); return url; } // Legacy fallback: srv_url JS variable (older doodstream theme). const srvMatch = html.match(/srv_url['":\s]+['"]?(https?:\/\/[^'">\s]+)['"]?/i); if (srvMatch) return srvMatch[1]; // 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 geaendert?). ` + `op=upload_server status=${res.status} ctype=${ctype} body=${(text || '').slice(0, 300)} ` + `| upload-page URL-Treffer: ${urlHints || 'keine'}` ); } /** * Upload file using web session */ async upload(filePath, progressCb, signal, throttle) { const fileName = path.basename(filePath); const fileSize = fs.statSync(filePath).size; // Get upload server const uploadUrl = await this._getUploadServer(); // Remember which CDN node handled this upload so a later parse failure can // report it — failures sometimes correlate with a specific node. this._lastUploadUrl = uploadUrl; // Build multipart form const boundary = `----WebKitFormBoundary${crypto.randomBytes(16).toString('hex')}`; // Build form parts let preamble = ''; preamble += `--${boundary}\r\nContent-Disposition: form-data; name="sess_id"\r\n\r\n${this.sessId}\r\n`; preamble += `--${boundary}\r\nContent-Disposition: form-data; name="utype"\r\n\r\nreg\r\n`; const safeFileName = fileName.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); preamble += `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${safeFileName}"\r\nContent-Type: application/octet-stream\r\n\r\n`; const epilogue = `\r\n--${boundary}--\r\n`; const preambleBuf = Buffer.from(preamble, 'utf-8'); const epilogueBuf = Buffer.from(epilogue, 'utf-8'); const totalSize = preambleBuf.length + fileSize + epilogueBuf.length; const CHUNK_SIZE = 256 * 1024; let bytesRead = 0; async function* generate() { yield preambleBuf; const fileStream = fs.createReadStream(filePath, { highWaterMark: CHUNK_SIZE }); for await (const chunk of fileStream) { if (signal && signal.aborted) throw new Error('Aborted'); if (throttle) await throttle.consume(chunk.length, signal); bytesRead += chunk.length; yield chunk; if (progressCb) progressCb(bytesRead, fileSize); } yield epilogueBuf; } let uploadRes; try { uploadRes = await request(uploadUrl, { method: 'POST', headers: { 'Content-Type': `multipart/form-data; boundary=${boundary}`, 'Content-Length': String(totalSize), 'User-Agent': USER_AGENT, 'Cookie': this._cookieHeader() }, body: generate(), signal, 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. const mb = Math.round(bytesRead / 1048576); throw new Error(`Doodstream Upload-POST (${mb} MB an ${uploadUrl}): ${err && err.message ? err.message : err}`); } const statusCode = uploadRes.statusCode; _debugLog(`Upload response status: ${statusCode}`); // Handle redirects from upload server (undici doesn't follow them) if ([301, 302, 303, 307, 308].includes(statusCode)) { const location = uploadRes.headers['location']; try { await uploadRes.body.text(); } catch {} _debugLog(`Upload redirect to: ${location}`); if (location) { return this._handleUploadResult(location); } } const resText = await uploadRes.body.text(); _debugLog(`Upload response body (first 500): ${resText.slice(0, 500)}`); 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}`); } return this._parseUploadResponse(resText); } /** * Follow a redirect URL from upload server and extract filecode */ async _handleUploadResult(url) { _debugLog(`Following upload result URL: ${url}`); const res = await this._fetch(url); const html = await res.text(); _debugLog(`Result page (first 500): ${html.slice(0, 500)}`); return this._parseUploadResponse(html); } /** * Extract hidden form fields from HTML (handles various attribute orders) */ _extractHiddenFields(html) { const fields = {}; // Textarea fields: const ta = /]*name=['"]([^'"]+)['"][^>]*>([\s\S]*?)<\/textarea>/gi; let m; while ((m = ta.exec(html)) !== null) fields[m[1]] = m[2].trim(); // Input hidden fields const p1 = /]*type=['"]hidden['"][^>]*name=['"]([^'"]+)['"][^>]*value=['"]([^'"]*)['"]/gi; while ((m = p1.exec(html)) !== null) { if (!fields[m[1]]) fields[m[1]] = m[2]; } const p2 = /]*name=['"]([^'"]+)['"][^>]*value=['"]([^'"]*)['"]/gi; while ((m = p2.exec(html)) !== null) { if (!fields[m[1]]) fields[m[1]] = m[2]; } const p3 = /]*value=['"]([^'"]*)['"]\s[^>]*name=['"]([^'"]+)['"]/gi; while ((m = p3.exec(html)) !== null) { if (!fields[m[2]]) fields[m[2]] = m[1]; } return fields; } /** * Parse filecode from upload server response (JSON or HTML) */ async _parseUploadResponse(resText) { // 1. Try JSON let payload; try { payload = JSON.parse(resText); } catch {} if (payload) { return this._extractFromJson(payload); } // 2. Try filecode directly in HTML const code = this._findFilecodeInHtml(resText); if (code) { _debugLog(`Found filecode in HTML: ${code}`); return this._buildResult(code); } // 3. Parse HTML form (XFileSharing two-step upload) const hiddenFields = this._extractHiddenFields(resText); _debugLog(`Hidden fields: ${JSON.stringify(hiddenFields)}`); // 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}`); // We still need to submit the form so doodstream registers the file // But the filecode is the 'fn' value } // XFileSharing standard: form with op=upload_result, fn, st // Always submit to doodstream.com, not to CDN if (hiddenFields.fn || hiddenFields.op === 'upload_result') { // 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)}`); const formData = new URLSearchParams(hiddenFields); let followText = ''; try { const followRes = await this._fetch(BASE_URL + '/', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Referer': BASE_URL + '/' }, body: formData.toString() }); followText = await followRes.text(); } catch (err) { // 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}`); return this._buildResult(fnCode); } throw err; } _debugLog(`upload_result response (first 500): ${followText.slice(0, 500)}`); // Try to find filecode in result page const resultCode = this._findFilecodeInHtml(followText); if (resultCode) { return this._buildResult(resultCode); } // If we had fn from hidden fields, use that as filecode if (fnCode && fnCode.length >= 8) { return this._buildResult(fnCode); } // Try download URL pattern in result page const dlMatch = followText.match(/https?:\/\/[a-z0-9.]+\/d\/([a-zA-Z0-9]+)/i); if (dlMatch) { return this._buildResult(dlMatch[1]); } // No filecode anywhere. Surface WHY: XFileSharing puts the real reason // in the `st` field (anything other than "OK" means the backend refused // the file — copyright/hash match, duplicate, size, quota, …). The // 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)}`); if (st && st !== 'OK') { throw new Error(`Doodstream lehnt Datei ab (Server-Status: ${st}). CDN=${node}`); } // 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 // upload-manager classifies this as a hoster-transient error and does NOT // blacklist the account (otherwise one of these flakes poisons the whole // 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 zurueck (st=${st || '?'}, fn=${fnInfo}, CDN=${node}). CDN-Antwort: ${(resText || '').slice(0, 200)}`); emptyLinkErr.hosterTransient = true; throw emptyLinkErr; } // 4. Fallback: follow form action as-is (for non-XFS forms) const formAction = resText.match(/]*action=['"]([^'"]+)['"]/i); if (formAction) { _debugLog(`Fallback: following form action ${formAction[1]}`); const formData = new URLSearchParams(hiddenFields); const followRes = await this._fetch(formAction[1], { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Referer': BASE_URL + '/' }, body: formData.toString() }); const followText = await followRes.text(); _debugLog(`Fallback response (first 500): ${followText.slice(0, 500)}`); const fallbackCode = this._findFilecodeInHtml(followText); if (fallbackCode) return this._buildResult(fallbackCode); // Check if fn was in original hidden fields if (fnCode && fnCode.length >= 8) return this._buildResult(fnCode); throw new Error(`Doodstream Upload: Redirect-Antwort ungueltig (${followText.slice(0, 150)})`); } throw new Error(`Doodstream Upload: Keine gueltige Antwort (Body: ${resText.slice(0, 150)})`); } /** * Search for filecode patterns in HTML */ _findFilecodeInHtml(html) { // filecode: "xxx" or filecode = "xxx" const m1 = html.match(/filecode['":\s]+['"]([a-zA-Z0-9]{8,})['"]/i); if (m1) return m1[1]; // file_code: "xxx" const m2 = html.match(/file_code['":\s]+['"]([a-zA-Z0-9]{8,})['"]/i); if (m2) return m2[1]; // Download URL pattern: /d/FILECODE const m3 = html.match(/\/d\/([a-zA-Z0-9]{8,})/); if (m3) return m3[1]; return null; } /** * Extract result from JSON payload */ _extractFromJson(payload) { if (payload.status && Number(payload.status) !== 200 && payload.msg) { throw new Error(`Doodstream Upload: ${payload.msg}`); } let item = null; const result = payload.result; if (Array.isArray(result) && result.length > 0) { item = result[0]; } else if (typeof result === 'object' && result) { item = result; } if (!item) { throw new Error(`Doodstream Upload fehlgeschlagen: ${payload.msg || JSON.stringify(payload).slice(0, 150)}`); } const fileCode = item.filecode || item.file_code || ''; return { download_url: item.download_url || item.protected_dl || (fileCode ? `https://doodstream.com/d/${fileCode}` : null), embed_url: item.protected_embed || (fileCode ? `https://doodstream.com/e/${fileCode}` : null), file_code: fileCode }; } _buildResult(fileCode) { return { download_url: `https://doodstream.com/d/${fileCode}`, embed_url: `https://doodstream.com/e/${fileCode}`, file_code: fileCode }; } /** * Pull candidate API-key tokens out of a logged-in settings page. We do NOT * rely on knowing doodstream's exact (cookie-gated, unseen) settings DOM — * instead we gather every plausible long token from form-field values and * element contents, ranked so tokens near an "api" mention are tried first. * The caller validates each against the official API, so a wrong guess is * harmless (it just fails validation). Returned newest-/most-likely-first. */ _extractApiKeyCandidates(html) { if (!html) return []; const cands = new Set(); const patterns = [ /value=["']([A-Za-z0-9]{20,})["']/gi, // /<(?:textarea|code|span|pre|input)[^>]*>\s*([A-Za-z0-9]{20,})\s*KEY /\b(?:api[_-]?key|apikey)\b["':\s=>]*["']?([A-Za-z0-9]{20,})/gi // api_key: "KEY" ]; for (const re of patterns) { let m; while ((m = re.exec(html)) !== null) cands.add(m[1]); } // Rank tokens whose preceding context mentions "api" ahead of the rest. return [...cands] .map(t => { const idx = html.indexOf(t); const ctx = html.slice(Math.max(0, idx - 160), idx).toLowerCase(); return { t, near: /api/.test(ctx) ? 0 : 1 }; }) .sort((a, b) => a.near - b.near) .map(s => s.t); } /** * Validate a candidate key against the official API. Only the account's real * key returns status 200, so this is what makes the brute-force extraction * safe regardless of the settings-page markup. */ async _validateApiKey(key) { try { const res = await fetch(`https://doodapi.co/api/account/info?key=${encodeURIComponent(key)}`, { method: 'GET', redirect: 'follow', signal: AbortSignal.timeout(15000) }); const json = await res.json().catch(() => null); return !!(json && Number(json.status) === 200); } catch { return false; } } /** * Derive the account's doodapi API key from the logged-in web session, so a * login-only account can upload via the reliable JSON API (which returns the * filecode directly) instead of the fragile web upload form. Best-effort: * returns null if no valid key can be found, and the caller falls back to the * web-form upload. Requires login() to have run first (needs the cookies). */ async deriveApiKey() { if (this.apiKey) return this.apiKey; let html = ''; for (const page of ['/?op=my_account', '/settings', '/?op=profile']) { try { const res = await this._fetch(BASE_URL + page); const text = await res.text(); if (text && /api[\s_-]?key/i.test(text)) { html = text; break; } if (text && !html) html = text; } catch { /* try next page */ } } const candidates = this._extractApiKeyCandidates(html); // Cap validation calls (rate limit 10/s; settings page yields few tokens). for (const key of candidates.slice(0, 15)) { if (await this._validateApiKey(key)) { this.apiKey = key; _debugLog(`api-key derive: validated key (len ${key.length})`); return key; } } _debugLog(`api-key derive: ${candidates.length} candidate(s), none validated. settings html(2500)=${(html || '').slice(0, 2500)}`); return null; } } module.exports = DoodstreamUploader;