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:
+300
-124
@@ -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 */ }
|
||||
|
||||
Reference in New Issue
Block a user