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:
Sucukdeluxe
2026-08-10 09:28:28 +02:00
commit 76a228fb7c
113 changed files with 36923 additions and 0 deletions
+518
View File
@@ -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;