From 6d0ad84d2d36353df69bbedf620f9cf36c67fd11 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe <259325684+Sucukdeluxe@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:46:29 +0200 Subject: [PATCH] Release Multi-Hoster Uploader 3.3.108 --- .gitignore | 20 + README.md | 45 + SECURITY.md | 7 + assets/app_icon.ico | Bin 0 -> 18385 bytes assets/app_icon.png | Bin 0 -> 8228 bytes assets/product-overview.png | Bin 0 -> 291162 bytes eslint.config.mjs | 105 + lib/account-auth.js | 36 + lib/account-rotation.js | 27 + lib/backup-crypto.js | 95 + lib/clouddrop-upload.js | 239 + lib/coalesced-set.js | 73 + lib/config-store.js | 654 +++ lib/diagnostics-agent.js | 32 + lib/diagnostics-collectors.js | 277 ++ lib/doodstream-upload.js | 705 +++ lib/file-probe.js | 77 + lib/folder-monitor.js | 103 + lib/hosters.js | 728 +++ lib/ip-allowlist.js | 50 + lib/log-mode.js | 109 + lib/log-policy.js | 17 + lib/log-rotation.js | 52 + lib/orphan-tmp.js | 29 + lib/queue-dedup.js | 113 + lib/queue-prune.js | 59 + lib/remote-capture-preload.js | 23 + lib/remote-capture.html | 150 + lib/remote-server.js | 216 + lib/secret-store.js | 75 + lib/semaphore.js | 73 + lib/stats.js | 142 + lib/support-bundle.js | 112 + lib/throttle-timer.js | 59 + lib/throttle.js | 43 + lib/throttled-cache.js | 51 + lib/updater.js | 283 ++ lib/upload-log.js | 34 + lib/upload-manager.js | 1423 ++++++ lib/vidmoly-upload.js | 518 ++ lib/voe-upload.js | 409 ++ lib/webhook-notify.js | 123 + main.js | 3158 ++++++++++++ package-lock.json | 4948 +++++++++++++++++++ package.json | 57 + preload-drop-target.js | 5 + preload.js | 162 + renderer/app.js | 5640 ++++++++++++++++++++++ renderer/drop-target.html | 68 + renderer/index.html | 426 ++ renderer/styles.css | 1341 +++++ scripts/afterPack.cjs | 32 + scripts/verify-public-release.mjs | 253 + tests/account-auth.test.js | 44 + tests/account-rotation.test.js | 126 + tests/backup-crypto.test.js | 73 + tests/byse-reject-recovery.test.js | 234 + tests/coalesced-set.test.js | 144 + tests/config-store.test.js | 409 ++ tests/diagnostics-agent.test.js | 62 + tests/diagnostics-collectors.test.js | 166 + tests/diagnostics-protocol.test.js | 120 + tests/doodstream-api-upload.test.js | 105 + tests/doodstream-upload.test.js | 221 + tests/file-probe.test.js | 115 + tests/history-retention.test.js | 84 + tests/hosters.test.js | 95 + tests/ip-allowlist.test.js | 52 + tests/log-mode.test.js | 150 + tests/log-policy.test.js | 52 + tests/log-rotation.test.js | 134 + tests/orphan-tmp.test.js | 56 + tests/queue-dedup-property.test.js | 114 + tests/queue-dedup.test.js | 253 + tests/queue-persistence-scenario.test.js | 83 + tests/queue-prune.test.js | 115 + tests/remote-config.test.js | 53 + tests/remote-server.test.js | 41 + tests/semaphore.test.js | 174 + tests/stats.test.js | 132 + tests/support-bundle.test.js | 160 + tests/suspect-reject-alternates.test.js | 286 ++ tests/throttle-timer.test.js | 133 + tests/throttle.test.js | 101 + tests/throttled-cache.test.js | 113 + tests/ui-smoke.js | 322 ++ tests/upload-log.test.js | 80 + tests/upload-manager.test.js | 1021 ++++ tests/validate-credentials.test.js | 195 + tests/webhook-notify.test.js | 156 + 90 files changed, 29450 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 SECURITY.md create mode 100644 assets/app_icon.ico create mode 100644 assets/app_icon.png create mode 100644 assets/product-overview.png create mode 100644 eslint.config.mjs create mode 100644 lib/account-auth.js create mode 100644 lib/account-rotation.js create mode 100644 lib/backup-crypto.js create mode 100644 lib/clouddrop-upload.js create mode 100644 lib/coalesced-set.js create mode 100644 lib/config-store.js create mode 100644 lib/diagnostics-agent.js create mode 100644 lib/diagnostics-collectors.js create mode 100644 lib/doodstream-upload.js create mode 100644 lib/file-probe.js create mode 100644 lib/folder-monitor.js create mode 100644 lib/hosters.js create mode 100644 lib/ip-allowlist.js create mode 100644 lib/log-mode.js create mode 100644 lib/log-policy.js create mode 100644 lib/log-rotation.js create mode 100644 lib/orphan-tmp.js create mode 100644 lib/queue-dedup.js create mode 100644 lib/queue-prune.js create mode 100644 lib/remote-capture-preload.js create mode 100644 lib/remote-capture.html create mode 100644 lib/remote-server.js create mode 100644 lib/secret-store.js create mode 100644 lib/semaphore.js create mode 100644 lib/stats.js create mode 100644 lib/support-bundle.js create mode 100644 lib/throttle-timer.js create mode 100644 lib/throttle.js create mode 100644 lib/throttled-cache.js create mode 100644 lib/updater.js create mode 100644 lib/upload-log.js create mode 100644 lib/upload-manager.js create mode 100644 lib/vidmoly-upload.js create mode 100644 lib/voe-upload.js create mode 100644 lib/webhook-notify.js create mode 100644 main.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 preload-drop-target.js create mode 100644 preload.js create mode 100644 renderer/app.js create mode 100644 renderer/drop-target.html create mode 100644 renderer/index.html create mode 100644 renderer/styles.css create mode 100644 scripts/afterPack.cjs create mode 100644 scripts/verify-public-release.mjs create mode 100644 tests/account-auth.test.js create mode 100644 tests/account-rotation.test.js create mode 100644 tests/backup-crypto.test.js create mode 100644 tests/byse-reject-recovery.test.js create mode 100644 tests/coalesced-set.test.js create mode 100644 tests/config-store.test.js create mode 100644 tests/diagnostics-agent.test.js create mode 100644 tests/diagnostics-collectors.test.js create mode 100644 tests/diagnostics-protocol.test.js create mode 100644 tests/doodstream-api-upload.test.js create mode 100644 tests/doodstream-upload.test.js create mode 100644 tests/file-probe.test.js create mode 100644 tests/history-retention.test.js create mode 100644 tests/hosters.test.js create mode 100644 tests/ip-allowlist.test.js create mode 100644 tests/log-mode.test.js create mode 100644 tests/log-policy.test.js create mode 100644 tests/log-rotation.test.js create mode 100644 tests/orphan-tmp.test.js create mode 100644 tests/queue-dedup-property.test.js create mode 100644 tests/queue-dedup.test.js create mode 100644 tests/queue-persistence-scenario.test.js create mode 100644 tests/queue-prune.test.js create mode 100644 tests/remote-config.test.js create mode 100644 tests/remote-server.test.js create mode 100644 tests/semaphore.test.js create mode 100644 tests/stats.test.js create mode 100644 tests/support-bundle.test.js create mode 100644 tests/suspect-reject-alternates.test.js create mode 100644 tests/throttle-timer.test.js create mode 100644 tests/throttle.test.js create mode 100644 tests/throttled-cache.test.js create mode 100644 tests/ui-smoke.js create mode 100644 tests/upload-log.test.js create mode 100644 tests/upload-manager.test.js create mode 100644 tests/validate-credentials.test.js create mode 100644 tests/webhook-notify.test.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..42a6a46 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +node_modules/ +release/ +.worktrees/ +__pycache__/ +*.pyc + +electron-config.json +electron-config.json.bak +electron-config.json.tmp +electron-config.json.pre-history-split.bak +electron-config.pre-import-*.json +electron-history.json +electron-history.json.tmp +*.log +debug.log +fileuploader.log +account-rotation.log +doodstream-debug.log +upload-debug.log +release-*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..7609905 --- /dev/null +++ b/README.md @@ -0,0 +1,45 @@ +# Multi-Hoster-Upload + +Multi-Hoster-Upload is a Windows desktop app for managing large file batches across multiple video hosting services from one queue. + +![Multi-Hoster-Upload product overview](assets/product-overview.png) + +[Download the latest release](https://github.com/Sucukdeluxe/Multi-Hoster-Upload/releases/latest) + +## Capabilities + +- Upload one batch to several supported hosters in parallel. +- Manage multiple accounts per hoster with validation, health checks, and automatic rotation. +- Add files by drag and drop or file selection and monitor live queue progress. +- Control per-hoster concurrency, bandwidth limits, retries, and folder monitoring. +- Keep local upload history and copy completed links in bulk. + +## Supported hosters + +| Hoster | Authentication | +| --- | --- | +| Doodstream | Web login or API key | +| VOE | Web login or API key | +| Vidmoly | Web login | +| Byse | API key | +| Clouddrop | API key | + +## Installation + +1. Download the Setup or portable executable from the [latest GitHub release](https://github.com/Sucukdeluxe/Multi-Hoster-Upload/releases/latest). +2. Run the installer, or launch the portable executable directly. +3. Add and validate at least one hoster account in Settings, then select files and start the queue. + +## Local data and credentials + +Settings, queue state, and upload history are stored locally in the app's user-data directory. Hoster passwords and API keys are encrypted with Electron safeStorage before being written when operating-system encryption is available; on Windows this uses DPAPI for the current user profile. + +## Development + +```powershell +npm install +npm start +npm test +npm run lint +npm run release:win +``` diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..aa8784a --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,7 @@ +# Security Policy + +Report security vulnerabilities through GitHub private vulnerability reporting when it is available for this repository: open the Security tab, choose Advisories, and select "Report a vulnerability." + +Do not post credentials, passwords, API keys, tokens, cookies, private logs, configuration files, or private upload links in public issues. Redact sensitive values from reproduction steps and attachments. + +If private vulnerability reporting is unavailable, contact the repository owner through a private channel before sharing technical details. diff --git a/assets/app_icon.ico b/assets/app_icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..ed6b808bf9739b0694cf20e2e534702768298dd0 GIT binary patch literal 18385 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 Hb_@yt0@{W& literal 0 HcmV?d00001 diff --git a/assets/app_icon.png b/assets/app_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..ac13bab8685365fbf73427b9989fa7ac46b5cd12 GIT binary patch literal 8228 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

_ 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 literal 0 HcmV?d00001 diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..513008a --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,105 @@ +import security from 'eslint-plugin-security'; + +const sharedRules = { + // Security rules + // detect-object-injection disabled: 78 false positives from config lookups like obj[hosterName] + 'security/detect-object-injection': 'off', + 'security/detect-non-literal-regexp': 'warn', + 'security/detect-unsafe-regex': 'warn', + 'security/detect-buffer-noassert': 'warn', + 'security/detect-eval-with-expression': 'error', + 'security/detect-no-csrf-before-method-override': 'warn', + 'security/detect-possible-timing-attacks': 'warn', + 'security/detect-pseudoRandomBytes': 'warn', + // Code quality + 'no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }], + 'no-undef': 'error', + 'no-constant-condition': 'warn', + 'no-debugger': 'error', + 'no-duplicate-case': 'error', + 'no-empty': ['warn', { allowEmptyCatch: true }], + 'no-ex-assign': 'error', + 'no-extra-boolean-cast': 'warn', + 'no-func-assign': 'error', + 'no-inner-declarations': 'error', + 'no-irregular-whitespace': 'error', + 'no-unreachable': 'error', + 'use-isnan': 'error', + 'valid-typeof': 'error', + 'eqeqeq': ['warn', 'always'], + 'no-caller': 'error', + 'no-eval': 'error', + 'no-implied-eval': 'error', + 'no-new-func': 'error', + 'no-throw-literal': 'warn', + 'no-self-assign': 'error', + 'no-self-compare': 'error', + 'no-loss-of-precision': 'error', + 'no-dupe-keys': 'error', + 'no-unsafe-finally': 'error', + 'no-unmodified-loop-condition': 'warn', + 'no-template-curly-in-string': 'warn', +}; + +const nodeGlobals = { + process: 'readonly', + console: 'readonly', + setTimeout: 'readonly', + clearTimeout: 'readonly', + setInterval: 'readonly', + clearInterval: 'readonly', + setImmediate: 'readonly', + Buffer: 'readonly', + URL: 'readonly', + URLSearchParams: 'readonly', + fetch: 'readonly', + crypto: 'readonly', + structuredClone: 'readonly', +}; + +export default [ + { ignores: ['**/node_modules/**', 'release/**', 'tests/**'] }, + { + files: ['**/*.js'], + ignores: ['gateway/**'], + plugins: { security }, + languageOptions: { + ecmaVersion: 2022, + sourceType: 'commonjs', + globals: { + require: 'readonly', + module: 'readonly', + exports: 'readonly', + __dirname: 'readonly', + __filename: 'readonly', + ...nodeGlobals, + AbortController: 'readonly', + AbortSignal: 'readonly', + navigator: 'readonly', + document: 'readonly', + window: 'readonly', + localStorage: 'readonly', + HTMLElement: 'readonly', + alert: 'readonly', + confirm: 'readonly', + requestAnimationFrame: 'readonly', + queueMicrotask: 'readonly', + Intl: 'readonly', + performance: 'readonly', + EventSource: 'readonly', + } + }, + rules: sharedRules + }, + { + files: ['gateway/**/*.js', 'gateway/**/*.mjs'], + ignores: ['gateway/node_modules/**'], + plugins: { security }, + languageOptions: { + ecmaVersion: 2022, + sourceType: 'module', + globals: nodeGlobals + }, + rules: sharedRules + } +]; diff --git a/lib/account-auth.js b/lib/account-auth.js new file mode 100644 index 0000000..1fd0a4a --- /dev/null +++ b/lib/account-auth.js @@ -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 }; diff --git a/lib/account-rotation.js b/lib/account-rotation.js new file mode 100644 index 0000000..de6ee7c --- /dev/null +++ b/lib/account-rotation.js @@ -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 }; diff --git a/lib/backup-crypto.js b/lib/backup-crypto.js new file mode 100644 index 0000000..9fd9f22 --- /dev/null +++ b/lib/backup-crypto.js @@ -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 }; diff --git a/lib/clouddrop-upload.js b/lib/clouddrop-upload.js new file mode 100644 index 0000000..5223b88 --- /dev/null +++ b/lib/clouddrop-upload.js @@ -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; diff --git a/lib/coalesced-set.js b/lib/coalesced-set.js new file mode 100644 index 0000000..4136d3f --- /dev/null +++ b/lib/coalesced-set.js @@ -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); diff --git a/lib/config-store.js b/lib/config-store.js new file mode 100644 index 0000000..566372f --- /dev/null +++ b/lib/config-store.js @@ -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; diff --git a/lib/diagnostics-agent.js b/lib/diagnostics-agent.js new file mode 100644 index 0000000..f5c2239 --- /dev/null +++ b/lib/diagnostics-agent.js @@ -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 }; diff --git a/lib/diagnostics-collectors.js b/lib/diagnostics-collectors.js new file mode 100644 index 0000000..dcfaf23 --- /dev/null +++ b/lib/diagnostics-collectors.js @@ -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 }; diff --git a/lib/doodstream-upload.js b/lib/doodstream-upload.js new file mode 100644 index 0000000..1c3821b --- /dev/null +++ b/lib/doodstream-upload.js @@ -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: + 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. + //

+ // + // 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(/&/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(/]*\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: + const ta = /]*name=['"]([^'"]+)['"][^>]*>([\s\S]*?)<\/textarea>/gi; + let m; + while ((m = ta.exec(html)) !== null) fields[m[1]] = m[2].trim(); + // Input hidden fields + const p1 = /]*type=['"]hidden['"][^>]*name=['"]([^'"]+)['"][^>]*value=['"]([^'"]*)['"]/gi; + while ((m = p1.exec(html)) !== null) { if (!fields[m[1]]) fields[m[1]] = m[2]; } + const p2 = /]*name=['"]([^'"]+)['"][^>]*value=['"]([^'"]*)['"]/gi; + while ((m = p2.exec(html)) !== null) { if (!fields[m[1]]) fields[m[1]] = m[2]; } + const p3 = /]*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(/]*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, // + /<(?:textarea|code|span|pre|input)[^>]*>\s*([A-Za-z0-9]{20,})\s*KEY + /\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; diff --git a/lib/file-probe.js b/lib/file-probe.js new file mode 100644 index 0000000..b312f6a --- /dev/null +++ b/lib/file-probe.js @@ -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(' 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 }; diff --git a/lib/folder-monitor.js b/lib/folder-monitor.js new file mode 100644 index 0000000..1c6914f --- /dev/null +++ b/lib/folder-monitor.js @@ -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; diff --git a/lib/hosters.js b/lib/hosters.js new file mode 100644 index 0000000..5bd7a5d --- /dev/null +++ b/lib/hosters.js @@ -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 + } +}; diff --git a/lib/ip-allowlist.js b/lib/ip-allowlist.js new file mode 100644 index 0000000..50ec344 --- /dev/null +++ b/lib/ip-allowlist.js @@ -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 }; diff --git a/lib/log-mode.js b/lib/log-mode.js new file mode 100644 index 0000000..2a945ce --- /dev/null +++ b/lib/log-mode.js @@ -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); diff --git a/lib/log-policy.js b/lib/log-policy.js new file mode 100644 index 0000000..3bef9f8 --- /dev/null +++ b/lib/log-policy.js @@ -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 }; diff --git a/lib/log-rotation.js b/lib/log-rotation.js new file mode 100644 index 0000000..0242848 --- /dev/null +++ b/lib/log-rotation.js @@ -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 }; diff --git a/lib/orphan-tmp.js b/lib/orphan-tmp.js new file mode 100644 index 0000000..eb66d51 --- /dev/null +++ b/lib/orphan-tmp.js @@ -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); diff --git a/lib/queue-dedup.js b/lib/queue-dedup.js new file mode 100644 index 0000000..4b97799 --- /dev/null +++ b/lib/queue-dedup.js @@ -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); diff --git a/lib/queue-prune.js b/lib/queue-prune.js new file mode 100644 index 0000000..81352f4 --- /dev/null +++ b/lib/queue-prune.js @@ -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); diff --git a/lib/remote-capture-preload.js b/lib/remote-capture-preload.js new file mode 100644 index 0000000..c32fa96 --- /dev/null +++ b/lib/remote-capture-preload.js @@ -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(' ')) +}); diff --git a/lib/remote-capture.html b/lib/remote-capture.html new file mode 100644 index 0000000..64d3699 --- /dev/null +++ b/lib/remote-capture.html @@ -0,0 +1,150 @@ + + +Remote Capture + + + + diff --git a/lib/remote-server.js b/lib/remote-server.js new file mode 100644 index 0000000..486c5e3 --- /dev/null +++ b/lib/remote-server.js @@ -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; diff --git a/lib/secret-store.js b/lib/secret-store.js new file mode 100644 index 0000000..51412e9 --- /dev/null +++ b/lib/secret-store.js @@ -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 }; diff --git a/lib/semaphore.js b/lib/semaphore.js new file mode 100644 index 0000000..0c0a179 --- /dev/null +++ b/lib/semaphore.js @@ -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; diff --git a/lib/stats.js b/lib/stats.js new file mode 100644 index 0000000..277c21e --- /dev/null +++ b/lib/stats.js @@ -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 `${label}`; + }).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); diff --git a/lib/support-bundle.js b/lib/support-bundle.js new file mode 100644 index 0000000..5ba0dc0 --- /dev/null +++ b/lib/support-bundle.js @@ -0,0 +1,112 @@ +const fs = require('fs'); + +const CRED_KEYS = new Set(['password', 'apiKey', 'token', 'cookie', 'sessionId', 'webhookUrl', 'diagToken']); +const 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\n\n`; + let stat; + try { stat = fs.statSync(filePath); } + catch (err) { + if (err && err.code === 'ENOENT') return `=== ${label} (${filePath}) ===\n\n\n`; + return `=== ${label} (${filePath}) ===\n\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 = `\n` + buf.toString('utf-8'); + } else { + content = fs.readFileSync(filePath, 'utf-8'); + } + } catch (err) { + content = ``; + } + 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 }; diff --git a/lib/throttle-timer.js b/lib/throttle-timer.js new file mode 100644 index 0000000..c4a9d52 --- /dev/null +++ b/lib/throttle-timer.js @@ -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); diff --git a/lib/throttle.js b/lib/throttle.js new file mode 100644 index 0000000..abb1ad0 --- /dev/null +++ b/lib/throttle.js @@ -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; diff --git a/lib/throttled-cache.js b/lib/throttled-cache.js new file mode 100644 index 0000000..c6376cf --- /dev/null +++ b/lib/throttled-cache.js @@ -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); diff --git a/lib/updater.js b/lib/updater.js new file mode 100644 index 0000000..ac38810 --- /dev/null +++ b/lib/updater.js @@ -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 }; diff --git a/lib/upload-log.js b/lib/upload-log.js new file mode 100644 index 0000000..6025791 --- /dev/null +++ b/lib/upload-log.js @@ -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); diff --git a/lib/upload-manager.js b/lib/upload-manager.js new file mode 100644 index 0000000..c95f1aa --- /dev/null +++ b/lib/upload-manager.js @@ -0,0 +1,1423 @@ +const { EventEmitter } = require('events'); +const path = require('path'); +const fs = require('fs'); +const crypto = require('crypto'); +const { uploadFile, prefetchBaseline } = require('./hosters'); +const VidmolyUploader = require('./vidmoly-upload'); +const VoeUploader = require('./voe-upload'); +const DoodstreamUploader = require('./doodstream-upload'); +const ClouddropUploader = require('./clouddrop-upload'); +const Semaphore = require('./semaphore'); +const Throttle = require('./throttle'); +const { probeFileHead } = require('./file-probe'); + +const DEFAULT_SETTINGS = { + retries: 3, + maxSpeedKbs: 0, + parallelCount: 2, + restartBelowKbs: 0, + timeIntervalSec: 0, + maxSizeMb: 0, + sizeMemoEnabled: true +}; + +class UploadManager extends EventEmitter { + constructor(hosterSettings, globalSettings, accountPools) { + super(); + this.hosterSettings = hosterSettings || {}; + this.globalSettings = globalSettings || {}; + this.accountPools = accountPools || {}; + this.semaphores = {}; + this.globalSemaphore = null; + this.abortController = new AbortController(); + this.running = false; + this.stopAfterActive = false; + this.statsInterval = null; + this.startTime = 0; + this.activeJobs = new Map(); // uploadId -> { jobId, speedKbs, bytesUploaded, hoster } + this.jobAbortControllers = new Map(); // jobId -> AbortController + this.cancelledJobIds = new Set(); + this.sessionBytes = 0; + this._transientErrorTotal = 0; + this.lastStartTime = {}; // hoster -> timestamp of last upload start + this.intervalLocks = {}; // hoster -> Promise chain for serialized interval waits + this.globalThrottle = null; + this._failedAccounts = new Map(); // hoster -> Set of failed accountIds + this._accountOverrides = new Map(); // hoster -> fallback account object + this._suspectSizeMemo = new Map(); // 'hoster:accountId' -> { size: smallest suspect-rejected fileSize, count: confirmed rejections }; blocks only after 2nd rejection + this._suspectGoodAccounts = new Map(); // hoster -> accountId that accepted a suspect-class file + this._doodApiKeyCache = new Map(); // accountId/username -> derived doodstream API key ('' = tried, none) + this._baselineCache = new Map(); // hoster:apiKey -> Promise> (one fetch shared across all jobs in batch) + } + + updateAccountPools(accountPools) { + if (accountPools && typeof accountPools === 'object') { + this.accountPools = accountPools; + } + } + + switchAccount(hoster, fallbackAccount) { + const prev = this._accountOverrides.get(hoster); + this._accountOverrides.set(hoster, fallbackAccount); + this._rotLog('switchAccount', { + hoster, + prevOverrideId: prev ? prev.id : null, + toAccountId: fallbackAccount ? fallbackAccount.id : null + }); + } + + // Introspection helpers used by main.js to re-resolve fallbacks when the + // config changes mid-batch (e.g. user adds a new account after their only + // one ran out of space). Without this, an account that got marked failed + // before a fallback existed stays stuck until the app restarts. + getFailedAccountKeys() { + return Array.from(this._failedAccounts.keys()); + } + + getOverride(hoster) { + return this._accountOverrides.get(hoster) || null; + } + + getActiveJobCount() { + return this.activeJobs.size; + } + + getDiagnostics() { + const activeByHoster = {}; + for (const v of this.activeJobs.values()) { + const h = v && v.hoster ? v.hoster : 'unknown'; + activeByHoster[h] = (activeByHoster[h] || 0) + 1; + } + let pending = 0; + for (const sem of Object.values(this.semaphores)) pending += (sem && sem.pending) || 0; + return { activeByHoster, transientErrors: this._transientErrorTotal, pending, active: this.activeJobs.size }; + } + + clearFailedAccount(hoster, accountId) { + return this._failedAccounts.delete(`${hoster}:${accountId}`); + } + + clearAllFailedAccounts() { + const n = this._failedAccounts.size; + this._failedAccounts.clear(); + return n; + } + + // True if the hoster has a usable override stored that differs from the + // account currently in the task and isn't itself already marked failed. + // Used by the retry loop to decide "retry on same account vs break to + // rotation" — skipping wasted attempts on a likely-bad primary when a + // pre-resolved fallback is ready to try. + _hasPendingOverride(hoster, currentAccountId) { + const override = this._accountOverrides.get(hoster); + if (!override) return false; + if (override.id === currentAccountId) return false; + if (this._failedAccounts.has(hoster + ':' + override.id)) return false; + return true; + } + + _rotLog(event, data) { + this.emit('rot-log', { ts: Date.now(), event, ...data }); + } + + _noteSuspectReject(hoster, accountId, fileSize) { + if (!accountId || !Number.isFinite(fileSize) || fileSize <= 0) return; + const key = hoster + ':' + accountId; + const prev = this._suspectSizeMemo.get(key); + if (prev === undefined) this._suspectSizeMemo.set(key, { size: fileSize, count: 1 }); + else this._suspectSizeMemo.set(key, { size: Math.min(prev.size, fileSize), count: prev.count + 1 }); + } + + _suspectMemoBlocks(hoster, accountId, fileSize) { + if (this.hosterSettings[hoster] && this.hosterSettings[hoster].sizeMemoEnabled === false) return false; + const memo = this._suspectSizeMemo.get(hoster + ':' + accountId); + return !!memo && memo.count >= 2 && fileSize > memo.size; + } + + // File-specific rejections from the hoster: the same file will get rejected + // on any account, so rotation is pointless. Matches the `err.fileRejected` + // flag set by parsers plus known rejection phrases. + // NOTE: We deliberately do NOT match the generic "lehnte Datei ab" prefix + // here — that phrase is used by the Byse parser for both file- AND + // account-level errors. Account-level ones set err.accountError instead, + // which takes priority in _shouldSkipRetryOnAccountError. + _isFileRejectedError(err) { + if (!err) return false; + if (err.transientNetwork === true) return false; + if (err.accountError === true) return false; // explicit account-level wins + if (err.fileRejected === true) return true; + if (!err.message) return false; + const m = String(err.message); + return /(Not video file format|Duplicate|Datei zu (klein|gross|groß)|File too (small|large)|Invalid file|Unsupported format)/i.test(m); + } + + // Hoster-side transient flake — the hoster's backend accepted the upload but + // returned a malformed/empty result (e.g. doodstream CDN form with no fn/no + // st). Same account + same file works on a later attempt; this is NOT an + // account problem. Treated exactly like a transient network error: skip + // remaining in-batch retries (the flake won't clear in 3s and a re-upload of + // 95 MB is expensive), don't blacklist the account, fail this file cleanly. + // The user's next manual retry — or a later batch — can use the same account. + _isHosterTransientError(err) { + if (!err) return false; + if (err.hosterTransient === true) return true; // explicit flag — primary + if (!err.message) return false; + // Defensive fallback: catch the same class of error if it bubbles up + // wrapped (e.g. through a different code path) without the flag set. + return /Server gab leeren Link zurueck|kein Filecode/i.test(String(err.message)); + } + + // Transient network errors — the account is fine, the network or the + // hoster's own backend hiccuped. Retrying on the SAME account is the right + // move; marking it failed would wrongly poison the fallback chain. If all + // retries on the current account still hit this class of error, we bail + // out for this file without blacklisting the account, so other jobs in the + // batch still get a fresh chance on it. + _isTransientNetworkError(err) { + if (!err) return false; + if (err.transientNetwork === true) return true; + if (!err.message) return false; + const m = String(err.message); + const TRANSIENT = [ + /ENOTFOUND/i, + /ECONNRESET/i, + /ECONNREFUSED/i, + /ETIMEDOUT/i, + /EAI_AGAIN/i, + /EHOSTUNREACH/i, + /ENETUNREACH/i, + /EPIPE/i, + /socket hang up/i, + /network (error|failure|problem)/i, + /dns (lookup|error|failed)/i, + /getaddrinfo/i, + /fetch failed/i, + /\bconnect (ETIMEDOUT|ECONN)/i, + /HTTP 5\d\d\b/i, + /Bad Gateway/i, + /Service Unavailable/i, + /Gateway Time-?out/i + ]; + return TRANSIENT.some(p => p.test(m)); + } + + // Error classes that mean "this account is the problem, retrying on it won't + // help" — we skip the remaining retries and go straight to the fallback + // account. Keeps single runs fast when an account is rate-limited, banned, + // or out of quota. + _shouldSkipRetryOnAccountError(err) { + if (!err) return false; + if (err.transientNetwork === true) return false; + // Explicit account-level flag from hoster parsers — highest priority. + if (err.accountError === true) return true; + if (!err.message) return false; + const m = String(err.message); + const PATTERNS = [ + /Kein Upload-Server/i, + /No upload server/i, + /kein server/i, + /quota/i, + /limit (reached|exceeded|überschritten)/i, + /rate[- ]?limit/i, + /too many requests/i, + /\b(401|403|429)\b/, + /Falscher (User|Username|Passwort)/i, + /Incorrect (Login|Password)/i, + /invalid (credentials|api[- ]?key|token|session)/i, + /(account|user) (banned|suspended|disabled|gesperrt)/i, + /not authorized/i, + /forbidden/i, + /session (expired|abgelaufen)/i, + // Session/CSRF hints — the account's server session went stale, which + // no amount of retrying will fix. Re-login happens on the next account. + /CSRF[- ]?Token nicht gefunden/i, + /CSRF[- ]?token not found/i, + /Bist du eingeloggt/i, + /not logged in/i, + // Storage exhaustion — account is full. Rotate instead of hammering it. + /not enough (disk )?(space|storage)/i, + /insufficient (disk )?space/i, + /disk (space )?full/i, + /storage (exhausted|full|voll|limit)/i, + /account (full|voll)/i + ]; + return PATTERNS.some(p => p.test(m)); + } + + updateSettings(hosterSettings, globalSettings) { + this.hosterSettings = hosterSettings || this.hosterSettings; + this.globalSettings = globalSettings || this.globalSettings; + // Live-update semaphores for running uploads + for (const [hoster, sem] of Object.entries(this.semaphores)) { + const settings = this._getSettings(hoster); + sem.updateLimit(settings.parallelCount); + } + // Update global throttle if speed limit changed + const newKbs = (this.globalSettings.globalMaxSpeedKbs || 0); + if (newKbs > 0) { + if (this.globalThrottle) { + this.globalThrottle.updateRate(newKbs * 1024); + } else { + this.globalThrottle = new Throttle(newKbs * 1024); + } + } else { + this.globalThrottle = null; + } + // Update global semaphore live + const globalLimit = this._getGlobalParallelLimit(); + if (globalLimit > 0 && this.globalSemaphore) { + this.globalSemaphore.updateLimit(globalLimit); + } + } + + _getSettings(hoster) { + const settings = { ...DEFAULT_SETTINGS, ...(this.hosterSettings[hoster] || {}) }; + const globalLimit = this._getGlobalParallelLimit(); + if (this.globalSettings.scaleParallelUploads && globalLimit > 0) { + settings.parallelCount = Math.min(settings.parallelCount || 1, globalLimit); + } + return settings; + } + + _getGlobalParallelLimit() { + const raw = Number(this.globalSettings.parallelUploadCount || 0); + if (!Number.isFinite(raw) || raw <= 0) return 0; + return Math.max(1, Math.min(100, Math.round(raw))); + } + + _getGlobalSemaphore() { + const limit = this._getGlobalParallelLimit(); + if (limit <= 0) return null; + if (!this.globalSemaphore) { + this.globalSemaphore = new Semaphore(limit); + } else { + this.globalSemaphore.updateLimit(limit); + } + return this.globalSemaphore; + } + + _getGlobalThrottle() { + const kbs = Number(this.globalSettings.globalMaxSpeedKbs || 0); + if (!Number.isFinite(kbs) || kbs <= 0) return null; + if (!this.globalThrottle) { + this.globalThrottle = new Throttle(kbs * 1024); + } else { + this.globalThrottle.updateRate(kbs * 1024); + } + return this.globalThrottle; + } + + _getSemaphore(hoster) { + if (!this.semaphores[hoster]) { + const settings = this._getSettings(hoster); + this.semaphores[hoster] = new Semaphore(settings.parallelCount); + } else { + this.semaphores[hoster].updateLimit(this._getSettings(hoster).parallelCount); + } + return this.semaphores[hoster]; + } + + async startBatch(tasks, opts = {}) { + this.running = true; + this.stopAfterActive = false; + this.abortController = new AbortController(); + this.startTime = Date.now(); + this.sessionBytes = 0; + this.activeJobs.clear(); + this.jobAbortControllers.clear(); + this.cancelledJobIds.clear(); + this._doodApiKeyCache.clear(); // re-derive doodstream keys fresh each batch + this._baselineCache.clear(); // re-fetch baselines per batch (a long batch could outlast remote-side relevance) + this.semaphores = {}; + this.globalSemaphore = null; + this.globalThrottle = null; + this.lastStartTime = {}; + // Reset account-rotation state each batch — but optionally re-prime from + // app-session memory so a "Retry failed" right after batch-done doesn't + // burn 5 retries on the account we already know is dead. Caller (main.js) + // passes the session-scoped failed/override state. + this._failedAccounts.clear(); + this._accountOverrides.clear(); + this._suspectSizeMemo.clear(); + this._suspectGoodAccounts.clear(); + if (Array.isArray(opts.primeFailedAccounts)) { + for (const key of opts.primeFailedAccounts) this._failedAccounts.set(key, true); + } + if (Array.isArray(opts.primeOverrides)) { + for (const entry of opts.primeOverrides) { + if (Array.isArray(entry) && entry.length === 2) this._accountOverrides.set(entry[0], entry[1]); + } + } + this._rotLog('batch-start', { + taskCount: tasks.length, + primedFailed: this._failedAccounts.size, + primedOverrides: this._accountOverrides.size + }); + + const { signal } = this.abortController; + const batchId = `batch-${Date.now()}`; + const results = new Map(); // filePath -> { name, size, results: [] } + this._batchResults = results; + this._additionalPromises = []; // Track jobs added mid-batch via addJobs() + + const DEDUP_CHUNK = 200; + for (let i = 0; i < tasks.length; i += DEDUP_CHUNK) { + if (signal.aborted) break; + const end = Math.min(i + DEDUP_CHUNK, tasks.length); + const toStat = []; + for (let j = i; j < end; j++) { + const task = tasks[j]; + if (!results.has(task.file)) { + results.set(task.file, { name: path.basename(task.file), size: 0, results: [] }); + toStat.push(task.file); + } + } + await Promise.all(toStat.map(async (f) => { + try { const st = await fs.promises.stat(f); const e = results.get(f); if (e) e.size = st.size; } catch {} + })); + } + + this._startStatsTimer(); + + const SPAWN_CHUNK = 100; + const promises = []; + for (let i = 0; i < tasks.length; i += SPAWN_CHUNK) { + if (signal.aborted) break; + const end = Math.min(i + SPAWN_CHUNK, tasks.length); + for (let j = i; j < end; j++) promises.push(this._runJob(tasks[j], results, signal)); + if (end < tasks.length) await new Promise(setImmediate); + } + await Promise.allSettled(promises); + // Wait for any jobs added mid-batch via addJobs() + while (this._additionalPromises.length > 0) { + const batch = this._additionalPromises.splice(0); + await Promise.allSettled(batch); + } + + this._stopStatsTimer(); + this.running = false; + + const files = Array.from(results.values()); + const total = tasks.length; + const succeeded = files.reduce((count, file) => count + file.results.filter((result) => result.status === 'done').length, 0); + + const summary = { + id: batchId, + timestamp: new Date().toISOString(), + total, + succeeded, + failed: total - succeeded, + files + }; + + this.emit('batch-done', summary); + } + + async _runJob(task, results, batchSignal) { + const settings = this._getSettings(task.hoster); + const hosterSemaphore = this._getSemaphore(task.hoster); + const globalSemaphore = this._getGlobalSemaphore(); + const uploadId = crypto.randomBytes(8).toString('hex'); + const jobId = task.jobId || uploadId; + const fileName = path.basename(task.file); + let fileSize = 0; + let fileNotFound = false; + const cachedResult = results && results.get(task.file); + if (cachedResult && typeof cachedResult.size === 'number' && cachedResult.size > 0) { + fileSize = cachedResult.size; + } else { + try { fileSize = (await fs.promises.stat(task.file)).size; } catch { fileNotFound = true; } + } + + const maxAttempts = Math.max(1, (settings.retries || 0) + 1); + const jobAbortController = new AbortController(); + const { signal, cleanup: cleanupSignals } = this._combineSignals(batchSignal, jobAbortController.signal); + this.jobAbortControllers.set(jobId, jobAbortController); + + let hosterSlotAcquired = false; + let globalSlotAcquired = false; + let finalResultRecorded = false; + let lastError = null; + + const recordFinalResult = (status, payload = {}) => { + if (finalResultRecorded) return; + finalResultRecorded = true; + + const result = { + hoster: task.hoster, + status, + error: payload.error || null, + download_url: payload.result ? payload.result.download_url || null : null, + embed_url: payload.result ? payload.result.embed_url || null : null, + file_code: payload.result ? payload.result.file_code || null : null + }; + + results.get(task.file).results.push(result); + }; + + const emitFinalStatus = (status, payload = {}) => { + this._emitProgress(uploadId, fileName, task.hoster, { accountId: task.accountId, + jobId, + status, + progress: status === 'done' ? 1 : 0, + bytesUploaded: status === 'done' ? fileSize : 0, + bytesTotal: fileSize, + speedKbs: payload.speedKbs || 0, + elapsed: payload.elapsed || 0, + remaining: 0, + error: payload.error || null, + result: payload.result || null, + attempt: payload.attempt || maxAttempts, + maxAttempts + }); + }; + + try { + if (fileNotFound) { + const error = 'Datei nicht gefunden'; + emitFinalStatus('skipped', { error, attempt: 0 }); + recordFinalResult('error', { error }); + return; + } + if (fileSize <= 0) { + const error = 'Datei ist leer (0 Bytes)'; + emitFinalStatus('skipped', { error, attempt: 0 }); + recordFinalResult('error', { error }); + return; + } + if (settings.maxSizeMb > 0 && fileSize > settings.maxSizeMb * 1024 * 1024) { + const error = `Datei zu groß (Max: ${settings.maxSizeMb} MB)`; + emitFinalStatus('skipped', { error, attempt: 0 }); + recordFinalResult('error', { error }); + return; + } + + // The initial 'queued' emit per job is suppressed: with N=2000+ tasks + // it produces 2000+ main→renderer IPCs back-to-back at startBatch and + // freezes the renderer event loop for tens of seconds. The renderer + // already holds each job in 'queued'/'preview' state from its own + // queueJobs array; the first event it actually needs from main is the + // 'getting-server' / 'uploading' transition for the jobs that the + // semaphore lets through. + await hosterSemaphore.acquire(signal); + hosterSlotAcquired = true; + + let fileProbe = null; + try { + fileProbe = await probeFileHead(task.file, 512); + } catch (err) { + fileProbe = { ok: false, error: err && err.message, kind: 'unreadable' }; + } + this._rotLog('upload-start', { + jobId, hoster: task.hoster, accountId: task.accountId, fileName, + fileSize, + detectedKind: fileProbe && fileProbe.kind ? fileProbe.kind : 'unknown', + isVideoLike: !!(fileProbe && fileProbe.isVideoLike), + headHex: fileProbe && fileProbe.headHex ? fileProbe.headHex.slice(0, 32) : null + }); + + if (globalSemaphore) { + await globalSemaphore.acquire(signal); + globalSlotAcquired = true; + } + + if (settings.timeIntervalSec > 0) { + await this._waitForInterval(task.hoster, settings.timeIntervalSec * 1000, signal); + } + + // Pre-job-swap: if this account was marked failed WHILE this task was + // waiting in the semaphore queue, jump straight to the override instead + // of burning a guaranteed-to-fail upload attempt. Critical at scale: + // with 500 queued jobs and 1 parallel slot, without this check every + // job still hits the original dead account first. + if (task.accountId && this._failedAccounts.has(task.hoster + ':' + task.accountId)) { + const override = this._accountOverrides.get(task.hoster); + if (override && !this._failedAccounts.has(task.hoster + ':' + override.id)) { + this._rotLog('pre-job-swap', { + jobId, hoster: task.hoster, fileName, fromAccountId: task.accountId, toAccountId: override.id + }); + task.accountId = override.id; + task.username = override.username; + task.password = override.password; + task.apiKey = override.apiKey; + } else { + this._rotLog('pre-job-swap-blocked', { + jobId, hoster: task.hoster, fileName, accountId: task.accountId, + hasOverride: !!override, + overrideAlsoFailed: override ? this._failedAccounts.has(task.hoster + ':' + override.id) : false + }); + } + } + + // A previous file of at least this size already got a suspect rejection + // on this exact account — skip the guaranteed-to-fail multi-GB upload + // and go straight to the alternate-account walk below. + let memoSuspect = null; + if (fileProbe && fileProbe.isVideoLike === true && task.accountId + && this._suspectMemoBlocks(task.hoster, task.accountId, fileSize)) { + memoSuspect = new Error('Bekanntes Größen-Limit auf diesem Account (frühere verdächtige Ablehnung)'); + memoSuspect.fileRejected = true; + memoSuspect.suspectReject = true; + lastError = memoSuspect; + this._rotLog('suspect-memo-skip', { + jobId, hoster: task.hoster, fileName, accountId: task.accountId, fileSize + }); + } + + for (let attempt = 1; attempt <= maxAttempts && !memoSuspect; attempt++) { + if (signal.aborted || this.stopAfterActive) break; + + if (attempt > 1) { + this._emitProgress(uploadId, fileName, task.hoster, { accountId: task.accountId, + jobId, + status: 'retrying', + progress: 0, + bytesUploaded: 0, + bytesTotal: fileSize, + speedKbs: 0, + elapsed: 0, + remaining: 0, + error: lastError ? lastError.message : null, + result: null, + attempt, + maxAttempts + }); + await this._sleep(3000, signal); + } + + const jobStart = Date.now(); + let lastBytes = 0; + let lastSpeedTime = jobStart; + let currentSpeedKbs = 0; + let lowSpeedSince = 0; + let speedAbort = null; + let speedMonitor = null; + let uploadSignalBundle = { signal, cleanup() {} }; + + try { + this._emitProgress(uploadId, fileName, task.hoster, { accountId: task.accountId, + jobId, + status: 'getting-server', + progress: 0, + bytesUploaded: 0, + bytesTotal: fileSize, + speedKbs: 0, + elapsed: 0, + remaining: 0, + error: null, + result: null, + attempt, + maxAttempts + }); + + const hosterThrottle = settings.maxSpeedKbs > 0 + ? new Throttle(settings.maxSpeedKbs * 1024) + : null; + const globalThrottle = this._getGlobalThrottle(); + const throttle = hosterThrottle && globalThrottle + ? { consume: async (bytes, sig) => { await hosterThrottle.consume(bytes, sig); await globalThrottle.consume(bytes, sig); } } + : hosterThrottle || globalThrottle; + + if (settings.restartBelowKbs > 0) { + speedAbort = new AbortController(); + uploadSignalBundle = this._combineManySignals([signal, speedAbort.signal]); + speedMonitor = setInterval(() => { + try { + if (currentSpeedKbs > 0 && currentSpeedKbs < settings.restartBelowKbs) { + if (!lowSpeedSince) lowSpeedSince = Date.now(); + if (Date.now() - lowSpeedSince > 6000) { + speedAbort.abort(); + } + } else { + lowSpeedSince = 0; + } + } catch (e) { this._rotLog('speed-monitor-error', { jobId, error: e && e.message }); } + }, 2000); + } + + // Mutate this single object on each progress callback instead of + // allocating a fresh one — callback fires on every stream chunk + // (hundreds/sec per active job). + const activeEntry = { jobId, speedKbs: 0, bytesUploaded: 0, hoster: task.hoster }; + this.activeJobs.set(uploadId, activeEntry); + + let lastEmitTime = 0; + const PROGRESS_EMIT_INTERVAL = 250; // ms – throttle UI updates + + const progressCb = (bytesUploaded, bytesTotal) => { + try { + const now = Date.now(); + const elapsed = Math.round((now - jobStart) / 1000); + const timeDelta = (now - lastSpeedTime) / 1000; + if (Number.isFinite(timeDelta) && timeDelta >= 1) { + const bytesDelta = bytesUploaded - lastBytes; + currentSpeedKbs = Math.round(bytesDelta / timeDelta / 1024); + lastBytes = bytesUploaded; + lastSpeedTime = now; + } + + activeEntry.speedKbs = currentSpeedKbs; + activeEntry.bytesUploaded = bytesUploaded; + + if (now - lastEmitTime < PROGRESS_EMIT_INTERVAL) return; + lastEmitTime = now; + + const remaining = currentSpeedKbs > 0 + ? Math.round((bytesTotal - bytesUploaded) / (currentSpeedKbs * 1024)) + : 0; + + this._emitProgress(uploadId, fileName, task.hoster, { accountId: task.accountId, + jobId, + status: 'uploading', + progress: bytesTotal > 0 ? Math.min(1, bytesUploaded / bytesTotal) : 0, + bytesUploaded, + bytesTotal, + speedKbs: currentSpeedKbs, + elapsed, + remaining, + error: null, + result: null, + attempt, + maxAttempts + }); + } catch { /* progress callbacks must never throw — swallowing is correct, the stream keeps going */ } + }; + + const result = await this._executeUpload(task, progressCb, uploadSignalBundle.signal, throttle, fileProbe); + + const elapsed = Math.round((Date.now() - jobStart) / 1000); + this.sessionBytes += fileSize; + this.activeJobs.delete(uploadId); + + emitFinalStatus('done', { + result, + speedKbs: currentSpeedKbs, + elapsed, + attempt + }); + recordFinalResult('done', { result }); + return; + } catch (err) { + this.activeJobs.delete(uploadId); + if (this._isTransientNetworkError(err)) this._transientErrorTotal++; + + const isSpeedRestart = speedAbort && speedAbort.signal.aborted && !signal.aborted; + if (!signal.aborted && !isSpeedRestart) { + const diag = (err && typeof err === 'object' && err.diagnostic) || {}; + this._rotLog('upload-failure', { + jobId, hoster: task.hoster, accountId: task.accountId, fileName, + attempt, + error: err && err.message ? err.message : String(err), + fileRejected: !!(err && err.fileRejected), + accountError: !!(err && err.accountError), + hosterTransient: !!(err && err.hosterTransient), + http: diag.http || null, + contentType: diag.contentType || null, + detectedKind: (typeof fileProbe !== 'undefined' && fileProbe && fileProbe.kind) ? fileProbe.kind : null, + isVideoLike: !!(typeof fileProbe !== 'undefined' && fileProbe && fileProbe.isVideoLike), + headHex: (typeof fileProbe !== 'undefined' && fileProbe && fileProbe.headHex) ? fileProbe.headHex.slice(0, 32) : null, + payloadSnippet: diag.payloadSnippet || null + }); + } + if (signal.aborted) { + lastError = new Error('Abgebrochen'); + break; + } + + if (this.stopAfterActive) { + lastError = new Error('Angehalten'); + break; + } + + if (isSpeedRestart && attempt < maxAttempts) { + lastError = new Error('Geschwindigkeit zu niedrig - Neustart'); + await this._sleep(3000, signal); + continue; + } + + lastError = err; + // File-specific rejection — re-uploading won't change the server's + // mind. Break out immediately; the outer file-rejected branch then + // records the final error without burning through 5 × 3s retries. + if (this._isFileRejectedError(err)) break; + // Hoster-side transient flake (e.g. doodstream empty CDN form). Server + // flake won't clear in 3s and re-uploading the whole file 4× is pure + // bandwidth waste; bail out of the retry loop so the post-loop branch + // can fail this file without blacklisting the account. + if (this._isHosterTransientError(err)) { + this._rotLog('hoster-transient', { + jobId, hoster: task.hoster, fileName, accountId: task.accountId, + attempt, error: err && err.message ? err.message : String(err) + }); + break; + } + // Account-specific errors — don't waste retries on the same account, + // jump straight to rotation. + if (this._shouldSkipRetryOnAccountError(err)) { + this._rotLog('fast-fail', { + jobId, hoster: task.hoster, fileName, accountId: task.accountId, + attempt, error: err && err.message ? err.message : String(err) + }); + break; + } + // Generic non-transient error AND a fallback is already resolved for + // this hoster: bail to rotation instead of burning more retries on a + // possibly-dead primary. The fallback (pre-resolved at batch-start) + // deserves a real shot. Transient network errors stay on the same + // account — the network is the issue, not the account. + if (!this._isTransientNetworkError(err) && + this._hasPendingOverride(task.hoster, task.accountId)) { + this._rotLog('try-alternate-after-fail', { + jobId, hoster: task.hoster, fileName, accountId: task.accountId, + attempt, error: err && err.message ? err.message : String(err) + }); + break; + } + if (attempt >= maxAttempts) break; + // Wait 3 seconds before retry + await this._sleep(3000, signal); + } finally { + if (speedMonitor) clearInterval(speedMonitor); + uploadSignalBundle.cleanup(); + } + } + + const wasStopped = this.stopAfterActive && !signal.aborted; + const wasAborted = signal.aborted || this.cancelledJobIds.has(jobId); + if (wasStopped || wasAborted) { + const error = wasStopped ? 'Warteschlange angehalten' : 'Abgebrochen'; + emitFinalStatus('aborted', { error }); + recordFinalResult('aborted', { error }); + return; + } + + // Account rotation: mark the current account failed (if not already), + // wait for main to resolve the next fallback, then retry. Loops so + // A → B → C → ... works for hosters with 3+ accounts. + // + // CRITICAL: we must ALWAYS check for an existing override, even if this + // account is already in _failedAccounts (e.g. another concurrent job + // already marked it failed). Otherwise the second job falls straight + // through to final-error instead of using the already-resolved fallback. + this._rotLog('retries-exhausted', { + jobId, hoster: task.hoster, fileName, accountId: task.accountId, + lastError: lastError ? lastError.message : null + }); + // File-specific rejection → same file will get the same verdict on + // every other account, rotation is pointless. Don't blacklist, don't + // retry siblings, just fail this file cleanly. + // + // EXCEPT suspect rejections (err.suspectReject, e.g. byse "Not video + // file format" on a probe-verified video): those verdicts are + // account-conditional in practice (per-account size tiers), so the file + // gets one attempt on each remaining account — WITHOUT blacklisting the + // current one, which keeps working for files the hoster does accept. + if (this._isFileRejectedError(lastError)) { + if (lastError.suspectReject === true && fileProbe && fileProbe.isVideoLike === true) { + this._noteSuspectReject(task.hoster, task.accountId, fileSize); + const alt = await this._trySuspectRejectAlternates(task, { uploadId, jobId, fileName, fileSize, settings, signal, fileProbe }); + if (alt) { + emitFinalStatus('done', { result: alt.result, speedKbs: alt.speedKbs, elapsed: alt.elapsed, attempt: 1 }); + recordFinalResult('done', { result: alt.result }); + return; + } + const stoppedInAlternates = this.stopAfterActive && !signal.aborted; + const abortedInAlternates = signal.aborted || this.cancelledJobIds.has(jobId); + if (stoppedInAlternates || abortedInAlternates) { + const error = stoppedInAlternates ? 'Warteschlange angehalten' : 'Abgebrochen'; + emitFinalStatus('aborted', { error }); + recordFinalResult('aborted', { error }); + return; + } + } + this._rotLog('skip-rotation-file-rejected', { + jobId, hoster: task.hoster, fileName, accountId: task.accountId, + lastError: lastError ? lastError.message : null + }); + const error = lastError.message || 'Datei abgelehnt'; + emitFinalStatus('error', { error }); + recordFinalResult('error', { error }); + return; + } + // Hoster-side transient flake → identical handling to network-transient: + // the account is fine, don't blacklist it, just fail this file. Critical + // to keep the account usable across batches — otherwise one empty-form + // response poisons every subsequent batch with `pre-job-swap-blocked`. + if (this._isHosterTransientError(lastError)) { + this._rotLog('skip-rotation-hoster-transient', { + jobId, hoster: task.hoster, fileName, accountId: task.accountId, + lastError: lastError ? lastError.message : null + }); + const error = lastError.message || 'Hoster-Backend lieferte leeres Ergebnis'; + emitFinalStatus('error', { error }); + recordFinalResult('error', { error }); + return; + } + // If the reason for failure was a transient network error we do NOT + // blacklist the account. Other jobs on the same account in this batch + // can still try fresh. This file just errors out for now. + if (this._isTransientNetworkError(lastError)) { + this._rotLog('skip-rotation-transient', { + jobId, hoster: task.hoster, fileName, accountId: task.accountId, + lastError: lastError ? lastError.message : null + }); + const error = lastError.message || 'Netzwerkfehler'; + emitFinalStatus('error', { error }); + recordFinalResult('error', { error }); + return; + } + while (task.accountId) { + if (signal.aborted || this.stopAfterActive) break; + // The rotated-to account failed with a file-class error (file + // rejection / hoster flake / network) — blacklisting it for that + // would poison a working account for the whole batch. Fail only + // this file instead. + if (this._isFileRejectedError(lastError) || this._isHosterTransientError(lastError) || this._isTransientNetworkError(lastError)) { + this._rotLog('skip-rotation-after-rotate', { + jobId, hoster: task.hoster, fileName, accountId: task.accountId, + lastError: lastError ? lastError.message : null + }); + break; + } + const alreadyMarked = this._failedAccounts.has(task.hoster + ':' + task.accountId); + if (!alreadyMarked) { + this._failedAccounts.set(task.hoster + ':' + task.accountId, true); + this._rotLog('mark-failed', { + jobId, hoster: task.hoster, fileName, accountId: task.accountId, + lastError: lastError ? lastError.message : null + }); + this.emit('account-failed', { hoster: task.hoster, accountId: task.accountId }); + await this._sleep(800, signal); + // Re-check after the await: the user could have cancelled while + // we were waiting for main.js to resolve the fallback. Without + // this, rotation proceeds another full attempt-loop's worth of + // work before the next signal-check inside _executeUpload notices. + if (signal.aborted || this.stopAfterActive) break; + } else { + this._rotLog('already-marked', { + jobId, hoster: task.hoster, fileName, accountId: task.accountId + }); + } + const override = this._accountOverrides.get(task.hoster); + if (!override) { + this._rotLog('rotation-end', { + jobId, hoster: task.hoster, fileName, reason: 'no-override-set', + lastFailedAccountId: task.accountId + }); + break; + } + if (this._failedAccounts.has(task.hoster + ':' + override.id)) { + this._rotLog('rotation-end', { + jobId, hoster: task.hoster, fileName, reason: 'override-already-failed', + overrideId: override.id, lastFailedAccountId: task.accountId + }); + break; + } + if (override.id === task.accountId) { + this._rotLog('rotation-end', { + jobId, hoster: task.hoster, fileName, reason: 'override-same-as-current', + lastFailedAccountId: task.accountId + }); + break; + } + // Switch to fallback account and retry this file + this._rotLog('rotate', { + jobId, hoster: task.hoster, fileName, + fromAccountId: task.accountId, toAccountId: override.id + }); + task.accountId = override.id; + task.username = override.username; + task.password = override.password; + task.apiKey = override.apiKey; + this._emitProgress(uploadId, fileName, task.hoster, { accountId: task.accountId, + jobId, status: 'retrying', progress: 0, bytesUploaded: 0, bytesTotal: fileSize, + speedKbs: 0, elapsed: 0, remaining: 0, + error: 'Account-Wechsel zu Fallback', result: null, attempt: 1, maxAttempts + }); + // Retry loop with the new account. On exhausted failure, the while + // loop iterates: marks this account failed too, asks main for the next + // fallback, and so on. + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + if (signal.aborted || this.stopAfterActive) break; + if (attempt > 1) { + this._emitProgress(uploadId, fileName, task.hoster, { accountId: task.accountId, + jobId, status: 'retrying', progress: 0, bytesUploaded: 0, bytesTotal: fileSize, + speedKbs: 0, elapsed: 0, remaining: 0, + error: lastError ? lastError.message : '', result: null, attempt, maxAttempts + }); + await this._sleep(3000, signal); + } + try { + const jobStart = Date.now(); + let lastBytes = 0; + let lastSpeedTime = jobStart; + let currentSpeedKbs = 0; + const activeEntry = { jobId, speedKbs: 0, bytesUploaded: 0, hoster: task.hoster }; + this.activeJobs.set(uploadId, activeEntry); + + let lastEmitTime = 0; + const PROGRESS_EMIT_INTERVAL = 250; + const progressCb = (bytesUploaded, bytesTotal) => { + const now = Date.now(); + const timeDelta = (now - lastSpeedTime) / 1000; + if (timeDelta >= 1) { + currentSpeedKbs = Math.round((bytesUploaded - lastBytes) / timeDelta / 1024); + lastBytes = bytesUploaded; + lastSpeedTime = now; + } + activeEntry.speedKbs = currentSpeedKbs; + activeEntry.bytesUploaded = bytesUploaded; + if (now - lastEmitTime < PROGRESS_EMIT_INTERVAL) return; + lastEmitTime = now; + const elapsed = Math.round((now - jobStart) / 1000); + const remaining = currentSpeedKbs > 0 ? Math.round((bytesTotal - bytesUploaded) / (currentSpeedKbs * 1024)) : 0; + this._emitProgress(uploadId, fileName, task.hoster, { accountId: task.accountId, + jobId, status: 'uploading', + progress: bytesTotal > 0 ? Math.min(1, bytesUploaded / bytesTotal) : 0, + bytesUploaded, bytesTotal, speedKbs: currentSpeedKbs, + elapsed, remaining, error: null, result: null, attempt, maxAttempts + }); + }; + + const hosterThrottle = settings.maxSpeedKbs > 0 ? new Throttle(settings.maxSpeedKbs * 1024) : null; + const globalThrottle = this._getGlobalThrottle(); + const throttle = hosterThrottle && globalThrottle + ? { consume: async (bytes, sig) => { await hosterThrottle.consume(bytes, sig); await globalThrottle.consume(bytes, sig); } } + : hosterThrottle || globalThrottle; + + const result = await this._executeUpload(task, progressCb, signal, throttle, fileProbe); + this.activeJobs.delete(uploadId); + this.sessionBytes += fileSize; + emitFinalStatus('done', { result, speedKbs: currentSpeedKbs, elapsed: Math.round((Date.now() - jobStart) / 1000), attempt }); + recordFinalResult('done', { result }); + return; + } catch (err) { + this.activeJobs.delete(uploadId); + lastError = err; + if (!signal.aborted) { + this._rotLog('upload-failure', { + jobId, hoster: task.hoster, accountId: task.accountId, fileName, + attempt, + error: err && err.message ? err.message : String(err), + fileRejected: !!(err && err.fileRejected), + accountError: !!(err && err.accountError), + hosterTransient: !!(err && err.hosterTransient), + rotationRetry: true + }); + } + if (signal.aborted || this.stopAfterActive) break; + if (this._isFileRejectedError(err)) break; + if (this._isHosterTransientError(err)) break; + if (this._isTransientNetworkError(err)) break; + if (attempt >= maxAttempts) break; + } + } + } + + const stoppedLate = this.stopAfterActive && !signal.aborted; + const abortedLate = signal.aborted || this.cancelledJobIds.has(jobId); + if (stoppedLate || abortedLate) { + const error = stoppedLate ? 'Warteschlange angehalten' : 'Abgebrochen'; + emitFinalStatus('aborted', { error }); + recordFinalResult('aborted', { error }); + return; + } + const error = lastError && lastError.message ? lastError.message : 'Unbekannter Fehler'; + this._rotLog('final-error', { + jobId, hoster: task.hoster, fileName, lastFailedAccountId: task.accountId, error + }); + emitFinalStatus('error', { error }); + recordFinalResult('error', { error }); + } catch (err) { + const wasStopped = this.stopAfterActive && !signal.aborted; + const error = wasStopped + ? 'Warteschlange angehalten' + : (signal.aborted || this.cancelledJobIds.has(jobId) ? 'Abgebrochen' : (err && err.message ? err.message : 'Unbekannter Fehler')); + const status = signal.aborted || this.cancelledJobIds.has(jobId) || wasStopped ? 'aborted' : 'error'; + emitFinalStatus(status, { error }); + recordFinalResult(status === 'error' ? 'error' : 'aborted', { error }); + } finally { + this.activeJobs.delete(uploadId); + this.jobAbortControllers.delete(jobId); + cleanupSignals(); + // Release in reverse order of acquire (global first, then hoster) + if (globalSlotAcquired && globalSemaphore) globalSemaphore.release(); + if (hosterSlotAcquired) hosterSemaphore.release(); + } + } + + async _trySuspectRejectAlternates(task, ctx) { + const { uploadId, jobId, fileName, fileSize, settings, signal, fileProbe } = ctx; + const pool = this.accountPools && Array.isArray(this.accountPools[task.hoster]) + ? this.accountPools[task.hoster] + : []; + const original = { accountId: task.accountId, username: task.username, password: task.password, apiKey: task.apiKey }; + const goodId = this._suspectGoodAccounts.get(task.hoster); + const ordered = []; + for (const account of pool) { + if (account && account.id === goodId) ordered.unshift(account); + else ordered.push(account); + } + const tried = new Set([task.accountId]); + let attempted = 0; + for (const account of ordered) { + if (signal.aborted || this.stopAfterActive) break; + if (!account || !account.id || tried.has(account.id)) continue; + if (this._failedAccounts.has(task.hoster + ':' + account.id)) continue; + tried.add(account.id); + if (this._suspectMemoBlocks(task.hoster, account.id, fileSize)) { + this._rotLog('suspect-memo-skip-alt', { + jobId, hoster: task.hoster, fileName, accountId: account.id, fileSize + }); + continue; + } + attempted += 1; + this._rotLog('suspect-reject-alt', { + jobId, hoster: task.hoster, fileName, fromAccountId: task.accountId, toAccountId: account.id + }); + task.accountId = account.id; + task.username = account.username; + task.password = account.password; + task.apiKey = account.apiKey; + this._emitProgress(uploadId, fileName, task.hoster, { accountId: task.accountId, + jobId, status: 'retrying', progress: 0, bytesUploaded: 0, bytesTotal: fileSize, + speedKbs: 0, elapsed: 0, remaining: 0, + error: 'Ablehnung verdächtig - Versuch auf anderem Account', result: null, attempt: 1, maxAttempts: 1 + }); + const jobStart = Date.now(); + let lastBytes = 0; + let lastSpeedTime = jobStart; + let currentSpeedKbs = 0; + const activeEntry = { jobId, speedKbs: 0, bytesUploaded: 0, hoster: task.hoster }; + this.activeJobs.set(uploadId, activeEntry); + let lastEmitTime = 0; + const PROGRESS_EMIT_INTERVAL = 250; + const progressCb = (bytesUploaded, bytesTotal) => { + const now = Date.now(); + const timeDelta = (now - lastSpeedTime) / 1000; + if (timeDelta >= 1) { + currentSpeedKbs = Math.round((bytesUploaded - lastBytes) / timeDelta / 1024); + lastBytes = bytesUploaded; + lastSpeedTime = now; + } + activeEntry.speedKbs = currentSpeedKbs; + activeEntry.bytesUploaded = bytesUploaded; + if (now - lastEmitTime < PROGRESS_EMIT_INTERVAL) return; + lastEmitTime = now; + const elapsed = Math.round((now - jobStart) / 1000); + const remaining = currentSpeedKbs > 0 ? Math.round((bytesTotal - bytesUploaded) / (currentSpeedKbs * 1024)) : 0; + this._emitProgress(uploadId, fileName, task.hoster, { accountId: task.accountId, + jobId, status: 'uploading', + progress: bytesTotal > 0 ? Math.min(1, bytesUploaded / bytesTotal) : 0, + bytesUploaded, bytesTotal, speedKbs: currentSpeedKbs, + elapsed, remaining, error: null, result: null, attempt: 1, maxAttempts: 1 + }); + }; + const hosterThrottle = settings.maxSpeedKbs > 0 ? new Throttle(settings.maxSpeedKbs * 1024) : null; + const globalThrottle = this._getGlobalThrottle(); + const throttle = hosterThrottle && globalThrottle + ? { consume: async (bytes, sig) => { await hosterThrottle.consume(bytes, sig); await globalThrottle.consume(bytes, sig); } } + : hosterThrottle || globalThrottle; + try { + const result = await this._executeUpload(task, progressCb, signal, throttle, fileProbe); + this.activeJobs.delete(uploadId); + this.sessionBytes += fileSize; + this._suspectGoodAccounts.set(task.hoster, account.id); + return { result, speedKbs: currentSpeedKbs, elapsed: Math.round((Date.now() - jobStart) / 1000) }; + } catch (err) { + this.activeJobs.delete(uploadId); + if (!signal.aborted) { + this._rotLog('upload-failure', { + jobId, hoster: task.hoster, accountId: task.accountId, fileName, + attempt: 1, + error: err && err.message ? err.message : String(err), + fileRejected: !!(err && err.fileRejected), + accountError: !!(err && err.accountError), + hosterTransient: !!(err && err.hosterTransient), + suspectAlternate: true + }); + } + if (signal.aborted || this.stopAfterActive) break; + if (err && err.suspectReject === true) { + this._noteSuspectReject(task.hoster, account.id, fileSize); + } + // A genuine account-class error (quota, ban, full disk) positively + // identifies a dead account — remember it so parallel and later + // suspect jobs stop re-uploading multi-GB files to it. Deliberately + // no 'account-failed' emit: that would re-point the hoster-wide + // override and reroute normal-sized files away from a primary that + // still works for them. + if (err && err.accountError === true) { + this._failedAccounts.set(task.hoster + ':' + account.id, true); + this._rotLog('mark-failed', { + jobId, hoster: task.hoster, fileName, accountId: account.id, + lastError: err && err.message ? err.message : String(err), + suspectAlternate: true + }); + } + } + } + task.accountId = original.accountId; + task.username = original.username; + task.password = original.password; + task.apiKey = original.apiKey; + if (!signal.aborted && !this.stopAfterActive) { + this._rotLog('suspect-reject-exhausted', { + jobId, hoster: task.hoster, fileName, alternatesTried: attempted + }); + } + return null; + } + + async _executeUpload(task, progressCb, signal, throttle, fileProbe) { + if (task.hoster === 'vidmoly.me' && task.username) { + const vidmoly = new VidmolyUploader(); + await vidmoly.login(task.username, task.password); + return vidmoly.upload(task.file, progressCb, signal, throttle); + } else if (task.hoster === 'voe.sx' && task.username) { + const voe = new VoeUploader(); + await voe.login(task.username, task.password); + return voe.upload(task.file, progressCb, signal, throttle); + } else if (task.hoster === 'doodstream.com' && task.username) { + // Login-path reliability fix: the web-form upload returns the filecode in + // an HTML form that comes back empty for large files (doodstream backend + // registration timeout). Derive the account's API key from the logged-in + // session ONCE per batch and upload via the official API instead — it + // returns result[0].filecode directly and has no empty-form failure mode. + // Falls back to the web-form upload if no valid key can be derived. + const apiKey = await this._resolveDoodstreamApiKey(task); + if (apiKey) { + this._rotLog('doodstream-via-api', { accountId: task.accountId, fileName: path.basename(task.file) }); + return uploadFile('doodstream.com', task.file, apiKey, progressCb, signal, throttle, { + doodBaseline: await this._getBaseline('doodstream.com', apiKey, signal) + }); + } + this._rotLog('doodstream-via-web', { accountId: task.accountId, fileName: path.basename(task.file) }); + const dood = new DoodstreamUploader(); + await dood.login(task.username, task.password); + return dood.upload(task.file, progressCb, signal, throttle); + } else if (task.hoster === 'clouddrop.cc') { + const clouddrop = new ClouddropUploader(task.apiKey); + return clouddrop.upload(task.file, progressCb, signal, throttle); + } else { + const baselineOpts = {}; + if (task.hoster === 'byse.sx') { + baselineOpts.byseBaseline = await this._getBaseline('byse.sx', task.apiKey, signal); + if (fileProbe && fileProbe.ok !== false) baselineOpts.probeIsVideoLike = fileProbe.isVideoLike === true; + } + if (task.hoster === 'doodstream.com') baselineOpts.doodBaseline = await this._getBaseline('doodstream.com', task.apiKey, signal); + return uploadFile(task.hoster, task.file, task.apiKey, progressCb, signal, throttle, baselineOpts); + } + } + + _getBaseline(hosterName, apiKey, signal) { + if (!apiKey) return Promise.resolve(null); + const key = `${hosterName}:${apiKey}`; + let pending = this._baselineCache.get(key); + if (pending) return pending; + pending = prefetchBaseline(hosterName, apiKey, signal); + this._baselineCache.set(key, pending); + return pending; + } + + // Resolve (and cache per batch) the doodstream API key for a login-only + // account by logging in once and scraping+validating it from the session. + // Returns the key string, or '' when none could be derived (cached either way + // so a 40-file batch logs in + derives ONCE, not per file). The empty-string + // sentinel distinguishes "tried, none" from "not yet tried" (undefined). + async _resolveDoodstreamApiKey(task) { + const cacheKey = task.accountId || task.username; + const cached = this._doodApiKeyCache.get(cacheKey); + if (cached !== undefined) return cached || null; + + let key = ''; + try { + const probe = new DoodstreamUploader(); + await probe.login(task.username, task.password); + key = (await probe.deriveApiKey()) || ''; + } catch { + key = ''; + } + this._doodApiKeyCache.set(cacheKey, key); + return key || null; + } + + _emitProgress(uploadId, fileName, hoster, data) { + this.emit('progress', { uploadId, fileName, hoster, ...data }); + } + + _startStatsTimer() { + if (this.statsInterval) clearInterval(this.statsInterval); + this.statsInterval = setInterval(() => { + try { + let globalSpeedKbs = 0; + let activeCount = 0; + let inProgressBytes = 0; + for (const job of this.activeJobs.values()) { + globalSpeedKbs += job.speedKbs || 0; + inProgressBytes += job.bytesUploaded || 0; + activeCount++; + } + + const elapsed = Math.round((Date.now() - this.startTime) / 1000); + + this.emit('stats', { + state: this.running ? (this.stopAfterActive ? 'stopping' : 'uploading') : 'idle', + globalSpeedKbs, + totalBytes: this.sessionBytes + inProgressBytes, + elapsed, + activeJobs: activeCount, + pendingJobs: Object.values(this.semaphores).reduce((sum, semaphore) => sum + semaphore.pending, 0) + }); + } catch { /* never let a stats tick crash the timer + caller */ } + }, 1000); + } + + _stopStatsTimer() { + if (this.statsInterval) { + clearInterval(this.statsInterval); + this.statsInterval = null; + } + } + + _combineSignals(signal1, signal2) { + const controller = new AbortController(); + if (signal1.aborted || signal2.aborted) { + controller.abort(); + return { signal: controller.signal, cleanup() {} }; + } + + const onAbort = () => { + controller.abort(); + cleanup(); + }; + + const cleanup = () => { + signal1.removeEventListener('abort', onAbort); + signal2.removeEventListener('abort', onAbort); + }; + + signal1.addEventListener('abort', onAbort, { once: true }); + signal2.addEventListener('abort', onAbort, { once: true }); + return { signal: controller.signal, cleanup }; + } + + _combineManySignals(signals) { + const liveSignals = signals.filter(Boolean); + const controller = new AbortController(); + + if (liveSignals.some((signal) => signal.aborted)) { + controller.abort(); + return { signal: controller.signal, cleanup() {} }; + } + + const listeners = liveSignals.map((signal) => { + const handler = () => { + controller.abort(); + cleanup(); + }; + signal.addEventListener('abort', handler, { once: true }); + return { signal, handler }; + }); + + const cleanup = () => { + for (const entry of listeners) { + entry.signal.removeEventListener('abort', entry.handler); + } + }; + + return { signal: controller.signal, cleanup }; + } + + _sleep(ms, signal) { + return new Promise((resolve, reject) => { + const onAbort = () => { + clearTimeout(timer); + reject(new Error('Aborted')); + }; + + const timer = setTimeout(() => { + if (signal) signal.removeEventListener('abort', onAbort); + resolve(); + }, ms); + + if (signal) { + if (signal.aborted) { + clearTimeout(timer); + reject(new Error('Aborted')); + return; + } + signal.addEventListener('abort', onAbort, { once: true }); + } + }); + } + + _waitForInterval(hoster, intervalMs, signal) { + // Serialize interval waits per hoster so concurrent jobs queue up properly + const prev = this.intervalLocks[hoster] || Promise.resolve(); + const next = prev.then(async () => { + const now = Date.now(); + const last = this.lastStartTime[hoster] || 0; + const elapsed = now - last; + if (elapsed < intervalMs) { + await this._sleep(intervalMs - elapsed, signal); + } + this.lastStartTime[hoster] = Date.now(); + }); + this.intervalLocks[hoster] = next.catch(() => {}); + return next; + } + + addJobs(tasks) { + if (!this.running || !tasks || tasks.length === 0) { + return { added: 0, alreadyInBatchJobIds: [] }; + } + const { signal } = this.abortController; + const results = this._batchResults || new Map(); + const addResult = { added: 0, alreadyInBatchJobIds: [] }; + for (const task of tasks) { + // Skip if this job is already being processed (prevent duplicates) + if (task.jobId && this.jobAbortControllers.has(task.jobId)) { + addResult.alreadyInBatchJobIds.push(task.jobId); + continue; + } + const fileName = path.basename(task.file); + if (!results.has(task.file)) { + let size = 0; + try { size = fs.statSync(task.file).size; } catch {} + results.set(task.file, { name: fileName, size, results: [] }); + } + this._additionalPromises.push(this._runJob(task, results, signal)); + addResult.added++; + } + return addResult; + } + + cancelJobs(jobIds) { + for (const jobId of jobIds || []) { + if (!jobId) continue; + this.cancelledJobIds.add(jobId); + const controller = this.jobAbortControllers.get(jobId); + if (controller && !controller.signal.aborted) { + controller.abort(); + } + } + } + + finishAfterActive() { + this.stopAfterActive = true; + } + + cancel() { + if (!this.running) return; + this.abortController.abort(); + this.stopAfterActive = false; + this.running = false; + for (const controller of this.jobAbortControllers.values()) { + if (!controller.signal.aborted) controller.abort(); + } + this._stopStatsTimer(); + } +} + +module.exports = UploadManager; diff --git a/lib/vidmoly-upload.js b/lib/vidmoly-upload.js new file mode 100644 index 0000000..cd9b145 --- /dev/null +++ b/lib/vidmoly-upload.js @@ -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; diff --git a/lib/voe-upload.js b/lib/voe-upload.js new file mode 100644 index 0000000..4c105c3 --- /dev/null +++ b/lib/voe-upload.js @@ -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(/