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
+152 -57
View File
@@ -2,6 +2,12 @@ const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { request } = require('undici');
const {
createTransportError,
safeEndpoint,
sanitizeRemoteText,
summarizeResponse
} = require('./hoster-transport-error');
const BASE_URL = 'https://doodstream.com';
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';
@@ -97,8 +103,15 @@ class DoodstreamUploader {
break;
} catch (err) {
if (opts.signal && opts.signal.aborted) throw err; // caller abort: don't retry
if (attempt >= 3) throw err;
_debugLog(`_fetch transient (${attempt}/3) ${url}: ${err && err.message}; retry`);
if (attempt >= 3) {
throw createTransportError('Doodstream: Webanfrage fehlgeschlagen', {
phase: 'web-request',
endpoint: url,
retryable: true,
transientNetwork: true
});
}
_debugLog(`_fetch transient (${attempt}/3) ${safeEndpoint(url) || 'unknown endpoint'}: ${err && err.name ? err.name : 'network error'}; retry`);
await new Promise(r => setTimeout(r, 400 * attempt));
}
}
@@ -167,16 +180,35 @@ class DoodstreamUploader {
// Explicit success response
} else if (json && json.message && /otp/i.test(json.message)) {
// OTP required — signal caller to collect OTP from user
const err = new Error(`Doodstream Login: ${json.message}`);
const err = createTransportError(`Doodstream Login: ${sanitizeRemoteText(json.message)}`, {
phase: 'login',
endpoint: BASE_URL,
httpStatus: res.status,
contentType: res.headers && res.headers.get ? res.headers.get('content-type') : null,
body
});
err.otpRequired = true;
throw err;
} else if (json && json.status === 'fail') {
throw new Error(`Doodstream Login: ${json.message || 'Login fehlgeschlagen'}`);
throw createTransportError(`Doodstream Login: ${sanitizeRemoteText(json.message) || 'Login fehlgeschlagen'}`, {
phase: 'login',
endpoint: BASE_URL,
httpStatus: res.status,
contentType: res.headers && res.headers.get ? res.headers.get('content-type') : null,
body,
accountError: true
});
} else if (body.includes('Dashboard')) {
// Got dashboard HTML directly — login worked
} else {
const msg = (json && json.message) || 'Login fehlgeschlagen';
throw new Error(`Doodstream Login: ${msg}`);
const msg = sanitizeRemoteText(json && json.message) || 'Login fehlgeschlagen';
throw createTransportError(`Doodstream Login: ${msg}`, {
phase: 'login',
endpoint: BASE_URL,
httpStatus: res.status,
contentType: res.headers && res.headers.get ? res.headers.get('content-type') : null,
body
});
}
}
@@ -220,7 +252,7 @@ class DoodstreamUploader {
const res = await this._fetch(BASE_URL + '/?op=upload_server');
const text = await res.text();
const ctype = (res.headers && res.headers.get) ? (res.headers.get('content-type') || '') : '';
_debugLog(`upload_server: status=${res.status} ctype=${ctype} body(800)=${(text || '').slice(0, 800)}`);
_debugLog(`upload_server: status=${res.status} ctype=${ctype} response=${summarizeResponse(text, ctype)}`);
let json;
try { json = JSON.parse(text); } catch { json = null; }
@@ -254,7 +286,7 @@ class DoodstreamUploader {
// Capture the form's real fields so upload() submits exactly what the
// browser would (file_title, submit_btn, …) instead of stale hardcoded ones.
this._uploadFormFields = this._parseUploadFormFields(html);
_debugLog(`upload_server: using form action node=${url} sess=${this.sessId} fields=${Object.keys(this._uploadFormFields).join(',')}`);
_debugLog(`upload_server: using form action node=${safeEndpoint(url)} sessLength=${this.sessId.length} fields=${Object.keys(this._uploadFormFields).join(',')}`);
return url;
}
@@ -265,15 +297,19 @@ class DoodstreamUploader {
// No upload server could be extracted. We MUST NOT silently fall back to a
// hardcoded node: that node is stale and accepts the bytes but returns an
// empty form (no filecode) — so the user wastes ~90s uploading 95 MB into a
// dead end and gets a cryptic "kein Filecode" 90s later. Fail fast and put
// the raw responses in the error so the real format change is diagnosable.
const urlHints = (html.match(/https?:\/\/[^'">\s]+/g) || []).slice(0, 4).join(' , ');
_debugLog(`upload_server: NO SERVER. upload-page html(2000)=${(html || '').slice(0, 2000)}`);
throw new Error(
`Doodstream: konnte Upload-Server nicht ermitteln (Endpoint geändert?). ` +
`op=upload_server status=${res.status} ctype=${ctype} body=${(text || '').slice(0, 300)} ` +
`| upload-page URL-Treffer: ${urlHints || 'keine'}`
);
// dead end and gets a cryptic "kein Filecode" 90s later. Fail fast with
// safe structured diagnostics.
_debugLog(`upload_server: no server response=${summarizeResponse(text, ctype)} page=${summarizeResponse(html, pageRes.headers && pageRes.headers.get ? pageRes.headers.get('content-type') : '')}`);
throw createTransportError('Doodstream: konnte Upload-Server nicht ermitteln', {
phase: 'upload-server',
endpoint: BASE_URL + '/?op=upload_server',
httpStatus: res.status,
contentType: ctype,
body: text,
retryable: res.status >= 500,
transientNetwork: res.status >= 500,
hosterTransient: res.status >= 500
});
}
/**
@@ -370,15 +406,14 @@ class DoodstreamUploader {
bodyTimeout: UPLOAD_TIMEOUT,
headersTimeout: 60000
});
} catch (err) {
// Label which phase failed so a future "fetch failed"/"terminated" is
// attributable to the big upload POST vs the small bookend requests. The
// original message is preserved as a substring so upload-manager's
// transient classification still matches. NOTE: undici may surface
// "terminated"/"other side closed", which are not yet in that transient
// list — revisit if logs show them.
} catch {
const mb = Math.round(bytesRead / 1048576);
throw new Error(`Doodstream Upload-POST (${mb} MB an ${uploadUrl}): ${err && err.message ? err.message : err}`);
throw createTransportError(`Doodstream Upload-POST nach ${mb} MB fehlgeschlagen`, {
phase: 'upload-request',
endpoint: uploadUrl,
retryable: true,
transientNetwork: true
});
}
const statusCode = uploadRes.statusCode;
@@ -395,13 +430,22 @@ class DoodstreamUploader {
}
const resText = await uploadRes.body.text();
_debugLog(`Upload response body (first 500): ${resText.slice(0, 500)}`);
const uploadContentType = uploadRes.headers && uploadRes.headers['content-type'];
_debugLog(`Upload response: ${summarizeResponse(resText, uploadContentType)}`);
if (statusCode >= 400) {
let payload;
try { payload = JSON.parse(resText); } catch {}
const msg = payload && payload.msg ? payload.msg : resText.slice(0, 200);
throw new Error(`Doodstream Upload HTTP ${statusCode}: ${msg}`);
const msg = payload && payload.msg ? sanitizeRemoteText(payload.msg) : '';
throw createTransportError(`Doodstream Upload fehlgeschlagen${msg ? `: ${msg}` : ''}`, {
phase: 'upload-response',
endpoint: uploadUrl,
httpStatus: statusCode,
contentType: uploadContentType,
body: resText,
retryable: statusCode === 429 || statusCode >= 500,
transientNetwork: statusCode >= 500
});
}
return this._parseUploadResponse(resText);
@@ -411,10 +455,11 @@ class DoodstreamUploader {
* Follow a redirect URL from upload server and extract filecode
*/
async _handleUploadResult(url) {
_debugLog(`Following upload result URL: ${url}`);
_debugLog(`Following upload result URL: ${safeEndpoint(url) || 'unknown endpoint'}`);
const res = await this._fetch(url);
const html = await res.text();
_debugLog(`Result page (first 500): ${html.slice(0, 500)}`);
const contentType = res.headers && typeof res.headers.get === 'function' ? res.headers.get('content-type') : '';
_debugLog(`Result page: ${summarizeResponse(html, contentType)}`);
return this._parseUploadResponse(html);
}
@@ -458,12 +503,12 @@ class DoodstreamUploader {
// 3. Parse HTML form (XFileSharing two-step upload)
const hiddenFields = this._extractHiddenFields(resText);
_debugLog(`Hidden fields: ${JSON.stringify(hiddenFields)}`);
_debugLog(`Hidden fields: ${Object.keys(hiddenFields).join(',')}`);
// Check if filecode is already in hidden fields
const fnCode = hiddenFields.fn || hiddenFields.filecode || hiddenFields.file_code;
if (fnCode && fnCode.length >= 8) {
_debugLog(`Filecode from hidden field 'fn': ${fnCode}`);
_debugLog(`Filecode from hidden field 'fn': length ${fnCode.length}`);
// We still need to submit the form so doodstream registers the file
// But the filecode is the 'fn' value
}
@@ -474,7 +519,7 @@ class DoodstreamUploader {
// Ensure op=upload_result is set
if (!hiddenFields.op) hiddenFields.op = 'upload_result';
_debugLog(`Submitting upload_result to ${BASE_URL}/ with fields: ${JSON.stringify(hiddenFields)}`);
_debugLog(`Submitting upload_result fields: ${Object.keys(hiddenFields).join(',')}`);
const formData = new URLSearchParams(hiddenFields);
let followText = '';
try {
@@ -487,18 +532,23 @@ class DoodstreamUploader {
body: formData.toString()
});
followText = await followRes.text();
} catch (err) {
} catch {
// The file already uploaded to the CDN; this POST only registers it on
// doodstream's side. If it fails transiently (even after _fetch's own
// retries) but we already hold the filecode, the upload succeeded from
// the user's view — return it rather than discarding a done upload.
if (fnCode && fnCode.length >= 8) {
_debugLog(`upload_result submit failed (${err && err.message}); using fn ${fnCode}`);
_debugLog(`upload_result submit failed; using existing filecode length ${fnCode.length}`);
return this._buildResult(fnCode);
}
throw err;
throw createTransportError('Doodstream Upload: Ergebnis konnte nicht registriert werden', {
phase: 'upload-result-submit',
endpoint: BASE_URL,
retryable: true,
transientNetwork: true
});
}
_debugLog(`upload_result response (first 500): ${followText.slice(0, 500)}`);
_debugLog(`upload_result response: ${summarizeResponse(followText, '')}`);
// Try to find filecode in result page
const resultCode = this._findFilecodeInHtml(followText);
@@ -523,11 +573,17 @@ class DoodstreamUploader {
// download link being empty while the page structure is unchanged points
// at doodstream's backend, not at a parsing bug on our side.
const st = hiddenFields.st || '';
const fnInfo = fnCode ? `"${fnCode}"(len ${fnCode.length})` : 'fehlt/leer';
const node = this._lastUploadUrl || '?';
_debugLog(`No filecode. st=${st} fn=${fnInfo} node=${node} CDN-body=${(resText || '').slice(0, 400)}`);
const safeStatus = sanitizeRemoteText(st, 100);
const fnInfo = fnCode ? `vorhanden(len ${fnCode.length})` : 'fehlt/leer';
const node = safeEndpoint(this._lastUploadUrl) || 'unbekannt';
_debugLog(`No filecode. st=${safeStatus || '?'} fn=${fnInfo} node=${node} response=${summarizeResponse(resText, 'text/html')}`);
if (st && st !== 'OK') {
throw new Error(`Doodstream lehnt Datei ab (Server-Status: ${st}). CDN=${node}`);
throw createTransportError(`Doodstream lehnt Datei ab (Server-Status: ${safeStatus || 'unbekannt'})`, {
phase: 'upload-result',
endpoint: this._lastUploadUrl || BASE_URL,
contentType: 'text/html',
body: resText
});
}
// Empty form (no fn, no st) is a doodstream-side processing flake — same
// account + same file works on a later attempt. Tag it explicitly so the
@@ -536,15 +592,20 @@ class DoodstreamUploader {
// session and later batches hit `pre-job-swap-blocked` for no fault of
// the account). The flag is the primary signal; the message text is a
// belt-and-suspenders regex fallback in the classifier.
const emptyLinkErr = new Error(`Doodstream Upload: kein Filecode — Server gab leeren Link zurück (st=${st || '?'}, fn=${fnInfo}, CDN=${node}). CDN-Antwort: ${(resText || '').slice(0, 200)}`);
emptyLinkErr.hosterTransient = true;
throw emptyLinkErr;
throw createTransportError(`Doodstream Upload: kein Filecode (st=${safeStatus || '?'}, fn=${fnInfo}, CDN=${node})`, {
phase: 'upload-result',
endpoint: this._lastUploadUrl || BASE_URL,
contentType: 'text/html',
body: resText,
retryable: true,
hosterTransient: true
});
}
// 4. Fallback: follow form action as-is (for non-XFS forms)
const formAction = resText.match(/<form[^>]*action=['"]([^'"]+)['"]/i);
if (formAction) {
_debugLog(`Fallback: following form action ${formAction[1]}`);
_debugLog(`Fallback: following form action ${safeEndpoint(formAction[1]) || 'unknown endpoint'}`);
const formData = new URLSearchParams(hiddenFields);
const followRes = await this._fetch(formAction[1], {
method: 'POST',
@@ -555,7 +616,7 @@ class DoodstreamUploader {
body: formData.toString()
});
const followText = await followRes.text();
_debugLog(`Fallback response (first 500): ${followText.slice(0, 500)}`);
_debugLog(`Fallback response: ${summarizeResponse(followText, '')}`);
const fallbackCode = this._findFilecodeInHtml(followText);
if (fallbackCode) return this._buildResult(fallbackCode);
@@ -563,10 +624,23 @@ class DoodstreamUploader {
// Check if fn was in original hidden fields
if (fnCode && fnCode.length >= 8) return this._buildResult(fnCode);
throw new Error(`Doodstream Upload: Redirect-Antwort ungültig (${followText.slice(0, 150)})`);
throw createTransportError('Doodstream Upload: Redirect-Antwort ungültig', {
phase: 'upload-result',
endpoint: formAction[1],
body: followText,
hosterTransient: true,
retryable: true
});
}
throw new Error(`Doodstream Upload: Keine gültige Antwort (Body: ${resText.slice(0, 150)})`);
throw createTransportError('Doodstream Upload: Keine gültige Antwort', {
phase: 'upload-result',
endpoint: this._lastUploadUrl || BASE_URL,
contentType: /<\s*(?:!doctype|html|body|form|input)\b/i.test(resText) ? 'text/html' : 'text/plain',
body: resText,
hosterTransient: true,
retryable: true
});
}
/**
@@ -590,7 +664,15 @@ class DoodstreamUploader {
*/
_extractFromJson(payload) {
if (payload.status && Number(payload.status) !== 200 && payload.msg) {
throw new Error(`Doodstream Upload: ${payload.msg}`);
throw createTransportError(`Doodstream Upload: ${sanitizeRemoteText(payload.msg) || 'Antwort wurde abgelehnt'}`, {
phase: 'upload-result',
endpoint: this._lastUploadUrl || BASE_URL,
httpStatus: Number(payload.status),
contentType: 'application/json',
body: JSON.stringify(payload),
retryable: Number(payload.status) === 429 || Number(payload.status) >= 500,
transientNetwork: Number(payload.status) >= 500
});
}
let item = null;
@@ -602,15 +684,28 @@ class DoodstreamUploader {
}
if (!item) {
throw new Error(`Doodstream Upload fehlgeschlagen: ${payload.msg || JSON.stringify(payload).slice(0, 150)}`);
throw createTransportError('Doodstream Upload: Antwort enthielt kein Ergebnis', {
phase: 'upload-result',
endpoint: this._lastUploadUrl || BASE_URL,
contentType: 'application/json',
body: JSON.stringify(payload),
hosterTransient: true,
retryable: true
});
}
const fileCode = item.filecode || item.file_code || '';
return {
download_url: item.download_url || item.protected_dl || (fileCode ? `https://doodstream.com/d/${fileCode}` : null),
embed_url: item.protected_embed || (fileCode ? `https://doodstream.com/e/${fileCode}` : null),
file_code: fileCode
};
const fileCode = String(item.filecode || item.file_code || '').trim();
if (!fileCode) {
throw createTransportError('Doodstream Upload: Antwort enthielt keinen Filecode', {
phase: 'upload-result',
endpoint: this._lastUploadUrl || BASE_URL,
contentType: 'application/json',
body: JSON.stringify(payload),
hosterTransient: true,
retryable: true
});
}
return this._buildResult(fileCode);
}
_buildResult(fileCode) {
@@ -696,7 +791,7 @@ class DoodstreamUploader {
return key;
}
}
_debugLog(`api-key derive: ${candidates.length} candidate(s), none validated. settings html(2500)=${(html || '').slice(0, 2500)}`);
_debugLog(`api-key derive: ${candidates.length} candidate(s), none validated. response=${summarizeResponse(html, 'text/html')}`);
return null;
}
}
+90
View File
@@ -0,0 +1,90 @@
function normalizeContentType(value) {
const contentType = String(value || '').trim().slice(0, 120);
const parts = contentType.split(';').map(part => part.trim());
if (parts.length < 1 || parts.length > 2) return null;
const slashIndex = parts[0].indexOf('/');
if (slashIndex <= 0 || slashIndex === parts[0].length - 1) return null;
const tokenCharacters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789.+-_';
const validToken = token => Array.from(token).every(char => tokenCharacters.includes(char));
if (!validToken(parts[0].slice(0, slashIndex)) || !validToken(parts[0].slice(slashIndex + 1))) return null;
if (parts.length === 2) {
const charsetPrefix = 'charset=';
if (!parts[1].toLowerCase().startsWith(charsetPrefix)) return null;
const charset = parts[1].slice(charsetPrefix.length);
if (!charset || !validToken(charset)) return null;
}
return contentType;
}
function safeEndpoint(value) {
try {
const url = new URL(String(value || ''));
return `${url.hostname.toLowerCase()}${url.pathname}`;
} catch {
return null;
}
}
function safeEndpointHost(value) {
try {
return new URL(String(value || '')).hostname.toLowerCase();
} catch {
return null;
}
}
function responseKind(body, contentType) {
const text = String(body || '').trim();
if (!text) return 'empty';
const type = String(contentType || '').toLowerCase();
if (type.includes('json') || /^[\[{]/.test(text)) return 'json';
if (type.includes('html') || /<\s*(?:!doctype|html|body|form|input)\b/i.test(text)) return 'html';
return 'text';
}
function summarizeResponse(body, contentType) {
const text = String(body || '');
const kind = responseKind(text, contentType);
return `${kind} response (${Buffer.byteLength(text, 'utf8')} bytes)`;
}
function sanitizeRemoteText(value, limit = 180) {
let text = String(value || '').replace(/[\r\n\t]+/g, ' ').replace(/\s+/g, ' ').trim();
text = text.replace(/https?:\/\/[^\s"'<>]+/gi, (raw) => safeEndpoint(raw) || '[URL]');
text = text.replace(/\b(?:authorization|proxy-authorization|cookie|set-cookie)\s*[:=]\s*[^,]+/gi, '[redacted]');
text = text.replace(/((?:api[_-]?key|token|password|secret|session|sess[_-]?id|csrf)["']?\s*[:=]\s*["']?)[^\s,;"'<>]+/gi, '$1[redacted]');
text = text.replace(/(<(?:input|textarea)[^>]*(?:name|id)=["'][^"']*(?:key|token|password|secret|session|sess|csrf)[^"']*["'][^>]*(?:value=["']))[^"']*(["'])/gi, '$1[redacted]$2');
text = text.replace(/\b[A-Za-z0-9_-]{20,}\b/g, '[redacted]');
return text.slice(0, limit);
}
function createTransportError(message, options = {}) {
const httpStatus = Number(options.httpStatus);
const hasHttpStatus = Number.isInteger(httpStatus) && httpStatus >= 100 && httpStatus <= 599;
const contentType = normalizeContentType(options.contentType);
const endpointHost = safeEndpointHost(options.endpoint);
const kind = responseKind(options.body, contentType);
const suffix = hasHttpStatus ? ` (HTTP ${httpStatus})` : '';
const error = new Error(`${sanitizeRemoteText(message, 220)}${suffix}`);
error.diagnostic = {
phase: String(options.phase || 'transport').slice(0, 80),
http: hasHttpStatus ? httpStatus : null,
contentType,
safeEndpointHost: endpointHost,
responseKind: kind,
retryable: options.retryable === true,
payloadSnippet: summarizeResponse(options.body, contentType)
};
if (options.transientNetwork === true) error.transientNetwork = true;
if (options.hosterTransient === true) error.hosterTransient = true;
if (options.accountError === true) error.accountError = true;
if (options.fileRejected === true) error.fileRejected = true;
return error;
}
module.exports = {
createTransportError,
safeEndpoint,
sanitizeRemoteText,
summarizeResponse
};
+300 -124
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 UPLOAD_TIMEOUT = 1800000; // 30 minutes
const API_TIMEOUT = 45000; // 45 seconds
@@ -172,10 +173,11 @@ function parseDoodstreamResult(payload) {
item = result;
}
const fileCode = item.filecode || item.file_code || null;
return {
download_url: item.download_url || item.protected_dl || null,
embed_url: item.protected_embed || null,
file_code: item.filecode || item.file_code || null
download_url: fileCode ? `https://doodstream.com/d/${fileCode}` : null,
embed_url: fileCode ? `https://doodstream.com/e/${fileCode}` : null,
file_code: fileCode
};
}
@@ -234,7 +236,7 @@ function parseByseResult(payload) {
// wall, so we must rotate. File-specific rejections (Duplicate, wrong
// format, too small/large) ARE per-file and rotation is pointless.
const accountLevel = /(not enough (disk )?(space|storage)|insufficient (disk )?space|disk (space )?full|storage (exhausted|full|voll|limit)|quota (exceeded|voll|überschritten)|account (full|voll|suspended|banned))/i.test(perFileError);
const err = new Error(`Byse lehnte Datei ab: ${perFileError}`);
const err = new Error(`Byse lehnte Datei ab: ${sanitizeRemoteText(perFileError)}`);
if (accountLevel) {
err.accountError = true;
} else {
@@ -308,32 +310,64 @@ function createUploadBody(filePath, formFields, onProgress, throttle, signal) {
// --- API helper using built-in fetch (follows redirects automatically) ---
async function apiGet(url, signal) {
async function apiGet(url, signal, hosterName) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), API_TIMEOUT);
const onAbort = () => controller.abort();
if (signal) signal.addEventListener('abort', onAbort);
try {
const res = await fetch(url, {
method: 'GET',
signal: controller.signal,
redirect: 'follow'
});
let res;
try {
res = await fetch(url, {
method: 'GET',
signal: controller.signal,
redirect: 'follow'
});
} catch (err) {
if (signal && signal.aborted) throw err;
throw createTransportError(`${hosterName}: Upload-Server-Abfrage fehlgeschlagen`, {
phase: 'upload-server',
endpoint: url,
retryable: true,
transientNetwork: true
});
}
const text = await res.text();
const contentType = res.headers && typeof res.headers.get === 'function'
? res.headers.get('content-type')
: null;
let data;
try {
data = JSON.parse(text);
} catch {
const err = new Error(`API-Antwort war kein JSON (HTTP ${res.status}): ${(text || '').slice(0, 200)}`);
if (res.status >= 500) err.transientNetwork = true;
throw err;
throw createTransportError(`${hosterName}: Upload-Server-Antwort war kein JSON`, {
phase: 'upload-server',
endpoint: url,
httpStatus: res.status,
contentType,
body: text,
retryable: res.status >= 500,
transientNetwork: res.status >= 500
});
}
if (data.status && [401, 403, 429, 500].includes(data.status)) {
const err = new Error(data.msg || data.message || JSON.stringify(data));
if (data.status === 500) err.transientNetwork = true;
throw err;
const apiStatus = Number(data && data.status);
const effectiveStatus = res.status < 200 || res.status >= 300
? res.status
: (apiStatus >= 400 ? apiStatus : null);
if (effectiveStatus) {
const retryable = effectiveStatus === 429 || effectiveStatus >= 500;
throw createTransportError(`${hosterName}: Upload-Server-Abfrage wurde abgelehnt`, {
phase: 'upload-server',
endpoint: url,
httpStatus: effectiveStatus,
contentType,
body: text,
retryable,
transientNetwork: effectiveStatus >= 500,
accountError: effectiveStatus === 401 || effectiveStatus === 403
});
}
return data;
} finally {
@@ -347,12 +381,14 @@ async function apiGet(url, signal) {
async function getUploadServer(hosterName, hosterConfig, apiKey, signal) {
let lastMessage = '';
let lastTransient = false;
let lastError = null;
for (let attempt = 1; attempt <= SERVER_RETRY_ATTEMPTS; attempt++) {
for (const endpoint of hosterConfig.serverEndpoints) {
const url = `${hosterConfig.apiBase}${endpoint}?key=${encodeURIComponent(apiKey)}`;
try {
const data = await apiGet(url, signal);
const data = await apiGet(url, signal, hosterName);
lastError = null;
const uploadUrl = extractUploadServerUrl(data, hosterConfig.apiBase);
if (uploadUrl) {
LAST_UPLOAD_SERVERS.set(hosterName, uploadUrl);
@@ -365,12 +401,16 @@ async function getUploadServer(hosterName, hosterConfig, apiKey, signal) {
if (apiMessage) lastMessage = apiMessage;
} catch (err) {
if (err.name === 'AbortError') throw err;
lastError = err;
if (err.message) lastMessage = err.message;
if (err.transientNetwork === true) lastTransient = true;
}
}
if (attempt < SERVER_RETRY_ATTEMPTS && shouldRetryServerLookup(lastMessage)) {
const retryable = lastError && lastError.diagnostic
? lastError.diagnostic.retryable === true
: shouldRetryServerLookup(lastMessage);
if (attempt < SERVER_RETRY_ATTEMPTS && retryable) {
await sleep(SERVER_RETRY_DELAY_MS, signal);
continue;
}
@@ -379,11 +419,14 @@ async function getUploadServer(hosterName, hosterConfig, apiKey, signal) {
}
const cachedServer = LAST_UPLOAD_SERVERS.get(hosterName);
if (cachedServer && shouldRetryServerLookup(lastMessage)) {
const retryable = lastError && lastError.diagnostic
? lastError.diagnostic.retryable === true
: shouldRetryServerLookup(lastMessage);
if (cachedServer && retryable) {
return cachedServer;
}
if (shouldRetryServerLookup(lastMessage) && Array.isArray(hosterConfig.fallbackUploadServers)) {
if (retryable && Array.isArray(hosterConfig.fallbackUploadServers)) {
for (const fallback of hosterConfig.fallbackUploadServers) {
const normalized = normalizeAbsoluteUrl(fallback, hosterConfig.apiBase);
if (normalized) {
@@ -394,43 +437,122 @@ async function getUploadServer(hosterName, hosterConfig, apiKey, signal) {
}
if (lastMessage) {
const e = new Error(`Kein Upload-Server erhalten: ${lastMessage}`);
// "no servers available" / busy / try-again is a transient hoster-side
// condition, not an account fault — tag it so the account isn't blacklisted.
// Genuine auth failures (invalid key / unauthorized / forbidden) make
// shouldRetryServerLookup return false and stay classified as account errors.
if (shouldRetryServerLookup(lastMessage)) e.hosterTransient = true;
const e = lastError || createTransportError(`Kein Upload-Server für ${hosterName} erhalten`, {
phase: 'upload-server',
endpoint: hosterConfig.apiBase,
retryable
});
if (retryable) e.hosterTransient = true;
if (lastTransient) e.transientNetwork = true;
throw e;
}
throw new Error('Kein Upload-Server erhalten. API-Key prüfen.');
throw createTransportError(`Kein Upload-Server für ${hosterName} erhalten`, {
phase: 'upload-server',
endpoint: hosterConfig.apiBase
});
}
async function _fetchByseFileList(apiKey, signal) {
async function _requestFileList(url, signal, phase, hosterName) {
let response;
try {
response = await request(url, {
method: 'GET', signal,
headers: { 'Accept': 'application/json', 'User-Agent': 'multi-hoster-uploader/1.1' },
headersTimeout: 30_000, bodyTimeout: 30_000
});
} catch (err) {
if (signal && signal.aborted) throw err;
throw createTransportError(`${hosterName}: Dateiliste konnte nicht geladen werden`, {
phase,
endpoint: url,
retryable: true,
transientNetwork: true
});
}
const contentType = response.headers && response.headers['content-type'];
let text;
try {
text = await response.body.text();
} catch (err) {
if (signal && signal.aborted) throw err;
throw createTransportError(`${hosterName}: Dateiliste konnte nicht gelesen werden`, {
phase,
endpoint: url,
httpStatus: response.statusCode,
contentType,
retryable: true,
transientNetwork: true
});
}
if (response.statusCode < 200 || response.statusCode >= 300) {
const retryable = response.statusCode === 429 || response.statusCode >= 500;
throw createTransportError(`${hosterName}: Dateiliste konnte nicht geladen werden`, {
phase,
endpoint: url,
httpStatus: response.statusCode,
contentType,
body: text,
retryable,
transientNetwork: response.statusCode >= 500
});
}
let data;
try {
data = JSON.parse(text);
} catch {
throw createTransportError(`${hosterName}: Dateiliste war kein JSON`, {
phase,
endpoint: url,
httpStatus: response.statusCode,
contentType,
body: text
});
}
if (!data || typeof data !== 'object') {
throw createTransportError(`${hosterName}: Dateiliste hatte ein ungültiges Format`, {
phase,
endpoint: url,
httpStatus: response.statusCode,
contentType,
body: text
});
}
const apiStatus = Number(data && data.status);
if (apiStatus >= 400) {
const retryable = apiStatus === 429 || apiStatus >= 500;
throw createTransportError(`${hosterName}: Dateiliste wurde abgelehnt`, {
phase,
endpoint: url,
httpStatus: apiStatus,
contentType,
body: text,
retryable,
transientNetwork: apiStatus >= 500
});
}
return data;
}
async function _fetchByseFileList(apiKey, signal, phase = 'recovery-poll') {
// Byse's file-list endpoint. Returns up to 100 most-recent files — enough
// to match the upload we just did against what the server has. The API
// shape is typical XFS: { status, msg, result: { files: [...] } } or
// { status, msg, files: [...] }.
const url = `https://api.byse.sx/file/list?key=${encodeURIComponent(apiKey)}&per_page=100&sort=date&order=desc`;
try {
const { body, statusCode } = await request(url, {
method: 'GET', signal,
headers: { 'Accept': 'application/json', 'User-Agent': 'multi-hoster-uploader/1.1' },
headersTimeout: 30_000, bodyTimeout: 30_000
});
const text = await body.text();
if (statusCode < 200 || statusCode >= 300) return [];
const data = JSON.parse(text);
const src = Array.isArray(data.files) ? data.files
: (data.result && Array.isArray(data.result.files) ? data.result.files
: (Array.isArray(data.result) ? data.result : []));
return src.map(f => ({
file_code: String(f.file_code || f.filecode || '').trim(),
file_name: String(f.title || f.name || f.file_name || '').trim()
})).filter(f => f.file_code);
} catch {
return [];
}
const data = await _requestFileList(url, signal, phase, 'Byse');
const src = Array.isArray(data.files) ? data.files
: (data.result && Array.isArray(data.result.files) ? data.result.files
: (Array.isArray(data.result) ? data.result : []));
return src.map(f => ({
file_code: String(f.file_code || f.filecode || '').trim(),
file_name: String(f.title || f.name || f.file_name || '').trim()
})).filter(f => f.file_code);
}
function _normalizeFileTitle(s) {
@@ -438,6 +560,7 @@ function _normalizeFileTitle(s) {
}
async function _resolveByseUploadByName(apiKey, fileName, baselineCodes, signal) {
if (!(baselineCodes instanceof Set)) return null;
const expected = _normalizeFileTitle(fileName);
const POLL_ATTEMPTS = 15;
const POLL_DELAY_MS = 2000;
@@ -450,8 +573,10 @@ async function _resolveByseUploadByName(apiKey, fileName, baselineCodes, signal)
// poller could claim job B's newly appeared file and return the wrong
// URL. At the cost of a few false-negatives when byse mangles the
// filename beyond our normalizer, correctness for parallel uploads wins.
const match = newFiles.find(f => _normalizeFileTitle(f.file_name) === expected);
if (match) {
const matches = newFiles.filter(f => _normalizeFileTitle(f.file_name) === expected);
if (matches.length > 1) return null;
if (matches.length === 1) {
const match = matches[0];
return {
download_url: `https://byse.sx/d/${match.file_code}`,
embed_url: `https://byse.sx/e/${match.file_code}`,
@@ -469,34 +594,24 @@ async function _resolveByseUploadByName(apiKey, fileName, baselineCodes, signal)
return null;
}
async function _fetchDoodstreamFileList(apiKey, signal) {
async function _fetchDoodstreamFileList(apiKey, signal, phase = 'recovery-poll') {
// doodapi.co file list: { msg, status:200, result: { files: [{ file_code, title, uploaded, ... }] } }
// sort=created&order=desc forces newest-first — VERIFIED against a real 90k-file
// account, where a single page without it could miss a just-uploaded file. The
// recovery only needs the most recent uploads, so page 1 newest-first suffices.
const url = `https://doodapi.co/api/file/list?key=${encodeURIComponent(apiKey)}&per_page=200&sort=created&order=desc`;
try {
const { body, statusCode } = await request(url, {
method: 'GET', signal,
headers: { 'Accept': 'application/json', 'User-Agent': 'multi-hoster-uploader/1.1' },
headersTimeout: 30_000, bodyTimeout: 30_000
});
const text = await body.text();
if (statusCode < 200 || statusCode >= 300) return [];
const data = JSON.parse(text);
const files = data && data.result && Array.isArray(data.result.files) ? data.result.files : [];
return files.map(f => ({
file_code: String(f.file_code || f.filecode || '').trim(),
file_name: String(f.title || f.file_name || f.name || '').trim()
})).filter(f => f.file_code);
} catch {
return [];
}
const data = await _requestFileList(url, signal, phase, 'Doodstream');
const files = data && data.result && Array.isArray(data.result.files) ? data.result.files : [];
return files.map(f => ({
file_code: String(f.file_code || f.filecode || '').trim(),
file_name: String(f.title || f.file_name || f.name || '').trim()
})).filter(f => f.file_code);
}
const DOODSTREAM_POLL = { attempts: 12, delayMs: 2500 }; // test-tunable via __test
async function _resolveDoodstreamUploadByName(apiKey, fileName, baselineCodes, signal) {
if (!(baselineCodes instanceof Set)) return null;
// Same recovery byse uses: the upload POST returned no filecode, but the file
// may register in the account a little later. Poll the list for a NEW file
// whose normalized title matches what we uploaded. Exact-name match only
@@ -509,8 +624,10 @@ async function _resolveDoodstreamUploadByName(apiKey, fileName, baselineCodes, s
if (signal && signal.aborted) return null;
const list = await _fetchDoodstreamFileList(apiKey, signal);
const fresh = list.filter(f => !baselineCodes.has(f.file_code));
const match = fresh.find(f => _normalizeFileTitle(f.file_name) === expected);
if (match) {
const matches = fresh.filter(f => _normalizeFileTitle(f.file_name) === expected);
if (matches.length > 1) return null;
if (matches.length === 1) {
const match = matches[0];
return {
download_url: `https://doodstream.com/d/${match.file_code}`,
embed_url: `https://doodstream.com/e/${match.file_code}`,
@@ -533,21 +650,33 @@ async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, thro
if (!config) throw new Error(`Unbekannter Hoster: ${hosterName}`);
let byseBaseline = null;
let byseBaselineError = null;
if (hosterName === 'byse.sx') {
if (opts && opts.byseBaseline instanceof Set) {
byseBaseline = opts.byseBaseline;
} else {
const baseline = await _fetchByseFileList(apiKey, signal);
byseBaseline = new Set(baseline.map(f => f.file_code));
try {
const baseline = await _fetchByseFileList(apiKey, signal, 'recovery-baseline');
byseBaseline = new Set(baseline.map(f => f.file_code));
} catch (err) {
if (signal && signal.aborted) throw err;
byseBaselineError = err;
}
}
}
let doodBaseline = null;
let doodBaselineError = null;
if (hosterName === 'doodstream.com') {
if (opts && opts.doodBaseline instanceof Set) {
doodBaseline = opts.doodBaseline;
} else {
const baseline = await _fetchDoodstreamFileList(apiKey, signal);
doodBaseline = new Set(baseline.map(f => f.file_code));
try {
const baseline = await _fetchDoodstreamFileList(apiKey, signal, 'recovery-baseline');
doodBaseline = new Set(baseline.map(f => f.file_code));
} catch (err) {
if (signal && signal.aborted) throw err;
doodBaselineError = err;
}
}
}
@@ -560,31 +689,47 @@ async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, thro
const { iterable, boundary, totalSize } = createUploadBody(filePath, formFields, onProgress, throttle, signal);
const { body, statusCode, headers } = await request(targetUrl, {
method: 'POST',
body: iterable,
signal,
headers: {
'Content-Type': `multipart/form-data; boundary=${boundary}`,
'Content-Length': String(totalSize),
'Accept': 'application/json, text/plain;q=0.9, */*;q=0.8',
'User-Agent': 'multi-hoster-uploader/1.1'
},
headersTimeout: UPLOAD_TIMEOUT,
bodyTimeout: UPLOAD_TIMEOUT
});
let uploadResponse;
try {
uploadResponse = await request(targetUrl, {
method: 'POST',
body: iterable,
signal,
headers: {
'Content-Type': `multipart/form-data; boundary=${boundary}`,
'Content-Length': String(totalSize),
'Accept': 'application/json, text/plain;q=0.9, */*;q=0.8',
'User-Agent': 'multi-hoster-uploader/1.1'
},
headersTimeout: UPLOAD_TIMEOUT,
bodyTimeout: UPLOAD_TIMEOUT
});
} catch (err) {
if (signal && signal.aborted) throw err;
throw createTransportError(`Upload zu ${hosterName} konnte nicht übertragen werden`, {
phase: 'upload-request',
endpoint: targetUrl,
retryable: true,
transientNetwork: true
});
}
const { body, statusCode, headers } = uploadResponse;
const rawBody = await body.text();
let payload = null;
try {
payload = rawBody ? JSON.parse(rawBody) : {};
} catch {
const snippet = rawBody ? rawBody.slice(0, 240).replace(/\s+/g, ' ').trim() : '';
const err = new Error(
`Upload-Antwort von ${hosterName} war kein JSON (HTTP ${statusCode}${snippet ? `): ${snippet}` : ')'}`
);
if (statusCode >= 500) err.transientNetwork = true;
throw err;
throw createTransportError(`Upload-Antwort von ${hosterName} war kein JSON`, {
phase: 'upload-response',
endpoint: targetUrl,
httpStatus: statusCode,
contentType: headers && headers['content-type'],
body: rawBody,
retryable: statusCode >= 500,
transientNetwork: statusCode >= 500
});
}
// Normalize valid-but-not-object JSON (JSON.parse('null') → null;
// JSON.parse('"foo"') → string; JSON.parse('[1]') → array). Without this
@@ -598,19 +743,27 @@ async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, thro
}
if (statusCode < 200 || statusCode >= 300) {
const err = new Error(
payload.msg
|| payload.message
|| `Upload fehlgeschlagen (HTTP ${statusCode}${headers?.['content-type'] ? `, ${headers['content-type']}` : ''})`
);
if (statusCode >= 500) err.transientNetwork = true;
throw err;
throw createTransportError(`Upload zu ${hosterName} fehlgeschlagen`, {
phase: 'upload-response',
endpoint: targetUrl,
httpStatus: statusCode,
contentType: headers && headers['content-type'],
body: rawBody,
retryable: statusCode === 429 || statusCode >= 500,
transientNetwork: statusCode >= 500
});
}
if (payload.status && [401, 403, 429, 500].includes(payload.status)) {
const err = new Error(payload.msg || payload.message || JSON.stringify(payload));
if (payload.status === 500) err.transientNetwork = true;
throw err;
throw createTransportError(`Upload zu ${hosterName} wurde abgelehnt`, {
phase: 'upload-response',
endpoint: targetUrl,
httpStatus: Number(payload.status),
contentType: headers && headers['content-type'],
body: rawBody,
retryable: Number(payload.status) === 429 || Number(payload.status) >= 500,
transientNetwork: Number(payload.status) >= 500
});
}
let result = null;
@@ -619,15 +772,13 @@ async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, thro
result = config.parseResult(payload);
} catch (err) {
if (err && typeof err === 'object' && !err.diagnostic) {
try {
err.diagnostic = {
hoster: hosterName,
http: statusCode,
contentType: (headers && headers['content-type']) || null,
payloadSnippet: JSON.stringify(payload).slice(0, 1000),
uploadUrl: targetUrl
};
} catch { /* JSON cycle — skip diagnostic */ }
err.diagnostic = createTransportError(`Upload zu ${hosterName} konnte nicht ausgewertet werden`, {
phase: 'upload-result',
endpoint: targetUrl,
httpStatus: statusCode,
contentType: headers && headers['content-type'],
body: rawBody
}).diagnostic;
}
parseErr = err;
}
@@ -670,20 +821,35 @@ async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, thro
if (polled) return polled;
}
if (hosterName === 'byse.sx' && byseBaselineError && !explicitlyRejected) {
byseBaselineError.hosterTransient = true;
throw byseBaselineError;
}
if (hosterName === 'doodstream.com' && doodBaselineError && !explicitlyRejected) {
doodBaselineError.hosterTransient = true;
throw doodBaselineError;
}
if (parseErr) throw parseErr;
if (payload.success === false) {
throw new Error(payload.msg || payload.message || `Upload zu ${hosterName} wurde vom Server abgelehnt.`);
throw createTransportError(`Upload zu ${hosterName} wurde vom Server abgelehnt`, {
phase: 'upload-result',
endpoint: targetUrl,
httpStatus: statusCode,
contentType: headers && headers['content-type'],
body: rawBody
});
}
// Avoid throwing a bare "OK" / "SUCCESS" as the error message — that happens
// when the server says "msg: OK" but ships no file_code anywhere we know
// about, typically an API change. Surface the full (trimmed) payload so
// future logs actually show what the server returned.
// about, typically an API change. Surface safe structured response metadata
// so future logs show what kind of response the server returned.
const msg = String(payload.msg || payload.message || '').trim();
const isOkishNoPayload = /^(ok|success|done|accepted)$/i.test(msg);
if (isOkishNoPayload || !msg) {
const snippet = JSON.stringify(payload).slice(0, 400);
// 2xx with no filecode: the hoster accepted the upload (bytes sent, status
// OK) but returned no usable link. For doodstream this is the API-path
// analog of the web empty-form — the backend file-registration timing out
@@ -691,23 +857,33 @@ async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, thro
// so tag it hosterTransient: the upload-manager then fails this file WITHOUT
// blacklisting the account (same protection the web path got in 3.3.29) and
// the account stays usable for the next retry/batch.
const err = new Error(
`Upload zu ${hosterName} lieferte keine file_code-Antwort (Payload: ${snippet})`
);
err.hosterTransient = true;
throw err;
throw createTransportError(`Upload zu ${hosterName} lieferte keine file_code-Antwort`, {
phase: 'upload-result',
endpoint: targetUrl,
httpStatus: statusCode,
contentType: headers && headers['content-type'],
body: rawBody,
retryable: true,
hosterTransient: true
});
}
throw new Error(msg);
throw createTransportError(`Upload zu ${hosterName} wurde abgelehnt`, {
phase: 'upload-result',
endpoint: targetUrl,
httpStatus: statusCode,
contentType: headers && headers['content-type'],
body: rawBody
});
}
async function prefetchBaseline(hosterName, apiKey, signal) {
try {
if (hosterName === 'byse.sx') {
const baseline = await _fetchByseFileList(apiKey, signal);
const baseline = await _fetchByseFileList(apiKey, signal, 'recovery-baseline');
return new Set(baseline.map(f => f.file_code));
}
if (hosterName === 'doodstream.com') {
const baseline = await _fetchDoodstreamFileList(apiKey, signal);
const baseline = await _fetchDoodstreamFileList(apiKey, signal, 'recovery-baseline');
return new Set(baseline.map(f => f.file_code));
}
} catch { /* leave caller to fall back to per-job fetch */ }
+14 -27
View File
@@ -11,13 +11,13 @@ const HOSTER_RESULT_DOMAINS = {
'doodstream.com': ['doodstream.com', 'dood.to', 'dood.la', 'dood.so', 'dsvplay.com']
};
function isExpectedHostUrl(value, expectedHost) {
function isExpectedHostUrl(value, expectedHost, allowHttp = false) {
if (typeof value !== 'string' || value.trim() === '') return false;
try {
const url = new URL(value);
const hostname = url.hostname.toLowerCase();
const acceptedDomains = HOSTER_RESULT_DOMAINS[expectedHost] || [expectedHost];
return (url.protocol === 'http:' || url.protocol === 'https:')
return (url.protocol === 'https:' || (allowHttp && url.protocol === 'http:'))
&& acceptedDomains.some(domain => hostname === domain || hostname.endsWith(`.${domain}`));
} catch {
return false;
@@ -32,27 +32,6 @@ function getUrlHost(value) {
}
}
function normalizeDoodstreamUrl(value) {
if (typeof value !== 'string' || value.trim() === '') return value;
try {
const url = new URL(value);
const hostname = url.hostname.toLowerCase();
if (hostname !== 'doodstream.com' && HOSTER_RESULT_DOMAINS['doodstream.com'].includes(hostname)) {
url.hostname = 'doodstream.com';
return url.toString();
}
} catch {}
return value;
}
function normalizeConfirmedResult(result, hoster) {
if (hoster !== 'doodstream.com') return result;
const downloadUrl = normalizeDoodstreamUrl(result.download_url);
const embedUrl = normalizeDoodstreamUrl(result.embed_url);
if (downloadUrl === result.download_url && embedUrl === result.embed_url) return result;
return { ...result, download_url: downloadUrl, embed_url: embedUrl };
}
function assertUploadConfirmation(result, hoster) {
const expectedHost = typeof hoster === 'string' ? hoster.trim().toLowerCase() : '';
const fileCode = typeof result?.file_code === 'string' ? result.file_code.trim() : '';
@@ -61,10 +40,18 @@ function assertUploadConfirmation(result, hoster) {
&& value !== undefined
&& !(typeof value === 'string' && value.trim() === '')
));
if (SUPPORTED_HOSTERS.has(expectedHost)
&& FILE_CODE_PATTERN.test(fileCode)
&& urls.every(value => isExpectedHostUrl(value, expectedHost))) {
return normalizeConfirmedResult(result, expectedHost);
if (SUPPORTED_HOSTERS.has(expectedHost) && FILE_CODE_PATTERN.test(fileCode)) {
if (expectedHost === 'doodstream.com' && urls.every(value => isExpectedHostUrl(value, expectedHost, true))) {
return {
...result,
file_code: fileCode,
download_url: `https://doodstream.com/d/${fileCode}`,
embed_url: `https://doodstream.com/e/${fileCode}`
};
}
if (urls.length > 0 && urls.every(value => isExpectedHostUrl(value, expectedHost))) {
return fileCode === result.file_code ? result : { ...result, file_code: fileCode };
}
}
const error = new Error(`Upload zu ${hoster || 'unbekanntem Hoster'} wurde nicht bestätigt`);
error.diagnostic = {
+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 };
+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;