const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const { request } = require('undici'); const UPLOAD_TIMEOUT = 1800000; // 30 minutes const API_TIMEOUT = 45000; // 45 seconds const SERVER_RETRY_ATTEMPTS = 6; const SERVER_RETRY_DELAY_MS = 2500; const LAST_UPLOAD_SERVERS = new Map(); function appendRawQuery(url, rawQuery) { const parsed = new URL(url); const cleanQuery = String(rawQuery || '').trim().replace(/^\?+/, ''); if (!cleanQuery) return parsed.toString(); if (parsed.search && parsed.search.length > 1) { parsed.search = `${parsed.search.slice(1)}&${cleanQuery}`; } else { parsed.search = cleanQuery; } return parsed.toString(); } function appendKeyParam(url, key) { const parsed = new URL(url); parsed.searchParams.set('key', key); return parsed.toString(); } // Hoster definitions - based on official API docs const HOSTER_CONFIGS = { 'doodstream.com': { apiBase: 'https://doodapi.co', serverEndpoints: ['/api/upload/server'], fallbackUploadServers: ['https://tr1128ve.cloudatacdn.com/upload/01'], buildUploadUrl: (url, key) => appendRawQuery(url, key), formFields: (key) => ({ api_key: key }), parseResult: parseDoodstreamResult }, 'voe.sx': { apiBase: 'https://voe.sx', serverEndpoints: ['/api/upload/server', '/api/v1/upload/server'], buildUploadUrl: (url, key) => appendKeyParam(url, key), formFields: () => ({}), parseResult: parseVoeResult }, 'byse.sx': { apiBase: 'https://api.byse.sx', serverEndpoints: ['/upload/server'], buildUploadUrl: (url, key) => appendKeyParam(url, key), formFields: (key) => ({ key }), parseResult: parseByseResult } }; function normalizeAbsoluteUrl(raw, apiBase) { if (typeof raw !== 'string') return null; const trimmed = raw.trim(); if (!trimmed || /^\[object\s+Object\]$/i.test(trimmed)) return null; let candidate = trimmed; if (candidate.startsWith('//')) { candidate = `https:${candidate}`; } else if (candidate.startsWith('/')) { try { candidate = new URL(candidate, apiBase).href; } catch { return null; } } else if (!/^[a-z][a-z\d+.-]*:\/\//i.test(candidate)) { candidate = `https://${candidate.replace(/^\/+/, '')}`; } try { const parsed = new URL(candidate); if (!['http:', 'https:'].includes(parsed.protocol)) return null; return parsed.href; } catch { return null; } } function collectUploadUrlCandidates(value, out = []) { if (typeof value === 'string') { out.push(value); return out; } if (Array.isArray(value)) { for (const entry of value) collectUploadUrlCandidates(entry, out); return out; } if (value && typeof value === 'object') { const preferredKeys = ['upload_url', 'uploadUrl', 'url', 'server', 'srv', 'result']; for (const key of preferredKeys) { if (Object.prototype.hasOwnProperty.call(value, key)) { collectUploadUrlCandidates(value[key], out); } } for (const nested of Object.values(value)) { if (typeof nested === 'string') out.push(nested); } } return out; } function extractUploadServerUrl(payload, apiBase) { const source = payload && Object.prototype.hasOwnProperty.call(payload, 'result') ? payload.result : payload; const candidates = collectUploadUrlCandidates(source, []); for (const candidate of candidates) { const normalized = normalizeAbsoluteUrl(candidate, apiBase); if (normalized) return normalized; } return null; } function shouldRetryServerLookup(message) { const msg = String(message || '').toLowerCase(); if (!msg) return true; if (msg.includes('invalid') && msg.includes('key')) return false; if (msg.includes('unauthorized') || msg.includes('forbidden')) return false; if (msg.includes('no servers available')) return true; if (msg.includes('temporar') || msg.includes('busy') || msg.includes('try again')) return true; return true; } function sleep(ms, signal) { return new Promise((resolve, reject) => { const timer = setTimeout(() => { if (signal) signal.removeEventListener('abort', onAbort); resolve(); }, ms); function onAbort() { clearTimeout(timer); if (signal) signal.removeEventListener('abort', onAbort); const err = new Error('Aborted'); err.name = 'AbortError'; reject(err); } if (signal) { if (signal.aborted) return onAbort(); signal.addEventListener('abort', onAbort); } }); } // --- Result parsers --- // Doodstream: { result: [{ download_url, protected_embed, filecode, protected_dl }] } function parseDoodstreamResult(payload) { let item = {}; const result = payload.result; if (Array.isArray(result) && result.length > 0) { item = result[0]; } else if (result && typeof result === 'object') { item = result; } return { download_url: item.download_url || item.protected_dl || null, embed_url: item.protected_embed || null, file_code: item.filecode || item.file_code || null }; } // VOE: { file: { file_code } } function parseVoeResult(payload) { const source = payload && typeof payload === 'object' && payload.result && typeof payload.result === 'object' ? payload.result : payload; const file = source && typeof source.file === 'object' ? source.file : null; const file_code = file?.file_code || file?.filecode || source?.file_code || source?.filecode || null; return { download_url: file_code ? `https://voe.sx/${file_code}` : null, embed_url: file_code ? `https://voe.sx/e/${file_code}` : null, file_code }; } // Byse: { files: [{ filecode, filename, status }] } function parseByseResult(payload) { let file_code = null; // Primary: files array (per official Byse API docs) if (Array.isArray(payload.files) && payload.files.length > 0) { file_code = payload.files[0].filecode || payload.files[0].file_code; } // Fallback: result object if (!file_code && payload.result) { const result = payload.result; if (Array.isArray(result) && result.length > 0) { file_code = result[0].filecode || result[0].file_code; } else if (typeof result === 'object') { file_code = result.filecode || result.file_code; } } return { download_url: file_code ? `https://byse.sx/d/${file_code}` : null, embed_url: file_code ? `https://byse.sx/e/${file_code}` : null, file_code }; } // --- Multipart upload with progress --- function buildMultipart(filePath, formFields) { const boundary = '----FormBoundary' + crypto.randomBytes(16).toString('hex'); const fileName = path.basename(filePath); const fileSize = fs.statSync(filePath).size; let preamble = ''; for (const [key, value] of Object.entries(formFields)) { preamble += `--${boundary}\r\n`; preamble += `Content-Disposition: form-data; name="${key}"\r\n\r\n`; preamble += `${value}\r\n`; } preamble += `--${boundary}\r\n`; const safeFileName = fileName.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); preamble += `Content-Disposition: form-data; name="file"; filename="${safeFileName}"\r\n`; preamble += `Content-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; return { boundary, preambleBuf, epilogueBuf, totalSize, fileSize }; } function createUploadBody(filePath, formFields, onProgress, throttle, signal) { const { boundary, preambleBuf, epilogueBuf, totalSize, fileSize } = buildMultipart(filePath, formFields); let bytesRead = 0; const CHUNK_SIZE = 256 * 1024; 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 (onProgress) onProgress(bytesRead, fileSize); } yield epilogueBuf; } return { iterable: generate(), boundary, totalSize }; } // --- API helper using built-in fetch (follows redirects automatically) --- async function apiGet(url, signal) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), API_TIMEOUT); const onAbort = () => controller.abort(); if (signal) signal.addEventListener('abort', onAbort); try { const res = await fetch(url, { method: 'GET', signal: controller.signal, redirect: 'follow' }); let data; try { data = await res.json(); } catch { const text = await res.text().catch(() => ''); throw new Error(`API-Antwort war kein JSON (HTTP ${res.status}): ${(text || '').slice(0, 200)}`); } if (data.status && [401, 403, 429, 500].includes(data.status)) { throw new Error(data.msg || data.message || JSON.stringify(data)); } return data; } finally { clearTimeout(timeout); if (signal) signal.removeEventListener('abort', onAbort); } } // --- Main upload function --- async function getUploadServer(hosterName, hosterConfig, apiKey, signal) { let lastMessage = ''; for (let attempt = 1; attempt <= SERVER_RETRY_ATTEMPTS; attempt++) { for (const endpoint of hosterConfig.serverEndpoints) { const url = `${hosterConfig.apiBase}${endpoint}?key=${encodeURIComponent(apiKey)}`; try { const data = await apiGet(url, signal); const uploadUrl = extractUploadServerUrl(data, hosterConfig.apiBase); if (uploadUrl) { LAST_UPLOAD_SERVERS.set(hosterName, uploadUrl); return uploadUrl; } const apiMessage = data && (data.msg || data.message) ? String(data.msg || data.message).trim() : ''; if (apiMessage) lastMessage = apiMessage; } catch (err) { if (err.name === 'AbortError') throw err; if (err.message) lastMessage = err.message; } } if (attempt < SERVER_RETRY_ATTEMPTS && shouldRetryServerLookup(lastMessage)) { await sleep(SERVER_RETRY_DELAY_MS, signal); continue; } break; } const cachedServer = LAST_UPLOAD_SERVERS.get(hosterName); if (cachedServer && shouldRetryServerLookup(lastMessage)) { return cachedServer; } if (shouldRetryServerLookup(lastMessage) && Array.isArray(hosterConfig.fallbackUploadServers)) { for (const fallback of hosterConfig.fallbackUploadServers) { const normalized = normalizeAbsoluteUrl(fallback, hosterConfig.apiBase); if (normalized) { LAST_UPLOAD_SERVERS.set(hosterName, normalized); return normalized; } } } if (lastMessage) { throw new Error(`Kein Upload-Server erhalten: ${lastMessage}`); } throw new Error('Kein Upload-Server erhalten. API-Key pruefen.'); } async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, throttle) { const config = HOSTER_CONFIGS[hosterName]; if (!config) throw new Error(`Unbekannter Hoster: ${hosterName}`); // Step 1: Get upload server const uploadUrl = await getUploadServer(hosterName, config, apiKey, signal); // Step 2: Upload file with progress const targetUrl = config.buildUploadUrl(uploadUrl, apiKey); const formFields = config.formFields(apiKey); const { iterable, boundary, totalSize } = createUploadBody(filePath, formFields, onProgress, throttle, signal); const { body, statusCode, headers } = await request(targetUrl, { method: 'POST', body: iterable, signal, headers: { 'Content-Type': `multipart/form-data; boundary=${boundary}`, 'Content-Length': String(totalSize), 'Accept': 'application/json, text/plain;q=0.9, */*;q=0.8', 'User-Agent': 'multi-hoster-uploader/1.1' }, headersTimeout: UPLOAD_TIMEOUT, bodyTimeout: UPLOAD_TIMEOUT }); const rawBody = await body.text(); let payload = null; try { payload = rawBody ? JSON.parse(rawBody) : {}; } catch { const snippet = rawBody ? rawBody.slice(0, 240).replace(/\s+/g, ' ').trim() : ''; throw new Error( `Upload-Antwort von ${hosterName} war kein JSON (HTTP ${statusCode}${snippet ? `): ${snippet}` : ')'}` ); } if (statusCode < 200 || statusCode >= 300) { throw new Error( payload.msg || payload.message || `Upload fehlgeschlagen (HTTP ${statusCode}${headers?.['content-type'] ? `, ${headers['content-type']}` : ''})` ); } if (payload.status && [401, 403, 429, 500].includes(payload.status)) { throw new Error(payload.msg || payload.message || JSON.stringify(payload)); } const result = config.parseResult(payload); if (result?.file_code || result?.download_url || result?.embed_url) { return result; } if (payload.success === false) { throw new Error(payload.msg || payload.message || `Upload zu ${hosterName} wurde vom Server abgelehnt.`); } throw new Error( payload.msg || payload.message || `Upload zu ${hosterName} lieferte keine verwendbaren Dateidaten zurueck.` ); } module.exports = { uploadFile, HOSTER_CONFIGS, __test: { extractUploadServerUrl, parseVoeResult } };