Upload stalled at 99% because we were sending vidmoly.me cookies to *.vmwesa.online (transit server rejects them silently). Browsers never send those cross-origin. Now we omit the Cookie header and match the Origin/Referer the browser uses. Also added the full classic XFS field set (upload_type, sess_id, srv_tmp_url, utype) in the order the server's handler expects.
507 lines
16 KiB
JavaScript
507 lines
16 KiB
JavaScript
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 via the new JSON API (replaces the old XFS form POST
|
|
* at `/` with `op=login`, which the SPA redesign deprecated). The response
|
|
* sets a `vidmoly_session` HttpOnly cookie that the upload API checks.
|
|
*/
|
|
async login(username, password) {
|
|
// Warm up — get baseline cookies (cf_clearance etc.)
|
|
try {
|
|
const initRes = await this._fetch(BASE_URL);
|
|
await initRes.text();
|
|
} catch {}
|
|
|
|
const res = await this._fetch(`${BASE_URL}/api/auth/login`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ login: username, password }),
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Accept': 'application/json',
|
|
'Origin': BASE_URL,
|
|
'Referer': `${BASE_URL}/login`
|
|
}
|
|
});
|
|
|
|
const body = await res.text();
|
|
if (res.status === 401 || res.status === 403 || /incorrect|invalid|wrong/i.test(body)) {
|
|
throw new Error('Vidmoly Login fehlgeschlagen: Falscher Username oder Passwort');
|
|
}
|
|
if (res.status < 200 || res.status >= 300) {
|
|
throw new Error(`Vidmoly Login fehlgeschlagen: HTTP ${res.status}`);
|
|
}
|
|
if (!this.cookies.has('vidmoly_session')) {
|
|
throw new Error('Vidmoly Login fehlgeschlagen: Keine Session erhalten (vidmoly_session fehlt)');
|
|
}
|
|
|
|
// Probe the upload API so downstream getUploadParams() has a warm path.
|
|
const probe = await this._fetch(`${BASE_URL}/api/upload/config`);
|
|
const probeBody = await probe.text();
|
|
let probeJson = null;
|
|
try { probeJson = JSON.parse(probeBody); } catch {}
|
|
if (!probeJson || !probeJson.sess_id || !probeJson.upload_url) {
|
|
throw new Error('Vidmoly Login fehlgeschlagen: Session konnte nicht verifiziert werden (API-Probe)');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch the upload session config from Vidmoly's new SPA API.
|
|
* Replaces the old HTML-form scrape at /?op=upload which the redesign
|
|
* removed. Returns an XFS-style session token + a transit-server URL.
|
|
*/
|
|
async getUploadParams() {
|
|
const res = await this._fetch(`${BASE_URL}/api/upload/config`);
|
|
const body = await res.text();
|
|
let payload = null;
|
|
try { payload = JSON.parse(body); } catch {
|
|
throw new Error('Vidmoly: /api/upload/config lieferte kein JSON — evtl. nicht eingeloggt?');
|
|
}
|
|
if (!payload || !payload.sess_id || !payload.upload_url) {
|
|
throw new Error('Vidmoly: /api/upload/config unvollständig (sess_id/upload_url fehlt)');
|
|
}
|
|
return {
|
|
uploadUrl: payload.upload_url,
|
|
// Classic XFS upload fields in the order the server expects.
|
|
params: { upload_type: 'file', sess_id: payload.sess_id, srv_tmp_url: '', utype: 'reg' },
|
|
fileFieldName: 'file_0'
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
// Transit server lives on a different domain (*.vmwesa.online). Browsers
|
|
// don't send vidmoly.me cookies across origins, so we don't either.
|
|
// Origin + Referer match the browser's actual upload headers.
|
|
const { body, statusCode, headers } = await request(uploadUrl, {
|
|
method: 'POST',
|
|
body: generate(),
|
|
signal,
|
|
headers: {
|
|
'User-Agent': USER_AGENT,
|
|
'Accept': '*/*',
|
|
'Origin': BASE_URL,
|
|
'Referer': `${BASE_URL}/`,
|
|
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
|
'Content-Length': String(totalSize)
|
|
},
|
|
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;
|