release: v2.0.6
Redesign the desktop workspace with task sidebars, live filters, clearer settings, and an accessible update dialog. Harden encrypted backup imports, configuration persistence, history retention, queue snapshots, shutdown recovery, and update installation ordering. Publish verified Windows artifacts and refreshed English documentation.
This commit is contained in:
@@ -0,0 +1,518 @@
|
||||
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,
|
||||
// Fields verified from a real browser POST capture.
|
||||
// to_json=1 forces a JSON response instead of an HTML redirect page.
|
||||
params: { sess_id: payload.sess_id, to_json: '1', fld_id: '0' },
|
||||
fileFieldName: 'file'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = 1024 * 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) and runs
|
||||
// the nginx-upload-progress module. It requires an X-Progress-ID query
|
||||
// parameter on the POST URL — without it the upload hangs at the final
|
||||
// byte because the module can't finalize the session. Browsers append it
|
||||
// automatically before submitting the form.
|
||||
const progressId = Date.now().toString() + Math.floor(Math.random() * 1e6).toString().padStart(6, '0');
|
||||
const targetUrl = uploadUrl + (uploadUrl.includes('?') ? '&' : '?') + 'X-Progress-ID=' + progressId;
|
||||
|
||||
// Browsers don't send vidmoly.me cookies across origins, so we don't either.
|
||||
const { body, statusCode, headers } = await request(targetUrl, {
|
||||
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. The current transit server returns
|
||||
// { status: "OK", file_code: "...", msg: "Upload Completed" }.
|
||||
// Legacy XFS shapes (json.files / json.result) are kept as fallback.
|
||||
try {
|
||||
const json = JSON.parse(resultHtml);
|
||||
if (json.status && /ok/i.test(json.status) && json.file_code) {
|
||||
return this._buildUrlsFromCode(json.file_code);
|
||||
}
|
||||
if (json.file_code || json.filecode) {
|
||||
return this._buildUrlsFromCode(json.file_code || json.filecode);
|
||||
}
|
||||
if (json.files && json.files.length > 0) {
|
||||
const f = json.files[0];
|
||||
return this._buildUrlsFromCode(f.filecode || f.file_code);
|
||||
}
|
||||
if (json.result) {
|
||||
const r = Array.isArray(json.result) ? json.result[0] : json.result;
|
||||
const code = r.filecode || r.file_code;
|
||||
const urls = this._buildUrlsFromCode(code);
|
||||
if (urls) return urls;
|
||||
}
|
||||
if (json.status && !/ok/i.test(json.status) && json.msg) {
|
||||
throw new Error(`Vidmoly Upload abgelehnt: ${json.msg}`);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err && /Vidmoly Upload abgelehnt/.test(err.message)) throw err;
|
||||
}
|
||||
|
||||
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) {
|
||||
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;
|
||||
Reference in New Issue
Block a user