const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const { request } = require('undici'); const BASE_URL = 'https://vidmoly.me'; 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 const RESULT_POLL_ATTEMPTS = 10; const RESULT_POLL_DELAY_MS = 2000; /** * XFileSharing-based upload for Vidmoly (login + form upload) */ class VidmolyUploader { constructor() { this.cookies = new Map(); } _cookieHeader() { return Array.from(this.cookies.entries()) .map(([k, v]) => `${k}=${v}`) .join('; '); } _parseCookiesFromHeaders(headers) { // Handle both undici response headers and fetch 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()); } } } /** * Simple GET/POST using built-in fetch (handles redirects) */ 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' // handle manually to capture cookies from redirect responses }); this._parseCookiesFromHeaders(res.headers); // Follow redirects manually (to capture cookies at each hop) if ([301, 302, 303, 307, 308].includes(res.status)) { // Drain body to prevent connection leak 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 Vidmoly */ async login(username, password) { // First GET the main page to get initial cookies const initRes = await this._fetch(BASE_URL); await initRes.text(); // POST login const loginData = new URLSearchParams({ op: 'login', login: username, password: password, redirect: '' }); const res = await this._fetch(BASE_URL, { method: 'POST', body: loginData.toString(), headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Referer': BASE_URL } }); const body = await res.text(); if (body.includes('Incorrect Login or Password')) { throw new Error('Vidmoly Login fehlgeschlagen: Falscher Username oder Passwort'); } // Check for login cookie const hasSession = this.cookies.has('login') || this.cookies.has('xfsts') || this.cookies.size > 1; if (!hasSession) { throw new Error('Vidmoly Login fehlgeschlagen: Keine Session erhalten'); } } /** * Get upload form parameters from the upload page */ async getUploadParams() { const res = await this._fetch(`${BASE_URL}/?op=upload`); const html = await res.text(); // Parse hidden form fields from XFS upload form const params = {}; const inputRegex = /]*type=["']hidden["'][^>]*>/gi; let match; while ((match = inputRegex.exec(html)) !== null) { const tag = match[0]; const nameMatch = tag.match(/name=["']([^"']+)["']/); const valueMatch = tag.match(/value=["']([^"']*?)["']/); if (nameMatch) { params[nameMatch[1]] = valueMatch ? valueMatch[1] : ''; } } // Extract form action const formMatch = html.match(/]*id=["']?file_upload["']?[^>]*action=["']([^"']+)["']/i) || html.match(/]*enctype=["']multipart\/form-data["'][^>]*action=["']([^"']+)["']/i) || html.match(/]*action=["']([^"']+)["'][^>]*enctype=["']multipart\/form-data["']/i); let uploadUrl = null; if (formMatch) { uploadUrl = formMatch[1]; } else if (params.srv_tmp_url) { uploadUrl = params.srv_tmp_url; } if (!uploadUrl) { const cgiMatch = html.match(/(https?:\/\/[^"'\s]+\/cgi-bin\/upload\.cgi[^"'\s]*)/i) || html.match(/(https?:\/\/[^"'\s]+\/upload\/\d+)/i); if (cgiMatch) uploadUrl = cgiMatch[1]; } if (!uploadUrl) { throw new Error('Vidmoly Upload-URL nicht gefunden. Bist du eingeloggt?'); } let fileFieldName = 'file'; const fileInputMatch = html.match(/]*type=["']file["'][^>]*name=["']([^"']+)["']/i) || html.match(/]*name=["']([^"']+)["'][^>]*type=["']file["']/i); if (fileInputMatch && fileInputMatch[1]) { fileFieldName = fileInputMatch[1].trim(); } return { uploadUrl, params, fileFieldName }; } /** * Upload a file to Vidmoly (uses undici.request for streaming progress) */ async upload(filePath, onProgress, signal, throttle) { const fileName = path.basename(filePath); const fileSize = fs.statSync(filePath).size; const baselineCodes = await this._captureVmFileCodes(); const { uploadUrl, params, fileFieldName } = await this.getUploadParams(); const boundary = '----FormBoundary' + crypto.randomBytes(16).toString('hex'); // XFS form fields const formFields = {}; for (const [k, v] of Object.entries(params)) { if (!/^file(?:_\d+)?$/i.test(k)) { // eslint-disable-line security/detect-unsafe-regex -- safe: no backtracking formFields[k] = v; } } // Build multipart 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="${fileFieldName || '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; 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; } // Use undici.request for the upload (streaming body for progress) const { body, statusCode, headers } = await request(uploadUrl, { method: 'POST', body: generate(), signal, headers: { 'User-Agent': USER_AGENT, 'Cookie': this._cookieHeader(), 'Content-Type': `multipart/form-data; boundary=${boundary}`, 'Content-Length': String(totalSize), 'Referer': `${BASE_URL}/upload.html` }, headersTimeout: UPLOAD_TIMEOUT, bodyTimeout: UPLOAD_TIMEOUT }); this._parseCookiesFromHeaders(headers || {}); // Check if upload response is a redirect (XFS often redirects to result page) let resultHtml; if ([301, 302, 303].includes(statusCode)) { const location = headers && headers.location; // Always drain the original body to prevent connection leak try { await body.text(); } catch {} if (location) { const resultRes = await this._fetch(new URL(location, uploadUrl).href); resultHtml = await resultRes.text(); } else { resultHtml = ''; } } else { resultHtml = await body.text(); } // Try JSON first (some XFS versions return JSON) try { const json = JSON.parse(resultHtml); if (json.files && json.files.length > 0) { const f = json.files[0]; const code = f.filecode || f.file_code; return { download_url: code ? `${BASE_URL}/w/${code}` : null, embed_url: code ? `${BASE_URL}/embed-${code}.html` : null, file_code: code }; } if (json.result) { const r = Array.isArray(json.result) ? json.result[0] : json.result; const code = r.filecode || r.file_code; return { download_url: r.download_url || (code ? `${BASE_URL}/w/${code}` : null), embed_url: r.embed_url || (code ? `${BASE_URL}/embed-${code}.html` : null), file_code: code }; } } catch {} try { return this._parseUploadResult(resultHtml); } catch (primaryErr) { const fallback = await this._resolveUploadedFileFromVmApi(fileName, baselineCodes, signal); if (fallback) return fallback; throw primaryErr; } } _normalizeTitle(value) { return String(value || '') .toLowerCase() .normalize('NFKD') .replace(/[^a-z0-9]+/g, ''); } _scoreVmCandidate(file, expectedTitle) { if (!file || !file.file_code) return -1; if (!expectedTitle) return 0; const title = this._normalizeTitle(file.full_title || file.title_txt || ''); if (!title) return -1; if (title === expectedTitle) return 120; if (title.startsWith(expectedTitle) || expectedTitle.startsWith(title)) return 90; if (title.includes(expectedTitle) || expectedTitle.includes(title)) return 70; return 0; } _buildUrlsFromCode(fileCode) { const code = String(fileCode || '').trim(); if (!code) return null; return { download_url: `${BASE_URL}/w/${code}`, embed_url: `${BASE_URL}/embed-${code}.html`, file_code: code }; } async _captureVmFileCodes() { try { const files = await this._fetchVmList(); return new Set( files .map((f) => String(f.file_code || '').trim()) .filter(Boolean) ); } catch { return new Set(); } } async _fetchVmList() { const params = new URLSearchParams({ op: 'vm', api: 'list', page: '1', per: '100', sort: 'date', order: 'desc', fld_id: '0' }); const res = await this._fetch(`${BASE_URL}/?${params.toString()}`); const body = await res.text(); let payload; try { payload = JSON.parse(body); } catch { throw new Error('Vidmoly VM API lieferte kein JSON'); } if (!payload || !Array.isArray(payload.files)) return []; return payload.files; } async _resolveUploadedFileFromVmApi(fileName, baselineCodes, signal) { const expectedTitle = this._normalizeTitle(path.parse(fileName).name); for (let attempt = 0; attempt < RESULT_POLL_ATTEMPTS; attempt++) { if (signal && signal.aborted) { const err = new Error('Aborted'); err.name = 'AbortError'; throw err; } let files = []; try { files = await this._fetchVmList(); } catch { files = []; } const withCode = files.filter((f) => f && typeof f.file_code === 'string' && f.file_code.trim()); const newFiles = withCode.filter((f) => !baselineCodes.has(f.file_code)); if (newFiles.length > 0) { let best = null; let bestScore = -1; for (const file of newFiles) { const score = this._scoreVmCandidate(file, expectedTitle); if (score > bestScore) { bestScore = score; best = file; } } if (best && (bestScore > 0 || newFiles.length === 1)) { return this._buildUrlsFromCode(best.file_code); } } if (expectedTitle) { let bestMatch = null; let bestScore = -1; for (const file of withCode) { const score = this._scoreVmCandidate(file, expectedTitle); if (score > bestScore) { bestScore = score; bestMatch = file; } } if (bestMatch && bestScore >= 90) { return this._buildUrlsFromCode(bestMatch.file_code); } } if (attempt < RESULT_POLL_ATTEMPTS - 1) { await this._sleep(RESULT_POLL_DELAY_MS, signal); } } return null; } _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); } }); } _parseUploadResult(html) { let download_url = null; let embed_url = null; let file_code = null; const fnMatch = html.match(/<(?:input|textarea)[^>]*name=["']fn["'][^>]*(?:value=["']([^"']+)["'])?[^>]*>([^<]*)/i); // eslint-disable-line security/detect-unsafe-regex -- parses trusted hoster HTML only if (fnMatch) { const codeFromFn = (fnMatch[1] || fnMatch[2] || '').trim(); if (/^[a-z0-9]{8,16}$/i.test(codeFromFn)) { file_code = codeFromFn; } } if (!file_code) { const fnAltMatch = html.match(/(?:^|[?&])fn=([a-z0-9]{8,16})(?:&|$)/i); if (fnAltMatch) file_code = fnAltMatch[1]; } // Vidmoly URL patterns - includes /w/ path format const linkPatterns = [ /https?:\/\/vidmoly\.[a-z]+\/w\/[a-z0-9]{12}/gi, /https?:\/\/vidmoly\.[a-z]+\/embed-[a-z0-9]{12}[^\s"']*/gi, /https?:\/\/vidmoly\.[a-z]+\/[a-z0-9]{12}\.html/gi, /https?:\/\/vidmoly\.[a-z]+\/[a-z0-9]{12}/gi ]; for (const pattern of linkPatterns) { const matches = html.match(pattern); if (matches) { for (const url of matches) { if (url.includes('/embed-') || url.includes('/embed/')) { if (!embed_url) embed_url = url; } else { if (!download_url) download_url = url; } } } } // Extract file code from URLs const codeMatch = (download_url || embed_url || '').match(/\/(?:w\/)?([a-z0-9]{12})/i) || (download_url || embed_url || '').match(/embed-([a-z0-9]{12})/i); if (codeMatch) { file_code = codeMatch[1]; } // Try input/textarea fields if (!download_url) { const inputMatch = html.match(/<(?:input|textarea)[^>]*value=["'](https?:\/\/vidmoly[^"']+)["']/i); if (inputMatch) { download_url = inputMatch[1]; const code = download_url.match(/\/(?:w\/)?([a-z0-9]{12})/i); if (code) file_code = code[1]; } } // Try to find file code in any filecode reference if (!file_code) { const codeInPage = html.match(/filecode['":\s]+['"]?([a-z0-9]{12})['"]?/i) || html.match(/file_code['":\s]+['"]?([a-z0-9]{12})['"]?/i); if (codeInPage) file_code = codeInPage[1]; } // Build URLs from file_code if (file_code && !download_url) { download_url = `${BASE_URL}/w/${file_code}`; } if (file_code && !embed_url) { embed_url = `${BASE_URL}/embed-${file_code}.html`; } if (!download_url && !file_code) { const errMatch = html.match(/class=["']err["'][^>]*>([^<]+)/i); const errMsg = errMatch ? errMatch[1].trim() : 'Kein Download-Link gefunden'; throw new Error(`Vidmoly Upload-Ergebnis: ${errMsg}`); } return { download_url, embed_url, file_code }; } } module.exports = VidmolyUploader;