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'], 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 file_code = (payload.file && typeof payload.file === 'object') ? payload.file.file_code : 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/${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`; preamble += `Content-Disposition: form-data; name="file"; filename="${fileName}"\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) { 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) { 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); if (signal) signal.addEventListener('abort', () => controller.abort()); try { const res = await fetch(url, { method: 'GET', signal: controller.signal, redirect: 'follow' }); const data = await res.json(); 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); } } // --- 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=${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) { 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); const { body, statusCode } = await request(targetUrl, { method: 'POST', body: iterable, signal, headers: { 'Content-Type': `multipart/form-data; boundary=${boundary}`, 'Content-Length': String(totalSize) }, headersTimeout: UPLOAD_TIMEOUT, bodyTimeout: UPLOAD_TIMEOUT }); const payload = await body.json(); if (payload.status && [401, 403, 429, 500].includes(payload.status)) { throw new Error(payload.msg || payload.message || JSON.stringify(payload)); } // Step 3: Parse result return config.parseResult(payload); } module.exports = { uploadFile, HOSTER_CONFIGS };