diff --git a/lib/upload-manager.js b/lib/upload-manager.js index d0a8bb9..8be88fb 100644 --- a/lib/upload-manager.js +++ b/lib/upload-manager.js @@ -4,6 +4,7 @@ const fs = require('fs'); const crypto = require('crypto'); const { uploadFile } = require('./hosters'); const VidmolyUploader = require('./vidmoly-upload'); +const VoeUploader = require('./voe-upload'); const Semaphore = require('./semaphore'); const Throttle = require('./throttle'); @@ -249,6 +250,10 @@ class UploadManager extends EventEmitter { const vidmoly = new VidmolyUploader(); await vidmoly.login(task.username, task.password); result = await vidmoly.upload(task.file, progressCb, jobSignal, throttle); + } else if (task.hoster === 'voe.sx' && task.username) { + const voe = new VoeUploader(); + await voe.login(task.username, task.password); + result = await voe.upload(task.file, progressCb, jobSignal, throttle); } else { result = await uploadFile(task.hoster, task.file, task.apiKey, progressCb, jobSignal, throttle); } diff --git a/lib/voe-upload.js b/lib/voe-upload.js new file mode 100644 index 0000000..c925a1c --- /dev/null +++ b/lib/voe-upload.js @@ -0,0 +1,373 @@ +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const { request } = require('undici'); + +const BASE_URL = 'https://voe.sx'; +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; + +/** + * Login-based upload for VOE.sx (Laravel / FilePond) + * Fallback when API-based upload fails or is unavailable. + */ +class VoeUploader { + constructor() { + this.cookies = new Map(); + } + + _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()); + } + } + } + + /** + * GET/POST with cookie management and manual redirect following + */ + 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; + } + + /** + * Extract CSRF token from page HTML + */ + _extractCsrfToken(html) { + // Laravel meta tag + const metaMatch = html.match(/]*name=["']_token["'][^>]*value=["']([^"']+)["']/i) + || html.match(/]*value=["']([^"']+)["'][^>]*name=["']_token["']/i); + if (inputMatch) return inputMatch[1]; + + return null; + } + + /** + * Login to VOE.sx + */ + async login(email, password) { + // GET login page for cookies + CSRF token + const loginPageRes = await this._fetch(`${BASE_URL}/login`); + const loginHtml = await loginPageRes.text(); + + const csrfToken = this._extractCsrfToken(loginHtml); + if (!csrfToken) { + throw new Error('VOE Login: CSRF-Token nicht gefunden'); + } + + // POST login + const loginData = new URLSearchParams({ + _token: csrfToken, + email: email, + password: password + }); + + const res = await this._fetch(`${BASE_URL}/login`, { + method: 'POST', + body: loginData.toString(), + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Referer': `${BASE_URL}/login` + } + }); + + const body = await res.text(); + + // Check for login errors + if (body.includes('credentials do not match') || body.includes('Incorrect') || body.includes('invalid')) { + throw new Error('VOE Login fehlgeschlagen: Falscher Username oder Passwort'); + } + + // Verify we have a session + const hasSession = this.cookies.has('voe_session') || + this.cookies.has('laravel_session') || + this.cookies.size > 2; + + if (!hasSession) { + throw new Error('VOE Login fehlgeschlagen: Keine Session erhalten'); + } + } + + /** + * Get the upload page and extract CSRF token + any upload params + */ + async _getUploadParams() { + const res = await this._fetch(`${BASE_URL}/file-upload`); + const html = await res.text(); + + const csrfToken = this._extractCsrfToken(html); + if (!csrfToken) { + throw new Error('VOE Upload: CSRF-Token nicht gefunden. Bist du eingeloggt?'); + } + + return { csrfToken }; + } + + /** + * List current files via VOE API (for result polling fallback) + */ + async _fetchFileList() { + try { + const res = await this._fetch(`${BASE_URL}/api2/my-files?sort=date&order=dsc&page=1&per_page=50`); + const body = await res.text(); + const data = JSON.parse(body); + if (data && Array.isArray(data.data)) return data.data; + if (data && Array.isArray(data.files)) return data.files; + return []; + } catch { + return []; + } + } + + async _captureFileCodes() { + try { + const files = await this._fetchFileList(); + return new Set(files.map(f => String(f.file_code || f.slug || '').trim()).filter(Boolean)); + } catch { + return new Set(); + } + } + + /** + * Upload a file to VOE.sx via login session + */ + async upload(filePath, onProgress, signal, throttle) { + const fileName = path.basename(filePath); + const fileSize = fs.statSync(filePath).size; + const baselineCodes = await this._captureFileCodes(); + + const { csrfToken } = await this._getUploadParams(); + + const boundary = '----FormBoundary' + crypto.randomBytes(16).toString('hex'); + + // Build multipart body + let preamble = ''; + 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; + + 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 (throttle) await throttle.consume(chunk.length, signal); + bytesRead += chunk.length; + yield chunk; + if (onProgress) onProgress(bytesRead, fileSize); + } + yield epilogueBuf; + } + + // POST to /engine/delivery-node + const uploadUrl = `${BASE_URL}/engine/delivery-node`; + + 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), + 'X-CSRF-TOKEN': csrfToken, + 'X-Requested-With': 'XMLHttpRequest', + 'Referer': `${BASE_URL}/file-upload`, + 'Origin': BASE_URL + }, + headersTimeout: UPLOAD_TIMEOUT, + bodyTimeout: UPLOAD_TIMEOUT + }); + + this._parseCookiesFromHeaders(headers || {}); + + const rawBody = await body.text(); + + // Try JSON response + try { + const json = JSON.parse(rawBody); + + // Direct file_code in response + const fileCode = json.file_code || json.filecode || json.slug || + (json.file && (json.file.file_code || json.file.slug)) || + (json.data && (json.data.file_code || json.data.slug)); + + if (fileCode) { + return this._buildUrls(fileCode); + } + + // Check for error + if (json.error || json.message) { + throw new Error(`VOE Upload-Fehler: ${json.error || json.message}`); + } + } catch (parseErr) { + if (parseErr.message.startsWith('VOE Upload-Fehler')) throw parseErr; + // Not JSON - might be a redirect or HTML response + } + + // Fallback: poll the file list to find the newly uploaded file + const result = await this._resolveUploadedFile(fileName, baselineCodes, signal); + if (result) return result; + + throw new Error('VOE Upload: Kein file_code in der Antwort gefunden'); + } + + async _resolveUploadedFile(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._fetchFileList(); + } catch { files = []; } + + const withCode = files.filter(f => f && (f.file_code || f.slug)); + const newFiles = withCode.filter(f => !baselineCodes.has(String(f.file_code || f.slug || '').trim())); + + if (newFiles.length > 0) { + // Try to match by title + let best = null; + let bestScore = -1; + + for (const file of newFiles) { + const score = this._scoreCandidate(file, expectedTitle); + if (score > bestScore) { + bestScore = score; + best = file; + } + } + + if (best && (bestScore > 0 || newFiles.length === 1)) { + const code = best.file_code || best.slug; + return this._buildUrls(code); + } + } + + if (attempt < RESULT_POLL_ATTEMPTS - 1) { + await this._sleep(RESULT_POLL_DELAY_MS, signal); + } + } + + return null; + } + + _normalizeTitle(value) { + return String(value || '') + .toLowerCase() + .normalize('NFKD') + .replace(/[^a-z0-9]+/g, ''); + } + + _scoreCandidate(file, expectedTitle) { + if (!file || !(file.file_code || file.slug)) return -1; + if (!expectedTitle) return 0; + + const title = this._normalizeTitle(file.title || file.name || ''); + 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; + } + + _buildUrls(fileCode) { + const code = String(fileCode || '').trim(); + if (!code) return null; + return { + download_url: `${BASE_URL}/${code}`, + embed_url: `${BASE_URL}/e/${code}`, + file_code: code + }; + } + + _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); + } + }); + } +} + +module.exports = VoeUploader; diff --git a/main.js b/main.js index d429b25..090cce2 100644 --- a/main.js +++ b/main.js @@ -5,6 +5,7 @@ const ConfigStore = require('./lib/config-store'); const UploadManager = require('./lib/upload-manager'); const { HOSTER_CONFIGS } = require('./lib/hosters'); const VidmolyUploader = require('./lib/vidmoly-upload'); +const VoeUploader = require('./lib/voe-upload'); const { checkForUpdate, installUpdate, abortUpdate } = require('./lib/updater'); let mainWindow; @@ -102,6 +103,10 @@ function buildUploadTasks(config, files, hosters) { continue; } tasks.push({ file, hoster, username: hosterConfig.username, password: hosterConfig.password }); + } else if (hoster === 'voe.sx' && hosterConfig.username && hosterConfig.password) { + // VOE login-based upload (preferred over API) + tasks.push({ file, hoster, username: hosterConfig.username, password: hosterConfig.password }); + debugLog(` task: ${hoster} login=${hosterConfig.username.slice(0, 6)}...`); } else { if (!hosterConfig.apiKey) { debugLog(` skip ${hoster}: missing apiKey`); diff --git a/renderer/app.js b/renderer/app.js index 3f9be1e..5cdb250 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -94,6 +94,7 @@ document.querySelectorAll('.tab').forEach(tab => { // --- Hoster selection --- function hosterHasCredentials(name, hoster) { if (name === 'vidmoly.me') return !!(hoster.username && hoster.password); + if (name === 'voe.sx') return !!(hoster.username && hoster.password) || !!hoster.apiKey; return !!hoster.apiKey; } @@ -148,7 +149,10 @@ function renderHosterModal() { list.innerHTML = available.map(item => { const checked = selectedUploadHosters.includes(item.name); - const subtitle = item.name === 'vidmoly.me' ? 'Login hinterlegt' : 'API-Key hinterlegt'; + const h = config.hosters[item.name] || {}; + const subtitle = item.name === 'vidmoly.me' ? 'Login hinterlegt' + : (item.name === 'voe.sx' && h.username && h.password) ? 'Login hinterlegt' + : 'API-Key hinterlegt'; return ` @@ -905,6 +909,23 @@ function renderSettings() { 👁 `; + } else if (name === 'voe.sx') { + credsHtml = ` + + E-Mail (Login) + + + + Passwort (Login) + + 👁 + + + API Key (optional) + + 👁 + + Login wird bevorzugt. API-Key nur als Fallback.`; } else { credsHtml = ` @@ -1001,6 +1022,11 @@ async function saveSettings() { const username = (document.querySelector(`.key-input[data-hoster="${name}"][data-field="username"]`)?.value || '').trim(); const password = (document.querySelector(`.key-input[data-hoster="${name}"][data-field="password"]`)?.value || '').trim(); hosters[name] = { enabled: !!(username && password), authType: 'login', username, password }; + } else if (name === 'voe.sx') { + const username = (document.querySelector(`.key-input[data-hoster="${name}"][data-field="username"]`)?.value || '').trim(); + const password = (document.querySelector(`.key-input[data-hoster="${name}"][data-field="password"]`)?.value || '').trim(); + const apiKey = (document.querySelector(`.key-input[data-hoster="${name}"][data-field="apiKey"]`)?.value || '').trim(); + hosters[name] = { enabled: !!(username && password) || !!apiKey, username, password, apiKey }; } else { const apiKey = (document.querySelector(`.key-input[data-hoster="${name}"][data-field="apiKey"]`)?.value || '').trim(); hosters[name] = { enabled: !!apiKey, apiKey };
Login wird bevorzugt. API-Key nur als Fallback.