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 = ''; } _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. //