When Doodstream requires 2FA, the account modal now dynamically shows an OTP input field so the user can enter the code from their email and complete the login without restarting. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
459 lines
15 KiB
JavaScript
459 lines
15 KiB
JavaScript
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
|
|
|
|
function _debugLog(msg) {
|
|
try {
|
|
const ts = new Date().toISOString();
|
|
fs.appendFileSync(path.join(__dirname, '..', 'doodstream-debug.log'), `[${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();
|
|
}
|
|
|
|
const res = await fetch(url, {
|
|
...opts,
|
|
headers,
|
|
redirect: 'manual'
|
|
});
|
|
|
|
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: <input type="hidden" name="sess_id" value="xxx">
|
|
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();
|
|
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();
|
|
const srvMatch = html.match(/srv_url['":\s]+['"]?(https?:\/\/[^'">\s]+)['"]?/i);
|
|
if (srvMatch) return srvMatch[1];
|
|
|
|
// Last resort fallback
|
|
return 'https://tr1128ve.cloudatacdn.com/upload/01';
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
|
|
// 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`;
|
|
preamble += `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${fileName}"\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;
|
|
|
|
const self = this;
|
|
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;
|
|
}
|
|
|
|
const 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
|
|
});
|
|
|
|
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: <textarea name="op">upload_result</textarea>
|
|
const ta = /<textarea[^>]*name=['"]([^'"]+)['"][^>]*>([\s\S]*?)<\/textarea>/gi;
|
|
let m;
|
|
while ((m = ta.exec(html)) !== null) fields[m[1]] = m[2].trim();
|
|
// Input hidden fields
|
|
const p1 = /<input[^>]*type=['"]hidden['"][^>]*name=['"]([^'"]+)['"][^>]*value=['"]([^'"]*)['"]/gi;
|
|
while ((m = p1.exec(html)) !== null) { if (!fields[m[1]]) fields[m[1]] = m[2]; }
|
|
const p2 = /<input[^>]*name=['"]([^'"]+)['"][^>]*value=['"]([^'"]*)['"]/gi;
|
|
while ((m = p2.exec(html)) !== null) { if (!fields[m[1]]) fields[m[1]] = m[2]; }
|
|
const p3 = /<input[^>]*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);
|
|
const followRes = await this._fetch(BASE_URL + '/', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
'Referer': BASE_URL + '/'
|
|
},
|
|
body: formData.toString()
|
|
});
|
|
const followText = await followRes.text();
|
|
_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]);
|
|
}
|
|
|
|
throw new Error(`Doodstream Upload: upload_result Seite hat keinen filecode (${followText.slice(0, 150)})`);
|
|
}
|
|
|
|
// 4. Fallback: follow form action as-is (for non-XFS forms)
|
|
const formAction = resText.match(/<form[^>]*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
|
|
};
|
|
}
|
|
}
|
|
|
|
module.exports = DoodstreamUploader;
|