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
+181 -77
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://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';
@@ -160,21 +161,58 @@ class VoeUploader {
* Returns { server: "https://cdn-xxx.edgeon-bandwidth.com/node/u/01", session_id: "..." }
*/
async _getDeliveryNode(csrfToken) {
const res = await this._fetch(`${BASE_URL}/engine/delivery-node`, {
headers: {
'X-CSRF-TOKEN': csrfToken,
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json'
}
});
const endpoint = `${BASE_URL}/engine/delivery-node`;
let res;
try {
res = await this._fetch(endpoint, {
headers: {
'X-CSRF-TOKEN': csrfToken,
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json'
}
});
} catch {
throw createTransportError('VOE: Upload-Server konnte nicht geladen werden', {
phase: 'upload-server',
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('VOE: Upload-Server konnte nicht geladen werden', {
phase: 'upload-server',
endpoint,
httpStatus: res.status,
contentType,
body,
retryable: res.status === 429 || res.status >= 500,
transientNetwork: res.status >= 500
});
}
let data;
try { data = JSON.parse(body); } catch {
throw new Error(`VOE: Upload-Server Antwort war kein JSON: ${body.slice(0, 200)}`);
throw createTransportError('VOE: Upload-Server Antwort war kein JSON', {
phase: 'upload-server',
endpoint,
httpStatus: res.status,
contentType,
body
});
}
if (!data || !data.success || !data.server) {
throw new Error('VOE: Kein Upload-Server erhalten von delivery-node');
throw createTransportError('VOE: Kein Upload-Server erhalten von delivery-node', {
phase: 'upload-server',
endpoint,
httpStatus: res.status,
contentType,
body
});
}
return { uploadServer: data.server, sessionId: data.session_id || '' };
@@ -183,26 +221,54 @@ class VoeUploader {
/**
* List current files via VOE API (for result polling fallback)
*/
async _fetchFileList() {
async _fetchFileList(phase = 'recovery-poll') {
const endpoint = `${BASE_URL}/api2/my-files?sort=date&order=dsc&page=1&per_page=50`;
let res;
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 [];
res = await this._fetch(endpoint);
} catch {
return [];
throw createTransportError('VOE: 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('VOE: Dateiliste konnte nicht geladen werden', {
phase,
endpoint,
httpStatus: res.status,
contentType,
body,
retryable: res.status === 429 || res.status >= 500,
transientNetwork: res.status >= 500
});
}
let data;
try {
data = JSON.parse(body);
} catch {
throw createTransportError('VOE: Dateiliste war kein JSON', {
phase,
endpoint,
httpStatus: res.status,
contentType,
body
});
}
if (data && Array.isArray(data.data)) return data.data;
if (data && Array.isArray(data.files)) return data.files;
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();
}
const files = await this._fetchFileList('recovery-baseline');
return new Set(files.map(f => String(f.file_code || f.slug || '').trim()).filter(Boolean));
}
/**
@@ -212,7 +278,14 @@ class VoeUploader {
async upload(filePath, onProgress, signal, throttle) {
const fileName = path.basename(filePath);
const fileSize = fs.statSync(filePath).size;
const baselineCodes = await this._captureFileCodes();
let baselineCodes = null;
let baselineError = null;
try {
baselineCodes = await this._captureFileCodes();
} catch (err) {
if (signal && signal.aborted) throw err;
baselineError = err;
}
// Step 1: Get CSRF token from upload page
const { csrfToken } = await this._getUploadParams();
@@ -258,27 +331,51 @@ class VoeUploader {
}
// Step 3: POST file to CDN upload server
const { body, headers } = await request(uploadServer, {
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
});
let uploadResponse;
try {
uploadResponse = await request(uploadServer, {
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
});
} catch (err) {
if (signal && signal.aborted) throw err;
throw createTransportError('VOE Upload konnte nicht übertragen werden', {
phase: 'upload-request',
endpoint: uploadServer,
retryable: true,
transientNetwork: true
});
}
const { body, headers, statusCode } = uploadResponse;
this._parseCookiesFromHeaders(headers || {});
const rawBody = await body.text();
if (statusCode < 200 || statusCode >= 300) {
throw createTransportError('VOE Upload fehlgeschlagen', {
phase: 'upload-response',
endpoint: uploadServer,
httpStatus: statusCode,
contentType: headers && headers['content-type'],
body: rawBody,
retryable: statusCode === 429 || statusCode >= 500,
transientNetwork: statusCode >= 500
});
}
// Try JSON response
try {
@@ -295,22 +392,44 @@ class VoeUploader {
// Check for error
if (json.error || json.message) {
throw new Error(`VOE Upload-Fehler: ${json.error || json.message}`);
throw createTransportError(`VOE Upload-Fehler: ${sanitizeRemoteText(json.error || json.message)}`, {
phase: 'upload-result',
endpoint: uploadServer,
contentType: 'application/json',
body: rawBody
});
}
} catch (parseErr) {
if (parseErr.message.startsWith('VOE Upload-Fehler')) throw parseErr;
if (parseErr && parseErr.diagnostic) 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;
if (baselineCodes) {
const result = await this._resolveUploadedFile(fileName, baselineCodes, signal);
if (result) return result;
}
throw new Error('VOE Upload: Kein file_code in der Antwort gefunden');
if (baselineError) {
baselineError.hosterTransient = true;
throw baselineError;
}
throw createTransportError('VOE Upload: Kein file_code in der Antwort gefunden', {
phase: 'upload-result',
endpoint: uploadServer,
contentType: headers && headers['content-type'],
body: rawBody,
hosterTransient: true,
retryable: true
});
}
async _resolveUploadedFile(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) {
@@ -321,29 +440,24 @@ class VoeUploader {
let files = [];
try {
files = await this._fetchFileList();
} catch { files = []; }
files = await this._fetchFileList('recovery-poll');
successfulPoll = true;
} catch (err) {
if (err && err.name === 'AbortError') throw err;
lastPollError = err;
}
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()));
const matches = newFiles.filter(file => {
const title = this._normalizeTitle(file.title || file.name || '');
return expectedTitle && title === expectedTitle;
});
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 (matches.length > 1) return null;
if (matches.length === 1) {
const code = matches[0].file_code || matches[0].slug;
return this._buildUrls(code);
}
if (attempt < RESULT_POLL_ATTEMPTS - 1) {
@@ -351,6 +465,7 @@ class VoeUploader {
}
}
if (!successfulPoll && lastPollError) throw lastPollError;
return null;
}
@@ -358,21 +473,10 @@ class VoeUploader {
return String(value || '')
.toLowerCase()
.normalize('NFKD')
.replace(/\.[a-z0-9]+$/i, '')
.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;