Release Multi-Hoster Uploader 3.3.108

This commit is contained in:
Sucukdeluxe
2026-08-01 17:46:29 +02:00
commit 6d0ad84d2d
90 changed files with 29450 additions and 0 deletions
+36
View File
@@ -0,0 +1,36 @@
// Decides which credential an upload task should use for a given hoster.
// Extracted from main.js buildTaskFromAccount so the routing can be unit-tested
// without Electron.
//
// DOODSTREAM SPECIAL CASE: prefer the official doodapi.co API key whenever the
// account has one. The web-login path (username/password) drives doodstream's
// browser upload flow, which hands the filecode back inside an XFileSharing
// HTML form. On long/large uploads that form comes back empty (no fn) because a
// per-page-load sess_id token ages out over the multi-minute upload and/or the
// server-side file-registration callback times out — the upload then "succeeds"
// (bytes sent, HTTP 200) but yields no link. The JSON API returns the filecode
// directly in result[0].filecode and authenticates with a persistent api_key,
// so it has no empty-form failure mode for result retrieval. The API path was
// doodstream's ORIGINAL upload path (present since the initial commit); web
// login was added later only as an alternative for keyless accounts — so
// preferring the key here restores the intended primary path, it doesn't fight
// a deliberate choice. Keyless accounts keep using web login unchanged.
function selectUploadAuth(hoster, account) {
if (!account || typeof account !== 'object') return {};
if (hoster === 'doodstream.com' && account.apiKey) {
return { apiKey: account.apiKey };
}
if (account.authType === 'api' && account.apiKey) {
return { apiKey: account.apiKey };
}
if (account.username && account.password) {
return { username: account.username, password: account.password };
}
if (account.apiKey) {
return { apiKey: account.apiKey };
}
return {};
}
module.exports = { selectUploadAuth };
+27
View File
@@ -0,0 +1,27 @@
function enabledAccountsFor(hosters, hoster, hasCreds) {
const list = hosters && hosters[hoster];
if (!Array.isArray(list)) return [];
return list.filter(a => a && a.enabled !== false && hasCreds(hoster, a));
}
function createAccountPicker({ hosters, hosterSettings, hasCreds, indices }) {
const rotIdx = Object.assign(Object.create(null), indices || {});
let dirty = false;
function pick(hoster) {
const enabled = enabledAccountsFor(hosters, hoster, hasCreds);
if (enabled.length === 0) return null;
const hs = (hosterSettings && hosterSettings[hoster]) || {};
if (hs.rotateAccounts === true && enabled.length > 1) {
const cursor = Number.isFinite(rotIdx[hoster]) ? rotIdx[hoster] : 0;
rotIdx[hoster] = cursor + 1;
dirty = true;
return enabled[cursor % enabled.length];
}
return enabled[0];
}
pick.indices = () => ({ ...rotIdx });
pick.dirty = () => dirty;
return pick;
}
module.exports = { createAccountPicker, enabledAccountsFor };
+95
View File
@@ -0,0 +1,95 @@
const crypto = require('crypto');
const MAGIC = Buffer.from('MHU1');
const SALT_LEN = 16;
const IV_LEN = 12;
const TAG_LEN = 16;
const KEY_LEN = 32;
const ITERATIONS = 100_000;
const DIGEST = 'sha512';
const ALGO = 'aes-256-gcm';
// Fixed app-internal passphrase — backups are opaque without the app, which is
// enough protection for API keys stored locally. We keep the AES-GCM envelope
// (with random salt/iv) so each export is still distinct and authenticated.
const APP_PASSPHRASE = 'multi-hoster-upload::backup::v1';
function deriveKey(passphrase, salt) {
return crypto.pbkdf2Sync(passphrase, salt, ITERATIONS, KEY_LEN, DIGEST);
}
/**
* Encrypt a config object.
* Returns a Buffer: MHU1 | salt(16) | iv(12) | tag(16) | ciphertext
*/
function encrypt(config) {
const plaintext = Buffer.from(JSON.stringify(config), 'utf-8');
const salt = crypto.randomBytes(SALT_LEN);
const iv = crypto.randomBytes(IV_LEN);
const key = deriveKey(APP_PASSPHRASE, salt);
const cipher = crypto.createCipheriv(ALGO, key, iv);
const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
const tag = cipher.getAuthTag();
plaintext.fill(0);
key.fill(0);
return Buffer.concat([MAGIC, salt, iv, tag, encrypted]);
}
/**
* Decrypt a .mhu buffer.
* Tries the app's built-in key first; if that fails and a user password is
* provided, falls back to legacy password-based decryption. Throws a special
* error with `needsPassword = true` if the app key fails and no password was
* given, so callers can prompt the user for the legacy password.
*/
function decrypt(buffer, userPassword) {
if (buffer.length < MAGIC.length + SALT_LEN + IV_LEN + TAG_LEN + 1) {
throw new Error('Ungültiges Backup-Format');
}
const magic = buffer.subarray(0, 4);
if (!magic.equals(MAGIC)) {
throw new Error('Keine gültige .mhu Backup-Datei');
}
let offset = MAGIC.length;
const salt = buffer.subarray(offset, offset += SALT_LEN);
const iv = buffer.subarray(offset, offset += IV_LEN);
const tag = buffer.subarray(offset, offset += TAG_LEN);
const ciphertext = buffer.subarray(offset);
const tryPassphrase = (passphrase) => {
const key = deriveKey(passphrase, salt);
const decipher = crypto.createDecipheriv(ALGO, key, iv);
decipher.setAuthTag(tag);
try {
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
const result = JSON.parse(decrypted.toString('utf-8'));
decrypted.fill(0);
key.fill(0);
return result;
} catch {
key.fill(0);
return null;
}
};
// 1) Try the app-internal key (new format, no password required).
const fromApp = tryPassphrase(APP_PASSPHRASE);
if (fromApp) return fromApp;
// 2) Legacy format: user had set their own password.
if (userPassword) {
const fromUser = tryPassphrase(userPassword);
if (fromUser) return fromUser;
throw new Error('Falsches Passwort oder beschädigte Datei');
}
const err = new Error('Dieses Backup wurde mit einem Passwort verschlüsselt');
err.needsPassword = true;
throw err;
}
module.exports = { encrypt, decrypt };
+239
View File
@@ -0,0 +1,239 @@
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { request, Agent } = require('undici');
const BASE_URL = 'https://clouddrop.cc';
const API_BASE = `${BASE_URL}/api/cloud`;
const CHUNK_UPLOAD_BASE = 'https://upload.clouddrop.cc/api/cloud';
const USER_AGENT = 'multi-hoster-uploader/1.0';
const SIMPLE_UPLOAD_LIMIT = 16 * 1024 * 1024; // 16 MB
const CHUNK_SIZE = 16 * 1024 * 1024; // 16 MB — server's fixed chunk size
const INIT_TIMEOUT = 60_000;
const CHUNK_TIMEOUT = 30 * 60_000; // 30 min per chunk
const COMPLETE_TIMEOUT = 5 * 60_000;
const SIMPLE_UPLOAD_TIMEOUT = 30 * 60_000;
// Cap concurrent TCP connections to clouddrop.cc at 50 to stay well under
// the server's per-IP limit of 100 concurrent connections (cd_conn).
// Shared across all ClouddropUploader instances via module-level agent.
const clouddropAgent = new Agent({
connections: 50,
pipelining: 1,
keepAliveTimeout: 30_000,
keepAliveMaxTimeout: 60_000
});
/**
* Clouddrop.cc uploader — uses API Key (Bearer) authentication.
* Files > 16 MB use the chunked protocol, smaller files use simple upload.
* After upload, a share link is created and returned as download_url.
*/
class ClouddropUploader {
constructor(apiKey) {
this.apiKey = String(apiKey || '').trim();
}
_headers(extra) {
return {
'Authorization': `Bearer ${this.apiKey}`,
'User-Agent': USER_AGENT,
'Accept': 'application/json',
...(extra || {})
};
}
async _parseJsonResponse(res) {
const text = await res.body.text();
let payload = null;
try { payload = text ? JSON.parse(text) : {}; } catch {
throw new Error(`Clouddrop: API-Antwort war kein JSON (HTTP ${res.statusCode}): ${text.slice(0, 200)}`);
}
if (res.statusCode < 200 || res.statusCode >= 300) {
const msg = (payload && (payload.error || payload.message))
|| `HTTP ${res.statusCode}`;
const err = new Error(`Clouddrop: ${msg}`);
err.status = res.statusCode;
throw err;
}
return payload;
}
/**
* Upload a file. Returns { download_url, embed_url, file_code }.
*/
async upload(filePath, progressCb, signal, throttle) {
if (!this.apiKey) throw new Error('Clouddrop: API-Key fehlt');
const fileName = path.basename(filePath);
let fileSize = 0;
try { fileSize = fs.statSync(filePath).size; }
catch { throw new Error(`Clouddrop: Datei nicht lesbar: ${fileName}`); }
if (fileSize <= 0) throw new Error('Clouddrop: Datei ist leer');
let fileId;
if (fileSize <= SIMPLE_UPLOAD_LIMIT) {
fileId = await this._uploadSimple(filePath, fileName, fileSize, progressCb, signal, throttle);
} else {
fileId = await this._uploadChunked(filePath, fileName, fileSize, progressCb, signal, throttle);
}
return {
download_url: `${BASE_URL}/share/${fileId}`,
embed_url: null,
file_code: fileId
};
}
/**
* Simple upload for files < 16 MB — single multipart POST.
*/
async _uploadSimple(filePath, fileName, fileSize, progressCb, signal, throttle) {
const boundary = '----FormBoundary' + crypto.randomBytes(16).toString('hex');
const safeFileName = fileName.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
const preamble =
`--${boundary}\r\n` +
`Content-Disposition: form-data; name="file"; filename="${safeFileName}"\r\n` +
`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;
let bytesRead = 0;
async function* generate() {
yield preambleBuf;
const fileStream = fs.createReadStream(filePath, { highWaterMark: 1024 * 1024 });
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 (progressCb) progressCb(bytesRead, fileSize);
}
yield epilogueBuf;
}
const res = await request(`${API_BASE}/upload?mode=rename`, {
method: 'POST',
dispatcher: clouddropAgent,
body: generate(),
signal,
headers: this._headers({
'Content-Type': `multipart/form-data; boundary=${boundary}`,
'Content-Length': String(totalSize)
}),
headersTimeout: SIMPLE_UPLOAD_TIMEOUT,
bodyTimeout: SIMPLE_UPLOAD_TIMEOUT
});
const payload = await this._parseJsonResponse(res);
if (!payload.fileId) throw new Error(`Clouddrop: Keine fileId in Upload-Antwort`);
return payload.fileId;
}
/**
* Chunked upload for files > 16 MB.
* Flow: POST /upload/init → PUT /upload/:sessionId/chunk/:n (0-based) → POST /upload/:sessionId/complete
*/
async _uploadChunked(filePath, fileName, fileSize, progressCb, signal, throttle) {
// 1. Init session
const initRes = await request(`${API_BASE}/upload/init`, {
method: 'POST',
dispatcher: clouddropAgent,
signal,
headers: this._headers({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ filename: fileName, size: fileSize, parentId: null }),
headersTimeout: INIT_TIMEOUT,
bodyTimeout: INIT_TIMEOUT
});
const initPayload = await this._parseJsonResponse(initRes);
const sessionId = initPayload.sessionId;
const chunkSize = initPayload.chunkSize || CHUNK_SIZE;
const totalChunks = initPayload.totalChunks || Math.ceil(fileSize / chunkSize);
if (!sessionId) throw new Error('Clouddrop: Keine sessionId von /upload/init');
// 2. Read file and PUT chunks sequentially.
// Reuse a single buffer for all chunks (only the last chunk may be smaller,
// in which case we slice a view). Avoids 64× 16 MB allocations on a 1 GB
// file — real GC pressure during busy uploads.
const fh = await fs.promises.open(filePath, 'r');
let bytesSent = 0;
const reusableBuf = Buffer.allocUnsafe(chunkSize);
try {
for (let i = 0; i < totalChunks; i++) {
if (signal && signal.aborted) throw new Error('Aborted');
const offset = i * chunkSize;
const remaining = fileSize - offset;
const thisChunkSize = Math.min(chunkSize, remaining);
await fh.read(reusableBuf, 0, thisChunkSize, offset);
const body = thisChunkSize === chunkSize
? reusableBuf
: reusableBuf.subarray(0, thisChunkSize);
if (throttle) await throttle.consume(thisChunkSize, signal);
const chunkRes = await request(`${CHUNK_UPLOAD_BASE}/upload/${sessionId}/chunk/${i}`, {
method: 'PUT',
dispatcher: clouddropAgent,
signal,
body,
headers: this._headers({
'Content-Type': 'application/octet-stream',
'Content-Length': String(thisChunkSize)
}),
headersTimeout: CHUNK_TIMEOUT,
bodyTimeout: CHUNK_TIMEOUT
});
await this._parseJsonResponse(chunkRes);
bytesSent += thisChunkSize;
if (progressCb) progressCb(bytesSent, fileSize);
}
} finally {
try { await fh.close(); } catch {}
}
// 3. Complete session — all bytes are already on the server at this point.
// We MUST NOT throw here, otherwise the upload-manager would retry the entire
// multi-GB upload. Any failure (timeout, non-JSON, missing fileId, server still
// post-processing) is swallowed and we fall back to sessionId as file_code.
try {
const completeRes = await request(`${API_BASE}/upload/${sessionId}/complete`, {
method: 'POST',
dispatcher: clouddropAgent,
signal,
headers: this._headers({ 'Content-Type': 'application/json' }),
body: '{}',
headersTimeout: COMPLETE_TIMEOUT,
bodyTimeout: COMPLETE_TIMEOUT
});
const completePayload = await this._parseJsonResponse(completeRes).catch(() => ({}));
return completePayload.fileId || completePayload.id || sessionId;
} catch {
return sessionId;
}
}
/**
* Lightweight auth check — GET /api/cloud/files (list root, small response).
*/
async checkAuth(signal) {
if (!this.apiKey) throw new Error('Clouddrop: API-Key fehlt');
const res = await request(`${API_BASE}/files/?limit=1`, {
method: 'GET',
dispatcher: clouddropAgent,
signal,
headers: this._headers(),
headersTimeout: 15_000,
bodyTimeout: 15_000
});
await this._parseJsonResponse(res);
return true;
}
}
module.exports = ClouddropUploader;
+73
View File
@@ -0,0 +1,73 @@
// Microtask-coalesced set. Adds are O(1); the apply callback runs once per
// scheduler tick with every id collected since the last flush.
//
// Used by the renderer to merge a burst of done-jobs (e.g. 500 jobs all
// finishing within milliseconds) into a single queueJobs.filter() pass —
// without this each event was its own O(N) sweep, so 500 finishes were
// O(N²) and visibly froze the UI on completion.
//
// Loaded both as a CommonJS module (Node tests) and as a browser global
// (renderer/app.js via index.html script tag).
(function (root) {
'use strict';
/**
* Build a coalesced set.
* @param {{ apply: (Set) => void, scheduler?: (cb: () => void) => void }} opts
* apply: called once per scheduler tick with the accumulated ids.
* scheduler: defaults to queueMicrotask. Tests can pass a synchronous
* stand-in to avoid async waits.
*/
function makeCoalescedSet(opts) {
if (!opts || typeof opts.apply !== 'function') {
throw new TypeError('makeCoalescedSet: { apply: fn } required');
}
const apply = opts.apply;
const scheduler = typeof opts.scheduler === 'function'
? opts.scheduler
: (typeof queueMicrotask === 'function' ? queueMicrotask : (cb) => Promise.resolve().then(cb));
let pending = new Set();
let scheduled = false;
function flush() {
scheduled = false;
if (pending.size === 0) return;
const drop = pending;
pending = new Set();
try { apply(drop); } catch (e) {
// Don't let a failing apply lock out the next batch — surface it
// but keep the coalescer usable.
if (typeof console !== 'undefined' && console.error) console.error(e);
}
}
return {
add(id) {
pending.add(id);
if (!scheduled) {
scheduled = true;
scheduler(flush);
}
},
/**
* Synchronously consume any pending ids. Used by beforeunload paths
* where we can't wait for the next microtask before persisting.
*/
drainSync() {
if (pending.size === 0) return;
const drop = pending;
pending = new Set();
scheduled = false;
apply(drop);
},
/** Introspection for tests + diagnostics. */
pendingSize() { return pending.size; },
isScheduled() { return scheduled; }
};
}
const api = { makeCoalescedSet };
if (typeof module !== 'undefined' && module.exports) module.exports = api;
else if (root) root.CoalescedSet = api;
})(typeof window !== 'undefined' ? window : this);
+654
View File
@@ -0,0 +1,654 @@
const fs = require('fs');
const path = require('path');
const secretStore = require('./secret-store');
const { normalizeLogMode } = require('./log-mode');
const HOSTER_SETTINGS_DEFAULTS = {
retries: 3,
maxSpeedKbs: 0, // 0 = unlimited
parallelCount: 2, // 1-100
restartBelowKbs: 0, // 0 = off
timeIntervalSec: 0, // delay between jobs
maxSizeMb: 0, // 0 = unlimited
logToFile: true, // write this hoster's successful links to fileuploader.log
rotateAccounts: false,
sizeMemoEnabled: true
};
// Template for each hoster type (used as defaults for new accounts)
const HOSTER_ACCOUNT_TEMPLATES = {
'doodstream.com': { enabled: true, authType: 'login', username: '', password: '' },
'doodstream.com:api': { enabled: true, authType: 'api', apiKey: '' },
'voe.sx': { enabled: true, authType: 'login', username: '', password: '' },
'voe.sx:api': { enabled: true, authType: 'api', apiKey: '' },
'vidmoly.me': { enabled: true, authType: 'login', username: '', password: '' },
'byse.sx': { enabled: true, authType: 'api', apiKey: '' },
'clouddrop.cc': { enabled: true, authType: 'api', apiKey: '' }
};
// All known hoster names (used for iteration)
const HOSTER_NAMES = ['doodstream.com', 'voe.sx', 'vidmoly.me', 'byse.sx', 'clouddrop.cc'];
// Dropdown options for "Add Account" modal: value -> label
const HOSTER_ADD_OPTIONS = [
{ value: 'doodstream.com', label: 'Doodstream (Web Login)', hoster: 'doodstream.com', authType: 'login' },
{ value: 'doodstream.com:api', label: 'Doodstream (API)', hoster: 'doodstream.com', authType: 'api' },
{ value: 'voe.sx', label: 'Voe (Web Login)', hoster: 'voe.sx', authType: 'login' },
{ value: 'voe.sx:api', label: 'Voe (API)', hoster: 'voe.sx', authType: 'api' },
{ value: 'vidmoly.me', label: 'Vidmoly (Web Login)', hoster: 'vidmoly.me', authType: 'login' },
{ value: 'byse.sx', label: 'Byse (API)', hoster: 'byse.sx', authType: 'api' },
{ value: 'clouddrop.cc', label: 'Clouddrop (API)', hoster: 'clouddrop.cc', authType: 'api' }
];
const DEFAULTS = {
hosters: {
'doodstream.com': [],
'voe.sx': [],
'vidmoly.me': [],
'byse.sx': [],
'clouddrop.cc': []
},
hosterSettings: {
'doodstream.com': { ...HOSTER_SETTINGS_DEFAULTS },
'voe.sx': { ...HOSTER_SETTINGS_DEFAULTS },
'vidmoly.me': { ...HOSTER_SETTINGS_DEFAULTS },
'byse.sx': { ...HOSTER_SETTINGS_DEFAULTS },
'clouddrop.cc': { ...HOSTER_SETTINGS_DEFAULTS }
},
globalSettings: {
alwaysOnTop: false,
shutdownAfterFinish: 'nothing', // nothing | sleep | shutdown | restart
logFilePath: '',
sessionLog: false, // legacy boolean (kept for back-compat reads); normalized into logMode on load
logVerbose: false, // when true, [DEBUG] level entries are written to debug.log
webhookUrl: '', // POST target on batch-done (Discord or generic JSON)
webhookMention: '', // optional Discord ping target: user-id, role:id, @here, @everyone
autoRetryRounds: 0, // 0 = off; 1-5 automatic retry rounds for transient failures after batch end
autoRetryDelayMin: 5, // base delay in minutes between auto-retry rounds (linear backoff: round N waits N*delay)
historyRetention: 'all', // 'all' | '7d' | '30d' | '90d' | '1000' | '100' — storage cap for upload history
// NOTE: logMode is intentionally NOT in DEFAULTS. If it were, the deep-merge
// would seed logMode='single' for every load, which would beat (and silently
// erase) the legacy sessionLog:true → "daily" migration. normalizeLogMode in
// load() sets logMode after the merge, looking at the saved-only data.
resumeQueueOnLaunch: true,
parallelUploadCount: 0, // 0 = use per-hoster limits only
scaleParallelUploads: false,
removeFromQueueOnDone: false,
showDropTarget: false,
globalMaxSpeedKbs: 0, // 0 = unlimited global speed
pendingQueue: null,
scramble: {
active: false,
prefix: '',
suffix: '',
chars: 'both', // 'letters' | 'numbers' | 'both'
length: 0 // 0 = same as original basename length
},
folderMonitor: {
enabled: false,
folderPath: '',
recursive: false,
filterMode: 'include', // 'include' | 'exclude'
extensions: '', // comma-separated: 'mp4,mkv,avi'
skipDuplicates: true,
delaySec: 3,
autoStart: true,
hosters: [] // pre-selected hosters, empty = ask via modal
},
remote: {
enabled: false,
port: 9100,
token: '',
allowInput: true
},
diagnostics: {
enabled: false,
port: 9110,
token: '',
label: '',
codeIssuedAt: 0,
bindMode: 'local',
publicHost: '',
allowlist: [],
bindAddress: '127.0.0.1'
}
},
history: [],
rotationCursors: {}
};
const HISTORY_RETENTION_OPTIONS = [
{ value: 'all', label: 'Alles behalten' },
{ value: '7d', label: 'Letzte 7 Tage' },
{ value: '30d', label: 'Letzte 30 Tage' },
{ value: '90d', label: 'Letzte 90 Tage' },
{ value: '1000', label: 'Letzte 1000 Uploads' },
{ value: '100', label: 'Letzte 100 Uploads' }
];
function batchTimestampMs(batch) {
const raw = batch && batch.timestamp;
if (raw === null || raw === undefined || raw === '') return null;
const ms = typeof raw === 'number' ? raw : Date.parse(raw);
return Number.isFinite(ms) ? ms : null;
}
function batchRowCount(batch) {
let n = 0;
const files = (batch && batch.files) || [];
for (const file of files) {
for (const result of (file.results || [])) {
if (result.status === 'aborted' || result.status === 'error') continue;
n++;
}
}
return n;
}
function countHistoryRows(history) {
let n = 0;
for (const batch of (history || [])) n += batchRowCount(batch);
return n;
}
function applyHistoryRetention(history, retention, nowMs) {
if (!Array.isArray(history) || history.length === 0) return history;
const policy = String(retention || 'all');
if (policy === 'all') return history;
if (/^\d+d$/.test(policy)) {
const days = parseInt(policy, 10);
if (!Number.isFinite(days) || days <= 0) return history;
const cutoff = nowMs - days * 86400000;
return history.filter(b => {
const ts = batchTimestampMs(b);
return ts === null || ts >= cutoff;
});
}
const maxRows = parseInt(policy, 10);
if (!Number.isFinite(maxRows) || maxRows <= 0) return history;
const keptReversed = [];
let acc = 0;
for (let i = history.length - 1; i >= 0; i--) {
keptReversed.push(history[i]);
acc += batchRowCount(history[i]);
if (acc >= maxRows) break;
}
return keptReversed.reverse();
}
class ConfigStore {
constructor(app) {
const dir = app && app.isPackaged
? app.getPath('userData')
: path.join(__dirname, '..');
this.filePath = path.join(dir, 'electron-config.json');
this.historyPath = path.join(dir, 'electron-history.json');
this._writeQueue = Promise.resolve(); // Serializes all writes to prevent race conditions
this._historyWriteQueue = Promise.resolve();
this._historyMigrated = false;
this._cache = null;
this._cacheKey = '';
this._perfLog = null;
this._wqDepth = 0;
// Migrate config from old location if current doesn't exist
if (!fs.existsSync(this.filePath) && app && app.isPackaged) {
this._migrateFromOldPath(app);
}
if (app && app.isPackaged) {
this._migrateHistory();
}
}
_readHistoryFile() {
try {
const raw = fs.readFileSync(this.historyPath, 'utf-8');
if (!raw || raw.trim().length < 2) return [];
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) return parsed;
if (parsed && Array.isArray(parsed.history)) return parsed.history;
return [];
} catch {
return null;
}
}
_writeHistoryFileDurable(arr) {
const tmp = this.historyPath + '.tmp';
const fd = fs.openSync(tmp, 'w');
try {
fs.writeSync(fd, JSON.stringify(arr));
fs.fsyncSync(fd);
} finally {
fs.closeSync(fd);
}
fs.renameSync(tmp, this.historyPath);
}
_writeHistoryFileAtomic(arr) {
return new Promise((resolve, reject) => {
const tmp = this.historyPath + '.tmp';
fs.writeFile(tmp, JSON.stringify(arr), 'utf-8', (err) => {
if (err) return reject(err);
try { fs.renameSync(tmp, this.historyPath); } catch (e) { return reject(e); }
resolve();
});
});
}
_enqueueHistoryWrite(fn) {
this._historyWriteQueue = this._historyWriteQueue.then(fn, fn);
return this._historyWriteQueue;
}
_migrateHistory() {
try {
if (fs.existsSync(this.historyPath)) {
this._historyMigrated = Array.isArray(this._readHistoryFile());
return;
}
let cfg = null;
try { cfg = this._readAndParse(this.filePath); } catch {}
const hist = (cfg && Array.isArray(cfg.history)) ? cfg.history : [];
this._writeHistoryFileDurable(hist);
const check = this._readHistoryFile();
if (Array.isArray(check) && check.length === hist.length) {
if (hist.length > 0) {
try { fs.copyFileSync(this.filePath, this.filePath + '.pre-history-split.bak'); } catch {}
}
this._historyMigrated = true;
} else {
this._historyMigrated = false;
}
} catch {
this._historyMigrated = false;
}
}
_migrateFromOldPath(app) {
try {
const appDataDir = path.dirname(app.getPath('userData'));
// Check alternate folder names that may have been used
const candidates = ['multi-hoster-uploader', 'Multi-Hoster-Upload'];
for (const name of candidates) {
const oldPath = path.join(appDataDir, name, 'electron-config.json');
if (oldPath !== this.filePath && fs.existsSync(oldPath)) {
fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
fs.copyFileSync(oldPath, this.filePath);
return;
}
}
// Also check next to the executable (portable mode previous location)
const exeDir = path.dirname(app.getPath('exe'));
const portablePath = path.join(exeDir, 'electron-config.json');
if (portablePath !== this.filePath && fs.existsSync(portablePath)) {
fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
fs.copyFileSync(portablePath, this.filePath);
}
} catch {}
}
_readAndParse(filePath) {
const raw = fs.readFileSync(filePath, 'utf-8');
if (!raw || raw.trim().length < 2) return null;
return JSON.parse(raw);
}
_clone(obj) {
try { return structuredClone(obj); }
catch { return JSON.parse(JSON.stringify(obj)); }
}
setPerfLog(fn) { this._perfLog = typeof fn === 'function' ? fn : null; }
_pqLen(globalSettings) {
const pq = globalSettings && globalSettings.pendingQueue;
return pq && Array.isArray(pq.queueJobs) ? pq.queueJobs.length : 0;
}
_callerTag() {
const lines = (new Error().stack || '').split('\n');
const out = [];
for (let i = 2; i < lines.length && out.length < 3; i++) {
const line = lines[i].trim();
if (/config-store\.js/.test(line)) continue;
const m = line.match(/at (?:async )?([^ (]+)/);
if (m) out.push(m[1].split('.').pop());
}
return out.join('<') || '?';
}
load() {
if (!this._perfLog) return this._loadImpl();
const hadCache = !!this._cache;
const t0 = performance.now();
const r = this._loadImpl();
const dt = performance.now() - t0;
if (dt >= 20) {
const q = this._pqLen(r && r.globalSettings);
const h = (r && r.history || []).length;
this._perfLog(`config-load wall=${dt.toFixed(0)}ms cache=${hadCache ? 'hit' : 'miss'} hist=${h} queue=${q} via=${this._callerTag()}`);
}
return r;
}
_loadImpl() {
try {
// In-memory cache keyed on the file's mtime+size. The processed config
// (merged + credential-decrypted) is reparsed/re-decrypted from disk ONLY
// when the file actually changes. Our own writes refresh the cache (see
// _commit), and an external edit changes mtime/size so the cache misses
// and we reread. Without this, every one of the ~38 main.js load() call
// sites (incl. the per-500ms log-flush path) re-read disk + JSON.parse the
// whole growing history + DPAPI-decrypt every credential — the dominant
// long-running main-thread drag. load() always returns a CLONE so callers
// can mutate the result without corrupting the cache.
let stat = null;
try { stat = fs.statSync(this.filePath); } catch {}
const statKey = stat ? `${stat.mtimeMs}:${stat.size}` : '';
if (stat && this._cache && this._cacheKey === statKey) {
return this._clone(this._cache);
}
let data = null;
// Try main config
try { data = this._readAndParse(this.filePath); } catch {}
// Fallback to backup if main is empty/corrupt
if (!data) {
try { data = this._readAndParse(this.filePath + '.bak'); } catch {}
}
if (!data) {
try { data = this._readAndParse(this.filePath + '.pre-history-split.bak'); } catch {}
}
if (!data) {
const fresh = JSON.parse(JSON.stringify(DEFAULTS));
fresh.globalSettings.logMode = normalizeLogMode(fresh.globalSettings);
return fresh;
}
// Migrate old single-object format to array format
for (const [name, val] of Object.entries(data.hosters || {})) {
if (val && !Array.isArray(val)) {
if (!val.id) val.id = `${name}-migrated-${Date.now()}`;
// Infer authType for old format accounts
if (!val.authType) {
if (name === 'byse.sx') val.authType = 'api';
else if (name === 'vidmoly.me') val.authType = 'login';
else if (val.username && val.password) val.authType = 'login';
else if (val.apiKey) val.authType = 'api';
else val.authType = 'login';
}
data.hosters[name] = [val];
}
}
// Merge hosters: ensure all known hosters exist as arrays
const hosters = {};
for (const name of HOSTER_NAMES) {
const saved = data.hosters && data.hosters[name];
if (Array.isArray(saved) && saved.length > 0) {
hosters[name] = saved.map((acc, i) => {
// Ensure authType is set on every account
if (!acc.authType) {
if (name === 'byse.sx') acc.authType = 'api';
else if (name === 'vidmoly.me') acc.authType = 'login';
else if (acc.username && acc.password) acc.authType = 'login';
else if (acc.apiKey) acc.authType = 'api';
else acc.authType = 'login';
}
return {
...acc,
id: acc.id || `${name}-${Date.now()}-${i}`
};
});
} else {
hosters[name] = [];
}
}
// Merge hoster settings with defaults
const hosterSettings = {};
for (const name of Object.keys(DEFAULTS.hosterSettings)) {
hosterSettings[name] = {
...HOSTER_SETTINGS_DEFAULTS,
...(data.hosterSettings && data.hosterSettings[name] || {})
};
}
const savedGlobal = data.globalSettings || {};
const globalSettings = {
...DEFAULTS.globalSettings,
...savedGlobal
};
// Deep-merge nested objects so new keys are always present
for (const key of Object.keys(DEFAULTS.globalSettings)) {
const def = DEFAULTS.globalSettings[key];
if (def && typeof def === 'object' && !Array.isArray(def)) {
globalSettings[key] = { ...def, ...(savedGlobal[key] || {}) };
}
}
// Normalize logMode at this single boundary. Legacy sessionLog: true
// means *daily* (the old field was named after a misnomer); see log-mode.js.
// Downstream readers consume logMode only and must NOT derive from
// sessionLog at call sites.
globalSettings.logMode = normalizeLogMode(globalSettings);
const rotationCursors = (data.rotationCursors && typeof data.rotationCursors === 'object' && !Array.isArray(data.rotationCursors))
? data.rotationCursors
: {};
const result = { hosters, hosterSettings, globalSettings, history: this._historyMigrated ? [] : (data.history || []), rotationCursors };
// Decrypt credentials stored with safeStorage so the rest of the app
// keeps working with plaintext in memory.
secretStore.decryptCredentials(result);
if (stat) {
this._cache = result;
this._cacheKey = statKey;
}
return this._clone(result);
} catch {
const fresh = JSON.parse(JSON.stringify(DEFAULTS));
fresh.globalSettings.logMode = normalizeLogMode(fresh.globalSettings);
return fresh;
}
}
// Encrypt credential fields without mutating the caller's plaintext object.
// Only `hosters` carries credentials, so we clone ONLY that subtree — the rest
// (history, globalSettings, …) is referenced read-only into the stringified
// object. Deep-cloning the whole config here (incl. an ever-growing history)
// on every write was a primary long-running main-thread stall.
_serializeForDisk(config) {
const hosters = this._clone(config.hosters || {});
secretStore.encryptCredentials({ hosters });
return JSON.stringify({ ...config, hosters }, null, 2);
}
_commit(config) {
if (!this._perfLog) return this._atomicWrite(this._serializeForDisk(config));
const t0 = performance.now();
const data = this._serializeForDisk(config);
const dt = performance.now() - t0;
if (dt >= 20) {
const q = this._pqLen(config.globalSettings);
const h = (config.history || []).length;
this._perfLog(`config-serialize wall=${dt.toFixed(0)}ms bytes=${data.length} hist=${h} queue=${q} wqDepth=${this._wqDepth} via=${this._callerTag()}`);
}
return this._atomicWrite(data);
}
_enqueueWrite(fn) {
this._wqDepth++;
const done = () => { this._wqDepth--; };
this._writeQueue = this._writeQueue.then(fn, fn).then(done, done);
return this._writeQueue;
}
_anyHosters(cfg) {
const h = cfg && cfg.hosters;
return !!h && typeof h === 'object' && Object.values(h).some(a => Array.isArray(a) && a.length > 0);
}
_recoverHostersFromDisk() {
for (const p of [this.filePath, this.filePath + '.bak', this.filePath + '.pre-history-split.bak']) {
try {
const raw = fs.readFileSync(p, 'utf-8');
if (!raw || raw.trim().length < 2) continue;
const data = JSON.parse(raw);
if (this._anyHosters(data)) return data.hosters;
} catch {}
}
return null;
}
_guardHosters(current, hostersIntentional) {
if (!hostersIntentional && !this._anyHosters(current)) {
const recovered = this._recoverHostersFromDisk();
if (recovered) {
current.hosters = recovered;
if (this._perfLog) this._perfLog('config-guard: prevented account wipe — restored hosters from on-disk backup after a corrupt/empty read');
}
}
return current;
}
save(config) {
return this._enqueueWrite(() => {
const current = this.load();
if (config.hosters) current.hosters = config.hosters;
if (config.hosterSettings) current.hosterSettings = config.hosterSettings;
if (config.globalSettings) current.globalSettings = config.globalSettings;
this._guardHosters(current, !!config.hosters);
return this._commit(current);
});
}
loadHistory() {
if (this._historyMigrated) {
return this._readHistoryFile() || [];
}
const config = this.load();
return config.history || [];
}
_atomicWrite(data) {
return new Promise((resolve, reject) => {
const tmpPath = this.filePath + '.tmp';
const backupPath = this.filePath + '.bak';
let fd;
try {
fd = fs.openSync(tmpPath, 'w');
fs.writeSync(fd, data);
fs.fsyncSync(fd);
} catch (e) {
try { if (fd !== undefined) fs.closeSync(fd); } catch {}
return reject(e);
}
try { fs.closeSync(fd); } catch {}
Promise.resolve().then(() => {
try {
try {
if (fs.existsSync(this.filePath)) {
const cur = fs.readFileSync(this.filePath, 'utf-8');
if (cur && cur.trim().length > 2) fs.writeFileSync(backupPath, cur, 'utf-8');
}
} catch {}
fs.renameSync(tmpPath, this.filePath);
} catch (e) { return reject(e); }
// Invalidate the read cache: the next load() re-reads + re-merges the
// freshly-written file (the on-disk format is sparse — load() fills
// defaults — so we must NOT serve a pre-merge in-memory object).
this._cache = null;
this._cacheKey = '';
resolve();
});
});
}
appendHistory(entry) {
if (this._historyMigrated) {
return this._enqueueHistoryWrite(() => {
const cur = this._readHistoryFile();
if (cur === null && fs.existsSync(this.historyPath)) return;
const arr = cur || [];
arr.push(entry);
const gs = this.load().globalSettings;
const retention = (gs && gs.historyRetention) || 'all';
const pruned = applyHistoryRetention(arr, retention, Date.now());
return this._writeHistoryFileAtomic(pruned);
});
}
return this._enqueueWrite(() => {
const config = this.load();
config.history.push(entry);
const retention = (config.globalSettings && config.globalSettings.historyRetention) || 'all';
config.history = applyHistoryRetention(config.history, retention, Date.now());
return this._commit(config);
});
}
pruneHistory(retention, opts = {}) {
const dryRun = !!opts.dryRun;
if (this._historyMigrated) {
return this._enqueueHistoryWrite(() => {
const current = this._readHistoryFile() || [];
const beforeBatches = current.length;
const beforeRows = countHistoryRows(current);
const pruned = applyHistoryRetention(current, retention, Date.now());
const result = {
removedBatches: beforeBatches - pruned.length,
removedRows: beforeRows - countHistoryRows(pruned),
keptBatches: pruned.length,
keptRows: countHistoryRows(pruned)
};
if (dryRun) return result;
return this._writeHistoryFileAtomic(pruned)
.then(() => this.save({ globalSettings: { ...this.load().globalSettings, historyRetention: String(retention || 'all') } }))
.then(() => result);
});
}
return this._enqueueWrite(() => {
const config = this.load();
const beforeBatches = config.history.length;
const beforeRows = countHistoryRows(config.history);
const pruned = applyHistoryRetention(config.history, retention, Date.now());
const result = {
removedBatches: beforeBatches - pruned.length,
removedRows: beforeRows - countHistoryRows(pruned),
keptBatches: pruned.length,
keptRows: countHistoryRows(pruned)
};
if (dryRun) return result;
config.history = pruned;
if (config.globalSettings) config.globalSettings.historyRetention = String(retention || 'all');
return this._commit(config).then(() => result);
});
}
clearHistory() {
if (this._historyMigrated) {
return this._enqueueHistoryWrite(() => this._writeHistoryFileAtomic([]));
}
return this._enqueueWrite(() => {
const config = this.load();
config.history = [];
return this._commit(config);
});
}
saveRotationCursors(cursors) {
return this._enqueueWrite(() => {
const config = this.load();
config.rotationCursors = (cursors && typeof cursors === 'object' && !Array.isArray(cursors)) ? cursors : {};
this._guardHosters(config, false);
return this._commit(config);
});
}
}
module.exports = ConfigStore;
module.exports.HOSTER_ACCOUNT_TEMPLATES = HOSTER_ACCOUNT_TEMPLATES;
module.exports.HOSTER_NAMES = HOSTER_NAMES;
module.exports.HOSTER_ADD_OPTIONS = HOSTER_ADD_OPTIONS;
module.exports.HISTORY_RETENTION_OPTIONS = HISTORY_RETENTION_OPTIONS;
module.exports.applyHistoryRetention = applyHistoryRetention;
module.exports.countHistoryRows = countHistoryRows;
+32
View File
@@ -0,0 +1,32 @@
function createAgent(collectors) {
const OPS = {
get_system_info: (a) => collectors.getSystemInfo(a),
server_health: (a) => collectors.serverHealth(a),
get_config_redacted: (a) => collectors.getConfigRedacted(a),
list_logs: () => collectors.listLogs(),
read_log: (a) => collectors.readLog(a),
tail_log: (a) => collectors.readLog(a),
get_app_events: (a) => collectors.getAppEvents(a),
list_errors: (a) => collectors.listErrors(a),
get_queue_state: (a) => collectors.getQueueState(a),
get_history: (a) => collectors.getHistory(a),
get_rotation_state: () => collectors.getRotationState(),
get_health: () => collectors.getHealth()
};
function handle(op, args) {
const fn = (typeof op === 'string' && Object.prototype.hasOwnProperty.call(OPS, op)) ? OPS[op] : null;
if (typeof fn !== 'function') return { ok: false, error: `unknown or non-readonly op: ${op}` };
try {
const data = fn(args || {});
if (data && data.ok === false) return data;
return { ok: true, data };
} catch (e) {
return { ok: false, error: String((e && e.message) || e) };
}
}
return { handle, ops: Object.keys(OPS) };
}
module.exports = { createAgent };
+277
View File
@@ -0,0 +1,277 @@
const fs = require('fs');
const path = require('path');
const READABLE_LOGS = {
debug: 'debug',
fileuploader: 'fileuploader',
accountRotation: 'accountRotation',
crash: 'crashLog'
};
const QUEUE_STATUSES = ['preview', 'queued', 'getting-server', 'uploading', 'retrying', 'done', 'error', 'aborted', 'skipped'];
function createCollectors(deps) {
const { loadConfig, loadHistory, getAllLogPaths, support, stats, appInfo, systemInfo, agentInfo } = deps;
function _secrets() {
try { return support.collectSecretValues(loadConfig()); } catch { return []; }
}
function _deepRedact(value, secrets) {
const s = secrets || _secrets();
const walk = (v) => {
if (typeof v === 'string') return support.redactLogText(v, s);
if (Array.isArray(v)) return v.map(walk);
if (v && typeof v === 'object') {
const o = {};
for (const k of Object.keys(v)) o[k] = walk(v[k]);
return o;
}
return v;
};
try { return walk(value); } catch { return value; }
}
function _resolveLogPath(name, backup) {
const key = READABLE_LOGS[name];
if (!key) return null;
const paths = getAllLogPaths();
let p = paths[key];
if (!p) return null;
if (backup === 1 || backup === 2) p = `${p}.${backup}`;
return p;
}
function getSystemInfo() {
return { app: appInfo(), system: systemInfo(), agent: agentInfo() };
}
function getConfigRedacted(args) {
const section = (args && args.section) || 'all';
const cfg = loadConfig();
const secrets = support.collectSecretValues(cfg);
const sanitized = support.sanitizeConfig(cfg);
let pick;
let note;
if (section === 'all') {
pick = { ...sanitized };
delete pick.history;
note = 'history omitted from config — use get_history';
} else {
pick = sanitized[section] !== undefined ? sanitized[section] : null;
}
return { section, note, config: _deepRedact(pick, secrets) };
}
function listLogs() {
const paths = getAllLogPaths();
const dir = paths.logDir;
const files = [];
for (const [name, key] of Object.entries(READABLE_LOGS)) {
const base = paths[key];
if (!base) continue;
const variants = [];
for (const suffix of ['', '.1', '.2']) {
const fp = base + suffix;
try {
const st = fs.statSync(fp);
variants.push({ backup: suffix === '' ? 0 : Number(suffix.slice(1)), sizeBytes: st.size, mtime: st.mtime.toISOString() });
} catch {}
}
files.push({ name, path: base, readable: true, present: variants.length > 0, variants });
}
let siblings = [];
try {
siblings = fs.readdirSync(dir)
.filter(f => /\.log(\.\d+)?$/i.test(f))
.filter(f => !files.some(x => path.basename(x.path) === f || f.startsWith(path.basename(x.path))));
siblings = siblings.map(f => {
let size = 0, mtime = null;
try { const st = fs.statSync(path.join(dir, f)); size = st.size; mtime = st.mtime.toISOString(); } catch {}
return { name: f, readable: false, sizeBytes: size, mtime };
});
} catch {}
return { dir, files, otherLogs: siblings };
}
function readLog(args) {
const a = args || {};
const name = a.name;
const p = _resolveLogPath(name, a.backup);
if (!p) return { ok: false, error: `unknown or non-readable log: ${name}` };
const tailKb = Math.min(Math.max(Number(a.tailKb) || 256, 1), 1024);
const raw = support.collectFile(p, name, tailKb * 1024);
let content = support.redactLogText(raw, _secrets());
let matchedLines;
if (a.grep && typeof a.grep === 'string' && a.grep.length <= 200) {
const terms = a.grep.split('|').map(s => s.trim().toLowerCase()).filter(Boolean);
if (terms.length) {
const lines = content.split('\n').filter(l => {
const low = l.toLowerCase();
return terms.some(t => low.includes(t));
});
matchedLines = lines.length;
content = lines.join('\n');
}
}
let sizeBytes = null;
try { sizeBytes = fs.statSync(p).size; } catch {}
return { name, path: p, sizeBytes, returnedBytes: Buffer.byteLength(content), tailKb, matchedLines, content };
}
function getAppEvents(args) {
const limit = Math.min(Math.max(Number(args && args.limit) || 50, 1), 500);
const out = [];
const secrets = _secrets();
for (const name of ['crash', 'debug']) {
const p = _resolveLogPath(name);
if (!p) continue;
const raw = support.redactLogText(support.collectFile(p, name, 256 * 1024), secrets);
const lines = raw.split('\n').filter(l => l.trim() && !l.startsWith('==='));
for (const line of lines.slice(-limit)) out.push({ source: name, text: line });
}
return { events: out.slice(-limit), truncated: out.length > limit };
}
function _historyErrors(history, opts) {
const o = opts || {};
const sinceMs = Number.isFinite(o.sinceMs) ? o.sinceMs : null;
const secrets = _secrets();
const errors = [];
const byCategory = {};
for (const batch of (Array.isArray(history) ? history : [])) {
if (!batch || !Array.isArray(batch.files)) continue;
const ts = batch.timestamp ? Date.parse(batch.timestamp) : null;
if (sinceMs !== null && ts !== null && ts < sinceMs) continue;
for (const file of batch.files) {
if (!file || !Array.isArray(file.results)) continue;
for (const r of file.results) {
if (!r || r.status === 'done') continue;
const category = stats.classifyErrorCategory(r.error);
if (o.category && o.category !== category) continue;
if (o.hoster && o.hoster !== r.hoster) continue;
byCategory[category] = (byCategory[category] || 0) + 1;
errors.push({
ts: batch.timestamp || null,
fileName: file.name || file.fileName || '',
hoster: r.hoster || '',
accountId: r.accountId || undefined,
category,
error: support.redactLogText(String(r.error || ''), secrets)
});
}
}
}
return { errors, byCategory };
}
function listErrors(args) {
const a = args || {};
const cfg = loadConfig();
const { errors, byCategory } = _historyErrors(cfg.history, a);
const limit = Math.min(Math.max(Number(a.limit) || 100, 1), 1000);
const window = Number.isFinite(a.sinceMs) ? `since ${new Date(a.sinceMs).toISOString()}` : 'all history';
return { window, total: errors.length, byCategory, errors: errors.slice(-limit) };
}
function getQueueState(args) {
const a = args || {};
const cfg = loadConfig();
const pending = cfg.globalSettings && cfg.globalSettings.pendingQueue;
if (!pending || typeof pending !== 'object') {
return { source: 'empty', stale: false, counts: {}, selectedHosters: [] };
}
const counts = {};
for (const s of QUEUE_STATUSES) counts[s] = 0;
const jobs = Array.isArray(pending.queueJobs) ? pending.queueJobs : [];
for (const j of jobs) { if (counts[j.status] !== undefined) counts[j.status]++; }
const result = {
source: 'persisted',
stale: true,
savedAt: pending.savedAt || null,
selectedHosters: Array.isArray(pending.selectedUploadHosters) ? pending.selectedUploadHosters : [],
fileCount: Array.isArray(pending.selectedFiles) ? pending.selectedFiles.length : 0,
counts
};
if (a.includeJobs !== false) {
const maxJobs = Math.min(Math.max(Number(a.maxJobs) || 200, 1), 2000);
result.jobs = _deepRedact(jobs.slice(0, maxJobs).map(j => ({
file: j.file, fileName: j.fileName, hoster: j.hoster, status: j.status, error: j.error || null
})));
result.jobsTruncated = jobs.length > maxJobs;
}
return result;
}
function getHistory(args) {
const a = args || {};
const history = typeof loadHistory === 'function'
? (loadHistory() || [])
: (Array.isArray(loadConfig().history) ? loadConfig().history : []);
const limit = Math.min(Math.max(Number(a.limit) || 20, 1), 200);
const perHoster = stats.summarizePerHoster(history);
const recent = [...history].slice(-limit).reverse();
const secrets = _secrets();
const batches = recent.map(b => {
const out = { timestamp: b.timestamp || null, fileCount: Array.isArray(b.files) ? b.files.length : 0 };
if (a.includeFiles) {
out.files = (b.files || []).map(f => ({
name: f.name || f.fileName || '',
results: (f.results || []).map(r => {
const rr = { hoster: r.hoster, status: r.status };
if (r.error) rr.error = support.redactLogText(String(r.error), secrets);
if (a.includeUrls && r.url) rr.url = r.url;
return rr;
})
}));
}
return out;
});
return { totalBatches: history.length, returned: batches.length, perHoster, batches };
}
function getRotationState() {
const cfg = loadConfig();
return { rotationCursors: _deepRedact(cfg.rotationCursors || {}) };
}
function getHealth() {
const cfg = loadConfig();
const hosters = cfg.hosters && typeof cfg.hosters === 'object' ? Object.keys(cfg.hosters).filter(h => Array.isArray(cfg.hosters[h]) && cfg.hosters[h].length > 0) : [];
return {
reachabilityKnown: false,
hint: 'Live hoster probing (run_health_check) is disabled in this build. Configured hosters with at least one account are listed.',
configuredHosters: hosters
};
}
function serverHealth(args) {
const a = args || {};
const errorLimit = Math.min(Math.max(Number(a.errorLimit) || 20, 1), 200);
const errArgs = Number.isFinite(a.errorSinceMs) ? { sinceMs: a.errorSinceMs, limit: errorLimit } : { limit: errorLimit };
const errors = listErrors(errArgs);
const queue = getQueueState({ includeJobs: false });
const history = getHistory({ limit: 5 });
const warnings = [];
if (queue.source === 'persisted' && queue.stale) warnings.push('queue state is from the persisted snapshot (may lag live state; UploadManager not introspected in this build).');
if (errors.total > 0) warnings.push(`${errors.total} non-success result(s) in the error window.`);
return {
server: getSystemInfo(),
queue,
recentBatches: history.batches,
perHoster: history.perHoster,
errors,
hosters: getHealth(),
logs: listLogs(),
warnings
};
}
return {
getSystemInfo, getConfigRedacted, listLogs, readLog, getAppEvents,
listErrors, getQueueState, getHistory, getRotationState, getHealth, serverHealth,
READABLE_LOGS
};
}
module.exports = { createCollectors, READABLE_LOGS };
+705
View File
@@ -0,0 +1,705 @@
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { request } = require('undici');
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';
const UPLOAD_TIMEOUT = 1800000; // 30 min
// Cap doodstream's per-hoster debug log alongside the main log files so
// dev-mode sessions don't accumulate gigabytes of upload trace.
const { maybeRotateLogFile } = require('./log-rotation');
const _DOODSTREAM_LOG_MAX_BYTES = 10 * 1024 * 1024;
const _DOODSTREAM_LOG_MAX_BACKUPS = 1;
// Resolve the log path at write-time. In a packaged build __dirname lives
// inside app.asar (read-only) — writing there fails silently and we lose every
// production trace. Prefer Electron's writable userData dir, fall back to the
// repo root only when running outside Electron (tests / plain node).
function _doodstreamLogPath() {
try {
const { app } = require('electron');
if (app && typeof app.getPath === 'function') {
return path.join(app.getPath('userData'), 'doodstream-debug.log');
}
} catch { /* not running under Electron */ }
return path.join(__dirname, '..', 'doodstream-debug.log');
}
let _debugVerbose = false;
function setDebugVerbose(v) { _debugVerbose = !!v; }
function _debugLog(msg) {
if (!_debugVerbose) return;
try {
const logPath = _doodstreamLogPath();
maybeRotateLogFile(logPath, _DOODSTREAM_LOG_MAX_BYTES, _DOODSTREAM_LOG_MAX_BACKUPS);
const ts = new Date().toISOString();
fs.appendFileSync(logPath, `[${ts}] ${msg}\n`);
} catch {}
}
class DoodstreamUploader {
constructor() {
this.cookies = new Map();
this.sessId = '';
this.apiKey = ''; // optionally derived from the logged-in session (deriveApiKey)
}
_cookieHeader() {
return Array.from(this.cookies.entries())
.map(([k, v]) => `${k}=${v}`)
.join('; ');
}
_parseCookiesFromHeaders(headers) {
let setCookies;
if (typeof headers.getSetCookie === 'function') {
setCookies = headers.getSetCookie();
} else if (headers['set-cookie']) {
setCookies = Array.isArray(headers['set-cookie']) ? headers['set-cookie'] : [headers['set-cookie']];
} else {
return;
}
for (const raw of setCookies) {
const pair = raw.split(';')[0];
const eq = pair.indexOf('=');
if (eq > 0) {
this.cookies.set(pair.substring(0, eq).trim(), pair.substring(eq + 1).trim());
}
}
}
async _fetch(url, opts = {}, _redirectCount = 0) {
const MAX_REDIRECTS = 10;
const headers = {
'User-Agent': USER_AGENT,
...(opts.headers || {})
};
if (this.cookies.size > 0) {
headers['Cookie'] = this._cookieHeader();
}
// The small discovery/result requests that bookend a multi-minute upload
// occasionally hit a transient blip ("fetch failed", ECONNRESET, a hung TLS
// handshake). A blip here shouldn't throw away the whole upload, so retry a
// few times with short backoff. Each attempt gets its own 20s timeout —
// Node's fetch has none by default, and a hung socket would otherwise stall
// the attempt for minutes. The big file upload (undici) is retried at the
// upload-manager level, not here.
let res;
for (let attempt = 1; ; attempt++) {
const timeoutSignal = AbortSignal.timeout(20000);
const signal = opts.signal ? AbortSignal.any([opts.signal, timeoutSignal]) : timeoutSignal;
try {
res = await fetch(url, { ...opts, headers, redirect: 'manual', signal });
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`);
await new Promise(r => setTimeout(r, 400 * attempt));
}
}
this._parseCookiesFromHeaders(res.headers);
if ([301, 302, 303, 307, 308].includes(res.status)) {
try { await res.text(); } catch {}
if (_redirectCount >= MAX_REDIRECTS) throw new Error('Zu viele Redirects');
const location = res.headers.get('location');
if (location) {
const nextUrl = new URL(location, url).href;
return this._fetch(nextUrl, { ...opts, method: 'GET', body: undefined }, _redirectCount + 1);
}
}
return res;
}
/**
* Login to DoodStream via web form
*/
async login(username, password, otp) {
// GET homepage first to collect cookies
const homeRes = await this._fetch(BASE_URL);
await homeRes.text();
// POST login via AJAX (op in body, XHR header required for JSON response)
const loginData = new URLSearchParams({
op: 'login_ajax',
login: username,
password: password,
loginotp: otp || ''
});
// Use raw fetch with redirect: 'manual' to detect success redirects
const headers = {
'Content-Type': 'application/x-www-form-urlencoded',
'Referer': BASE_URL + '/',
'X-Requested-With': 'XMLHttpRequest',
'User-Agent': USER_AGENT
};
if (this.cookies.size > 0) {
headers['Cookie'] = this._cookieHeader();
}
const res = await fetch(BASE_URL + '/', {
method: 'POST',
body: loginData.toString(),
headers,
redirect: 'manual'
});
this._parseCookiesFromHeaders(res.headers);
// On successful login, server may redirect (3xx) to dashboard
if ([301, 302, 303, 307, 308].includes(res.status)) {
try { await res.text(); } catch {}
// Redirect means login succeeded
} else {
const body = await res.text();
let json;
try { json = JSON.parse(body); } catch { json = null; }
if (json && json.status === 'success') {
// 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}`);
err.otpRequired = true;
throw err;
} else if (json && json.status === 'fail') {
throw new Error(`Doodstream Login: ${json.message || 'Login fehlgeschlagen'}`);
} 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}`);
}
}
// Extract sess_id from the upload page
await this._extractSessId();
}
async _extractSessId() {
const res = await this._fetch(BASE_URL + '/?op=upload');
const html = await res.text();
// Hidden input: <input type="hidden" name="sess_id" value="xxx">
const hiddenMatch = html.match(/name=["']sess_id["'][^>]*value=["']([a-zA-Z0-9]+)["']/);
if (hiddenMatch) {
this.sessId = hiddenMatch[1];
return;
}
// Vue component prop or JS: sess_id: "xxx" or sess_id="xxx"
const sessMatch = html.match(/sess_id['":\s]+['"]([a-zA-Z0-9]+)['"]/);
if (sessMatch) {
this.sessId = sessMatch[1];
return;
}
// Assignment: sess_id = 'xxx'
const altMatch = html.match(/sess_id\s*=\s*['"]([a-zA-Z0-9]+)['"]/);
if (altMatch) {
this.sessId = altMatch[1];
return;
}
throw new Error('Doodstream: sess_id nicht gefunden nach Login');
}
/**
* Get upload server URL from web interface
*/
async _getUploadServer() {
// Use the standard upload server endpoint
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)}`);
let json;
try { json = JSON.parse(text); } catch { json = null; }
if (json && json.result && /^https?:\/\//i.test(json.result)) {
return json.result;
}
// Fallback: try fetching from upload page HTML
const pageRes = await this._fetch(BASE_URL + '/?op=upload');
const html = await pageRes.text();
// Current doodstream format: the upload server is the action of the
// multipart upload form, e.g.
// <form name="file" enctype="multipart/form-data"
// action="https://xxx.cloudatacdn.com/upload/01?SESSID" ...>
// <input type="hidden" name="sess_id" value="SESSID">
// The node is assigned per page-load and the action carries a session token
// in its query string that matches the page's hidden sess_id. We refresh
// this.sessId from THIS page so the multipart sess_id field matches the node
// URL — login-time and node tokens otherwise diverge and the upload comes
// back with an empty filecode.
const actionMatch = html.match(/action=["'](https?:\/\/[^"']+\/upload\/[^"']*)["']/i);
if (actionMatch) {
const url = actionMatch[1].replace(/&amp;/g, '&'); // un-escape HTML entities in query
const freshSess = html.match(/name=["']sess_id["'][^>]*value=["']([a-zA-Z0-9]+)["']/);
if (freshSess) {
this.sessId = freshSess[1];
} else {
_debugLog('upload_server: form action found but no sess_id on page; keeping existing sessId');
}
// 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(',')}`);
return url;
}
// Legacy fallback: srv_url JS variable (older doodstream theme).
const srvMatch = html.match(/srv_url['":\s]+['"]?(https?:\/\/[^'">\s]+)['"]?/i);
if (srvMatch) return srvMatch[1];
// 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 geaendert?). ` +
`op=upload_server status=${res.status} ctype=${ctype} body=${(text || '').slice(0, 300)} ` +
`| upload-page URL-Treffer: ${urlHints || 'keine'}`
);
}
/**
* Replicate the non-file fields of doodstream's CURRENT upload form so our
* POST matches what the browser actually submits. Doodstream dropped the old
* `utype` field and added file_title / fakefilepc / submit_btn; submitting a
* stale/incomplete field set can make the node accept the bytes but skip
* registration (→ empty result form). We parse the live form rather than
* hardcode, so we track whatever fields doodstream uses now. The file input
* (type=file) is excluded — the file is streamed separately.
*/
_parseUploadFormFields(html) {
const fields = {};
if (!html) return fields;
// Narrow to the upload form (its action points at a /upload/ node).
const formMatch = html.match(/<form[^>]*\baction=["'][^"']*\/upload\/[^"']*["'][\s\S]*?<\/form>/i);
const scope = formMatch ? formMatch[0] : html;
const re = /<(?:input|button)\b([^>]*)>/gi;
let m;
while ((m = re.exec(scope)) !== null) {
const attrs = m[1];
const typeM = attrs.match(/\btype=["']([^"']*)["']/i);
if (typeM && typeM[1].toLowerCase() === 'file') continue;
const nameM = attrs.match(/\bname=["']([^"']+)["']/i);
if (!nameM) continue;
const valM = attrs.match(/\bvalue=["']([^"']*)["']/i);
fields[nameM[1]] = valM ? valM[1] : '';
}
return fields;
}
/**
* Upload file using web session
*/
async upload(filePath, progressCb, signal, throttle) {
const fileName = path.basename(filePath);
const fileSize = fs.statSync(filePath).size;
// Get upload server
const uploadUrl = await this._getUploadServer();
// Remember which CDN node handled this upload so a later parse failure can
// report it — failures sometimes correlate with a specific node.
this._lastUploadUrl = uploadUrl;
// Build multipart form
const boundary = `----WebKitFormBoundary${crypto.randomBytes(16).toString('hex')}`;
// Build form parts. Submit the live form's fields (parsed in
// _getUploadServer) so our POST matches the browser; merge in sess_id (the
// fresh node token) and keep utype=reg as a harmless compatibility extra.
// Falls back to the minimal known-good set if the form wasn't parsed.
const formFields = { utype: 'reg', ...(this._uploadFormFields || {}) };
formFields.sess_id = this.sessId;
let preamble = '';
for (const [name, value] of Object.entries(formFields)) {
preamble += `--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`;
}
const safeFileName = fileName.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
preamble += `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${safeFileName}"\r\nContent-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;
const CHUNK_SIZE = 1024 * 1024;
let bytesRead = 0;
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 (progressCb) progressCb(bytesRead, fileSize);
}
yield epilogueBuf;
}
let uploadRes;
try {
uploadRes = await request(uploadUrl, {
method: 'POST',
headers: {
'Content-Type': `multipart/form-data; boundary=${boundary}`,
'Content-Length': String(totalSize),
'User-Agent': USER_AGENT,
'Cookie': this._cookieHeader()
},
body: generate(),
signal,
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.
const mb = Math.round(bytesRead / 1048576);
throw new Error(`Doodstream Upload-POST (${mb} MB an ${uploadUrl}): ${err && err.message ? err.message : err}`);
}
const statusCode = uploadRes.statusCode;
_debugLog(`Upload response status: ${statusCode}`);
// Handle redirects from upload server (undici doesn't follow them)
if ([301, 302, 303, 307, 308].includes(statusCode)) {
const location = uploadRes.headers['location'];
try { await uploadRes.body.text(); } catch {}
_debugLog(`Upload redirect to: ${location}`);
if (location) {
return this._handleUploadResult(location);
}
}
const resText = await uploadRes.body.text();
_debugLog(`Upload response body (first 500): ${resText.slice(0, 500)}`);
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}`);
}
return this._parseUploadResponse(resText);
}
/**
* Follow a redirect URL from upload server and extract filecode
*/
async _handleUploadResult(url) {
_debugLog(`Following upload result URL: ${url}`);
const res = await this._fetch(url);
const html = await res.text();
_debugLog(`Result page (first 500): ${html.slice(0, 500)}`);
return this._parseUploadResponse(html);
}
/**
* Extract hidden form fields from HTML (handles various attribute orders)
*/
_extractHiddenFields(html) {
const fields = {};
// Textarea fields: <textarea name="op">upload_result</textarea>
const ta = /<textarea[^>]*name=['"]([^'"]+)['"][^>]*>([\s\S]*?)<\/textarea>/gi;
let m;
while ((m = ta.exec(html)) !== null) fields[m[1]] = m[2].trim();
// Input hidden fields
const p1 = /<input[^>]*type=['"]hidden['"][^>]*name=['"]([^'"]+)['"][^>]*value=['"]([^'"]*)['"]/gi;
while ((m = p1.exec(html)) !== null) { if (!fields[m[1]]) fields[m[1]] = m[2]; }
const p2 = /<input[^>]*name=['"]([^'"]+)['"][^>]*value=['"]([^'"]*)['"]/gi;
while ((m = p2.exec(html)) !== null) { if (!fields[m[1]]) fields[m[1]] = m[2]; }
const p3 = /<input[^>]*value=['"]([^'"]*)['"]\s[^>]*name=['"]([^'"]+)['"]/gi;
while ((m = p3.exec(html)) !== null) { if (!fields[m[2]]) fields[m[2]] = m[1]; }
return fields;
}
/**
* Parse filecode from upload server response (JSON or HTML)
*/
async _parseUploadResponse(resText) {
// 1. Try JSON
let payload;
try { payload = JSON.parse(resText); } catch {}
if (payload) {
return this._extractFromJson(payload);
}
// 2. Try filecode directly in HTML
const code = this._findFilecodeInHtml(resText);
if (code) {
_debugLog(`Found filecode in HTML: ${code}`);
return this._buildResult(code);
}
// 3. Parse HTML form (XFileSharing two-step upload)
const hiddenFields = this._extractHiddenFields(resText);
_debugLog(`Hidden fields: ${JSON.stringify(hiddenFields)}`);
// 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}`);
// We still need to submit the form so doodstream registers the file
// But the filecode is the 'fn' value
}
// XFileSharing standard: form with op=upload_result, fn, st
// Always submit to doodstream.com, not to CDN
if (hiddenFields.fn || hiddenFields.op === 'upload_result') {
// 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)}`);
const formData = new URLSearchParams(hiddenFields);
let followText = '';
try {
const followRes = await this._fetch(BASE_URL + '/', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Referer': BASE_URL + '/'
},
body: formData.toString()
});
followText = await followRes.text();
} catch (err) {
// 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}`);
return this._buildResult(fnCode);
}
throw err;
}
_debugLog(`upload_result response (first 500): ${followText.slice(0, 500)}`);
// Try to find filecode in result page
const resultCode = this._findFilecodeInHtml(followText);
if (resultCode) {
return this._buildResult(resultCode);
}
// If we had fn from hidden fields, use that as filecode
if (fnCode && fnCode.length >= 8) {
return this._buildResult(fnCode);
}
// Try download URL pattern in result page
const dlMatch = followText.match(/https?:\/\/[a-z0-9.]+\/d\/([a-zA-Z0-9]+)/i);
if (dlMatch) {
return this._buildResult(dlMatch[1]);
}
// No filecode anywhere. Surface WHY: XFileSharing puts the real reason
// in the `st` field (anything other than "OK" means the backend refused
// the file — copyright/hash match, duplicate, size, quota, …). The
// 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)}`);
if (st && st !== 'OK') {
throw new Error(`Doodstream lehnt Datei ab (Server-Status: ${st}). CDN=${node}`);
}
// 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
// upload-manager classifies this as a hoster-transient error and does NOT
// blacklist the account (otherwise one of these flakes poisons the whole
// 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 zurueck (st=${st || '?'}, fn=${fnInfo}, CDN=${node}). CDN-Antwort: ${(resText || '').slice(0, 200)}`);
emptyLinkErr.hosterTransient = true;
throw emptyLinkErr;
}
// 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]}`);
const formData = new URLSearchParams(hiddenFields);
const followRes = await this._fetch(formAction[1], {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Referer': BASE_URL + '/'
},
body: formData.toString()
});
const followText = await followRes.text();
_debugLog(`Fallback response (first 500): ${followText.slice(0, 500)}`);
const fallbackCode = this._findFilecodeInHtml(followText);
if (fallbackCode) return this._buildResult(fallbackCode);
// Check if fn was in original hidden fields
if (fnCode && fnCode.length >= 8) return this._buildResult(fnCode);
throw new Error(`Doodstream Upload: Redirect-Antwort ungueltig (${followText.slice(0, 150)})`);
}
throw new Error(`Doodstream Upload: Keine gueltige Antwort (Body: ${resText.slice(0, 150)})`);
}
/**
* Search for filecode patterns in HTML
*/
_findFilecodeInHtml(html) {
// filecode: "xxx" or filecode = "xxx"
const m1 = html.match(/filecode['":\s]+['"]([a-zA-Z0-9]{8,})['"]/i);
if (m1) return m1[1];
// file_code: "xxx"
const m2 = html.match(/file_code['":\s]+['"]([a-zA-Z0-9]{8,})['"]/i);
if (m2) return m2[1];
// Download URL pattern: /d/FILECODE
const m3 = html.match(/\/d\/([a-zA-Z0-9]{8,})/);
if (m3) return m3[1];
return null;
}
/**
* Extract result from JSON payload
*/
_extractFromJson(payload) {
if (payload.status && Number(payload.status) !== 200 && payload.msg) {
throw new Error(`Doodstream Upload: ${payload.msg}`);
}
let item = null;
const result = payload.result;
if (Array.isArray(result) && result.length > 0) {
item = result[0];
} else if (typeof result === 'object' && result) {
item = result;
}
if (!item) {
throw new Error(`Doodstream Upload fehlgeschlagen: ${payload.msg || JSON.stringify(payload).slice(0, 150)}`);
}
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
};
}
_buildResult(fileCode) {
return {
download_url: `https://doodstream.com/d/${fileCode}`,
embed_url: `https://doodstream.com/e/${fileCode}`,
file_code: fileCode
};
}
/**
* Pull candidate API-key tokens out of a logged-in settings page. We do NOT
* rely on knowing doodstream's exact (cookie-gated, unseen) settings DOM —
* instead we gather every plausible long token from form-field values and
* element contents, ranked so tokens near an "api" mention are tried first.
* The caller validates each against the official API, so a wrong guess is
* harmless (it just fails validation). Returned newest-/most-likely-first.
*/
_extractApiKeyCandidates(html) {
if (!html) return [];
const cands = new Set();
const patterns = [
/value=["']([A-Za-z0-9]{20,})["']/gi, // <input value="KEY">
/<(?:textarea|code|span|pre|input)[^>]*>\s*([A-Za-z0-9]{20,})\s*</gi, // <textarea>KEY</textarea>
/\b(?:api[_-]?key|apikey)\b["':\s=>]*["']?([A-Za-z0-9]{20,})/gi // api_key: "KEY"
];
for (const re of patterns) {
let m;
while ((m = re.exec(html)) !== null) cands.add(m[1]);
}
// Rank tokens whose preceding context mentions "api" ahead of the rest.
return [...cands]
.map(t => {
const idx = html.indexOf(t);
const ctx = html.slice(Math.max(0, idx - 160), idx).toLowerCase();
return { t, near: /api/.test(ctx) ? 0 : 1 };
})
.sort((a, b) => a.near - b.near)
.map(s => s.t);
}
/**
* Validate a candidate key against the official API. Only the account's real
* key returns status 200, so this is what makes the brute-force extraction
* safe regardless of the settings-page markup.
*/
async _validateApiKey(key) {
try {
const res = await fetch(`https://doodapi.co/api/account/info?key=${encodeURIComponent(key)}`, {
method: 'GET', redirect: 'follow', signal: AbortSignal.timeout(15000)
});
const json = await res.json().catch(() => null);
return !!(json && Number(json.status) === 200);
} catch {
return false;
}
}
/**
* Derive the account's doodapi API key from the logged-in web session, so a
* login-only account can upload via the reliable JSON API (which returns the
* filecode directly) instead of the fragile web upload form. Best-effort:
* returns null if no valid key can be found, and the caller falls back to the
* web-form upload. Requires login() to have run first (needs the cookies).
*/
async deriveApiKey() {
if (this.apiKey) return this.apiKey;
let html = '';
for (const page of ['/?op=my_account', '/settings', '/?op=profile']) {
try {
const res = await this._fetch(BASE_URL + page);
const text = await res.text();
if (text && /api[\s_-]?key/i.test(text)) { html = text; break; }
if (text && !html) html = text;
} catch { /* try next page */ }
}
const candidates = this._extractApiKeyCandidates(html);
// Cap validation calls (rate limit 10/s; settings page yields few tokens).
for (const key of candidates.slice(0, 15)) {
if (await this._validateApiKey(key)) {
this.apiKey = key;
_debugLog(`api-key derive: validated key (len ${key.length})`);
return key;
}
}
_debugLog(`api-key derive: ${candidates.length} candidate(s), none validated. settings html(2500)=${(html || '').slice(0, 2500)}`);
return null;
}
}
module.exports = DoodstreamUploader;
module.exports.setDebugVerbose = setDebugVerbose;
+77
View File
@@ -0,0 +1,77 @@
const fs = require('fs');
const SIGNATURES = [
{ kind: 'mp4-iso', test: (b) => b.length >= 12 && b.slice(4, 8).toString('ascii') === 'ftyp' },
{ kind: 'matroska', test: (b) => b.length >= 4 && b[0] === 0x1A && b[1] === 0x45 && b[2] === 0xDF && b[3] === 0xA3 },
{ kind: 'avi', test: (b) => b.length >= 12 && b.slice(0, 4).toString('ascii') === 'RIFF' && b.slice(8, 12).toString('ascii') === 'AVI ' },
{ kind: 'wav', test: (b) => b.length >= 12 && b.slice(0, 4).toString('ascii') === 'RIFF' && b.slice(8, 12).toString('ascii') === 'WAVE' },
{ kind: 'flv', test: (b) => b.length >= 3 && b.slice(0, 3).toString('ascii') === 'FLV' },
{ kind: 'asf-wmv', test: (b) => b.length >= 4 && b[0] === 0x30 && b[1] === 0x26 && b[2] === 0xB2 && b[3] === 0x75 },
{ kind: 'mpeg-ps', test: (b) => b.length >= 4 && b[0] === 0x00 && b[1] === 0x00 && b[2] === 0x01 && (b[3] === 0xBA || b[3] === 0xB3) },
{ kind: 'gif', test: (b) => b.length >= 6 && (b.slice(0, 6).toString('ascii') === 'GIF87a' || b.slice(0, 6).toString('ascii') === 'GIF89a') },
// TS demands the 0x47 sync byte every 188 bytes — a single leading 0x47
// matches every GIF and every text file starting with "G", so require
// three consecutive packet boundaries before classifying as video.
{ kind: 'mpeg-ts', test: (b) => b.length >= 377 && b[0] === 0x47 && b[188] === 0x47 && b[376] === 0x47 },
{ kind: 'mp3', test: (b) => b.length >= 3 && (b.slice(0, 3).toString('ascii') === 'ID3' || (b[0] === 0xFF && (b[1] & 0xE0) === 0xE0)) },
{ kind: 'ogg', test: (b) => b.length >= 4 && b.slice(0, 4).toString('ascii') === 'OggS' },
{ kind: 'jpeg', test: (b) => b.length >= 3 && b[0] === 0xFF && b[1] === 0xD8 && b[2] === 0xFF },
{ kind: 'png', test: (b) => b.length >= 8 && b[0] === 0x89 && b.slice(1, 4).toString('ascii') === 'PNG' },
{ kind: 'pdf', test: (b) => b.length >= 5 && b.slice(0, 5).toString('ascii') === '%PDF-' },
{ kind: 'zip', test: (b) => b.length >= 4 && b[0] === 0x50 && b[1] === 0x4B && (b[2] === 0x03 || b[2] === 0x05 || b[2] === 0x07) },
{ kind: 'html', test: (b) => {
const s = b.toString('ascii', 0, Math.min(b.length, 64)).trimStart().toLowerCase();
return s.startsWith('<!doctype html') || s.startsWith('<html');
} }
];
const VIDEO_KINDS = new Set(['mp4-iso', 'matroska', 'avi', 'flv', 'asf-wmv', 'mpeg-ps', 'mpeg-ts']);
function detectKind(buf) {
if (!buf || buf.length === 0) return 'empty';
for (const sig of SIGNATURES) {
try { if (sig.test(buf)) return sig.kind; } catch { /* ignore malformed buffer slice */ }
}
return 'unknown';
}
function isVideoLikeKind(kind) {
return VIDEO_KINDS.has(kind);
}
function probeFileHead(filePath, bytes) {
const want = Number.isFinite(bytes) && bytes > 0 ? bytes : 64;
return new Promise((resolve) => {
fs.open(filePath, 'r', (err, fd) => {
if (err) return resolve({ ok: false, error: err.message, kind: 'unreadable' });
const buf = Buffer.alloc(want);
fs.read(fd, buf, 0, want, 0, (rerr, bytesRead) => {
fs.close(fd, () => {});
if (rerr) return resolve({ ok: false, error: rerr.message, kind: 'unreadable' });
const slice = buf.slice(0, bytesRead);
resolve({
ok: true,
bytesRead,
kind: detectKind(slice),
isVideoLike: isVideoLikeKind(detectKind(slice)),
headHex: slice.toString('hex')
});
});
});
});
}
function summarizeFileStat(filePath) {
try {
const st = fs.statSync(filePath);
return {
size: st.size,
mtime: st.mtime.toISOString(),
isFile: st.isFile()
};
} catch (err) {
return { error: err.message };
}
}
module.exports = { detectKind, isVideoLikeKind, probeFileHead, summarizeFileStat, VIDEO_KINDS, SIGNATURES };
+103
View File
@@ -0,0 +1,103 @@
const { EventEmitter } = require('events');
const path = require('path');
const chokidar = require('chokidar');
class FolderMonitor extends EventEmitter {
constructor() {
super();
this._watcher = null;
this._settings = null;
this._seenFiles = new Set();
this._batchBuffer = [];
this._batchTimer = null;
}
get running() {
return !!this._watcher;
}
start(settings) {
this.stop();
this._settings = settings;
const folderPath = String(settings.folderPath || '').trim();
if (!folderPath) throw new Error('Kein Ordnerpfad angegeben');
const watchOptions = {
persistent: true,
ignoreInitial: true,
depth: settings.recursive ? undefined : 0,
awaitWriteFinish: {
stabilityThreshold: Math.max(1000, (settings.delaySec || 3) * 1000),
pollInterval: 500
}
};
this._watcher = chokidar.watch(folderPath, watchOptions);
this._watcher.on('add', (filePath) => this._onNewFile(filePath));
this._watcher.on('unlink', (filePath) => {
// Allow re-added files (e.g. re-encoded) to be detected again
const normalized = filePath.replace(/\\/g, '/').toLowerCase();
this._seenFiles.delete(normalized);
});
this._watcher.on('error', (err) => this.emit('error', err));
}
stop() {
if (this._watcher) {
this._watcher.close().catch(() => {});
this._watcher = null;
}
if (this._batchTimer) {
clearTimeout(this._batchTimer);
this._batchTimer = null;
}
this._batchBuffer = [];
this._seenFiles = new Set();
}
status() {
return {
running: this.running,
folderPath: this._settings ? this._settings.folderPath : '',
seenCount: this._seenFiles.size
};
}
_onNewFile(filePath) {
const settings = this._settings;
if (!settings) return;
// Extension filter
const ext = path.extname(filePath).replace(/^\./, '').toLowerCase();
const rawExtensions = String(settings.extensions || '').trim();
if (rawExtensions) {
const extList = rawExtensions.split(',').map(e => e.trim().toLowerCase().replace(/^\./, '')).filter(Boolean);
if (extList.length > 0) {
const matches = extList.includes(ext);
if (settings.filterMode === 'include' && !matches) return;
if (settings.filterMode === 'exclude' && matches) return;
}
}
// Skip duplicates (session-based)
if (settings.skipDuplicates) {
const normalized = filePath.replace(/\\/g, '/').toLowerCase();
if (this._seenFiles.has(normalized)) return;
this._seenFiles.add(normalized);
}
// Batch: collect files over 200ms window then emit together
this._batchBuffer.push(filePath);
if (this._batchTimer) clearTimeout(this._batchTimer);
this._batchTimer = setTimeout(() => {
const files = this._batchBuffer.splice(0);
this._batchTimer = null;
if (files.length > 0) {
this.emit('new-files', files);
}
}, 200);
}
}
module.exports = FolderMonitor;
+728
View File
@@ -0,0 +1,728 @@
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'],
// No hardcoded fallback node: that stale CDN host (tr1128ve.cloudatacdn.com)
// accepts the bytes but returns an empty result form with no filecode, so a
// failed server lookup must throw cleanly rather than upload ~1 GB into a
// dead end. (Same reasoning as the web-session path's fail-fast.)
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 = {};
// Defensive: also handle direct callers that bypass uploadFile's payload
// normalisation (e.g. unit tests, future callers).
const result = payload && 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) {
// Defensive: bypass-callers may pass null/non-object directly.
if (!payload || typeof payload !== 'object') payload = {};
let file_code = null;
let perFileError = null;
// Primary: files array (per official Byse API docs)
if (Array.isArray(payload.files) && payload.files.length > 0) {
const f = payload.files[0];
file_code = f && (f.filecode || f.file_code) || null;
// Byse returns HTTP 200 + msg=OK even when a specific file was rejected
// ("Not video file format", "Duplicate", "File too small", ...). When
// filecode is empty and status carries a non-OK message, that IS the
// actual per-file error, not a server problem.
if (!file_code && f && f.status && !/^(ok|success|done)$/i.test(String(f.status))) {
perFileError = String(f.status).trim();
}
}
// 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;
}
}
if (!file_code && perFileError) {
// Distinguish account-level from file-level failure. "not enough disk
// space", "quota exceeded", "storage full" etc. mean the ACCOUNT is
// exhausted — every further file on the same account will hit the same
// 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}`);
if (accountLevel) {
err.accountError = true;
} else {
err.fileRejected = true;
// "Not video file format" is byse's known-misleading status: observed
// live (2026-06-09) ONLY on valid MKVs >2.7 GB while the same account
// accepted 1100+ smaller MKVs. Per-account size tiers produce it, and
// async registration can land the file anyway. Flag it suspect so the
// recovery poll still runs and the upload manager may try the file on
// the remaining accounts instead of failing it everywhere.
if (/not video file format/i.test(perFileError)) err.suspectReject = true;
}
throw err;
}
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 = 1024 * 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 {
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;
}
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;
}
return data;
} finally {
clearTimeout(timeout);
if (signal) signal.removeEventListener('abort', onAbort);
}
}
// --- Main upload function ---
async function getUploadServer(hosterName, hosterConfig, apiKey, signal) {
let lastMessage = '';
let lastTransient = false;
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 (err.transientNetwork === true) lastTransient = true;
}
}
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) {
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;
if (lastTransient) e.transientNetwork = true;
throw e;
}
throw new Error('Kein Upload-Server erhalten. API-Key pruefen.');
}
async function _fetchByseFileList(apiKey, signal) {
// 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 [];
}
}
function _normalizeFileTitle(s) {
return String(s || '').toLowerCase().replace(/\.[a-z0-9]+$/i, '').replace(/[^a-z0-9]+/g, '');
}
async function _resolveByseUploadByName(apiKey, fileName, baselineCodes, signal) {
const expected = _normalizeFileTitle(fileName);
const POLL_ATTEMPTS = 15;
const POLL_DELAY_MS = 2000;
for (let i = 0; i < POLL_ATTEMPTS; i++) {
if (signal && signal.aborted) return null;
const list = await _fetchByseFileList(apiKey, signal);
const newFiles = list.filter(f => !baselineCodes.has(f.file_code));
// Exact-normalized filename match ONLY. The old fallback ("only one new
// file → take it") was unsafe during parallel byse uploads: job A's
// 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) {
return {
download_url: `https://byse.sx/d/${match.file_code}`,
embed_url: `https://byse.sx/e/${match.file_code}`,
file_code: match.file_code
};
}
if (i < POLL_ATTEMPTS - 1) {
try {
await sleep(POLL_DELAY_MS, signal);
} catch {
return null;
}
}
}
return null;
}
async function _fetchDoodstreamFileList(apiKey, signal) {
// 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 DOODSTREAM_POLL = { attempts: 12, delayMs: 2500 }; // test-tunable via __test
async function _resolveDoodstreamUploadByName(apiKey, fileName, baselineCodes, signal) {
// 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
// (never "take the only new one") so parallel doodstream uploads can't claim
// each other's files.
const expected = _normalizeFileTitle(fileName);
const POLL_ATTEMPTS = DOODSTREAM_POLL.attempts;
const POLL_DELAY_MS = DOODSTREAM_POLL.delayMs;
for (let i = 0; i < POLL_ATTEMPTS; i++) {
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) {
return {
download_url: `https://doodstream.com/d/${match.file_code}`,
embed_url: `https://doodstream.com/e/${match.file_code}`,
file_code: match.file_code
};
}
if (i < POLL_ATTEMPTS - 1) {
try {
await sleep(POLL_DELAY_MS, signal);
} catch {
return null;
}
}
}
return null;
}
async function uploadFile(hosterName, filePath, apiKey, onProgress, signal, throttle, opts) {
const config = HOSTER_CONFIGS[hosterName];
if (!config) throw new Error(`Unbekannter Hoster: ${hosterName}`);
let byseBaseline = 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));
}
}
let doodBaseline = 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));
}
}
// 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() : '';
const err = new Error(
`Upload-Antwort von ${hosterName} war kein JSON (HTTP ${statusCode}${snippet ? `): ${snippet}` : ')'}`
);
if (statusCode >= 500) err.transientNetwork = true;
throw err;
}
// Normalize valid-but-not-object JSON (JSON.parse('null') → null;
// JSON.parse('"foo"') → string; JSON.parse('[1]') → array). Without this
// the downstream `payload.msg` / `payload.status` / parseResult(payload)
// calls crash with a confusing TypeError instead of letting the existing
// fallback defaults kick in. Arrays from servers that return a top-level
// list (rare but seen in the wild) are kept addressable as `payload.X`
// → undefined, which the parsers already handle.
if (payload === null || typeof payload !== 'object') {
payload = {};
}
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;
}
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;
}
let result = null;
let parseErr = null;
try {
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 */ }
}
parseErr = err;
}
if (result && (result.file_code || result.download_url || result.embed_url)) {
return result;
}
// Explicit rejections skip the recovery poll — EXCEPT suspect ones
// (byse "Not video file format", see parseByseResult): for those the file
// may have registered asynchronously despite the rejection-looking status,
// so the poll must still run. Without this exception the rescue below is
// dead for the very case it documents (regression shipped in 3.3.5x).
// When the caller's file probe positively says the upload is NOT a video
// (opts.probeIsVideoLike === false), the rejection is genuine — skip the
// 30s poll for it like any other explicit rejection.
const suspectBypass = parseErr
&& parseErr.suspectReject === true
&& !(opts && opts.probeIsVideoLike === false);
const explicitlyRejected = parseErr
&& (parseErr.fileRejected === true || parseErr.accountError === true)
&& !suspectBypass;
// Byse-specific async handling: server accepts the file but responds with
// filecode="" + misleading status ("Not video file format"). The file shows
// up in the account shortly after — poll the list to claim it. User observed
// this with 2+ GB MKV uploads that appeared as "OK" on the byse dashboard
// even after our uploader gave up.
if (hosterName === 'byse.sx' && byseBaseline && !explicitlyRejected) {
const fileName = path.basename(filePath);
const polled = await _resolveByseUploadByName(apiKey, fileName, byseBaseline, signal);
if (polled) return polled;
}
// Doodstream: the doodapi upload POST returned no filecode (the same backend
// hiccup that empties the web form). Poll the account file list by name — if
// the file did register, claim its code instead of failing the upload.
if (hosterName === 'doodstream.com' && doodBaseline && !explicitlyRejected) {
const fileName = path.basename(filePath);
const polled = await _resolveDoodstreamUploadByName(apiKey, fileName, doodBaseline, signal);
if (polled) return polled;
}
if (parseErr) throw parseErr;
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);
// 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
// under large-file load. It's a hoster-side flake, NOT an account problem,
// 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 new Error(msg);
}
async function prefetchBaseline(hosterName, apiKey, signal) {
try {
if (hosterName === 'byse.sx') {
const baseline = await _fetchByseFileList(apiKey, signal);
return new Set(baseline.map(f => f.file_code));
}
if (hosterName === 'doodstream.com') {
const baseline = await _fetchDoodstreamFileList(apiKey, signal);
return new Set(baseline.map(f => f.file_code));
}
} catch { /* leave caller to fall back to per-job fetch */ }
return null;
}
module.exports = {
uploadFile,
prefetchBaseline,
HOSTER_CONFIGS,
__test: {
extractUploadServerUrl,
parseVoeResult,
parseDoodstreamResult,
parseByseResult,
DOODSTREAM_POLL
}
};
+50
View File
@@ -0,0 +1,50 @@
function normalizeIp(ip) {
return String(ip || '').trim().replace(/^::ffff:/i, '').toLowerCase();
}
function isLoopbackIp(ip) {
const c = normalizeIp(ip);
return c === '' || c === '::1' || c === 'localhost' || /^127\./.test(c);
}
function ipv4ToInt(ip) {
const parts = String(ip).split('.');
if (parts.length !== 4) return null;
let n = 0;
for (const p of parts) {
if (!/^\d{1,3}$/.test(p)) return null;
const v = Number(p);
if (v < 0 || v > 255) return null;
n = (n << 8) + v;
}
return n >>> 0;
}
function matchIpRule(clientIp, rule) {
const client = normalizeIp(clientIp);
const r = String(rule || '').trim().toLowerCase();
if (!r) return false;
if (r === '*' || r === '0.0.0.0/0') return true;
if (r === client) return true;
const slash = r.indexOf('/');
if (slash > 0) {
const baseInt = ipv4ToInt(r.slice(0, slash));
const clientInt = ipv4ToInt(client);
const bits = Number(r.slice(slash + 1));
if (baseInt === null || clientInt === null || !Number.isInteger(bits) || bits < 0 || bits > 32) return false;
if (bits === 0) return true;
const mask = bits === 32 ? 0xffffffff : (~((1 << (32 - bits)) - 1)) >>> 0;
return (clientInt & mask) === (baseInt & mask);
}
return false;
}
function evaluateClientAllowed(clientIp, rules) {
const client = normalizeIp(clientIp);
if (isLoopbackIp(client)) return true;
const list = Array.isArray(rules) ? rules : [];
if (list.length === 0) return false;
return list.some((rule) => matchIpRule(client, rule));
}
module.exports = { normalizeIp, isLoopbackIp, ipv4ToInt, matchIpRule, evaluateClientAllowed };
+109
View File
@@ -0,0 +1,109 @@
// Log-file mode resolution for fileuploader.log:
// - "single" → one file: fileuploader.log
// - "daily" → per-day: fileuploader-YYYY-MM-DD.log
// - "session" → per-launch: DD-MM-YYYY-mdu-session-HH-MM-NNNNNN.log
//
// Pure functions only — no fs, no Date.now() at call time — so they unit-test
// cleanly and the main.js call sites pass in `new Date()` + the session stamp.
//
// MIGRATION TRAP this lib protects against: the legacy boolean was named
// `sessionLog` but actually toggled *daily* mode. A naive rename would silently
// flip every per-day user onto per-session. normalizeLogMode below maps the
// legacy `sessionLog: true` to "daily", NOT "session". Read logMode everywhere
// downstream; do not derive from sessionLog at call sites.
//
// Loaded both as CommonJS (main.js, tests) and as a browser global
// (renderer/app.js via index.html script tag) so a single implementation backs
// runtime and tests — same pattern as queue-prune.js / queue-dedup.js.
(function (root) {
'use strict';
const VALID_MODES = new Set(['single', 'daily', 'session']);
function normalizeLogMode(globalSettings) {
const gs = globalSettings && typeof globalSettings === 'object' ? globalSettings : {};
if (typeof gs.logMode === 'string' && VALID_MODES.has(gs.logMode)) {
return gs.logMode;
}
// Legacy boolean migration: sessionLog *named* like "session" but actually
// implemented "daily" — preserve daily users on the migration path.
if (gs.sessionLog === true) return 'daily';
return 'single';
}
function _two(n) { return String(n).padStart(2, '0'); }
function formatDateStamp(date) {
return `${date.getFullYear()}-${_two(date.getMonth() + 1)}-${_two(date.getDate())}`;
}
function formatSessionStamp(date, rand) {
const d = `${_two(date.getDate())}-${_two(date.getMonth() + 1)}-${date.getFullYear()}`;
const t = `${_two(date.getHours())}-${_two(date.getMinutes())}`;
const r = (rand !== undefined && rand !== null && String(rand).trim()) ? `-${String(rand).trim()}` : '';
return `${d}-mdu-session-${t}${r}`;
}
/**
* Compute the log filename for the given mode + clock.
* @param {Object} args
* @param {string} args.baseName e.g. "fileuploader"
* @param {string} args.ext e.g. ".log"
* @param {string} args.mode "single" | "daily" | "session"
* @param {Date} args.date current timestamp
* @param {string} [args.sessionId] required when mode === "session"
* @returns {string} the bare filename (no directory)
*/
function resolveLogFileName(args) {
const a = args || {};
const base = String(a.baseName || 'fileuploader');
const ext = String(a.ext || '.log');
const mode = VALID_MODES.has(a.mode) ? a.mode : 'single';
if (mode === 'single') return `${base}${ext}`;
if (mode === 'daily') {
const date = a.date instanceof Date ? a.date : new Date();
return `${base}-${formatDateStamp(date)}${ext}`;
}
// session — the stamp is the full app-defined stem (DD-MM-YYYY-mdu-session-HH-MM),
// independent of baseName.
const sid = a.sessionId && String(a.sessionId).trim();
if (sid) return `${sid}${ext}`;
// Defensive: if a session-id wasn't passed, fall back to single rather
// than emit a malformed name. main.js always supplies one.
return `${base}${ext}`;
}
/**
* Reverse of resolveLogFileName: given a full filename like
* "fileuploader-2026-06-03.log" or
* "fileuploader-session-2026-06-03_18-16-20-8132.log", strip the mode-stamp
* so the bare base ("fileuploader.log") remains. Used when persisting an
* auto-resolved fallback path back into config — otherwise the saved path
* would keep growing a new stamp on every reload.
*/
function stripModeStampFromFileName(fileName) {
if (!fileName || typeof fileName !== 'string') return fileName;
const newSessionRe = /^\d{2}-\d{2}-\d{4}-mdu-session-\d{2}-\d{2}(?:-\d+)?(\.[^.]+)?$/;
const mNew = fileName.match(newSessionRe);
if (mNew) return `fileuploader${mNew[1] || ''}`;
// Order matters: session first (longer, more specific) before daily.
// Both regexes are anchored to $ with no nested/ambiguous quantifiers, so
// matching is linear — the eslint security warning is precautionary.
// eslint-disable-next-line security/detect-unsafe-regex
const sessionRe = /-session-\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}(?:-\d+)?(\.[^.]+)?$/;
// eslint-disable-next-line security/detect-unsafe-regex
const dailyRe = /-\d{4}-\d{2}-\d{2}(\.[^.]+)?$/;
let out = fileName.replace(sessionRe, (m, ext) => ext || '');
out = out.replace(dailyRe, (m, ext) => ext || '');
return out;
}
const api = { normalizeLogMode, resolveLogFileName, formatDateStamp, formatSessionStamp, stripModeStampFromFileName, VALID_MODES };
if (typeof module !== 'undefined' && module.exports) {
module.exports = api;
} else if (root) {
root.LogMode = api;
}
})(typeof window !== 'undefined' ? window : this);
+17
View File
@@ -0,0 +1,17 @@
// Per-hoster upload-log policy. Decides whether a hoster's successful upload
// links get written to fileuploader.log. Pure + dependency-free so it's
// trivially unit-testable and shared between the runtime decision and tests.
//
// Contract: logging is ON unless the hoster's settings explicitly set
// logToFile === false. Missing settings / missing hoster / malformed input
// all default to ON, so the feature is strictly opt-out and never silently
// drops links because a config key wasn't present.
function hosterLogToFileEnabled(hosterSettings, hoster) {
if (!hosterSettings || typeof hosterSettings !== 'object') return true;
const hs = hosterSettings[hoster];
if (!hs || typeof hs !== 'object') return true;
return hs.logToFile !== false;
}
module.exports = { hosterLogToFileEnabled };
+52
View File
@@ -0,0 +1,52 @@
// Generic numbered-backup log rotation. Used by the upload log + can be
// reused by other long-lived log files (debug log, account-rotation log).
//
// Behaviour:
// - File missing → no-op, returns false.
// - File ≤ maxBytes → no-op, returns false.
// - File > maxBytes → drop oldest .N backup, shift .K → .K+1, rename live
// file to .1, return true. Caller (or the next append) creates a fresh
// primary on demand.
//
// Errors are reported via `log` (e.g. debugLog) but never thrown — rotation
// is best-effort; the caller's append happens anyway.
const fs = require('fs');
const path = require('path');
function maybeRotateLogFile(filePath, maxBytes, maxBackups = 3, log = () => {}) {
if (!filePath || !Number.isFinite(maxBytes) || maxBytes <= 0) return false;
let size = 0;
try {
const st = fs.statSync(filePath);
size = st.size;
} catch (err) {
// ENOENT is normal — nothing to rotate yet.
if (err && err.code !== 'ENOENT') {
log(`logRotation: stat ${filePath} failed: ${err.message}`);
}
return false;
}
if (size <= maxBytes) return false;
const ext = path.extname(filePath);
const base = filePath.slice(0, filePath.length - ext.length);
// Drop the oldest backup if it exists, then shift each numbered backup up
// one slot. Errors are ignored: missing intermediate backups are normal,
// failed renames just mean we'll rotate again next time.
try { fs.unlinkSync(`${base}.${maxBackups}${ext}`); } catch {}
for (let i = maxBackups - 1; i >= 1; i--) {
try { fs.renameSync(`${base}.${i}${ext}`, `${base}.${i + 1}${ext}`); } catch {}
}
try {
fs.renameSync(filePath, `${base}.1${ext}`);
log(`logRotation: rotated ${filePath} (${(size / 1024 / 1024).toFixed(1)} MB) → ${base}.1${ext}`);
return true;
} catch (err) {
log(`logRotation: rename ${filePath}${base}.1${ext} failed: ${err.message}`);
return false;
}
}
module.exports = { maybeRotateLogFile };
+29
View File
@@ -0,0 +1,29 @@
(function (root) {
'use strict';
function selectOrphanTmps(fileNames, opts) {
const o = opts || {};
const baseName = String(o.baseName || '');
const currentPid = o.currentPid;
const isAlive = typeof o.isAlive === 'function' ? o.isAlive : () => false;
const out = [];
if (!baseName || !Array.isArray(fileNames)) return out;
const prefix = baseName + '.';
const suffix = '.tmp';
for (const file of fileNames) {
if (typeof file !== 'string') continue;
if (!file.startsWith(prefix) || !file.endsWith(suffix)) continue;
const mid = file.slice(prefix.length, file.length - suffix.length);
if (!/^\d+$/.test(mid)) continue;
const pid = Number(mid);
if (pid === currentPid) continue;
if (isAlive(pid)) continue;
out.push(file);
}
return out;
}
const api = { selectOrphanTmps };
if (typeof module !== 'undefined' && module.exports) module.exports = api;
else if (root) root.OrphanTmp = api;
})(typeof window !== 'undefined' ? window : this);
+113
View File
@@ -0,0 +1,113 @@
// Startup queue auto-dedup logic. Extracted from renderer/app.js
// _autoDeduplicateFromLog so the decision can be unit-tested without a DOM or
// the renderer's module-level state.
//
// Loaded both as a CommonJS module (Node tests) and as a browser global
// (renderer/app.js via index.html script tag) so a single implementation backs
// runtime and tests — no drift.
//
// Behaviour: on launch the restored queue is compared against the lifetime
// upload log. Two rules drop a job:
// 1) a 'done' job whose fileName|hoster appears in the log (declutter of
// already-finished work), and
// 2) ANY job (incl. preview) whose newest matching log entry is timestamped
// at/after the snapshot's savedAt — it provably completed AFTER the queue
// was last persisted, so a restored 'preview' row for it is a stale ghost.
//
// Rule 2 only fires when a savedAt is passed AND the log carries timestamps;
// without them this falls back to rule 1 alone. That fallback is the invariant
// the canary tests pin: a pending job matching an OLDER log line (ts < savedAt,
// or no ts at all) is KEPT — it's an intentional re-upload of a file uploaded
// before, not a ghost. The old code filtered on log-presence alone, regardless
// of status, so the ENTIRE restored queue vanished on the next restart/update
// whenever the files had been uploaded previously. Manual log import
// (importUploadLog) stays separate and explicit for bulk dedup.
(function (root) {
'use strict';
function _key(fileName, hoster) {
return `${String(fileName).toLowerCase()}|${String(hoster).toLowerCase()}`;
}
/**
* Partition restored queue jobs into kept vs removed, given lifetime log
* entries. Removes only 'done' jobs whose fileName|hoster is in the log.
* @param {Array<{status:string,fileName:string,hoster:string}>} jobs
* @param {Array<{fileName:string,hoster:string}>} logEntries
* @returns {{ kept: Array, removed: Array }}
*/
function partitionRestoredJobsByLog(jobs, logEntries, savedAt) {
const kept = [];
const removed = [];
if (!Array.isArray(jobs) || jobs.length === 0) return { kept, removed };
const logKeys = new Set();
const logMaxTs = new Map();
for (const e of (Array.isArray(logEntries) ? logEntries : [])) {
if (e && e.fileName && e.hoster) {
const k = _key(e.fileName, e.hoster);
logKeys.add(k);
if (typeof e.ts === 'number' && isFinite(e.ts)) {
const prev = logMaxTs.get(k);
if (prev === undefined || e.ts > prev) logMaxTs.set(k, e.ts);
}
}
}
const savedAtFloor = (typeof savedAt === 'number' && isFinite(savedAt))
? Math.floor(savedAt / 1000) * 1000
: null;
const filesPerKey = new Map();
for (const job of jobs) {
if (job && job.fileName && job.hoster) {
const jk = _key(job.fileName, job.hoster);
let set = filesPerKey.get(jk);
if (!set) { set = new Set(); filesPerKey.set(jk, set); }
set.add(job.file || '');
}
}
for (const job of jobs) {
const hasIds = job && job.fileName && job.hoster;
const k = hasIds ? _key(job.fileName, job.hoster) : null;
const doneInLog = job && job.status === 'done' && hasIds && logKeys.has(k);
const keyUnambiguous = k !== null && filesPerKey.get(k).size <= 1;
const uploadedAfterSnapshot = savedAtFloor !== null && k !== null && keyUnambiguous
&& logMaxTs.has(k) && logMaxTs.get(k) >= savedAtFloor;
if (doneInLog || uploadedAfterSnapshot) {
removed.push(job);
} else {
kept.push(job);
}
}
return { kept, removed };
}
function completedSelectionKeys(selectedFiles, hosters, logEntries, savedAt) {
const out = [];
if (!Array.isArray(selectedFiles) || !Array.isArray(hosters)) return out;
if (!(typeof savedAt === 'number' && isFinite(savedAt))) return out;
const synthetic = [];
for (const f of selectedFiles) {
if (!f || !f.path) continue;
const name = f.name || String(f.path).split(/[\\/]/).pop();
for (const h of hosters) {
if (h) synthetic.push({ fileName: name, hoster: h, file: f.path, status: 'preview' });
}
}
if (synthetic.length === 0) return out;
const { removed } = partitionRestoredJobsByLog(synthetic, logEntries, savedAt);
for (const job of removed) out.push(`${job.file}|${job.hoster}`);
return out;
}
const api = { partitionRestoredJobsByLog, completedSelectionKeys };
if (typeof module !== 'undefined' && module.exports) {
module.exports = api;
} else if (root) {
root.QueueDedup = api;
}
})(typeof window !== 'undefined' ? window : this);
+59
View File
@@ -0,0 +1,59 @@
// Queue auto-prune logic. Extracted from renderer/app.js handleBatchDone so
// the algorithm can be unit-tested without needing a DOM or the renderer's
// module-level state (queueJobs, _jobIndexById).
//
// Loaded both as a CommonJS module (Node tests) and as a browser global
// (renderer/app.js via index.html script tag) so the same single
// implementation backs both runtime and tests — no drift between them.
//
// Behaviour: when the number of terminal-status jobs (done / skipped /
// error / aborted) in the queue exceeds `limit`, drop the oldest terminal
// jobs (insertion order) until we're back at the limit. Non-terminal jobs
// (queued / preview / uploading / retrying / getting-server) are always
// kept — those are work the user can still act on. Without this cap a
// long session accumulates thousands of done rows and every render becomes
// O(N) on a perpetually-growing N.
(function (root) {
'use strict';
const TERMINAL_STATUSES = new Set(['done', 'skipped', 'error', 'aborted']);
/**
* Compute which jobs to keep vs drop, given a queue and a terminal-jobs cap.
* @param {Array<{id: string, status: string}>} jobs the current queue
* @param {number} limit max terminal jobs to keep
* @returns {null | { kept: Array, dropped: Array }} null when nothing changed
*/
function pruneOldestTerminalJobs(jobs, limit) {
if (!Array.isArray(jobs) || jobs.length === 0) return null;
if (!Number.isFinite(limit) || limit < 0) return null;
// Walk once, record indices of terminal jobs in insertion order.
const terminalIdxs = [];
for (let i = 0; i < jobs.length; i++) {
const j = jobs[i];
if (j && TERMINAL_STATUSES.has(j.status)) terminalIdxs.push(i);
}
if (terminalIdxs.length <= limit) return null;
const dropCount = terminalIdxs.length - limit;
const dropSet = new Set(terminalIdxs.slice(0, dropCount));
const kept = [];
const dropped = [];
for (let i = 0; i < jobs.length; i++) {
if (dropSet.has(i)) dropped.push(jobs[i]);
else kept.push(jobs[i]);
}
return { kept, dropped };
}
const api = { pruneOldestTerminalJobs, TERMINAL_STATUSES };
if (typeof module !== 'undefined' && module.exports) {
module.exports = api;
} else if (root) {
root.QueuePrune = api;
}
})(typeof window !== 'undefined' ? window : this);
+23
View File
@@ -0,0 +1,23 @@
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('capture', {
// Get capture source ID from main process (desktopCapturer runs in main)
getSourceId: () => ipcRenderer.invoke('remote:get-capture-source-id'),
// Signaling: receive offer/ICE from main process (relayed from dashboard)
onSignaling: (callback) => {
ipcRenderer.on('remote:signaling-to-capture', (_event, data) => callback(data));
},
// Signaling: send answer/ICE back to main process (relayed to dashboard)
sendSignaling: (data) => ipcRenderer.send('remote:signaling-from-capture', data),
// Input: forward input events from DataChannel to main process
sendInput: (data) => ipcRenderer.send('remote:input-event', data),
// Notify main process of client connection/disconnection
notifyClientCount: (count) => ipcRenderer.send('remote:client-count', count),
// Debug logging to main process
log: (...args) => ipcRenderer.send('remote:capture-log', args.join(' '))
});
+150
View File
@@ -0,0 +1,150 @@
<!DOCTYPE html>
<html>
<head><title>Remote Capture</title></head>
<body>
<script>
// Maps clientId -> { pc: RTCPeerConnection, dc: RTCDataChannel }
const clients = new Map();
let captureStream = null;
async function getCaptureStream() {
if (captureStream) return captureStream;
// desktopCapturer runs in main process (Electron 33+), we get the source ID via IPC
const sourceId = await window.capture.getSourceId();
window.capture.log('getSourceId returned:', sourceId || 'NULL');
if (!sourceId) throw new Error('No capture source ID from main process');
try {
captureStream = await navigator.mediaDevices.getUserMedia({
audio: false,
video: {
mandatory: {
chromeMediaSource: 'desktop',
chromeMediaSourceId: sourceId,
maxFrameRate: 15
}
}
});
const tracks = captureStream.getTracks();
window.capture.log('getUserMedia OK, tracks:', tracks.length, tracks.map(t => `${t.kind}:${t.readyState}`).join(','));
return captureStream;
} catch (err) {
window.capture.log('getUserMedia FAILED:', err.message);
throw err;
}
}
async function handleOffer(clientId, offer, role) {
window.capture.log('handleOffer called for', clientId);
let stream;
try {
stream = await getCaptureStream();
} catch (err) {
window.capture.log('FATAL: getCaptureStream failed:', err.message);
// Send diagnostic back to dashboard
window.capture.sendSignaling({ type: 'capture-error', clientId, error: err.message });
return;
}
const pc = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
});
clients.set(clientId, { pc, role });
// Add video tracks
const tracks = stream.getTracks();
window.capture.log('Adding', tracks.length, 'tracks to peer connection');
for (const track of tracks) {
window.capture.log('addTrack:', track.kind, track.label, track.readyState);
pc.addTrack(track, stream);
}
window.capture.log('Senders after addTrack:', pc.getSenders().length);
// Handle DataChannel from dashboard (dashboard creates it as offerer)
pc.ondatachannel = (event) => {
const dc = event.channel;
clients.get(clientId).dc = dc;
dc.onmessage = (msg) => {
try {
const input = JSON.parse(msg.data);
input.clientId = clientId;
input.role = role;
window.capture.sendInput(input);
} catch {}
};
};
// ICE candidates — serialize to plain object (WebRTC objects don't survive IPC)
pc.onicecandidate = (event) => {
if (event.candidate) {
window.capture.sendSignaling({
type: 'ice-candidate',
clientId,
candidate: {
candidate: event.candidate.candidate,
sdpMid: event.candidate.sdpMid,
sdpMLineIndex: event.candidate.sdpMLineIndex,
usernameFragment: event.candidate.usernameFragment
}
});
}
};
pc.onconnectionstatechange = () => {
if (pc.connectionState === 'disconnected' || pc.connectionState === 'failed') {
removeClient(clientId);
}
};
await pc.setRemoteDescription(new RTCSessionDescription(offer));
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
// Serialize to plain object (RTCSessionDescription doesn't survive IPC)
window.capture.sendSignaling({
type: 'answer',
clientId,
answer: { type: pc.localDescription.type, sdp: pc.localDescription.sdp }
});
window.capture.notifyClientCount(clients.size);
}
function handleIceCandidate(clientId, candidate) {
const client = clients.get(clientId);
if (client && client.pc) {
client.pc.addIceCandidate(new RTCIceCandidate(candidate)).catch(() => {});
}
}
function removeClient(clientId) {
const client = clients.get(clientId);
if (client) {
if (client.dc) client.dc.close();
client.pc.close();
clients.delete(clientId);
window.capture.notifyClientCount(clients.size);
}
}
// Listen for signaling messages from main process
window.capture.onSignaling((data) => {
switch (data.type) {
case 'offer':
handleOffer(data.clientId, data.offer, data.role).catch(err => {
console.error('Failed to handle offer:', err);
window.capture.sendSignaling({ type: 'error', clientId: data.clientId, error: err.message });
});
break;
case 'ice-candidate':
handleIceCandidate(data.clientId, data.candidate);
break;
case 'client-disconnected':
removeClient(data.clientId);
break;
}
});
</script>
</body>
</html>
+216
View File
@@ -0,0 +1,216 @@
const { WebSocketServer } = require('ws');
const crypto = require('crypto');
const { evaluateClientAllowed } = require('./ip-allowlist');
function timingSafeEqualStr(a, b) {
const x = Buffer.from(String(a == null ? '' : a));
const y = Buffer.from(String(b == null ? '' : b));
return x.length === y.length && crypto.timingSafeEqual(x, y);
}
class RemoteServer {
constructor() {
this._wss = null;
this._clients = new Map(); // ws -> { id, role, authenticated }
this._config = null;
this._failedAttempts = new Map(); // ip -> { count, blockedUntil }
this._lastAccess = null;
}
start(opts) {
return new Promise((resolve, reject) => {
this._config = opts;
const wssOpts = { port: opts.port, maxPayload: 256 * 1024 };
if (opts.host) wssOpts.host = opts.host;
this._wss = new WebSocketServer(wssOpts, () => {
resolve();
});
this._wss.on('error', (err) => {
reject(err);
});
this._wss.on('connection', (ws, req) => {
this._handleConnection(ws, req);
});
});
}
stop() {
if (this._wss) {
for (const [ws] of this._clients) {
ws.close(1000, 'Server shutting down');
}
this._clients.clear();
this._wss.close();
this._wss = null;
}
}
getClientCount() {
let count = 0;
for (const [, client] of this._clients) {
if (client.authenticated) count++;
}
return count;
}
getPort() {
if (this._wss && this._wss.address()) {
return this._wss.address().port;
}
return null;
}
_handleConnection(ws, req) {
const ip = req.socket.remoteAddress || 'unknown';
if (this._isBlocked(ip)) {
ws.close(4003, 'Too many failed attempts');
return;
}
if (Array.isArray(this._config.allowlist) && !evaluateClientAllowed(ip, this._config.allowlist)) {
ws.close(4005, 'Client IP not allowed');
return;
}
const clientId = crypto.randomUUID();
this._clients.set(ws, { id: clientId, role: null, authenticated: false });
let authReceived = false;
const authTimeout = setTimeout(() => {
if (!authReceived) {
ws.close(4001, 'Auth timeout');
this._clients.delete(ws);
}
}, 5000);
ws.on('message', (raw) => {
let msg;
try { msg = JSON.parse(raw); } catch { return; }
const client = this._clients.get(ws);
if (!client) return;
if (!client.authenticated) {
authReceived = true;
clearTimeout(authTimeout);
if (msg.type === 'auth' && timingSafeEqualStr(msg.token, this._config.token)) {
client.authenticated = true;
client.role = this._config.diagnosticMode ? 'diagnostic' : (msg.role || 'viewer');
this._lastAccess = Date.now();
ws.send(JSON.stringify({ type: 'auth-ok', clientId }));
if (!this._config.diagnosticMode && this.getClientCount() === 1) {
this._config.onCreateCaptureWindow();
}
} else {
this._recordFailedAttempt(ip);
ws.close(4002, 'Invalid token');
this._clients.delete(ws);
}
return;
}
if (this._config.diagnosticMode) {
if (msg.type === 'diag-request' && typeof this._config.onDiagnosticRequest === 'function') {
this._lastAccess = Date.now();
this._config.onDiagnosticRequest(msg, client, (payload) => {
this.sendToClient(client.id, { type: 'diag-response', reqId: msg.reqId, ...payload });
});
}
return;
}
if (msg.type === 'offer' || msg.type === 'ice-candidate') {
msg.clientId = client.id;
msg.role = client.role;
this._config.onSignalingToCapture(msg);
}
});
ws.on('close', () => {
clearTimeout(authTimeout);
const client = this._clients.get(ws);
const wasAuthenticated = client && client.authenticated;
this._clients.delete(ws);
if (wasAuthenticated && !this._config.diagnosticMode) {
this._config.onSignalingToCapture({
type: 'client-disconnected',
clientId: client.id
});
if (this.getClientCount() === 0) {
this._config.onDestroyCaptureWindow();
}
}
});
ws.on('error', () => {
clearTimeout(authTimeout);
const client = this._clients.get(ws);
const wasAuthenticated = client && client.authenticated;
this._clients.delete(ws);
if (wasAuthenticated && !this._config.diagnosticMode) {
this._config.onSignalingToCapture({
type: 'client-disconnected',
clientId: client.id
});
if (this.getClientCount() === 0) {
this._config.onDestroyCaptureWindow();
}
}
});
}
getLastAccess() {
return this._lastAccess;
}
sendToClient(clientId, data) {
for (const [ws, client] of this._clients) {
if (client.id === clientId && client.authenticated) {
if (ws.readyState === 1) {
try { ws.send(JSON.stringify(data)); } catch {}
}
break;
}
}
}
broadcast(data) {
const msg = JSON.stringify(data);
for (const [ws, client] of this._clients) {
if (client.authenticated && ws.readyState === 1) {
ws.send(msg);
}
}
}
_isBlocked(ip) {
const entry = this._failedAttempts.get(ip);
if (!entry) return false;
if (entry.blockedUntil && Date.now() < entry.blockedUntil) return true;
if (entry.blockedUntil && Date.now() >= entry.blockedUntil) {
this._failedAttempts.delete(ip);
return false;
}
return false;
}
_recordFailedAttempt(ip) {
const entry = this._failedAttempts.get(ip) || { count: 0, blockedUntil: null };
entry.count++;
if (entry.count >= 5) {
entry.blockedUntil = Date.now() + 60000;
}
this._failedAttempts.set(ip, entry);
}
}
module.exports = RemoteServer;
+75
View File
@@ -0,0 +1,75 @@
// Wraps Electron's safeStorage (OS-level credential encryption: DPAPI on
// Windows, Keychain on macOS, libsecret on Linux) to keep hoster passwords and
// API keys out of the plaintext electron-config.json.
//
// On Windows the DPAPI key is tied to the current user profile, so credentials
// encrypted here are only readable by the same Windows user. For backups we
// export to plaintext (the .mhu envelope has its own AES-GCM layer) so moving
// between machines/users works transparently.
const SENTINEL = 'enc:v1:';
const CRED_FIELDS = ['password', 'apiKey'];
let _safeStorageCache = undefined;
function getSafeStorage() {
if (_safeStorageCache !== undefined) return _safeStorageCache;
try {
const { safeStorage } = require('electron');
if (safeStorage && typeof safeStorage.isEncryptionAvailable === 'function'
&& safeStorage.isEncryptionAvailable()) {
_safeStorageCache = safeStorage;
return _safeStorageCache;
}
} catch {}
_safeStorageCache = null;
return null;
}
function isEncrypted(value) {
return typeof value === 'string' && value.startsWith(SENTINEL);
}
function encryptField(value) {
if (!value || typeof value !== 'string') return value;
if (isEncrypted(value)) return value;
const ss = getSafeStorage();
if (!ss) return value;
try {
const buf = ss.encryptString(value);
return SENTINEL + buf.toString('base64');
} catch {
return value;
}
}
function decryptField(value) {
if (!value || typeof value !== 'string') return value;
if (!isEncrypted(value)) return value;
const ss = getSafeStorage();
if (!ss) return '';
try {
const buf = Buffer.from(value.slice(SENTINEL.length), 'base64');
return ss.decryptString(buf);
} catch {
return '';
}
}
function mapHosterAccounts(config, fn) {
if (!config || !config.hosters || typeof config.hosters !== 'object') return config;
for (const accounts of Object.values(config.hosters)) {
if (!Array.isArray(accounts)) continue;
for (const acc of accounts) {
if (!acc || typeof acc !== 'object') continue;
for (const f of CRED_FIELDS) {
if (acc[f]) acc[f] = fn(acc[f]);
}
}
}
return config;
}
function encryptCredentials(config) { return mapHosterAccounts(config, encryptField); }
function decryptCredentials(config) { return mapHosterAccounts(config, decryptField); }
module.exports = { encryptField, decryptField, encryptCredentials, decryptCredentials, isEncrypted };
+73
View File
@@ -0,0 +1,73 @@
/**
* FIFO Semaphore for per-hoster concurrency control.
* acquire(signal?) blocks until a slot is available or the signal aborts.
* release() frees a slot.
*/
class Semaphore {
constructor(limit) {
this.limit = Math.max(1, limit || 1);
this.active = 0;
this.queue = []; // { resolve, reject, signal?, onAbort? }
}
acquire(signal) {
return new Promise((resolve, reject) => {
if (signal && signal.aborted) {
reject(new Error('Aborted'));
return;
}
if (this.active < this.limit) {
this.active++;
resolve();
return;
}
const entry = { resolve, reject };
if (signal) {
entry.signal = signal;
entry.onAbort = () => {
const idx = this.queue.indexOf(entry);
if (idx !== -1) this.queue.splice(idx, 1);
reject(new Error('Aborted'));
};
signal.addEventListener('abort', entry.onAbort, { once: true });
}
this.queue.push(entry);
});
}
_cleanupEntry(entry) {
if (entry.signal && entry.onAbort) {
entry.signal.removeEventListener('abort', entry.onAbort);
}
}
release() {
if (this.queue.length > 0) {
const entry = this.queue.shift();
this._cleanupEntry(entry);
entry.resolve();
} else {
this.active = Math.max(0, this.active - 1);
}
}
updateLimit(newLimit) {
this.limit = Math.max(1, newLimit || 1);
while (this.active < this.limit && this.queue.length > 0) {
this.active++;
const entry = this.queue.shift();
this._cleanupEntry(entry);
entry.resolve();
}
}
get pending() {
return this.queue.length;
}
}
module.exports = Semaphore;
+142
View File
@@ -0,0 +1,142 @@
(function (root) {
function summarizePerHoster(history, opts) {
const out = {};
if (!Array.isArray(history)) return out;
const cutoff = opts && Number.isFinite(opts.sinceMs) ? opts.sinceMs : null;
const limitBatches = opts && Number.isFinite(opts.lastNBatches) && opts.lastNBatches > 0 ? opts.lastNBatches : null;
const entries = [...history];
entries.sort((a, b) => {
const ta = a && a.timestamp ? Date.parse(a.timestamp) : 0;
const tb = b && b.timestamp ? Date.parse(b.timestamp) : 0;
return tb - ta;
});
const sliced = limitBatches ? entries.slice(0, limitBatches) : entries;
for (const batch of sliced) {
if (!batch || !Array.isArray(batch.files)) continue;
if (cutoff !== null) {
const ts = batch.timestamp ? Date.parse(batch.timestamp) : 0;
if (!ts || ts < cutoff) continue;
}
for (const file of batch.files) {
if (!file || !Array.isArray(file.results)) continue;
for (const r of file.results) {
if (!r || !r.hoster) continue;
const bucket = out[r.hoster] || (out[r.hoster] = { ok: 0, fail: 0, total: 0 });
bucket.total++;
if (r.status === 'done') bucket.ok++;
else bucket.fail++;
}
}
}
for (const h of Object.keys(out)) {
const b = out[h];
b.rate = b.total > 0 ? b.ok / b.total : null;
}
return out;
}
function classifyErrorCategory(err) {
if (!err || typeof err !== 'string') return 'unknown';
const s = err.toLowerCase();
if (/abgebrochen|aborted|cancel/.test(s)) return 'aborted';
if (/not video file format|kein videoformat|invalid file|wrong format|duplicate|already exists|file too (small|big|large)|datei zu (gro|klein)/.test(s)) return 'file-rejected';
if (/quota|storage (full|exhausted|voll)|account (full|banned|suspended)|disk (space )?full|insufficient (disk )?space|not enough (disk )?(space|storage)/.test(s)) return 'account-error';
if (/csrf|kein upload-server|server.*?(busy|unavailable|try again)|no servers available|filecode|kein filecode|empty.*?(form|response)/.test(s)) return 'hoster-transient';
if (/timeout|econnreset|enotfound|fetch failed|network|socket hang up|abort/.test(s)) return 'network';
return 'unknown';
}
function summarizeBatchErrors(batchSummary) {
const buckets = {
'file-rejected': [],
'account-error': [],
'hoster-transient': [],
'network': [],
'unknown': [],
'aborted': []
};
if (!batchSummary || !Array.isArray(batchSummary.files)) return buckets;
for (const f of batchSummary.files) {
if (!f || !Array.isArray(f.results)) continue;
for (const r of f.results) {
if (!r || r.status === 'done') continue;
const cat = classifyErrorCategory(r.error);
buckets[cat].push({
fileName: f.name || f.fileName || '',
hoster: r.hoster || '',
error: r.error || '',
jobId: r.jobId || null
});
}
}
return buckets;
}
const RETRYABLE_CATEGORIES = new Set(['hoster-transient', 'network', 'unknown']);
function isRetryableCategory(cat) {
return RETRYABLE_CATEGORIES.has(cat);
}
const CATEGORY_LABELS = {
'file-rejected': 'Datei abgelehnt',
'account-error': 'Account-Problem',
'hoster-transient': 'Hoster-Flake',
'network': 'Netzwerk',
'unknown': 'Unbekannt',
'aborted': 'Abgebrochen'
};
function formatLinks(rows, format) {
if (!Array.isArray(rows)) return '';
const safe = rows.filter(r => r && r.url);
if (safe.length === 0) return '';
switch (format) {
case 'plain':
return safe.map(r => r.url).join('\n');
case 'bbcode':
return safe.map(r => {
const label = r.fileName || r.hoster || r.url;
return `[url=${r.url}]${label}[/url]`;
}).join('\n');
case 'markdown':
return safe.map(r => {
const label = r.fileName || r.hoster || r.url;
return `- [${label}](${r.url})`;
}).join('\n');
case 'html':
return safe.map(r => {
const label = r.fileName || r.hoster || r.url;
return `<a href="${r.url}">${label}</a>`;
}).join('\n');
case 'csv': {
const head = 'fileName,hoster,url\n';
return head + safe.map(r => {
const esc = (v) => `"${String(v || '').replace(/"/g, '""')}"`;
return [esc(r.fileName), esc(r.hoster), esc(r.url)].join(',');
}).join('\n');
}
case 'json':
return JSON.stringify(safe.map(r => ({ fileName: r.fileName || '', hoster: r.hoster || '', url: r.url })), null, 2);
default:
return safe.map(r => r.url).join('\n');
}
}
const api = {
summarizePerHoster,
classifyErrorCategory,
summarizeBatchErrors,
isRetryableCategory,
RETRYABLE_CATEGORIES,
CATEGORY_LABELS,
formatLinks
};
if (typeof module !== 'undefined' && module.exports) {
module.exports = api;
} else if (root) {
root.Stats = api;
}
})(typeof window !== 'undefined' ? window : this);
+112
View File
@@ -0,0 +1,112 @@
const fs = require('fs');
const CRED_KEYS = new Set(['password', 'apiKey', 'token', 'cookie', 'sessionId', 'webhookUrl', 'diagToken']);
const REDACTED = '<redacted>';
function sanitizeConfig(config) {
if (!config || typeof config !== 'object') return config;
const clone = JSON.parse(JSON.stringify(config));
(function walk(o) {
if (!o) return;
if (Array.isArray(o)) { for (const e of o) walk(e); return; }
if (typeof o !== 'object') return;
for (const k of Object.keys(o)) {
if (CRED_KEYS.has(k) && typeof o[k] === 'string' && o[k]) o[k] = REDACTED;
else walk(o[k]);
}
})(clone);
return clone;
}
function collectSecretValues(config) {
const out = new Set();
(function walk(o) {
if (!o) return;
if (Array.isArray(o)) { for (const e of o) walk(e); return; }
if (typeof o !== 'object') return;
for (const k of Object.keys(o)) {
const v = o[k];
if (CRED_KEYS.has(k) && typeof v === 'string' && v.length >= 6) out.add(v);
else walk(v);
}
})(config);
return Array.from(out);
}
function redactLogText(text, secrets) {
if (typeof text !== 'string' || !text) return text;
let out = text;
if (Array.isArray(secrets)) {
for (const s of secrets) {
if (typeof s === 'string' && s.length >= 6) out = out.split(s).join(REDACTED);
}
}
out = out
.replace(/https?:\/\/(?:ptb\.|canary\.)?discord(?:app)?\.com\/api\/webhooks\/\d+\/[\w-]+/gi, 'https://discord.com/api/webhooks/' + REDACTED)
.replace(/(\/\/[^\s/:@]+:)[^\s/@]+(@)/g, '$1' + REDACTED + '$2')
.replace(/(authorization:\s*(?:bearer|basic)\s+)\S+/gi, '$1' + REDACTED)
.replace(/\bbearer\s+[A-Za-z0-9._\-/+]{16,}/gi, 'bearer ' + REDACTED)
.replace(/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{6,}/g, REDACTED)
.replace(/([?&](?:api_?key|key|token|access_token|password|pass)=)[^\s&"'`]+/gi, '$1' + REDACTED)
.replace(/("?\b(?:api[_-]?key|apikey|password|passwd|secret|(?:access|refresh|auth|session)[_-]?token|token|sessionid|session)"?\s*[:=]\s*"?)[A-Za-z0-9._\-/+]{8,}/gi, '$1' + REDACTED)
.replace(/(\bset-cookie:|\bcookie:)\s*\S[^\n]*/gi, '$1 ' + REDACTED)
.replace(/(\bsess(?:_?id)?\b["'=:\s]+)[A-Za-z0-9._\-]{8,}/gi, '$1' + REDACTED);
return out;
}
function valueScrub(value, secrets) {
if (value == null) return value;
const json = JSON.stringify(value);
let scrubbed = json;
if (Array.isArray(secrets)) {
for (const s of secrets) {
if (typeof s === 'string' && s.length >= 6) scrubbed = scrubbed.split(s).join(REDACTED);
}
}
return JSON.parse(scrubbed);
}
function collectFile(filePath, label, maxBytes) {
if (!filePath) return `=== ${label} ===\n<no path configured>\n\n`;
let stat;
try { stat = fs.statSync(filePath); }
catch (err) {
if (err && err.code === 'ENOENT') return `=== ${label} (${filePath}) ===\n<file does not exist yet>\n\n`;
return `=== ${label} (${filePath}) ===\n<stat error: ${err.message}>\n\n`;
}
const cap = Number.isFinite(maxBytes) && maxBytes > 0 ? maxBytes : 5 * 1024 * 1024;
let content;
try {
if (stat.size > cap) {
const fd = fs.openSync(filePath, 'r');
const buf = Buffer.alloc(cap);
fs.readSync(fd, buf, 0, cap, stat.size - cap);
fs.closeSync(fd);
const skipped = stat.size - cap;
content = `<truncated: skipped first ${skipped} bytes; showing last ${cap} bytes of ${stat.size}>\n` + buf.toString('utf-8');
} else {
content = fs.readFileSync(filePath, 'utf-8');
}
} catch (err) {
content = `<read error: ${err.message}>`;
}
return `=== ${label} (${filePath}, size=${stat.size} bytes) ===\n${content}\n\n`;
}
function buildSupportBundleText({ header, sanitizedConfig, files }) {
const parts = [];
parts.push('=== Multi-Hoster-Upload Support Bundle ===\n');
if (header && typeof header === 'object') {
for (const [k, v] of Object.entries(header)) parts.push(`${k}: ${v}\n`);
}
parts.push('\n');
parts.push('=== Config (sanitized — password/apiKey/token/cookie/sessionId redacted) ===\n');
parts.push(JSON.stringify(sanitizedConfig, null, 2));
parts.push('\n\n');
for (const f of (files || [])) {
parts.push(collectFile(f.path, f.label || f.path, f.maxBytes));
}
return parts.join('');
}
module.exports = { sanitizeConfig, collectSecretValues, redactLogText, valueScrub, collectFile, buildSupportBundleText, CRED_KEYS, REDACTED };
+59
View File
@@ -0,0 +1,59 @@
(function (root) {
'use strict';
function makeThrottleTimer(opts) {
const o = opts || {};
const now = typeof o.now === 'function' ? o.now : (() => Date.now());
const schedule = typeof o.schedule === 'function'
? o.schedule
: ((cb, ms) => setTimeout(cb, ms));
const clear = typeof o.clear === 'function' ? o.clear : ((h) => clearTimeout(h));
let handle = null;
let burstStart = null;
let pendingFn = null;
function fire() {
handle = null;
burstStart = null;
const fn = pendingFn;
pendingFn = null;
if (typeof fn === 'function') fn();
}
function request(fn, delay, maxWait) {
if (typeof fn === 'function') pendingFn = fn;
const t = now();
if (burstStart === null) burstStart = t;
let wait = typeof delay === 'number' && delay >= 0 ? delay : 0;
if (typeof maxWait === 'number' && maxWait >= 0) {
const remaining = maxWait - (t - burstStart);
wait = Math.min(wait, remaining < 0 ? 0 : remaining);
}
if (handle !== null) clear(handle);
handle = schedule(fire, wait);
}
function flushSync() {
if (handle !== null) { clear(handle); handle = null; }
burstStart = null;
const fn = pendingFn;
pendingFn = null;
if (typeof fn === 'function') fn();
}
function cancel() {
if (handle !== null) { clear(handle); handle = null; }
burstStart = null;
pendingFn = null;
}
function isPending() { return handle !== null; }
return { request, flushSync, cancel, isPending };
}
const api = { makeThrottleTimer };
if (typeof module !== 'undefined' && module.exports) module.exports = api;
else if (root) root.ThrottleTimer = api;
})(typeof window !== 'undefined' ? window : this);
+43
View File
@@ -0,0 +1,43 @@
/**
* Token-bucket speed limiter for bandwidth throttling.
* maxBytesPerSec = 0 means unlimited (passthrough).
*/
class Throttle {
constructor(maxBytesPerSec) {
this.maxBps = maxBytesPerSec || 0;
this.tokens = this.maxBps;
this.lastRefill = Date.now();
}
async consume(bytes, signal) {
if (this.maxBps <= 0) return; // unlimited
while (bytes > 0) {
if (signal && signal.aborted) return;
this._refill();
const available = Math.min(bytes, Math.floor(this.tokens));
if (available > 0) {
this.tokens -= available;
bytes -= available;
}
if (bytes > 0) {
if (signal && signal.aborted) return;
// Wait 50ms for tokens to refill
await new Promise((r) => setTimeout(r, 50));
}
}
}
_refill() {
const now = Date.now();
const elapsed = (now - this.lastRefill) / 1000;
this.tokens = Math.min(this.maxBps, this.tokens + elapsed * this.maxBps);
this.lastRefill = now;
}
updateRate(maxBytesPerSec) {
this.maxBps = maxBytesPerSec || 0;
}
}
module.exports = Throttle;
+51
View File
@@ -0,0 +1,51 @@
// Time-windowed memoization. Reuses a previously-computed value if the
// signature + input identity match AND the cached entry is younger than
// `refreshMs`. Used by the renderer's dynamic-key sort throttle (every
// progress tick re-sorts a 5000-row queue → reuse for 200 ms, the user
// can't perceive sub-200 ms reorder lag).
//
// Loaded both as a CommonJS module (Node tests) and as a browser global
// (renderer/app.js via index.html script tag) — same single implementation
// across runtime and tests.
(function (root) {
'use strict';
/**
* Build a throttled cache. The clock is injected so tests don't have to
* sleep — pass `() => fakeClock.value` from tests.
*
* @param {number} refreshMs cache TTL in milliseconds
* @param {() => number} [now] clock source, defaults to Date.now
*/
function makeThrottledCache(refreshMs, now) {
if (!Number.isFinite(refreshMs) || refreshMs < 0) {
throw new TypeError('refreshMs must be a non-negative finite number');
}
const clock = typeof now === 'function' ? now : () => Date.now();
let entry = null;
return {
get(sig, input) {
if (!entry) return undefined;
if (entry.sig !== sig) return undefined;
if (entry.input !== input) return undefined;
if (clock() - entry.ts >= refreshMs) return undefined;
return entry.value;
},
set(sig, input, value) {
entry = { sig, input, value, ts: clock() };
return value;
},
clear() { entry = null; },
// Introspection (mainly for tests/debug). Returns null when empty.
peek() {
if (!entry) return null;
return { sig: entry.sig, ts: entry.ts, age: clock() - entry.ts };
}
};
}
const api = { makeThrottledCache };
if (typeof module !== 'undefined' && module.exports) module.exports = api;
else if (root) root.ThrottledCache = api;
})(typeof window !== 'undefined' ? window : this);
+283
View File
@@ -0,0 +1,283 @@
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { app } = require('electron');
const UPDATE_REPO = 'Administrator/Multi-Hoster-Upload';
const GITEA_BASE = 'https://git.24-music.de';
const API_URL = `${GITEA_BASE}/api/v1/repos/${UPDATE_REPO}/releases?limit=1`;
const CHECK_TIMEOUT = 15000;
let cachedCheck = null;
let cachedCheckTs = 0;
const CACHE_TTL = 10 * 60 * 1000; // 10 min
let activeAbort = null;
function getCurrentVersion() {
return app.getVersion();
}
function parseVersion(str) {
const clean = String(str || '').replace(/^v/i, '').trim();
const parts = clean.split('.').map(Number);
return {
major: parts[0] || 0,
minor: parts[1] || 0,
patch: parts[2] || 0
};
}
function isNewer(remote, current) {
const r = parseVersion(remote);
const c = parseVersion(current);
if (r.major !== c.major) return r.major > c.major;
if (r.minor !== c.minor) return r.minor > c.minor;
return r.patch > c.patch;
}
function pickSetupAsset(assets) {
if (!Array.isArray(assets)) return null;
// Prefer asset with "setup" in the name (case-insensitive)
const setup = assets.find(a =>
/setup/i.test(a.name) && /\.exe$/i.test(a.name)
);
if (setup) return setup;
// Fallback: any .exe
return assets.find(a => /\.exe$/i.test(a.name)) || null;
}
function findLatestYml(assets) {
if (!Array.isArray(assets)) return null;
return assets.find(a => /^latest\.yml$/i.test(a.name)) || null;
}
async function fetchJson(url, signal) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), CHECK_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();
try {
return JSON.parse(text);
} catch {
throw new Error(`Update-Server Antwort war kein JSON (HTTP ${res.status}): ${text.slice(0, 200)}`);
}
} finally {
clearTimeout(timeout);
if (signal) signal.removeEventListener('abort', onAbort);
}
}
async function checkForUpdate() {
// Return cached result if fresh
if (cachedCheck && (Date.now() - cachedCheckTs) < CACHE_TTL) {
return cachedCheck;
}
const releases = await fetchJson(API_URL);
if (!Array.isArray(releases) || releases.length === 0) {
return { available: false };
}
const release = releases[0];
const remoteVersion = release.tag_name || release.name || '';
const currentVersion = getCurrentVersion();
if (!isNewer(remoteVersion, currentVersion)) {
cachedCheck = { available: false, currentVersion, remoteVersion };
cachedCheckTs = Date.now();
return cachedCheck;
}
const setupAsset = pickSetupAsset(release.assets);
const latestYml = findLatestYml(release.assets);
if (!setupAsset) {
return { available: false, reason: 'Kein Setup-Asset im Release gefunden' };
}
cachedCheck = {
available: true,
currentVersion,
remoteVersion: remoteVersion.replace(/^v/i, ''),
releaseUrl: release.html_url,
assetUrl: setupAsset.browser_download_url,
assetSize: setupAsset.size,
assetName: setupAsset.name,
latestYmlUrl: latestYml ? latestYml.browser_download_url : null,
releaseNotes: release.body || ''
};
cachedCheckTs = Date.now();
return cachedCheck;
}
async function parseLatestYml(url) {
if (!url) return null;
try {
const res = await fetch(url, { redirect: 'follow' });
const text = await res.text();
// Extract sha512 from latest.yml
const match = text.match(/sha512:\s*([A-Za-z0-9+/=]+)/);
return match ? match[1] : null;
} catch {
return null;
}
}
function verifyExeHeader(buf) {
// Check MZ header
if (buf.length < 128 * 1024) return false;
return buf[0] === 0x4D && buf[1] === 0x5A; // 'MZ'
}
async function installUpdate(onProgress) {
if (activeAbort) activeAbort.abort();
activeAbort = new AbortController();
const signal = activeAbort.signal;
try {
// Stage: starting
if (onProgress) onProgress({ stage: 'starting', percent: 0 });
// Check or use cached
let check = cachedCheck;
if (!check || !check.available) {
check = await checkForUpdate();
}
if (!check || !check.available) {
throw new Error('Kein Update verfuegbar');
}
if (!check.assetUrl || !check.assetName) {
throw new Error('Update-Asset unvollstaendig (URL oder Name fehlt)');
}
// Stage: downloading
const tmpDir = app.getPath('temp');
const installerPath = path.join(tmpDir, check.assetName);
const res = await fetch(check.assetUrl, {
method: 'GET',
signal,
redirect: 'follow'
});
if (!res.ok) {
throw new Error(`Download fehlgeschlagen: HTTP ${res.status}`);
}
const totalBytes = check.assetSize || 0;
let downloadedBytes = 0;
const chunks = [];
const DOWNLOAD_STALL_MS = 45000;
let stallTimer = null;
const reader = res.body.getReader();
while (true) {
if (signal.aborted) throw new Error('Abgebrochen');
let chunk;
try {
chunk = await Promise.race([
reader.read(),
new Promise((_, reject) => { stallTimer = setTimeout(() => reject(new Error('__STALL__')), DOWNLOAD_STALL_MS); })
]);
} catch (e) {
if (e && e.message === '__STALL__') {
try { activeAbort.abort(); } catch {}
throw new Error('Download hängt — seit 45 s keine Daten (Netzwerk/Server überlastet). Bitte laufende Uploads stoppen und erneut versuchen.');
}
throw e;
} finally {
if (stallTimer) { clearTimeout(stallTimer); stallTimer = null; }
}
const { done, value } = chunk;
if (done) break;
chunks.push(value);
downloadedBytes += value.length;
if (onProgress) {
onProgress({
stage: 'downloading',
percent: totalBytes > 0 ? Math.round((downloadedBytes / totalBytes) * 100) : 0,
bytesDownloaded: downloadedBytes,
bytesTotal: totalBytes
});
}
}
const fileBuffer = Buffer.concat(chunks);
// Stage: verifying
if (onProgress) onProgress({ stage: 'verifying', percent: 0 });
if (!verifyExeHeader(fileBuffer)) {
throw new Error('Heruntergeladene Datei ist keine gueltige EXE');
}
// Optional SHA-512 verification from latest.yml
const expectedSha = await parseLatestYml(check.latestYmlUrl);
if (expectedSha) {
const actualSha = crypto.createHash('sha512').update(fileBuffer).digest('base64');
if (actualSha !== expectedSha) {
// Try hex comparison
const actualHex = crypto.createHash('sha512').update(fileBuffer).digest('hex');
if (actualHex !== expectedSha.toLowerCase()) {
throw new Error('SHA-512 Pruefung fehlgeschlagen');
}
}
}
// Write to disk
fs.writeFileSync(installerPath, fileBuffer);
// Stage: launching
if (onProgress) onProgress({ stage: 'launching', percent: 100 });
const { spawn } = require('child_process');
spawn(installerPath, ['/S', '--updated', '--force-run'], {
detached: true,
stdio: 'ignore'
}).unref();
// Stage: done
if (onProgress) onProgress({ stage: 'done', percent: 100 });
const _doQuit = () => setTimeout(() => app.quit(), 900);
const _getActive = () => {
try { return globalThis._mhuUploadManagerRef && globalThis._mhuUploadManagerRef.getActiveJobCount ? globalThis._mhuUploadManagerRef.getActiveJobCount() : 0; }
catch { return 0; }
};
if (_getActive() > 0) {
const POLL_MS = 3000;
const poller = setInterval(() => {
if (_getActive() === 0) { clearInterval(poller); _doQuit(); }
}, POLL_MS);
setTimeout(() => { try { clearInterval(poller); } catch {} _doQuit(); }, 30 * 60 * 1000);
} else {
_doQuit();
}
} catch (err) {
if (onProgress) onProgress({ stage: 'error', error: err.message });
throw err;
} finally {
activeAbort = null;
}
}
function abortUpdate() {
if (activeAbort) {
activeAbort.abort();
activeAbort = null;
}
}
module.exports = { checkForUpdate, installUpdate, abortUpdate };
+34
View File
@@ -0,0 +1,34 @@
(function (root) {
'use strict';
function _pad(n) { return String(n).padStart(2, '0'); }
function formatUploadLogLine(date, hoster, link, fileName) {
const d = date instanceof Date ? date : new Date();
const dateStr = `${d.getFullYear()}-${_pad(d.getMonth() + 1)}-${_pad(d.getDate())} ` +
`${_pad(d.getHours())}:${_pad(d.getMinutes())}:${_pad(d.getSeconds())}`;
return `${dateStr}|${hoster}|${link}||${fileName}|\n`;
}
function parseUploadLogLine(line) {
if (typeof line !== 'string') return null;
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) return null;
const parts = trimmed.split('|');
if (parts.length < 5) return null;
const hoster = (parts[1] || '').trim();
let fileName = '';
for (let i = parts.length - 1; i >= 4; i--) {
if (parts[i].trim() !== '') { fileName = parts[i]; break; }
}
if (!hoster || !fileName) return null;
const tsStr = (parts[0] || '').trim();
const tsParsed = tsStr ? Date.parse(tsStr.replace(' ', 'T')) : NaN;
const ts = isNaN(tsParsed) ? undefined : tsParsed;
return { hoster, fileName, ts };
}
const api = { formatUploadLogLine, parseUploadLogLine };
if (typeof module !== 'undefined' && module.exports) module.exports = api;
else if (root) root.UploadLog = api;
})(typeof window !== 'undefined' ? window : this);
File diff suppressed because it is too large Load Diff
+518
View File
@@ -0,0 +1,518 @@
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { request } = require('undici');
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';
const UPLOAD_TIMEOUT = 1800000; // 30 min
const RESULT_POLL_ATTEMPTS = 10;
const RESULT_POLL_DELAY_MS = 2000;
/**
* XFileSharing-based upload for Vidmoly (login + form upload)
*/
class VidmolyUploader {
constructor() {
this.cookies = new Map();
}
_cookieHeader() {
return Array.from(this.cookies.entries())
.map(([k, v]) => `${k}=${v}`)
.join('; ');
}
_parseCookiesFromHeaders(headers) {
// Handle both undici response headers and fetch Headers
let setCookies;
if (typeof headers.getSetCookie === 'function') {
setCookies = headers.getSetCookie();
} else if (headers['set-cookie']) {
setCookies = Array.isArray(headers['set-cookie']) ? headers['set-cookie'] : [headers['set-cookie']];
} else {
return;
}
for (const raw of setCookies) {
const pair = raw.split(';')[0];
const eq = pair.indexOf('=');
if (eq > 0) {
this.cookies.set(pair.substring(0, eq).trim(), pair.substring(eq + 1).trim());
}
}
}
/**
* Simple GET/POST using built-in fetch (handles redirects)
*/
async _fetch(url, opts = {}, _redirectCount = 0) {
const MAX_REDIRECTS = 10;
const headers = {
'User-Agent': USER_AGENT,
...(opts.headers || {})
};
if (this.cookies.size > 0) {
headers['Cookie'] = this._cookieHeader();
}
const res = await fetch(url, {
...opts,
headers,
redirect: 'manual' // handle manually to capture cookies from redirect responses
});
this._parseCookiesFromHeaders(res.headers);
// Follow redirects manually (to capture cookies at each hop)
if ([301, 302, 303, 307, 308].includes(res.status)) {
// Drain body to prevent connection leak
try { await res.text(); } catch {}
if (_redirectCount >= MAX_REDIRECTS) {
throw new Error('Zu viele Redirects');
}
const location = res.headers.get('location');
if (location) {
const nextUrl = new URL(location, url).href;
return this._fetch(nextUrl, { ...opts, method: 'GET', body: undefined }, _redirectCount + 1);
}
}
return res;
}
/**
* Login to Vidmoly via the new JSON API (replaces the old XFS form POST
* at `/` with `op=login`, which the SPA redesign deprecated). The response
* sets a `vidmoly_session` HttpOnly cookie that the upload API checks.
*/
async login(username, password) {
// Warm up — get baseline cookies (cf_clearance etc.)
try {
const initRes = await this._fetch(BASE_URL);
await initRes.text();
} catch {}
const res = await this._fetch(`${BASE_URL}/api/auth/login`, {
method: 'POST',
body: JSON.stringify({ login: username, password }),
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Origin': BASE_URL,
'Referer': `${BASE_URL}/login`
}
});
const body = await res.text();
if (res.status === 401 || res.status === 403 || /incorrect|invalid|wrong/i.test(body)) {
throw new Error('Vidmoly Login fehlgeschlagen: Falscher Username oder Passwort');
}
if (res.status < 200 || res.status >= 300) {
throw new Error(`Vidmoly Login fehlgeschlagen: HTTP ${res.status}`);
}
if (!this.cookies.has('vidmoly_session')) {
throw new Error('Vidmoly Login fehlgeschlagen: Keine Session erhalten (vidmoly_session fehlt)');
}
// Probe the upload API so downstream getUploadParams() has a warm path.
const probe = await this._fetch(`${BASE_URL}/api/upload/config`);
const probeBody = await probe.text();
let probeJson = null;
try { probeJson = JSON.parse(probeBody); } catch {}
if (!probeJson || !probeJson.sess_id || !probeJson.upload_url) {
throw new Error('Vidmoly Login fehlgeschlagen: Session konnte nicht verifiziert werden (API-Probe)');
}
}
/**
* Fetch the upload session config from Vidmoly's new SPA API.
* Replaces the old HTML-form scrape at /?op=upload which the redesign
* removed. Returns an XFS-style session token + a transit-server URL.
*/
async getUploadParams() {
const res = await this._fetch(`${BASE_URL}/api/upload/config`);
const body = await res.text();
let payload = null;
try { payload = JSON.parse(body); } catch {
throw new Error('Vidmoly: /api/upload/config lieferte kein JSON — evtl. nicht eingeloggt?');
}
if (!payload || !payload.sess_id || !payload.upload_url) {
throw new Error('Vidmoly: /api/upload/config unvollständig (sess_id/upload_url fehlt)');
}
return {
uploadUrl: payload.upload_url,
// Fields verified from a real browser POST capture.
// to_json=1 forces a JSON response instead of an HTML redirect page.
params: { sess_id: payload.sess_id, to_json: '1', fld_id: '0' },
fileFieldName: 'file'
};
}
/**
* Upload a file to Vidmoly (uses undici.request for streaming progress)
*/
async upload(filePath, onProgress, signal, throttle) {
const fileName = path.basename(filePath);
const fileSize = fs.statSync(filePath).size;
const baselineCodes = await this._captureVmFileCodes();
const { uploadUrl, params, fileFieldName } = await this.getUploadParams();
const boundary = '----FormBoundary' + crypto.randomBytes(16).toString('hex');
// XFS form fields
const formFields = {};
for (const [k, v] of Object.entries(params)) {
if (!/^file(?:_\d+)?$/i.test(k)) { // eslint-disable-line security/detect-unsafe-regex -- safe: no backtracking
formFields[k] = v;
}
}
// Build multipart
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="${fileFieldName || '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;
let bytesRead = 0;
const CHUNK_SIZE = 1024 * 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;
}
// Transit server lives on a different domain (*.vmwesa.online) and runs
// the nginx-upload-progress module. It requires an X-Progress-ID query
// parameter on the POST URL — without it the upload hangs at the final
// byte because the module can't finalize the session. Browsers append it
// automatically before submitting the form.
const progressId = Date.now().toString() + Math.floor(Math.random() * 1e6).toString().padStart(6, '0');
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
});
this._parseCookiesFromHeaders(headers || {});
// Check if upload response is a redirect (XFS often redirects to result page)
let resultHtml;
if ([301, 302, 303].includes(statusCode)) {
const location = headers && headers.location;
// Always drain the original body to prevent connection leak
try { await body.text(); } catch {}
if (location) {
const resultRes = await this._fetch(new URL(location, uploadUrl).href);
resultHtml = await resultRes.text();
} else {
resultHtml = '';
}
} else {
resultHtml = await body.text();
}
// 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.
try {
const json = JSON.parse(resultHtml);
if (json.status && /ok/i.test(json.status) && json.file_code) {
return this._buildUrlsFromCode(json.file_code);
}
if (json.file_code || json.filecode) {
return this._buildUrlsFromCode(json.file_code || json.filecode);
}
if (json.files && json.files.length > 0) {
const f = json.files[0];
return this._buildUrlsFromCode(f.filecode || f.file_code);
}
if (json.result) {
const r = Array.isArray(json.result) ? json.result[0] : json.result;
const code = r.filecode || r.file_code;
const urls = this._buildUrlsFromCode(code);
if (urls) return urls;
}
if (json.status && !/ok/i.test(json.status) && json.msg) {
throw new Error(`Vidmoly Upload abgelehnt: ${json.msg}`);
}
} catch (err) {
if (err && /Vidmoly Upload abgelehnt/.test(err.message)) throw err;
}
try {
return this._parseUploadResult(resultHtml);
} catch (primaryErr) {
const fallback = await this._resolveUploadedFileFromVmApi(fileName, baselineCodes, signal);
if (fallback) return fallback;
throw primaryErr;
}
}
_normalizeTitle(value) {
return String(value || '')
.toLowerCase()
.normalize('NFKD')
.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;
return {
download_url: `${BASE_URL}/w/${code}`,
embed_url: `${BASE_URL}/embed-${code}.html`,
file_code: code
};
}
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();
}
}
async _fetchVmList() {
const params = new URLSearchParams({
op: 'vm',
api: 'list',
page: '1',
per: '100',
sort: 'date',
order: 'desc',
fld_id: '0'
});
const res = await this._fetch(`${BASE_URL}/?${params.toString()}`);
const body = await res.text();
let payload;
try {
payload = JSON.parse(body);
} catch {
throw new Error('Vidmoly VM API lieferte kein JSON');
}
if (!payload || !Array.isArray(payload.files)) return [];
return payload.files;
}
async _resolveUploadedFileFromVmApi(fileName, baselineCodes, signal) {
const expectedTitle = this._normalizeTitle(path.parse(fileName).name);
for (let attempt = 0; attempt < RESULT_POLL_ATTEMPTS; attempt++) {
if (signal && signal.aborted) {
const err = new Error('Aborted');
err.name = 'AbortError';
throw err;
}
let files = [];
try {
files = await this._fetchVmList();
} catch {
files = [];
}
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));
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 (attempt < RESULT_POLL_ATTEMPTS - 1) {
await this._sleep(RESULT_POLL_DELAY_MS, signal);
}
}
return null;
}
_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);
}
});
}
_parseUploadResult(html) {
let download_url = null;
let embed_url = null;
let file_code = null;
const fnMatch = html.match(/<(?:input|textarea)[^>]*name=["']fn["'][^>]*(?:value=["']([^"']+)["'])?[^>]*>([^<]*)/i); // eslint-disable-line security/detect-unsafe-regex -- parses trusted hoster HTML only
if (fnMatch) {
const codeFromFn = (fnMatch[1] || fnMatch[2] || '').trim();
if (/^[a-z0-9]{8,16}$/i.test(codeFromFn)) {
file_code = codeFromFn;
}
}
if (!file_code) {
const fnAltMatch = html.match(/(?:^|[?&])fn=([a-z0-9]{8,16})(?:&|$)/i);
if (fnAltMatch) file_code = fnAltMatch[1];
}
// Vidmoly URL patterns - includes /w/ path format
const linkPatterns = [
/https?:\/\/vidmoly\.[a-z]+\/w\/[a-z0-9]{12}/gi,
/https?:\/\/vidmoly\.[a-z]+\/embed-[a-z0-9]{12}[^\s"']*/gi,
/https?:\/\/vidmoly\.[a-z]+\/[a-z0-9]{12}\.html/gi,
/https?:\/\/vidmoly\.[a-z]+\/[a-z0-9]{12}/gi
];
for (const pattern of linkPatterns) {
const matches = html.match(pattern);
if (matches) {
for (const url of matches) {
if (url.includes('/embed-') || url.includes('/embed/')) {
if (!embed_url) embed_url = url;
} else {
if (!download_url) download_url = url;
}
}
}
}
// Extract file code from URLs
const codeMatch = (download_url || embed_url || '').match(/\/(?:w\/)?([a-z0-9]{12})/i)
|| (download_url || embed_url || '').match(/embed-([a-z0-9]{12})/i);
if (codeMatch) {
file_code = codeMatch[1];
}
// Try input/textarea fields
if (!download_url) {
const inputMatch = html.match(/<(?:input|textarea)[^>]*value=["'](https?:\/\/vidmoly[^"']+)["']/i);
if (inputMatch) {
download_url = inputMatch[1];
const code = download_url.match(/\/(?:w\/)?([a-z0-9]{12})/i);
if (code) file_code = code[1];
}
}
// Try to find file code in any filecode reference
if (!file_code) {
const codeInPage = html.match(/filecode['":\s]+['"]?([a-z0-9]{12})['"]?/i)
|| html.match(/file_code['":\s]+['"]?([a-z0-9]{12})['"]?/i);
if (codeInPage) file_code = codeInPage[1];
}
// Build URLs from file_code
if (file_code && !download_url) {
download_url = `${BASE_URL}/w/${file_code}`;
}
if (file_code && !embed_url) {
embed_url = `${BASE_URL}/embed-${file_code}.html`;
}
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}`);
}
return { download_url, embed_url, file_code };
}
}
module.exports = VidmolyUploader;
+409
View File
@@ -0,0 +1,409 @@
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { request } = require('undici');
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';
const UPLOAD_TIMEOUT = 1800000; // 30 min
const RESULT_POLL_ATTEMPTS = 10;
const RESULT_POLL_DELAY_MS = 2000;
/**
* Login-based upload for VOE.sx (Laravel / FilePond)
* Fallback when API-based upload fails or is unavailable.
*/
class VoeUploader {
constructor() {
this.cookies = new Map();
}
_cookieHeader() {
return Array.from(this.cookies.entries())
.map(([k, v]) => `${k}=${v}`)
.join('; ');
}
_parseCookiesFromHeaders(headers) {
let setCookies;
if (typeof headers.getSetCookie === 'function') {
setCookies = headers.getSetCookie();
} else if (headers['set-cookie']) {
setCookies = Array.isArray(headers['set-cookie']) ? headers['set-cookie'] : [headers['set-cookie']];
} else {
return;
}
for (const raw of setCookies) {
const pair = raw.split(';')[0];
const eq = pair.indexOf('=');
if (eq > 0) {
this.cookies.set(pair.substring(0, eq).trim(), pair.substring(eq + 1).trim());
}
}
}
/**
* GET/POST with cookie management and manual redirect following
*/
async _fetch(url, opts = {}, _redirectCount = 0) {
const MAX_REDIRECTS = 10;
const headers = {
'User-Agent': USER_AGENT,
...(opts.headers || {})
};
if (this.cookies.size > 0) {
headers['Cookie'] = this._cookieHeader();
}
const res = await fetch(url, {
...opts,
headers,
redirect: 'manual'
});
this._parseCookiesFromHeaders(res.headers);
if ([301, 302, 303, 307, 308].includes(res.status)) {
try { await res.text(); } catch {}
if (_redirectCount >= MAX_REDIRECTS) {
throw new Error('Zu viele Redirects');
}
const location = res.headers.get('location');
if (location) {
const nextUrl = new URL(location, url).href;
return this._fetch(nextUrl, { ...opts, method: 'GET', body: undefined }, _redirectCount + 1);
}
}
return res;
}
/**
* Extract CSRF token from page HTML
*/
_extractCsrfToken(html) {
// Laravel meta tag
const metaMatch = html.match(/<meta\s+name=["']csrf-token["']\s+content=["']([^"']+)["']/i);
if (metaMatch) return metaMatch[1];
// Hidden input field
const inputMatch = html.match(/<input[^>]*name=["']_token["'][^>]*value=["']([^"']+)["']/i)
|| html.match(/<input[^>]*value=["']([^"']+)["'][^>]*name=["']_token["']/i);
if (inputMatch) return inputMatch[1];
return null;
}
/**
* Login to VOE.sx
*/
async login(email, password) {
// GET login page for cookies + CSRF token
const loginPageRes = await this._fetch(`${BASE_URL}/login`);
const loginHtml = await loginPageRes.text();
const csrfToken = this._extractCsrfToken(loginHtml);
if (!csrfToken) {
throw new Error('VOE Login: CSRF-Token nicht gefunden');
}
// POST login
const loginData = new URLSearchParams({
_token: csrfToken,
email: email,
password: password
});
const res = await this._fetch(`${BASE_URL}/login`, {
method: 'POST',
body: loginData.toString(),
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Referer': `${BASE_URL}/login`
}
});
const body = await res.text();
// Check for login errors
if (body.includes('credentials do not match') || body.includes('Incorrect') || body.includes('invalid')) {
throw new Error('VOE Login fehlgeschlagen: Falscher Username oder Passwort');
}
// Verify we have a session
const hasSession = this.cookies.has('voe_session') ||
this.cookies.has('laravel_session') ||
this.cookies.size > 2;
if (!hasSession) {
throw new Error('VOE Login fehlgeschlagen: Keine Session erhalten');
}
}
/**
* Get the upload page and extract CSRF token
*/
async _getUploadParams() {
const res = await this._fetch(`${BASE_URL}/file-upload`);
const html = await res.text();
const csrfToken = this._extractCsrfToken(html);
if (!csrfToken) {
throw new Error('VOE Upload: CSRF-Token nicht gefunden. Bist du eingeloggt?');
}
return { csrfToken };
}
/**
* Get upload server URL from /engine/delivery-node
* 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 body = await res.text();
let data;
try { data = JSON.parse(body); } catch {
throw new Error(`VOE: Upload-Server Antwort war kein JSON: ${body.slice(0, 200)}`);
}
if (!data || !data.success || !data.server) {
throw new Error('VOE: Kein Upload-Server erhalten von delivery-node');
}
return { uploadServer: data.server, sessionId: data.session_id || '' };
}
/**
* List current files via VOE API (for result polling fallback)
*/
async _fetchFileList() {
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 [];
} catch {
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();
}
}
/**
* Upload a file to VOE.sx via login session
* Flow: GET delivery-node → POST file to CDN server
*/
async upload(filePath, onProgress, signal, throttle) {
const fileName = path.basename(filePath);
const fileSize = fs.statSync(filePath).size;
const baselineCodes = await this._captureFileCodes();
// Step 1: Get CSRF token from upload page
const { csrfToken } = await this._getUploadParams();
// Step 2: Get CDN upload server from delivery-node
const { uploadServer, sessionId } = await this._getDeliveryNode(csrfToken);
const boundary = '----FormBoundary' + crypto.randomBytes(16).toString('hex');
// Build multipart body
let preamble = '';
// Include session_id if provided
if (sessionId) {
preamble += `--${boundary}\r\n`;
preamble += `Content-Disposition: form-data; name="session_id"\r\n\r\n`;
preamble += `${sessionId}\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;
let bytesRead = 0;
const CHUNK_SIZE = 1024 * 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;
}
// 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
});
this._parseCookiesFromHeaders(headers || {});
const rawBody = await body.text();
// Try JSON response
try {
const json = JSON.parse(rawBody);
// Direct file_code in response
const fileCode = json.file_code || json.filecode || json.slug ||
(json.file && (json.file.file_code || json.file.slug)) ||
(json.data && (json.data.file_code || json.data.slug));
if (fileCode) {
return this._buildUrls(fileCode);
}
// Check for error
if (json.error || json.message) {
throw new Error(`VOE Upload-Fehler: ${json.error || json.message}`);
}
} catch (parseErr) {
if (parseErr.message.startsWith('VOE Upload-Fehler')) 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;
throw new Error('VOE Upload: Kein file_code in der Antwort gefunden');
}
async _resolveUploadedFile(fileName, baselineCodes, signal) {
const expectedTitle = this._normalizeTitle(path.parse(fileName).name);
for (let attempt = 0; attempt < RESULT_POLL_ATTEMPTS; attempt++) {
if (signal && signal.aborted) {
const err = new Error('Aborted');
err.name = 'AbortError';
throw err;
}
let files = [];
try {
files = await this._fetchFileList();
} catch { files = []; }
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()));
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 (attempt < RESULT_POLL_ATTEMPTS - 1) {
await this._sleep(RESULT_POLL_DELAY_MS, signal);
}
}
return null;
}
_normalizeTitle(value) {
return String(value || '')
.toLowerCase()
.normalize('NFKD')
.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;
return {
download_url: `${BASE_URL}/${code}`,
embed_url: `${BASE_URL}/e/${code}`,
file_code: code
};
}
_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);
}
});
}
}
module.exports = VoeUploader;
+123
View File
@@ -0,0 +1,123 @@
function isDiscordWebhook(url) {
return /^https?:\/\/(ptb\.|canary\.)?(discord(app)?\.com)\/api\/webhooks\/\d+\/[\w-]+/i.test(String(url || ''));
}
const DISCORD_CONTENT_LIMIT = 1900;
function clampDiscordContent(text) {
const s = String(text || '');
if (s.length <= DISCORD_CONTENT_LIMIT) return s;
return s.slice(0, DISCORD_CONTENT_LIMIT - 1) + '…';
}
function formatDurationShort(sec) {
const s = Math.max(0, Math.round(Number(sec) || 0));
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const r = s % 60;
if (h > 0) return `${h}h ${m}m`;
if (m > 0) return `${m}m ${r}s`;
return `${r}s`;
}
function summarizePerHosterFromBatch(summary) {
const out = {};
if (!summary || !Array.isArray(summary.files)) return out;
for (const f of summary.files) {
if (!f || !Array.isArray(f.results)) continue;
for (const r of f.results) {
if (!r || !r.hoster) continue;
const b = out[r.hoster] || (out[r.hoster] = { ok: 0, fail: 0 });
if (r.status === 'done') b.ok++;
else b.fail++;
}
}
return out;
}
function resolveDiscordMention(raw) {
const s = String(raw || '').trim();
if (!s) return null;
const keyword = s.replace(/^@/, '').toLowerCase();
if (keyword === 'here' || keyword === 'everyone') {
return { token: `@${keyword}`, allowed: { parse: ['everyone'] } };
}
const roleMatch = s.match(/^(?:<@&(\d+)>|role:(\d+))$/i);
if (roleMatch) {
const id = roleMatch[1] || roleMatch[2];
return { token: `<@&${id}>`, allowed: { roles: [id] } };
}
const userMatch = s.match(/^(?:<@!?(\d+)>|user:(\d+)|(\d{5,30}))$/i);
if (userMatch) {
const id = userMatch[1] || userMatch[2] || userMatch[3];
return { token: `<@${id}>`, allowed: { users: [id] } };
}
return null;
}
function buildWebhookRequest(url, summary, meta) {
const m = meta || {};
const total = Number(summary && summary.total) || 0;
const succeeded = Number(summary && summary.succeeded) || 0;
const failed = Number(summary && summary.failed) || 0;
const perHoster = summarizePerHosterFromBatch(summary);
const duration = formatDurationShort(m.durationSec);
let body;
if (isDiscordWebhook(url)) {
const headline = m.aborted ? 'Batch abgebrochen' : 'Batch fertig';
const hosterEntries = Object.entries(perHoster);
const MAX_HOSTER_LINES = 12;
let hosterLines = hosterEntries.slice(0, MAX_HOSTER_LINES)
.map(([h, b]) => `${h}: ${b.ok}/${b.ok + b.fail}`)
.join(' · ');
if (hosterEntries.length > MAX_HOSTER_LINES) hosterLines += ` · …+${hosterEntries.length - MAX_HOSTER_LINES}`;
const lines = [
`**Multi-Hoster-Upload — ${headline}**${m.machineName ? ` (${m.machineName})` : ''}`,
`${succeeded} ok · ❌ ${failed} Fehler · 📦 ${total} gesamt · ⏱ ${duration}`
];
if (hosterLines) lines.push(hosterLines);
const mention = resolveDiscordMention(m.mention);
const content = clampDiscordContent((mention ? mention.token + ' ' : '') + lines.join('\n'));
const payload = { content };
payload.allowed_mentions = mention ? mention.allowed : { parse: [] };
body = JSON.stringify(payload);
} else {
body = JSON.stringify({
event: 'batch-done',
app: 'multi-hoster-upload',
version: m.appVersion || null,
machine: m.machineName || null,
total,
succeeded,
failed,
durationSec: Math.round(Number(m.durationSec) || 0),
aborted: !!m.aborted,
perHoster,
timestamp: m.timestamp || null
});
}
return {
url: String(url),
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body
};
}
function isAllAborted(summary) {
if (!summary || !Array.isArray(summary.files) || summary.files.length === 0) return false;
let sawResult = false;
for (const f of summary.files) {
if (!f || !Array.isArray(f.results)) continue;
for (const r of f.results) {
if (!r) continue;
sawResult = true;
if (r.status !== 'aborted') return false;
}
}
return sawResult;
}
module.exports = { isDiscordWebhook, formatDurationShort, summarizePerHosterFromBatch, buildWebhookRequest, resolveDiscordMention, isAllAborted, clampDiscordContent, DISCORD_CONTENT_LIMIT };