fix: harden hoster confirmation and recovery

Require every successful upload to expose a validated HTTPS result and rebuild all Doodstream and DSVPlay output from the confirmed file code.

Keep failed baselines distinct from empty accounts, reject stale, foreign, and ambiguous recovery candidates across Doodstream, Byse, VOE, and Vidmoly, and preserve exact filename recovery with normalized extensions.

Emit bounded structured transport diagnostics without raw response bodies or tokenized URLs, and remove sensitive values from Doodstream debug traces.

Tests: node --test tests/upload-confirmation.test.js tests/hosters.test.js tests/doodstream-api-upload.test.js tests/doodstream-upload.test.js tests/byse-reject-recovery.test.js tests/hoster-recovery-provenance.test.js tests/suspect-reject-alternates.test.js

Lint: eslint lib/hoster-transport-error.js lib/hosters.js lib/doodstream-upload.js lib/voe-upload.js lib/vidmoly-upload.js lib/upload-confirmation.js
This commit is contained in:
Sucukdeluxe
2026-08-13 20:43:48 +02:00
parent e63214cae8
commit b64cdd0ff3
12 changed files with 1320 additions and 378 deletions
+172 -85
View File
@@ -2,6 +2,7 @@ const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { request } = require('undici');
const { createTransportError, sanitizeRemoteText } = require('./hoster-transport-error');
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';
@@ -130,14 +131,51 @@ class VidmolyUploader {
* removed. Returns an XFS-style session token + a transit-server URL.
*/
async getUploadParams() {
const res = await this._fetch(`${BASE_URL}/api/upload/config`);
const endpoint = `${BASE_URL}/api/upload/config`;
let res;
try {
res = await this._fetch(endpoint);
} catch {
throw createTransportError('Vidmoly: Upload-Konfiguration konnte nicht geladen werden', {
phase: 'upload-config',
endpoint,
retryable: true,
transientNetwork: true
});
}
const body = await res.text();
const contentType = res.headers && typeof res.headers.get === 'function'
? res.headers.get('content-type')
: null;
if (res.status < 200 || res.status >= 300) {
throw createTransportError('Vidmoly: Upload-Konfiguration konnte nicht geladen werden', {
phase: 'upload-config',
endpoint,
httpStatus: res.status,
contentType,
body,
retryable: res.status === 429 || res.status >= 500,
transientNetwork: res.status >= 500
});
}
let payload = null;
try { payload = JSON.parse(body); } catch {
throw new Error('Vidmoly: /api/upload/config lieferte kein JSON — evtl. nicht eingeloggt?');
throw createTransportError('Vidmoly: Upload-Konfiguration war kein JSON', {
phase: 'upload-config',
endpoint,
httpStatus: res.status,
contentType,
body
});
}
if (!payload || !payload.sess_id || !payload.upload_url) {
throw new Error('Vidmoly: /api/upload/config unvollständig (sess_id/upload_url fehlt)');
throw createTransportError('Vidmoly: Upload-Konfiguration war unvollständig', {
phase: 'upload-config',
endpoint,
httpStatus: res.status,
contentType,
body
});
}
return {
uploadUrl: payload.upload_url,
@@ -154,7 +192,14 @@ class VidmolyUploader {
async upload(filePath, onProgress, signal, throttle) {
const fileName = path.basename(filePath);
const fileSize = fs.statSync(filePath).size;
const baselineCodes = await this._captureVmFileCodes();
let baselineCodes = null;
let baselineError = null;
try {
baselineCodes = await this._captureVmFileCodes();
} catch (err) {
if (signal && signal.aborted) throw err;
baselineError = err;
}
const { uploadUrl, params, fileFieldName } = await this.getUploadParams();
@@ -211,21 +256,34 @@ class VidmolyUploader {
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
});
let uploadResponse;
try {
uploadResponse = 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
});
} catch (err) {
if (signal && signal.aborted) throw err;
throw createTransportError('Vidmoly Upload konnte nicht übertragen werden', {
phase: 'upload-request',
endpoint: targetUrl,
retryable: true,
transientNetwork: true
});
}
const { body, statusCode, headers } = uploadResponse;
this._parseCookiesFromHeaders(headers || {});
@@ -245,6 +303,18 @@ class VidmolyUploader {
resultHtml = await body.text();
}
if (statusCode >= 400) {
throw createTransportError('Vidmoly Upload fehlgeschlagen', {
phase: 'upload-response',
endpoint: targetUrl,
httpStatus: statusCode,
contentType: headers && headers['content-type'],
body: resultHtml,
retryable: statusCode === 429 || statusCode >= 500,
transientNetwork: statusCode >= 500
});
}
// 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.
@@ -267,17 +337,29 @@ class VidmolyUploader {
if (urls) return urls;
}
if (json.status && !/ok/i.test(json.status) && json.msg) {
throw new Error(`Vidmoly Upload abgelehnt: ${json.msg}`);
throw createTransportError(`Vidmoly Upload abgelehnt: ${sanitizeRemoteText(json.msg)}`, {
phase: 'upload-result',
endpoint: targetUrl,
httpStatus: statusCode,
contentType: 'application/json',
body: resultHtml
});
}
} catch (err) {
if (err && /Vidmoly Upload abgelehnt/.test(err.message)) throw err;
if (err && err.diagnostic) throw err;
}
try {
return this._parseUploadResult(resultHtml);
} catch (primaryErr) {
const fallback = await this._resolveUploadedFileFromVmApi(fileName, baselineCodes, signal);
if (fallback) return fallback;
if (baselineCodes) {
const fallback = await this._resolveUploadedFileFromVmApi(fileName, baselineCodes, signal);
if (fallback) return fallback;
}
if (baselineError) {
baselineError.hosterTransient = true;
throw baselineError;
}
throw primaryErr;
}
}
@@ -286,21 +368,10 @@ class VidmolyUploader {
return String(value || '')
.toLowerCase()
.normalize('NFKD')
.replace(/\.[a-z0-9]+$/i, '')
.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;
@@ -313,19 +384,15 @@ class VidmolyUploader {
}
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();
}
const files = await this._fetchVmList('recovery-baseline');
return new Set(
files
.map((f) => String(f.file_code || '').trim())
.filter(Boolean)
);
}
async _fetchVmList() {
async _fetchVmList(phase = 'recovery-poll') {
const params = new URLSearchParams({
op: 'vm',
api: 'list',
@@ -336,14 +403,46 @@ class VidmolyUploader {
fld_id: '0'
});
const res = await this._fetch(`${BASE_URL}/?${params.toString()}`);
const endpoint = `${BASE_URL}/?${params.toString()}`;
let res;
try {
res = await this._fetch(endpoint);
} catch {
throw createTransportError('Vidmoly: Dateiliste konnte nicht geladen werden', {
phase,
endpoint,
retryable: true,
transientNetwork: true
});
}
const body = await res.text();
const contentType = res.headers && typeof res.headers.get === 'function'
? res.headers.get('content-type')
: null;
if (res.status < 200 || res.status >= 300) {
throw createTransportError('Vidmoly: Dateiliste konnte nicht geladen werden', {
phase,
endpoint,
httpStatus: res.status,
contentType,
body,
retryable: res.status === 429 || res.status >= 500,
transientNetwork: res.status >= 500
});
}
let payload;
try {
payload = JSON.parse(body);
} catch {
throw new Error('Vidmoly VM API lieferte kein JSON');
throw createTransportError('Vidmoly: Dateiliste war kein JSON', {
phase,
endpoint,
httpStatus: res.status,
contentType,
body
});
}
if (!payload || !Array.isArray(payload.files)) return [];
@@ -351,7 +450,10 @@ class VidmolyUploader {
}
async _resolveUploadedFileFromVmApi(fileName, baselineCodes, signal) {
if (!(baselineCodes instanceof Set)) return null;
const expectedTitle = this._normalizeTitle(path.parse(fileName).name);
let lastPollError = null;
let successfulPoll = false;
for (let attempt = 0; attempt < RESULT_POLL_ATTEMPTS; attempt++) {
if (signal && signal.aborted) {
@@ -362,46 +464,23 @@ class VidmolyUploader {
let files = [];
try {
files = await this._fetchVmList();
} catch {
files = [];
files = await this._fetchVmList('recovery-poll');
successfulPoll = true;
} catch (err) {
if (err && err.name === 'AbortError') throw err;
lastPollError = err;
}
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));
const newFiles = withCode.filter((f) => !baselineCodes.has(f.file_code.trim()));
const matches = newFiles.filter((file) => {
const title = this._normalizeTitle(file.full_title || file.title_txt || '');
return expectedTitle && title === expectedTitle;
});
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 (matches.length > 1) return null;
if (matches.length === 1) {
return this._buildUrlsFromCode(matches[0].file_code);
}
if (attempt < RESULT_POLL_ATTEMPTS - 1) {
@@ -409,6 +488,7 @@ class VidmolyUploader {
}
}
if (!successfulPoll && lastPollError) throw lastPollError;
return null;
}
@@ -508,7 +588,14 @@ class VidmolyUploader {
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}`);
throw createTransportError(`Vidmoly Upload-Ergebnis: ${sanitizeRemoteText(errMsg)}`, {
phase: 'upload-result',
endpoint: BASE_URL,
contentType: 'text/html',
body: html,
hosterTransient: true,
retryable: true
});
}
return { download_url, embed_url, file_code };