Two bugs visible in the user's rotation log:
1. 'error=OK' for byse.sx — the server returned a payload with
msg='OK' and no file_code anywhere we recognized. Our generic
uploadFile threw the bare 'OK' as the error message, which is
useless and misleading. Now when we see an ok-ish msg without
the expected file_code we throw a descriptive error that
includes the first ~400 bytes of the payload so the next time
it happens we can see what's actually being returned (API
changed, new field name, etc.).
2. 'getaddrinfo ENOTFOUND s1055.filemoon' was marking accounts as
permanently failed, blacklisting BOTH byse accounts within the
same batch even though neither was the actual problem — filemoon
(byse's storage backend) briefly had a DNS blip. Added
_isTransientNetworkError() covering DNS/ECONNRESET/ETIMEDOUT/etc.
When all retries on an account exhaust with a transient error,
we now fail just that file and emit 'skip-rotation-transient'
instead of adding the account to _failedAccounts. Other files
in the same batch still get a fresh try on the same account.
439 lines
14 KiB
JavaScript
439 lines
14 KiB
JavaScript
const fs = require('fs');
|
|
const path = require('path');
|
|
const crypto = require('crypto');
|
|
const { request } = require('undici');
|
|
|
|
const UPLOAD_TIMEOUT = 1800000; // 30 minutes
|
|
const API_TIMEOUT = 45000; // 45 seconds
|
|
const SERVER_RETRY_ATTEMPTS = 6;
|
|
const SERVER_RETRY_DELAY_MS = 2500;
|
|
const LAST_UPLOAD_SERVERS = new Map();
|
|
|
|
function appendRawQuery(url, rawQuery) {
|
|
const parsed = new URL(url);
|
|
const cleanQuery = String(rawQuery || '').trim().replace(/^\?+/, '');
|
|
if (!cleanQuery) return parsed.toString();
|
|
|
|
if (parsed.search && parsed.search.length > 1) {
|
|
parsed.search = `${parsed.search.slice(1)}&${cleanQuery}`;
|
|
} else {
|
|
parsed.search = cleanQuery;
|
|
}
|
|
|
|
return parsed.toString();
|
|
}
|
|
|
|
function appendKeyParam(url, key) {
|
|
const parsed = new URL(url);
|
|
parsed.searchParams.set('key', key);
|
|
return parsed.toString();
|
|
}
|
|
|
|
// Hoster definitions - based on official API docs
|
|
const HOSTER_CONFIGS = {
|
|
'doodstream.com': {
|
|
apiBase: 'https://doodapi.co',
|
|
serverEndpoints: ['/api/upload/server'],
|
|
fallbackUploadServers: ['https://tr1128ve.cloudatacdn.com/upload/01'],
|
|
buildUploadUrl: (url, key) => appendRawQuery(url, key),
|
|
formFields: (key) => ({ api_key: key }),
|
|
parseResult: parseDoodstreamResult
|
|
},
|
|
'voe.sx': {
|
|
apiBase: 'https://voe.sx',
|
|
serverEndpoints: ['/api/upload/server', '/api/v1/upload/server'],
|
|
buildUploadUrl: (url, key) => appendKeyParam(url, key),
|
|
formFields: () => ({}),
|
|
parseResult: parseVoeResult
|
|
},
|
|
'byse.sx': {
|
|
apiBase: 'https://api.byse.sx',
|
|
serverEndpoints: ['/upload/server'],
|
|
buildUploadUrl: (url, key) => appendKeyParam(url, key),
|
|
formFields: (key) => ({ key }),
|
|
parseResult: parseByseResult
|
|
}
|
|
};
|
|
|
|
function normalizeAbsoluteUrl(raw, apiBase) {
|
|
if (typeof raw !== 'string') return null;
|
|
const trimmed = raw.trim();
|
|
if (!trimmed || /^\[object\s+Object\]$/i.test(trimmed)) return null;
|
|
|
|
let candidate = trimmed;
|
|
if (candidate.startsWith('//')) {
|
|
candidate = `https:${candidate}`;
|
|
} else if (candidate.startsWith('/')) {
|
|
try {
|
|
candidate = new URL(candidate, apiBase).href;
|
|
} catch {
|
|
return null;
|
|
}
|
|
} else if (!/^[a-z][a-z\d+.-]*:\/\//i.test(candidate)) {
|
|
candidate = `https://${candidate.replace(/^\/+/, '')}`;
|
|
}
|
|
|
|
try {
|
|
const parsed = new URL(candidate);
|
|
if (!['http:', 'https:'].includes(parsed.protocol)) return null;
|
|
return parsed.href;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function collectUploadUrlCandidates(value, out = []) {
|
|
if (typeof value === 'string') {
|
|
out.push(value);
|
|
return out;
|
|
}
|
|
|
|
if (Array.isArray(value)) {
|
|
for (const entry of value) collectUploadUrlCandidates(entry, out);
|
|
return out;
|
|
}
|
|
|
|
if (value && typeof value === 'object') {
|
|
const preferredKeys = ['upload_url', 'uploadUrl', 'url', 'server', 'srv', 'result'];
|
|
for (const key of preferredKeys) {
|
|
if (Object.prototype.hasOwnProperty.call(value, key)) {
|
|
collectUploadUrlCandidates(value[key], out);
|
|
}
|
|
}
|
|
|
|
for (const nested of Object.values(value)) {
|
|
if (typeof nested === 'string') out.push(nested);
|
|
}
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
function extractUploadServerUrl(payload, apiBase) {
|
|
const source = payload && Object.prototype.hasOwnProperty.call(payload, 'result')
|
|
? payload.result
|
|
: payload;
|
|
|
|
const candidates = collectUploadUrlCandidates(source, []);
|
|
for (const candidate of candidates) {
|
|
const normalized = normalizeAbsoluteUrl(candidate, apiBase);
|
|
if (normalized) return normalized;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function shouldRetryServerLookup(message) {
|
|
const msg = String(message || '').toLowerCase();
|
|
if (!msg) return true;
|
|
if (msg.includes('invalid') && msg.includes('key')) return false;
|
|
if (msg.includes('unauthorized') || msg.includes('forbidden')) return false;
|
|
if (msg.includes('no servers available')) return true;
|
|
if (msg.includes('temporar') || msg.includes('busy') || msg.includes('try again')) return true;
|
|
return true;
|
|
}
|
|
|
|
function sleep(ms, signal) {
|
|
return new Promise((resolve, reject) => {
|
|
const timer = setTimeout(() => {
|
|
if (signal) signal.removeEventListener('abort', onAbort);
|
|
resolve();
|
|
}, ms);
|
|
|
|
function onAbort() {
|
|
clearTimeout(timer);
|
|
if (signal) signal.removeEventListener('abort', onAbort);
|
|
const err = new Error('Aborted');
|
|
err.name = 'AbortError';
|
|
reject(err);
|
|
}
|
|
|
|
if (signal) {
|
|
if (signal.aborted) return onAbort();
|
|
signal.addEventListener('abort', onAbort);
|
|
}
|
|
});
|
|
}
|
|
|
|
// --- Result parsers ---
|
|
|
|
// Doodstream: { result: [{ download_url, protected_embed, filecode, protected_dl }] }
|
|
function parseDoodstreamResult(payload) {
|
|
let item = {};
|
|
const result = payload.result;
|
|
if (Array.isArray(result) && result.length > 0) {
|
|
item = result[0];
|
|
} else if (result && typeof result === 'object') {
|
|
item = result;
|
|
}
|
|
|
|
return {
|
|
download_url: item.download_url || item.protected_dl || null,
|
|
embed_url: item.protected_embed || null,
|
|
file_code: item.filecode || item.file_code || null
|
|
};
|
|
}
|
|
|
|
// VOE: { file: { file_code } }
|
|
function parseVoeResult(payload) {
|
|
const source = payload && typeof payload === 'object' && payload.result && typeof payload.result === 'object'
|
|
? payload.result
|
|
: payload;
|
|
const file = source && typeof source.file === 'object' ? source.file : null;
|
|
const file_code = file?.file_code
|
|
|| file?.filecode
|
|
|| source?.file_code
|
|
|| source?.filecode
|
|
|| null;
|
|
|
|
return {
|
|
download_url: file_code ? `https://voe.sx/${file_code}` : null,
|
|
embed_url: file_code ? `https://voe.sx/e/${file_code}` : null,
|
|
file_code
|
|
};
|
|
}
|
|
|
|
// Byse: { files: [{ filecode, filename, status }] }
|
|
function parseByseResult(payload) {
|
|
let file_code = null;
|
|
|
|
// Primary: files array (per official Byse API docs)
|
|
if (Array.isArray(payload.files) && payload.files.length > 0) {
|
|
file_code = payload.files[0].filecode || payload.files[0].file_code;
|
|
}
|
|
// Fallback: result object
|
|
if (!file_code && payload.result) {
|
|
const result = payload.result;
|
|
if (Array.isArray(result) && result.length > 0) {
|
|
file_code = result[0].filecode || result[0].file_code;
|
|
} else if (typeof result === 'object') {
|
|
file_code = result.filecode || result.file_code;
|
|
}
|
|
}
|
|
|
|
return {
|
|
download_url: file_code ? `https://byse.sx/d/${file_code}` : null,
|
|
embed_url: file_code ? `https://byse.sx/e/${file_code}` : null,
|
|
file_code
|
|
};
|
|
}
|
|
|
|
// --- Multipart upload with progress ---
|
|
|
|
function buildMultipart(filePath, formFields) {
|
|
const boundary = '----FormBoundary' + crypto.randomBytes(16).toString('hex');
|
|
const fileName = path.basename(filePath);
|
|
const fileSize = fs.statSync(filePath).size;
|
|
|
|
let preamble = '';
|
|
for (const [key, value] of Object.entries(formFields)) {
|
|
preamble += `--${boundary}\r\n`;
|
|
preamble += `Content-Disposition: form-data; name="${key}"\r\n\r\n`;
|
|
preamble += `${value}\r\n`;
|
|
}
|
|
preamble += `--${boundary}\r\n`;
|
|
const safeFileName = fileName.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
preamble += `Content-Disposition: form-data; name="file"; filename="${safeFileName}"\r\n`;
|
|
preamble += `Content-Type: application/octet-stream\r\n\r\n`;
|
|
|
|
const epilogue = `\r\n--${boundary}--\r\n`;
|
|
|
|
const preambleBuf = Buffer.from(preamble, 'utf-8');
|
|
const epilogueBuf = Buffer.from(epilogue, 'utf-8');
|
|
const totalSize = preambleBuf.length + fileSize + epilogueBuf.length;
|
|
|
|
return { boundary, preambleBuf, epilogueBuf, totalSize, fileSize };
|
|
}
|
|
|
|
function createUploadBody(filePath, formFields, onProgress, throttle, signal) {
|
|
const { boundary, preambleBuf, epilogueBuf, totalSize, fileSize } = buildMultipart(filePath, formFields);
|
|
|
|
let bytesRead = 0;
|
|
const CHUNK_SIZE = 256 * 1024;
|
|
|
|
async function* generate() {
|
|
yield preambleBuf;
|
|
const fileStream = fs.createReadStream(filePath, { highWaterMark: CHUNK_SIZE });
|
|
for await (const chunk of fileStream) {
|
|
if (signal && signal.aborted) throw new Error('Aborted');
|
|
if (throttle) await throttle.consume(chunk.length, signal);
|
|
bytesRead += chunk.length;
|
|
yield chunk;
|
|
if (onProgress) onProgress(bytesRead, fileSize);
|
|
}
|
|
yield epilogueBuf;
|
|
}
|
|
|
|
return { iterable: generate(), boundary, totalSize };
|
|
}
|
|
|
|
// --- API helper using built-in fetch (follows redirects automatically) ---
|
|
|
|
async function apiGet(url, signal) {
|
|
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'
|
|
});
|
|
const text = await res.text();
|
|
let data;
|
|
try {
|
|
data = JSON.parse(text);
|
|
} catch {
|
|
throw new Error(`API-Antwort war kein JSON (HTTP ${res.status}): ${(text || '').slice(0, 200)}`);
|
|
}
|
|
|
|
if (data.status && [401, 403, 429, 500].includes(data.status)) {
|
|
throw new Error(data.msg || data.message || JSON.stringify(data));
|
|
}
|
|
return data;
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
if (signal) signal.removeEventListener('abort', onAbort);
|
|
}
|
|
}
|
|
|
|
// --- Main upload function ---
|
|
|
|
async function getUploadServer(hosterName, hosterConfig, apiKey, signal) {
|
|
let lastMessage = '';
|
|
|
|
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 uploadUrl = extractUploadServerUrl(data, hosterConfig.apiBase);
|
|
if (uploadUrl) {
|
|
LAST_UPLOAD_SERVERS.set(hosterName, uploadUrl);
|
|
return uploadUrl;
|
|
}
|
|
|
|
const apiMessage = data && (data.msg || data.message)
|
|
? String(data.msg || data.message).trim()
|
|
: '';
|
|
if (apiMessage) lastMessage = apiMessage;
|
|
} catch (err) {
|
|
if (err.name === 'AbortError') throw err;
|
|
if (err.message) lastMessage = err.message;
|
|
}
|
|
}
|
|
|
|
if (attempt < SERVER_RETRY_ATTEMPTS && shouldRetryServerLookup(lastMessage)) {
|
|
await sleep(SERVER_RETRY_DELAY_MS, signal);
|
|
continue;
|
|
}
|
|
|
|
break;
|
|
}
|
|
|
|
const cachedServer = LAST_UPLOAD_SERVERS.get(hosterName);
|
|
if (cachedServer && shouldRetryServerLookup(lastMessage)) {
|
|
return cachedServer;
|
|
}
|
|
|
|
if (shouldRetryServerLookup(lastMessage) && Array.isArray(hosterConfig.fallbackUploadServers)) {
|
|
for (const fallback of hosterConfig.fallbackUploadServers) {
|
|
const normalized = normalizeAbsoluteUrl(fallback, hosterConfig.apiBase);
|
|
if (normalized) {
|
|
LAST_UPLOAD_SERVERS.set(hosterName, normalized);
|
|
return normalized;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (lastMessage) {
|
|
throw new Error(`Kein Upload-Server erhalten: ${lastMessage}`);
|
|
}
|
|
throw new Error('Kein Upload-Server erhalten. API-Key pruefen.');
|
|
}
|
|
|
|
async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, throttle) {
|
|
const config = HOSTER_CONFIGS[hosterName];
|
|
if (!config) throw new Error(`Unbekannter Hoster: ${hosterName}`);
|
|
|
|
// Step 1: Get upload server
|
|
const uploadUrl = await getUploadServer(hosterName, config, apiKey, signal);
|
|
|
|
// Step 2: Upload file with progress
|
|
const targetUrl = config.buildUploadUrl(uploadUrl, apiKey);
|
|
const formFields = config.formFields(apiKey);
|
|
|
|
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
|
|
});
|
|
|
|
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() : '';
|
|
throw new Error(
|
|
`Upload-Antwort von ${hosterName} war kein JSON (HTTP ${statusCode}${snippet ? `): ${snippet}` : ')'}`
|
|
);
|
|
}
|
|
|
|
if (statusCode < 200 || statusCode >= 300) {
|
|
throw new Error(
|
|
payload.msg
|
|
|| payload.message
|
|
|| `Upload fehlgeschlagen (HTTP ${statusCode}${headers?.['content-type'] ? `, ${headers['content-type']}` : ''})`
|
|
);
|
|
}
|
|
|
|
if (payload.status && [401, 403, 429, 500].includes(payload.status)) {
|
|
throw new Error(payload.msg || payload.message || JSON.stringify(payload));
|
|
}
|
|
|
|
const result = config.parseResult(payload);
|
|
if (result?.file_code || result?.download_url || result?.embed_url) {
|
|
return result;
|
|
}
|
|
|
|
if (payload.success === false) {
|
|
throw new Error(payload.msg || payload.message || `Upload zu ${hosterName} wurde vom Server abgelehnt.`);
|
|
}
|
|
|
|
// 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.
|
|
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);
|
|
throw new Error(
|
|
`Upload zu ${hosterName} lieferte keine file_code-Antwort (Payload: ${snippet})`
|
|
);
|
|
}
|
|
throw new Error(msg);
|
|
}
|
|
|
|
module.exports = {
|
|
uploadFile,
|
|
HOSTER_CONFIGS,
|
|
__test: {
|
|
extractUploadServerUrl,
|
|
parseVoeResult
|
|
}
|
|
};
|