- Fix doodstream login: handle redirect on success (server returns HTML dashboard instead of JSON) - Fix sess_id extraction: match hidden input field format - Files are now only added to queue after clicking "Uebernehmen" in hoster modal - Cancel/Escape/click-outside discards pending files Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
306 lines
8.9 KiB
JavaScript
306 lines
8.9 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
|
|
|
|
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) {
|
|
// 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: ''
|
|
});
|
|
|
|
// 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.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')}`;
|
|
const fileStream = fs.createReadStream(filePath);
|
|
|
|
// Build form parts
|
|
const fields = {
|
|
sess_id: this.sessId,
|
|
utype: 'reg'
|
|
};
|
|
|
|
const preamble = [];
|
|
for (const [key, val] of Object.entries(fields)) {
|
|
preamble.push(
|
|
`--${boundary}\r\nContent-Disposition: form-data; name="${key}"\r\n\r\n${val}\r\n`
|
|
);
|
|
}
|
|
preamble.push(
|
|
`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${fileName}"\r\nContent-Type: application/octet-stream\r\n\r\n`
|
|
);
|
|
|
|
const preambleBuffer = Buffer.from(preamble.join(''));
|
|
const epilogue = Buffer.from(`\r\n--${boundary}--\r\n`);
|
|
const totalSize = preambleBuffer.length + fileSize + epilogue.length;
|
|
|
|
// Assemble body
|
|
const { Readable } = require('stream');
|
|
let bytesSent = 0;
|
|
|
|
const bodyStream = new Readable({
|
|
read() {}
|
|
});
|
|
|
|
// Push preamble
|
|
bodyStream.push(preambleBuffer);
|
|
bytesSent += preambleBuffer.length;
|
|
|
|
// Pipe file
|
|
fileStream.on('data', (chunk) => {
|
|
if (signal && signal.aborted) {
|
|
fileStream.destroy();
|
|
bodyStream.destroy();
|
|
return;
|
|
}
|
|
bodyStream.push(chunk);
|
|
bytesSent += chunk.length;
|
|
if (progressCb) progressCb(Math.max(0, bytesSent - preambleBuffer.length), fileSize);
|
|
});
|
|
|
|
fileStream.on('end', () => {
|
|
bodyStream.push(epilogue);
|
|
bodyStream.push(null);
|
|
});
|
|
|
|
fileStream.on('error', (err) => {
|
|
bodyStream.destroy(err);
|
|
});
|
|
|
|
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: bodyStream,
|
|
signal,
|
|
bodyTimeout: UPLOAD_TIMEOUT,
|
|
headersTimeout: 60000
|
|
});
|
|
|
|
const resText = await uploadRes.body.text();
|
|
let payload;
|
|
try { payload = JSON.parse(resText); } catch {}
|
|
|
|
if (!payload) {
|
|
// Try to extract from HTML response
|
|
const match = resText.match(/filecode['":\s]+['"]([a-zA-Z0-9]+)['"]/i);
|
|
if (match) {
|
|
return {
|
|
download_url: `https://doodstream.com/d/${match[1]}`,
|
|
embed_url: `https://doodstream.com/e/${match[1]}`,
|
|
file_code: match[1]
|
|
};
|
|
}
|
|
throw new Error('Doodstream Upload: Keine gueltige Antwort erhalten');
|
|
}
|
|
|
|
// Parse result
|
|
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 || 'Unbekannter Fehler'}`);
|
|
}
|
|
|
|
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
|
|
};
|
|
}
|
|
}
|
|
|
|
module.exports = DoodstreamUploader;
|