Release Multi-Hoster Uploader 3.3.108
This commit is contained in:
+20
@@ -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
|
||||||
@@ -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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
[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
|
||||||
|
```
|
||||||
@@ -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.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 284 KiB |
@@ -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
|
||||||
|
}
|
||||||
|
];
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -0,0 +1,705 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { request } = require('undici');
|
||||||
|
|
||||||
|
const BASE_URL = 'https://doodstream.com';
|
||||||
|
const USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
|
||||||
|
const UPLOAD_TIMEOUT = 1800000; // 30 min
|
||||||
|
|
||||||
|
// Cap doodstream's per-hoster debug log alongside the main log files so
|
||||||
|
// dev-mode sessions don't accumulate gigabytes of upload trace.
|
||||||
|
const { maybeRotateLogFile } = require('./log-rotation');
|
||||||
|
const _DOODSTREAM_LOG_MAX_BYTES = 10 * 1024 * 1024;
|
||||||
|
const _DOODSTREAM_LOG_MAX_BACKUPS = 1;
|
||||||
|
|
||||||
|
// Resolve the log path at write-time. In a packaged build __dirname lives
|
||||||
|
// inside app.asar (read-only) — writing there fails silently and we lose every
|
||||||
|
// production trace. Prefer Electron's writable userData dir, fall back to the
|
||||||
|
// repo root only when running outside Electron (tests / plain node).
|
||||||
|
function _doodstreamLogPath() {
|
||||||
|
try {
|
||||||
|
const { app } = require('electron');
|
||||||
|
if (app && typeof app.getPath === 'function') {
|
||||||
|
return path.join(app.getPath('userData'), 'doodstream-debug.log');
|
||||||
|
}
|
||||||
|
} catch { /* not running under Electron */ }
|
||||||
|
return path.join(__dirname, '..', 'doodstream-debug.log');
|
||||||
|
}
|
||||||
|
|
||||||
|
let _debugVerbose = false;
|
||||||
|
function setDebugVerbose(v) { _debugVerbose = !!v; }
|
||||||
|
|
||||||
|
function _debugLog(msg) {
|
||||||
|
if (!_debugVerbose) return;
|
||||||
|
try {
|
||||||
|
const logPath = _doodstreamLogPath();
|
||||||
|
maybeRotateLogFile(logPath, _DOODSTREAM_LOG_MAX_BYTES, _DOODSTREAM_LOG_MAX_BACKUPS);
|
||||||
|
const ts = new Date().toISOString();
|
||||||
|
fs.appendFileSync(logPath, `[${ts}] ${msg}\n`);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DoodstreamUploader {
|
||||||
|
constructor() {
|
||||||
|
this.cookies = new Map();
|
||||||
|
this.sessId = '';
|
||||||
|
this.apiKey = ''; // optionally derived from the logged-in session (deriveApiKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
_cookieHeader() {
|
||||||
|
return Array.from(this.cookies.entries())
|
||||||
|
.map(([k, v]) => `${k}=${v}`)
|
||||||
|
.join('; ');
|
||||||
|
}
|
||||||
|
|
||||||
|
_parseCookiesFromHeaders(headers) {
|
||||||
|
let setCookies;
|
||||||
|
if (typeof headers.getSetCookie === 'function') {
|
||||||
|
setCookies = headers.getSetCookie();
|
||||||
|
} else if (headers['set-cookie']) {
|
||||||
|
setCookies = Array.isArray(headers['set-cookie']) ? headers['set-cookie'] : [headers['set-cookie']];
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const raw of setCookies) {
|
||||||
|
const pair = raw.split(';')[0];
|
||||||
|
const eq = pair.indexOf('=');
|
||||||
|
if (eq > 0) {
|
||||||
|
this.cookies.set(pair.substring(0, eq).trim(), pair.substring(eq + 1).trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _fetch(url, opts = {}, _redirectCount = 0) {
|
||||||
|
const MAX_REDIRECTS = 10;
|
||||||
|
const headers = {
|
||||||
|
'User-Agent': USER_AGENT,
|
||||||
|
...(opts.headers || {})
|
||||||
|
};
|
||||||
|
if (this.cookies.size > 0) {
|
||||||
|
headers['Cookie'] = this._cookieHeader();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The small discovery/result requests that bookend a multi-minute upload
|
||||||
|
// occasionally hit a transient blip ("fetch failed", ECONNRESET, a hung TLS
|
||||||
|
// handshake). A blip here shouldn't throw away the whole upload, so retry a
|
||||||
|
// few times with short backoff. Each attempt gets its own 20s timeout —
|
||||||
|
// Node's fetch has none by default, and a hung socket would otherwise stall
|
||||||
|
// the attempt for minutes. The big file upload (undici) is retried at the
|
||||||
|
// upload-manager level, not here.
|
||||||
|
let res;
|
||||||
|
for (let attempt = 1; ; attempt++) {
|
||||||
|
const timeoutSignal = AbortSignal.timeout(20000);
|
||||||
|
const signal = opts.signal ? AbortSignal.any([opts.signal, timeoutSignal]) : timeoutSignal;
|
||||||
|
try {
|
||||||
|
res = await fetch(url, { ...opts, headers, redirect: 'manual', signal });
|
||||||
|
break;
|
||||||
|
} catch (err) {
|
||||||
|
if (opts.signal && opts.signal.aborted) throw err; // caller abort: don't retry
|
||||||
|
if (attempt >= 3) throw err;
|
||||||
|
_debugLog(`_fetch transient (${attempt}/3) ${url}: ${err && err.message}; retry`);
|
||||||
|
await new Promise(r => setTimeout(r, 400 * attempt));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this._parseCookiesFromHeaders(res.headers);
|
||||||
|
|
||||||
|
if ([301, 302, 303, 307, 308].includes(res.status)) {
|
||||||
|
try { await res.text(); } catch {}
|
||||||
|
if (_redirectCount >= MAX_REDIRECTS) throw new Error('Zu viele Redirects');
|
||||||
|
const location = res.headers.get('location');
|
||||||
|
if (location) {
|
||||||
|
const nextUrl = new URL(location, url).href;
|
||||||
|
return this._fetch(nextUrl, { ...opts, method: 'GET', body: undefined }, _redirectCount + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login to DoodStream via web form
|
||||||
|
*/
|
||||||
|
async login(username, password, otp) {
|
||||||
|
// GET homepage first to collect cookies
|
||||||
|
const homeRes = await this._fetch(BASE_URL);
|
||||||
|
await homeRes.text();
|
||||||
|
|
||||||
|
// POST login via AJAX (op in body, XHR header required for JSON response)
|
||||||
|
const loginData = new URLSearchParams({
|
||||||
|
op: 'login_ajax',
|
||||||
|
login: username,
|
||||||
|
password: password,
|
||||||
|
loginotp: otp || ''
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use raw fetch with redirect: 'manual' to detect success redirects
|
||||||
|
const headers = {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'Referer': BASE_URL + '/',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
|
'User-Agent': USER_AGENT
|
||||||
|
};
|
||||||
|
if (this.cookies.size > 0) {
|
||||||
|
headers['Cookie'] = this._cookieHeader();
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(BASE_URL + '/', {
|
||||||
|
method: 'POST',
|
||||||
|
body: loginData.toString(),
|
||||||
|
headers,
|
||||||
|
redirect: 'manual'
|
||||||
|
});
|
||||||
|
|
||||||
|
this._parseCookiesFromHeaders(res.headers);
|
||||||
|
|
||||||
|
// On successful login, server may redirect (3xx) to dashboard
|
||||||
|
if ([301, 302, 303, 307, 308].includes(res.status)) {
|
||||||
|
try { await res.text(); } catch {}
|
||||||
|
// Redirect means login succeeded
|
||||||
|
} else {
|
||||||
|
const body = await res.text();
|
||||||
|
let json;
|
||||||
|
try { json = JSON.parse(body); } catch { json = null; }
|
||||||
|
|
||||||
|
if (json && json.status === 'success') {
|
||||||
|
// Explicit success response
|
||||||
|
} else if (json && json.message && /otp/i.test(json.message)) {
|
||||||
|
// OTP required — signal caller to collect OTP from user
|
||||||
|
const err = new Error(`Doodstream Login: ${json.message}`);
|
||||||
|
err.otpRequired = true;
|
||||||
|
throw err;
|
||||||
|
} else if (json && json.status === 'fail') {
|
||||||
|
throw new Error(`Doodstream Login: ${json.message || 'Login fehlgeschlagen'}`);
|
||||||
|
} else if (body.includes('Dashboard')) {
|
||||||
|
// Got dashboard HTML directly — login worked
|
||||||
|
} else {
|
||||||
|
const msg = (json && json.message) || 'Login fehlgeschlagen';
|
||||||
|
throw new Error(`Doodstream Login: ${msg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract sess_id from the upload page
|
||||||
|
await this._extractSessId();
|
||||||
|
}
|
||||||
|
|
||||||
|
async _extractSessId() {
|
||||||
|
const res = await this._fetch(BASE_URL + '/?op=upload');
|
||||||
|
const html = await res.text();
|
||||||
|
|
||||||
|
// Hidden input: <input type="hidden" name="sess_id" value="xxx">
|
||||||
|
const hiddenMatch = html.match(/name=["']sess_id["'][^>]*value=["']([a-zA-Z0-9]+)["']/);
|
||||||
|
if (hiddenMatch) {
|
||||||
|
this.sessId = hiddenMatch[1];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vue component prop or JS: sess_id: "xxx" or sess_id="xxx"
|
||||||
|
const sessMatch = html.match(/sess_id['":\s]+['"]([a-zA-Z0-9]+)['"]/);
|
||||||
|
if (sessMatch) {
|
||||||
|
this.sessId = sessMatch[1];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assignment: sess_id = 'xxx'
|
||||||
|
const altMatch = html.match(/sess_id\s*=\s*['"]([a-zA-Z0-9]+)['"]/);
|
||||||
|
if (altMatch) {
|
||||||
|
this.sessId = altMatch[1];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Doodstream: sess_id nicht gefunden nach Login');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get upload server URL from web interface
|
||||||
|
*/
|
||||||
|
async _getUploadServer() {
|
||||||
|
// Use the standard upload server endpoint
|
||||||
|
const res = await this._fetch(BASE_URL + '/?op=upload_server');
|
||||||
|
const text = await res.text();
|
||||||
|
const ctype = (res.headers && res.headers.get) ? (res.headers.get('content-type') || '') : '';
|
||||||
|
_debugLog(`upload_server: status=${res.status} ctype=${ctype} body(800)=${(text || '').slice(0, 800)}`);
|
||||||
|
let json;
|
||||||
|
try { json = JSON.parse(text); } catch { json = null; }
|
||||||
|
|
||||||
|
if (json && json.result && /^https?:\/\//i.test(json.result)) {
|
||||||
|
return json.result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: try fetching from upload page HTML
|
||||||
|
const pageRes = await this._fetch(BASE_URL + '/?op=upload');
|
||||||
|
const html = await pageRes.text();
|
||||||
|
|
||||||
|
// Current doodstream format: the upload server is the action of the
|
||||||
|
// multipart upload form, e.g.
|
||||||
|
// <form name="file" enctype="multipart/form-data"
|
||||||
|
// action="https://xxx.cloudatacdn.com/upload/01?SESSID" ...>
|
||||||
|
// <input type="hidden" name="sess_id" value="SESSID">
|
||||||
|
// The node is assigned per page-load and the action carries a session token
|
||||||
|
// in its query string that matches the page's hidden sess_id. We refresh
|
||||||
|
// this.sessId from THIS page so the multipart sess_id field matches the node
|
||||||
|
// URL — login-time and node tokens otherwise diverge and the upload comes
|
||||||
|
// back with an empty filecode.
|
||||||
|
const actionMatch = html.match(/action=["'](https?:\/\/[^"']+\/upload\/[^"']*)["']/i);
|
||||||
|
if (actionMatch) {
|
||||||
|
const url = actionMatch[1].replace(/&/g, '&'); // un-escape HTML entities in query
|
||||||
|
const freshSess = html.match(/name=["']sess_id["'][^>]*value=["']([a-zA-Z0-9]+)["']/);
|
||||||
|
if (freshSess) {
|
||||||
|
this.sessId = freshSess[1];
|
||||||
|
} else {
|
||||||
|
_debugLog('upload_server: form action found but no sess_id on page; keeping existing sessId');
|
||||||
|
}
|
||||||
|
// Capture the form's real fields so upload() submits exactly what the
|
||||||
|
// browser would (file_title, submit_btn, …) instead of stale hardcoded ones.
|
||||||
|
this._uploadFormFields = this._parseUploadFormFields(html);
|
||||||
|
_debugLog(`upload_server: using form action node=${url} sess=${this.sessId} fields=${Object.keys(this._uploadFormFields).join(',')}`);
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy fallback: srv_url JS variable (older doodstream theme).
|
||||||
|
const srvMatch = html.match(/srv_url['":\s]+['"]?(https?:\/\/[^'">\s]+)['"]?/i);
|
||||||
|
if (srvMatch) return srvMatch[1];
|
||||||
|
|
||||||
|
// No upload server could be extracted. We MUST NOT silently fall back to a
|
||||||
|
// hardcoded node: that node is stale and accepts the bytes but returns an
|
||||||
|
// empty form (no filecode) — so the user wastes ~90s uploading 95 MB into a
|
||||||
|
// dead end and gets a cryptic "kein Filecode" 90s later. Fail fast and put
|
||||||
|
// the raw responses in the error so the real format change is diagnosable.
|
||||||
|
const urlHints = (html.match(/https?:\/\/[^'">\s]+/g) || []).slice(0, 4).join(' , ');
|
||||||
|
_debugLog(`upload_server: NO SERVER. upload-page html(2000)=${(html || '').slice(0, 2000)}`);
|
||||||
|
throw new Error(
|
||||||
|
`Doodstream: konnte Upload-Server nicht ermitteln (Endpoint geaendert?). ` +
|
||||||
|
`op=upload_server status=${res.status} ctype=${ctype} body=${(text || '').slice(0, 300)} ` +
|
||||||
|
`| upload-page URL-Treffer: ${urlHints || 'keine'}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replicate the non-file fields of doodstream's CURRENT upload form so our
|
||||||
|
* POST matches what the browser actually submits. Doodstream dropped the old
|
||||||
|
* `utype` field and added file_title / fakefilepc / submit_btn; submitting a
|
||||||
|
* stale/incomplete field set can make the node accept the bytes but skip
|
||||||
|
* registration (→ empty result form). We parse the live form rather than
|
||||||
|
* hardcode, so we track whatever fields doodstream uses now. The file input
|
||||||
|
* (type=file) is excluded — the file is streamed separately.
|
||||||
|
*/
|
||||||
|
_parseUploadFormFields(html) {
|
||||||
|
const fields = {};
|
||||||
|
if (!html) return fields;
|
||||||
|
// Narrow to the upload form (its action points at a /upload/ node).
|
||||||
|
const formMatch = html.match(/<form[^>]*\baction=["'][^"']*\/upload\/[^"']*["'][\s\S]*?<\/form>/i);
|
||||||
|
const scope = formMatch ? formMatch[0] : html;
|
||||||
|
const re = /<(?:input|button)\b([^>]*)>/gi;
|
||||||
|
let m;
|
||||||
|
while ((m = re.exec(scope)) !== null) {
|
||||||
|
const attrs = m[1];
|
||||||
|
const typeM = attrs.match(/\btype=["']([^"']*)["']/i);
|
||||||
|
if (typeM && typeM[1].toLowerCase() === 'file') continue;
|
||||||
|
const nameM = attrs.match(/\bname=["']([^"']+)["']/i);
|
||||||
|
if (!nameM) continue;
|
||||||
|
const valM = attrs.match(/\bvalue=["']([^"']*)["']/i);
|
||||||
|
fields[nameM[1]] = valM ? valM[1] : '';
|
||||||
|
}
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload file using web session
|
||||||
|
*/
|
||||||
|
async upload(filePath, progressCb, signal, throttle) {
|
||||||
|
const fileName = path.basename(filePath);
|
||||||
|
const fileSize = fs.statSync(filePath).size;
|
||||||
|
|
||||||
|
// Get upload server
|
||||||
|
const uploadUrl = await this._getUploadServer();
|
||||||
|
// Remember which CDN node handled this upload so a later parse failure can
|
||||||
|
// report it — failures sometimes correlate with a specific node.
|
||||||
|
this._lastUploadUrl = uploadUrl;
|
||||||
|
|
||||||
|
// Build multipart form
|
||||||
|
const boundary = `----WebKitFormBoundary${crypto.randomBytes(16).toString('hex')}`;
|
||||||
|
|
||||||
|
// Build form parts. Submit the live form's fields (parsed in
|
||||||
|
// _getUploadServer) so our POST matches the browser; merge in sess_id (the
|
||||||
|
// fresh node token) and keep utype=reg as a harmless compatibility extra.
|
||||||
|
// Falls back to the minimal known-good set if the form wasn't parsed.
|
||||||
|
const formFields = { utype: 'reg', ...(this._uploadFormFields || {}) };
|
||||||
|
formFields.sess_id = this.sessId;
|
||||||
|
let preamble = '';
|
||||||
|
for (const [name, value] of Object.entries(formFields)) {
|
||||||
|
preamble += `--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`;
|
||||||
|
}
|
||||||
|
const safeFileName = fileName.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||||
|
preamble += `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${safeFileName}"\r\nContent-Type: application/octet-stream\r\n\r\n`;
|
||||||
|
|
||||||
|
const epilogue = `\r\n--${boundary}--\r\n`;
|
||||||
|
const preambleBuf = Buffer.from(preamble, 'utf-8');
|
||||||
|
const epilogueBuf = Buffer.from(epilogue, 'utf-8');
|
||||||
|
const totalSize = preambleBuf.length + fileSize + epilogueBuf.length;
|
||||||
|
|
||||||
|
const CHUNK_SIZE = 1024 * 1024;
|
||||||
|
let bytesRead = 0;
|
||||||
|
|
||||||
|
async function* generate() {
|
||||||
|
yield preambleBuf;
|
||||||
|
const fileStream = fs.createReadStream(filePath, { highWaterMark: CHUNK_SIZE });
|
||||||
|
for await (const chunk of fileStream) {
|
||||||
|
if (signal && signal.aborted) throw new Error('Aborted');
|
||||||
|
if (throttle) await throttle.consume(chunk.length, signal);
|
||||||
|
bytesRead += chunk.length;
|
||||||
|
yield chunk;
|
||||||
|
if (progressCb) progressCb(bytesRead, fileSize);
|
||||||
|
}
|
||||||
|
yield epilogueBuf;
|
||||||
|
}
|
||||||
|
|
||||||
|
let uploadRes;
|
||||||
|
try {
|
||||||
|
uploadRes = await request(uploadUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
||||||
|
'Content-Length': String(totalSize),
|
||||||
|
'User-Agent': USER_AGENT,
|
||||||
|
'Cookie': this._cookieHeader()
|
||||||
|
},
|
||||||
|
body: generate(),
|
||||||
|
signal,
|
||||||
|
bodyTimeout: UPLOAD_TIMEOUT,
|
||||||
|
headersTimeout: 60000
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// Label which phase failed so a future "fetch failed"/"terminated" is
|
||||||
|
// attributable to the big upload POST vs the small bookend requests. The
|
||||||
|
// original message is preserved as a substring so upload-manager's
|
||||||
|
// transient classification still matches. NOTE: undici may surface
|
||||||
|
// "terminated"/"other side closed", which are not yet in that transient
|
||||||
|
// list — revisit if logs show them.
|
||||||
|
const mb = Math.round(bytesRead / 1048576);
|
||||||
|
throw new Error(`Doodstream Upload-POST (${mb} MB an ${uploadUrl}): ${err && err.message ? err.message : err}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusCode = uploadRes.statusCode;
|
||||||
|
_debugLog(`Upload response status: ${statusCode}`);
|
||||||
|
|
||||||
|
// Handle redirects from upload server (undici doesn't follow them)
|
||||||
|
if ([301, 302, 303, 307, 308].includes(statusCode)) {
|
||||||
|
const location = uploadRes.headers['location'];
|
||||||
|
try { await uploadRes.body.text(); } catch {}
|
||||||
|
_debugLog(`Upload redirect to: ${location}`);
|
||||||
|
if (location) {
|
||||||
|
return this._handleUploadResult(location);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resText = await uploadRes.body.text();
|
||||||
|
_debugLog(`Upload response body (first 500): ${resText.slice(0, 500)}`);
|
||||||
|
|
||||||
|
if (statusCode >= 400) {
|
||||||
|
let payload;
|
||||||
|
try { payload = JSON.parse(resText); } catch {}
|
||||||
|
const msg = payload && payload.msg ? payload.msg : resText.slice(0, 200);
|
||||||
|
throw new Error(`Doodstream Upload HTTP ${statusCode}: ${msg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this._parseUploadResponse(resText);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Follow a redirect URL from upload server and extract filecode
|
||||||
|
*/
|
||||||
|
async _handleUploadResult(url) {
|
||||||
|
_debugLog(`Following upload result URL: ${url}`);
|
||||||
|
const res = await this._fetch(url);
|
||||||
|
const html = await res.text();
|
||||||
|
_debugLog(`Result page (first 500): ${html.slice(0, 500)}`);
|
||||||
|
return this._parseUploadResponse(html);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract hidden form fields from HTML (handles various attribute orders)
|
||||||
|
*/
|
||||||
|
_extractHiddenFields(html) {
|
||||||
|
const fields = {};
|
||||||
|
// Textarea fields: <textarea name="op">upload_result</textarea>
|
||||||
|
const ta = /<textarea[^>]*name=['"]([^'"]+)['"][^>]*>([\s\S]*?)<\/textarea>/gi;
|
||||||
|
let m;
|
||||||
|
while ((m = ta.exec(html)) !== null) fields[m[1]] = m[2].trim();
|
||||||
|
// Input hidden fields
|
||||||
|
const p1 = /<input[^>]*type=['"]hidden['"][^>]*name=['"]([^'"]+)['"][^>]*value=['"]([^'"]*)['"]/gi;
|
||||||
|
while ((m = p1.exec(html)) !== null) { if (!fields[m[1]]) fields[m[1]] = m[2]; }
|
||||||
|
const p2 = /<input[^>]*name=['"]([^'"]+)['"][^>]*value=['"]([^'"]*)['"]/gi;
|
||||||
|
while ((m = p2.exec(html)) !== null) { if (!fields[m[1]]) fields[m[1]] = m[2]; }
|
||||||
|
const p3 = /<input[^>]*value=['"]([^'"]*)['"]\s[^>]*name=['"]([^'"]+)['"]/gi;
|
||||||
|
while ((m = p3.exec(html)) !== null) { if (!fields[m[2]]) fields[m[2]] = m[1]; }
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse filecode from upload server response (JSON or HTML)
|
||||||
|
*/
|
||||||
|
async _parseUploadResponse(resText) {
|
||||||
|
// 1. Try JSON
|
||||||
|
let payload;
|
||||||
|
try { payload = JSON.parse(resText); } catch {}
|
||||||
|
|
||||||
|
if (payload) {
|
||||||
|
return this._extractFromJson(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Try filecode directly in HTML
|
||||||
|
const code = this._findFilecodeInHtml(resText);
|
||||||
|
if (code) {
|
||||||
|
_debugLog(`Found filecode in HTML: ${code}`);
|
||||||
|
return this._buildResult(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Parse HTML form (XFileSharing two-step upload)
|
||||||
|
const hiddenFields = this._extractHiddenFields(resText);
|
||||||
|
_debugLog(`Hidden fields: ${JSON.stringify(hiddenFields)}`);
|
||||||
|
|
||||||
|
// Check if filecode is already in hidden fields
|
||||||
|
const fnCode = hiddenFields.fn || hiddenFields.filecode || hiddenFields.file_code;
|
||||||
|
if (fnCode && fnCode.length >= 8) {
|
||||||
|
_debugLog(`Filecode from hidden field 'fn': ${fnCode}`);
|
||||||
|
// We still need to submit the form so doodstream registers the file
|
||||||
|
// But the filecode is the 'fn' value
|
||||||
|
}
|
||||||
|
|
||||||
|
// XFileSharing standard: form with op=upload_result, fn, st
|
||||||
|
// Always submit to doodstream.com, not to CDN
|
||||||
|
if (hiddenFields.fn || hiddenFields.op === 'upload_result') {
|
||||||
|
// Ensure op=upload_result is set
|
||||||
|
if (!hiddenFields.op) hiddenFields.op = 'upload_result';
|
||||||
|
|
||||||
|
_debugLog(`Submitting upload_result to ${BASE_URL}/ with fields: ${JSON.stringify(hiddenFields)}`);
|
||||||
|
const formData = new URLSearchParams(hiddenFields);
|
||||||
|
let followText = '';
|
||||||
|
try {
|
||||||
|
const followRes = await this._fetch(BASE_URL + '/', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'Referer': BASE_URL + '/'
|
||||||
|
},
|
||||||
|
body: formData.toString()
|
||||||
|
});
|
||||||
|
followText = await followRes.text();
|
||||||
|
} catch (err) {
|
||||||
|
// The file already uploaded to the CDN; this POST only registers it on
|
||||||
|
// doodstream's side. If it fails transiently (even after _fetch's own
|
||||||
|
// retries) but we already hold the filecode, the upload succeeded from
|
||||||
|
// the user's view — return it rather than discarding a done upload.
|
||||||
|
if (fnCode && fnCode.length >= 8) {
|
||||||
|
_debugLog(`upload_result submit failed (${err && err.message}); using fn ${fnCode}`);
|
||||||
|
return this._buildResult(fnCode);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
_debugLog(`upload_result response (first 500): ${followText.slice(0, 500)}`);
|
||||||
|
|
||||||
|
// Try to find filecode in result page
|
||||||
|
const resultCode = this._findFilecodeInHtml(followText);
|
||||||
|
if (resultCode) {
|
||||||
|
return this._buildResult(resultCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we had fn from hidden fields, use that as filecode
|
||||||
|
if (fnCode && fnCode.length >= 8) {
|
||||||
|
return this._buildResult(fnCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try download URL pattern in result page
|
||||||
|
const dlMatch = followText.match(/https?:\/\/[a-z0-9.]+\/d\/([a-zA-Z0-9]+)/i);
|
||||||
|
if (dlMatch) {
|
||||||
|
return this._buildResult(dlMatch[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No filecode anywhere. Surface WHY: XFileSharing puts the real reason
|
||||||
|
// in the `st` field (anything other than "OK" means the backend refused
|
||||||
|
// the file — copyright/hash match, duplicate, size, quota, …). The
|
||||||
|
// download link being empty while the page structure is unchanged points
|
||||||
|
// at doodstream's backend, not at a parsing bug on our side.
|
||||||
|
const st = hiddenFields.st || '';
|
||||||
|
const fnInfo = fnCode ? `"${fnCode}"(len ${fnCode.length})` : 'fehlt/leer';
|
||||||
|
const node = this._lastUploadUrl || '?';
|
||||||
|
_debugLog(`No filecode. st=${st} fn=${fnInfo} node=${node} CDN-body=${(resText || '').slice(0, 400)}`);
|
||||||
|
if (st && st !== 'OK') {
|
||||||
|
throw new Error(`Doodstream lehnt Datei ab (Server-Status: ${st}). CDN=${node}`);
|
||||||
|
}
|
||||||
|
// Empty form (no fn, no st) is a doodstream-side processing flake — same
|
||||||
|
// account + same file works on a later attempt. Tag it explicitly so the
|
||||||
|
// upload-manager classifies this as a hoster-transient error and does NOT
|
||||||
|
// blacklist the account (otherwise one of these flakes poisons the whole
|
||||||
|
// session and later batches hit `pre-job-swap-blocked` for no fault of
|
||||||
|
// the account). The flag is the primary signal; the message text is a
|
||||||
|
// belt-and-suspenders regex fallback in the classifier.
|
||||||
|
const emptyLinkErr = new Error(`Doodstream Upload: kein Filecode — Server gab leeren Link zurueck (st=${st || '?'}, fn=${fnInfo}, CDN=${node}). CDN-Antwort: ${(resText || '').slice(0, 200)}`);
|
||||||
|
emptyLinkErr.hosterTransient = true;
|
||||||
|
throw emptyLinkErr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Fallback: follow form action as-is (for non-XFS forms)
|
||||||
|
const formAction = resText.match(/<form[^>]*action=['"]([^'"]+)['"]/i);
|
||||||
|
if (formAction) {
|
||||||
|
_debugLog(`Fallback: following form action ${formAction[1]}`);
|
||||||
|
const formData = new URLSearchParams(hiddenFields);
|
||||||
|
const followRes = await this._fetch(formAction[1], {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'Referer': BASE_URL + '/'
|
||||||
|
},
|
||||||
|
body: formData.toString()
|
||||||
|
});
|
||||||
|
const followText = await followRes.text();
|
||||||
|
_debugLog(`Fallback response (first 500): ${followText.slice(0, 500)}`);
|
||||||
|
|
||||||
|
const fallbackCode = this._findFilecodeInHtml(followText);
|
||||||
|
if (fallbackCode) return this._buildResult(fallbackCode);
|
||||||
|
|
||||||
|
// Check if fn was in original hidden fields
|
||||||
|
if (fnCode && fnCode.length >= 8) return this._buildResult(fnCode);
|
||||||
|
|
||||||
|
throw new Error(`Doodstream Upload: Redirect-Antwort ungueltig (${followText.slice(0, 150)})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Doodstream Upload: Keine gueltige Antwort (Body: ${resText.slice(0, 150)})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search for filecode patterns in HTML
|
||||||
|
*/
|
||||||
|
_findFilecodeInHtml(html) {
|
||||||
|
// filecode: "xxx" or filecode = "xxx"
|
||||||
|
const m1 = html.match(/filecode['":\s]+['"]([a-zA-Z0-9]{8,})['"]/i);
|
||||||
|
if (m1) return m1[1];
|
||||||
|
// file_code: "xxx"
|
||||||
|
const m2 = html.match(/file_code['":\s]+['"]([a-zA-Z0-9]{8,})['"]/i);
|
||||||
|
if (m2) return m2[1];
|
||||||
|
// Download URL pattern: /d/FILECODE
|
||||||
|
const m3 = html.match(/\/d\/([a-zA-Z0-9]{8,})/);
|
||||||
|
if (m3) return m3[1];
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract result from JSON payload
|
||||||
|
*/
|
||||||
|
_extractFromJson(payload) {
|
||||||
|
if (payload.status && Number(payload.status) !== 200 && payload.msg) {
|
||||||
|
throw new Error(`Doodstream Upload: ${payload.msg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let item = null;
|
||||||
|
const result = payload.result;
|
||||||
|
if (Array.isArray(result) && result.length > 0) {
|
||||||
|
item = result[0];
|
||||||
|
} else if (typeof result === 'object' && result) {
|
||||||
|
item = result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
throw new Error(`Doodstream Upload fehlgeschlagen: ${payload.msg || JSON.stringify(payload).slice(0, 150)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileCode = item.filecode || item.file_code || '';
|
||||||
|
return {
|
||||||
|
download_url: item.download_url || item.protected_dl || (fileCode ? `https://doodstream.com/d/${fileCode}` : null),
|
||||||
|
embed_url: item.protected_embed || (fileCode ? `https://doodstream.com/e/${fileCode}` : null),
|
||||||
|
file_code: fileCode
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildResult(fileCode) {
|
||||||
|
return {
|
||||||
|
download_url: `https://doodstream.com/d/${fileCode}`,
|
||||||
|
embed_url: `https://doodstream.com/e/${fileCode}`,
|
||||||
|
file_code: fileCode
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pull candidate API-key tokens out of a logged-in settings page. We do NOT
|
||||||
|
* rely on knowing doodstream's exact (cookie-gated, unseen) settings DOM —
|
||||||
|
* instead we gather every plausible long token from form-field values and
|
||||||
|
* element contents, ranked so tokens near an "api" mention are tried first.
|
||||||
|
* The caller validates each against the official API, so a wrong guess is
|
||||||
|
* harmless (it just fails validation). Returned newest-/most-likely-first.
|
||||||
|
*/
|
||||||
|
_extractApiKeyCandidates(html) {
|
||||||
|
if (!html) return [];
|
||||||
|
const cands = new Set();
|
||||||
|
const patterns = [
|
||||||
|
/value=["']([A-Za-z0-9]{20,})["']/gi, // <input value="KEY">
|
||||||
|
/<(?:textarea|code|span|pre|input)[^>]*>\s*([A-Za-z0-9]{20,})\s*</gi, // <textarea>KEY</textarea>
|
||||||
|
/\b(?:api[_-]?key|apikey)\b["':\s=>]*["']?([A-Za-z0-9]{20,})/gi // api_key: "KEY"
|
||||||
|
];
|
||||||
|
for (const re of patterns) {
|
||||||
|
let m;
|
||||||
|
while ((m = re.exec(html)) !== null) cands.add(m[1]);
|
||||||
|
}
|
||||||
|
// Rank tokens whose preceding context mentions "api" ahead of the rest.
|
||||||
|
return [...cands]
|
||||||
|
.map(t => {
|
||||||
|
const idx = html.indexOf(t);
|
||||||
|
const ctx = html.slice(Math.max(0, idx - 160), idx).toLowerCase();
|
||||||
|
return { t, near: /api/.test(ctx) ? 0 : 1 };
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.near - b.near)
|
||||||
|
.map(s => s.t);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a candidate key against the official API. Only the account's real
|
||||||
|
* key returns status 200, so this is what makes the brute-force extraction
|
||||||
|
* safe regardless of the settings-page markup.
|
||||||
|
*/
|
||||||
|
async _validateApiKey(key) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`https://doodapi.co/api/account/info?key=${encodeURIComponent(key)}`, {
|
||||||
|
method: 'GET', redirect: 'follow', signal: AbortSignal.timeout(15000)
|
||||||
|
});
|
||||||
|
const json = await res.json().catch(() => null);
|
||||||
|
return !!(json && Number(json.status) === 200);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive the account's doodapi API key from the logged-in web session, so a
|
||||||
|
* login-only account can upload via the reliable JSON API (which returns the
|
||||||
|
* filecode directly) instead of the fragile web upload form. Best-effort:
|
||||||
|
* returns null if no valid key can be found, and the caller falls back to the
|
||||||
|
* web-form upload. Requires login() to have run first (needs the cookies).
|
||||||
|
*/
|
||||||
|
async deriveApiKey() {
|
||||||
|
if (this.apiKey) return this.apiKey;
|
||||||
|
let html = '';
|
||||||
|
for (const page of ['/?op=my_account', '/settings', '/?op=profile']) {
|
||||||
|
try {
|
||||||
|
const res = await this._fetch(BASE_URL + page);
|
||||||
|
const text = await res.text();
|
||||||
|
if (text && /api[\s_-]?key/i.test(text)) { html = text; break; }
|
||||||
|
if (text && !html) html = text;
|
||||||
|
} catch { /* try next page */ }
|
||||||
|
}
|
||||||
|
const candidates = this._extractApiKeyCandidates(html);
|
||||||
|
// Cap validation calls (rate limit 10/s; settings page yields few tokens).
|
||||||
|
for (const key of candidates.slice(0, 15)) {
|
||||||
|
if (await this._validateApiKey(key)) {
|
||||||
|
this.apiKey = key;
|
||||||
|
_debugLog(`api-key derive: validated key (len ${key.length})`);
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_debugLog(`api-key derive: ${candidates.length} candidate(s), none validated. settings html(2500)=${(html || '').slice(0, 2500)}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = DoodstreamUploader;
|
||||||
|
module.exports.setDebugVerbose = setDebugVerbose;
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
const SIGNATURES = [
|
||||||
|
{ kind: 'mp4-iso', test: (b) => b.length >= 12 && b.slice(4, 8).toString('ascii') === 'ftyp' },
|
||||||
|
{ kind: 'matroska', test: (b) => b.length >= 4 && b[0] === 0x1A && b[1] === 0x45 && b[2] === 0xDF && b[3] === 0xA3 },
|
||||||
|
{ kind: 'avi', test: (b) => b.length >= 12 && b.slice(0, 4).toString('ascii') === 'RIFF' && b.slice(8, 12).toString('ascii') === 'AVI ' },
|
||||||
|
{ kind: 'wav', test: (b) => b.length >= 12 && b.slice(0, 4).toString('ascii') === 'RIFF' && b.slice(8, 12).toString('ascii') === 'WAVE' },
|
||||||
|
{ kind: 'flv', test: (b) => b.length >= 3 && b.slice(0, 3).toString('ascii') === 'FLV' },
|
||||||
|
{ kind: 'asf-wmv', test: (b) => b.length >= 4 && b[0] === 0x30 && b[1] === 0x26 && b[2] === 0xB2 && b[3] === 0x75 },
|
||||||
|
{ kind: 'mpeg-ps', test: (b) => b.length >= 4 && b[0] === 0x00 && b[1] === 0x00 && b[2] === 0x01 && (b[3] === 0xBA || b[3] === 0xB3) },
|
||||||
|
{ kind: 'gif', test: (b) => b.length >= 6 && (b.slice(0, 6).toString('ascii') === 'GIF87a' || b.slice(0, 6).toString('ascii') === 'GIF89a') },
|
||||||
|
// TS demands the 0x47 sync byte every 188 bytes — a single leading 0x47
|
||||||
|
// matches every GIF and every text file starting with "G", so require
|
||||||
|
// three consecutive packet boundaries before classifying as video.
|
||||||
|
{ kind: 'mpeg-ts', test: (b) => b.length >= 377 && b[0] === 0x47 && b[188] === 0x47 && b[376] === 0x47 },
|
||||||
|
{ kind: 'mp3', test: (b) => b.length >= 3 && (b.slice(0, 3).toString('ascii') === 'ID3' || (b[0] === 0xFF && (b[1] & 0xE0) === 0xE0)) },
|
||||||
|
{ kind: 'ogg', test: (b) => b.length >= 4 && b.slice(0, 4).toString('ascii') === 'OggS' },
|
||||||
|
{ kind: 'jpeg', test: (b) => b.length >= 3 && b[0] === 0xFF && b[1] === 0xD8 && b[2] === 0xFF },
|
||||||
|
{ kind: 'png', test: (b) => b.length >= 8 && b[0] === 0x89 && b.slice(1, 4).toString('ascii') === 'PNG' },
|
||||||
|
{ kind: 'pdf', test: (b) => b.length >= 5 && b.slice(0, 5).toString('ascii') === '%PDF-' },
|
||||||
|
{ kind: 'zip', test: (b) => b.length >= 4 && b[0] === 0x50 && b[1] === 0x4B && (b[2] === 0x03 || b[2] === 0x05 || b[2] === 0x07) },
|
||||||
|
{ kind: 'html', test: (b) => {
|
||||||
|
const s = b.toString('ascii', 0, Math.min(b.length, 64)).trimStart().toLowerCase();
|
||||||
|
return s.startsWith('<!doctype html') || s.startsWith('<html');
|
||||||
|
} }
|
||||||
|
];
|
||||||
|
|
||||||
|
const VIDEO_KINDS = new Set(['mp4-iso', 'matroska', 'avi', 'flv', 'asf-wmv', 'mpeg-ps', 'mpeg-ts']);
|
||||||
|
|
||||||
|
function detectKind(buf) {
|
||||||
|
if (!buf || buf.length === 0) return 'empty';
|
||||||
|
for (const sig of SIGNATURES) {
|
||||||
|
try { if (sig.test(buf)) return sig.kind; } catch { /* ignore malformed buffer slice */ }
|
||||||
|
}
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isVideoLikeKind(kind) {
|
||||||
|
return VIDEO_KINDS.has(kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
function probeFileHead(filePath, bytes) {
|
||||||
|
const want = Number.isFinite(bytes) && bytes > 0 ? bytes : 64;
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
fs.open(filePath, 'r', (err, fd) => {
|
||||||
|
if (err) return resolve({ ok: false, error: err.message, kind: 'unreadable' });
|
||||||
|
const buf = Buffer.alloc(want);
|
||||||
|
fs.read(fd, buf, 0, want, 0, (rerr, bytesRead) => {
|
||||||
|
fs.close(fd, () => {});
|
||||||
|
if (rerr) return resolve({ ok: false, error: rerr.message, kind: 'unreadable' });
|
||||||
|
const slice = buf.slice(0, bytesRead);
|
||||||
|
resolve({
|
||||||
|
ok: true,
|
||||||
|
bytesRead,
|
||||||
|
kind: detectKind(slice),
|
||||||
|
isVideoLike: isVideoLikeKind(detectKind(slice)),
|
||||||
|
headHex: slice.toString('hex')
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeFileStat(filePath) {
|
||||||
|
try {
|
||||||
|
const st = fs.statSync(filePath);
|
||||||
|
return {
|
||||||
|
size: st.size,
|
||||||
|
mtime: st.mtime.toISOString(),
|
||||||
|
isFile: st.isFile()
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return { error: err.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { detectKind, isVideoLikeKind, probeFileHead, summarizeFileStat, VIDEO_KINDS, SIGNATURES };
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
const { EventEmitter } = require('events');
|
||||||
|
const path = require('path');
|
||||||
|
const chokidar = require('chokidar');
|
||||||
|
|
||||||
|
class FolderMonitor extends EventEmitter {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this._watcher = null;
|
||||||
|
this._settings = null;
|
||||||
|
this._seenFiles = new Set();
|
||||||
|
this._batchBuffer = [];
|
||||||
|
this._batchTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
get running() {
|
||||||
|
return !!this._watcher;
|
||||||
|
}
|
||||||
|
|
||||||
|
start(settings) {
|
||||||
|
this.stop();
|
||||||
|
this._settings = settings;
|
||||||
|
|
||||||
|
const folderPath = String(settings.folderPath || '').trim();
|
||||||
|
if (!folderPath) throw new Error('Kein Ordnerpfad angegeben');
|
||||||
|
|
||||||
|
const watchOptions = {
|
||||||
|
persistent: true,
|
||||||
|
ignoreInitial: true,
|
||||||
|
depth: settings.recursive ? undefined : 0,
|
||||||
|
awaitWriteFinish: {
|
||||||
|
stabilityThreshold: Math.max(1000, (settings.delaySec || 3) * 1000),
|
||||||
|
pollInterval: 500
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this._watcher = chokidar.watch(folderPath, watchOptions);
|
||||||
|
this._watcher.on('add', (filePath) => this._onNewFile(filePath));
|
||||||
|
this._watcher.on('unlink', (filePath) => {
|
||||||
|
// Allow re-added files (e.g. re-encoded) to be detected again
|
||||||
|
const normalized = filePath.replace(/\\/g, '/').toLowerCase();
|
||||||
|
this._seenFiles.delete(normalized);
|
||||||
|
});
|
||||||
|
this._watcher.on('error', (err) => this.emit('error', err));
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
if (this._watcher) {
|
||||||
|
this._watcher.close().catch(() => {});
|
||||||
|
this._watcher = null;
|
||||||
|
}
|
||||||
|
if (this._batchTimer) {
|
||||||
|
clearTimeout(this._batchTimer);
|
||||||
|
this._batchTimer = null;
|
||||||
|
}
|
||||||
|
this._batchBuffer = [];
|
||||||
|
this._seenFiles = new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
status() {
|
||||||
|
return {
|
||||||
|
running: this.running,
|
||||||
|
folderPath: this._settings ? this._settings.folderPath : '',
|
||||||
|
seenCount: this._seenFiles.size
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
_onNewFile(filePath) {
|
||||||
|
const settings = this._settings;
|
||||||
|
if (!settings) return;
|
||||||
|
|
||||||
|
// Extension filter
|
||||||
|
const ext = path.extname(filePath).replace(/^\./, '').toLowerCase();
|
||||||
|
const rawExtensions = String(settings.extensions || '').trim();
|
||||||
|
if (rawExtensions) {
|
||||||
|
const extList = rawExtensions.split(',').map(e => e.trim().toLowerCase().replace(/^\./, '')).filter(Boolean);
|
||||||
|
if (extList.length > 0) {
|
||||||
|
const matches = extList.includes(ext);
|
||||||
|
if (settings.filterMode === 'include' && !matches) return;
|
||||||
|
if (settings.filterMode === 'exclude' && matches) return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip duplicates (session-based)
|
||||||
|
if (settings.skipDuplicates) {
|
||||||
|
const normalized = filePath.replace(/\\/g, '/').toLowerCase();
|
||||||
|
if (this._seenFiles.has(normalized)) return;
|
||||||
|
this._seenFiles.add(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch: collect files over 200ms window then emit together
|
||||||
|
this._batchBuffer.push(filePath);
|
||||||
|
if (this._batchTimer) clearTimeout(this._batchTimer);
|
||||||
|
this._batchTimer = setTimeout(() => {
|
||||||
|
const files = this._batchBuffer.splice(0);
|
||||||
|
this._batchTimer = null;
|
||||||
|
if (files.length > 0) {
|
||||||
|
this.emit('new-files', files);
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = FolderMonitor;
|
||||||
+728
@@ -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
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
function normalizeIp(ip) {
|
||||||
|
return String(ip || '').trim().replace(/^::ffff:/i, '').toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLoopbackIp(ip) {
|
||||||
|
const c = normalizeIp(ip);
|
||||||
|
return c === '' || c === '::1' || c === 'localhost' || /^127\./.test(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ipv4ToInt(ip) {
|
||||||
|
const parts = String(ip).split('.');
|
||||||
|
if (parts.length !== 4) return null;
|
||||||
|
let n = 0;
|
||||||
|
for (const p of parts) {
|
||||||
|
if (!/^\d{1,3}$/.test(p)) return null;
|
||||||
|
const v = Number(p);
|
||||||
|
if (v < 0 || v > 255) return null;
|
||||||
|
n = (n << 8) + v;
|
||||||
|
}
|
||||||
|
return n >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchIpRule(clientIp, rule) {
|
||||||
|
const client = normalizeIp(clientIp);
|
||||||
|
const r = String(rule || '').trim().toLowerCase();
|
||||||
|
if (!r) return false;
|
||||||
|
if (r === '*' || r === '0.0.0.0/0') return true;
|
||||||
|
if (r === client) return true;
|
||||||
|
const slash = r.indexOf('/');
|
||||||
|
if (slash > 0) {
|
||||||
|
const baseInt = ipv4ToInt(r.slice(0, slash));
|
||||||
|
const clientInt = ipv4ToInt(client);
|
||||||
|
const bits = Number(r.slice(slash + 1));
|
||||||
|
if (baseInt === null || clientInt === null || !Number.isInteger(bits) || bits < 0 || bits > 32) return false;
|
||||||
|
if (bits === 0) return true;
|
||||||
|
const mask = bits === 32 ? 0xffffffff : (~((1 << (32 - bits)) - 1)) >>> 0;
|
||||||
|
return (clientInt & mask) === (baseInt & mask);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function evaluateClientAllowed(clientIp, rules) {
|
||||||
|
const client = normalizeIp(clientIp);
|
||||||
|
if (isLoopbackIp(client)) return true;
|
||||||
|
const list = Array.isArray(rules) ? rules : [];
|
||||||
|
if (list.length === 0) return false;
|
||||||
|
return list.some((rule) => matchIpRule(client, rule));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { normalizeIp, isLoopbackIp, ipv4ToInt, matchIpRule, evaluateClientAllowed };
|
||||||
+109
@@ -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);
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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(' '))
|
||||||
|
});
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head><title>Remote Capture</title></head>
|
||||||
|
<body>
|
||||||
|
<script>
|
||||||
|
// Maps clientId -> { pc: RTCPeerConnection, dc: RTCDataChannel }
|
||||||
|
const clients = new Map();
|
||||||
|
let captureStream = null;
|
||||||
|
|
||||||
|
async function getCaptureStream() {
|
||||||
|
if (captureStream) return captureStream;
|
||||||
|
|
||||||
|
// desktopCapturer runs in main process (Electron 33+), we get the source ID via IPC
|
||||||
|
const sourceId = await window.capture.getSourceId();
|
||||||
|
window.capture.log('getSourceId returned:', sourceId || 'NULL');
|
||||||
|
if (!sourceId) throw new Error('No capture source ID from main process');
|
||||||
|
|
||||||
|
try {
|
||||||
|
captureStream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
audio: false,
|
||||||
|
video: {
|
||||||
|
mandatory: {
|
||||||
|
chromeMediaSource: 'desktop',
|
||||||
|
chromeMediaSourceId: sourceId,
|
||||||
|
maxFrameRate: 15
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const tracks = captureStream.getTracks();
|
||||||
|
window.capture.log('getUserMedia OK, tracks:', tracks.length, tracks.map(t => `${t.kind}:${t.readyState}`).join(','));
|
||||||
|
return captureStream;
|
||||||
|
} catch (err) {
|
||||||
|
window.capture.log('getUserMedia FAILED:', err.message);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleOffer(clientId, offer, role) {
|
||||||
|
window.capture.log('handleOffer called for', clientId);
|
||||||
|
let stream;
|
||||||
|
try {
|
||||||
|
stream = await getCaptureStream();
|
||||||
|
} catch (err) {
|
||||||
|
window.capture.log('FATAL: getCaptureStream failed:', err.message);
|
||||||
|
// Send diagnostic back to dashboard
|
||||||
|
window.capture.sendSignaling({ type: 'capture-error', clientId, error: err.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pc = new RTCPeerConnection({
|
||||||
|
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
|
||||||
|
});
|
||||||
|
clients.set(clientId, { pc, role });
|
||||||
|
|
||||||
|
// Add video tracks
|
||||||
|
const tracks = stream.getTracks();
|
||||||
|
window.capture.log('Adding', tracks.length, 'tracks to peer connection');
|
||||||
|
for (const track of tracks) {
|
||||||
|
window.capture.log('addTrack:', track.kind, track.label, track.readyState);
|
||||||
|
pc.addTrack(track, stream);
|
||||||
|
}
|
||||||
|
window.capture.log('Senders after addTrack:', pc.getSenders().length);
|
||||||
|
|
||||||
|
// Handle DataChannel from dashboard (dashboard creates it as offerer)
|
||||||
|
pc.ondatachannel = (event) => {
|
||||||
|
const dc = event.channel;
|
||||||
|
clients.get(clientId).dc = dc;
|
||||||
|
dc.onmessage = (msg) => {
|
||||||
|
try {
|
||||||
|
const input = JSON.parse(msg.data);
|
||||||
|
input.clientId = clientId;
|
||||||
|
input.role = role;
|
||||||
|
window.capture.sendInput(input);
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ICE candidates — serialize to plain object (WebRTC objects don't survive IPC)
|
||||||
|
pc.onicecandidate = (event) => {
|
||||||
|
if (event.candidate) {
|
||||||
|
window.capture.sendSignaling({
|
||||||
|
type: 'ice-candidate',
|
||||||
|
clientId,
|
||||||
|
candidate: {
|
||||||
|
candidate: event.candidate.candidate,
|
||||||
|
sdpMid: event.candidate.sdpMid,
|
||||||
|
sdpMLineIndex: event.candidate.sdpMLineIndex,
|
||||||
|
usernameFragment: event.candidate.usernameFragment
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
pc.onconnectionstatechange = () => {
|
||||||
|
if (pc.connectionState === 'disconnected' || pc.connectionState === 'failed') {
|
||||||
|
removeClient(clientId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await pc.setRemoteDescription(new RTCSessionDescription(offer));
|
||||||
|
const answer = await pc.createAnswer();
|
||||||
|
await pc.setLocalDescription(answer);
|
||||||
|
|
||||||
|
// Serialize to plain object (RTCSessionDescription doesn't survive IPC)
|
||||||
|
window.capture.sendSignaling({
|
||||||
|
type: 'answer',
|
||||||
|
clientId,
|
||||||
|
answer: { type: pc.localDescription.type, sdp: pc.localDescription.sdp }
|
||||||
|
});
|
||||||
|
|
||||||
|
window.capture.notifyClientCount(clients.size);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleIceCandidate(clientId, candidate) {
|
||||||
|
const client = clients.get(clientId);
|
||||||
|
if (client && client.pc) {
|
||||||
|
client.pc.addIceCandidate(new RTCIceCandidate(candidate)).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeClient(clientId) {
|
||||||
|
const client = clients.get(clientId);
|
||||||
|
if (client) {
|
||||||
|
if (client.dc) client.dc.close();
|
||||||
|
client.pc.close();
|
||||||
|
clients.delete(clientId);
|
||||||
|
window.capture.notifyClientCount(clients.size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for signaling messages from main process
|
||||||
|
window.capture.onSignaling((data) => {
|
||||||
|
switch (data.type) {
|
||||||
|
case 'offer':
|
||||||
|
handleOffer(data.clientId, data.offer, data.role).catch(err => {
|
||||||
|
console.error('Failed to handle offer:', err);
|
||||||
|
window.capture.sendSignaling({ type: 'error', clientId: data.clientId, error: err.message });
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'ice-candidate':
|
||||||
|
handleIceCandidate(data.clientId, data.candidate);
|
||||||
|
break;
|
||||||
|
case 'client-disconnected':
|
||||||
|
removeClient(data.clientId);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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;
|
||||||
@@ -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 };
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* FIFO Semaphore for per-hoster concurrency control.
|
||||||
|
* acquire(signal?) blocks until a slot is available or the signal aborts.
|
||||||
|
* release() frees a slot.
|
||||||
|
*/
|
||||||
|
class Semaphore {
|
||||||
|
constructor(limit) {
|
||||||
|
this.limit = Math.max(1, limit || 1);
|
||||||
|
this.active = 0;
|
||||||
|
this.queue = []; // { resolve, reject, signal?, onAbort? }
|
||||||
|
}
|
||||||
|
|
||||||
|
acquire(signal) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (signal && signal.aborted) {
|
||||||
|
reject(new Error('Aborted'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.active < this.limit) {
|
||||||
|
this.active++;
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = { resolve, reject };
|
||||||
|
|
||||||
|
if (signal) {
|
||||||
|
entry.signal = signal;
|
||||||
|
entry.onAbort = () => {
|
||||||
|
const idx = this.queue.indexOf(entry);
|
||||||
|
if (idx !== -1) this.queue.splice(idx, 1);
|
||||||
|
reject(new Error('Aborted'));
|
||||||
|
};
|
||||||
|
signal.addEventListener('abort', entry.onAbort, { once: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
this.queue.push(entry);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_cleanupEntry(entry) {
|
||||||
|
if (entry.signal && entry.onAbort) {
|
||||||
|
entry.signal.removeEventListener('abort', entry.onAbort);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
release() {
|
||||||
|
if (this.queue.length > 0) {
|
||||||
|
const entry = this.queue.shift();
|
||||||
|
this._cleanupEntry(entry);
|
||||||
|
entry.resolve();
|
||||||
|
} else {
|
||||||
|
this.active = Math.max(0, this.active - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateLimit(newLimit) {
|
||||||
|
this.limit = Math.max(1, newLimit || 1);
|
||||||
|
while (this.active < this.limit && this.queue.length > 0) {
|
||||||
|
this.active++;
|
||||||
|
const entry = this.queue.shift();
|
||||||
|
this._cleanupEntry(entry);
|
||||||
|
entry.resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get pending() {
|
||||||
|
return this.queue.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = Semaphore;
|
||||||
+142
@@ -0,0 +1,142 @@
|
|||||||
|
(function (root) {
|
||||||
|
function summarizePerHoster(history, opts) {
|
||||||
|
const out = {};
|
||||||
|
if (!Array.isArray(history)) return out;
|
||||||
|
const cutoff = opts && Number.isFinite(opts.sinceMs) ? opts.sinceMs : null;
|
||||||
|
const limitBatches = opts && Number.isFinite(opts.lastNBatches) && opts.lastNBatches > 0 ? opts.lastNBatches : null;
|
||||||
|
|
||||||
|
const entries = [...history];
|
||||||
|
entries.sort((a, b) => {
|
||||||
|
const ta = a && a.timestamp ? Date.parse(a.timestamp) : 0;
|
||||||
|
const tb = b && b.timestamp ? Date.parse(b.timestamp) : 0;
|
||||||
|
return tb - ta;
|
||||||
|
});
|
||||||
|
const sliced = limitBatches ? entries.slice(0, limitBatches) : entries;
|
||||||
|
|
||||||
|
for (const batch of sliced) {
|
||||||
|
if (!batch || !Array.isArray(batch.files)) continue;
|
||||||
|
if (cutoff !== null) {
|
||||||
|
const ts = batch.timestamp ? Date.parse(batch.timestamp) : 0;
|
||||||
|
if (!ts || ts < cutoff) continue;
|
||||||
|
}
|
||||||
|
for (const file of batch.files) {
|
||||||
|
if (!file || !Array.isArray(file.results)) continue;
|
||||||
|
for (const r of file.results) {
|
||||||
|
if (!r || !r.hoster) continue;
|
||||||
|
const bucket = out[r.hoster] || (out[r.hoster] = { ok: 0, fail: 0, total: 0 });
|
||||||
|
bucket.total++;
|
||||||
|
if (r.status === 'done') bucket.ok++;
|
||||||
|
else bucket.fail++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const h of Object.keys(out)) {
|
||||||
|
const b = out[h];
|
||||||
|
b.rate = b.total > 0 ? b.ok / b.total : null;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function classifyErrorCategory(err) {
|
||||||
|
if (!err || typeof err !== 'string') return 'unknown';
|
||||||
|
const s = err.toLowerCase();
|
||||||
|
if (/abgebrochen|aborted|cancel/.test(s)) return 'aborted';
|
||||||
|
if (/not video file format|kein videoformat|invalid file|wrong format|duplicate|already exists|file too (small|big|large)|datei zu (gro|klein)/.test(s)) return 'file-rejected';
|
||||||
|
if (/quota|storage (full|exhausted|voll)|account (full|banned|suspended)|disk (space )?full|insufficient (disk )?space|not enough (disk )?(space|storage)/.test(s)) return 'account-error';
|
||||||
|
if (/csrf|kein upload-server|server.*?(busy|unavailable|try again)|no servers available|filecode|kein filecode|empty.*?(form|response)/.test(s)) return 'hoster-transient';
|
||||||
|
if (/timeout|econnreset|enotfound|fetch failed|network|socket hang up|abort/.test(s)) return 'network';
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeBatchErrors(batchSummary) {
|
||||||
|
const buckets = {
|
||||||
|
'file-rejected': [],
|
||||||
|
'account-error': [],
|
||||||
|
'hoster-transient': [],
|
||||||
|
'network': [],
|
||||||
|
'unknown': [],
|
||||||
|
'aborted': []
|
||||||
|
};
|
||||||
|
if (!batchSummary || !Array.isArray(batchSummary.files)) return buckets;
|
||||||
|
for (const f of batchSummary.files) {
|
||||||
|
if (!f || !Array.isArray(f.results)) continue;
|
||||||
|
for (const r of f.results) {
|
||||||
|
if (!r || r.status === 'done') continue;
|
||||||
|
const cat = classifyErrorCategory(r.error);
|
||||||
|
buckets[cat].push({
|
||||||
|
fileName: f.name || f.fileName || '',
|
||||||
|
hoster: r.hoster || '',
|
||||||
|
error: r.error || '',
|
||||||
|
jobId: r.jobId || null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return buckets;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RETRYABLE_CATEGORIES = new Set(['hoster-transient', 'network', 'unknown']);
|
||||||
|
function isRetryableCategory(cat) {
|
||||||
|
return RETRYABLE_CATEGORIES.has(cat);
|
||||||
|
}
|
||||||
|
|
||||||
|
const CATEGORY_LABELS = {
|
||||||
|
'file-rejected': 'Datei abgelehnt',
|
||||||
|
'account-error': 'Account-Problem',
|
||||||
|
'hoster-transient': 'Hoster-Flake',
|
||||||
|
'network': 'Netzwerk',
|
||||||
|
'unknown': 'Unbekannt',
|
||||||
|
'aborted': 'Abgebrochen'
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatLinks(rows, format) {
|
||||||
|
if (!Array.isArray(rows)) return '';
|
||||||
|
const safe = rows.filter(r => r && r.url);
|
||||||
|
if (safe.length === 0) return '';
|
||||||
|
switch (format) {
|
||||||
|
case 'plain':
|
||||||
|
return safe.map(r => r.url).join('\n');
|
||||||
|
case 'bbcode':
|
||||||
|
return safe.map(r => {
|
||||||
|
const label = r.fileName || r.hoster || r.url;
|
||||||
|
return `[url=${r.url}]${label}[/url]`;
|
||||||
|
}).join('\n');
|
||||||
|
case 'markdown':
|
||||||
|
return safe.map(r => {
|
||||||
|
const label = r.fileName || r.hoster || r.url;
|
||||||
|
return `- [${label}](${r.url})`;
|
||||||
|
}).join('\n');
|
||||||
|
case 'html':
|
||||||
|
return safe.map(r => {
|
||||||
|
const label = r.fileName || r.hoster || r.url;
|
||||||
|
return `<a href="${r.url}">${label}</a>`;
|
||||||
|
}).join('\n');
|
||||||
|
case 'csv': {
|
||||||
|
const head = 'fileName,hoster,url\n';
|
||||||
|
return head + safe.map(r => {
|
||||||
|
const esc = (v) => `"${String(v || '').replace(/"/g, '""')}"`;
|
||||||
|
return [esc(r.fileName), esc(r.hoster), esc(r.url)].join(',');
|
||||||
|
}).join('\n');
|
||||||
|
}
|
||||||
|
case 'json':
|
||||||
|
return JSON.stringify(safe.map(r => ({ fileName: r.fileName || '', hoster: r.hoster || '', url: r.url })), null, 2);
|
||||||
|
default:
|
||||||
|
return safe.map(r => r.url).join('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = {
|
||||||
|
summarizePerHoster,
|
||||||
|
classifyErrorCategory,
|
||||||
|
summarizeBatchErrors,
|
||||||
|
isRetryableCategory,
|
||||||
|
RETRYABLE_CATEGORIES,
|
||||||
|
CATEGORY_LABELS,
|
||||||
|
formatLinks
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = api;
|
||||||
|
} else if (root) {
|
||||||
|
root.Stats = api;
|
||||||
|
}
|
||||||
|
})(typeof window !== 'undefined' ? window : this);
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
const CRED_KEYS = new Set(['password', 'apiKey', 'token', 'cookie', 'sessionId', 'webhookUrl', 'diagToken']);
|
||||||
|
const REDACTED = '<redacted>';
|
||||||
|
|
||||||
|
function sanitizeConfig(config) {
|
||||||
|
if (!config || typeof config !== 'object') return config;
|
||||||
|
const clone = JSON.parse(JSON.stringify(config));
|
||||||
|
(function walk(o) {
|
||||||
|
if (!o) return;
|
||||||
|
if (Array.isArray(o)) { for (const e of o) walk(e); return; }
|
||||||
|
if (typeof o !== 'object') return;
|
||||||
|
for (const k of Object.keys(o)) {
|
||||||
|
if (CRED_KEYS.has(k) && typeof o[k] === 'string' && o[k]) o[k] = REDACTED;
|
||||||
|
else walk(o[k]);
|
||||||
|
}
|
||||||
|
})(clone);
|
||||||
|
return clone;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectSecretValues(config) {
|
||||||
|
const out = new Set();
|
||||||
|
(function walk(o) {
|
||||||
|
if (!o) return;
|
||||||
|
if (Array.isArray(o)) { for (const e of o) walk(e); return; }
|
||||||
|
if (typeof o !== 'object') return;
|
||||||
|
for (const k of Object.keys(o)) {
|
||||||
|
const v = o[k];
|
||||||
|
if (CRED_KEYS.has(k) && typeof v === 'string' && v.length >= 6) out.add(v);
|
||||||
|
else walk(v);
|
||||||
|
}
|
||||||
|
})(config);
|
||||||
|
return Array.from(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
function redactLogText(text, secrets) {
|
||||||
|
if (typeof text !== 'string' || !text) return text;
|
||||||
|
let out = text;
|
||||||
|
if (Array.isArray(secrets)) {
|
||||||
|
for (const s of secrets) {
|
||||||
|
if (typeof s === 'string' && s.length >= 6) out = out.split(s).join(REDACTED);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = out
|
||||||
|
.replace(/https?:\/\/(?:ptb\.|canary\.)?discord(?:app)?\.com\/api\/webhooks\/\d+\/[\w-]+/gi, 'https://discord.com/api/webhooks/' + REDACTED)
|
||||||
|
.replace(/(\/\/[^\s/:@]+:)[^\s/@]+(@)/g, '$1' + REDACTED + '$2')
|
||||||
|
.replace(/(authorization:\s*(?:bearer|basic)\s+)\S+/gi, '$1' + REDACTED)
|
||||||
|
.replace(/\bbearer\s+[A-Za-z0-9._\-/+]{16,}/gi, 'bearer ' + REDACTED)
|
||||||
|
.replace(/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{6,}/g, REDACTED)
|
||||||
|
.replace(/([?&](?:api_?key|key|token|access_token|password|pass)=)[^\s&"'`]+/gi, '$1' + REDACTED)
|
||||||
|
.replace(/("?\b(?:api[_-]?key|apikey|password|passwd|secret|(?:access|refresh|auth|session)[_-]?token|token|sessionid|session)"?\s*[:=]\s*"?)[A-Za-z0-9._\-/+]{8,}/gi, '$1' + REDACTED)
|
||||||
|
.replace(/(\bset-cookie:|\bcookie:)\s*\S[^\n]*/gi, '$1 ' + REDACTED)
|
||||||
|
.replace(/(\bsess(?:_?id)?\b["'=:\s]+)[A-Za-z0-9._\-]{8,}/gi, '$1' + REDACTED);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function valueScrub(value, secrets) {
|
||||||
|
if (value == null) return value;
|
||||||
|
const json = JSON.stringify(value);
|
||||||
|
let scrubbed = json;
|
||||||
|
if (Array.isArray(secrets)) {
|
||||||
|
for (const s of secrets) {
|
||||||
|
if (typeof s === 'string' && s.length >= 6) scrubbed = scrubbed.split(s).join(REDACTED);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return JSON.parse(scrubbed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectFile(filePath, label, maxBytes) {
|
||||||
|
if (!filePath) return `=== ${label} ===\n<no path configured>\n\n`;
|
||||||
|
let stat;
|
||||||
|
try { stat = fs.statSync(filePath); }
|
||||||
|
catch (err) {
|
||||||
|
if (err && err.code === 'ENOENT') return `=== ${label} (${filePath}) ===\n<file does not exist yet>\n\n`;
|
||||||
|
return `=== ${label} (${filePath}) ===\n<stat error: ${err.message}>\n\n`;
|
||||||
|
}
|
||||||
|
const cap = Number.isFinite(maxBytes) && maxBytes > 0 ? maxBytes : 5 * 1024 * 1024;
|
||||||
|
let content;
|
||||||
|
try {
|
||||||
|
if (stat.size > cap) {
|
||||||
|
const fd = fs.openSync(filePath, 'r');
|
||||||
|
const buf = Buffer.alloc(cap);
|
||||||
|
fs.readSync(fd, buf, 0, cap, stat.size - cap);
|
||||||
|
fs.closeSync(fd);
|
||||||
|
const skipped = stat.size - cap;
|
||||||
|
content = `<truncated: skipped first ${skipped} bytes; showing last ${cap} bytes of ${stat.size}>\n` + buf.toString('utf-8');
|
||||||
|
} else {
|
||||||
|
content = fs.readFileSync(filePath, 'utf-8');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
content = `<read error: ${err.message}>`;
|
||||||
|
}
|
||||||
|
return `=== ${label} (${filePath}, size=${stat.size} bytes) ===\n${content}\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSupportBundleText({ header, sanitizedConfig, files }) {
|
||||||
|
const parts = [];
|
||||||
|
parts.push('=== Multi-Hoster-Upload Support Bundle ===\n');
|
||||||
|
if (header && typeof header === 'object') {
|
||||||
|
for (const [k, v] of Object.entries(header)) parts.push(`${k}: ${v}\n`);
|
||||||
|
}
|
||||||
|
parts.push('\n');
|
||||||
|
parts.push('=== Config (sanitized — password/apiKey/token/cookie/sessionId redacted) ===\n');
|
||||||
|
parts.push(JSON.stringify(sanitizedConfig, null, 2));
|
||||||
|
parts.push('\n\n');
|
||||||
|
for (const f of (files || [])) {
|
||||||
|
parts.push(collectFile(f.path, f.label || f.path, f.maxBytes));
|
||||||
|
}
|
||||||
|
return parts.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { sanitizeConfig, collectSecretValues, redactLogText, valueScrub, collectFile, buildSupportBundleText, CRED_KEYS, REDACTED };
|
||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// Time-windowed memoization. Reuses a previously-computed value if the
|
||||||
|
// signature + input identity match AND the cached entry is younger than
|
||||||
|
// `refreshMs`. Used by the renderer's dynamic-key sort throttle (every
|
||||||
|
// progress tick re-sorts a 5000-row queue → reuse for 200 ms, the user
|
||||||
|
// can't perceive sub-200 ms reorder lag).
|
||||||
|
//
|
||||||
|
// Loaded both as a CommonJS module (Node tests) and as a browser global
|
||||||
|
// (renderer/app.js via index.html script tag) — same single implementation
|
||||||
|
// across runtime and tests.
|
||||||
|
|
||||||
|
(function (root) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a throttled cache. The clock is injected so tests don't have to
|
||||||
|
* sleep — pass `() => fakeClock.value` from tests.
|
||||||
|
*
|
||||||
|
* @param {number} refreshMs cache TTL in milliseconds
|
||||||
|
* @param {() => number} [now] clock source, defaults to Date.now
|
||||||
|
*/
|
||||||
|
function makeThrottledCache(refreshMs, now) {
|
||||||
|
if (!Number.isFinite(refreshMs) || refreshMs < 0) {
|
||||||
|
throw new TypeError('refreshMs must be a non-negative finite number');
|
||||||
|
}
|
||||||
|
const clock = typeof now === 'function' ? now : () => Date.now();
|
||||||
|
let entry = null;
|
||||||
|
return {
|
||||||
|
get(sig, input) {
|
||||||
|
if (!entry) return undefined;
|
||||||
|
if (entry.sig !== sig) return undefined;
|
||||||
|
if (entry.input !== input) return undefined;
|
||||||
|
if (clock() - entry.ts >= refreshMs) return undefined;
|
||||||
|
return entry.value;
|
||||||
|
},
|
||||||
|
set(sig, input, value) {
|
||||||
|
entry = { sig, input, value, ts: clock() };
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
clear() { entry = null; },
|
||||||
|
// Introspection (mainly for tests/debug). Returns null when empty.
|
||||||
|
peek() {
|
||||||
|
if (!entry) return null;
|
||||||
|
return { sig: entry.sig, ts: entry.ts, age: clock() - entry.ts };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = { makeThrottledCache };
|
||||||
|
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||||
|
else if (root) root.ThrottledCache = api;
|
||||||
|
})(typeof window !== 'undefined' ? window : this);
|
||||||
+283
@@ -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 };
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
(function (root) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
function _pad(n) { return String(n).padStart(2, '0'); }
|
||||||
|
|
||||||
|
function formatUploadLogLine(date, hoster, link, fileName) {
|
||||||
|
const d = date instanceof Date ? date : new Date();
|
||||||
|
const dateStr = `${d.getFullYear()}-${_pad(d.getMonth() + 1)}-${_pad(d.getDate())} ` +
|
||||||
|
`${_pad(d.getHours())}:${_pad(d.getMinutes())}:${_pad(d.getSeconds())}`;
|
||||||
|
return `${dateStr}|${hoster}|${link}||${fileName}|\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseUploadLogLine(line) {
|
||||||
|
if (typeof line !== 'string') return null;
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith('#')) return null;
|
||||||
|
const parts = trimmed.split('|');
|
||||||
|
if (parts.length < 5) return null;
|
||||||
|
const hoster = (parts[1] || '').trim();
|
||||||
|
let fileName = '';
|
||||||
|
for (let i = parts.length - 1; i >= 4; i--) {
|
||||||
|
if (parts[i].trim() !== '') { fileName = parts[i]; break; }
|
||||||
|
}
|
||||||
|
if (!hoster || !fileName) return null;
|
||||||
|
const tsStr = (parts[0] || '').trim();
|
||||||
|
const tsParsed = tsStr ? Date.parse(tsStr.replace(' ', 'T')) : NaN;
|
||||||
|
const ts = isNaN(tsParsed) ? undefined : tsParsed;
|
||||||
|
return { hoster, fileName, ts };
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = { formatUploadLogLine, parseUploadLogLine };
|
||||||
|
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||||
|
else if (root) root.UploadLog = api;
|
||||||
|
})(typeof window !== 'undefined' ? window : this);
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||||
@@ -0,0 +1,409 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { request } = require('undici');
|
||||||
|
|
||||||
|
const BASE_URL = 'https://voe.sx';
|
||||||
|
const USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
|
||||||
|
const UPLOAD_TIMEOUT = 1800000; // 30 min
|
||||||
|
const RESULT_POLL_ATTEMPTS = 10;
|
||||||
|
const RESULT_POLL_DELAY_MS = 2000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login-based upload for VOE.sx (Laravel / FilePond)
|
||||||
|
* Fallback when API-based upload fails or is unavailable.
|
||||||
|
*/
|
||||||
|
class VoeUploader {
|
||||||
|
constructor() {
|
||||||
|
this.cookies = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
_cookieHeader() {
|
||||||
|
return Array.from(this.cookies.entries())
|
||||||
|
.map(([k, v]) => `${k}=${v}`)
|
||||||
|
.join('; ');
|
||||||
|
}
|
||||||
|
|
||||||
|
_parseCookiesFromHeaders(headers) {
|
||||||
|
let setCookies;
|
||||||
|
if (typeof headers.getSetCookie === 'function') {
|
||||||
|
setCookies = headers.getSetCookie();
|
||||||
|
} else if (headers['set-cookie']) {
|
||||||
|
setCookies = Array.isArray(headers['set-cookie']) ? headers['set-cookie'] : [headers['set-cookie']];
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const raw of setCookies) {
|
||||||
|
const pair = raw.split(';')[0];
|
||||||
|
const eq = pair.indexOf('=');
|
||||||
|
if (eq > 0) {
|
||||||
|
this.cookies.set(pair.substring(0, eq).trim(), pair.substring(eq + 1).trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET/POST with cookie management and manual redirect following
|
||||||
|
*/
|
||||||
|
async _fetch(url, opts = {}, _redirectCount = 0) {
|
||||||
|
const MAX_REDIRECTS = 10;
|
||||||
|
const headers = {
|
||||||
|
'User-Agent': USER_AGENT,
|
||||||
|
...(opts.headers || {})
|
||||||
|
};
|
||||||
|
if (this.cookies.size > 0) {
|
||||||
|
headers['Cookie'] = this._cookieHeader();
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
...opts,
|
||||||
|
headers,
|
||||||
|
redirect: 'manual'
|
||||||
|
});
|
||||||
|
|
||||||
|
this._parseCookiesFromHeaders(res.headers);
|
||||||
|
|
||||||
|
if ([301, 302, 303, 307, 308].includes(res.status)) {
|
||||||
|
try { await res.text(); } catch {}
|
||||||
|
if (_redirectCount >= MAX_REDIRECTS) {
|
||||||
|
throw new Error('Zu viele Redirects');
|
||||||
|
}
|
||||||
|
const location = res.headers.get('location');
|
||||||
|
if (location) {
|
||||||
|
const nextUrl = new URL(location, url).href;
|
||||||
|
return this._fetch(nextUrl, { ...opts, method: 'GET', body: undefined }, _redirectCount + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract CSRF token from page HTML
|
||||||
|
*/
|
||||||
|
_extractCsrfToken(html) {
|
||||||
|
// Laravel meta tag
|
||||||
|
const metaMatch = html.match(/<meta\s+name=["']csrf-token["']\s+content=["']([^"']+)["']/i);
|
||||||
|
if (metaMatch) return metaMatch[1];
|
||||||
|
|
||||||
|
// Hidden input field
|
||||||
|
const inputMatch = html.match(/<input[^>]*name=["']_token["'][^>]*value=["']([^"']+)["']/i)
|
||||||
|
|| html.match(/<input[^>]*value=["']([^"']+)["'][^>]*name=["']_token["']/i);
|
||||||
|
if (inputMatch) return inputMatch[1];
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login to VOE.sx
|
||||||
|
*/
|
||||||
|
async login(email, password) {
|
||||||
|
// GET login page for cookies + CSRF token
|
||||||
|
const loginPageRes = await this._fetch(`${BASE_URL}/login`);
|
||||||
|
const loginHtml = await loginPageRes.text();
|
||||||
|
|
||||||
|
const csrfToken = this._extractCsrfToken(loginHtml);
|
||||||
|
if (!csrfToken) {
|
||||||
|
throw new Error('VOE Login: CSRF-Token nicht gefunden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST login
|
||||||
|
const loginData = new URLSearchParams({
|
||||||
|
_token: csrfToken,
|
||||||
|
email: email,
|
||||||
|
password: password
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await this._fetch(`${BASE_URL}/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: loginData.toString(),
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'Referer': `${BASE_URL}/login`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = await res.text();
|
||||||
|
|
||||||
|
// Check for login errors
|
||||||
|
if (body.includes('credentials do not match') || body.includes('Incorrect') || body.includes('invalid')) {
|
||||||
|
throw new Error('VOE Login fehlgeschlagen: Falscher Username oder Passwort');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify we have a session
|
||||||
|
const hasSession = this.cookies.has('voe_session') ||
|
||||||
|
this.cookies.has('laravel_session') ||
|
||||||
|
this.cookies.size > 2;
|
||||||
|
|
||||||
|
if (!hasSession) {
|
||||||
|
throw new Error('VOE Login fehlgeschlagen: Keine Session erhalten');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the upload page and extract CSRF token
|
||||||
|
*/
|
||||||
|
async _getUploadParams() {
|
||||||
|
const res = await this._fetch(`${BASE_URL}/file-upload`);
|
||||||
|
const html = await res.text();
|
||||||
|
|
||||||
|
const csrfToken = this._extractCsrfToken(html);
|
||||||
|
if (!csrfToken) {
|
||||||
|
throw new Error('VOE Upload: CSRF-Token nicht gefunden. Bist du eingeloggt?');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { csrfToken };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get upload server URL from /engine/delivery-node
|
||||||
|
* Returns { server: "https://cdn-xxx.edgeon-bandwidth.com/node/u/01", session_id: "..." }
|
||||||
|
*/
|
||||||
|
async _getDeliveryNode(csrfToken) {
|
||||||
|
const res = await this._fetch(`${BASE_URL}/engine/delivery-node`, {
|
||||||
|
headers: {
|
||||||
|
'X-CSRF-TOKEN': csrfToken,
|
||||||
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
|
'Accept': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const body = await res.text();
|
||||||
|
let data;
|
||||||
|
try { data = JSON.parse(body); } catch {
|
||||||
|
throw new Error(`VOE: Upload-Server Antwort war kein JSON: ${body.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data || !data.success || !data.server) {
|
||||||
|
throw new Error('VOE: Kein Upload-Server erhalten von delivery-node');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { uploadServer: data.server, sessionId: data.session_id || '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List current files via VOE API (for result polling fallback)
|
||||||
|
*/
|
||||||
|
async _fetchFileList() {
|
||||||
|
try {
|
||||||
|
const res = await this._fetch(`${BASE_URL}/api2/my-files?sort=date&order=dsc&page=1&per_page=50`);
|
||||||
|
const body = await res.text();
|
||||||
|
const data = JSON.parse(body);
|
||||||
|
if (data && Array.isArray(data.data)) return data.data;
|
||||||
|
if (data && Array.isArray(data.files)) return data.files;
|
||||||
|
return [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _captureFileCodes() {
|
||||||
|
try {
|
||||||
|
const files = await this._fetchFileList();
|
||||||
|
return new Set(files.map(f => String(f.file_code || f.slug || '').trim()).filter(Boolean));
|
||||||
|
} catch {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a file to VOE.sx via login session
|
||||||
|
* Flow: GET delivery-node → POST file to CDN server
|
||||||
|
*/
|
||||||
|
async upload(filePath, onProgress, signal, throttle) {
|
||||||
|
const fileName = path.basename(filePath);
|
||||||
|
const fileSize = fs.statSync(filePath).size;
|
||||||
|
const baselineCodes = await this._captureFileCodes();
|
||||||
|
|
||||||
|
// Step 1: Get CSRF token from upload page
|
||||||
|
const { csrfToken } = await this._getUploadParams();
|
||||||
|
|
||||||
|
// Step 2: Get CDN upload server from delivery-node
|
||||||
|
const { uploadServer, sessionId } = await this._getDeliveryNode(csrfToken);
|
||||||
|
|
||||||
|
const boundary = '----FormBoundary' + crypto.randomBytes(16).toString('hex');
|
||||||
|
|
||||||
|
// Build multipart body
|
||||||
|
let preamble = '';
|
||||||
|
// Include session_id if provided
|
||||||
|
if (sessionId) {
|
||||||
|
preamble += `--${boundary}\r\n`;
|
||||||
|
preamble += `Content-Disposition: form-data; name="session_id"\r\n\r\n`;
|
||||||
|
preamble += `${sessionId}\r\n`;
|
||||||
|
}
|
||||||
|
preamble += `--${boundary}\r\n`;
|
||||||
|
const safeFileName = fileName.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||||
|
preamble += `Content-Disposition: form-data; name="file"; filename="${safeFileName}"\r\n`;
|
||||||
|
preamble += `Content-Type: application/octet-stream\r\n\r\n`;
|
||||||
|
|
||||||
|
const epilogue = `\r\n--${boundary}--\r\n`;
|
||||||
|
|
||||||
|
const preambleBuf = Buffer.from(preamble, 'utf-8');
|
||||||
|
const epilogueBuf = Buffer.from(epilogue, 'utf-8');
|
||||||
|
const totalSize = preambleBuf.length + fileSize + epilogueBuf.length;
|
||||||
|
|
||||||
|
let bytesRead = 0;
|
||||||
|
const CHUNK_SIZE = 1024 * 1024;
|
||||||
|
|
||||||
|
async function* generate() {
|
||||||
|
yield preambleBuf;
|
||||||
|
const fileStream = fs.createReadStream(filePath, { highWaterMark: CHUNK_SIZE });
|
||||||
|
for await (const chunk of fileStream) {
|
||||||
|
if (signal && signal.aborted) throw new Error('Aborted');
|
||||||
|
if (throttle) await throttle.consume(chunk.length, signal);
|
||||||
|
bytesRead += chunk.length;
|
||||||
|
yield chunk;
|
||||||
|
if (onProgress) onProgress(bytesRead, fileSize);
|
||||||
|
}
|
||||||
|
yield epilogueBuf;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: POST file to CDN upload server
|
||||||
|
const { body, headers } = await request(uploadServer, {
|
||||||
|
method: 'POST',
|
||||||
|
body: generate(),
|
||||||
|
signal,
|
||||||
|
headers: {
|
||||||
|
'User-Agent': USER_AGENT,
|
||||||
|
'Cookie': this._cookieHeader(),
|
||||||
|
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
||||||
|
'Content-Length': String(totalSize),
|
||||||
|
'X-CSRF-TOKEN': csrfToken,
|
||||||
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
|
'Referer': `${BASE_URL}/file-upload`,
|
||||||
|
'Origin': BASE_URL
|
||||||
|
},
|
||||||
|
headersTimeout: UPLOAD_TIMEOUT,
|
||||||
|
bodyTimeout: UPLOAD_TIMEOUT
|
||||||
|
});
|
||||||
|
|
||||||
|
this._parseCookiesFromHeaders(headers || {});
|
||||||
|
|
||||||
|
const rawBody = await body.text();
|
||||||
|
|
||||||
|
// Try JSON response
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(rawBody);
|
||||||
|
|
||||||
|
// Direct file_code in response
|
||||||
|
const fileCode = json.file_code || json.filecode || json.slug ||
|
||||||
|
(json.file && (json.file.file_code || json.file.slug)) ||
|
||||||
|
(json.data && (json.data.file_code || json.data.slug));
|
||||||
|
|
||||||
|
if (fileCode) {
|
||||||
|
return this._buildUrls(fileCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for error
|
||||||
|
if (json.error || json.message) {
|
||||||
|
throw new Error(`VOE Upload-Fehler: ${json.error || json.message}`);
|
||||||
|
}
|
||||||
|
} catch (parseErr) {
|
||||||
|
if (parseErr.message.startsWith('VOE Upload-Fehler')) throw parseErr;
|
||||||
|
// Not JSON - might be a redirect or HTML response
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: poll the file list to find the newly uploaded file
|
||||||
|
const result = await this._resolveUploadedFile(fileName, baselineCodes, signal);
|
||||||
|
if (result) return result;
|
||||||
|
|
||||||
|
throw new Error('VOE Upload: Kein file_code in der Antwort gefunden');
|
||||||
|
}
|
||||||
|
|
||||||
|
async _resolveUploadedFile(fileName, baselineCodes, signal) {
|
||||||
|
const expectedTitle = this._normalizeTitle(path.parse(fileName).name);
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < RESULT_POLL_ATTEMPTS; attempt++) {
|
||||||
|
if (signal && signal.aborted) {
|
||||||
|
const err = new Error('Aborted');
|
||||||
|
err.name = 'AbortError';
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
let files = [];
|
||||||
|
try {
|
||||||
|
files = await this._fetchFileList();
|
||||||
|
} catch { files = []; }
|
||||||
|
|
||||||
|
const withCode = files.filter(f => f && (f.file_code || f.slug));
|
||||||
|
const newFiles = withCode.filter(f => !baselineCodes.has(String(f.file_code || f.slug || '').trim()));
|
||||||
|
|
||||||
|
if (newFiles.length > 0) {
|
||||||
|
// Try to match by title
|
||||||
|
let best = null;
|
||||||
|
let bestScore = -1;
|
||||||
|
|
||||||
|
for (const file of newFiles) {
|
||||||
|
const score = this._scoreCandidate(file, expectedTitle);
|
||||||
|
if (score > bestScore) {
|
||||||
|
bestScore = score;
|
||||||
|
best = file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (best && (bestScore > 0 || newFiles.length === 1)) {
|
||||||
|
const code = best.file_code || best.slug;
|
||||||
|
return this._buildUrls(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt < RESULT_POLL_ATTEMPTS - 1) {
|
||||||
|
await this._sleep(RESULT_POLL_DELAY_MS, signal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_normalizeTitle(value) {
|
||||||
|
return String(value || '')
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize('NFKD')
|
||||||
|
.replace(/[^a-z0-9]+/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
_scoreCandidate(file, expectedTitle) {
|
||||||
|
if (!file || !(file.file_code || file.slug)) return -1;
|
||||||
|
if (!expectedTitle) return 0;
|
||||||
|
|
||||||
|
const title = this._normalizeTitle(file.title || file.name || '');
|
||||||
|
if (!title) return -1;
|
||||||
|
if (title === expectedTitle) return 120;
|
||||||
|
if (title.startsWith(expectedTitle) || expectedTitle.startsWith(title)) return 90;
|
||||||
|
if (title.includes(expectedTitle) || expectedTitle.includes(title)) return 70;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildUrls(fileCode) {
|
||||||
|
const code = String(fileCode || '').trim();
|
||||||
|
if (!code) return null;
|
||||||
|
return {
|
||||||
|
download_url: `${BASE_URL}/${code}`,
|
||||||
|
embed_url: `${BASE_URL}/e/${code}`,
|
||||||
|
file_code: code
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
_sleep(ms, signal) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (signal) signal.removeEventListener('abort', onAbort);
|
||||||
|
resolve();
|
||||||
|
}, ms);
|
||||||
|
|
||||||
|
function onAbort() {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (signal) signal.removeEventListener('abort', onAbort);
|
||||||
|
const err = new Error('Aborted');
|
||||||
|
err.name = 'AbortError';
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signal) {
|
||||||
|
if (signal.aborted) return onAbort();
|
||||||
|
signal.addEventListener('abort', onAbort);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = VoeUploader;
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
function isDiscordWebhook(url) {
|
||||||
|
return /^https?:\/\/(ptb\.|canary\.)?(discord(app)?\.com)\/api\/webhooks\/\d+\/[\w-]+/i.test(String(url || ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
const DISCORD_CONTENT_LIMIT = 1900;
|
||||||
|
|
||||||
|
function clampDiscordContent(text) {
|
||||||
|
const s = String(text || '');
|
||||||
|
if (s.length <= DISCORD_CONTENT_LIMIT) return s;
|
||||||
|
return s.slice(0, DISCORD_CONTENT_LIMIT - 1) + '…';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDurationShort(sec) {
|
||||||
|
const s = Math.max(0, Math.round(Number(sec) || 0));
|
||||||
|
const h = Math.floor(s / 3600);
|
||||||
|
const m = Math.floor((s % 3600) / 60);
|
||||||
|
const r = s % 60;
|
||||||
|
if (h > 0) return `${h}h ${m}m`;
|
||||||
|
if (m > 0) return `${m}m ${r}s`;
|
||||||
|
return `${r}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizePerHosterFromBatch(summary) {
|
||||||
|
const out = {};
|
||||||
|
if (!summary || !Array.isArray(summary.files)) return out;
|
||||||
|
for (const f of summary.files) {
|
||||||
|
if (!f || !Array.isArray(f.results)) continue;
|
||||||
|
for (const r of f.results) {
|
||||||
|
if (!r || !r.hoster) continue;
|
||||||
|
const b = out[r.hoster] || (out[r.hoster] = { ok: 0, fail: 0 });
|
||||||
|
if (r.status === 'done') b.ok++;
|
||||||
|
else b.fail++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDiscordMention(raw) {
|
||||||
|
const s = String(raw || '').trim();
|
||||||
|
if (!s) return null;
|
||||||
|
const keyword = s.replace(/^@/, '').toLowerCase();
|
||||||
|
if (keyword === 'here' || keyword === 'everyone') {
|
||||||
|
return { token: `@${keyword}`, allowed: { parse: ['everyone'] } };
|
||||||
|
}
|
||||||
|
const roleMatch = s.match(/^(?:<@&(\d+)>|role:(\d+))$/i);
|
||||||
|
if (roleMatch) {
|
||||||
|
const id = roleMatch[1] || roleMatch[2];
|
||||||
|
return { token: `<@&${id}>`, allowed: { roles: [id] } };
|
||||||
|
}
|
||||||
|
const userMatch = s.match(/^(?:<@!?(\d+)>|user:(\d+)|(\d{5,30}))$/i);
|
||||||
|
if (userMatch) {
|
||||||
|
const id = userMatch[1] || userMatch[2] || userMatch[3];
|
||||||
|
return { token: `<@${id}>`, allowed: { users: [id] } };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWebhookRequest(url, summary, meta) {
|
||||||
|
const m = meta || {};
|
||||||
|
const total = Number(summary && summary.total) || 0;
|
||||||
|
const succeeded = Number(summary && summary.succeeded) || 0;
|
||||||
|
const failed = Number(summary && summary.failed) || 0;
|
||||||
|
const perHoster = summarizePerHosterFromBatch(summary);
|
||||||
|
const duration = formatDurationShort(m.durationSec);
|
||||||
|
|
||||||
|
let body;
|
||||||
|
if (isDiscordWebhook(url)) {
|
||||||
|
const headline = m.aborted ? 'Batch abgebrochen' : 'Batch fertig';
|
||||||
|
const hosterEntries = Object.entries(perHoster);
|
||||||
|
const MAX_HOSTER_LINES = 12;
|
||||||
|
let hosterLines = hosterEntries.slice(0, MAX_HOSTER_LINES)
|
||||||
|
.map(([h, b]) => `${h}: ${b.ok}/${b.ok + b.fail}`)
|
||||||
|
.join(' · ');
|
||||||
|
if (hosterEntries.length > MAX_HOSTER_LINES) hosterLines += ` · …+${hosterEntries.length - MAX_HOSTER_LINES}`;
|
||||||
|
const lines = [
|
||||||
|
`**Multi-Hoster-Upload — ${headline}**${m.machineName ? ` (${m.machineName})` : ''}`,
|
||||||
|
`✅ ${succeeded} ok · ❌ ${failed} Fehler · 📦 ${total} gesamt · ⏱ ${duration}`
|
||||||
|
];
|
||||||
|
if (hosterLines) lines.push(hosterLines);
|
||||||
|
const mention = resolveDiscordMention(m.mention);
|
||||||
|
const content = clampDiscordContent((mention ? mention.token + ' ' : '') + lines.join('\n'));
|
||||||
|
const payload = { content };
|
||||||
|
payload.allowed_mentions = mention ? mention.allowed : { parse: [] };
|
||||||
|
body = JSON.stringify(payload);
|
||||||
|
} else {
|
||||||
|
body = JSON.stringify({
|
||||||
|
event: 'batch-done',
|
||||||
|
app: 'multi-hoster-upload',
|
||||||
|
version: m.appVersion || null,
|
||||||
|
machine: m.machineName || null,
|
||||||
|
total,
|
||||||
|
succeeded,
|
||||||
|
failed,
|
||||||
|
durationSec: Math.round(Number(m.durationSec) || 0),
|
||||||
|
aborted: !!m.aborted,
|
||||||
|
perHoster,
|
||||||
|
timestamp: m.timestamp || null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: String(url),
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAllAborted(summary) {
|
||||||
|
if (!summary || !Array.isArray(summary.files) || summary.files.length === 0) return false;
|
||||||
|
let sawResult = false;
|
||||||
|
for (const f of summary.files) {
|
||||||
|
if (!f || !Array.isArray(f.results)) continue;
|
||||||
|
for (const r of f.results) {
|
||||||
|
if (!r) continue;
|
||||||
|
sawResult = true;
|
||||||
|
if (r.status !== 'aborted') return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sawResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { isDiscordWebhook, formatDurationShort, summarizePerHosterFromBatch, buildWebhookRequest, resolveDiscordMention, isAllAborted, clampDiscordContent, DISCORD_CONTENT_LIMIT };
|
||||||
Generated
+4948
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"name": "multi-hoster-uploader",
|
||||||
|
"version": "3.3.108",
|
||||||
|
"description": "Upload files to doodstream, voe, vidmoly, byse simultaneously",
|
||||||
|
"main": "main.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "electron .",
|
||||||
|
"test": "node --test tests/*.test.js tests/ui-smoke.js",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"dist": "electron-builder --win",
|
||||||
|
"release:win": "electron-builder --publish never --win nsis portable"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"chokidar": "^3.6.0",
|
||||||
|
"undici": "^7.28.0",
|
||||||
|
"ws": "^8.21.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"electron": "^41.3.0",
|
||||||
|
"electron-builder": "^26.8.1",
|
||||||
|
"eslint": "^10.1.0",
|
||||||
|
"eslint-plugin-security": "^4.0.0",
|
||||||
|
"rcedit": "^4.0.1"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"appId": "com.multihoster.uploader",
|
||||||
|
"productName": "Multi-Hoster-Upload",
|
||||||
|
"directories": {
|
||||||
|
"buildResources": "assets",
|
||||||
|
"output": "release"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"main.js",
|
||||||
|
"preload.js",
|
||||||
|
"preload-drop-target.js",
|
||||||
|
"lib/**/*",
|
||||||
|
"renderer/**/*",
|
||||||
|
"assets/app_icon.ico",
|
||||||
|
"assets/app_icon.png"
|
||||||
|
],
|
||||||
|
"win": {
|
||||||
|
"target": [
|
||||||
|
"nsis",
|
||||||
|
"portable"
|
||||||
|
],
|
||||||
|
"icon": "assets/app_icon.ico",
|
||||||
|
"signAndEditExecutable": false
|
||||||
|
},
|
||||||
|
"nsis": {
|
||||||
|
"oneClick": false,
|
||||||
|
"perMachine": false,
|
||||||
|
"allowToChangeInstallationDirectory": true,
|
||||||
|
"createDesktopShortcut": true
|
||||||
|
},
|
||||||
|
"afterPack": "scripts/afterPack.cjs"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
const { contextBridge, ipcRenderer } = require('electron');
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('dropTargetApi', {
|
||||||
|
sendFiles: (paths) => ipcRenderer.send('drop-target:files', paths)
|
||||||
|
});
|
||||||
+162
@@ -0,0 +1,162 @@
|
|||||||
|
const { contextBridge, ipcRenderer, webUtils } = require('electron');
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('api', {
|
||||||
|
// Config
|
||||||
|
getConfig: () => ipcRenderer.invoke('get-config'),
|
||||||
|
saveConfig: (config) => ipcRenderer.invoke('save-config', config),
|
||||||
|
getHistory: () => ipcRenderer.invoke('get-history'),
|
||||||
|
clearHistory: () => ipcRenderer.invoke('clear-history'),
|
||||||
|
pruneHistory: (retention, opts) => ipcRenderer.invoke('prune-history', { retention, dryRun: !!(opts && opts.dryRun) }),
|
||||||
|
exportHistory: (format) => ipcRenderer.invoke('export-history', format),
|
||||||
|
saveTextFile: (defaultName, content, filters) => ipcRenderer.invoke('save-text-file', defaultName, content, filters),
|
||||||
|
|
||||||
|
// Hoster settings
|
||||||
|
getHosterSettings: () => ipcRenderer.invoke('get-hoster-settings'),
|
||||||
|
saveHosterSettings: (settings) => ipcRenderer.invoke('save-hoster-settings', settings),
|
||||||
|
|
||||||
|
// Global settings
|
||||||
|
getGlobalSettings: () => ipcRenderer.invoke('get-global-settings'),
|
||||||
|
saveGlobalSettings: (settings) => ipcRenderer.invoke('save-global-settings', settings),
|
||||||
|
saveGlobalSettingsSync: (settings) => ipcRenderer.sendSync('save-global-settings-sync', settings),
|
||||||
|
|
||||||
|
// Always on top
|
||||||
|
setAlwaysOnTop: (value) => ipcRenderer.invoke('set-always-on-top', value),
|
||||||
|
getAlwaysOnTop: () => ipcRenderer.invoke('get-always-on-top'),
|
||||||
|
|
||||||
|
// Shutdown after finish
|
||||||
|
setShutdownAfterFinish: (mode) => ipcRenderer.invoke('set-shutdown-after-finish', mode),
|
||||||
|
getShutdownAfterFinish: () => ipcRenderer.invoke('get-shutdown-after-finish'),
|
||||||
|
cancelShutdown: () => ipcRenderer.invoke('cancel-shutdown'),
|
||||||
|
|
||||||
|
// File selection
|
||||||
|
selectFiles: () => ipcRenderer.invoke('select-files'),
|
||||||
|
selectFolder: () => ipcRenderer.invoke('select-folder'),
|
||||||
|
selectFolderWithSizes: () => ipcRenderer.invoke('select-folder-with-sizes'),
|
||||||
|
resolveFolderFiles: (folderPath) => ipcRenderer.invoke('resolve-folder-files', folderPath),
|
||||||
|
getFileSizes: (paths) => ipcRenderer.invoke('get-file-sizes', paths),
|
||||||
|
|
||||||
|
// Upload control
|
||||||
|
startUpload: (payload) => ipcRenderer.invoke('start-upload', payload),
|
||||||
|
cancelUpload: () => ipcRenderer.invoke('cancel-upload'),
|
||||||
|
cancelSelectedJobs: (jobIds) => ipcRenderer.invoke('cancel-selected-jobs', jobIds),
|
||||||
|
addJobsToBatch: (payload) => ipcRenderer.invoke('add-jobs-to-batch', payload),
|
||||||
|
finishAfterActive: () => ipcRenderer.invoke('finish-after-active'),
|
||||||
|
runHealthCheck: (payload) => ipcRenderer.invoke('run-health-check', payload),
|
||||||
|
validateCredentials: (payload) => ipcRenderer.invoke('validate-credentials', payload),
|
||||||
|
|
||||||
|
// Log import
|
||||||
|
readOwnUploadLog: () => ipcRenderer.invoke('read-own-upload-log'),
|
||||||
|
importUploadLog: () => ipcRenderer.invoke('import-upload-log'),
|
||||||
|
|
||||||
|
// Clipboard
|
||||||
|
copyToClipboard: (text) => ipcRenderer.invoke('copy-to-clipboard', text),
|
||||||
|
|
||||||
|
// Updates
|
||||||
|
checkForUpdate: () => ipcRenderer.invoke('app:check-updates'),
|
||||||
|
installUpdate: () => ipcRenderer.invoke('app:install-update'),
|
||||||
|
abortUpdate: () => ipcRenderer.invoke('app:abort-update'),
|
||||||
|
getVersion: () => ipcRenderer.invoke('app:get-version'),
|
||||||
|
restartApp: () => ipcRenderer.invoke('app:restart'),
|
||||||
|
quitApp: () => ipcRenderer.invoke('app:quit'),
|
||||||
|
onUpdateAvailable: (callback) => {
|
||||||
|
ipcRenderer.on('app:update-available', (_event, data) => callback(data));
|
||||||
|
},
|
||||||
|
onUpdateProgress: (callback) => {
|
||||||
|
ipcRenderer.on('app:update-progress', (_event, data) => callback(data));
|
||||||
|
},
|
||||||
|
|
||||||
|
// Backup
|
||||||
|
exportBackup: () => ipcRenderer.invoke('export-backup'),
|
||||||
|
importBackup: (legacyPassword) => ipcRenderer.invoke('import-backup', legacyPassword),
|
||||||
|
|
||||||
|
// Folder Monitor
|
||||||
|
folderMonitorStart: (settings) => ipcRenderer.invoke('folder-monitor:start', settings),
|
||||||
|
folderMonitorStop: () => ipcRenderer.invoke('folder-monitor:stop'),
|
||||||
|
folderMonitorStatus: () => ipcRenderer.invoke('folder-monitor:status'),
|
||||||
|
folderMonitorSelectFolder: () => ipcRenderer.invoke('folder-monitor:select-folder'),
|
||||||
|
onFolderMonitorNewFiles: (callback) => {
|
||||||
|
ipcRenderer.on('folder-monitor:new-files', (_event, data) => callback(data));
|
||||||
|
},
|
||||||
|
|
||||||
|
// Account switched event
|
||||||
|
onAccountSwitched: (callback) => {
|
||||||
|
ipcRenderer.on('account-switched', (_event, data) => callback(data));
|
||||||
|
},
|
||||||
|
|
||||||
|
// Drop Target
|
||||||
|
showDropTarget: () => ipcRenderer.invoke('show-drop-target'),
|
||||||
|
hideDropTarget: () => ipcRenderer.invoke('hide-drop-target'),
|
||||||
|
onDropTargetFiles: (callback) => {
|
||||||
|
ipcRenderer.on('drop-target:files', (_event, paths) => callback(paths));
|
||||||
|
},
|
||||||
|
|
||||||
|
// Debug
|
||||||
|
debugTestUpload: () => ipcRenderer.invoke('debug-test-upload'),
|
||||||
|
debugLog: (msg) => ipcRenderer.invoke('debug-log', msg),
|
||||||
|
|
||||||
|
// Events (main -> renderer)
|
||||||
|
onUploadProgress: (callback) => {
|
||||||
|
ipcRenderer.on('upload-progress', (_event, data) => callback(data));
|
||||||
|
},
|
||||||
|
onUploadProgressBatch: (callback) => {
|
||||||
|
ipcRenderer.on('upload-progress-batch', (_event, batch) => callback(batch));
|
||||||
|
},
|
||||||
|
onUploadBatchDone: (callback) => {
|
||||||
|
ipcRenderer.on('upload-batch-done', (_event, data) => callback(data));
|
||||||
|
},
|
||||||
|
onUploadStats: (callback) => {
|
||||||
|
ipcRenderer.on('upload-stats', (_event, data) => callback(data));
|
||||||
|
},
|
||||||
|
onShutdownCountdown: (callback) => {
|
||||||
|
ipcRenderer.on('shutdown-countdown', (_event, data) => callback(data));
|
||||||
|
},
|
||||||
|
onUploadLogFallback: (callback) => {
|
||||||
|
ipcRenderer.on('upload-log-fallback', (_event, data) => callback(data));
|
||||||
|
},
|
||||||
|
onAccountRotationLog: (callback) => {
|
||||||
|
ipcRenderer.on('account-rotation-log', (_event, data) => callback(data));
|
||||||
|
},
|
||||||
|
openLogFolder: () => ipcRenderer.invoke('open-log-folder'),
|
||||||
|
getJobLog: (jobId) => ipcRenderer.invoke('get-job-log', jobId),
|
||||||
|
getSessionFailedAccounts: () => ipcRenderer.invoke('get-session-failed-accounts'),
|
||||||
|
resetSessionFailedAccount: (payload) => ipcRenderer.invoke('reset-session-failed-account', payload),
|
||||||
|
resetAllSessionFailedAccounts: () => ipcRenderer.invoke('reset-all-session-failed-accounts'),
|
||||||
|
getLogPaths: () => ipcRenderer.invoke('get-log-paths'),
|
||||||
|
testWebhook: (payload) => ipcRenderer.invoke('test-webhook', payload),
|
||||||
|
revealLogFile: (target) => ipcRenderer.invoke('reveal-log-file', target),
|
||||||
|
setLogVerbose: (enabled) => ipcRenderer.invoke('set-log-verbose', enabled),
|
||||||
|
createSupportBundle: () => ipcRenderer.invoke('create-support-bundle'),
|
||||||
|
getAppInfo: () => ipcRenderer.invoke('get-app-info'),
|
||||||
|
onLogPathAutoUpdated: (callback) => {
|
||||||
|
ipcRenderer.on('log-path-auto-updated', (_event, data) => callback(data));
|
||||||
|
},
|
||||||
|
// Remote Control
|
||||||
|
remoteGetSettings: () => ipcRenderer.invoke('remote:get-settings'),
|
||||||
|
remoteSaveSettings: (settings) => ipcRenderer.invoke('remote:save-settings', settings),
|
||||||
|
remoteGenerateToken: () => ipcRenderer.invoke('remote:generate-token'),
|
||||||
|
remoteStatus: () => ipcRenderer.invoke('remote:status'),
|
||||||
|
onRemoteClientCount: (callback) => {
|
||||||
|
ipcRenderer.on('remote:client-count', (_event, count) => callback(count));
|
||||||
|
},
|
||||||
|
|
||||||
|
// Remote Diagnostics (read-only)
|
||||||
|
diagnosticsGetSettings: () => ipcRenderer.invoke('diagnostics:get-settings'),
|
||||||
|
diagnosticsSaveSettings: (settings) => ipcRenderer.invoke('diagnostics:save-settings', settings),
|
||||||
|
diagnosticsRegenerate: () => ipcRenderer.invoke('diagnostics:regenerate'),
|
||||||
|
diagnosticsStatus: () => ipcRenderer.invoke('diagnostics:status'),
|
||||||
|
|
||||||
|
// File path from drag & drop (Electron 33+ compatible)
|
||||||
|
getPathForFile: (file) => webUtils.getPathForFile(file),
|
||||||
|
removeAllListeners: () => {
|
||||||
|
ipcRenderer.removeAllListeners('upload-progress');
|
||||||
|
ipcRenderer.removeAllListeners('upload-batch-done');
|
||||||
|
ipcRenderer.removeAllListeners('upload-stats');
|
||||||
|
ipcRenderer.removeAllListeners('app:update-available');
|
||||||
|
ipcRenderer.removeAllListeners('app:update-progress');
|
||||||
|
ipcRenderer.removeAllListeners('shutdown-countdown');
|
||||||
|
ipcRenderer.removeAllListeners('folder-monitor:new-files');
|
||||||
|
ipcRenderer.removeAllListeners('drop-target:files');
|
||||||
|
ipcRenderer.removeAllListeners('account-switched');
|
||||||
|
ipcRenderer.removeAllListeners('remote:client-count');
|
||||||
|
}
|
||||||
|
});
|
||||||
+5640
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,68 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
html, body {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: transparent;
|
||||||
|
-webkit-app-region: drag;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.target {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 2px dashed rgba(126, 220, 255, 0.5);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(22, 24, 28, 0.85);
|
||||||
|
transition: border-color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
.target.drag-over {
|
||||||
|
border-color: rgba(126, 220, 255, 0.9);
|
||||||
|
background: rgba(62, 167, 255, 0.15);
|
||||||
|
}
|
||||||
|
.icon {
|
||||||
|
font-size: 64px;
|
||||||
|
font-weight: 200;
|
||||||
|
color: rgba(126, 220, 255, 0.7);
|
||||||
|
line-height: 1;
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="target" id="target">
|
||||||
|
<div class="icon">+</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
const target = document.getElementById('target');
|
||||||
|
target.addEventListener('dragover', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
target.classList.add('drag-over');
|
||||||
|
});
|
||||||
|
target.addEventListener('dragleave', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
target.classList.remove('drag-over');
|
||||||
|
});
|
||||||
|
target.addEventListener('drop', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
target.classList.remove('drag-over');
|
||||||
|
const paths = [];
|
||||||
|
for (const file of e.dataTransfer.files) {
|
||||||
|
if (file.path) paths.push(file.path);
|
||||||
|
}
|
||||||
|
if (paths.length > 0) {
|
||||||
|
window.dropTargetApi.sendFiles(paths);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,426 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline';">
|
||||||
|
<title>Multi-Hoster-Upload</title>
|
||||||
|
<link rel="stylesheet" href="styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="menu-bar" id="menuBar">
|
||||||
|
<div class="menu-bar-item" data-menu="datei">
|
||||||
|
<button class="menu-bar-trigger" data-menu-trigger="datei">Datei</button>
|
||||||
|
<div class="menu-dropdown" data-menu-dropdown="datei" style="display:none">
|
||||||
|
<button class="menu-dropdown-item" data-menu-action="add-files"><span>Dateien hinzufügen</span></button>
|
||||||
|
<button class="menu-dropdown-item" data-menu-action="add-folder"><span>Ordner hinzufügen</span></button>
|
||||||
|
<div class="menu-separator"></div>
|
||||||
|
<div class="menu-submenu" data-submenu="sicherung">
|
||||||
|
<button class="menu-submenu-trigger">Sicherung</button>
|
||||||
|
<div class="menu-submenu-dropdown" style="display:none">
|
||||||
|
<button class="menu-dropdown-item" data-menu-action="backup-export"><span>Exportieren</span></button>
|
||||||
|
<button class="menu-dropdown-item" data-menu-action="backup-import"><span>Importieren</span></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="menu-separator"></div>
|
||||||
|
<button class="menu-dropdown-item" data-menu-action="restart"><span>Neustart</span></button>
|
||||||
|
<button class="menu-dropdown-item" data-menu-action="quit"><span>Beenden</span></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="menu-bar-item" data-menu="einstellungen">
|
||||||
|
<button class="menu-bar-trigger" data-menu-trigger="einstellungen">Einstellungen</button>
|
||||||
|
<div class="menu-dropdown" data-menu-dropdown="einstellungen" style="display:none">
|
||||||
|
<button class="menu-dropdown-item" data-menu-action="open-settings"><span>Einstellungen öffnen</span></button>
|
||||||
|
<div class="menu-separator"></div>
|
||||||
|
<div class="menu-settings-grid" id="menuSettingsGrid">
|
||||||
|
<span>Max. parallele Uploads</span>
|
||||||
|
<span></span>
|
||||||
|
<div class="menu-spinner">
|
||||||
|
<input type="text" inputmode="numeric" id="menuParallelInput">
|
||||||
|
<div class="menu-spinner-arrows">
|
||||||
|
<button data-spin="parallel-up">▲</button>
|
||||||
|
<button data-spin="parallel-down">▼</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span></span>
|
||||||
|
<span>Geschwindigkeitslimit</span>
|
||||||
|
<input type="checkbox" id="menuSpeedLimitCheck">
|
||||||
|
<div class="menu-spinner" id="menuSpeedSpinner">
|
||||||
|
<input type="text" inputmode="decimal" id="menuSpeedInput">
|
||||||
|
<div class="menu-spinner-arrows">
|
||||||
|
<button data-spin="speed-up">▲</button>
|
||||||
|
<button data-spin="speed-down">▼</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="menu-speed-unit">MB/s</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="menu-bar-item" data-menu="hilfe">
|
||||||
|
<button class="menu-bar-trigger" data-menu-trigger="hilfe">Hilfe</button>
|
||||||
|
<div class="menu-dropdown" data-menu-dropdown="hilfe" style="display:none">
|
||||||
|
<button class="menu-dropdown-item" data-menu-action="open-log-folder"><span>Log-Ordner öffnen</span></button>
|
||||||
|
<button class="menu-dropdown-item" data-menu-action="support-bundle"><span>Diagnose-Paket exportieren</span></button>
|
||||||
|
<div class="menu-separator"></div>
|
||||||
|
<button class="menu-dropdown-item" data-menu-action="check-updates"><span>Suche Aktualisierungen</span></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<nav class="tab-bar">
|
||||||
|
<button class="tab active" data-view="upload">Upload</button>
|
||||||
|
<button class="tab" data-view="accounts">Accounts</button>
|
||||||
|
<button class="tab" data-view="settings">Einstellungen</button>
|
||||||
|
<button class="tab" data-view="history">Verlauf</button>
|
||||||
|
<span class="version-label" id="versionLabel"></span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div id="updateBanner" class="update-banner" style="display:none">
|
||||||
|
<span id="updateMessage"></span>
|
||||||
|
<button class="btn btn-sm btn-primary" id="installUpdateBtn">Update installieren</button>
|
||||||
|
<button class="btn btn-sm btn-secondary" id="dismissUpdateBtn">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="upload-view" class="view active">
|
||||||
|
<div class="upload-toolbar">
|
||||||
|
<div class="toolbar-left">
|
||||||
|
<span class="hoster-summary" id="hosterSummary" style="display:none"></span>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-right">
|
||||||
|
<button class="btn btn-xs btn-primary" id="addFilesBtn">+ Dateien</button>
|
||||||
|
<button class="btn btn-xs btn-secondary" id="addFolderBtn">+ Ordner</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="upload-workspace">
|
||||||
|
<div class="drop-zone" id="dropZone">
|
||||||
|
<div class="drop-icon">📁</div>
|
||||||
|
<p>Dateien hierher ziehen oder klicken</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="queue-shell" id="queueShell" style="display:none">
|
||||||
|
<div class="queue-command-bar" id="queueCommandBar">
|
||||||
|
<button class="toolbar-btn" id="startUploadBtn" title="Start all" disabled>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16"><path d="M4 2l10 6-10 6z" fill="#4caf50"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="toolbar-btn" id="startSelectedBtn" title="Start selected" disabled>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16"><path d="M6 3l8 5-8 5z" fill="#4caf50"/><rect x="1" y="3" width="3" height="10" rx="0.5" fill="#4caf50"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="toolbar-btn" id="reuploadSelectedBtn" title="Reupload selected file">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16"><path d="M8 1a7 7 0 0 0-5 2.1V1H2v4h4V4H3.7A5.5 5.5 0 1 1 2.5 8H1a7 7 0 1 0 7-7z" fill="#4caf50"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="toolbar-btn" id="abortSelectedBtn" title="Abort selected file">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16"><rect x="3" y="3" width="10" height="10" rx="1" fill="#e53935"/><path d="M5.5 5.5l5 5M10.5 5.5l-5 5" stroke="#fff" stroke-width="1.5" stroke-linecap="round"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="toolbar-btn" id="finishStopBtn" title="Finish Uploads in Progress and Stop">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16"><path d="M2 8l4 4 8-8" stroke="#4caf50" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/><rect x="11" y="9" width="5" height="5" rx="1" fill="#e53935"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="toolbar-btn toolbar-btn-danger" id="abortAllBtn" title="Abort all Downloads">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="2" fill="#e53935"/><path d="M4.5 4.5l7 7M11.5 4.5l-7 7" stroke="#fff" stroke-width="2" stroke-linecap="round"/></svg>
|
||||||
|
</button>
|
||||||
|
<span class="toolbar-sep"></span>
|
||||||
|
<button class="toolbar-btn" id="moveTopBtn" title="Move to the top">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16"><rect x="4" y="1" width="8" height="2" rx="0.5" fill="#4caf50"/><path d="M8 5l-4 5h8z" fill="#4caf50"/><path d="M8 9l-4 5h8z" fill="#4caf50"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="toolbar-btn" id="moveUpBtn" title="Move up">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16"><path d="M8 2l-5 6h10z" fill="#4caf50"/><rect x="6" y="8" width="4" height="6" rx="0.5" fill="#4caf50"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="toolbar-btn" id="moveDownBtn" title="Move down">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16"><rect x="6" y="2" width="4" height="6" rx="0.5" fill="#4caf50"/><path d="M8 14l-5-6h10z" fill="#4caf50"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="toolbar-btn" id="moveBottomBtn" title="Move to the bottom">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16"><path d="M8 7l-4-5h8z" fill="#4caf50"/><path d="M8 11l-4-5h8z" fill="#4caf50"/><rect x="4" y="13" width="8" height="2" rx="0.5" fill="#4caf50"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="queue-container" id="queueContainer">
|
||||||
|
<table class="queue-table" id="queueTable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="col-filename sortable" data-col="filename" data-sort="filename">Filename<span class="col-resizer"></span></th>
|
||||||
|
<th class="col-size sortable" data-col="size" data-sort="size">Uploaded / Size<span class="col-resizer"></span></th>
|
||||||
|
<th class="col-host sortable" data-col="host" data-sort="host">Host<span class="col-resizer"></span></th>
|
||||||
|
<th class="col-status sortable" data-col="status" data-sort="status">Status<span class="col-resizer"></span></th>
|
||||||
|
<th class="col-elapsed" data-col="elapsed">Zeit<span class="col-resizer"></span></th>
|
||||||
|
<th class="col-remaining" data-col="remaining">Rest<span class="col-resizer"></span></th>
|
||||||
|
<th class="col-speed sortable" data-col="speed" data-sort="speed">Speed<span class="col-resizer"></span></th>
|
||||||
|
<th class="col-progress sortable" data-col="progress" data-sort="progress">Progress</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="queueBody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="queue-actions" id="queueActions" style="display:none">
|
||||||
|
<button class="btn btn-xs btn-primary" id="copyAllLinksBtn">Alle Links kopieren</button>
|
||||||
|
<select class="hs-input" id="linkExportFormat" title="Ausgabe-Format der kopierten Links" style="max-width:none;width:auto;min-width:130px">
|
||||||
|
<option value="plain">Plaintext</option>
|
||||||
|
<option value="bbcode">BBCode</option>
|
||||||
|
<option value="markdown">Markdown</option>
|
||||||
|
<option value="html">HTML</option>
|
||||||
|
<option value="csv">CSV</option>
|
||||||
|
<option value="json">JSON</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-xs btn-secondary" id="retryFailedBtn" style="display:none">Fehlgeschlagene erneut</button>
|
||||||
|
<button class="btn btn-xs btn-secondary" id="importLogBtn" title="Log importieren — bereits hochgeladene aus Queue entfernen">Log importieren</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="resize-handle" id="recentFilesResizer"></div>
|
||||||
|
<div class="recent-files-panel" id="recentFilesPanel">
|
||||||
|
<div class="recent-files-header">
|
||||||
|
<div class="recent-tabs">
|
||||||
|
<button class="recent-tab active" data-panel="filesTab">Files</button>
|
||||||
|
<button class="recent-tab" data-panel="statsTab">Stats</button>
|
||||||
|
</div>
|
||||||
|
<span class="recent-files-hint" id="recentFilesHint">Zuletzt erzeugte Upload-Links</span>
|
||||||
|
<button class="btn btn-xs btn-secondary" id="exportRecentFilesBtn" title="Alle Zeilen als Datei exportieren (Zeit, Hoster, Link, Dateiname)">Exportieren</button>
|
||||||
|
<button class="btn btn-xs btn-danger" id="clearRecentFilesBtn" title="Alle Links aus diesem Panel entfernen">Alle entfernen</button>
|
||||||
|
</div>
|
||||||
|
<div class="recent-tab-body active" id="filesTab">
|
||||||
|
<div class="recent-files-table-wrap">
|
||||||
|
<table class="recent-files-table">
|
||||||
|
<thead id="recentFilesHead">
|
||||||
|
<tr>
|
||||||
|
<th class="col-date sortable" data-recent-sort="date">Datum<span class="sort-indicator">▼</span></th>
|
||||||
|
<th class="col-filename sortable" data-recent-sort="filename">Filename<span class="sort-indicator">↕</span></th>
|
||||||
|
<th class="col-host sortable" data-recent-sort="host">Host<span class="sort-indicator">↕</span></th>
|
||||||
|
<th class="col-link sortable" data-recent-sort="link">Link<span class="sort-indicator">↕</span></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="recentFilesBody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="recent-tab-body" id="statsTab">
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stats-col">
|
||||||
|
<h4>Files in queue (count)</h4>
|
||||||
|
<div class="stats-row"><span>total:</span><span id="statQueueTotal">0</span></div>
|
||||||
|
<div class="stats-row"><span>done:</span><span id="statQueueDone">0</span></div>
|
||||||
|
<div class="stats-row"><span>remaining:</span><span id="statQueueRemaining">0</span></div>
|
||||||
|
<div class="stats-row"><span>in progress:</span><span id="statQueueInProgress">0</span></div>
|
||||||
|
<div class="stats-row"><span>error:</span><span id="statQueueError">0</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="stats-col">
|
||||||
|
<h4>File size in queue</h4>
|
||||||
|
<div class="stats-row"><span>total:</span><span id="statSizeTotal">0 B</span></div>
|
||||||
|
<div class="stats-row"><span>remaining:</span><span id="statSizeRemaining">0 B</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="stats-col">
|
||||||
|
<h4>Session</h4>
|
||||||
|
<div class="stats-row"><span>Upload speed:</span><span id="statSpeed">0 B/s</span></div>
|
||||||
|
<div class="stats-row"><span>Remaining time:</span><span id="statEta">--:--</span></div>
|
||||||
|
<div class="stats-row"><span>Run time:</span><span id="statRunTime">00:00:00</span></div>
|
||||||
|
<div class="stats-row"><span>Uploaded (this run):</span><span id="statSessionBytes">0 B</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="accounts-view" class="view">
|
||||||
|
<div class="accounts-container">
|
||||||
|
<div class="accounts-header">
|
||||||
|
<div>
|
||||||
|
<h2>Accounts</h2>
|
||||||
|
<p class="settings-hint">Hoster-Zugangsdaten verwalten und prüfen</p>
|
||||||
|
</div>
|
||||||
|
<div class="accounts-header-actions">
|
||||||
|
<button class="btn btn-secondary" id="accountsRunHealthCheckBtn">Accounts prüfen</button>
|
||||||
|
<label class="auto-check-label accounts-auto-check" title="Automatischer Check vor dem Upload">
|
||||||
|
<input type="checkbox" id="autoHealthCheckToggle" checked>
|
||||||
|
<span>Auto-Check vor Upload</span>
|
||||||
|
</label>
|
||||||
|
<button class="btn btn-primary" id="addAccountBtn">+ Account hinzufügen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="health-check-results account-health-results" id="healthCheckResults"></div>
|
||||||
|
<div class="accounts-list" id="accountsList"></div>
|
||||||
|
<div class="accounts-list-footer" id="accountsListFooter" style="display:none">
|
||||||
|
<button class="btn btn-secondary" id="toggleAllAccountsBtn">Alle ausklappen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="accountModal" style="display:none">
|
||||||
|
<div class="modal-card">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div>
|
||||||
|
<h3 id="accountModalTitle">Account hinzufügen</h3>
|
||||||
|
<p id="accountModalSubtitle">Wähle einen Hoster und gib deine Zugangsdaten ein.</p>
|
||||||
|
</div>
|
||||||
|
<button class="icon-btn" id="closeAccountModalBtn" aria-label="Schließen">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="settings-row" id="accountHosterRow">
|
||||||
|
<label>Hoster</label>
|
||||||
|
<select class="key-input" id="accountHosterSelect" style="max-width:300px"></select>
|
||||||
|
</div>
|
||||||
|
<div class="settings-row">
|
||||||
|
<label>Label (optional)</label>
|
||||||
|
<input type="text" class="key-input" id="accField_label" placeholder="z.B. Hauptaccount, Premium, Kunde XY" maxlength="60">
|
||||||
|
</div>
|
||||||
|
<div id="accountCredsFields"></div>
|
||||||
|
<div class="account-modal-status" id="accountModalStatus"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary" id="cancelAccountModalBtn">Abbrechen</button>
|
||||||
|
<button class="btn btn-primary" id="saveAccountBtn">Anlegen & prüfen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="jobLogModal" style="display:none">
|
||||||
|
<div class="modal-card" style="width:min(820px,96%);max-height:80vh;display:flex;flex-direction:column">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div><h3 id="jobLogTitle">Upload-Log</h3></div>
|
||||||
|
<button class="icon-btn" id="closeJobLogBtn" aria-label="Schließen">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" style="flex:1 1 auto;overflow:auto">
|
||||||
|
<pre id="jobLogBody" style="white-space:pre-wrap;font-family:ui-monospace,Consolas,Menlo,monospace;font-size:12px;line-height:1.4;margin:0">Keine Einträge.</pre>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary" id="copyJobLogBtn">In Zwischenablage</button>
|
||||||
|
<button class="btn btn-primary" id="closeJobLogBtn2">Schließen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="deleteAccountModal" style="display:none">
|
||||||
|
<div class="modal-card" style="width:min(400px,100%)">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div><h3>Account löschen?</h3></div>
|
||||||
|
<button class="icon-btn" id="closeDeleteModalBtn" aria-label="Schließen">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p id="deleteAccountMessage">Account wirklich löschen?</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary" id="cancelDeleteBtn">Abbrechen</button>
|
||||||
|
<button class="btn btn-danger" id="confirmDeleteBtn">Löschen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="settings-view" class="view">
|
||||||
|
<div class="settings-container">
|
||||||
|
<h2>Upload-Einstellungen</h2>
|
||||||
|
<p class="settings-hint">Hoster-Einstellungen erscheinen erst, sobald ein Account hinterlegt ist. Änderungen werden automatisch gespeichert.</p>
|
||||||
|
<div class="settings-hosters" id="settingsHosters"></div>
|
||||||
|
<div class="settings-save-row">
|
||||||
|
<span class="save-feedback" id="saveFeedback">Änderungen werden automatisch gespeichert.</span>
|
||||||
|
<button class="btn btn-secondary" id="saveSettingsBtn">Jetzt speichern</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="history-view" class="view">
|
||||||
|
<div class="history-container">
|
||||||
|
<div class="history-header">
|
||||||
|
<h2>Upload-Verlauf</h2>
|
||||||
|
<div style="display:flex; gap:8px; align-items:center">
|
||||||
|
<label for="historyRetentionSelect" class="history-retention-label">Aufbewahrung</label>
|
||||||
|
<select id="historyRetentionSelect" class="key-input history-retention-select">
|
||||||
|
<option value="all">Alles behalten</option>
|
||||||
|
<option value="7d">Letzte 7 Tage</option>
|
||||||
|
<option value="30d">Letzte 30 Tage</option>
|
||||||
|
<option value="90d">Letzte 90 Tage</option>
|
||||||
|
<option value="1000">Letzte 1000 Uploads</option>
|
||||||
|
<option value="100">Letzte 100 Uploads</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-secondary" id="exportHistoryBtn">Verlauf exportieren</button>
|
||||||
|
<button class="btn btn-secondary" id="clearHistoryBtn">Verlauf löschen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="historyCapNotice" class="history-cap-notice" style="display:none"></div>
|
||||||
|
<div id="historyContainer"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="context-menu" id="contextMenu" style="display:none">
|
||||||
|
<div class="ctx-item" data-action="start-selected">Ausgewählte starten</div>
|
||||||
|
<div class="ctx-item" data-action="retry-selected">Erneut versuchen</div>
|
||||||
|
<div class="ctx-item" data-action="show-log">Log anzeigen</div>
|
||||||
|
<div class="ctx-separator"></div>
|
||||||
|
<div class="ctx-item" data-action="copy-links">Links kopieren</div>
|
||||||
|
<div class="ctx-item" data-action="copy-all-links">Alle Links kopieren</div>
|
||||||
|
<div class="ctx-separator"></div>
|
||||||
|
<div class="ctx-item" data-action="delete-selected">Entfernen</div>
|
||||||
|
<div class="ctx-item" data-action="delete-all">Alle entfernen</div>
|
||||||
|
<div class="ctx-submenu ctx-hoster-delete-submenu" style="display:none">
|
||||||
|
<div class="ctx-item ctx-item-danger">Hoster entfernen ▸</div>
|
||||||
|
<div class="ctx-submenu-items ctx-hoster-delete-items"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="context-menu" id="recentContextMenu" style="display:none">
|
||||||
|
<div class="ctx-item" data-action="recent-copy-links">Links kopieren</div>
|
||||||
|
<div class="ctx-item" data-action="recent-delete">Entfernen</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="statusbar" id="statusbar">
|
||||||
|
<span class="sb-state" id="sbState">Bereit</span>
|
||||||
|
<span class="sb-separator">|</span>
|
||||||
|
<span class="sb-speed" id="sbSpeed">0 kB/s</span>
|
||||||
|
<span class="sb-separator">|</span>
|
||||||
|
<span class="sb-total" id="sbTotal">0 B</span>
|
||||||
|
<span class="sb-separator">|</span>
|
||||||
|
<span class="sb-eta" id="sbEta">ETA --:--</span>
|
||||||
|
<span class="sb-separator">|</span>
|
||||||
|
<span class="sb-connections" id="sbConnections">Aktive Verbindungen 0</span>
|
||||||
|
<span class="sb-separator">|</span>
|
||||||
|
<span class="sb-queue-count" id="sbQueueCount">Gesamt 0</span>
|
||||||
|
<span class="sb-separator">|</span>
|
||||||
|
<span class="sb-remaining-count" id="sbRemainingCount">Remaining 0</span>
|
||||||
|
<span class="sb-separator">|</span>
|
||||||
|
<span class="sb-progress-count" id="sbInProgressCount">In Progress 0</span>
|
||||||
|
<span class="sb-separator">|</span>
|
||||||
|
<span class="sb-done-count" id="sbDoneCount">Done 0</span>
|
||||||
|
<span class="sb-separator">|</span>
|
||||||
|
<span class="sb-error-count" id="sbErrorCount">Error 0</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="copy-toast" id="copyToast"></div>
|
||||||
|
|
||||||
|
<div class="shutdown-overlay" id="shutdownOverlay" style="display:none">
|
||||||
|
<div class="shutdown-box">
|
||||||
|
<p id="shutdownMessage">System wird heruntergefahren in <span id="shutdownSeconds">60</span>s...</p>
|
||||||
|
<button class="btn btn-danger" id="cancelShutdownBtn">Abbrechen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="hosterModal" style="display:none">
|
||||||
|
<div class="modal-card">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div>
|
||||||
|
<h3>Upload-Ziele auswählen</h3>
|
||||||
|
<p>Dateien wurden hinzugefügt. Wähle jetzt die Hoster für den Upload.</p>
|
||||||
|
</div>
|
||||||
|
<button class="icon-btn" id="closeHosterModalBtn" aria-label="Schließen">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="modal-actions-inline">
|
||||||
|
<button class="btn btn-xs btn-secondary" id="selectAllHostersBtn">Alle</button>
|
||||||
|
<button class="btn btn-xs btn-secondary" id="clearHostersBtn">Keine</button>
|
||||||
|
</div>
|
||||||
|
<div class="hoster-modal-list" id="hosterModalList"></div>
|
||||||
|
<p class="modal-hint" id="hosterModalHint"></p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary" id="cancelHosterModalBtn">Abbrechen</button>
|
||||||
|
<button class="btn btn-primary" id="confirmHosterModalBtn">Übernehmen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="../lib/queue-prune.js"></script>
|
||||||
|
<script src="../lib/queue-dedup.js"></script>
|
||||||
|
<script src="../lib/log-mode.js"></script>
|
||||||
|
<script src="../lib/stats.js"></script>
|
||||||
|
<script src="../lib/throttled-cache.js"></script>
|
||||||
|
<script src="../lib/coalesced-set.js"></script>
|
||||||
|
<script src="../lib/throttle-timer.js"></script>
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+1341
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
|||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
module.exports = async function afterPack(context) {
|
||||||
|
let rcedit;
|
||||||
|
try {
|
||||||
|
rcedit = require("rcedit");
|
||||||
|
} catch {
|
||||||
|
console.warn(" rcedit: skipped - rcedit not installed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const productFilename = context.packager?.appInfo?.productFilename;
|
||||||
|
if (!productFilename) {
|
||||||
|
console.warn(" rcedit: skipped - productFilename not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exePath = path.join(context.appOutDir, `${productFilename}.exe`);
|
||||||
|
const iconPath = path.resolve(__dirname, "..", "assets", "app_icon.ico");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fs = require("fs");
|
||||||
|
if (!fs.existsSync(iconPath)) {
|
||||||
|
console.warn(" rcedit: skipped - app_icon.ico not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(` rcedit: patching icon -> ${exePath}`);
|
||||||
|
await rcedit(exePath, { icon: iconPath });
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(` rcedit: failed - ${String(error)}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
import { readFile, readdir } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import process from 'node:process';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const sourceOnly = args.includes('--source-only');
|
||||||
|
const failures = new Map();
|
||||||
|
|
||||||
|
const requiredFiles = [
|
||||||
|
'.gitignore',
|
||||||
|
'README.md',
|
||||||
|
'SECURITY.md',
|
||||||
|
'package.json',
|
||||||
|
'package-lock.json',
|
||||||
|
'eslint.config.mjs',
|
||||||
|
'main.js',
|
||||||
|
'preload.js',
|
||||||
|
'preload-drop-target.js',
|
||||||
|
'assets/app_icon.ico',
|
||||||
|
'assets/app_icon.png',
|
||||||
|
'scripts/afterPack.cjs',
|
||||||
|
'scripts/verify-public-release.mjs'
|
||||||
|
];
|
||||||
|
|
||||||
|
const allowedFiles = new Set([...requiredFiles, 'assets/product-overview.png']);
|
||||||
|
const allowedPrefixes = ['lib/', 'renderer/', 'tests/'];
|
||||||
|
const ignoredDirectories = new Set(['.git', 'node_modules', 'release']);
|
||||||
|
const deniedDirectories = new Set([
|
||||||
|
`.${['clau', 'de'].join('')}`,
|
||||||
|
`.${['co', 'dex'].join('')}`,
|
||||||
|
'.playwright-mcp',
|
||||||
|
'.superpowers',
|
||||||
|
'__pycache__',
|
||||||
|
'backups',
|
||||||
|
'docs',
|
||||||
|
'gateway',
|
||||||
|
'logs',
|
||||||
|
'memories',
|
||||||
|
'prompts',
|
||||||
|
'tasks'
|
||||||
|
]);
|
||||||
|
const deniedBasenames = new Set([
|
||||||
|
'agents.md',
|
||||||
|
'app.py',
|
||||||
|
`${['clau', 'de'].join('')}.md`,
|
||||||
|
'credentials.json',
|
||||||
|
'gemini.md',
|
||||||
|
'hosters.py',
|
||||||
|
'memory.md',
|
||||||
|
'memory_summary.md',
|
||||||
|
'raw_memories.md',
|
||||||
|
'requirements.txt'
|
||||||
|
]);
|
||||||
|
const textExtensions = new Set(['.cjs', '.css', '.html', '.js', '.json', '.md', '.mjs', '.txt', '.yaml', '.yml']);
|
||||||
|
const binaryExtensions = new Set(['.ico', '.png']);
|
||||||
|
const expectedScripts = {
|
||||||
|
start: 'electron .',
|
||||||
|
test: 'node --test tests/*.test.js tests/ui-smoke.js',
|
||||||
|
lint: 'eslint .',
|
||||||
|
dist: 'electron-builder --win',
|
||||||
|
'release:win': 'electron-builder --publish never --win nsis portable'
|
||||||
|
};
|
||||||
|
const expectedBuildFiles = [
|
||||||
|
'main.js',
|
||||||
|
'preload.js',
|
||||||
|
'preload-drop-target.js',
|
||||||
|
'lib/**/*',
|
||||||
|
'renderer/**/*',
|
||||||
|
'assets/app_icon.ico',
|
||||||
|
'assets/app_icon.png'
|
||||||
|
];
|
||||||
|
const aiTerms = [
|
||||||
|
['clau', 'de'].join(''),
|
||||||
|
['co', 'dex'].join(''),
|
||||||
|
['chat', 'gpt'].join('')
|
||||||
|
].join('|');
|
||||||
|
const personalTerms = [
|
||||||
|
['pl', 'oet'].join(''),
|
||||||
|
['baker', 'edwin318'].join('')
|
||||||
|
].join('|');
|
||||||
|
const forbiddenAiPattern = new RegExp(`\\b(?:${aiTerms}|multi[\\s-]+agents?)\\b`, 'i');
|
||||||
|
const forbiddenPersonalPattern = new RegExp(`(?:[a-z]:[\\\\/]+users[\\\\/]+|\\b(?:${personalTerms})\\b|\\bdesktop-[a-z0-9-]+\\b)`, 'i');
|
||||||
|
const forbiddenInvestigationPattern = new RegExp(`\\b(?:${['internal', 'investigation'].join(' ')}|${['interne', 'untersuchung'].join(' ')}|${['audit', 'method'].join(' ')}|${['test', 'chronicle'].join(' ')})\\b`, 'i');
|
||||||
|
const updaterOnlyPattern = new RegExp([
|
||||||
|
['gi', 'tea'].join(''),
|
||||||
|
['git', '24-music', 'de'].join('\\.'),
|
||||||
|
[['Admin', 'istrator'].join(''), 'Multi-Hoster-Upload'].join('\\/')
|
||||||
|
].join('|'), 'i');
|
||||||
|
|
||||||
|
function addFailure(file, rule) {
|
||||||
|
if (!failures.has(file)) failures.set(file, new Set());
|
||||||
|
failures.get(file).add(rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRelative(value) {
|
||||||
|
return value.split(path.sep).join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAllowedFile(relativePath) {
|
||||||
|
return allowedFiles.has(relativePath) || allowedPrefixes.some((prefix) => relativePath.startsWith(prefix));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDeniedBasename(basename) {
|
||||||
|
const lower = basename.toLowerCase();
|
||||||
|
return deniedBasenames.has(lower)
|
||||||
|
|| /^\.env(?:\.|$)/i.test(basename)
|
||||||
|
|| /\.(?:bak|db|log|sqlite|sqlite3|tmp)$/i.test(basename);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enumerate(directory = root, relativeDirectory = '') {
|
||||||
|
const entries = await readdir(directory, { withFileTypes: true });
|
||||||
|
const files = [];
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const relativePath = normalizeRelative(path.join(relativeDirectory, entry.name));
|
||||||
|
const lowerName = entry.name.toLowerCase();
|
||||||
|
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
if (ignoredDirectories.has(lowerName)) continue;
|
||||||
|
if (deniedDirectories.has(lowerName)) addFailure(relativePath, 'denied-directory');
|
||||||
|
files.push(...await enumerate(path.join(directory, entry.name), relativePath));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!entry.isFile()) {
|
||||||
|
addFailure(relativePath, 'unsupported-file-type');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDeniedBasename(entry.name)) addFailure(relativePath, 'denied-basename');
|
||||||
|
if (!isAllowedFile(relativePath)) addFailure(relativePath, 'source-layout-allowlist');
|
||||||
|
files.push(relativePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJson(relativePath, rule) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(await readFile(path.join(root, relativePath), 'utf8'));
|
||||||
|
} catch {
|
||||||
|
addFailure(relativePath, rule);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stableJson(value) {
|
||||||
|
if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`;
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(',')}}`;
|
||||||
|
}
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateTextFiles(files) {
|
||||||
|
for (const relativePath of files) {
|
||||||
|
const extension = path.extname(relativePath).toLowerCase();
|
||||||
|
const isTextFile = textExtensions.has(extension) || relativePath === '.gitignore';
|
||||||
|
if (!isTextFile) {
|
||||||
|
if (!binaryExtensions.has(extension)) addFailure(relativePath, 'source-extension-allowlist');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await readFile(path.join(root, relativePath), 'utf8');
|
||||||
|
if (forbiddenPersonalPattern.test(text)) addFailure(relativePath, 'forbidden-personal-term');
|
||||||
|
if (forbiddenAiPattern.test(text)) addFailure(relativePath, 'forbidden-ai-term');
|
||||||
|
if (forbiddenInvestigationPattern.test(text)) addFailure(relativePath, 'forbidden-investigation-term');
|
||||||
|
if (relativePath !== 'lib/updater.js' && updaterOnlyPattern.test(text)) addFailure(relativePath, 'updater-endpoint-scope');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validatePackage(packageJson, packageLock, files) {
|
||||||
|
if (!packageJson) return;
|
||||||
|
|
||||||
|
if (packageJson.version !== '3.3.108') addFailure('package.json', 'package-version');
|
||||||
|
if (stableJson(packageJson.scripts) !== stableJson(expectedScripts)) addFailure('package.json', 'package-script-allowlist');
|
||||||
|
|
||||||
|
const buildFiles = packageJson.build?.files;
|
||||||
|
if (!Array.isArray(buildFiles) || stableJson(buildFiles) !== stableJson(expectedBuildFiles)) {
|
||||||
|
addFailure('package.json', 'build-file-allowlist');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const requiredEntry of expectedBuildFiles) {
|
||||||
|
if (!Array.isArray(buildFiles) || !buildFiles.includes(requiredEntry)) addFailure(requiredEntry.replace('/**/*', ''), 'build-file-entry');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (packageJson.build?.afterPack !== 'scripts/afterPack.cjs') addFailure('scripts/afterPack.cjs', 'build-hook-entry');
|
||||||
|
|
||||||
|
if (packageLock) {
|
||||||
|
const lockRoot = packageLock.packages?.[''];
|
||||||
|
if (packageLock.version !== '3.3.108' || lockRoot?.version !== '3.3.108') {
|
||||||
|
addFailure('package-lock.json', 'package-lock-version');
|
||||||
|
}
|
||||||
|
if (!lockRoot
|
||||||
|
|| lockRoot.name !== packageJson.name
|
||||||
|
|| stableJson(lockRoot.dependencies) !== stableJson(packageJson.dependencies)
|
||||||
|
|| stableJson(lockRoot.devDependencies) !== stableJson(packageJson.devDependencies)) {
|
||||||
|
addFailure('package-lock.json', 'package-lock-root-metadata');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of expectedBuildFiles) {
|
||||||
|
if (entry.endsWith('/**/*')) {
|
||||||
|
const prefix = entry.slice(0, -4);
|
||||||
|
if (!files.some((file) => file.startsWith(prefix))) addFailure(entry.slice(0, -5), 'build-entry-target');
|
||||||
|
} else if (!files.includes(entry)) {
|
||||||
|
addFailure(entry, 'build-entry-target');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function printFailures() {
|
||||||
|
for (const file of [...failures.keys()].sort()) {
|
||||||
|
for (const rule of [...failures.get(file)].sort()) {
|
||||||
|
process.stderr.write(`${file}\t${rule}\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
if (args.some((arg) => arg !== '--source-only') || args.filter((arg) => arg === '--source-only').length > 1) {
|
||||||
|
addFailure('scripts/verify-public-release.mjs', 'argument-allowlist');
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = await enumerate();
|
||||||
|
|
||||||
|
for (const requiredFile of requiredFiles) {
|
||||||
|
if (!files.includes(requiredFile)) addFailure(requiredFile, 'required-source-file');
|
||||||
|
}
|
||||||
|
if (!sourceOnly && !files.includes('assets/product-overview.png')) {
|
||||||
|
addFailure('assets/product-overview.png', 'required-screenshot');
|
||||||
|
}
|
||||||
|
|
||||||
|
await validateTextFiles(files);
|
||||||
|
const packageJson = await readJson('package.json', 'package-json');
|
||||||
|
const packageLock = await readJson('package-lock.json', 'package-lock-json');
|
||||||
|
validatePackage(packageJson, packageLock, files);
|
||||||
|
|
||||||
|
if (failures.size > 0) {
|
||||||
|
printFailures();
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
process.stdout.write(`public-release-source-ok files=${files.length} denied-paths=0 forbidden-terms=0 version=${packageJson.version} scripts=${Object.keys(packageJson.scripts).length} build-files=${packageJson.build.files.length} layout=valid screenshot=${sourceOnly ? 'deferred' : 'present'}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(() => {
|
||||||
|
process.stderr.write('scripts/verify-public-release.mjs\tverifier-runtime\n');
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const { selectUploadAuth } = require('../lib/account-auth');
|
||||||
|
|
||||||
|
test('doodstream prefers the API key even when username/password are also set', () => {
|
||||||
|
const auth = selectUploadAuth('doodstream.com', {
|
||||||
|
apiKey: 'KEY123', username: 'u', password: 'p'
|
||||||
|
});
|
||||||
|
assert.deepEqual(auth, { apiKey: 'KEY123' }); // API path — no username leaks through
|
||||||
|
});
|
||||||
|
|
||||||
|
test('doodstream with only username/password uses web login (keyless fallback)', () => {
|
||||||
|
const auth = selectUploadAuth('doodstream.com', { username: 'u', password: 'p' });
|
||||||
|
assert.deepEqual(auth, { username: 'u', password: 'p' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('doodstream with empty apiKey + creds falls back to web login (no false API route)', () => {
|
||||||
|
const auth = selectUploadAuth('doodstream.com', { apiKey: '', username: 'u', password: 'p' });
|
||||||
|
assert.deepEqual(auth, { username: 'u', password: 'p' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('doodstream with nothing usable returns empty', () => {
|
||||||
|
assert.deepEqual(selectUploadAuth('doodstream.com', { apiKey: '', username: '', password: '' }), {});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('voe.sx is unaffected by the doodstream special-case: username/password wins', () => {
|
||||||
|
// voe also supports both, but the empty-form bug is doodstream-specific; do
|
||||||
|
// not change voe routing.
|
||||||
|
const auth = selectUploadAuth('voe.sx', { apiKey: 'VKEY', username: 'u', password: 'p' });
|
||||||
|
assert.deepEqual(auth, { username: 'u', password: 'p' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('authType=api forces the API key for any hoster', () => {
|
||||||
|
assert.deepEqual(selectUploadAuth('voe.sx', { authType: 'api', apiKey: 'K', username: 'u', password: 'p' }), { apiKey: 'K' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('api-key-only account (no creds) uses the key', () => {
|
||||||
|
assert.deepEqual(selectUploadAuth('byse.sx', { apiKey: 'BKEY' }), { apiKey: 'BKEY' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('null / non-object account does not throw', () => {
|
||||||
|
assert.deepEqual(selectUploadAuth('doodstream.com', null), {});
|
||||||
|
assert.deepEqual(selectUploadAuth('doodstream.com', undefined), {});
|
||||||
|
});
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const { createAccountPicker, enabledAccountsFor } = require('../lib/account-rotation');
|
||||||
|
|
||||||
|
const hasCreds = (hoster, a) => !!(a && a.creds !== false);
|
||||||
|
function acc(id, opts = {}) { return { id, enabled: opts.enabled, creds: opts.creds }; }
|
||||||
|
function picks(pick, hoster, n) { return Array.from({ length: n }, () => { const a = pick(hoster); return a ? a.id : null; }); }
|
||||||
|
|
||||||
|
test('rotate OFF: always the first enabled account (primary, unchanged behavior)', () => {
|
||||||
|
const hosters = { 'byse.sx': [acc('a1'), acc('a2'), acc('a3')] };
|
||||||
|
const pick = createAccountPicker({ hosters, hosterSettings: {}, hasCreds });
|
||||||
|
assert.deepStrictEqual(picks(pick, 'byse.sx', 4), ['a1', 'a1', 'a1', 'a1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rotate ON, 3 accounts: round-robin per call and wraps around', () => {
|
||||||
|
const hosters = { 'byse.sx': [acc('a1'), acc('a2'), acc('a3')] };
|
||||||
|
const pick = createAccountPicker({ hosters, hosterSettings: { 'byse.sx': { rotateAccounts: true } }, hasCreds });
|
||||||
|
assert.deepStrictEqual(picks(pick, 'byse.sx', 7), ['a1', 'a2', 'a3', 'a1', 'a2', 'a3', 'a1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rotate ON, single enabled account: no-op (length must be > 1 to rotate)', () => {
|
||||||
|
const hosters = { 'byse.sx': [acc('a1')] };
|
||||||
|
const pick = createAccountPicker({ hosters, hosterSettings: { 'byse.sx': { rotateAccounts: true } }, hasCreds });
|
||||||
|
assert.deepStrictEqual(picks(pick, 'byse.sx', 3), ['a1', 'a1', 'a1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rotate ON skips a disabled account, keeps the rest in order', () => {
|
||||||
|
const hosters = { 'byse.sx': [acc('a1'), acc('a2', { enabled: false }), acc('a3')] };
|
||||||
|
const pick = createAccountPicker({ hosters, hosterSettings: { 'byse.sx': { rotateAccounts: true } }, hasCreds });
|
||||||
|
assert.deepStrictEqual(picks(pick, 'byse.sx', 4), ['a1', 'a3', 'a1', 'a3']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rotate ON skips an account without credentials', () => {
|
||||||
|
const hosters = { 'byse.sx': [acc('a1'), acc('a2', { creds: false }), acc('a3')] };
|
||||||
|
const pick = createAccountPicker({ hosters, hosterSettings: { 'byse.sx': { rotateAccounts: true } }, hasCreds });
|
||||||
|
assert.deepStrictEqual(picks(pick, 'byse.sx', 4), ['a1', 'a3', 'a1', 'a3']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no usable account → null (disabled hoster or missing hoster)', () => {
|
||||||
|
const hosters = { 'byse.sx': [acc('a1', { enabled: false })] };
|
||||||
|
const pick = createAccountPicker({ hosters, hosterSettings: { 'byse.sx': { rotateAccounts: true } }, hasCreds });
|
||||||
|
assert.strictEqual(pick('byse.sx'), null);
|
||||||
|
assert.strictEqual(pick('voe.sx'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rotation index is independent per hoster (interleaved calls)', () => {
|
||||||
|
const hosters = { 'byse.sx': [acc('b1'), acc('b2')], 'voe.sx': [acc('v1'), acc('v2')] };
|
||||||
|
const settings = { 'byse.sx': { rotateAccounts: true }, 'voe.sx': { rotateAccounts: true } };
|
||||||
|
const pick = createAccountPicker({ hosters, hosterSettings: settings, hasCreds });
|
||||||
|
assert.strictEqual(pick('byse.sx').id, 'b1');
|
||||||
|
assert.strictEqual(pick('voe.sx').id, 'v1');
|
||||||
|
assert.strictEqual(pick('byse.sx').id, 'b2');
|
||||||
|
assert.strictEqual(pick('voe.sx').id, 'v2');
|
||||||
|
assert.strictEqual(pick('byse.sx').id, 'b1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rotate ON for byse only: voe still uses its primary', () => {
|
||||||
|
const hosters = { 'byse.sx': [acc('b1'), acc('b2')], 'voe.sx': [acc('v1'), acc('v2')] };
|
||||||
|
const pick = createAccountPicker({ hosters, hosterSettings: { 'byse.sx': { rotateAccounts: true } }, hasCreds });
|
||||||
|
assert.deepStrictEqual(picks(pick, 'voe.sx', 2), ['v1', 'v1']);
|
||||||
|
assert.deepStrictEqual(picks(pick, 'byse.sx', 2), ['b1', 'b2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('user scenario: 100 files across 2 active accounts → even 50/50 alternating split', () => {
|
||||||
|
const hosters = { 'byse.sx': [acc('a1'), acc('a2')] };
|
||||||
|
const pick = createAccountPicker({ hosters, hosterSettings: { 'byse.sx': { rotateAccounts: true } }, hasCreds });
|
||||||
|
const ids = picks(pick, 'byse.sx', 100);
|
||||||
|
assert.strictEqual(ids.filter(x => x === 'a1').length, 50);
|
||||||
|
assert.strictEqual(ids.filter(x => x === 'a2').length, 50);
|
||||||
|
assert.deepStrictEqual(ids.slice(0, 5), ['a1', 'a2', 'a1', 'a2', 'a1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('enabledAccountsFor filters disabled + no-creds and preserves order', () => {
|
||||||
|
const hosters = { 'byse.sx': [acc('a1'), acc('a2', { enabled: false }), acc('a3', { creds: false }), acc('a4')] };
|
||||||
|
assert.deepStrictEqual(enabledAccountsFor(hosters, 'byse.sx', hasCreds).map(a => a.id), ['a1', 'a4']);
|
||||||
|
assert.deepStrictEqual(enabledAccountsFor(hosters, 'missing', hasCreds), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('seeded index resumes mid-cycle (restart / persisted cursor)', () => {
|
||||||
|
const hosters = { 'byse.sx': [acc('a1'), acc('a2'), acc('a3')] };
|
||||||
|
const pick = createAccountPicker({ hosters, hosterSettings: { 'byse.sx': { rotateAccounts: true } }, hasCreds, indices: { 'byse.sx': 1 } });
|
||||||
|
assert.deepStrictEqual(picks(pick, 'byse.sx', 4), ['a2', 'a3', 'a1', 'a2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('drip-feed: fresh picker per call seeded from prior indices keeps rotating (no per-batch reset)', () => {
|
||||||
|
const hosters = { 'byse.sx': [acc('a1'), acc('a2'), acc('a3')] };
|
||||||
|
const settings = { 'byse.sx': { rotateAccounts: true } };
|
||||||
|
let cursors = {};
|
||||||
|
const landed = [];
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
const pick = createAccountPicker({ hosters, hosterSettings: settings, hasCreds, indices: cursors });
|
||||||
|
landed.push(pick('byse.sx').id);
|
||||||
|
cursors = pick.indices();
|
||||||
|
}
|
||||||
|
assert.deepStrictEqual(landed, ['a1', 'a2', 'a3', 'a1', 'a2', 'a3']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dirty() is true only after an actual rotation advance', () => {
|
||||||
|
const hosters = { 'byse.sx': [acc('a1'), acc('a2')], 'voe.sx': [acc('v1'), acc('v2')] };
|
||||||
|
const offPick = createAccountPicker({ hosters, hosterSettings: {}, hasCreds });
|
||||||
|
offPick('byse.sx'); offPick('voe.sx');
|
||||||
|
assert.strictEqual(offPick.dirty(), false);
|
||||||
|
|
||||||
|
const singlePick = createAccountPicker({ hosters: { 'byse.sx': [acc('a1')] }, hosterSettings: { 'byse.sx': { rotateAccounts: true } }, hasCreds });
|
||||||
|
singlePick('byse.sx');
|
||||||
|
assert.strictEqual(singlePick.dirty(), false);
|
||||||
|
|
||||||
|
const onPick = createAccountPicker({ hosters, hosterSettings: { 'byse.sx': { rotateAccounts: true } }, hasCreds });
|
||||||
|
onPick('voe.sx');
|
||||||
|
assert.strictEqual(onPick.dirty(), false);
|
||||||
|
onPick('byse.sx');
|
||||||
|
assert.strictEqual(onPick.dirty(), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('indices() carries forward unrotated seeded hosters alongside advanced ones', () => {
|
||||||
|
const hosters = { 'byse.sx': [acc('a1'), acc('a2')], 'voe.sx': [acc('v1'), acc('v2')] };
|
||||||
|
const pick = createAccountPicker({ hosters, hosterSettings: { 'byse.sx': { rotateAccounts: true } }, hasCreds, indices: { 'voe.sx': 5 } });
|
||||||
|
pick('byse.sx');
|
||||||
|
assert.deepStrictEqual(pick.indices(), { 'voe.sx': 5, 'byse.sx': 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('persisted cursor wraps correctly after the enabled-account count shrinks', () => {
|
||||||
|
const hosters = { 'byse.sx': [acc('a1'), acc('a2')] };
|
||||||
|
const pick = createAccountPicker({ hosters, hosterSettings: { 'byse.sx': { rotateAccounts: true } }, hasCreds, indices: { 'byse.sx': 7 } });
|
||||||
|
assert.deepStrictEqual(picks(pick, 'byse.sx', 3), ['a2', 'a1', 'a2']);
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
const { describe, it } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { encrypt, decrypt } = require('../lib/backup-crypto');
|
||||||
|
|
||||||
|
describe('backup-crypto', () => {
|
||||||
|
const sampleConfig = {
|
||||||
|
hosters: { 'doodstream.com': { enabled: true, apiKey: 'test-key-123' } },
|
||||||
|
hosterSettings: { 'doodstream.com': { retries: 3 } },
|
||||||
|
globalSettings: { alwaysOnTop: false },
|
||||||
|
history: [{ file: 'test.mkv', link: 'https://example.com/abc' }]
|
||||||
|
};
|
||||||
|
|
||||||
|
it('encrypt then decrypt round-trips', () => {
|
||||||
|
const buf = encrypt(sampleConfig);
|
||||||
|
const result = decrypt(buf);
|
||||||
|
assert.deepStrictEqual(result, sampleConfig);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decrypt with corrupted data throws', () => {
|
||||||
|
const buf = encrypt(sampleConfig);
|
||||||
|
buf[buf.length - 1] ^= 0xff; // flip last byte
|
||||||
|
// With no password: app-key fails → needsPassword surfaces.
|
||||||
|
assert.throws(() => decrypt(buf), (err) => err.needsPassword === true);
|
||||||
|
// With a password: both app-key and password fail → Falsches Passwort.
|
||||||
|
assert.throws(() => decrypt(buf, 'anything'), /Falsches Passwort/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decrypt with invalid magic throws', () => {
|
||||||
|
// Buffer must be long enough to pass the length check (>= 4+16+12+16+1 = 49)
|
||||||
|
const buf = Buffer.alloc(60, 0x41); // 60 bytes of 'A'
|
||||||
|
assert.throws(() => decrypt(buf), /Keine gültige/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decrypt with too-short buffer throws', () => {
|
||||||
|
assert.throws(() => decrypt(Buffer.alloc(10)), /Ungültiges Backup-Format/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty config gracefully', () => {
|
||||||
|
const empty = { hosters: {}, hosterSettings: {}, globalSettings: {}, history: [] };
|
||||||
|
const buf = encrypt(empty);
|
||||||
|
assert.deepStrictEqual(decrypt(buf), empty);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decrypts legacy password-encrypted buffer when password is provided', () => {
|
||||||
|
// Reproduce the old format: same envelope, but key derived from user password.
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const plaintext = Buffer.from(JSON.stringify(sampleConfig), 'utf-8');
|
||||||
|
const salt = crypto.randomBytes(16);
|
||||||
|
const iv = crypto.randomBytes(12);
|
||||||
|
const key = crypto.pbkdf2Sync('oldUserPw', salt, 100_000, 32, 'sha512');
|
||||||
|
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
||||||
|
const enc = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
||||||
|
const tag = cipher.getAuthTag();
|
||||||
|
const legacyBuf = Buffer.concat([Buffer.from('MHU1'), salt, iv, tag, enc]);
|
||||||
|
|
||||||
|
// Without password → should throw needsPassword
|
||||||
|
assert.throws(() => decrypt(legacyBuf), (err) => err.needsPassword === true);
|
||||||
|
|
||||||
|
// With correct password → should decrypt
|
||||||
|
assert.deepStrictEqual(decrypt(legacyBuf, 'oldUserPw'), sampleConfig);
|
||||||
|
|
||||||
|
// With wrong password → should throw (not needsPassword)
|
||||||
|
assert.throws(() => decrypt(legacyBuf, 'wrongPw'), /Falsches Passwort/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('each encryption produces different output (random salt/iv)', () => {
|
||||||
|
const a = encrypt(sampleConfig);
|
||||||
|
const b = encrypt(sampleConfig);
|
||||||
|
assert.ok(!a.equals(b), 'two encryptions should differ');
|
||||||
|
// but both decrypt to same result
|
||||||
|
assert.deepStrictEqual(decrypt(a), decrypt(b));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
const { test, before, after } = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
let requestRouter = async () => ({ statusCode: 200, headers: {}, body: { text: async () => '{}' } });
|
||||||
|
const undici = require('undici');
|
||||||
|
const _origUndiciRequest = undici.request;
|
||||||
|
undici.request = (...a) => requestRouter(...a);
|
||||||
|
delete require.cache[require.resolve('../lib/hosters')];
|
||||||
|
const hostersMod = require('../lib/hosters');
|
||||||
|
const { uploadFile } = hostersMod;
|
||||||
|
|
||||||
|
let tmpFile;
|
||||||
|
let origFetch;
|
||||||
|
before(() => {
|
||||||
|
tmpFile = path.join(os.tmpdir(), `byse-itest-${process.pid}.mkv`);
|
||||||
|
fs.writeFileSync(tmpFile, Buffer.alloc(2048, 7));
|
||||||
|
origFetch = global.fetch;
|
||||||
|
});
|
||||||
|
after(() => {
|
||||||
|
global.fetch = origFetch;
|
||||||
|
undici.request = _origUndiciRequest;
|
||||||
|
delete require.cache[require.resolve('../lib/hosters')];
|
||||||
|
try { fs.unlinkSync(tmpFile); } catch {}
|
||||||
|
});
|
||||||
|
|
||||||
|
function stubByseUploadServer() {
|
||||||
|
global.fetch = async (url) => {
|
||||||
|
if (/upload\/server/.test(String(url))) {
|
||||||
|
return { status: 200, text: async () => JSON.stringify({ status: 200, result: 'https://node1.byse.sx/upload/01' }) };
|
||||||
|
}
|
||||||
|
return { status: 200, text: async () => '{"status":200}' };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('byse "Not video file format" (suspect) DOES poll recovery and claims the async-registered file', async () => {
|
||||||
|
stubByseUploadServer();
|
||||||
|
let listCalls = 0;
|
||||||
|
requestRouter = async (url, opts) => {
|
||||||
|
const u = String(url);
|
||||||
|
if (/\/file\/list/.test(u)) {
|
||||||
|
listCalls++;
|
||||||
|
const body = listCalls === 1
|
||||||
|
? '{"status":200,"result":{"files":[]}}'
|
||||||
|
: JSON.stringify({ status: 200, result: { files: [{ file_code: 'BIGMKV77', title: path.basename(tmpFile) }] } });
|
||||||
|
return { statusCode: 200, headers: {}, body: { text: async () => body } };
|
||||||
|
}
|
||||||
|
if (opts && opts.body && typeof opts.body[Symbol.asyncIterator] === 'function') {
|
||||||
|
for await (const chunk of opts.body) { if (chunk && chunk.length === -1) break; }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
statusCode: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: { text: async () => JSON.stringify({ status: 200, msg: 'OK', files: [{ filecode: '', filename: 'x.mkv', status: 'Not video file format' }] }) }
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await uploadFile('byse.sx', tmpFile, 'VALIDKEY', null, null, null);
|
||||||
|
assert.strictEqual(res.file_code, 'BIGMKV77');
|
||||||
|
assert.ok(listCalls >= 2, 'suspect rejection must still run the recovery poll (live 2026-06-09: >2.7GB MKVs got this status while registering fine)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('byse "Not video file format" with empty poll throws err.suspectReject so rotation can try other accounts', async () => {
|
||||||
|
stubByseUploadServer();
|
||||||
|
const abort = new AbortController();
|
||||||
|
let listCalls = 0;
|
||||||
|
requestRouter = async (url, opts) => {
|
||||||
|
const u = String(url);
|
||||||
|
if (/\/file\/list/.test(u)) {
|
||||||
|
listCalls++;
|
||||||
|
if (listCalls >= 2) abort.abort();
|
||||||
|
return { statusCode: 200, headers: {}, body: { text: async () => '{"status":200,"result":{"files":[]}}' } };
|
||||||
|
}
|
||||||
|
if (opts && opts.body && typeof opts.body[Symbol.asyncIterator] === 'function') {
|
||||||
|
for await (const chunk of opts.body) { if (chunk && chunk.length === -1) break; }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
statusCode: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: { text: async () => JSON.stringify({ status: 200, msg: 'OK', files: [{ filecode: '', filename: 'x.mkv', status: 'Not video file format' }] }) }
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => uploadFile('byse.sx', tmpFile, 'VALIDKEY', null, abort.signal, null),
|
||||||
|
(err) => err.fileRejected === true && err.suspectReject === true && /Not video file format/i.test(err.message)
|
||||||
|
);
|
||||||
|
assert.ok(listCalls >= 2, 'poll must have started before giving up');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('byse "Not video file format" with probe-confirmed NON-video skips the recovery poll (genuine rejection)', async () => {
|
||||||
|
stubByseUploadServer();
|
||||||
|
let listCalls = 0;
|
||||||
|
requestRouter = async (url, opts) => {
|
||||||
|
const u = String(url);
|
||||||
|
if (/\/file\/list/.test(u)) {
|
||||||
|
listCalls++;
|
||||||
|
return { statusCode: 200, headers: {}, body: { text: async () => '{"status":200,"result":{"files":[]}}' } };
|
||||||
|
}
|
||||||
|
if (opts && opts.body && typeof opts.body[Symbol.asyncIterator] === 'function') {
|
||||||
|
for await (const chunk of opts.body) { if (chunk && chunk.length === -1) break; }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
statusCode: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: { text: async () => JSON.stringify({ status: 200, msg: 'OK', files: [{ filecode: '', filename: 'x.mkv', status: 'Not video file format' }] }) }
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => uploadFile('byse.sx', tmpFile, 'VALIDKEY', null, null, null, { probeIsVideoLike: false }),
|
||||||
|
(err) => err.fileRejected === true && /Not video file format/i.test(err.message)
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.strictEqual(listCalls, 1, 'probe says non-video → the rejection is genuine, no 15-attempt poll');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('byse explicit "Duplicate" rejection still throws fast WITHOUT recovery polling', async () => {
|
||||||
|
stubByseUploadServer();
|
||||||
|
let listCalls = 0;
|
||||||
|
requestRouter = async (url, opts) => {
|
||||||
|
const u = String(url);
|
||||||
|
if (/\/file\/list/.test(u)) {
|
||||||
|
listCalls++;
|
||||||
|
return { statusCode: 200, headers: {}, body: { text: async () => '{"status":200,"result":{"files":[]}}' } };
|
||||||
|
}
|
||||||
|
if (opts && opts.body && typeof opts.body[Symbol.asyncIterator] === 'function') {
|
||||||
|
for await (const chunk of opts.body) { if (chunk && chunk.length === -1) break; }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
statusCode: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: { text: async () => JSON.stringify({ status: 200, msg: 'OK', files: [{ filecode: '', filename: 'x.mkv', status: 'Duplicate' }] }) }
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => uploadFile('byse.sx', tmpFile, 'VALIDKEY', null, null, null),
|
||||||
|
(err) => err.fileRejected === true && err.suspectReject !== true && /Duplicate/i.test(err.message)
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.strictEqual(listCalls, 1, 'file/list should be hit ONCE (baseline only) — no 15-attempt recovery poll on a genuine rejection');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('byse empty filecode WITHOUT explicit rejection still polls recovery', async () => {
|
||||||
|
stubByseUploadServer();
|
||||||
|
let listCalls = 0;
|
||||||
|
requestRouter = async (url, opts) => {
|
||||||
|
const u = String(url);
|
||||||
|
if (/\/file\/list/.test(u)) {
|
||||||
|
listCalls++;
|
||||||
|
const body = listCalls === 1
|
||||||
|
? '{"status":200,"result":{"files":[]}}'
|
||||||
|
: JSON.stringify({ status: 200, result: { files: [{ file_code: 'RECOVERED99', title: path.basename(tmpFile) }] } });
|
||||||
|
return { statusCode: 200, headers: {}, body: { text: async () => body } };
|
||||||
|
}
|
||||||
|
if (opts && opts.body && typeof opts.body[Symbol.asyncIterator] === 'function') {
|
||||||
|
for await (const chunk of opts.body) { if (chunk && chunk.length === -1) break; }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
statusCode: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: { text: async () => JSON.stringify({ status: 200, msg: 'OK' }) }
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await uploadFile('byse.sx', tmpFile, 'VALIDKEY', null, null, null);
|
||||||
|
assert.strictEqual(res.file_code, 'RECOVERED99');
|
||||||
|
assert.ok(listCalls >= 2, 'recovery polling must run when there is no explicit rejection');
|
||||||
|
});
|
||||||
|
|
||||||
|
function stubBysePost(response) {
|
||||||
|
requestRouter = async (url, opts) => {
|
||||||
|
const u = String(url);
|
||||||
|
if (/\/file\/list/.test(u)) {
|
||||||
|
return { statusCode: 200, headers: {}, body: { text: async () => '{"status":200,"result":{"files":[]}}' } };
|
||||||
|
}
|
||||||
|
if (opts && opts.body && typeof opts.body[Symbol.asyncIterator] === 'function') {
|
||||||
|
for await (const chunk of opts.body) { if (chunk && chunk.length === -1) break; }
|
||||||
|
}
|
||||||
|
return response();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('byse upload POST 502 (HTML gateway body) is tagged transientNetwork', async () => {
|
||||||
|
stubByseUploadServer();
|
||||||
|
stubBysePost(() => ({
|
||||||
|
statusCode: 502,
|
||||||
|
headers: { 'content-type': 'text/html' },
|
||||||
|
body: { text: async () => '<!doctype html><html><head><title>502 Bad Gateway</title></head><body>502 Bad Gateway</body></html>' }
|
||||||
|
}));
|
||||||
|
await assert.rejects(
|
||||||
|
() => uploadFile('byse.sx', tmpFile, 'VALIDKEY', null, null, null),
|
||||||
|
(err) => err.transientNetwork === true && /kein JSON \(HTTP 502\)/.test(err.message)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('byse upload POST non-2xx JSON 503 is tagged transientNetwork', async () => {
|
||||||
|
stubByseUploadServer();
|
||||||
|
stubBysePost(() => ({
|
||||||
|
statusCode: 503,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: { text: async () => JSON.stringify({ status: 503, msg: 'Service Unavailable' }) }
|
||||||
|
}));
|
||||||
|
await assert.rejects(
|
||||||
|
() => uploadFile('byse.sx', tmpFile, 'VALIDKEY', null, null, null),
|
||||||
|
(err) => err.transientNetwork === true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('byse upload POST 2xx envelope {status:500} is transient; {status:403} stays account-level', async () => {
|
||||||
|
stubByseUploadServer();
|
||||||
|
stubBysePost(() => ({
|
||||||
|
statusCode: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: { text: async () => JSON.stringify({ status: 500, msg: 'Internal Server Error' }) }
|
||||||
|
}));
|
||||||
|
await assert.rejects(
|
||||||
|
() => uploadFile('byse.sx', tmpFile, 'VALIDKEY', null, null, null),
|
||||||
|
(err) => err.transientNetwork === true
|
||||||
|
);
|
||||||
|
|
||||||
|
stubBysePost(() => ({
|
||||||
|
statusCode: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: { text: async () => JSON.stringify({ status: 403, msg: 'Forbidden' }) }
|
||||||
|
}));
|
||||||
|
await assert.rejects(
|
||||||
|
() => uploadFile('byse.sx', tmpFile, 'VALIDKEY', null, null, null),
|
||||||
|
(err) => err.transientNetwork !== true
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const { makeCoalescedSet } = require('../lib/coalesced-set');
|
||||||
|
|
||||||
|
// Synchronous scheduler stand-in: collects callbacks instead of running
|
||||||
|
// them, so tests can drive the timing explicitly.
|
||||||
|
function makeManualScheduler() {
|
||||||
|
const queue = [];
|
||||||
|
const fn = (cb) => queue.push(cb);
|
||||||
|
fn.flush = () => {
|
||||||
|
while (queue.length) {
|
||||||
|
const cb = queue.shift();
|
||||||
|
cb();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fn.queueLength = () => queue.length;
|
||||||
|
return fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('throws if apply callback missing', () => {
|
||||||
|
assert.throws(() => makeCoalescedSet());
|
||||||
|
assert.throws(() => makeCoalescedSet({}));
|
||||||
|
assert.throws(() => makeCoalescedSet({ apply: 'not-a-fn' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('multiple adds in one tick coalesce into one apply call', () => {
|
||||||
|
const sched = makeManualScheduler();
|
||||||
|
const calls = [];
|
||||||
|
const cs = makeCoalescedSet({
|
||||||
|
apply: (drop) => calls.push([...drop].sort()),
|
||||||
|
scheduler: sched
|
||||||
|
});
|
||||||
|
|
||||||
|
cs.add('a'); cs.add('b'); cs.add('c');
|
||||||
|
assert.equal(sched.queueLength(), 1, 'only one microtask scheduled');
|
||||||
|
assert.equal(cs.pendingSize(), 3);
|
||||||
|
|
||||||
|
sched.flush();
|
||||||
|
assert.deepEqual(calls, [['a', 'b', 'c']]);
|
||||||
|
assert.equal(cs.pendingSize(), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('duplicate adds are deduplicated', () => {
|
||||||
|
const sched = makeManualScheduler();
|
||||||
|
const calls = [];
|
||||||
|
const cs = makeCoalescedSet({ apply: (d) => calls.push([...d]), scheduler: sched });
|
||||||
|
cs.add('a'); cs.add('a'); cs.add('a');
|
||||||
|
sched.flush();
|
||||||
|
assert.deepEqual(calls, [['a']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('two batches in series stay independent', () => {
|
||||||
|
const sched = makeManualScheduler();
|
||||||
|
const calls = [];
|
||||||
|
const cs = makeCoalescedSet({ apply: (d) => calls.push([...d]), scheduler: sched });
|
||||||
|
|
||||||
|
cs.add('x'); cs.add('y');
|
||||||
|
sched.flush();
|
||||||
|
cs.add('z');
|
||||||
|
sched.flush();
|
||||||
|
|
||||||
|
assert.deepEqual(calls, [['x', 'y'], ['z']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('add after flush re-schedules a new microtask', () => {
|
||||||
|
const sched = makeManualScheduler();
|
||||||
|
const cs = makeCoalescedSet({ apply: () => {}, scheduler: sched });
|
||||||
|
cs.add('a');
|
||||||
|
assert.equal(sched.queueLength(), 1);
|
||||||
|
sched.flush();
|
||||||
|
assert.equal(sched.queueLength(), 0);
|
||||||
|
assert.equal(cs.isScheduled(), false);
|
||||||
|
cs.add('b');
|
||||||
|
assert.equal(sched.queueLength(), 1, 'new add → new microtask');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('drainSync flushes synchronously without waiting for scheduler', () => {
|
||||||
|
const sched = makeManualScheduler();
|
||||||
|
const calls = [];
|
||||||
|
const cs = makeCoalescedSet({ apply: (d) => calls.push([...d]), scheduler: sched });
|
||||||
|
cs.add('p'); cs.add('q');
|
||||||
|
cs.drainSync();
|
||||||
|
assert.deepEqual(calls, [['p', 'q']]);
|
||||||
|
assert.equal(cs.pendingSize(), 0);
|
||||||
|
|
||||||
|
// Pending microtask was for the same ids — when it runs, pending is empty
|
||||||
|
// → apply NOT called twice.
|
||||||
|
sched.flush();
|
||||||
|
assert.equal(calls.length, 1, 'queued microtask is a no-op after drainSync');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('drainSync on empty set is a no-op', () => {
|
||||||
|
let called = 0;
|
||||||
|
const cs = makeCoalescedSet({ apply: () => called++ });
|
||||||
|
cs.drainSync();
|
||||||
|
assert.equal(called, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('throwing apply does not lock out subsequent batches', () => {
|
||||||
|
const sched = makeManualScheduler();
|
||||||
|
let attempt = 0;
|
||||||
|
const cs = makeCoalescedSet({
|
||||||
|
apply: () => { attempt++; if (attempt === 1) throw new Error('boom'); },
|
||||||
|
scheduler: sched
|
||||||
|
});
|
||||||
|
cs.add('a');
|
||||||
|
// First flush throws inside apply but is swallowed; coalescer must still work.
|
||||||
|
sched.flush();
|
||||||
|
cs.add('b');
|
||||||
|
sched.flush();
|
||||||
|
assert.equal(attempt, 2, 'second batch still ran despite first throwing');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('default scheduler is queueMicrotask (or Promise fallback) — runs eventually', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const cs = makeCoalescedSet({ apply: (d) => calls.push([...d]) });
|
||||||
|
cs.add('z');
|
||||||
|
// Wait one microtask
|
||||||
|
await Promise.resolve();
|
||||||
|
assert.deepEqual(calls, [['z']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no-op tick: scheduler fires while pending is empty (e.g. drained)', () => {
|
||||||
|
const sched = makeManualScheduler();
|
||||||
|
let called = 0;
|
||||||
|
const cs = makeCoalescedSet({ apply: () => called++, scheduler: sched });
|
||||||
|
cs.add('a');
|
||||||
|
cs.drainSync();
|
||||||
|
assert.equal(called, 1);
|
||||||
|
// Pending microtask still in queue → flush; pending is empty → apply NOT called again.
|
||||||
|
sched.flush();
|
||||||
|
assert.equal(called, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('large burst of 5000 adds coalesces to one apply call', () => {
|
||||||
|
const sched = makeManualScheduler();
|
||||||
|
const calls = [];
|
||||||
|
const cs = makeCoalescedSet({ apply: (d) => calls.push(d.size), scheduler: sched });
|
||||||
|
for (let i = 0; i < 5000; i++) cs.add('id-' + i);
|
||||||
|
assert.equal(sched.queueLength(), 1);
|
||||||
|
sched.flush();
|
||||||
|
assert.deepEqual(calls, [5000]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,409 @@
|
|||||||
|
const { describe, it, beforeEach, afterEach } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const os = require('os');
|
||||||
|
const ConfigStore = require('../lib/config-store');
|
||||||
|
|
||||||
|
let tmpDir;
|
||||||
|
let store;
|
||||||
|
|
||||||
|
function createStore() {
|
||||||
|
const fakeApp = {
|
||||||
|
isPackaged: false,
|
||||||
|
getPath: () => tmpDir
|
||||||
|
};
|
||||||
|
// ConfigStore uses path.join(__dirname, '..') for non-packaged
|
||||||
|
// We override by setting filePath directly
|
||||||
|
store = new ConfigStore(fakeApp);
|
||||||
|
store.filePath = path.join(tmpDir, 'electron-config.json');
|
||||||
|
store.historyPath = path.join(tmpDir, 'electron-history.json');
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ConfigStore', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cfg-test-'));
|
||||||
|
store = createStore();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('load returns defaults when file does not exist', () => {
|
||||||
|
const config = store.load();
|
||||||
|
assert.ok(config.hosters);
|
||||||
|
assert.ok(config.hosters['doodstream.com']);
|
||||||
|
assert.ok(config.hosters['voe.sx']);
|
||||||
|
assert.ok(config.hosters['vidmoly.me']);
|
||||||
|
assert.ok(config.hosters['byse.sx']);
|
||||||
|
assert.ok(config.hosterSettings);
|
||||||
|
assert.equal(config.hosterSettings['doodstream.com'].retries, 3);
|
||||||
|
assert.equal(config.hosterSettings['doodstream.com'].parallelCount, 2);
|
||||||
|
assert.equal(config.globalSettings.alwaysOnTop, false);
|
||||||
|
assert.equal(config.globalSettings.shutdownAfterFinish, 'nothing');
|
||||||
|
assert.equal(config.globalSettings.logFilePath, '');
|
||||||
|
assert.equal(config.globalSettings.resumeQueueOnLaunch, true);
|
||||||
|
assert.equal(config.globalSettings.parallelUploadCount, 0);
|
||||||
|
assert.equal(config.globalSettings.scaleParallelUploads, false);
|
||||||
|
assert.equal(config.globalSettings.pendingQueue, null);
|
||||||
|
assert.deepEqual(config.history, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('save then load round-trips', async () => {
|
||||||
|
await store.save({ hosters: { 'doodstream.com': [{ id: 'test-1', enabled: true, authType: 'api', apiKey: 'test-key-123' }] } });
|
||||||
|
const config = store.load();
|
||||||
|
assert.equal(config.hosters['doodstream.com'][0].apiKey, 'test-key-123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('default logMode is "single"', () => {
|
||||||
|
const config = store.load();
|
||||||
|
assert.equal(config.globalSettings.logMode, 'single');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists pendingQueue incl. savedAt (number) and ts-bearing jobs across save/load', async () => {
|
||||||
|
// The queue-persistence fix stamps pendingQueue.savedAt and restores it on launch.
|
||||||
|
// This proves the persistence layer round-trips the new fields untouched (the
|
||||||
|
// ts-gate is worthless if savedAt does not survive serialization).
|
||||||
|
const pendingQueue = {
|
||||||
|
savedAt: 1750000000123,
|
||||||
|
selectedUploadHosters: ['voe.sx', 'byse.sx'],
|
||||||
|
selectedFiles: [{ path: 'C:/dl/a.mkv', name: 'a.mkv', size: 4242 }],
|
||||||
|
queueJobs: [
|
||||||
|
{ id: 'j1', file: 'C:/dl/a.mkv', fileName: 'a.mkv', hoster: 'voe.sx', status: 'preview', bytesTotal: 4242, maxAttempts: 0 },
|
||||||
|
{ id: 'j2', file: 'C:/dl/a.mkv', fileName: 'a.mkv', hoster: 'byse.sx', status: 'error', error: 'boom', maxAttempts: 3 }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
const current = store.load();
|
||||||
|
await store.save({ globalSettings: { ...current.globalSettings, pendingQueue } });
|
||||||
|
const loaded = store.load();
|
||||||
|
const pq = loaded.globalSettings.pendingQueue;
|
||||||
|
assert.equal(pq.savedAt, 1750000000123, 'savedAt epoch survives JSON round-trip');
|
||||||
|
assert.equal(typeof pq.savedAt, 'number');
|
||||||
|
assert.equal(pq.queueJobs.length, 2);
|
||||||
|
assert.equal(pq.queueJobs[0].fileName, 'a.mkv');
|
||||||
|
assert.equal(pq.queueJobs[0].hoster, 'voe.sx');
|
||||||
|
assert.equal(pq.queueJobs[1].status, 'error');
|
||||||
|
assert.deepEqual(pq.selectedUploadHosters, ['voe.sx', 'byse.sx']);
|
||||||
|
assert.equal(pq.selectedFiles[0].path, 'C:/dl/a.mkv');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pendingQueue can be cleared back to null (clearPersistedQueueStateSoon path)', async () => {
|
||||||
|
const current = store.load();
|
||||||
|
await store.save({ globalSettings: { ...current.globalSettings, pendingQueue: { savedAt: 1, queueJobs: [] } } });
|
||||||
|
assert.ok(store.load().globalSettings.pendingQueue);
|
||||||
|
const c2 = store.load();
|
||||||
|
await store.save({ globalSettings: { ...c2.globalSettings, pendingQueue: null } });
|
||||||
|
assert.equal(store.load().globalSettings.pendingQueue, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('regression: legacy sessionLog:true on disk normalizes to logMode "daily" (NOT "session")', async () => {
|
||||||
|
// Write a config with the legacy boolean only (what an existing user has).
|
||||||
|
await store.save({ globalSettings: { sessionLog: true } });
|
||||||
|
const config = store.load();
|
||||||
|
// The misnamed legacy field MUST map to daily — mapping to "session" would
|
||||||
|
// silently change every per-day user's behaviour on upgrade.
|
||||||
|
assert.equal(config.globalSettings.logMode, 'daily');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logMode round-trips for all three values', async () => {
|
||||||
|
for (const mode of ['single', 'daily', 'session']) {
|
||||||
|
await store.save({ globalSettings: { logMode: mode } });
|
||||||
|
const config = store.load();
|
||||||
|
assert.equal(config.globalSettings.logMode, mode, `mode ${mode}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('load merges with defaults for missing hosters', () => {
|
||||||
|
// Write partial config in old single-object format (triggers migration)
|
||||||
|
fs.writeFileSync(store.filePath, JSON.stringify({
|
||||||
|
hosters: { 'doodstream.com': { apiKey: 'abc' } }
|
||||||
|
}), 'utf-8');
|
||||||
|
|
||||||
|
const config = store.load();
|
||||||
|
// Old format is migrated to array
|
||||||
|
assert.ok(Array.isArray(config.hosters['doodstream.com']));
|
||||||
|
assert.equal(config.hosters['doodstream.com'][0].apiKey, 'abc');
|
||||||
|
// Other hosters should still have defaults (empty arrays)
|
||||||
|
assert.ok(Array.isArray(config.hosters['voe.sx']));
|
||||||
|
assert.equal(config.hosters['voe.sx'].length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hosterSettings merge fills gaps with defaults', () => {
|
||||||
|
fs.writeFileSync(store.filePath, JSON.stringify({
|
||||||
|
hosterSettings: { 'voe.sx': { retries: 5 } }
|
||||||
|
}), 'utf-8');
|
||||||
|
|
||||||
|
const config = store.load();
|
||||||
|
assert.equal(config.hosterSettings['voe.sx'].retries, 5);
|
||||||
|
assert.equal(config.hosterSettings['voe.sx'].parallelCount, 2); // default
|
||||||
|
assert.equal(config.hosterSettings['voe.sx'].maxSpeedKbs, 0); // default
|
||||||
|
assert.equal(config.hosterSettings['voe.sx'].logToFile, true); // default on
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logToFile defaults to true for every hoster', () => {
|
||||||
|
const config = store.load();
|
||||||
|
for (const name of ['doodstream.com', 'voe.sx', 'vidmoly.me', 'byse.sx', 'clouddrop.cc']) {
|
||||||
|
assert.equal(config.hosterSettings[name].logToFile, true, `${name} should default logToFile=true`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sizeMemoEnabled defaults to true for every hoster and persists when disabled', async () => {
|
||||||
|
const fresh = store.load();
|
||||||
|
for (const name of ['doodstream.com', 'voe.sx', 'vidmoly.me', 'byse.sx', 'clouddrop.cc']) {
|
||||||
|
assert.equal(fresh.hosterSettings[name].sizeMemoEnabled, true, `${name} should default sizeMemoEnabled=true`);
|
||||||
|
}
|
||||||
|
await store.save({ hosterSettings: { 'byse.sx': { sizeMemoEnabled: false } } });
|
||||||
|
const config = store.load();
|
||||||
|
assert.equal(config.hosterSettings['byse.sx'].sizeMemoEnabled, false, 'explicit false preserved');
|
||||||
|
assert.equal(config.hosterSettings['voe.sx'].sizeMemoEnabled, true, 'other hoster still defaults on');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logToFile=false persists and survives reload', async () => {
|
||||||
|
await store.save({ hosterSettings: { 'voe.sx': { logToFile: false } } });
|
||||||
|
const config = store.load();
|
||||||
|
assert.equal(config.hosterSettings['voe.sx'].logToFile, false, 'explicit false preserved');
|
||||||
|
assert.equal(config.hosterSettings['byse.sx'].logToFile, true, 'other hoster still defaults on');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('save only updates provided sections', async () => {
|
||||||
|
// Save hoster settings first
|
||||||
|
await store.save({ hosterSettings: { 'doodstream.com': { retries: 10, maxSpeedKbs: 0, parallelCount: 2, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 } } });
|
||||||
|
// Save hosters credentials separately (array format)
|
||||||
|
await store.save({ hosters: { 'doodstream.com': [{ id: 'test-1', enabled: true, authType: 'api', apiKey: 'key123' }] } });
|
||||||
|
|
||||||
|
const config = store.load();
|
||||||
|
assert.equal(config.hosters['doodstream.com'][0].apiKey, 'key123');
|
||||||
|
assert.equal(config.hosterSettings['doodstream.com'].retries, 10); // preserved
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appendHistory keeps complete history without truncation', async () => {
|
||||||
|
for (let i = 0; i < 105; i++) {
|
||||||
|
await store.appendHistory({ id: `batch-${i}`, timestamp: new Date().toISOString(), files: [] });
|
||||||
|
}
|
||||||
|
const history = store.loadHistory();
|
||||||
|
assert.equal(history.length, 105);
|
||||||
|
assert.equal(history[0].id, 'batch-0');
|
||||||
|
assert.equal(history[104].id, 'batch-104');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clearHistory empties the array', async () => {
|
||||||
|
await store.appendHistory({ id: 'test', files: [] });
|
||||||
|
assert.equal(store.loadHistory().length, 1);
|
||||||
|
await store.clearHistory();
|
||||||
|
assert.equal(store.loadHistory().length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rotationCursors default to an empty object', () => {
|
||||||
|
const config = store.load();
|
||||||
|
assert.deepEqual(config.rotationCursors, {});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('saveRotationCursors round-trips and survives reload', async () => {
|
||||||
|
await store.saveRotationCursors({ 'byse.sx': 7, 'voe.sx': 2 });
|
||||||
|
const config = store.load();
|
||||||
|
assert.equal(config.rotationCursors['byse.sx'], 7);
|
||||||
|
assert.equal(config.rotationCursors['voe.sx'], 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an unrelated save() does not clobber persisted rotationCursors', async () => {
|
||||||
|
await store.saveRotationCursors({ 'byse.sx': 3 });
|
||||||
|
await store.save({ globalSettings: { alwaysOnTop: true } });
|
||||||
|
const config = store.load();
|
||||||
|
assert.equal(config.rotationCursors['byse.sx'], 3, 'cursor preserved across a settings save');
|
||||||
|
assert.equal(config.globalSettings.alwaysOnTop, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('saveRotationCursors does not disturb credentials', async () => {
|
||||||
|
await store.save({ hosters: { 'byse.sx': [{ id: 'k1', enabled: true, authType: 'api', apiKey: 'secret-key' }] } });
|
||||||
|
await store.saveRotationCursors({ 'byse.sx': 1 });
|
||||||
|
const config = store.load();
|
||||||
|
assert.equal(config.hosters['byse.sx'][0].apiKey, 'secret-key');
|
||||||
|
assert.equal(config.rotationCursors['byse.sx'], 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('corrupted JSON falls back to defaults', () => {
|
||||||
|
fs.writeFileSync(store.filePath, '{invalid json!!!', 'utf-8');
|
||||||
|
const config = store.load();
|
||||||
|
assert.ok(config.hosters);
|
||||||
|
assert.ok(config.hosterSettings);
|
||||||
|
assert.deepEqual(config.history, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('globalSettings merge preserves partial values', () => {
|
||||||
|
fs.writeFileSync(store.filePath, JSON.stringify({
|
||||||
|
globalSettings: { alwaysOnTop: true }
|
||||||
|
}), 'utf-8');
|
||||||
|
|
||||||
|
const config = store.load();
|
||||||
|
assert.equal(config.globalSettings.alwaysOnTop, true);
|
||||||
|
assert.equal(config.globalSettings.shutdownAfterFinish, 'nothing'); // default
|
||||||
|
assert.equal(config.globalSettings.resumeQueueOnLaunch, true);
|
||||||
|
assert.equal(config.globalSettings.parallelUploadCount, 0);
|
||||||
|
assert.equal(config.globalSettings.scaleParallelUploads, false);
|
||||||
|
assert.equal(config.globalSettings.logFilePath, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('concurrent saves preserve both sections', async () => {
|
||||||
|
const save1 = store.save({ hosters: { 'doodstream.com': [{ id: 'c1', enabled: true, authType: 'api', apiKey: 'concurrent-key' }] } });
|
||||||
|
const save2 = store.save({ globalSettings: { alwaysOnTop: true } });
|
||||||
|
await Promise.all([save1, save2]);
|
||||||
|
const config = store.load();
|
||||||
|
assert.equal(config.hosters['doodstream.com'][0].apiKey, 'concurrent-key');
|
||||||
|
assert.equal(config.globalSettings.alwaysOnTop, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('load() returns independent clones — mutating one result must not leak into the cache', () => {
|
||||||
|
store.load(); // warm the cache
|
||||||
|
const a = store.load();
|
||||||
|
a.globalSettings.alwaysOnTop = true;
|
||||||
|
a.hosters['voe.sx'].push({ id: 'mutant' });
|
||||||
|
a.history.push({ id: 'ghost' });
|
||||||
|
const b = store.load();
|
||||||
|
assert.equal(b.globalSettings.alwaysOnTop, false, 'mutating a prior load() result must not corrupt the cache');
|
||||||
|
assert.equal(b.hosters['voe.sx'].length, 0);
|
||||||
|
assert.equal(b.history.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('load() reflects an external file change (mtime/size cache invalidation)', () => {
|
||||||
|
store.load(); // warm cache on the no-file defaults
|
||||||
|
fs.writeFileSync(store.filePath, JSON.stringify({ globalSettings: { alwaysOnTop: true } }), 'utf-8');
|
||||||
|
assert.equal(store.load().globalSettings.alwaysOnTop, true, 'an external write must invalidate the cache');
|
||||||
|
fs.writeFileSync(store.filePath, JSON.stringify({ globalSettings: { alwaysOnTop: false } }), 'utf-8');
|
||||||
|
assert.equal(store.load().globalSettings.alwaysOnTop, false, 'a second external write must be seen too');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('save() invalidates the cache so the next load() sees the new value', async () => {
|
||||||
|
assert.equal(store.load().globalSettings.alwaysOnTop, false);
|
||||||
|
await store.save({ globalSettings: { alwaysOnTop: true } });
|
||||||
|
assert.equal(store.load().globalSettings.alwaysOnTop, true, 'load() after save() must reflect the write');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('backup recovery when main file is corrupted', () => {
|
||||||
|
// Write valid config first
|
||||||
|
fs.writeFileSync(store.filePath, JSON.stringify({
|
||||||
|
hosters: { 'doodstream.com': [{ id: 'bak-1', authType: 'api', apiKey: 'from-backup' }] },
|
||||||
|
hosterSettings: {}, globalSettings: {}, history: []
|
||||||
|
}), 'utf-8');
|
||||||
|
// Copy to backup
|
||||||
|
fs.copyFileSync(store.filePath, store.filePath + '.bak');
|
||||||
|
// Corrupt main file
|
||||||
|
fs.writeFileSync(store.filePath, 'CORRUPTED!!!', 'utf-8');
|
||||||
|
const config = store.load();
|
||||||
|
assert.equal(config.hosters['doodstream.com'][0].apiKey, 'from-backup');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wipe-guard: a settings-only save recovers accounts from .bak when the live config validly has none', async () => {
|
||||||
|
// Post-wipe state: live config parses fine but has empty hosters; a backup still holds the accounts.
|
||||||
|
fs.writeFileSync(store.filePath, JSON.stringify({ hosters: {}, hosterSettings: {}, globalSettings: {}, history: [] }), 'utf-8');
|
||||||
|
fs.writeFileSync(store.filePath + '.bak', JSON.stringify({
|
||||||
|
hosters: { 'voe.sx': [{ id: 'v1', authType: 'api', apiKey: 'survive-key' }] },
|
||||||
|
hosterSettings: {}, globalSettings: {}, history: []
|
||||||
|
}), 'utf-8');
|
||||||
|
await store.save({ globalSettings: { alwaysOnTop: true } });
|
||||||
|
const cfg = store.load();
|
||||||
|
assert.ok(cfg.hosters['voe.sx'] && cfg.hosters['voe.sx'].length === 1, 'guard must restore accounts from .bak, not persist the wipe');
|
||||||
|
assert.equal(cfg.hosters['voe.sx'][0].apiKey, 'survive-key');
|
||||||
|
assert.equal(cfg.globalSettings.alwaysOnTop, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wipe-guard: an explicit save({hosters:{}}) (user deleted all) is NOT blocked', async () => {
|
||||||
|
await store.save({ hosters: { 'doodstream.com': [{ id: 'd1', authType: 'api', apiKey: 'k' }] } });
|
||||||
|
await store.save({ hosters: {} });
|
||||||
|
const cfg = store.load();
|
||||||
|
assert.equal((cfg.hosters['doodstream.com'] || []).length, 0, 'an intentional hosters write must be allowed to empty them');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ConfigStore history split (electron-history.json)', () => {
|
||||||
|
let dir;
|
||||||
|
let s;
|
||||||
|
|
||||||
|
function makeStore() {
|
||||||
|
const st = new ConfigStore({ isPackaged: false, getPath: () => dir });
|
||||||
|
st.filePath = path.join(dir, 'electron-config.json');
|
||||||
|
st.historyPath = path.join(dir, 'electron-history.json');
|
||||||
|
return st;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeConfigWithHistory(n) {
|
||||||
|
const history = [];
|
||||||
|
for (let i = 0; i < n; i++) history.push({ id: `batch-${i}`, timestamp: 1750000000000 + i, total: 3, files: [{ name: `f${i}.mkv` }] });
|
||||||
|
fs.writeFileSync(path.join(dir, 'electron-config.json'), JSON.stringify({
|
||||||
|
hosters: { 'byse.sx': [{ id: 'a1', authType: 'api', apiKey: 'k' }] },
|
||||||
|
hosterSettings: {}, globalSettings: { historyRetention: 'all' }, history
|
||||||
|
}), 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cfg-hist-')); s = makeStore(); });
|
||||||
|
afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
|
||||||
|
|
||||||
|
it('migration moves history into electron-history.json, preserving every entry', () => {
|
||||||
|
writeConfigWithHistory(50);
|
||||||
|
s._migrateHistory();
|
||||||
|
assert.equal(s._historyMigrated, true);
|
||||||
|
assert.ok(fs.existsSync(s.historyPath));
|
||||||
|
const hist = JSON.parse(fs.readFileSync(s.historyPath, 'utf-8'));
|
||||||
|
assert.equal(hist.length, 50);
|
||||||
|
assert.equal(hist[0].id, 'batch-0');
|
||||||
|
assert.equal(hist[49].id, 'batch-49');
|
||||||
|
assert.ok(fs.existsSync(s.filePath + '.pre-history-split.bak'), 'a permanent pre-split backup is kept');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('after migration load() excludes history (cheap hot path) but loadHistory() returns the real data', () => {
|
||||||
|
writeConfigWithHistory(30);
|
||||||
|
s._migrateHistory();
|
||||||
|
assert.deepEqual(s.load().history, [], 'history is not carried in the always-loaded config');
|
||||||
|
assert.equal(s.loadHistory().length, 30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appendHistory writes to history.json; the next config write strips stale history from the config file', async () => {
|
||||||
|
writeConfigWithHistory(10);
|
||||||
|
s._migrateHistory();
|
||||||
|
await s.appendHistory({ id: 'new-batch', timestamp: 1750000099999, total: 1, files: [{ name: 'x.mkv' }] });
|
||||||
|
assert.equal(s.loadHistory().length, 11, 'append goes to history.json');
|
||||||
|
await s.save({ globalSettings: { alwaysOnTop: true } });
|
||||||
|
const onDisk = JSON.parse(fs.readFileSync(s.filePath, 'utf-8'));
|
||||||
|
assert.ok(!onDisk.history || onDisk.history.length === 0, 'a config write strips stale history from the config file');
|
||||||
|
assert.equal(s.loadHistory().length, 11, 'history.json is unaffected by the config write');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('save({globalSettings}) after migration NEVER loses history (data-loss invariant)', async () => {
|
||||||
|
writeConfigWithHistory(40);
|
||||||
|
s._migrateHistory();
|
||||||
|
await s.save({ globalSettings: { alwaysOnTop: true } });
|
||||||
|
assert.equal(s.loadHistory().length, 40, 'a settings write must not touch history');
|
||||||
|
assert.equal(s.load().globalSettings.alwaysOnTop, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clearHistory empties history.json only', async () => {
|
||||||
|
writeConfigWithHistory(20);
|
||||||
|
s._migrateHistory();
|
||||||
|
await s.clearHistory();
|
||||||
|
assert.equal(s.loadHistory().length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('migration is idempotent — re-running with history.json present does not re-derive or clobber', () => {
|
||||||
|
writeConfigWithHistory(15);
|
||||||
|
s._migrateHistory();
|
||||||
|
const after = makeStore();
|
||||||
|
after._migrateHistory();
|
||||||
|
assert.equal(after._historyMigrated, true);
|
||||||
|
assert.equal(after.loadHistory().length, 15);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('crash-window fallback: not migrated + no history.json → loadHistory reads config.history', () => {
|
||||||
|
writeConfigWithHistory(7);
|
||||||
|
assert.equal(s._historyMigrated, false);
|
||||||
|
assert.equal(s.loadHistory().length, 7, 'legacy path still serves history if migration never ran');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pruneHistory trims history.json and persists the retention setting', async () => {
|
||||||
|
writeConfigWithHistory(12);
|
||||||
|
s._migrateHistory();
|
||||||
|
const res = await s.pruneHistory('all', { dryRun: false });
|
||||||
|
assert.equal(s.loadHistory().length, 12);
|
||||||
|
assert.ok(res.keptBatches === 12);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const { createAgent } = require('../lib/diagnostics-agent');
|
||||||
|
|
||||||
|
function stubCollectors() {
|
||||||
|
const calls = [];
|
||||||
|
const mk = (name) => (a) => { calls.push([name, a]); return { name, a }; };
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
getSystemInfo: mk('getSystemInfo'),
|
||||||
|
serverHealth: mk('serverHealth'),
|
||||||
|
getConfigRedacted: mk('getConfigRedacted'),
|
||||||
|
listLogs: mk('listLogs'),
|
||||||
|
readLog: mk('readLog'),
|
||||||
|
getAppEvents: mk('getAppEvents'),
|
||||||
|
listErrors: mk('listErrors'),
|
||||||
|
getQueueState: mk('getQueueState'),
|
||||||
|
getHistory: mk('getHistory'),
|
||||||
|
getRotationState: mk('getRotationState'),
|
||||||
|
getHealth: mk('getHealth')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('agent rejects unknown ops and any write/exec-shaped op', () => {
|
||||||
|
const agent = createAgent(stubCollectors());
|
||||||
|
for (const bad of ['delete_log', 'write_config', 'run_health_check', 'exec', 'eval', '__proto__', 'set_setting', 'restart']) {
|
||||||
|
const r = agent.handle(bad, {});
|
||||||
|
assert.equal(r.ok, false, `${bad} must be rejected`);
|
||||||
|
assert.match(r.error, /unknown or non-readonly/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('agent rejects inherited Object.prototype members (no whitelist bypass via the prototype chain)', () => {
|
||||||
|
const agent = createAgent(stubCollectors());
|
||||||
|
for (const proto of ['constructor', 'toString', 'valueOf', 'hasOwnProperty', 'isPrototypeOf', 'toLocaleString']) {
|
||||||
|
const r = agent.handle(proto, {});
|
||||||
|
assert.equal(r.ok, false, `${proto} (inherited) must NOT be treated as an op`);
|
||||||
|
}
|
||||||
|
for (const bad of [null, undefined, 42, {}, ['read_log']]) {
|
||||||
|
assert.equal(agent.handle(bad, {}).ok, false, `non-string op ${JSON.stringify(bad)} must be rejected`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('agent maps each whitelisted op to its collector and is read-only only', () => {
|
||||||
|
const stub = stubCollectors();
|
||||||
|
const agent = createAgent(stub);
|
||||||
|
assert.equal(agent.handle('server_health', { errorLimit: 5 }).ok, true);
|
||||||
|
assert.equal(agent.handle('read_log', { name: 'debug' }).ok, true);
|
||||||
|
assert.equal(agent.handle('tail_log', { name: 'debug' }).ok, true, 'tail_log aliases read_log');
|
||||||
|
assert.equal(agent.handle('get_config_redacted', {}).ok, true);
|
||||||
|
const ops = new Set(agent.ops);
|
||||||
|
assert.ok(!ops.has('run_health_check'), 'no live probe op in this build');
|
||||||
|
for (const op of agent.ops) assert.ok(!/write|delete|set_|exec|restart|cancel|retry/.test(op), `${op} must be read-only`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('agent surfaces a collector ok:false verbatim and never throws', () => {
|
||||||
|
const agent = createAgent({ readLog: () => ({ ok: false, error: 'unknown or non-readable log: x' }), getSystemInfo: () => { throw new Error('boom'); } });
|
||||||
|
assert.equal(agent.handle('read_log', { name: 'x' }).ok, false);
|
||||||
|
const thrown = agent.handle('get_system_info', {});
|
||||||
|
assert.equal(thrown.ok, false);
|
||||||
|
assert.match(thrown.error, /boom/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
const support = require('../lib/support-bundle');
|
||||||
|
const stats = require('../lib/stats');
|
||||||
|
const { createCollectors } = require('../lib/diagnostics-collectors');
|
||||||
|
const { createAgent } = require('../lib/diagnostics-agent');
|
||||||
|
|
||||||
|
const fixtureSecrets = {
|
||||||
|
diagnosticToken: ['fixture', 'diagnostic', 'token', '123456'].join('-'),
|
||||||
|
bearerToken: ['fixture', 'bearer', 'token', '123456'].join('-'),
|
||||||
|
doodstreamKey: ['fixture', 'doodstream', 'key', '99999'].join('-'),
|
||||||
|
password: ['fixture', 'password', 'not', 'real'].join('-'),
|
||||||
|
apiKey: ['fixture', 'api', 'key', '1234567'].join('-'),
|
||||||
|
webhookToken: ['fixture', 'webhook', 'token', '123456'].join('-')
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeFixture() {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-diag-'));
|
||||||
|
const paths = {
|
||||||
|
fileuploader: path.join(dir, 'fileuploader.log'),
|
||||||
|
debug: path.join(dir, 'debug.log'),
|
||||||
|
accountRotation: path.join(dir, 'account-rotation.log'),
|
||||||
|
doodstreamDebug: path.join(dir, 'doodstream-debug.log'),
|
||||||
|
crashLog: path.join(dir, 'crash.log'),
|
||||||
|
logDir: dir
|
||||||
|
};
|
||||||
|
fs.writeFileSync(paths.debug, `boot ok\nuploading file with token ${fixtureSecrets.diagnosticToken} inline\nAuthorization: Bearer ${fixtureSecrets.bearerToken}\n`);
|
||||||
|
fs.writeFileSync(paths.doodstreamDebug, `api_key=${fixtureSecrets.doodstreamKey} sess=abc\n`);
|
||||||
|
fs.writeFileSync(paths.crashLog, 'CRASH at 12:00\n');
|
||||||
|
const config = {
|
||||||
|
hosters: { 'voe.sx': [{ id: 'a1', username: 'u', password: fixtureSecrets.password }], 'byse.sx': [{ id: 'b1', apiKey: fixtureSecrets.apiKey }] },
|
||||||
|
hosterSettings: {},
|
||||||
|
globalSettings: {
|
||||||
|
webhookUrl: `https://discord.com/api/webhooks/12345/${fixtureSecrets.webhookToken}`,
|
||||||
|
diagnostics: { enabled: true, port: 9110, token: fixtureSecrets.diagnosticToken, bindAddress: '127.0.0.1' },
|
||||||
|
pendingQueue: { savedAt: 1, selectedUploadHosters: ['voe.sx'], selectedFiles: [{ path: 'C:/a.mkv' }], queueJobs: [{ file: 'C:/a.mkv', fileName: 'a.mkv', hoster: 'voe.sx', status: 'error', error: 'timeout' }] }
|
||||||
|
},
|
||||||
|
history: [{ timestamp: new Date(2026, 0, 1).toISOString(), files: [{ name: 'x.mkv', results: [{ hoster: 'voe.sx', status: 'error', error: 'Not video file format' }, { hoster: 'byse.sx', status: 'done', url: 'https://byse.sx/x' }] }] }],
|
||||||
|
rotationCursors: { 'voe.sx': 1 }
|
||||||
|
};
|
||||||
|
const collectors = createCollectors({
|
||||||
|
loadConfig: () => JSON.parse(JSON.stringify(config)),
|
||||||
|
getAllLogPaths: () => paths,
|
||||||
|
support, stats,
|
||||||
|
appInfo: () => ({ name: 'mhu', version: '9.9.9' }),
|
||||||
|
systemInfo: () => ({ platform: 'win32', hostname: 'srv' }),
|
||||||
|
agentInfo: () => ({ version: '9.9.9', port: 9110, clientCount: 0, lastAccess: null })
|
||||||
|
});
|
||||||
|
return { dir, paths, config, collectors };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('getConfigRedacted strips password/apiKey/token/webhookUrl and value-scrubs the token mid-string', () => {
|
||||||
|
const { collectors } = makeFixture();
|
||||||
|
const out = collectors.getConfigRedacted({ section: 'all' });
|
||||||
|
const json = JSON.stringify(out);
|
||||||
|
assert.ok(!json.includes(fixtureSecrets.password), 'password must be redacted');
|
||||||
|
assert.ok(!json.includes(fixtureSecrets.apiKey), 'apiKey must be redacted');
|
||||||
|
assert.ok(!json.includes(fixtureSecrets.diagnosticToken), 'diag token must be redacted');
|
||||||
|
assert.ok(!json.includes(fixtureSecrets.webhookToken), 'webhook secret must be redacted');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getHistory reads loadHistory (migrated mode: loadConfig().history is empty)', () => {
|
||||||
|
const c = createCollectors({
|
||||||
|
loadConfig: () => ({ hosters: {}, globalSettings: {}, history: [] }),
|
||||||
|
loadHistory: () => [
|
||||||
|
{ timestamp: '2026-01-01T00:00:00.000Z', files: [{ name: 'a.mkv', results: [{ hoster: 'voe.sx', status: 'done', url: 'https://voe.sx/a' }] }] },
|
||||||
|
{ timestamp: '2026-01-02T00:00:00.000Z', files: [{ name: 'b.mkv', results: [{ hoster: 'byse.sx', status: 'done', url: 'https://byse.sx/b' }] }] }
|
||||||
|
],
|
||||||
|
getAllLogPaths: () => ({ logDir: os.tmpdir() }),
|
||||||
|
support, stats,
|
||||||
|
appInfo: () => ({}), systemInfo: () => ({}), agentInfo: () => ({})
|
||||||
|
});
|
||||||
|
const out = c.getHistory({ limit: 10 });
|
||||||
|
assert.equal(out.totalBatches, 2, 'must report real history from loadHistory, not the empty load().history');
|
||||||
|
assert.equal(out.returned, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getHistory falls back to loadConfig().history when loadHistory is absent (legacy mode)', () => {
|
||||||
|
const c = createCollectors({
|
||||||
|
loadConfig: () => ({ hosters: {}, globalSettings: {}, history: [{ timestamp: '2026-01-01T00:00:00.000Z', files: [] }] }),
|
||||||
|
getAllLogPaths: () => ({ logDir: os.tmpdir() }),
|
||||||
|
support, stats,
|
||||||
|
appInfo: () => ({}), systemInfo: () => ({}), agentInfo: () => ({})
|
||||||
|
});
|
||||||
|
assert.equal(c.getHistory({ limit: 10 }).totalBatches, 1, 'legacy path reads load().history when loadHistory not injected');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('readLog redacts a planted token and a Bearer line; doodstream is NOT readable; unknown name rejected', () => {
|
||||||
|
const { collectors } = makeFixture();
|
||||||
|
const dbg = collectors.readLog({ name: 'debug', tailKb: 64 });
|
||||||
|
assert.ok(!dbg.content.includes(fixtureSecrets.diagnosticToken), 'value-scrub removes the live diag token from logs');
|
||||||
|
assert.ok(!dbg.content.includes(fixtureSecrets.bearerToken), 'pattern-scrub removes Authorization Bearer');
|
||||||
|
assert.equal(collectors.readLog({ name: 'doodstreamDebug' }).ok, false, 'doodstream-debug.log is not in the readable allowlist');
|
||||||
|
assert.equal(collectors.readLog({ name: '../../etc/passwd' }).ok, false, 'arbitrary names are rejected (no path traversal)');
|
||||||
|
assert.equal(collectors.readLog({ name: 'crash' }).name, 'crash');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('readLog grep is case-insensitive substring with | alternation, and is ReDoS-safe', () => {
|
||||||
|
const { paths } = makeFixture();
|
||||||
|
const fs2 = require('fs');
|
||||||
|
fs2.writeFileSync(paths.debug, ['ERROR upload failed', 'info all good', 'WARN timeout hit', 'a'.repeat(120) + '! catastrophic bait'].join('\n'));
|
||||||
|
const { collectors } = (() => {
|
||||||
|
const support2 = require('../lib/support-bundle');
|
||||||
|
const stats2 = require('../lib/stats');
|
||||||
|
const c = require('../lib/diagnostics-collectors').createCollectors({
|
||||||
|
loadConfig: () => ({ hosters: {}, globalSettings: {}, history: [] }),
|
||||||
|
getAllLogPaths: () => paths, support: support2, stats: stats2,
|
||||||
|
appInfo: () => ({}), systemInfo: () => ({}), agentInfo: () => ({})
|
||||||
|
});
|
||||||
|
return { collectors: c };
|
||||||
|
})();
|
||||||
|
const alt = collectors.readLog({ name: 'debug', grep: 'error|timeout' });
|
||||||
|
assert.equal(alt.matchedLines, 2, 'matches the ERROR and timeout lines case-insensitively');
|
||||||
|
assert.ok(alt.content.includes('ERROR upload failed') && alt.content.includes('WARN timeout hit'));
|
||||||
|
assert.ok(!alt.content.includes('info all good'), 'non-matching line excluded');
|
||||||
|
const t0 = Date.now();
|
||||||
|
const redos = collectors.readLog({ name: 'debug', grep: '(a+)+$' });
|
||||||
|
assert.ok(Date.now() - t0 < 1000, 'catastrophic-looking grep must return promptly (literal substring, no backtracking)');
|
||||||
|
assert.equal(redos.matchedLines, 0, '"(a+)+$" is treated as a literal substring, matching nothing here');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getQueueState flags stale=true for the persisted snapshot and counts by status', () => {
|
||||||
|
const { collectors } = makeFixture();
|
||||||
|
const q = collectors.getQueueState({});
|
||||||
|
assert.equal(q.source, 'persisted');
|
||||||
|
assert.equal(q.stale, true);
|
||||||
|
assert.equal(q.counts.error, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getQueueState (includeJobs default) pattern-scrubs an opaque token in a job error that is NOT a config secret', () => {
|
||||||
|
const opaqueToken = ['fixture', 'opaque', 'token', '9988'].join('_');
|
||||||
|
const config = {
|
||||||
|
hosters: {}, hosterSettings: {},
|
||||||
|
globalSettings: { pendingQueue: { savedAt: 1, selectedUploadHosters: [], selectedFiles: [], queueJobs: [
|
||||||
|
{ file: 'C:/b.mkv', fileName: 'b.mkv', hoster: 'streamtape', status: 'error', error: `upload rejected: token=${opaqueToken}` }
|
||||||
|
] } },
|
||||||
|
history: [], rotationCursors: {}
|
||||||
|
};
|
||||||
|
const collectors = createCollectors({
|
||||||
|
loadConfig: () => JSON.parse(JSON.stringify(config)),
|
||||||
|
getAllLogPaths: () => ({ logDir: os.tmpdir() }),
|
||||||
|
support, stats,
|
||||||
|
appInfo: () => ({}), systemInfo: () => ({}), agentInfo: () => ({})
|
||||||
|
});
|
||||||
|
const q = collectors.getQueueState({});
|
||||||
|
const json = JSON.stringify(q);
|
||||||
|
assert.ok(!json.includes(opaqueToken), 'opaque token in a job error must be pattern-scrubbed even on the default includeJobs path');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('listErrors classifies via stats.classifyErrorCategory and redacts error text', () => {
|
||||||
|
const { collectors } = makeFixture();
|
||||||
|
const e = collectors.listErrors({});
|
||||||
|
assert.equal(e.total, 1, 'only the non-done result is an error');
|
||||||
|
assert.equal(e.byCategory['file-rejected'], 1, '"Not video file format" -> file-rejected');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('serverHealth assembles the one-shot hub without leaking secrets', () => {
|
||||||
|
const { collectors } = makeFixture();
|
||||||
|
const h = collectors.serverHealth({});
|
||||||
|
const json = JSON.stringify(h);
|
||||||
|
assert.ok(h.server && h.queue && h.errors && h.logs, 'hub has all sections');
|
||||||
|
assert.ok(!json.includes(fixtureSecrets.password) && !json.includes(fixtureSecrets.diagnosticToken) && !json.includes(fixtureSecrets.webhookToken), 'no secret leaks in server_health');
|
||||||
|
});
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const os = require('os');
|
||||||
|
const WebSocket = require('ws');
|
||||||
|
const RemoteServer = require('../lib/remote-server');
|
||||||
|
|
||||||
|
const TOKEN = 'a'.repeat(64);
|
||||||
|
|
||||||
|
function firstLanIpv4() {
|
||||||
|
for (const entry of Object.values(os.networkInterfaces())) {
|
||||||
|
for (const net of (entry || [])) {
|
||||||
|
if (net && net.family === 'IPv4' && !net.internal && net.address) return net.address;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startAgent(onDiagnosticRequest, extra) {
|
||||||
|
const srv = new RemoteServer();
|
||||||
|
return srv.start({ port: 0, host: '127.0.0.1', token: TOKEN, diagnosticMode: true, onDiagnosticRequest, ...(extra || {}) })
|
||||||
|
.then(() => srv);
|
||||||
|
}
|
||||||
|
|
||||||
|
function connect(port) {
|
||||||
|
return new WebSocket(`ws://127.0.0.1:${port}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function once(ws, type) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
ws.on('message', (raw) => { const m = JSON.parse(raw); if (m.type === type) resolve(m); });
|
||||||
|
ws.on('close', (code) => reject(new Error('closed ' + code)));
|
||||||
|
ws.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('diagnostic client: auth -> diag-request -> reqId-correlated diag-response', async () => {
|
||||||
|
const agent = await startAgent((msg, _client, reply) => {
|
||||||
|
assert.equal(msg.op, 'server_health');
|
||||||
|
reply({ ok: true, data: { hello: 'world', echo: msg.args } });
|
||||||
|
});
|
||||||
|
const port = agent.getPort();
|
||||||
|
const ws = connect(port);
|
||||||
|
await new Promise((r) => ws.on('open', r));
|
||||||
|
ws.send(JSON.stringify({ type: 'auth', token: TOKEN, role: 'diagnostic' }));
|
||||||
|
const ok = await once(ws, 'auth-ok');
|
||||||
|
assert.ok(ok.clientId);
|
||||||
|
ws.send(JSON.stringify({ type: 'diag-request', reqId: 'r1', op: 'server_health', args: { errorLimit: 3 } }));
|
||||||
|
const resp = await once(ws, 'diag-response');
|
||||||
|
assert.equal(resp.reqId, 'r1');
|
||||||
|
assert.equal(resp.ok, true);
|
||||||
|
assert.equal(resp.data.hello, 'world');
|
||||||
|
assert.equal(resp.data.echo.errorLimit, 3);
|
||||||
|
assert.equal(agent.getLastAccess() !== null, true, 'access timestamp recorded');
|
||||||
|
ws.close(); agent.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a diagnostic client NEVER triggers the screen-capture window', async () => {
|
||||||
|
let captureCreated = false;
|
||||||
|
const agent = await startAgent(() => {}, { onCreateCaptureWindow: () => { captureCreated = true; } });
|
||||||
|
const ws = connect(agent.getPort());
|
||||||
|
await new Promise((r) => ws.on('open', r));
|
||||||
|
ws.send(JSON.stringify({ type: 'auth', token: TOKEN, role: 'diagnostic' }));
|
||||||
|
await once(ws, 'auth-ok');
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
assert.equal(captureCreated, false, 'diagnosticMode must not spawn the capture window');
|
||||||
|
ws.close(); agent.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('allowlist gate (wiring): a non-loopback peer is closed 4005 when not allowlisted (fail-closed)', () => {
|
||||||
|
const srv = new RemoteServer();
|
||||||
|
const closeCodeFor = (remoteAddress, allowlist) => {
|
||||||
|
srv._config = { allowlist, token: TOKEN, diagnosticMode: true };
|
||||||
|
let closed = null;
|
||||||
|
srv._handleConnection({ close: (c) => { closed = c; }, on: () => {} }, { socket: { remoteAddress } });
|
||||||
|
return closed;
|
||||||
|
};
|
||||||
|
assert.equal(closeCodeFor('100.64.0.9', []), 4005, 'empty allowlist => non-loopback rejected (fail-closed)');
|
||||||
|
assert.equal(closeCodeFor('203.0.113.5', ['100.64.0.0/10']), 4005, 'peer outside the allowlist CIDR rejected');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a loopback diagnostic client connects even with a non-matching allowlist (loopback is always allowed)', async () => {
|
||||||
|
const agent = await startAgent(() => {}, { allowlist: ['100.64.0.0/10'] });
|
||||||
|
const ws = connect(agent.getPort());
|
||||||
|
await new Promise((r) => ws.on('open', r));
|
||||||
|
ws.send(JSON.stringify({ type: 'auth', token: TOKEN, role: 'diagnostic' }));
|
||||||
|
const ok = await once(ws, 'auth-ok');
|
||||||
|
assert.ok(ok.clientId);
|
||||||
|
ws.close(); agent.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('network bind (0.0.0.0): an allowlisted non-loopback peer connects over a real socket (the Tailscale path)', async (t) => {
|
||||||
|
const lan = firstLanIpv4();
|
||||||
|
if (!lan) { t.skip('no non-internal IPv4 interface available'); return; }
|
||||||
|
const agent = await startAgent(() => {}, { host: '0.0.0.0', allowlist: [lan] });
|
||||||
|
const port = agent.getPort();
|
||||||
|
const ws = new WebSocket(`ws://${lan}:${port}`);
|
||||||
|
try {
|
||||||
|
await new Promise((resolve, reject) => { ws.on('open', resolve); ws.on('error', reject); });
|
||||||
|
ws.send(JSON.stringify({ type: 'auth', token: TOKEN, role: 'diagnostic' }));
|
||||||
|
const ok = await once(ws, 'auth-ok');
|
||||||
|
assert.ok(ok.clientId, 'allowlisted LAN peer authed over the 0.0.0.0 bind');
|
||||||
|
} finally {
|
||||||
|
ws.close(); agent.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wrong token is rejected and the ip is locked out after 5 attempts', async () => {
|
||||||
|
const agent = await startAgent(() => {});
|
||||||
|
const port = agent.getPort();
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const ws = connect(port);
|
||||||
|
await new Promise((r) => ws.on('open', r));
|
||||||
|
ws.send(JSON.stringify({ type: 'auth', token: 'wrong', role: 'diagnostic' }));
|
||||||
|
await new Promise((r) => ws.on('close', r));
|
||||||
|
}
|
||||||
|
const ws = connect(port);
|
||||||
|
const closeCode = await new Promise((resolve) => ws.on('close', (c) => resolve(c)));
|
||||||
|
assert.equal(closeCode, 4003, 'locked out after 5 failed attempts');
|
||||||
|
agent.stop();
|
||||||
|
});
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
const { test, before, after } = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// Mock the undici transport BEFORE requiring hosters so the destructured
|
||||||
|
// `request` picks up our stub. apiGet (getUploadServer) uses global fetch, which
|
||||||
|
// we override per-test. This exercises the FULL doodstream API upload + recovery
|
||||||
|
// orchestration against the doc-verified response shapes — the gap between the
|
||||||
|
// already-tested parseDoodstreamResult helper and the real uploadFile path.
|
||||||
|
// (mock.module needs an experimental flag npm test doesn't pass, so we reassign
|
||||||
|
// undici.request on the module object and refresh the hosters cache instead.)
|
||||||
|
let requestRouter = async () => ({ statusCode: 200, headers: {}, body: { text: async () => '{}' } });
|
||||||
|
const undici = require('undici');
|
||||||
|
const _origUndiciRequest = undici.request;
|
||||||
|
undici.request = (...a) => requestRouter(...a);
|
||||||
|
delete require.cache[require.resolve('../lib/hosters')];
|
||||||
|
const hostersMod = require('../lib/hosters');
|
||||||
|
const { uploadFile } = hostersMod;
|
||||||
|
|
||||||
|
let tmpFile;
|
||||||
|
let origFetch;
|
||||||
|
before(() => {
|
||||||
|
tmpFile = path.join(os.tmpdir(), `dood-itest-${process.pid}.mkv`);
|
||||||
|
fs.writeFileSync(tmpFile, Buffer.alloc(2048, 7));
|
||||||
|
origFetch = global.fetch;
|
||||||
|
// Keep the "never appears" recovery test fast (real default is 12 × 2.5 s).
|
||||||
|
hostersMod.__test.DOODSTREAM_POLL.attempts = 3;
|
||||||
|
hostersMod.__test.DOODSTREAM_POLL.delayMs = 5;
|
||||||
|
});
|
||||||
|
after(() => {
|
||||||
|
global.fetch = origFetch;
|
||||||
|
undici.request = _origUndiciRequest; // restore real transport for other test files
|
||||||
|
delete require.cache[require.resolve('../lib/hosters')];
|
||||||
|
try { fs.unlinkSync(tmpFile); } catch {}
|
||||||
|
});
|
||||||
|
|
||||||
|
// getUploadServer hits /api/upload/server via global fetch.
|
||||||
|
function stubUploadServer() {
|
||||||
|
global.fetch = async (url) => {
|
||||||
|
if (/upload\/server/.test(String(url))) {
|
||||||
|
return { status: 200, text: async () => JSON.stringify({ status: 200, result: 'https://node1.cloudatacdn.com/upload/01' }) };
|
||||||
|
}
|
||||||
|
return { status: 200, text: async () => '{"status":200}' };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build an undici-style router. uploadBody is the POST result; listBodies is a
|
||||||
|
// queue consumed by successive /api/file/list calls (baseline, then polls).
|
||||||
|
function routeWith(uploadBody, listBodies = []) {
|
||||||
|
return async (url, opts) => {
|
||||||
|
const u = String(url);
|
||||||
|
if (/\/api\/file\/list/.test(u)) {
|
||||||
|
const body = listBodies.length ? listBodies.shift() : '{"status":200,"result":{"files":[]}}';
|
||||||
|
return { statusCode: 200, headers: {}, body: { text: async () => body } };
|
||||||
|
}
|
||||||
|
// Upload POST: drain the streamed body so the file handle closes.
|
||||||
|
if (opts && opts.body && typeof opts.body[Symbol.asyncIterator] === 'function') {
|
||||||
|
for await (const chunk of opts.body) { if (chunk && chunk.length === -1) break; }
|
||||||
|
}
|
||||||
|
return { statusCode: uploadBody.status, headers: { 'content-type': 'application/json' }, body: { text: async () => uploadBody.body } };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('doodstream API upload: filecode returned directly is used', async () => {
|
||||||
|
stubUploadServer();
|
||||||
|
requestRouter = routeWith({
|
||||||
|
status: 200,
|
||||||
|
body: JSON.stringify({ status: 200, result: [{ filecode: 'DOODCODE1234', download_url: 'https://doodstream.com/d/DOODCODE1234', protected_embed: 'https://doodstream.com/e/DOODCODE1234' }] })
|
||||||
|
});
|
||||||
|
const res = await uploadFile('doodstream.com', tmpFile, 'VALIDKEY', null, null, null);
|
||||||
|
assert.equal(res.file_code, 'DOODCODE1234');
|
||||||
|
assert.equal(res.download_url, 'https://doodstream.com/d/DOODCODE1234');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('doodstream API upload: codeless result recovered via file-list name match', async () => {
|
||||||
|
stubUploadServer();
|
||||||
|
const fileName = path.basename(tmpFile).replace(/\.[^.]+$/, ''); // title doodstream stores
|
||||||
|
requestRouter = routeWith(
|
||||||
|
{ status: 200, body: JSON.stringify({ status: 200, msg: 'OK' }) }, // codeless upload
|
||||||
|
[
|
||||||
|
'{"status":200,"result":{"files":[]}}', // baseline (pre-upload)
|
||||||
|
`{"status":200,"result":{"files":[{"file_code":"RECOVER9999","title":"${fileName}"}]}}` // poll finds it
|
||||||
|
]
|
||||||
|
);
|
||||||
|
const res = await uploadFile('doodstream.com', tmpFile, 'VALIDKEY', null, null, null);
|
||||||
|
assert.equal(res.file_code, 'RECOVER9999');
|
||||||
|
assert.equal(res.download_url, 'https://doodstream.com/d/RECOVER9999');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('doodstream API upload: codeless + file never appears → throws hosterTransient (no account poison)', async () => {
|
||||||
|
stubUploadServer();
|
||||||
|
requestRouter = routeWith(
|
||||||
|
{ status: 200, body: JSON.stringify({ status: 200, msg: 'OK' }) },
|
||||||
|
[] // every file/list returns empty
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
() => uploadFile('doodstream.com', tmpFile, 'VALIDKEY', null, null, null),
|
||||||
|
(err) => {
|
||||||
|
assert.equal(err.hosterTransient, true, 'codeless result must be tagged hosterTransient');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const DoodstreamUploader = require('../lib/doodstream-upload');
|
||||||
|
|
||||||
|
// The CDN hands back an XFileSharing form. `fn` is the filecode, `st` is the
|
||||||
|
// status ("OK" on success, an error string when the backend refuses the file).
|
||||||
|
// These tests pin the parse/error behaviour of _parseUploadResponse without
|
||||||
|
// touching the network — _fetch is stubbed to return the upload_result page.
|
||||||
|
function cdnForm({ fn = '', st = 'OK' } = {}) {
|
||||||
|
return `<HTML><BODY><Form name='F1' action='https://cdn.example/' method='POST'>` +
|
||||||
|
`<textarea name="op">upload_result</textarea>` +
|
||||||
|
`<textarea name="fn">${fn}</textarea>` +
|
||||||
|
`<textarea name="st">${st}</textarea>` +
|
||||||
|
`</Form></BODY></HTML>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_RESULT = '<textarea id="copy_dl" readonly class="form-control" rows="5"></textarea>';
|
||||||
|
const LINK_RESULT = (code) => `<textarea id="copy_dl" readonly class="form-control" rows="5">https://myvidplay.com/d/${code}</textarea>`;
|
||||||
|
|
||||||
|
function uploaderWithResult(resultHtml) {
|
||||||
|
const up = new DoodstreamUploader();
|
||||||
|
up._lastUploadUrl = 'https://cdn.example/upload/01';
|
||||||
|
// Stub the second-step submit so no real request goes out.
|
||||||
|
up._fetch = async () => ({ text: async () => resultHtml });
|
||||||
|
return up;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('rejected file: empty fn + non-OK st surfaces the real status', async () => {
|
||||||
|
const up = uploaderWithResult(EMPTY_RESULT);
|
||||||
|
await assert.rejects(
|
||||||
|
() => up._parseUploadResponse(cdnForm({ fn: '', st: 'Error: file already exists' })),
|
||||||
|
(err) => {
|
||||||
|
assert.match(err.message, /lehnt Datei ab/);
|
||||||
|
assert.match(err.message, /file already exists/);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty fn + st OK: generic error still reports st, fn-state and CDN node', async () => {
|
||||||
|
const up = uploaderWithResult(EMPTY_RESULT);
|
||||||
|
await assert.rejects(
|
||||||
|
() => up._parseUploadResponse(cdnForm({ fn: '', st: 'OK' })),
|
||||||
|
(err) => {
|
||||||
|
assert.match(err.message, /kein Filecode/);
|
||||||
|
assert.match(err.message, /st=OK/);
|
||||||
|
assert.match(err.message, /fehlt\/leer/);
|
||||||
|
assert.match(err.message, /cdn\.example/);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('valid fn but empty result page: still resolves via fn (no regression)', async () => {
|
||||||
|
const up = uploaderWithResult(EMPTY_RESULT);
|
||||||
|
const res = await up._parseUploadResponse(cdnForm({ fn: '7mnp8xna3123', st: 'OK' }));
|
||||||
|
assert.equal(res.file_code, '7mnp8xna3123');
|
||||||
|
assert.equal(res.download_url, 'https://doodstream.com/d/7mnp8xna3123');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('happy path: link in result page wins', async () => {
|
||||||
|
const up = uploaderWithResult(LINK_RESULT('jjsuhr931ds9'));
|
||||||
|
const res = await up._parseUploadResponse(cdnForm({ fn: 'jjsuhr931ds9', st: 'OK' }));
|
||||||
|
assert.equal(res.file_code, 'jjsuhr931ds9');
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- _parseUploadFormFields: replicate the current upload form faithfully ---
|
||||||
|
test('_parseUploadFormFields extracts the real form fields and excludes the file input', () => {
|
||||||
|
const up = new DoodstreamUploader();
|
||||||
|
const html = `
|
||||||
|
<form name="file" enctype="multipart/form-data" action="https://uxg.cloudatacdn.com/upload/01?TOK" method="post">
|
||||||
|
<input type="hidden" name="sess_id" value="TOK">
|
||||||
|
<input name="file" type="file" size="30" id="filepc">
|
||||||
|
<input name="fakefilepc" class="d-none" type="text" id="fakefilepc">
|
||||||
|
<input type="text" name="file_title" class="form-control">
|
||||||
|
<button type="submit" name="submit_btn" class="btn">Upload</button>
|
||||||
|
</form>`;
|
||||||
|
const f = up._parseUploadFormFields(html);
|
||||||
|
assert.equal(f.sess_id, 'TOK');
|
||||||
|
assert.equal(f.fakefilepc, '');
|
||||||
|
assert.equal(f.file_title, '');
|
||||||
|
assert.ok('submit_btn' in f);
|
||||||
|
assert.ok(!('file' in f), 'the file input must be excluded (streamed separately)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('_parseUploadFormFields returns {} for markup without a form', () => {
|
||||||
|
const up = new DoodstreamUploader();
|
||||||
|
assert.deepEqual(up._parseUploadFormFields('<div>no form here</div>'), {});
|
||||||
|
assert.deepEqual(up._parseUploadFormFields(''), {});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- deriveApiKey: pull + validate the account API key from the web session ---
|
||||||
|
test('_extractApiKeyCandidates finds the key in an input value and ranks api-context first', () => {
|
||||||
|
const up = new DoodstreamUploader();
|
||||||
|
const csrfCandidate = ['fixture', 'csrf', 'candidate', '00000001'].join('');
|
||||||
|
const apiCandidate = ['fixture', 'api', 'candidate', '0000000001'].join('');
|
||||||
|
const html = `
|
||||||
|
<input type="text" name="csrf" value="${csrfCandidate}">
|
||||||
|
<div class="panel">API Key <input readonly value="${apiCandidate}"></div>
|
||||||
|
`;
|
||||||
|
const cands = up._extractApiKeyCandidates(html);
|
||||||
|
// The token whose preceding context mentions "API" must rank first.
|
||||||
|
assert.equal(cands[0], apiCandidate);
|
||||||
|
assert.ok(cands.includes(csrfCandidate));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('_extractApiKeyCandidates handles textarea + api_key: "x" shapes and empty input', () => {
|
||||||
|
const up = new DoodstreamUploader();
|
||||||
|
assert.deepEqual(up._extractApiKeyCandidates(''), []);
|
||||||
|
const textareaCandidate = ['fixture', 'textarea', 'candidate', '000001'].join('');
|
||||||
|
const objectCandidate = ['fixture', 'object', 'candidate', '00000001'].join('');
|
||||||
|
const ta = up._extractApiKeyCandidates(`<textarea id="k">${textareaCandidate}</textarea>`);
|
||||||
|
assert.ok(ta.includes(textareaCandidate));
|
||||||
|
const js = up._extractApiKeyCandidates(`var x = {"api_key":"${objectCandidate}"};`);
|
||||||
|
assert.ok(js.includes(objectCandidate));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deriveApiKey returns the candidate that validates against the API', async () => {
|
||||||
|
const up = new DoodstreamUploader();
|
||||||
|
const acceptedCandidate = ['fixture', 'accepted', 'candidate', '1234567890'].join('');
|
||||||
|
const rejectedCandidate = ['fixture', 'rejected', 'candidate', '0987654321'].join('');
|
||||||
|
up._fetch = async () => ({ text: async () => `<div>API Key <input value="${acceptedCandidate}"></div><input value="${rejectedCandidate}">` });
|
||||||
|
up._validateApiKey = async (key) => key === acceptedCandidate;
|
||||||
|
const key = await up.deriveApiKey();
|
||||||
|
assert.equal(key, acceptedCandidate);
|
||||||
|
assert.equal(up.apiKey, acceptedCandidate); // cached on the instance
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deriveApiKey returns null when no candidate validates (→ caller uses web fallback)', async () => {
|
||||||
|
const up = new DoodstreamUploader();
|
||||||
|
const rejectedCandidate = ['fixture', 'rejected', 'candidate', '0000000000'].join('');
|
||||||
|
up._fetch = async () => ({ text: async () => `<input value="${rejectedCandidate}">` });
|
||||||
|
up._validateApiKey = async () => false;
|
||||||
|
assert.equal(await up.deriveApiKey(), null);
|
||||||
|
assert.equal(up.apiKey, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deriveApiKey short-circuits when a key is already set', async () => {
|
||||||
|
const up = new DoodstreamUploader();
|
||||||
|
up.apiKey = 'PRESET';
|
||||||
|
let fetched = false;
|
||||||
|
up._fetch = async () => { fetched = true; return { text: async () => '' }; };
|
||||||
|
assert.equal(await up.deriveApiKey(), 'PRESET');
|
||||||
|
assert.equal(fetched, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- _fetch: transient network blips on the small requests self-heal ---
|
||||||
|
test('_fetch retries a transient network failure then succeeds', async () => {
|
||||||
|
const up = new DoodstreamUploader();
|
||||||
|
const origFetch = globalThis.fetch;
|
||||||
|
let calls = 0;
|
||||||
|
globalThis.fetch = async () => {
|
||||||
|
calls++;
|
||||||
|
if (calls === 1) throw new TypeError('fetch failed');
|
||||||
|
return { status: 200, headers: { getSetCookie: () => [], get: () => null }, text: async () => 'ok' };
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const res = await up._fetch('https://example.test/x');
|
||||||
|
assert.equal(calls, 2); // failed once, retried, succeeded
|
||||||
|
assert.equal(await res.text(), 'ok');
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = origFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- _getUploadServer: discovery must never fall back to a hardcoded node ---
|
||||||
|
function fakeRes(body, { status = 200, ctype = 'text/html' } = {}) {
|
||||||
|
return { status, headers: { get: (h) => (h.toLowerCase() === 'content-type' ? ctype : null) }, text: async () => body };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('getUploadServer: returns JSON result when present', async () => {
|
||||||
|
const up = new DoodstreamUploader();
|
||||||
|
up._fetch = async (url) => {
|
||||||
|
assert.match(url, /op=upload_server/);
|
||||||
|
return fakeRes(JSON.stringify({ result: 'https://node42.cloudatacdn.com/upload/01' }), { ctype: 'application/json' });
|
||||||
|
};
|
||||||
|
assert.equal(await up._getUploadServer(), 'https://node42.cloudatacdn.com/upload/01');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getUploadServer: falls back to srv_url in upload-page HTML', async () => {
|
||||||
|
const up = new DoodstreamUploader();
|
||||||
|
up._fetch = async (url) => {
|
||||||
|
if (/op=upload_server/.test(url)) return fakeRes('<html>not json</html>');
|
||||||
|
return fakeRes('<script>var srv_url: "https://node7.cloudatacdn.com/upload/01";</script>');
|
||||||
|
};
|
||||||
|
assert.equal(await up._getUploadServer(), 'https://node7.cloudatacdn.com/upload/01');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getUploadServer: parses current form-action node and refreshes sess_id from the same page', async () => {
|
||||||
|
const up = new DoodstreamUploader();
|
||||||
|
up.sessId = 'stale-from-login';
|
||||||
|
up._fetch = async (url) => {
|
||||||
|
if (/op=upload_server/.test(url)) return fakeRes('<html>not json</html>');
|
||||||
|
return fakeRes('<form name="file" enctype="multipart/form-data" action="https://n9.cloudatacdn.com/upload/01?FRESH123" method="post"><input type="hidden" name="sess_id" value="FRESH123"></form>');
|
||||||
|
};
|
||||||
|
const url = await up._getUploadServer();
|
||||||
|
assert.equal(url, 'https://n9.cloudatacdn.com/upload/01?FRESH123');
|
||||||
|
assert.equal(up.sessId, 'FRESH123'); // critical: form-field token must match the node URL token
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getUploadServer: un-escapes & in the form-action query string', async () => {
|
||||||
|
const up = new DoodstreamUploader();
|
||||||
|
up._fetch = async (url) => {
|
||||||
|
if (/op=upload_server/.test(url)) return fakeRes('<html>not json</html>');
|
||||||
|
return fakeRes('<form name="file" enctype="multipart/form-data" action="https://n9.cloudatacdn.com/upload/01?a=1&b=2" method="post"></form>');
|
||||||
|
};
|
||||||
|
assert.equal(await up._getUploadServer(), 'https://n9.cloudatacdn.com/upload/01?a=1&b=2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getUploadServer: throws (no silent dead fallback) when discovery fails', async () => {
|
||||||
|
const up = new DoodstreamUploader();
|
||||||
|
up._fetch = async () => fakeRes('<html><body>login required</body></html>', { status: 200 });
|
||||||
|
await assert.rejects(
|
||||||
|
() => up._getUploadServer(),
|
||||||
|
(err) => {
|
||||||
|
assert.match(err.message, /konnte Upload-Server nicht ermitteln/);
|
||||||
|
assert.doesNotMatch(err.message, /tr1128ve\.cloudatacdn\.com/); // never the hardcoded node
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
const { detectKind, isVideoLikeKind, probeFileHead, summarizeFileStat } = require('../lib/file-probe');
|
||||||
|
|
||||||
|
function tmpWrite(name, buf) {
|
||||||
|
const p = path.join(os.tmpdir(), `mhu-probe-${Date.now()}-${name}`);
|
||||||
|
fs.writeFileSync(p, buf);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('detectKind recognizes ISO-MP4 (ftyp box at offset 4)', () => {
|
||||||
|
const buf = Buffer.concat([Buffer.from([0x00, 0x00, 0x00, 0x20]), Buffer.from('ftypisom', 'ascii'), Buffer.alloc(8, 0)]);
|
||||||
|
assert.strictEqual(detectKind(buf), 'mp4-iso');
|
||||||
|
assert.strictEqual(isVideoLikeKind('mp4-iso'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detectKind recognizes Matroska / WebM EBML header', () => {
|
||||||
|
const buf = Buffer.from([0x1A, 0x45, 0xDF, 0xA3, 0x01, 0x00]);
|
||||||
|
assert.strictEqual(detectKind(buf), 'matroska');
|
||||||
|
assert.strictEqual(isVideoLikeKind('matroska'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detectKind recognizes AVI (RIFF...AVI )', () => {
|
||||||
|
const buf = Buffer.concat([Buffer.from('RIFF', 'ascii'), Buffer.from([0x00, 0x00, 0x00, 0x00]), Buffer.from('AVI ', 'ascii')]);
|
||||||
|
assert.strictEqual(detectKind(buf), 'avi');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detectKind recognizes FLV', () => {
|
||||||
|
const buf = Buffer.concat([Buffer.from('FLV', 'ascii'), Buffer.from([0x01])]);
|
||||||
|
assert.strictEqual(detectKind(buf), 'flv');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detectKind recognizes ASF (WMV)', () => {
|
||||||
|
const buf = Buffer.from([0x30, 0x26, 0xB2, 0x75, 0x00, 0x00]);
|
||||||
|
assert.strictEqual(detectKind(buf), 'asf-wmv');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detectKind recognizes MPEG-PS (00 00 01 BA)', () => {
|
||||||
|
const buf = Buffer.from([0x00, 0x00, 0x01, 0xBA, 0x00]);
|
||||||
|
assert.strictEqual(detectKind(buf), 'mpeg-ps');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detectKind recognizes JPEG (non-video)', () => {
|
||||||
|
const buf = Buffer.from([0xFF, 0xD8, 0xFF, 0xE0]);
|
||||||
|
assert.strictEqual(detectKind(buf), 'jpeg');
|
||||||
|
assert.strictEqual(isVideoLikeKind('jpeg'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detectKind recognizes HTML response (non-video)', () => {
|
||||||
|
const buf = Buffer.from('<!DOCTYPE html><html><head>', 'ascii');
|
||||||
|
assert.strictEqual(detectKind(buf), 'html');
|
||||||
|
assert.strictEqual(isVideoLikeKind('html'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detectKind returns empty for zero-length and unknown for noise', () => {
|
||||||
|
assert.strictEqual(detectKind(Buffer.alloc(0)), 'empty');
|
||||||
|
assert.strictEqual(detectKind(Buffer.from([0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF])), 'unknown');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('probeFileHead reads first bytes and returns hex + kind for an MP4-like file', async () => {
|
||||||
|
const mp4Head = Buffer.concat([Buffer.from([0x00, 0x00, 0x00, 0x20]), Buffer.from('ftypisom', 'ascii'), Buffer.alloc(16, 0xAA)]);
|
||||||
|
const p = tmpWrite('fake.mp4', mp4Head);
|
||||||
|
try {
|
||||||
|
const res = await probeFileHead(p, 64);
|
||||||
|
assert.strictEqual(res.ok, true);
|
||||||
|
assert.strictEqual(res.kind, 'mp4-iso');
|
||||||
|
assert.strictEqual(res.isVideoLike, true);
|
||||||
|
assert.ok(res.headHex.startsWith('0000002066747970'));
|
||||||
|
assert.strictEqual(res.bytesRead, mp4Head.length);
|
||||||
|
} finally {
|
||||||
|
fs.unlinkSync(p);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('probeFileHead returns ok:false with kind=unreadable for missing file', async () => {
|
||||||
|
const res = await probeFileHead(path.join(os.tmpdir(), `does-not-exist-${Date.now()}.mp4`), 32);
|
||||||
|
assert.strictEqual(res.ok, false);
|
||||||
|
assert.strictEqual(res.kind, 'unreadable');
|
||||||
|
assert.ok(res.error);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('summarizeFileStat returns size + mtime for a real file', () => {
|
||||||
|
const p = tmpWrite('stat.bin', Buffer.alloc(123, 0xCC));
|
||||||
|
try {
|
||||||
|
const stat = summarizeFileStat(p);
|
||||||
|
assert.strictEqual(stat.size, 123);
|
||||||
|
assert.strictEqual(stat.isFile, true);
|
||||||
|
assert.ok(stat.mtime);
|
||||||
|
} finally {
|
||||||
|
fs.unlinkSync(p);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('summarizeFileStat returns error for missing file', () => {
|
||||||
|
const stat = summarizeFileStat(path.join(os.tmpdir(), `does-not-exist-${Date.now()}.bin`));
|
||||||
|
assert.ok(stat.error);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detectKind requires TS sync-byte periodicity — GIF and G-prefixed text are NOT mpeg-ts', () => {
|
||||||
|
const ts = Buffer.alloc(377, 0xFF);
|
||||||
|
ts[0] = 0x47; ts[188] = 0x47; ts[376] = 0x47;
|
||||||
|
assert.strictEqual(detectKind(ts), 'mpeg-ts');
|
||||||
|
assert.strictEqual(isVideoLikeKind('mpeg-ts'), true);
|
||||||
|
|
||||||
|
const gif = Buffer.concat([Buffer.from('GIF89a', 'ascii'), Buffer.alloc(400, 0x00)]);
|
||||||
|
assert.strictEqual(detectKind(gif), 'gif');
|
||||||
|
assert.strictEqual(isVideoLikeKind('gif'), false);
|
||||||
|
|
||||||
|
const gText = Buffer.concat([Buffer.from('Gewinnerliste 2026\n', 'ascii'), Buffer.alloc(400, 0x20)]);
|
||||||
|
assert.notStrictEqual(detectKind(gText), 'mpeg-ts');
|
||||||
|
assert.strictEqual(isVideoLikeKind(detectKind(gText)), false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const { applyHistoryRetention, countHistoryRows } = require('../lib/config-store');
|
||||||
|
|
||||||
|
function batch(timestamp, okRows, extras = {}) {
|
||||||
|
const results = [];
|
||||||
|
for (let i = 0; i < okRows; i++) results.push({ status: 'success', hoster: 'voe.sx', download_url: `https://voe.sx/${i}` });
|
||||||
|
if (extras.aborted) for (let i = 0; i < extras.aborted; i++) results.push({ status: 'aborted', hoster: 'voe.sx' });
|
||||||
|
if (extras.error) for (let i = 0; i < extras.error; i++) results.push({ status: 'error', hoster: 'voe.sx' });
|
||||||
|
return { timestamp, files: [{ name: 'clip.mp4', results }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const DAY = 86400000;
|
||||||
|
|
||||||
|
test('countHistoryRows counts only non-aborted, non-error results', () => {
|
||||||
|
const h = [batch('2026-01-01', 3, { aborted: 2, error: 1 })];
|
||||||
|
assert.strictEqual(countHistoryRows(h), 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('retention "all" returns the array unchanged', () => {
|
||||||
|
const h = [batch('2026-01-01', 5), batch('2026-01-02', 5)];
|
||||||
|
assert.strictEqual(applyHistoryRetention(h, 'all', Date.parse('2026-06-01')), h);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('count policy keeps newest whole batches up to the row target', () => {
|
||||||
|
const h = [batch('2026-01-01', 400), batch('2026-01-02', 400), batch('2026-01-03', 400)];
|
||||||
|
const pruned = applyHistoryRetention(h, '1000', Date.parse('2026-06-01'));
|
||||||
|
assert.strictEqual(pruned.length, 3);
|
||||||
|
assert.strictEqual(countHistoryRows(pruned), 1200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('count policy drops older batches once target reached (newest first)', () => {
|
||||||
|
const h = [batch('2026-01-01', 600), batch('2026-01-02', 600), batch('2026-01-03', 600)];
|
||||||
|
const pruned = applyHistoryRetention(h, '1000', Date.parse('2026-06-01'));
|
||||||
|
assert.strictEqual(pruned.length, 2);
|
||||||
|
assert.deepStrictEqual(pruned.map(b => b.timestamp), ['2026-01-02', '2026-01-03']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('count policy always keeps the newest batch even if it alone exceeds N', () => {
|
||||||
|
const h = [batch('2026-01-01', 50), batch('2026-01-02', 5000)];
|
||||||
|
const pruned = applyHistoryRetention(h, '100', Date.parse('2026-06-01'));
|
||||||
|
assert.strictEqual(pruned.length, 1);
|
||||||
|
assert.strictEqual(pruned[0].timestamp, '2026-01-02');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('time policy drops batches older than the cutoff', () => {
|
||||||
|
const now = Date.parse('2026-06-15T00:00:00Z');
|
||||||
|
const h = [
|
||||||
|
batch(new Date(now - 10 * DAY).toISOString(), 5),
|
||||||
|
batch(new Date(now - 3 * DAY).toISOString(), 5),
|
||||||
|
batch(new Date(now - 1 * DAY).toISOString(), 5)
|
||||||
|
];
|
||||||
|
const pruned = applyHistoryRetention(h, '7d', now);
|
||||||
|
assert.strictEqual(pruned.length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('time policy keeps batches with missing or invalid timestamp', () => {
|
||||||
|
const now = Date.parse('2026-06-15T00:00:00Z');
|
||||||
|
const h = [
|
||||||
|
batch(undefined, 5),
|
||||||
|
batch('not-a-date', 5),
|
||||||
|
batch(new Date(now - 99 * DAY).toISOString(), 5),
|
||||||
|
batch(new Date(now - 1 * DAY).toISOString(), 5)
|
||||||
|
];
|
||||||
|
const pruned = applyHistoryRetention(h, '30d', now);
|
||||||
|
assert.strictEqual(pruned.length, 3);
|
||||||
|
assert.ok(pruned.includes(h[0]));
|
||||||
|
assert.ok(pruned.includes(h[1]));
|
||||||
|
assert.ok(!pruned.includes(h[2]));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('count policy shrinks a realistic 41-batch / >1000-row history', () => {
|
||||||
|
const h = [];
|
||||||
|
for (let i = 0; i < 41; i++) h.push(batch(`2026-04-${String((i % 28) + 1).padStart(2, '0')}`, 1300));
|
||||||
|
assert.strictEqual(countHistoryRows(h), 41 * 1300);
|
||||||
|
const pruned = applyHistoryRetention(h, '1000', Date.parse('2026-06-01'));
|
||||||
|
assert.strictEqual(pruned.length, 1);
|
||||||
|
assert.strictEqual(countHistoryRows(pruned), 1300);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty history is returned as-is for any policy', () => {
|
||||||
|
assert.deepStrictEqual(applyHistoryRetention([], '7d', Date.now()), []);
|
||||||
|
assert.deepStrictEqual(applyHistoryRetention([], '100', Date.now()), []);
|
||||||
|
});
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
const { describe, it } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const { __test } = require('../lib/hosters');
|
||||||
|
|
||||||
|
describe('hosters helpers', () => {
|
||||||
|
it('extracts VOE file_code from nested result payloads', () => {
|
||||||
|
assert.deepEqual(__test.parseVoeResult({ result: { file: { file_code: 'abc123' } } }), {
|
||||||
|
download_url: 'https://voe.sx/abc123',
|
||||||
|
embed_url: 'https://voe.sx/e/abc123',
|
||||||
|
file_code: 'abc123'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts VOE file_code from flat fallback payloads', () => {
|
||||||
|
assert.deepEqual(__test.parseVoeResult({ file_code: 'xyz789' }), {
|
||||||
|
download_url: 'https://voe.sx/xyz789',
|
||||||
|
embed_url: 'https://voe.sx/e/xyz789',
|
||||||
|
file_code: 'xyz789'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts upload server URLs from nested API responses', () => {
|
||||||
|
const url = __test.extractUploadServerUrl({
|
||||||
|
result: {
|
||||||
|
server: {
|
||||||
|
upload_url: 'https://delivery-hydra.voe-network.net/upload/01'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 'https://voe.sx');
|
||||||
|
|
||||||
|
assert.equal(url, 'https://delivery-hydra.voe-network.net/upload/01');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parseDoodstreamResult tolerates null/non-object payload without throwing', () => {
|
||||||
|
// Direct callers may bypass uploadFile's normalisation. The parser must
|
||||||
|
// never throw on bad input — empty fields are the contract.
|
||||||
|
for (const bad of [null, undefined, 'string', 42, true]) {
|
||||||
|
const r = __test.parseDoodstreamResult(bad);
|
||||||
|
assert.equal(r.file_code, null);
|
||||||
|
assert.equal(r.download_url, null);
|
||||||
|
assert.equal(r.embed_url, null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parseDoodstreamResult handles result-as-array and result-as-object', () => {
|
||||||
|
const arr = __test.parseDoodstreamResult({ result: [{ filecode: 'AB1', protected_dl: 'https://x/1', protected_embed: 'https://x/e/1' }] });
|
||||||
|
assert.equal(arr.file_code, 'AB1');
|
||||||
|
assert.equal(arr.download_url, 'https://x/1');
|
||||||
|
assert.equal(arr.embed_url, 'https://x/e/1');
|
||||||
|
|
||||||
|
const obj = __test.parseDoodstreamResult({ result: { filecode: 'OBJ1', download_url: 'https://x/2' } });
|
||||||
|
assert.equal(obj.file_code, 'OBJ1');
|
||||||
|
assert.equal(obj.download_url, 'https://x/2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parseByseResult tolerates null/non-object payload without throwing', () => {
|
||||||
|
for (const bad of [null, undefined, 'string', 42, []]) {
|
||||||
|
const r = __test.parseByseResult(bad);
|
||||||
|
assert.equal(r.file_code, null);
|
||||||
|
assert.equal(r.download_url, null);
|
||||||
|
assert.equal(r.embed_url, null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parseByseResult handles malformed files entries (null, missing fields)', () => {
|
||||||
|
// Files array with a null first element (server returned [null])
|
||||||
|
const a = __test.parseByseResult({ files: [null] });
|
||||||
|
assert.equal(a.file_code, null);
|
||||||
|
// Files array with object missing both filecode and status
|
||||||
|
const b = __test.parseByseResult({ files: [{}] });
|
||||||
|
assert.equal(b.file_code, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parseByseResult throws fileRejected for non-OK status with empty filecode', () => {
|
||||||
|
assert.throws(
|
||||||
|
() => __test.parseByseResult({ files: [{ status: 'Not video file format' }] }),
|
||||||
|
(err) => err.fileRejected === true && /Not video file format/i.test(err.message)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parseByseResult flips to accountError for storage-exhausted phrasing', () => {
|
||||||
|
assert.throws(
|
||||||
|
() => __test.parseByseResult({ files: [{ status: 'not enough disk space on your account' }] }),
|
||||||
|
(err) => err.accountError === true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parseByseResult succeeds with valid filecode in files[0]', () => {
|
||||||
|
const r = __test.parseByseResult({ files: [{ filecode: 'GOOD123', status: 'OK' }] });
|
||||||
|
assert.equal(r.file_code, 'GOOD123');
|
||||||
|
assert.equal(r.download_url, 'https://byse.sx/d/GOOD123');
|
||||||
|
assert.equal(r.embed_url, 'https://byse.sx/e/GOOD123');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const { normalizeIp, isLoopbackIp, matchIpRule, evaluateClientAllowed } = require('../lib/ip-allowlist');
|
||||||
|
|
||||||
|
test('normalizeIp strips ::ffff: and lowercases', () => {
|
||||||
|
assert.equal(normalizeIp('::ffff:100.64.0.5'), '100.64.0.5');
|
||||||
|
assert.equal(normalizeIp('::FFFF:127.0.0.1'), '127.0.0.1');
|
||||||
|
assert.equal(normalizeIp(' 100.64.0.5 '), '100.64.0.5');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loopback is always allowed, even with a non-matching allowlist', () => {
|
||||||
|
for (const ip of ['127.0.0.1', '::1', '::ffff:127.0.0.1', '', 'localhost', '127.5.5.5']) {
|
||||||
|
assert.equal(evaluateClientAllowed(ip, ['203.0.113.5']), true, `${ip} loopback`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fail-closed: empty allowlist rejects every non-loopback peer', () => {
|
||||||
|
for (const ip of ['100.64.0.5', '203.0.113.5', '10.0.0.2', '::ffff:192.168.1.9']) {
|
||||||
|
assert.equal(evaluateClientAllowed(ip, []), false, `${ip} must be rejected with empty allowlist`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exact IP allow + reject', () => {
|
||||||
|
assert.equal(evaluateClientAllowed('203.0.113.5', ['203.0.113.5']), true);
|
||||||
|
assert.equal(evaluateClientAllowed('203.0.113.6', ['203.0.113.5']), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CIDR matching incl. the Tailscale CGNAT range 100.64.0.0/10', () => {
|
||||||
|
assert.equal(evaluateClientAllowed('100.64.0.5', ['100.64.0.0/10']), true);
|
||||||
|
assert.equal(evaluateClientAllowed('100.127.255.254', ['100.64.0.0/10']), true);
|
||||||
|
assert.equal(evaluateClientAllowed('100.128.0.1', ['100.64.0.0/10']), false, 'just outside the /10');
|
||||||
|
assert.equal(evaluateClientAllowed('::ffff:100.64.0.5', ['100.64.0.0/10']), true, 'mapped v4 in CIDR');
|
||||||
|
assert.equal(evaluateClientAllowed('10.0.0.5', ['10.0.0.0/24']), true);
|
||||||
|
assert.equal(evaluateClientAllowed('10.0.1.5', ['10.0.0.0/24']), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wildcard rules allow everything', () => {
|
||||||
|
assert.equal(evaluateClientAllowed('8.8.8.8', ['*']), true);
|
||||||
|
assert.equal(evaluateClientAllowed('8.8.8.8', ['0.0.0.0/0']), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('matchIpRule rejects malformed rules and out-of-range octets', () => {
|
||||||
|
assert.equal(matchIpRule('1.2.3.4', 'not-an-ip'), false);
|
||||||
|
assert.equal(matchIpRule('1.2.3.4', '1.2.3.0/33'), false);
|
||||||
|
assert.equal(matchIpRule('1.2.3.999', '1.2.3.0/24'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isLoopbackIp recognizes loopback forms', () => {
|
||||||
|
assert.equal(isLoopbackIp('127.0.0.1'), true);
|
||||||
|
assert.equal(isLoopbackIp('::1'), true);
|
||||||
|
assert.equal(isLoopbackIp('100.64.0.1'), false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const { normalizeLogMode, resolveLogFileName, formatDateStamp, formatSessionStamp } = require('../lib/log-mode');
|
||||||
|
|
||||||
|
// --- normalizeLogMode ---
|
||||||
|
|
||||||
|
test('normalizeLogMode: default for empty/null/undefined is "single"', () => {
|
||||||
|
assert.equal(normalizeLogMode(), 'single');
|
||||||
|
assert.equal(normalizeLogMode(null), 'single');
|
||||||
|
assert.equal(normalizeLogMode({}), 'single');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizeLogMode: explicit logMode wins for all three valid values', () => {
|
||||||
|
assert.equal(normalizeLogMode({ logMode: 'single' }), 'single');
|
||||||
|
assert.equal(normalizeLogMode({ logMode: 'daily' }), 'daily');
|
||||||
|
assert.equal(normalizeLogMode({ logMode: 'session' }), 'session');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('regression: legacy sessionLog:true maps to "daily", NOT "session"', () => {
|
||||||
|
// The legacy boolean field was named after a misnomer — it actually toggled
|
||||||
|
// per-day logging. Mapping it to "session" would silently flip every existing
|
||||||
|
// per-day user onto per-session, which is exactly the bug the migration trap
|
||||||
|
// exists to prevent.
|
||||||
|
assert.equal(normalizeLogMode({ sessionLog: true }), 'daily');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizeLogMode: sessionLog:false / missing maps to "single"', () => {
|
||||||
|
assert.equal(normalizeLogMode({ sessionLog: false }), 'single');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizeLogMode: explicit logMode beats the legacy sessionLog field', () => {
|
||||||
|
// Once a user picks a mode in 3.3.35+, the legacy boolean must NOT override.
|
||||||
|
assert.equal(normalizeLogMode({ logMode: 'session', sessionLog: true }), 'session');
|
||||||
|
assert.equal(normalizeLogMode({ logMode: 'single', sessionLog: true }), 'single');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizeLogMode: invalid logMode strings fall through to single (or legacy if present)', () => {
|
||||||
|
assert.equal(normalizeLogMode({ logMode: 'lolnope' }), 'single');
|
||||||
|
assert.equal(normalizeLogMode({ logMode: '' }), 'single');
|
||||||
|
assert.equal(normalizeLogMode({ logMode: 'lolnope', sessionLog: true }), 'daily');
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- resolveLogFileName ---
|
||||||
|
|
||||||
|
test('resolveLogFileName: single mode → bare basename + ext', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveLogFileName({ baseName: 'fileuploader', ext: '.log', mode: 'single' }),
|
||||||
|
'fileuploader.log'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveLogFileName: daily mode → fileuploader-YYYY-MM-DD.log', () => {
|
||||||
|
const d = new Date(2026, 4, 28); // May 28, 2026 — month is 0-indexed
|
||||||
|
assert.equal(
|
||||||
|
resolveLogFileName({ baseName: 'fileuploader', ext: '.log', mode: 'daily', date: d }),
|
||||||
|
'fileuploader-2026-05-28.log'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveLogFileName: session mode → <sessionId>.log (baseName ignored)', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveLogFileName({ baseName: 'fileuploader', ext: '.log', mode: 'session', sessionId: '26-05-2026-mdu-session-22-44' }),
|
||||||
|
'26-05-2026-mdu-session-22-44.log'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formatSessionStamp: DD-MM-YYYY-mdu-session-HH-MM', () => {
|
||||||
|
const { formatSessionStamp } = require('../lib/log-mode');
|
||||||
|
assert.equal(formatSessionStamp(new Date(2026, 5, 26, 6, 2, 36)), '26-06-2026-mdu-session-06-02');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formatSessionStamp: appends a 6-digit suffix when a rand is supplied', () => {
|
||||||
|
assert.equal(formatSessionStamp(new Date(2026, 5, 26, 6, 2, 36), '847581'), '26-06-2026-mdu-session-06-02-847581');
|
||||||
|
assert.equal(formatSessionStamp(new Date(2026, 5, 26, 6, 2, 36), 847581), '26-06-2026-mdu-session-06-02-847581');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveLogFileName: session mode with missing sessionId falls back to single (never emits malformed name)', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveLogFileName({ baseName: 'fileuploader', ext: '.log', mode: 'session' }),
|
||||||
|
'fileuploader.log'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveLogFileName: unknown mode is treated as single', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveLogFileName({ baseName: 'fileuploader', ext: '.log', mode: 'lolnope' }),
|
||||||
|
'fileuploader.log'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- stripModeStampFromFileName ---
|
||||||
|
|
||||||
|
const { stripModeStampFromFileName } = require('../lib/log-mode');
|
||||||
|
|
||||||
|
test('stripModeStampFromFileName: leaves bare names alone', () => {
|
||||||
|
assert.equal(stripModeStampFromFileName('fileuploader.log'), 'fileuploader.log');
|
||||||
|
assert.equal(stripModeStampFromFileName('fileuploader'), 'fileuploader');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stripModeStampFromFileName: strips a daily YYYY-MM-DD suffix', () => {
|
||||||
|
assert.equal(stripModeStampFromFileName('fileuploader-2026-06-03.log'), 'fileuploader.log');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stripModeStampFromFileName: strips a session-stamp suffix (with and without pid)', () => {
|
||||||
|
assert.equal(
|
||||||
|
stripModeStampFromFileName('fileuploader-session-2026-06-03_18-16-20-8132.log'),
|
||||||
|
'fileuploader.log'
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
stripModeStampFromFileName('fileuploader-session-2026-06-03_18-16-20.log'),
|
||||||
|
'fileuploader.log'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stripModeStampFromFileName: new DD-MM-YYYY-mdu-session-HH-MM resets to the default base', () => {
|
||||||
|
assert.equal(stripModeStampFromFileName('26-06-2026-mdu-session-06-02.log'), 'fileuploader.log');
|
||||||
|
assert.equal(stripModeStampFromFileName('26-06-2026-mdu-session-06-02-847581.log'), 'fileuploader.log');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('regression: resolveLogFileName(stripModeStampFromFileName(...)) is idempotent — persisting then re-resolving never compounds stamps', () => {
|
||||||
|
// This is the exact bug shape: persist the resolved path, then on next call
|
||||||
|
// re-resolve from the saved base — must produce the same file, not a doubled
|
||||||
|
// session-stamped one. The fix is the strip; this test guards against
|
||||||
|
// regressing _persistFallbackLogPath into the 3.3.35 bug.
|
||||||
|
const sessionId = '03-06-2026-mdu-session-18-16';
|
||||||
|
const dailyDate = new Date(2026, 5, 3);
|
||||||
|
for (const mode of ['daily', 'session']) {
|
||||||
|
const date = mode === 'daily' ? dailyDate : new Date();
|
||||||
|
const initial = resolveLogFileName({ baseName: 'fileuploader', ext: '.log', mode, date, sessionId });
|
||||||
|
const stripped = stripModeStampFromFileName(initial);
|
||||||
|
// After strip, the base should be back to the bare name.
|
||||||
|
assert.equal(stripped, 'fileuploader.log', `${mode}: strip should produce bare base`);
|
||||||
|
// Re-resolving from the bare base gives the same final filename — no doubling.
|
||||||
|
const reBase = stripped.replace(/\.log$/, '');
|
||||||
|
const second = resolveLogFileName({ baseName: reBase, ext: '.log', mode, date, sessionId });
|
||||||
|
assert.equal(second, initial, `${mode}: round-trip must be idempotent`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- format helpers ---
|
||||||
|
|
||||||
|
test('formatDateStamp: zero-pads month and day', () => {
|
||||||
|
assert.equal(formatDateStamp(new Date(2026, 0, 3)), '2026-01-03');
|
||||||
|
assert.equal(formatDateStamp(new Date(2026, 11, 31)), '2026-12-31');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formatSessionStamp: DD-MM-YYYY-mdu-session-HH-MM (no seconds/pid)', () => {
|
||||||
|
assert.equal(formatSessionStamp(new Date(2026, 4, 28, 7, 9, 5)), '28-05-2026-mdu-session-07-09');
|
||||||
|
assert.equal(formatSessionStamp(new Date(2026, 4, 28, 22, 44, 52)), '28-05-2026-mdu-session-22-44');
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const { hosterLogToFileEnabled } = require('../lib/log-policy');
|
||||||
|
|
||||||
|
test('enabled by default when settings missing entirely', () => {
|
||||||
|
assert.equal(hosterLogToFileEnabled(null, 'voe.sx'), true);
|
||||||
|
assert.equal(hosterLogToFileEnabled(undefined, 'voe.sx'), true);
|
||||||
|
assert.equal(hosterLogToFileEnabled('not-an-object', 'voe.sx'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('enabled when hoster has no settings entry', () => {
|
||||||
|
assert.equal(hosterLogToFileEnabled({}, 'voe.sx'), true);
|
||||||
|
assert.equal(hosterLogToFileEnabled({ 'byse.sx': { logToFile: false } }, 'voe.sx'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('enabled when hoster entry has no logToFile key (back-compat with old configs)', () => {
|
||||||
|
assert.equal(hosterLogToFileEnabled({ 'voe.sx': { retries: 3 } }, 'voe.sx'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('enabled when logToFile is explicitly true', () => {
|
||||||
|
assert.equal(hosterLogToFileEnabled({ 'voe.sx': { logToFile: true } }, 'voe.sx'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DISABLED only when logToFile is explicitly false', () => {
|
||||||
|
assert.equal(hosterLogToFileEnabled({ 'voe.sx': { logToFile: false } }, 'voe.sx'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('truthy-but-not-true values do not accidentally disable', () => {
|
||||||
|
// Only the strict boolean false disables — guards against e.g. a stored 0/""
|
||||||
|
assert.equal(hosterLogToFileEnabled({ 'voe.sx': { logToFile: 0 } }, 'voe.sx'), true);
|
||||||
|
assert.equal(hosterLogToFileEnabled({ 'voe.sx': { logToFile: '' } }, 'voe.sx'), true);
|
||||||
|
assert.equal(hosterLogToFileEnabled({ 'voe.sx': { logToFile: null } }, 'voe.sx'), true);
|
||||||
|
assert.equal(hosterLogToFileEnabled({ 'voe.sx': { logToFile: undefined } }, 'voe.sx'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('per-hoster independence: one off, others on', () => {
|
||||||
|
const settings = {
|
||||||
|
'voe.sx': { logToFile: false },
|
||||||
|
'byse.sx': { logToFile: true },
|
||||||
|
'doodstream.com': { retries: 3 }
|
||||||
|
};
|
||||||
|
assert.equal(hosterLogToFileEnabled(settings, 'voe.sx'), false);
|
||||||
|
assert.equal(hosterLogToFileEnabled(settings, 'byse.sx'), true);
|
||||||
|
assert.equal(hosterLogToFileEnabled(settings, 'doodstream.com'), true);
|
||||||
|
assert.equal(hosterLogToFileEnabled(settings, 'clouddrop.cc'), true); // not present → on
|
||||||
|
});
|
||||||
|
|
||||||
|
test('malformed hoster entry (string/number) defaults to on', () => {
|
||||||
|
assert.equal(hosterLogToFileEnabled({ 'voe.sx': 'broken' }, 'voe.sx'), true);
|
||||||
|
assert.equal(hosterLogToFileEnabled({ 'voe.sx': 42 }, 'voe.sx'), true);
|
||||||
|
});
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
const { test, beforeEach, afterEach } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const os = require('os');
|
||||||
|
|
||||||
|
const { maybeRotateLogFile } = require('../lib/log-rotation');
|
||||||
|
|
||||||
|
let tmpDir;
|
||||||
|
let logFile;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-log-rotation-'));
|
||||||
|
logFile = path.join(tmpDir, 'fileuploader.log');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
|
||||||
|
});
|
||||||
|
|
||||||
|
function writeBytes(p, n, fill = 'a') {
|
||||||
|
fs.writeFileSync(p, fill.repeat(n), 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
test('returns false and skips rotation when file does not exist', () => {
|
||||||
|
const result = maybeRotateLogFile(logFile, 100);
|
||||||
|
assert.equal(result, false);
|
||||||
|
assert.equal(fs.existsSync(logFile), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns false when file is below the size cap', () => {
|
||||||
|
writeBytes(logFile, 50);
|
||||||
|
const result = maybeRotateLogFile(logFile, 100);
|
||||||
|
assert.equal(result, false);
|
||||||
|
assert.equal(fs.statSync(logFile).size, 50, 'live file untouched');
|
||||||
|
assert.equal(fs.existsSync(logFile + '.1'), false, 'no .1 created');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rotates live file to .1 when over cap', () => {
|
||||||
|
writeBytes(logFile, 200, 'X');
|
||||||
|
const result = maybeRotateLogFile(logFile, 100, 3);
|
||||||
|
assert.equal(result, true);
|
||||||
|
assert.equal(fs.existsSync(logFile), false, 'live file moved away');
|
||||||
|
const expectedBackup = path.join(tmpDir, 'fileuploader.1.log');
|
||||||
|
assert.equal(fs.existsSync(expectedBackup), true, '.1 backup exists');
|
||||||
|
assert.equal(fs.statSync(expectedBackup).size, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shifts existing backups up: .1 → .2, .2 → .3 on rotation', () => {
|
||||||
|
writeBytes(path.join(tmpDir, 'fileuploader.2.log'), 10, 'B');
|
||||||
|
writeBytes(path.join(tmpDir, 'fileuploader.1.log'), 20, 'A');
|
||||||
|
writeBytes(logFile, 200, 'L');
|
||||||
|
|
||||||
|
const result = maybeRotateLogFile(logFile, 100, 3);
|
||||||
|
assert.equal(result, true);
|
||||||
|
|
||||||
|
// Live file → .1 (latest live data)
|
||||||
|
assert.equal(fs.statSync(path.join(tmpDir, 'fileuploader.1.log')).size, 200);
|
||||||
|
// Old .1 → .2
|
||||||
|
assert.equal(fs.statSync(path.join(tmpDir, 'fileuploader.2.log')).size, 20);
|
||||||
|
// Old .2 → .3
|
||||||
|
assert.equal(fs.statSync(path.join(tmpDir, 'fileuploader.3.log')).size, 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('drops oldest backup when at maxBackups limit', () => {
|
||||||
|
// Pre-populate all three backup slots.
|
||||||
|
writeBytes(path.join(tmpDir, 'fileuploader.3.log'), 5, 'C'); // oldest, will be dropped
|
||||||
|
writeBytes(path.join(tmpDir, 'fileuploader.2.log'), 10, 'B');
|
||||||
|
writeBytes(path.join(tmpDir, 'fileuploader.1.log'), 20, 'A');
|
||||||
|
writeBytes(logFile, 200, 'L');
|
||||||
|
|
||||||
|
const result = maybeRotateLogFile(logFile, 100, 3);
|
||||||
|
assert.equal(result, true);
|
||||||
|
|
||||||
|
// Old .3 (5 bytes 'C') gone, replaced by old .2.
|
||||||
|
const f3 = fs.statSync(path.join(tmpDir, 'fileuploader.3.log'));
|
||||||
|
assert.equal(f3.size, 10, 'old .2 became new .3 (the C-file was dropped)');
|
||||||
|
// .2 = old .1
|
||||||
|
assert.equal(fs.statSync(path.join(tmpDir, 'fileuploader.2.log')).size, 20);
|
||||||
|
// .1 = the live file we just rotated
|
||||||
|
assert.equal(fs.statSync(path.join(tmpDir, 'fileuploader.1.log')).size, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('is idempotent — second call on still-large file rotates again', () => {
|
||||||
|
writeBytes(logFile, 200, 'X');
|
||||||
|
maybeRotateLogFile(logFile, 100, 3);
|
||||||
|
// Simulate fresh writes after the first rotation
|
||||||
|
writeBytes(logFile, 200, 'Y');
|
||||||
|
const result = maybeRotateLogFile(logFile, 100, 3);
|
||||||
|
assert.equal(result, true);
|
||||||
|
// The .Y file is now .1, the .X file moved to .2
|
||||||
|
assert.equal(fs.readFileSync(path.join(tmpDir, 'fileuploader.1.log'), 'utf-8')[0], 'Y');
|
||||||
|
assert.equal(fs.readFileSync(path.join(tmpDir, 'fileuploader.2.log'), 'utf-8')[0], 'X');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('maxBackups=1: only keeps a single .1 backup, never .2', () => {
|
||||||
|
writeBytes(logFile, 200, 'L');
|
||||||
|
maybeRotateLogFile(logFile, 100, 1);
|
||||||
|
writeBytes(logFile, 200, 'M');
|
||||||
|
maybeRotateLogFile(logFile, 100, 1);
|
||||||
|
|
||||||
|
// .1 holds the latest rotated content (M)
|
||||||
|
assert.equal(fs.readFileSync(path.join(tmpDir, 'fileuploader.1.log'), 'utf-8')[0], 'M');
|
||||||
|
// .2 must NOT exist
|
||||||
|
assert.equal(fs.existsSync(path.join(tmpDir, 'fileuploader.2.log')), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invalid maxBytes (0, negative, NaN) is a no-op', () => {
|
||||||
|
writeBytes(logFile, 1000, 'X');
|
||||||
|
for (const max of [0, -1, NaN]) {
|
||||||
|
const r = maybeRotateLogFile(logFile, max);
|
||||||
|
assert.equal(r, false, `maxBytes=${max} should be no-op`);
|
||||||
|
}
|
||||||
|
assert.equal(fs.existsSync(logFile), true);
|
||||||
|
assert.equal(fs.existsSync(logFile + '.1'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('logs through provided debug callback on rotation', () => {
|
||||||
|
writeBytes(logFile, 200, 'X');
|
||||||
|
const messages = [];
|
||||||
|
maybeRotateLogFile(logFile, 100, 3, (m) => messages.push(m));
|
||||||
|
assert.ok(messages.length >= 1, 'at least one log message');
|
||||||
|
assert.ok(messages.some(m => m.includes('rotated')), `expected "rotated" in: ${messages.join(' | ')}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles file without extension correctly', () => {
|
||||||
|
const noExtFile = path.join(tmpDir, 'plainlog');
|
||||||
|
writeBytes(noExtFile, 200, 'P');
|
||||||
|
const result = maybeRotateLogFile(noExtFile, 100, 3);
|
||||||
|
assert.equal(result, true);
|
||||||
|
// base = the full path, ext = '', so backup name is "plainlog.1"
|
||||||
|
assert.equal(fs.existsSync(path.join(tmpDir, 'plainlog.1')), true);
|
||||||
|
assert.equal(fs.existsSync(noExtFile), false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const { selectOrphanTmps } = require('../lib/orphan-tmp');
|
||||||
|
|
||||||
|
const BASE = 'electron-config.json';
|
||||||
|
const aliveSet = new Set([100, 200]);
|
||||||
|
const isAlive = (pid) => aliveSet.has(pid);
|
||||||
|
|
||||||
|
test('selects only dead-pid <base>.<pid>.tmp orphans', () => {
|
||||||
|
const files = [
|
||||||
|
'electron-config.json',
|
||||||
|
'electron-config.json.bak',
|
||||||
|
'electron-config.json.tmp',
|
||||||
|
'electron-config.json.100.tmp',
|
||||||
|
'electron-config.json.200.tmp',
|
||||||
|
'electron-config.json.999.tmp',
|
||||||
|
'electron-config.json.4242.tmp',
|
||||||
|
'something-else.500.tmp',
|
||||||
|
'electron-config.json.abc.tmp'
|
||||||
|
];
|
||||||
|
const orphans = selectOrphanTmps(files, { baseName: BASE, currentPid: 7, isAlive });
|
||||||
|
assert.deepEqual(orphans.sort(), ['electron-config.json.4242.tmp', 'electron-config.json.999.tmp']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('never selects the current process tmp', () => {
|
||||||
|
const files = ['electron-config.json.7.tmp'];
|
||||||
|
assert.deepEqual(selectOrphanTmps(files, { baseName: BASE, currentPid: 7, isAlive: () => false }), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('never selects the FIXED <base>.tmp (used by async _atomicWrite)', () => {
|
||||||
|
const files = ['electron-config.json.tmp'];
|
||||||
|
assert.deepEqual(selectOrphanTmps(files, { baseName: BASE, currentPid: 7, isAlive: () => false }), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('never selects the live config or its .bak', () => {
|
||||||
|
const files = ['electron-config.json', 'electron-config.json.bak'];
|
||||||
|
assert.deepEqual(selectOrphanTmps(files, { baseName: BASE, currentPid: 7, isAlive: () => false }), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('alive pid (incl. EPERM-as-alive) is skipped, preventing deletion of a concurrent instance tmp', () => {
|
||||||
|
const files = ['electron-config.json.100.tmp', 'electron-config.json.300.tmp'];
|
||||||
|
const orphans = selectOrphanTmps(files, { baseName: BASE, currentPid: 7, isAlive: (p) => p === 100 });
|
||||||
|
assert.deepEqual(orphans, ['electron-config.json.300.tmp']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('robust to junk / missing inputs', () => {
|
||||||
|
assert.deepEqual(selectOrphanTmps(null, { baseName: BASE, currentPid: 1, isAlive }), []);
|
||||||
|
assert.deepEqual(selectOrphanTmps(['x', 42, null, undefined], { baseName: BASE, currentPid: 1, isAlive }), []);
|
||||||
|
assert.deepEqual(selectOrphanTmps(['electron-config.json.5.tmp'], {}), []);
|
||||||
|
assert.deepEqual(selectOrphanTmps(['electron-config.json.5.tmp'], { baseName: '', currentPid: 1, isAlive }), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not match a different base that shares a prefix', () => {
|
||||||
|
const files = ['electron-config.json.backup.5.tmp'];
|
||||||
|
assert.deepEqual(selectOrphanTmps(files, { baseName: BASE, currentPid: 7, isAlive: () => false }), []);
|
||||||
|
});
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const { partitionRestoredJobsByLog } = require('../lib/queue-dedup');
|
||||||
|
|
||||||
|
function lcg(seed) {
|
||||||
|
let s = seed >>> 0;
|
||||||
|
return () => { s = (Math.imul(s, 1664525) + 1013904223) >>> 0; return s / 4294967296; };
|
||||||
|
}
|
||||||
|
|
||||||
|
function key(f, h) { return `${String(f).toLowerCase()}|${String(h).toLowerCase()}`; }
|
||||||
|
|
||||||
|
test('property: removed iff (done && key in log) OR (savedAt finite && key unambiguous && newest matching log ts >= floor(savedAt/1000)*1000)', () => {
|
||||||
|
const rnd = lcg(0x9e3779b1);
|
||||||
|
const statuses = ['preview', 'done', 'error', 'aborted', 'queued', 'skipped'];
|
||||||
|
const hosters = ['voe.sx', 'byse.sx', 'doodstream.com'];
|
||||||
|
const names = ['a.mkv', 'b.mp4', 'A.MKV', 'c.mov'];
|
||||||
|
const folders = ['C:/A/', 'C:/B/', 'D:/down/'];
|
||||||
|
const pick = (arr) => arr[Math.floor(rnd() * arr.length)];
|
||||||
|
|
||||||
|
for (let iter = 0; iter < 3000; iter++) {
|
||||||
|
const useSavedAt = rnd() < 0.7;
|
||||||
|
const savedAt = useSavedAt ? Math.floor(rnd() * 2_000_000_000_000) : undefined;
|
||||||
|
|
||||||
|
const jobs = [];
|
||||||
|
const nJobs = 1 + Math.floor(rnd() * 6);
|
||||||
|
for (let i = 0; i < nJobs; i++) {
|
||||||
|
const name = pick(names);
|
||||||
|
// Mix shared and distinct paths so ambiguous keys (same name+hoster,
|
||||||
|
// different folder) actually occur and exercise the guard.
|
||||||
|
jobs.push({ id: `j${i}`, fileName: name, hoster: pick(hosters), status: pick(statuses), file: `${pick(folders)}${name}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const log = [];
|
||||||
|
const nLog = Math.floor(rnd() * 5);
|
||||||
|
for (let i = 0; i < nLog; i++) {
|
||||||
|
const hasTs = rnd() < 0.8;
|
||||||
|
log.push({ fileName: pick(names), hoster: pick(hosters), ts: hasTs ? Math.floor(rnd() * 2_000_000_000_000) : undefined });
|
||||||
|
}
|
||||||
|
|
||||||
|
const logKeys = new Set();
|
||||||
|
const maxTs = new Map();
|
||||||
|
for (const e of log) {
|
||||||
|
const k = key(e.fileName, e.hoster);
|
||||||
|
logKeys.add(k);
|
||||||
|
if (typeof e.ts === 'number' && isFinite(e.ts)) {
|
||||||
|
const prev = maxTs.get(k);
|
||||||
|
if (prev === undefined || e.ts > prev) maxTs.set(k, e.ts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const filesPerKey = new Map();
|
||||||
|
for (const job of jobs) {
|
||||||
|
const k = key(job.fileName, job.hoster);
|
||||||
|
if (!filesPerKey.has(k)) filesPerKey.set(k, new Set());
|
||||||
|
filesPerKey.get(k).add(job.file || '');
|
||||||
|
}
|
||||||
|
const floor = (typeof savedAt === 'number' && isFinite(savedAt)) ? Math.floor(savedAt / 1000) * 1000 : null;
|
||||||
|
|
||||||
|
const { kept, removed } = partitionRestoredJobsByLog(jobs, log, savedAt);
|
||||||
|
|
||||||
|
assert.equal(kept.length + removed.length, jobs.length, `iter ${iter}: partition must cover every job exactly once`);
|
||||||
|
const keptIds = new Set(kept.map(j => j.id));
|
||||||
|
const removedIds = new Set(removed.map(j => j.id));
|
||||||
|
assert.equal(keptIds.size + removedIds.size, jobs.length, `iter ${iter}: no job in both partitions`);
|
||||||
|
|
||||||
|
for (const job of jobs) {
|
||||||
|
const k = key(job.fileName, job.hoster);
|
||||||
|
const doneInLog = job.status === 'done' && logKeys.has(k);
|
||||||
|
const unambiguous = filesPerKey.get(k).size <= 1;
|
||||||
|
const afterSnap = floor !== null && unambiguous && maxTs.has(k) && maxTs.get(k) >= floor;
|
||||||
|
const shouldRemove = doneInLog || afterSnap;
|
||||||
|
assert.equal(removedIds.has(job.id), shouldRemove,
|
||||||
|
`iter ${iter}: job ${job.id} (status=${job.status} key=${k} unambig=${unambiguous}) expected removed=${shouldRemove}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('property: a genuinely-pending job is NEVER lost to a same-basename sibling completing after the snapshot', () => {
|
||||||
|
const rnd = lcg(0x1234abcd);
|
||||||
|
for (let iter = 0; iter < 500; iter++) {
|
||||||
|
const savedAt = 1_000_000_000_000 + Math.floor(rnd() * 1_000_000);
|
||||||
|
// X completed after the snapshot (logged); Y is a DIFFERENT file, same
|
||||||
|
// basename + hoster, genuinely pending. Y must survive.
|
||||||
|
const jobs = [
|
||||||
|
{ id: 'X', fileName: 'clip.mp4', hoster: 'voe.sx', status: 'preview', file: 'C:/A/clip.mp4' },
|
||||||
|
{ id: 'Y', fileName: 'clip.mp4', hoster: 'voe.sx', status: 'preview', file: 'C:/B/clip.mp4' }
|
||||||
|
];
|
||||||
|
const log = [{ fileName: 'clip.mp4', hoster: 'voe.sx', ts: savedAt + 1000 + Math.floor(rnd() * 1000) }];
|
||||||
|
const { kept } = partitionRestoredJobsByLog(jobs, log, savedAt);
|
||||||
|
assert.ok(kept.some(j => j.id === 'Y'), `iter ${iter}: pending Y must never be silently dropped`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('property: 2-arg legacy call NEVER removes a non-done job (the v3.3.80 canary, fuzzed)', () => {
|
||||||
|
const rnd = lcg(0xdeadbeef);
|
||||||
|
const statuses = ['preview', 'error', 'aborted', 'queued', 'skipped'];
|
||||||
|
const hosters = ['voe.sx', 'byse.sx'];
|
||||||
|
const names = ['a.mkv', 'b.mp4'];
|
||||||
|
const pick = (arr) => arr[Math.floor(rnd() * arr.length)];
|
||||||
|
|
||||||
|
for (let iter = 0; iter < 1000; iter++) {
|
||||||
|
const jobs = [];
|
||||||
|
const nJobs = 1 + Math.floor(rnd() * 5);
|
||||||
|
for (let i = 0; i < nJobs; i++) {
|
||||||
|
jobs.push({ id: `j${i}`, fileName: pick(names), hoster: pick(hosters), status: pick(statuses), file: `C:/x/${i}` });
|
||||||
|
}
|
||||||
|
const log = [];
|
||||||
|
const nLog = Math.floor(rnd() * 4);
|
||||||
|
for (let i = 0; i < nLog; i++) {
|
||||||
|
log.push({ fileName: pick(names), hoster: pick(hosters), ts: Math.floor(rnd() * 2_000_000_000_000) });
|
||||||
|
}
|
||||||
|
const { removed } = partitionRestoredJobsByLog(jobs, log);
|
||||||
|
assert.ok(removed.every(j => j.status === 'done'), `iter ${iter}: legacy 2-arg call must never drop a non-done job`);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const { partitionRestoredJobsByLog, completedSelectionKeys } = require('../lib/queue-dedup');
|
||||||
|
|
||||||
|
function job(status, fileName, hoster) {
|
||||||
|
return { status, fileName, hoster, file: `C:/dl/${fileName}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('regression: pending preview jobs are NEVER dropped, even when all match the log', () => {
|
||||||
|
// Exact shape of the reproduced bug: 4 preview jobs for one file across 4
|
||||||
|
// hosters, every fileName|hoster present in the lifetime upload log.
|
||||||
|
const jobs = [
|
||||||
|
job('preview', 'Einfach mal die Fresse halten!!!.mp4', 'doodstream.com'),
|
||||||
|
job('preview', 'Einfach mal die Fresse halten!!!.mp4', 'voe.sx'),
|
||||||
|
job('preview', 'Einfach mal die Fresse halten!!!.mp4', 'vidmoly.me'),
|
||||||
|
job('preview', 'Einfach mal die Fresse halten!!!.mp4', 'byse.sx')
|
||||||
|
];
|
||||||
|
const log = [
|
||||||
|
{ fileName: 'Einfach mal die Fresse halten!!!.mp4', hoster: 'doodstream.com' },
|
||||||
|
{ fileName: 'Einfach mal die Fresse halten!!!.mp4', hoster: 'voe.sx' },
|
||||||
|
{ fileName: 'Einfach mal die Fresse halten!!!.mp4', hoster: 'vidmoly.me' },
|
||||||
|
{ fileName: 'Einfach mal die Fresse halten!!!.mp4', hoster: 'byse.sx' }
|
||||||
|
];
|
||||||
|
const { kept, removed } = partitionRestoredJobsByLog(jobs, log);
|
||||||
|
assert.equal(removed.length, 0, 'no pending job may be removed');
|
||||||
|
assert.equal(kept.length, 4, 'all 4 pending jobs survive restart/update');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('done jobs in the log are dropped (declutter); pending/error/aborted kept', () => {
|
||||||
|
const jobs = [
|
||||||
|
job('done', 'a.mkv', 'doodstream.com'),
|
||||||
|
job('preview', 'a.mkv', 'voe.sx'),
|
||||||
|
job('error', 'b.mkv', 'doodstream.com'),
|
||||||
|
job('aborted', 'c.mkv', 'doodstream.com')
|
||||||
|
];
|
||||||
|
const log = [
|
||||||
|
{ fileName: 'a.mkv', hoster: 'doodstream.com' },
|
||||||
|
{ fileName: 'a.mkv', hoster: 'voe.sx' },
|
||||||
|
{ fileName: 'b.mkv', hoster: 'doodstream.com' },
|
||||||
|
{ fileName: 'c.mkv', hoster: 'doodstream.com' }
|
||||||
|
];
|
||||||
|
const { kept, removed } = partitionRestoredJobsByLog(jobs, log);
|
||||||
|
assert.equal(removed.length, 1);
|
||||||
|
assert.equal(removed[0].status, 'done');
|
||||||
|
assert.equal(removed[0].hoster, 'doodstream.com');
|
||||||
|
// The preview a.mkv|voe.sx, error b.mkv, aborted c.mkv all survive.
|
||||||
|
assert.equal(kept.length, 3);
|
||||||
|
assert.ok(kept.some(j => j.status === 'preview' && j.hoster === 'voe.sx'));
|
||||||
|
assert.ok(kept.some(j => j.status === 'error'));
|
||||||
|
assert.ok(kept.some(j => j.status === 'aborted'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('done job NOT in the log is kept (e.g. hoster had logToFile disabled)', () => {
|
||||||
|
const jobs = [job('done', 'd.mkv', 'doodstream.com')];
|
||||||
|
const { kept, removed } = partitionRestoredJobsByLog(jobs, []);
|
||||||
|
assert.equal(removed.length, 0);
|
||||||
|
assert.equal(kept.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('case-insensitive match on fileName and hoster', () => {
|
||||||
|
const jobs = [job('done', 'Movie.MKV', 'DoodStream.com')];
|
||||||
|
const log = [{ fileName: 'movie.mkv', hoster: 'doodstream.com' }];
|
||||||
|
const { removed } = partitionRestoredJobsByLog(jobs, log);
|
||||||
|
assert.equal(removed.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty/missing inputs do not throw', () => {
|
||||||
|
assert.deepEqual(partitionRestoredJobsByLog([], []), { kept: [], removed: [] });
|
||||||
|
assert.deepEqual(partitionRestoredJobsByLog(null, null), { kept: [], removed: [] });
|
||||||
|
const jobs = [job('done', 'x.mkv', 'voe.sx')];
|
||||||
|
assert.equal(partitionRestoredJobsByLog(jobs, undefined).kept.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
const T = (s) => Date.parse(s.replace(' ', 'T'));
|
||||||
|
|
||||||
|
test('ts-gate: preview job uploaded AFTER the snapshot is dropped (the ghost bug)', () => {
|
||||||
|
const jobs = [job('preview', 'a.mkv', 'voe.sx')];
|
||||||
|
const log = [{ fileName: 'a.mkv', hoster: 'voe.sx', ts: T('2026-06-19 12:00:05') }];
|
||||||
|
const savedAt = T('2026-06-19 12:00:00');
|
||||||
|
const { kept, removed } = partitionRestoredJobsByLog(jobs, log, savedAt);
|
||||||
|
assert.equal(removed.length, 1, 'completed-after-snapshot preview is a ghost → drop');
|
||||||
|
assert.equal(kept.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ts-gate: preview job whose only log entry PREDATES the snapshot is kept (intentional re-upload)', () => {
|
||||||
|
const jobs = [job('preview', 'a.mkv', 'voe.sx')];
|
||||||
|
const log = [{ fileName: 'a.mkv', hoster: 'voe.sx', ts: T('2026-06-19 11:00:00') }];
|
||||||
|
const savedAt = T('2026-06-19 12:00:00');
|
||||||
|
const { kept, removed } = partitionRestoredJobsByLog(jobs, log, savedAt);
|
||||||
|
assert.equal(removed.length, 0, 'old upload + freshly-queued re-upload must survive');
|
||||||
|
assert.equal(kept.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ts-gate: same-second completion is dropped (savedAt floored to the second)', () => {
|
||||||
|
const jobs = [job('preview', 'a.mkv', 'voe.sx')];
|
||||||
|
const log = [{ fileName: 'a.mkv', hoster: 'voe.sx', ts: T('2026-06-19 12:00:00') }];
|
||||||
|
const savedAt = T('2026-06-19 12:00:00') + 800;
|
||||||
|
const { removed } = partitionRestoredJobsByLog(jobs, log, savedAt);
|
||||||
|
assert.equal(removed.length, 1, 'log second-granularity must not let same-second ghosts slip through');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ts-gate: uses the MAX log ts per key (re-upload after a stale earlier entry)', () => {
|
||||||
|
const jobs = [job('preview', 'a.mkv', 'voe.sx')];
|
||||||
|
const log = [
|
||||||
|
{ fileName: 'a.mkv', hoster: 'voe.sx', ts: T('2026-06-19 11:00:00') },
|
||||||
|
{ fileName: 'a.mkv', hoster: 'voe.sx', ts: T('2026-06-19 12:00:05') }
|
||||||
|
];
|
||||||
|
const savedAt = T('2026-06-19 12:00:00');
|
||||||
|
const { removed } = partitionRestoredJobsByLog(jobs, log, savedAt);
|
||||||
|
assert.equal(removed.length, 1, 'newest matching log entry decides');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ts-gate inactive without savedAt → legacy behavior (preview kept even if ts newer)', () => {
|
||||||
|
const jobs = [job('preview', 'a.mkv', 'voe.sx')];
|
||||||
|
const log = [{ fileName: 'a.mkv', hoster: 'voe.sx', ts: T('2026-06-19 12:00:05') }];
|
||||||
|
const { kept, removed } = partitionRestoredJobsByLog(jobs, log);
|
||||||
|
assert.equal(removed.length, 0);
|
||||||
|
assert.equal(kept.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ts-gate inactive when log entry lacks ts → legacy behavior', () => {
|
||||||
|
const jobs = [job('preview', 'a.mkv', 'voe.sx')];
|
||||||
|
const log = [{ fileName: 'a.mkv', hoster: 'voe.sx' }];
|
||||||
|
const savedAt = T('2026-06-19 12:00:00');
|
||||||
|
const { kept, removed } = partitionRestoredJobsByLog(jobs, log, savedAt);
|
||||||
|
assert.equal(removed.length, 0);
|
||||||
|
assert.equal(kept.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ts-gate: done job uploaded after snapshot is dropped via either rule', () => {
|
||||||
|
const jobs = [job('done', 'a.mkv', 'voe.sx')];
|
||||||
|
const log = [{ fileName: 'a.mkv', hoster: 'voe.sx', ts: T('2026-06-19 12:00:05') }];
|
||||||
|
const savedAt = T('2026-06-19 12:00:00');
|
||||||
|
const { removed } = partitionRestoredJobsByLog(jobs, log, savedAt);
|
||||||
|
assert.equal(removed.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ts-gate ambiguity guard: a pending same-basename file in a DIFFERENT folder is NOT lost when a sibling completes after the snapshot', () => {
|
||||||
|
// X (C:/A/clip.mp4) was uploaded after the snapshot and logged. Y is a
|
||||||
|
// genuinely-different file (C:/B/clip.mp4), same basename + hoster, still
|
||||||
|
// pending. The log records only basenames, so the ts-rule must not drop Y.
|
||||||
|
const jobs = [
|
||||||
|
{ status: 'preview', fileName: 'clip.mp4', hoster: 'voe.sx', file: 'C:/A/clip.mp4' },
|
||||||
|
{ status: 'preview', fileName: 'clip.mp4', hoster: 'voe.sx', file: 'C:/B/clip.mp4' }
|
||||||
|
];
|
||||||
|
const savedAt = T('2026-06-19 12:00:00');
|
||||||
|
const log = [{ fileName: 'clip.mp4', hoster: 'voe.sx', ts: T('2026-06-19 12:00:05') }];
|
||||||
|
const { kept, removed } = partitionRestoredJobsByLog(jobs, log, savedAt);
|
||||||
|
assert.equal(removed.length, 0, 'ambiguous key -> ts-rule suppressed, no pending file lost');
|
||||||
|
assert.equal(kept.length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ts-gate ambiguity guard: the done-in-log rule still applies on an ambiguous key', () => {
|
||||||
|
// Even when the key is ambiguous, a job that is actually 'done' and in the log
|
||||||
|
// is still decluttered (pre-existing rule, unchanged by the guard).
|
||||||
|
const jobs = [
|
||||||
|
{ status: 'done', fileName: 'clip.mp4', hoster: 'voe.sx', file: 'C:/A/clip.mp4' },
|
||||||
|
{ status: 'preview', fileName: 'clip.mp4', hoster: 'voe.sx', file: 'C:/B/clip.mp4' }
|
||||||
|
];
|
||||||
|
const log = [{ fileName: 'clip.mp4', hoster: 'voe.sx', ts: T('2026-06-19 12:00:05') }];
|
||||||
|
const savedAt = T('2026-06-19 12:00:00');
|
||||||
|
const { kept, removed } = partitionRestoredJobsByLog(jobs, log, savedAt);
|
||||||
|
assert.equal(removed.length, 1);
|
||||||
|
assert.equal(removed[0].status, 'done');
|
||||||
|
assert.equal(removed[0].file, 'C:/A/clip.mp4');
|
||||||
|
assert.ok(kept.some(j => j.file === 'C:/B/clip.mp4'), 'the distinct pending file survives');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ts-gate: a unique-path ghost still drops (guard does not weaken the common case)', () => {
|
||||||
|
const jobs = [{ status: 'preview', fileName: 'clip.mp4', hoster: 'voe.sx', file: 'C:/A/clip.mp4' }];
|
||||||
|
const log = [{ fileName: 'clip.mp4', hoster: 'voe.sx', ts: T('2026-06-19 12:00:05') }];
|
||||||
|
const savedAt = T('2026-06-19 12:00:00');
|
||||||
|
const { removed } = partitionRestoredJobsByLog(jobs, log, savedAt);
|
||||||
|
assert.equal(removed.length, 1, 'single job for the key -> unambiguous -> ghost dropped as before');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ts-gate: multi-hoster partial completion — the reported bug shape (drop only the completed hosters)', () => {
|
||||||
|
// One file queued to 4 hosters; close mid-upload. After the snapshot, 2 hosters
|
||||||
|
// completed (logged), 2 never started. On restart all 4 restore as 'preview'.
|
||||||
|
// Must drop EXACTLY the 2 that completed and keep the 2 still-pending. This also
|
||||||
|
// pins per-hoster keying: a fileName-only gate would wrongly drop all 4.
|
||||||
|
const f = 'Einfach mal die Fresse halten!!!.mp4';
|
||||||
|
const jobs = [
|
||||||
|
job('preview', f, 'doodstream.com'),
|
||||||
|
job('preview', f, 'voe.sx'),
|
||||||
|
job('preview', f, 'vidmoly.me'),
|
||||||
|
job('preview', f, 'byse.sx')
|
||||||
|
];
|
||||||
|
const savedAt = T('2026-06-19 12:00:00');
|
||||||
|
const log = [
|
||||||
|
{ fileName: f, hoster: 'doodstream.com', ts: T('2026-06-19 12:00:08') },
|
||||||
|
{ fileName: f, hoster: 'voe.sx', ts: T('2026-06-19 12:00:11') }
|
||||||
|
];
|
||||||
|
const { kept, removed } = partitionRestoredJobsByLog(jobs, log, savedAt);
|
||||||
|
assert.equal(removed.length, 2, 'only the 2 completed-after-snapshot hosters drop');
|
||||||
|
assert.ok(removed.every(j => j.hoster === 'doodstream.com' || j.hoster === 'voe.sx'));
|
||||||
|
assert.equal(kept.length, 2, 'the 2 never-started hosters survive');
|
||||||
|
assert.ok(kept.some(j => j.hoster === 'vidmoly.me'));
|
||||||
|
assert.ok(kept.some(j => j.hoster === 'byse.sx'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('completedSelectionKeys: a selectedFile that completed after the snapshot yields its full-path|hoster key', () => {
|
||||||
|
const selectedFiles = [{ path: 'C:/dl/done.mp4', name: 'done.mp4' }, { path: 'C:/dl/pending.mp4', name: 'pending.mp4' }];
|
||||||
|
const hosters = ['voe.sx'];
|
||||||
|
const savedAt = T('2026-06-19 12:00:00');
|
||||||
|
const log = [{ fileName: 'done.mp4', hoster: 'voe.sx', ts: T('2026-06-19 12:00:05') }];
|
||||||
|
const keys = completedSelectionKeys(selectedFiles, hosters, log, savedAt);
|
||||||
|
assert.deepEqual(keys, ['C:/dl/done.mp4|voe.sx'], 'only the completed file is seeded; pending is not');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('completedSelectionKeys: per-hoster — a file done on voe but not byse only seeds the voe key', () => {
|
||||||
|
const selectedFiles = [{ path: 'C:/dl/a.mp4', name: 'a.mp4' }];
|
||||||
|
const hosters = ['voe.sx', 'byse.sx'];
|
||||||
|
const savedAt = T('2026-06-19 12:00:00');
|
||||||
|
const log = [{ fileName: 'a.mp4', hoster: 'voe.sx', ts: T('2026-06-19 12:00:05') }];
|
||||||
|
const keys = completedSelectionKeys(selectedFiles, hosters, log, savedAt);
|
||||||
|
assert.deepEqual(keys, ['C:/dl/a.mp4|voe.sx'], 'the still-pending byse upload is NOT seeded');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('completedSelectionKeys: ambiguous basename across folders seeds NOTHING (no lost re-preview)', () => {
|
||||||
|
const selectedFiles = [{ path: 'C:/A/clip.mp4', name: 'clip.mp4' }, { path: 'C:/B/clip.mp4', name: 'clip.mp4' }];
|
||||||
|
const hosters = ['voe.sx'];
|
||||||
|
const savedAt = T('2026-06-19 12:00:00');
|
||||||
|
const log = [{ fileName: 'clip.mp4', hoster: 'voe.sx', ts: T('2026-06-19 12:00:05') }];
|
||||||
|
const keys = completedSelectionKeys(selectedFiles, hosters, log, savedAt);
|
||||||
|
assert.deepEqual(keys, [], 'ambiguous -> neither path is suppressed, both re-preview (safe direction)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('completedSelectionKeys: an OLDER completion (pre-snapshot re-queue) is NOT seeded', () => {
|
||||||
|
const selectedFiles = [{ path: 'C:/dl/reup.mp4', name: 'reup.mp4' }];
|
||||||
|
const hosters = ['voe.sx'];
|
||||||
|
const savedAt = T('2026-06-19 12:00:00');
|
||||||
|
const log = [{ fileName: 'reup.mp4', hoster: 'voe.sx', ts: T('2026-06-19 11:00:00') }];
|
||||||
|
assert.deepEqual(completedSelectionKeys(selectedFiles, hosters, log, savedAt), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('completedSelectionKeys: no savedAt / junk inputs -> empty (legacy + robustness)', () => {
|
||||||
|
const sf = [{ path: 'C:/dl/a.mp4', name: 'a.mp4' }];
|
||||||
|
const log = [{ fileName: 'a.mp4', hoster: 'voe.sx', ts: T('2026-06-19 12:00:05') }];
|
||||||
|
assert.deepEqual(completedSelectionKeys(sf, ['voe.sx'], log, undefined), []);
|
||||||
|
assert.deepEqual(completedSelectionKeys(null, ['voe.sx'], log, 1), []);
|
||||||
|
assert.deepEqual(completedSelectionKeys(sf, null, log, 1), []);
|
||||||
|
assert.deepEqual(completedSelectionKeys([], [], log, 1), []);
|
||||||
|
assert.deepEqual(completedSelectionKeys([{ name: 'x' }], ['voe.sx'], log, 1), [], 'entry without path is skipped');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('completedSelectionKeys: derives basename from path when name is missing', () => {
|
||||||
|
const selectedFiles = [{ path: 'C:/dl/sub/movie.mp4' }];
|
||||||
|
const hosters = ['voe.sx'];
|
||||||
|
const savedAt = T('2026-06-19 12:00:00');
|
||||||
|
const log = [{ fileName: 'movie.mp4', hoster: 'voe.sx', ts: T('2026-06-19 12:00:05') }];
|
||||||
|
assert.deepEqual(completedSelectionKeys(selectedFiles, hosters, log, savedAt), ['C:/dl/sub/movie.mp4|voe.sx']);
|
||||||
|
});
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const { formatUploadLogLine, parseUploadLogLine } = require('../lib/upload-log');
|
||||||
|
const { partitionRestoredJobsByLog } = require('../lib/queue-dedup');
|
||||||
|
|
||||||
|
function makeJobs(n, hoster, status, offset = 0) {
|
||||||
|
const jobs = [];
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const fileName = `clip_${String(i + offset).padStart(4, '0')}.mp4`;
|
||||||
|
jobs.push({ id: `j-${hoster}-${i + offset}`, file: `D:/inbox/${fileName}`, fileName, hoster, status });
|
||||||
|
}
|
||||||
|
return jobs;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('user report: 300 queued, ~200 finished mid-session before a hard kill — only the finished drop', () => {
|
||||||
|
const hoster = 'byse.sx';
|
||||||
|
const snapshot = new Date(2026, 5, 19, 22, 0, 0);
|
||||||
|
const savedAt = snapshot.getTime();
|
||||||
|
const restoredJobs = makeJobs(300, hoster, 'preview');
|
||||||
|
|
||||||
|
const completionBase = new Date(2026, 5, 19, 22, 5, 0).getTime();
|
||||||
|
const logEntries = [];
|
||||||
|
for (let i = 0; i < 200; i++) {
|
||||||
|
const d = new Date(completionBase + i * 1000);
|
||||||
|
logEntries.push(parseUploadLogLine(
|
||||||
|
formatUploadLogLine(d, hoster, `https://byse.sx/d/x${i}`, `clip_${String(i).padStart(4, '0')}.mp4`)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
const { kept, removed } = partitionRestoredJobsByLog(restoredJobs, logEntries, savedAt);
|
||||||
|
assert.equal(removed.length, 200, 'the 200 completed-after-snapshot files are dropped as ghosts');
|
||||||
|
assert.equal(kept.length, 100, 'the 100 never-finished files stay queued');
|
||||||
|
assert.ok(kept.every(j => Number(j.fileName.slice(5, 9)) >= 200), 'kept are exactly indices 200..299');
|
||||||
|
const keptNames = new Set(kept.map(j => j.fileName));
|
||||||
|
assert.ok(removed.every(j => !keptNames.has(j.fileName)));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('multi-hoster batch: per-hoster completion is independent (a file done on voe but not byse keeps byse)', () => {
|
||||||
|
const savedAt = new Date(2026, 5, 19, 22, 0, 0).getTime();
|
||||||
|
const done = new Date(2026, 5, 19, 22, 3, 0);
|
||||||
|
const jobs = [
|
||||||
|
...makeJobs(3, 'voe.sx', 'preview'),
|
||||||
|
...makeJobs(3, 'byse.sx', 'preview')
|
||||||
|
];
|
||||||
|
const logEntries = [
|
||||||
|
parseUploadLogLine(formatUploadLogLine(done, 'voe.sx', 'l', 'clip_0000.mp4')),
|
||||||
|
parseUploadLogLine(formatUploadLogLine(done, 'voe.sx', 'l', 'clip_0001.mp4')),
|
||||||
|
parseUploadLogLine(formatUploadLogLine(done, 'byse.sx', 'l', 'clip_0000.mp4'))
|
||||||
|
];
|
||||||
|
const { kept, removed } = partitionRestoredJobsByLog(jobs, logEntries, savedAt);
|
||||||
|
assert.equal(removed.length, 3);
|
||||||
|
assert.ok(removed.some(j => j.hoster === 'voe.sx' && j.fileName === 'clip_0000.mp4'));
|
||||||
|
assert.ok(removed.some(j => j.hoster === 'voe.sx' && j.fileName === 'clip_0001.mp4'));
|
||||||
|
assert.ok(removed.some(j => j.hoster === 'byse.sx' && j.fileName === 'clip_0000.mp4'));
|
||||||
|
assert.ok(kept.some(j => j.hoster === 'byse.sx' && j.fileName === 'clip_0001.mp4'), 'byse clip_0001 not logged -> kept');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clean idle close (snapshot AFTER completion) keeps an intentional re-queue of an old file', () => {
|
||||||
|
const hoster = 'voe.sx';
|
||||||
|
const yesterday = new Date(2026, 5, 18, 12, 0, 0);
|
||||||
|
const logEntries = [parseUploadLogLine(formatUploadLogLine(yesterday, hoster, 'link', 'reupload_me.mp4'))];
|
||||||
|
const savedAt = new Date(2026, 5, 19, 9, 0, 0).getTime();
|
||||||
|
const jobs = [{ id: 'r1', file: 'D:/x/reupload_me.mp4', fileName: 'reupload_me.mp4', hoster, status: 'preview' }];
|
||||||
|
const { kept, removed } = partitionRestoredJobsByLog(jobs, logEntries, savedAt);
|
||||||
|
assert.equal(removed.length, 0, 'an upload older than the snapshot is a deliberate re-queue and survives');
|
||||||
|
assert.equal(kept.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('legacy snapshot without savedAt (pre-v3.3.80 config) falls back to done-only dedup', () => {
|
||||||
|
const hoster = 'voe.sx';
|
||||||
|
const logEntries = [
|
||||||
|
parseUploadLogLine(formatUploadLogLine(new Date(2026, 5, 19, 12, 0, 0), hoster, 'l', 'done.mp4')),
|
||||||
|
parseUploadLogLine(formatUploadLogLine(new Date(2026, 5, 19, 12, 1, 0), hoster, 'l', 'preview.mp4'))
|
||||||
|
];
|
||||||
|
const jobs = [
|
||||||
|
{ id: 'a', file: 'D:/x/done.mp4', fileName: 'done.mp4', hoster, status: 'done' },
|
||||||
|
{ id: 'b', file: 'D:/x/preview.mp4', fileName: 'preview.mp4', hoster, status: 'preview' }
|
||||||
|
];
|
||||||
|
const { kept, removed } = partitionRestoredJobsByLog(jobs, logEntries);
|
||||||
|
assert.equal(removed.length, 1, 'only the done job is decluttered when no savedAt is available');
|
||||||
|
assert.equal(removed[0].id, 'a');
|
||||||
|
assert.ok(kept.some(j => j.id === 'b'), 'the preview survives the legacy path');
|
||||||
|
});
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const { pruneOldestTerminalJobs, TERMINAL_STATUSES } = require('../lib/queue-prune');
|
||||||
|
|
||||||
|
const j = (id, status) => ({ id, status });
|
||||||
|
|
||||||
|
test('returns null on empty / non-array input', () => {
|
||||||
|
assert.equal(pruneOldestTerminalJobs([], 5), null);
|
||||||
|
assert.equal(pruneOldestTerminalJobs(null, 5), null);
|
||||||
|
assert.equal(pruneOldestTerminalJobs(undefined, 5), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null when all jobs are non-terminal regardless of limit', () => {
|
||||||
|
const jobs = [j('a', 'queued'), j('b', 'uploading'), j('c', 'preview')];
|
||||||
|
assert.equal(pruneOldestTerminalJobs(jobs, 0), null);
|
||||||
|
assert.equal(pruneOldestTerminalJobs(jobs, 100), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null when terminal count is at or under the limit', () => {
|
||||||
|
const jobs = [j('a', 'done'), j('b', 'done'), j('c', 'queued')];
|
||||||
|
assert.equal(pruneOldestTerminalJobs(jobs, 2), null, 'terminal=2, limit=2 → no-op');
|
||||||
|
assert.equal(pruneOldestTerminalJobs(jobs, 3), null, 'terminal=2, limit=3 → no-op');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('drops oldest terminal jobs when over the limit, keeps non-terminal', () => {
|
||||||
|
const jobs = [
|
||||||
|
j('t1', 'done'), // oldest terminal — should be dropped
|
||||||
|
j('t2', 'done'), // should be dropped
|
||||||
|
j('queued1', 'queued'),
|
||||||
|
j('t3', 'error'), // newest of the dropped block
|
||||||
|
j('uploading1', 'uploading'),
|
||||||
|
j('t4', 'done'), // kept (within limit window)
|
||||||
|
j('t5', 'skipped'), // kept
|
||||||
|
j('t6', 'aborted'), // kept
|
||||||
|
];
|
||||||
|
// 6 terminal, limit 3 → drop 3 oldest (t1, t2, t3)
|
||||||
|
const result = pruneOldestTerminalJobs(jobs, 3);
|
||||||
|
assert.notEqual(result, null);
|
||||||
|
const droppedIds = result.dropped.map(x => x.id).sort();
|
||||||
|
assert.deepEqual(droppedIds, ['t1', 't2', 't3']);
|
||||||
|
// Non-terminal jobs always kept; surviving terminals are the newest 3
|
||||||
|
const keptIds = result.kept.map(x => x.id);
|
||||||
|
assert.deepEqual(keptIds, ['queued1', 'uploading1', 't4', 't5', 't6']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('respects insertion order (oldest by index, not by status)', () => {
|
||||||
|
const jobs = [
|
||||||
|
j('older-error', 'error'),
|
||||||
|
j('newer-done', 'done'),
|
||||||
|
j('newest-aborted', 'aborted'),
|
||||||
|
];
|
||||||
|
const result = pruneOldestTerminalJobs(jobs, 1);
|
||||||
|
assert.deepEqual(result.dropped.map(x => x.id), ['older-error', 'newer-done']);
|
||||||
|
assert.deepEqual(result.kept.map(x => x.id), ['newest-aborted']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('drops everything terminal when limit is 0', () => {
|
||||||
|
const jobs = [
|
||||||
|
j('q', 'queued'),
|
||||||
|
j('d1', 'done'),
|
||||||
|
j('d2', 'done'),
|
||||||
|
j('e1', 'error'),
|
||||||
|
];
|
||||||
|
const result = pruneOldestTerminalJobs(jobs, 0);
|
||||||
|
assert.deepEqual(result.dropped.map(x => x.id), ['d1', 'd2', 'e1']);
|
||||||
|
assert.deepEqual(result.kept.map(x => x.id), ['q']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects negative or non-finite limits', () => {
|
||||||
|
const jobs = [j('a', 'done'), j('b', 'done')];
|
||||||
|
assert.equal(pruneOldestTerminalJobs(jobs, -1), null);
|
||||||
|
assert.equal(pruneOldestTerminalJobs(jobs, NaN), null);
|
||||||
|
assert.equal(pruneOldestTerminalJobs(jobs, Infinity), null,
|
||||||
|
'Infinity is technically not finite; safer to treat as no-op');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('TERMINAL_STATUSES set covers all 4 terminal kinds', () => {
|
||||||
|
assert.ok(TERMINAL_STATUSES.has('done'));
|
||||||
|
assert.ok(TERMINAL_STATUSES.has('skipped'));
|
||||||
|
assert.ok(TERMINAL_STATUSES.has('error'));
|
||||||
|
assert.ok(TERMINAL_STATUSES.has('aborted'));
|
||||||
|
assert.equal(TERMINAL_STATUSES.size, 4);
|
||||||
|
// Non-terminal must not be in the set
|
||||||
|
for (const s of ['queued', 'preview', 'uploading', 'retrying', 'getting-server']) {
|
||||||
|
assert.equal(TERMINAL_STATUSES.has(s), false, `${s} must not be terminal`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles malformed entries (null / missing status) without throwing', () => {
|
||||||
|
const jobs = [
|
||||||
|
null,
|
||||||
|
j('a', 'done'),
|
||||||
|
{ id: 'no-status' }, // no status
|
||||||
|
j('b', 'done'),
|
||||||
|
];
|
||||||
|
// 2 terminal, limit 1 → drop oldest (a). null and no-status entries stay
|
||||||
|
// because they aren't terminal. The function must not throw on them.
|
||||||
|
const result = pruneOldestTerminalJobs(jobs, 1);
|
||||||
|
assert.notEqual(result, null);
|
||||||
|
assert.deepEqual(result.dropped.map(x => x && x.id), ['a']);
|
||||||
|
assert.equal(result.kept.length, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('large queue: keeps the newest `limit` terminals', () => {
|
||||||
|
const jobs = [];
|
||||||
|
for (let i = 0; i < 5000; i++) jobs.push(j(`done-${i}`, 'done'));
|
||||||
|
const result = pruneOldestTerminalJobs(jobs, 500);
|
||||||
|
assert.notEqual(result, null);
|
||||||
|
assert.equal(result.dropped.length, 4500);
|
||||||
|
assert.equal(result.kept.length, 500);
|
||||||
|
// First kept = done-4500 (the 4501st original entry)
|
||||||
|
assert.equal(result.kept[0].id, 'done-4500');
|
||||||
|
assert.equal(result.kept[result.kept.length - 1].id, 'done-4999');
|
||||||
|
});
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
const { describe, it } = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
|
||||||
|
// Minimal app mock for ConfigStore
|
||||||
|
function createTestConfigStore() {
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-test-'));
|
||||||
|
const mockApp = {
|
||||||
|
isPackaged: false,
|
||||||
|
getPath: (name) => tmpDir,
|
||||||
|
getPath: () => tmpDir
|
||||||
|
};
|
||||||
|
const ConfigStore = require('../lib/config-store');
|
||||||
|
const store = new ConfigStore(mockApp);
|
||||||
|
store.filePath = path.join(tmpDir, 'test-config.json');
|
||||||
|
return { store, tmpDir };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('remote config defaults', () => {
|
||||||
|
it('should include remote settings in defaults', () => {
|
||||||
|
const { store } = createTestConfigStore();
|
||||||
|
const config = store.load();
|
||||||
|
const remote = config.globalSettings.remote;
|
||||||
|
|
||||||
|
assert.strictEqual(remote.enabled, false);
|
||||||
|
assert.strictEqual(remote.port, 9100);
|
||||||
|
assert.strictEqual(typeof remote.token, 'string');
|
||||||
|
assert.strictEqual(remote.token, '');
|
||||||
|
assert.strictEqual(remote.allowInput, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should deep-merge remote settings with existing config', async () => {
|
||||||
|
const { store } = createTestConfigStore();
|
||||||
|
// Save config with partial remote settings
|
||||||
|
await store.save({
|
||||||
|
globalSettings: {
|
||||||
|
remote: { enabled: true, port: 9200 }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const config = store.load();
|
||||||
|
const remote = config.globalSettings.remote;
|
||||||
|
|
||||||
|
// Saved values preserved
|
||||||
|
assert.strictEqual(remote.enabled, true);
|
||||||
|
assert.strictEqual(remote.port, 9200);
|
||||||
|
// Defaults merged in
|
||||||
|
assert.strictEqual(remote.allowInput, true);
|
||||||
|
assert.strictEqual(remote.token, '');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
const { describe, it, beforeEach, afterEach } = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
|
||||||
|
// Test the module can be required and has the expected API
|
||||||
|
describe('RemoteServer', () => {
|
||||||
|
it('should export a class with start/stop methods', () => {
|
||||||
|
const RemoteServer = require('../lib/remote-server');
|
||||||
|
assert.strictEqual(typeof RemoteServer, 'function');
|
||||||
|
assert.strictEqual(typeof RemoteServer.prototype.start, 'function');
|
||||||
|
assert.strictEqual(typeof RemoteServer.prototype.stop, 'function');
|
||||||
|
assert.strictEqual(typeof RemoteServer.prototype.getClientCount, 'function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should start and stop without errors', async () => {
|
||||||
|
const RemoteServer = require('../lib/remote-server');
|
||||||
|
const server = new RemoteServer();
|
||||||
|
|
||||||
|
// Mock mainWindow
|
||||||
|
const mockMainWindow = {
|
||||||
|
isDestroyed: () => false,
|
||||||
|
getTitle: () => 'Test Window',
|
||||||
|
getContentBounds: () => ({ x: 0, y: 0, width: 1920, height: 1080 }),
|
||||||
|
webContents: {
|
||||||
|
sendInputEvent: () => {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await server.start({
|
||||||
|
port: 0, // random available port
|
||||||
|
token: 'test-token-123',
|
||||||
|
allowInput: true,
|
||||||
|
mainWindow: mockMainWindow,
|
||||||
|
onSignalingToCapture: () => {},
|
||||||
|
onCreateCaptureWindow: () => {},
|
||||||
|
onDestroyCaptureWindow: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(server.getClientCount(), 0);
|
||||||
|
server.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
const { describe, it } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const Semaphore = require('../lib/semaphore');
|
||||||
|
|
||||||
|
describe('Semaphore', () => {
|
||||||
|
it('clamps limit to at least 1', () => {
|
||||||
|
assert.equal(new Semaphore(0).limit, 1);
|
||||||
|
assert.equal(new Semaphore(-5).limit, 1);
|
||||||
|
assert.equal(new Semaphore(undefined).limit, 1);
|
||||||
|
assert.equal(new Semaphore(3).limit, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('acquire resolves immediately when slots available', async () => {
|
||||||
|
const sem = new Semaphore(2);
|
||||||
|
await sem.acquire();
|
||||||
|
await sem.acquire();
|
||||||
|
assert.equal(sem.active, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('acquire blocks when all slots taken', async () => {
|
||||||
|
const sem = new Semaphore(1);
|
||||||
|
await sem.acquire();
|
||||||
|
|
||||||
|
let resolved = false;
|
||||||
|
const p = sem.acquire().then(() => { resolved = true; });
|
||||||
|
|
||||||
|
// Give microtask a chance to resolve
|
||||||
|
await new Promise(r => setTimeout(r, 10));
|
||||||
|
assert.equal(resolved, false, 'should not resolve while slot is taken');
|
||||||
|
assert.equal(sem.pending, 1);
|
||||||
|
|
||||||
|
sem.release();
|
||||||
|
await p;
|
||||||
|
assert.equal(resolved, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FIFO ordering', async () => {
|
||||||
|
const sem = new Semaphore(1);
|
||||||
|
await sem.acquire(); // take the one slot
|
||||||
|
|
||||||
|
const order = [];
|
||||||
|
const p1 = sem.acquire().then(() => order.push(1));
|
||||||
|
const p2 = sem.acquire().then(() => order.push(2));
|
||||||
|
const p3 = sem.acquire().then(() => order.push(3));
|
||||||
|
|
||||||
|
assert.equal(sem.pending, 3);
|
||||||
|
|
||||||
|
sem.release(); await p1;
|
||||||
|
sem.release(); await p2;
|
||||||
|
sem.release(); await p3;
|
||||||
|
|
||||||
|
assert.deepEqual(order, [1, 2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('release with no waiters decrements active', async () => {
|
||||||
|
const sem = new Semaphore(2);
|
||||||
|
await sem.acquire();
|
||||||
|
assert.equal(sem.active, 1);
|
||||||
|
sem.release();
|
||||||
|
assert.equal(sem.active, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('release never goes below 0', () => {
|
||||||
|
const sem = new Semaphore(2);
|
||||||
|
sem.release();
|
||||||
|
assert.equal(sem.active, 0);
|
||||||
|
sem.release();
|
||||||
|
assert.equal(sem.active, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('acquire rejects immediately if signal already aborted', async () => {
|
||||||
|
const sem = new Semaphore(2);
|
||||||
|
const ac = new AbortController();
|
||||||
|
ac.abort();
|
||||||
|
|
||||||
|
await assert.rejects(sem.acquire(ac.signal), /Aborted/);
|
||||||
|
assert.equal(sem.active, 0, 'no slot should be acquired');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('abort while waiting in queue removes entry and rejects', async () => {
|
||||||
|
const sem = new Semaphore(1);
|
||||||
|
await sem.acquire(); // take the slot
|
||||||
|
|
||||||
|
const ac = new AbortController();
|
||||||
|
const p = sem.acquire(ac.signal);
|
||||||
|
|
||||||
|
assert.equal(sem.pending, 1);
|
||||||
|
ac.abort();
|
||||||
|
await assert.rejects(p, /Aborted/);
|
||||||
|
assert.equal(sem.pending, 0, 'entry should be removed from queue');
|
||||||
|
|
||||||
|
// Release original slot - should not cause issues
|
||||||
|
sem.release();
|
||||||
|
assert.equal(sem.active, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('abort listener is cleaned up when slot is granted via release', async () => {
|
||||||
|
const sem = new Semaphore(1);
|
||||||
|
await sem.acquire();
|
||||||
|
|
||||||
|
const ac = new AbortController();
|
||||||
|
let rejected = false;
|
||||||
|
const p = sem.acquire(ac.signal).catch(() => { rejected = true; });
|
||||||
|
|
||||||
|
sem.release(); // grants slot to the waiter
|
||||||
|
await p;
|
||||||
|
|
||||||
|
// Now abort after the slot was already granted
|
||||||
|
ac.abort();
|
||||||
|
await new Promise(r => setTimeout(r, 10));
|
||||||
|
assert.equal(rejected, false, 'reject should not fire after slot was granted');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updateLimit wakes waiters', async () => {
|
||||||
|
const sem = new Semaphore(1);
|
||||||
|
await sem.acquire();
|
||||||
|
|
||||||
|
const resolved = [];
|
||||||
|
const p1 = sem.acquire().then(() => resolved.push(1));
|
||||||
|
const p2 = sem.acquire().then(() => resolved.push(2));
|
||||||
|
|
||||||
|
sem.updateLimit(3);
|
||||||
|
await Promise.all([p1, p2]);
|
||||||
|
assert.deepEqual(resolved, [1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updateLimit to lower value does not kill active slots', async () => {
|
||||||
|
const sem = new Semaphore(3);
|
||||||
|
await sem.acquire();
|
||||||
|
await sem.acquire();
|
||||||
|
await sem.acquire();
|
||||||
|
assert.equal(sem.active, 3);
|
||||||
|
|
||||||
|
sem.updateLimit(1);
|
||||||
|
assert.equal(sem.active, 3, 'existing active slots should not be evicted');
|
||||||
|
|
||||||
|
sem.release();
|
||||||
|
sem.release();
|
||||||
|
sem.release();
|
||||||
|
assert.equal(sem.active, 0);
|
||||||
|
|
||||||
|
// Now only 1 slot should be available
|
||||||
|
await sem.acquire();
|
||||||
|
let blocked = false;
|
||||||
|
const p = sem.acquire().then(() => { blocked = true; });
|
||||||
|
await new Promise(r => setTimeout(r, 10));
|
||||||
|
assert.equal(blocked, false, 'should block at limit 1');
|
||||||
|
sem.release();
|
||||||
|
await p;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pending getter tracks queue size', async () => {
|
||||||
|
const sem = new Semaphore(1);
|
||||||
|
assert.equal(sem.pending, 0);
|
||||||
|
|
||||||
|
await sem.acquire();
|
||||||
|
sem.acquire(); // blocked
|
||||||
|
sem.acquire(); // blocked
|
||||||
|
assert.equal(sem.pending, 2);
|
||||||
|
|
||||||
|
sem.release();
|
||||||
|
await new Promise(r => setTimeout(r, 5));
|
||||||
|
assert.equal(sem.pending, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('release without acquire clamps active to 0', () => {
|
||||||
|
const sem = new Semaphore(2);
|
||||||
|
assert.equal(sem.active, 0);
|
||||||
|
sem.release();
|
||||||
|
assert.equal(sem.active, 0, 'should not go negative');
|
||||||
|
sem.release();
|
||||||
|
assert.equal(sem.active, 0, 'should still be 0');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const {
|
||||||
|
summarizePerHoster,
|
||||||
|
classifyErrorCategory,
|
||||||
|
summarizeBatchErrors,
|
||||||
|
isRetryableCategory
|
||||||
|
} = require('../lib/stats');
|
||||||
|
|
||||||
|
function makeBatch(timestamp, results) {
|
||||||
|
return {
|
||||||
|
id: 'b-' + timestamp,
|
||||||
|
timestamp: new Date(timestamp).toISOString(),
|
||||||
|
files: [{ name: 'foo.mp4', size: 1, results }]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('summarizePerHoster counts ok and fail per hoster across all batches', () => {
|
||||||
|
const history = [
|
||||||
|
makeBatch(1, [
|
||||||
|
{ hoster: 'voe.sx', status: 'done' },
|
||||||
|
{ hoster: 'byse.sx', status: 'error', error: 'Not video file format' }
|
||||||
|
]),
|
||||||
|
makeBatch(2, [
|
||||||
|
{ hoster: 'voe.sx', status: 'done' },
|
||||||
|
{ hoster: 'voe.sx', status: 'error', error: 'CSRF' },
|
||||||
|
{ hoster: 'byse.sx', status: 'done' }
|
||||||
|
])
|
||||||
|
];
|
||||||
|
const s = summarizePerHoster(history);
|
||||||
|
assert.strictEqual(s['voe.sx'].ok, 2);
|
||||||
|
assert.strictEqual(s['voe.sx'].fail, 1);
|
||||||
|
assert.strictEqual(s['voe.sx'].total, 3);
|
||||||
|
assert.strictEqual(Math.round(s['voe.sx'].rate * 100), 67);
|
||||||
|
assert.strictEqual(s['byse.sx'].ok, 1);
|
||||||
|
assert.strictEqual(s['byse.sx'].fail, 1);
|
||||||
|
assert.strictEqual(s['byse.sx'].rate, 0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('summarizePerHoster honors sinceMs cutoff', () => {
|
||||||
|
const history = [
|
||||||
|
makeBatch(1000, [{ hoster: 'voe.sx', status: 'done' }]),
|
||||||
|
makeBatch(5000, [{ hoster: 'voe.sx', status: 'error', error: 'x' }])
|
||||||
|
];
|
||||||
|
const s = summarizePerHoster(history, { sinceMs: 3000 });
|
||||||
|
assert.strictEqual(s['voe.sx'].ok, 0);
|
||||||
|
assert.strictEqual(s['voe.sx'].fail, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('summarizePerHoster honors lastNBatches (newest first)', () => {
|
||||||
|
const history = [
|
||||||
|
makeBatch(1000, [{ hoster: 'voe.sx', status: 'done' }]),
|
||||||
|
makeBatch(2000, [{ hoster: 'voe.sx', status: 'done' }]),
|
||||||
|
makeBatch(3000, [{ hoster: 'voe.sx', status: 'error', error: 'x' }])
|
||||||
|
];
|
||||||
|
const s = summarizePerHoster(history, { lastNBatches: 1 });
|
||||||
|
assert.strictEqual(s['voe.sx'].ok, 0);
|
||||||
|
assert.strictEqual(s['voe.sx'].fail, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('summarizePerHoster handles empty / malformed input', () => {
|
||||||
|
assert.deepStrictEqual(summarizePerHoster(null), {});
|
||||||
|
assert.deepStrictEqual(summarizePerHoster([]), {});
|
||||||
|
assert.deepStrictEqual(summarizePerHoster([{ id: 'x', files: null }]), {});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('classifyErrorCategory: file-rejected phrases', () => {
|
||||||
|
assert.strictEqual(classifyErrorCategory('Byse lehnte Datei ab: Not video file format'), 'file-rejected');
|
||||||
|
assert.strictEqual(classifyErrorCategory('Duplicate file already exists'), 'file-rejected');
|
||||||
|
assert.strictEqual(classifyErrorCategory('Datei zu groß (Max: 5 GB)'), 'file-rejected');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('classifyErrorCategory: account-error phrases', () => {
|
||||||
|
assert.strictEqual(classifyErrorCategory('Quota exceeded'), 'account-error');
|
||||||
|
assert.strictEqual(classifyErrorCategory('account banned'), 'account-error');
|
||||||
|
assert.strictEqual(classifyErrorCategory('not enough disk space'), 'account-error');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('classifyErrorCategory: hoster-transient phrases', () => {
|
||||||
|
assert.strictEqual(classifyErrorCategory('CSRF-Token nicht gefunden'), 'hoster-transient');
|
||||||
|
assert.strictEqual(classifyErrorCategory('Kein Upload-Server erhalten: server busy'), 'hoster-transient');
|
||||||
|
assert.strictEqual(classifyErrorCategory('Kein Filecode'), 'hoster-transient');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('classifyErrorCategory: network phrases', () => {
|
||||||
|
assert.strictEqual(classifyErrorCategory('socket hang up'), 'network');
|
||||||
|
assert.strictEqual(classifyErrorCategory('fetch failed'), 'network');
|
||||||
|
assert.strictEqual(classifyErrorCategory('Timeout while reading'), 'network');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('classifyErrorCategory: aborted is its own bucket (not retryable)', () => {
|
||||||
|
assert.strictEqual(classifyErrorCategory('Abgebrochen'), 'aborted');
|
||||||
|
assert.strictEqual(isRetryableCategory('aborted'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('classifyErrorCategory: unknown for everything else', () => {
|
||||||
|
assert.strictEqual(classifyErrorCategory(''), 'unknown');
|
||||||
|
assert.strictEqual(classifyErrorCategory(null), 'unknown');
|
||||||
|
assert.strictEqual(classifyErrorCategory('Some weird thing'), 'unknown');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('summarizeBatchErrors buckets results by category', () => {
|
||||||
|
const summary = {
|
||||||
|
files: [
|
||||||
|
{ name: 'a.mp4', results: [
|
||||||
|
{ hoster: 'voe.sx', status: 'done' },
|
||||||
|
{ hoster: 'byse.sx', status: 'error', error: 'Not video file format' }
|
||||||
|
] },
|
||||||
|
{ name: 'b.mp4', results: [
|
||||||
|
{ hoster: 'voe.sx', status: 'error', error: 'CSRF' },
|
||||||
|
{ hoster: 'doodstream.com', status: 'error', error: 'socket hang up' }
|
||||||
|
] }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
const buckets = summarizeBatchErrors(summary);
|
||||||
|
assert.strictEqual(buckets['file-rejected'].length, 1);
|
||||||
|
assert.strictEqual(buckets['file-rejected'][0].hoster, 'byse.sx');
|
||||||
|
assert.strictEqual(buckets['hoster-transient'].length, 1);
|
||||||
|
assert.strictEqual(buckets['hoster-transient'][0].hoster, 'voe.sx');
|
||||||
|
assert.strictEqual(buckets['network'].length, 1);
|
||||||
|
assert.strictEqual(buckets['network'][0].hoster, 'doodstream.com');
|
||||||
|
assert.strictEqual(buckets['account-error'].length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isRetryableCategory: only transient + network + unknown retry-worthy', () => {
|
||||||
|
assert.strictEqual(isRetryableCategory('hoster-transient'), true);
|
||||||
|
assert.strictEqual(isRetryableCategory('network'), true);
|
||||||
|
assert.strictEqual(isRetryableCategory('unknown'), true);
|
||||||
|
assert.strictEqual(isRetryableCategory('file-rejected'), false);
|
||||||
|
assert.strictEqual(isRetryableCategory('account-error'), false);
|
||||||
|
assert.strictEqual(isRetryableCategory('aborted'), false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
const { sanitizeConfig, collectFile, buildSupportBundleText, redactLogText, REDACTED } = require('../lib/support-bundle');
|
||||||
|
|
||||||
|
const artificialSecret = (...fragments) => fragments.join('');
|
||||||
|
|
||||||
|
test('sanitizeConfig redacts known credential keys at any nesting depth', () => {
|
||||||
|
const input = {
|
||||||
|
hosters: {
|
||||||
|
'voe.sx': [{ username: 'u', password: 'p1', apiKey: 'k1', enabled: true }],
|
||||||
|
'byse.sx': [{ apiKey: 'k2' }, { apiKey: 'k3', token: 't1', label: 'main' }]
|
||||||
|
},
|
||||||
|
globalSettings: { remote: { token: 'remT' }, scramble: { active: false } }
|
||||||
|
};
|
||||||
|
const out = sanitizeConfig(input);
|
||||||
|
assert.strictEqual(out.hosters['voe.sx'][0].password, REDACTED);
|
||||||
|
assert.strictEqual(out.hosters['voe.sx'][0].apiKey, REDACTED);
|
||||||
|
assert.strictEqual(out.hosters['voe.sx'][0].username, 'u');
|
||||||
|
assert.strictEqual(out.hosters['voe.sx'][0].enabled, true);
|
||||||
|
assert.strictEqual(out.hosters['byse.sx'][1].apiKey, REDACTED);
|
||||||
|
assert.strictEqual(out.hosters['byse.sx'][1].token, REDACTED);
|
||||||
|
assert.strictEqual(out.hosters['byse.sx'][1].label, 'main');
|
||||||
|
assert.strictEqual(out.globalSettings.remote.token, REDACTED);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('redactLogText scrubs opaque tokens that are NOT stored config secrets', () => {
|
||||||
|
const secrets = [
|
||||||
|
artificialSecret('fixture_token_', 'qwerty', '12345'),
|
||||||
|
artificialSecret('fixture_auth_', 'value', '123456'),
|
||||||
|
artificialSecret('fixture_refresh_', 'value', '123456'),
|
||||||
|
artificialSecret('fixture_bearer_', 'value', '123456'),
|
||||||
|
artificialSecret('fixture_authorization_', 'value', '123456')
|
||||||
|
];
|
||||||
|
const cases = [
|
||||||
|
`boom token=${secrets[0]}`,
|
||||||
|
`response auth_token: ${secrets[1]}`,
|
||||||
|
`refresh_token = ${secrets[2]}`,
|
||||||
|
`using Bearer ${secrets[3]}`,
|
||||||
|
`Authorization: Bearer ${secrets[4]}`
|
||||||
|
];
|
||||||
|
for (const [index, line] of cases.entries()) {
|
||||||
|
const out = redactLogText(line, []);
|
||||||
|
assert.ok(out.includes(REDACTED), `expected redaction in: ${line} -> ${out}`);
|
||||||
|
assert.ok(!out.includes(secrets[index]), `secret survived: ${out}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('redactLogText leaves benign "token" prose alone', () => {
|
||||||
|
const benign = 'token bucket refill rate is 5 per second';
|
||||||
|
assert.equal(redactLogText(benign, []), benign);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('redactLogText scrubs the password from a basic-auth URL but keeps host:port', () => {
|
||||||
|
const password = artificialSecret('fixture', 'Proxy', 'Password');
|
||||||
|
const out = redactLogText(`proxy https://admin:${password}@proxy.internal:8080/path`, []);
|
||||||
|
assert.ok(!out.includes(password), 'basic-auth password must be redacted');
|
||||||
|
assert.ok(out.includes('proxy.internal:8080'), 'host:port preserved');
|
||||||
|
assert.ok(out.includes('admin:'), 'username preserved');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('redactLogText does not touch a host:port URL without userinfo', () => {
|
||||||
|
const url = 'connecting to https://cdn.voe.sx:8080/upload now';
|
||||||
|
assert.equal(redactLogText(url, []), url);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('redactLogText scrubs Basic auth, JWTs and bare session= values (defense in depth)', () => {
|
||||||
|
const basicValue = artificialSecret('dXNlcjpw', 'YXNzd29y', 'ZDEyMw');
|
||||||
|
const jwtValue = artificialSecret('eyJhbGciOiJIUzI1NiJ9', '.', 'eyJzdWIiOiIxMjM0NTY3ODkwIn0', '.', 'dozjgNryP4J3jVmNHl0w5N');
|
||||||
|
const jwtSecret = artificialSecret('eyJhbGciOiJIUzI1NiJ9', '.', 'eyJzdWIiOiIxMjM0NTY3ODkwIn0');
|
||||||
|
const sessionValue = artificialSecret('fixture', 'Session', 'Value', '99887766');
|
||||||
|
const jsonSessionValue = artificialSecret('fixture', 'Json', 'Session', '123456');
|
||||||
|
const cases = [
|
||||||
|
{ line: `Authorization: Basic ${basicValue}==`, secret: basicValue },
|
||||||
|
{ line: `jwt ${jwtValue}`, secret: jwtSecret },
|
||||||
|
{ line: `session=${sessionValue}`, secret: sessionValue },
|
||||||
|
{ line: `"session":"${jsonSessionValue}"`, secret: jsonSessionValue },
|
||||||
|
];
|
||||||
|
for (const c of cases) {
|
||||||
|
const out = redactLogText(c.line, []);
|
||||||
|
assert.ok(!out.includes(c.secret), `must redact: ${c.line} -> ${out}`);
|
||||||
|
assert.ok(out.includes(REDACTED), `expected ${REDACTED} in ${out}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('redactLogText leaves a normal "session" word in prose alone', () => {
|
||||||
|
const benign = 'the session was idle for a while';
|
||||||
|
assert.equal(redactLogText(benign, []), benign);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sanitizeConfig does not mutate input', () => {
|
||||||
|
const input = { hosters: { 'voe.sx': [{ password: 'secret' }] } };
|
||||||
|
const clone = JSON.parse(JSON.stringify(input));
|
||||||
|
sanitizeConfig(input);
|
||||||
|
assert.deepStrictEqual(input, clone);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sanitizeConfig leaves empty/missing credentials alone', () => {
|
||||||
|
const input = { hosters: { 'voe.sx': [{ password: '', apiKey: null }] } };
|
||||||
|
const out = sanitizeConfig(input);
|
||||||
|
assert.strictEqual(out.hosters['voe.sx'][0].password, '');
|
||||||
|
assert.strictEqual(out.hosters['voe.sx'][0].apiKey, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sanitizeConfig handles null/undefined input', () => {
|
||||||
|
assert.strictEqual(sanitizeConfig(null), null);
|
||||||
|
assert.strictEqual(sanitizeConfig(undefined), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('collectFile tails when file exceeds maxBytes', () => {
|
||||||
|
const tmp = path.join(os.tmpdir(), `mhu-bundle-${Date.now()}.log`);
|
||||||
|
const bigLine = 'x'.repeat(1000) + '\n';
|
||||||
|
fs.writeFileSync(tmp, bigLine.repeat(100));
|
||||||
|
try {
|
||||||
|
const section = collectFile(tmp, 'big.log', 5000);
|
||||||
|
assert.match(section, /truncated: skipped first \d+ bytes/);
|
||||||
|
assert.ok(section.length < bigLine.length * 100, 'section should be truncated');
|
||||||
|
} finally {
|
||||||
|
fs.unlinkSync(tmp);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('collectFile returns placeholder for missing file', () => {
|
||||||
|
const section = collectFile(path.join(os.tmpdir(), `does-not-exist-${Date.now()}.log`), 'missing');
|
||||||
|
assert.match(section, /<file does not exist yet>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('collectFile returns placeholder for null path', () => {
|
||||||
|
const section = collectFile(null, 'no-path');
|
||||||
|
assert.match(section, /<no path configured>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildSupportBundleText produces structured output with header + config + file sections', () => {
|
||||||
|
const tmp = path.join(os.tmpdir(), `mhu-bundle-text-${Date.now()}.log`);
|
||||||
|
fs.writeFileSync(tmp, 'line one\nline two\n');
|
||||||
|
try {
|
||||||
|
const text = buildSupportBundleText({
|
||||||
|
header: { Version: '3.3.41', Platform: 'win32' },
|
||||||
|
sanitizedConfig: { hosters: { 'voe.sx': [{ apiKey: '<redacted>' }] } },
|
||||||
|
files: [{ label: 'debug.log', path: tmp }]
|
||||||
|
});
|
||||||
|
assert.match(text, /^=== Multi-Hoster-Upload Support Bundle ===/);
|
||||||
|
assert.match(text, /Version: 3\.3\.41/);
|
||||||
|
assert.match(text, /Platform: win32/);
|
||||||
|
assert.match(text, /=== Config \(sanitized/);
|
||||||
|
assert.match(text, /"apiKey": "<redacted>"/);
|
||||||
|
assert.match(text, /=== debug\.log/);
|
||||||
|
assert.match(text, /line one\nline two/);
|
||||||
|
} finally {
|
||||||
|
fs.unlinkSync(tmp);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildSupportBundleText handles empty file list and missing header', () => {
|
||||||
|
const text = buildSupportBundleText({ sanitizedConfig: {}, files: [] });
|
||||||
|
assert.match(text, /=== Multi-Hoster-Upload Support Bundle ===/);
|
||||||
|
assert.match(text, /=== Config/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
const { describe, it, beforeEach, mock } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
describe('suspect-reject alternate accounts', () => {
|
||||||
|
let UploadManager;
|
||||||
|
let mockUploadFile;
|
||||||
|
let mockProbe;
|
||||||
|
|
||||||
|
function suspectErr() {
|
||||||
|
const e = new Error('Byse lehnte Datei ab: Not video file format');
|
||||||
|
e.fileRejected = true;
|
||||||
|
e.suspectReject = true;
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
delete require.cache[require.resolve('../lib/upload-manager')];
|
||||||
|
|
||||||
|
const hosters = require('../lib/hosters');
|
||||||
|
mockUploadFile = mock.fn(async () => ({ download_url: 'https://byse.sx/d/ok', embed_url: null, file_code: 'ok' }));
|
||||||
|
hosters.uploadFile = (...a) => mockUploadFile(...a);
|
||||||
|
hosters.prefetchBaseline = async () => null;
|
||||||
|
|
||||||
|
const fileProbe = require('../lib/file-probe');
|
||||||
|
mockProbe = mock.fn(async () => ({ ok: true, kind: 'matroska', isVideoLike: true, headHex: '1a45dfa3' }));
|
||||||
|
fileProbe.probeFileHead = (...a) => mockProbe(...a);
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const fakeSize = (p) => {
|
||||||
|
const m = /-(\d+)gb/i.exec(p);
|
||||||
|
return { size: (m ? parseInt(m[1], 10) : 3) * 1024 * 1024 * 1024 };
|
||||||
|
};
|
||||||
|
const origStatSync = fs.statSync;
|
||||||
|
fs.statSync = function (p) {
|
||||||
|
if (typeof p === 'string' && p.startsWith('/test/')) return fakeSize(p);
|
||||||
|
return origStatSync.call(this, p);
|
||||||
|
};
|
||||||
|
const origStat = fs.promises.stat;
|
||||||
|
fs.promises.stat = async function (p) {
|
||||||
|
if (typeof p === 'string' && p.startsWith('/test/')) return fakeSize(p);
|
||||||
|
return origStat.call(this, p);
|
||||||
|
};
|
||||||
|
|
||||||
|
UploadManager = require('../lib/upload-manager');
|
||||||
|
});
|
||||||
|
|
||||||
|
function poolMgr(pool, settings) {
|
||||||
|
return new UploadManager({ 'byse.sx': { retries: 0, ...(settings || {}) } }, {}, { 'byse.sx': pool });
|
||||||
|
}
|
||||||
|
|
||||||
|
it('tries the file on the next pool account after a suspect rejection and succeeds without blacklisting', async () => {
|
||||||
|
const mgr = poolMgr([
|
||||||
|
{ id: 'acc1', apiKey: 'key1' },
|
||||||
|
{ id: 'acc2', apiKey: 'key2' }
|
||||||
|
]);
|
||||||
|
mockUploadFile.mock.mockImplementation(async (hoster, file, apiKey) => {
|
||||||
|
if (apiKey === 'key1') throw suspectErr();
|
||||||
|
return { download_url: 'https://byse.sx/d/alt', embed_url: null, file_code: 'alt' };
|
||||||
|
});
|
||||||
|
const rotEvents = [];
|
||||||
|
mgr.on('rot-log', (e) => rotEvents.push(e.event));
|
||||||
|
let summary = null;
|
||||||
|
mgr.on('batch-done', (s) => { summary = s; });
|
||||||
|
|
||||||
|
await mgr.startBatch([{ file: '/test/big.mkv', hoster: 'byse.sx', apiKey: 'key1', accountId: 'acc1' }]);
|
||||||
|
|
||||||
|
assert.equal(summary.succeeded, 1);
|
||||||
|
assert.equal(summary.failed, 0);
|
||||||
|
const keys = mockUploadFile.mock.calls.map(c => c.arguments[2]);
|
||||||
|
assert.deepEqual(keys, ['key1', 'key2']);
|
||||||
|
assert.ok(rotEvents.includes('suspect-reject-alt'));
|
||||||
|
assert.equal(mgr.getFailedAccountKeys().length, 0, 'suspect rejection must not blacklist any account');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails the file when every pool account gives the suspect rejection — each tried exactly once, none blacklisted', async () => {
|
||||||
|
const mgr = poolMgr([
|
||||||
|
{ id: 'acc1', apiKey: 'key1' },
|
||||||
|
{ id: 'acc2', apiKey: 'key2' },
|
||||||
|
{ id: 'acc3', apiKey: 'key3' }
|
||||||
|
]);
|
||||||
|
mockUploadFile.mock.mockImplementation(async () => { throw suspectErr(); });
|
||||||
|
const rotEvents = [];
|
||||||
|
mgr.on('rot-log', (e) => rotEvents.push(e.event));
|
||||||
|
let summary = null;
|
||||||
|
mgr.on('batch-done', (s) => { summary = s; });
|
||||||
|
|
||||||
|
await mgr.startBatch([{ file: '/test/big.mkv', hoster: 'byse.sx', apiKey: 'key1', accountId: 'acc1' }]);
|
||||||
|
|
||||||
|
assert.equal(summary.failed, 1);
|
||||||
|
const keys = mockUploadFile.mock.calls.map(c => c.arguments[2]);
|
||||||
|
assert.deepEqual(keys, ['key1', 'key2', 'key3']);
|
||||||
|
assert.ok(rotEvents.includes('suspect-reject-exhausted'));
|
||||||
|
assert.equal(mgr.getFailedAccountKeys().length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips pool accounts already marked failed and lands on the last one', async () => {
|
||||||
|
const mgr = poolMgr([
|
||||||
|
{ id: 'acc1', apiKey: 'key1' },
|
||||||
|
{ id: 'acc2', apiKey: 'key2' },
|
||||||
|
{ id: 'acc3', apiKey: 'key3' },
|
||||||
|
{ id: 'acc4', apiKey: 'key4' }
|
||||||
|
]);
|
||||||
|
mockUploadFile.mock.mockImplementation(async (hoster, file, apiKey) => {
|
||||||
|
if (apiKey === 'key3') throw suspectErr();
|
||||||
|
return { download_url: 'https://byse.sx/d/four', embed_url: null, file_code: 'four' };
|
||||||
|
});
|
||||||
|
let summary = null;
|
||||||
|
mgr.on('batch-done', (s) => { summary = s; });
|
||||||
|
|
||||||
|
await mgr.startBatch(
|
||||||
|
[{ file: '/test/big.mkv', hoster: 'byse.sx', apiKey: 'key3', accountId: 'acc3' }],
|
||||||
|
{ primeFailedAccounts: ['byse.sx:acc1', 'byse.sx:acc2'] }
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(summary.succeeded, 1);
|
||||||
|
const keys = mockUploadFile.mock.calls.map(c => c.arguments[2]);
|
||||||
|
assert.deepEqual(keys, ['key3', 'key4'], 'failed acc1/acc2 skipped, fourth account finally gets the file');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT try alternates when the probe says the file is not a video', async () => {
|
||||||
|
mockProbe.mock.mockImplementation(async () => ({ ok: true, kind: 'rar', isVideoLike: false, headHex: '52617221' }));
|
||||||
|
const mgr = poolMgr([
|
||||||
|
{ id: 'acc1', apiKey: 'key1' },
|
||||||
|
{ id: 'acc2', apiKey: 'key2' }
|
||||||
|
]);
|
||||||
|
mockUploadFile.mock.mockImplementation(async () => { throw suspectErr(); });
|
||||||
|
const rotEvents = [];
|
||||||
|
mgr.on('rot-log', (e) => rotEvents.push(e.event));
|
||||||
|
let summary = null;
|
||||||
|
mgr.on('batch-done', (s) => { summary = s; });
|
||||||
|
|
||||||
|
await mgr.startBatch([{ file: '/test/archive.rar', hoster: 'byse.sx', apiKey: 'key1', accountId: 'acc1' }]);
|
||||||
|
|
||||||
|
assert.equal(summary.failed, 1);
|
||||||
|
assert.equal(mockUploadFile.mock.calls.length, 1, 'genuine non-video rejection must not burn uploads on other accounts');
|
||||||
|
assert.ok(rotEvents.includes('skip-rotation-file-rejected'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records a user cancel during the alternates walk as aborted, not error', async () => {
|
||||||
|
const mgr = poolMgr([
|
||||||
|
{ id: 'acc1', apiKey: 'key1' },
|
||||||
|
{ id: 'acc2', apiKey: 'key2' }
|
||||||
|
]);
|
||||||
|
mockUploadFile.mock.mockImplementation(async (hoster, file, apiKey) => {
|
||||||
|
if (apiKey === 'key1') throw suspectErr();
|
||||||
|
mgr.cancel();
|
||||||
|
const e = new Error('This operation was aborted');
|
||||||
|
throw e;
|
||||||
|
});
|
||||||
|
let summary = null;
|
||||||
|
mgr.on('batch-done', (s) => { summary = s; });
|
||||||
|
|
||||||
|
await mgr.startBatch([{ file: '/test/big.mkv', hoster: 'byse.sx', apiKey: 'key1', accountId: 'acc1' }]);
|
||||||
|
|
||||||
|
assert.equal(summary.files[0].results[0].status, 'aborted');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks an alternate failed on a genuine account error so later suspect files skip it', async () => {
|
||||||
|
const mgr = poolMgr([
|
||||||
|
{ id: 'acc1', apiKey: 'key1' },
|
||||||
|
{ id: 'acc2', apiKey: 'key2' },
|
||||||
|
{ id: 'acc3', apiKey: 'key3' }
|
||||||
|
], { parallelCount: 1 });
|
||||||
|
mockUploadFile.mock.mockImplementation(async (hoster, file, apiKey) => {
|
||||||
|
if (apiKey === 'key1') throw suspectErr();
|
||||||
|
if (apiKey === 'key2') {
|
||||||
|
const e = new Error('Byse lehnte Datei ab: 0:0:0:not enough disk space on your account');
|
||||||
|
e.accountError = true;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
return { download_url: 'https://byse.sx/d/three', embed_url: null, file_code: 'three' };
|
||||||
|
});
|
||||||
|
let summary = null;
|
||||||
|
mgr.on('batch-done', (s) => { summary = s; });
|
||||||
|
|
||||||
|
await mgr.startBatch([
|
||||||
|
{ file: '/test/big1.mkv', hoster: 'byse.sx', apiKey: 'key1', accountId: 'acc1' },
|
||||||
|
{ file: '/test/big2.mkv', hoster: 'byse.sx', apiKey: 'key1', accountId: 'acc1' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(summary.succeeded, 2);
|
||||||
|
assert.ok(mgr.getFailedAccountKeys().includes('byse.sx:acc2'), 'dead alternate must be remembered');
|
||||||
|
const keys = mockUploadFile.mock.calls.map(c => c.arguments[2]);
|
||||||
|
assert.deepEqual(keys, ['key1', 'key2', 'key3', 'key1', 'key3'], 'second same-size file still gets one real attempt on the primary (memo arms only on the 2nd rejection), then skips the dead alternate and lands on the good account');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('size memo short-circuits a later LARGER file once the account has two confirmed rejections', async () => {
|
||||||
|
const mgr = poolMgr([
|
||||||
|
{ id: 'acc1', apiKey: 'key1' },
|
||||||
|
{ id: 'acc2', apiKey: 'key2' },
|
||||||
|
{ id: 'acc3', apiKey: 'key3' }
|
||||||
|
], { parallelCount: 1 });
|
||||||
|
mockUploadFile.mock.mockImplementation(async (hoster, file, apiKey) => {
|
||||||
|
if (apiKey === 'key1' || apiKey === 'key2') throw suspectErr();
|
||||||
|
return { download_url: 'https://byse.sx/d/three', embed_url: null, file_code: 'three' };
|
||||||
|
});
|
||||||
|
const rotEvents = [];
|
||||||
|
mgr.on('rot-log', (e) => rotEvents.push(e.event));
|
||||||
|
let summary = null;
|
||||||
|
mgr.on('batch-done', (s) => { summary = s; });
|
||||||
|
|
||||||
|
await mgr.startBatch([
|
||||||
|
{ file: '/test/a-1gb.mkv', hoster: 'byse.sx', apiKey: 'key1', accountId: 'acc1' },
|
||||||
|
{ file: '/test/b-1gb.mkv', hoster: 'byse.sx', apiKey: 'key1', accountId: 'acc1' },
|
||||||
|
{ file: '/test/c-2gb.mkv', hoster: 'byse.sx', apiKey: 'key1', accountId: 'acc1' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(summary.succeeded, 3);
|
||||||
|
const keys = mockUploadFile.mock.calls.map(c => c.arguments[2]);
|
||||||
|
assert.deepEqual(keys, ['key1', 'key2', 'key3', 'key1', 'key3', 'key3'], 'files 1+2 each get a real attempt on the 1GB-rejecting primary (arming the memo at count 2); the larger 3rd file then short-circuits the primary straight to the good account');
|
||||||
|
assert.ok(rotEvents.includes('suspect-memo-skip'), 'the larger third file must skip its primary via the armed size memo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sizeMemoEnabled:false disables the pre-skip — the larger third file still gets a real attempt on its primary', async () => {
|
||||||
|
const mgr = poolMgr([
|
||||||
|
{ id: 'acc1', apiKey: 'key1' },
|
||||||
|
{ id: 'acc2', apiKey: 'key2' },
|
||||||
|
{ id: 'acc3', apiKey: 'key3' }
|
||||||
|
], { parallelCount: 1, sizeMemoEnabled: false });
|
||||||
|
mockUploadFile.mock.mockImplementation(async (hoster, file, apiKey) => {
|
||||||
|
if (apiKey === 'key1' || apiKey === 'key2') throw suspectErr();
|
||||||
|
return { download_url: 'https://byse.sx/d/three', embed_url: null, file_code: 'three' };
|
||||||
|
});
|
||||||
|
const rotEvents = [];
|
||||||
|
mgr.on('rot-log', (e) => rotEvents.push(e.event));
|
||||||
|
let summary = null;
|
||||||
|
mgr.on('batch-done', (s) => { summary = s; });
|
||||||
|
|
||||||
|
await mgr.startBatch([
|
||||||
|
{ file: '/test/a-1gb.mkv', hoster: 'byse.sx', apiKey: 'key1', accountId: 'acc1' },
|
||||||
|
{ file: '/test/b-1gb.mkv', hoster: 'byse.sx', apiKey: 'key1', accountId: 'acc1' },
|
||||||
|
{ file: '/test/c-2gb.mkv', hoster: 'byse.sx', apiKey: 'key1', accountId: 'acc1' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(summary.succeeded, 3);
|
||||||
|
const keys = mockUploadFile.mock.calls.map(c => c.arguments[2]);
|
||||||
|
assert.equal(keys.filter(k => k === 'key1').length, 3, 'with the memo off every file gets a real attempt on the primary — including the larger third');
|
||||||
|
assert.ok(!rotEvents.includes('suspect-memo-skip'), 'the disabled memo must never short-circuit a primary');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('plain fileRejected without suspect flag keeps the old fast-fail behavior', async () => {
|
||||||
|
const mgr = poolMgr([
|
||||||
|
{ id: 'acc1', apiKey: 'key1' },
|
||||||
|
{ id: 'acc2', apiKey: 'key2' }
|
||||||
|
]);
|
||||||
|
mockUploadFile.mock.mockImplementation(async () => {
|
||||||
|
const e = new Error('Byse lehnte Datei ab: Duplicate');
|
||||||
|
e.fileRejected = true;
|
||||||
|
throw e;
|
||||||
|
});
|
||||||
|
let summary = null;
|
||||||
|
mgr.on('batch-done', (s) => { summary = s; });
|
||||||
|
|
||||||
|
await mgr.startBatch([{ file: '/test/big.mkv', hoster: 'byse.sx', apiKey: 'key1', accountId: 'acc1' }]);
|
||||||
|
|
||||||
|
assert.equal(summary.failed, 1);
|
||||||
|
assert.equal(mockUploadFile.mock.calls.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a transient 5xx (byse 502) retries the SAME account and fails clean — no blacklist, no failover cascade', async () => {
|
||||||
|
const mgr = poolMgr([
|
||||||
|
{ id: 'acc1', apiKey: 'key1' },
|
||||||
|
{ id: 'acc2', apiKey: 'key2' },
|
||||||
|
{ id: 'acc3', apiKey: 'key3' }
|
||||||
|
], { retries: 2 });
|
||||||
|
mgr._sleep = async () => {};
|
||||||
|
mockUploadFile.mock.mockImplementation(async () => {
|
||||||
|
const e = new Error('Upload-Antwort von byse.sx war kein JSON (HTTP 502): <!doctype html>');
|
||||||
|
e.transientNetwork = true;
|
||||||
|
throw e;
|
||||||
|
});
|
||||||
|
let accountFailed = 0;
|
||||||
|
mgr.on('account-failed', () => { accountFailed++; });
|
||||||
|
let summary = null;
|
||||||
|
mgr.on('batch-done', (s) => { summary = s; });
|
||||||
|
|
||||||
|
await mgr.startBatch([{ file: '/test/big.mkv', hoster: 'byse.sx', apiKey: 'key1', accountId: 'acc1' }]);
|
||||||
|
|
||||||
|
assert.equal(summary.failed, 1);
|
||||||
|
assert.equal(accountFailed, 0, 'a transient 502 must never emit account-failed');
|
||||||
|
assert.equal(mgr.getFailedAccountKeys().length, 0, 'no account blacklisted on a transient 502');
|
||||||
|
const keys = mockUploadFile.mock.calls.map(c => c.arguments[2]);
|
||||||
|
assert.ok(keys.length >= 2, 'the 502 is retried on the same account');
|
||||||
|
assert.ok(keys.every(k => k === 'key1'), 'every attempt stays on the primary — no cascade to key2/key3');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const { makeThrottleTimer } = require('../lib/throttle-timer');
|
||||||
|
|
||||||
|
function fakeClock() {
|
||||||
|
let t = 0;
|
||||||
|
let timers = [];
|
||||||
|
return {
|
||||||
|
now: () => t,
|
||||||
|
schedule: (cb, ms) => {
|
||||||
|
const h = { at: t + ms, cb, dead: false };
|
||||||
|
timers.push(h);
|
||||||
|
return h;
|
||||||
|
},
|
||||||
|
clear: (h) => { if (h) h.dead = true; },
|
||||||
|
advance: (ms) => {
|
||||||
|
const target = t + ms;
|
||||||
|
for (;;) {
|
||||||
|
let next = null;
|
||||||
|
for (const h of timers) {
|
||||||
|
if (!h.dead && h.at <= target && (next === null || h.at < next.at)) next = h;
|
||||||
|
}
|
||||||
|
if (!next) break;
|
||||||
|
t = next.at;
|
||||||
|
next.dead = true;
|
||||||
|
next.cb();
|
||||||
|
}
|
||||||
|
t = target;
|
||||||
|
timers = timers.filter(h => !h.dead);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('idle debounce: fires once after the delay window', () => {
|
||||||
|
const c = fakeClock();
|
||||||
|
const tt = makeThrottleTimer(c);
|
||||||
|
let fired = 0;
|
||||||
|
tt.request(() => fired++, 500);
|
||||||
|
c.advance(499);
|
||||||
|
assert.equal(fired, 0);
|
||||||
|
c.advance(1);
|
||||||
|
assert.equal(fired, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('idle debounce: rapid requests reset the timer (last wins)', () => {
|
||||||
|
const c = fakeClock();
|
||||||
|
const tt = makeThrottleTimer(c);
|
||||||
|
let fired = 0;
|
||||||
|
tt.request(() => fired++, 500);
|
||||||
|
c.advance(200);
|
||||||
|
tt.request(() => fired++, 500);
|
||||||
|
c.advance(300);
|
||||||
|
assert.equal(fired, 0, 'should not fire at original 500 — was reset');
|
||||||
|
c.advance(200);
|
||||||
|
assert.equal(fired, 1, 'fires 500ms after the second request');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('STARVATION repro: continuous requests with no maxWait NEVER fire', () => {
|
||||||
|
const c = fakeClock();
|
||||||
|
const tt = makeThrottleTimer(c);
|
||||||
|
let fired = 0;
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
tt.request(() => fired++, 500);
|
||||||
|
c.advance(100);
|
||||||
|
}
|
||||||
|
assert.equal(fired, 0, 'this is exactly the bug the maxWait fix addresses');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('maxWait: continuous requests still force a fire within the window', () => {
|
||||||
|
const c = fakeClock();
|
||||||
|
const tt = makeThrottleTimer(c);
|
||||||
|
let fired = 0;
|
||||||
|
const fireAtTimes = [];
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
tt.request(() => { fired++; fireAtTimes.push(c.now()); }, 500, 2000);
|
||||||
|
c.advance(100);
|
||||||
|
}
|
||||||
|
assert.ok(fired >= 1, 'maxWait guarantees at least one fire under continuous load');
|
||||||
|
assert.ok(fireAtTimes.every(t => t > 0), 'fires happened, not starved');
|
||||||
|
assert.ok(fireAtTimes.some(t => t <= 2000), 'first fire no later than maxWait');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('maxWait: after a forced fire a fresh burst starts (periodic fires)', () => {
|
||||||
|
const c = fakeClock();
|
||||||
|
const tt = makeThrottleTimer(c);
|
||||||
|
let fired = 0;
|
||||||
|
for (let i = 0; i < 60; i++) {
|
||||||
|
tt.request(() => fired++, 500, 2000);
|
||||||
|
c.advance(100);
|
||||||
|
}
|
||||||
|
assert.ok(fired >= 2, `~6000ms of continuous load with 2000ms maxWait should fire multiple times, got ${fired}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('flushSync fires the pending fn immediately and clears it', () => {
|
||||||
|
const c = fakeClock();
|
||||||
|
const tt = makeThrottleTimer(c);
|
||||||
|
let fired = 0;
|
||||||
|
tt.request(() => fired++, 5000);
|
||||||
|
assert.ok(tt.isPending());
|
||||||
|
tt.flushSync();
|
||||||
|
assert.equal(fired, 1);
|
||||||
|
assert.ok(!tt.isPending());
|
||||||
|
c.advance(10000);
|
||||||
|
assert.equal(fired, 1, 'no double fire after flushSync');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cancel drops the pending fn — no fire', () => {
|
||||||
|
const c = fakeClock();
|
||||||
|
const tt = makeThrottleTimer(c);
|
||||||
|
let fired = 0;
|
||||||
|
tt.request(() => fired++, 500);
|
||||||
|
tt.cancel();
|
||||||
|
assert.ok(!tt.isPending());
|
||||||
|
c.advance(10000);
|
||||||
|
assert.equal(fired, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('last-write-wins: a later request with a DIFFERENT fn replaces the earlier one', () => {
|
||||||
|
const c = fakeClock();
|
||||||
|
const tt = makeThrottleTimer(c);
|
||||||
|
const fired = [];
|
||||||
|
tt.request(() => fired.push('persist'), 500, 20000);
|
||||||
|
c.advance(100);
|
||||||
|
tt.request(() => fired.push('clear'), 0);
|
||||||
|
c.advance(100);
|
||||||
|
assert.deepEqual(fired, ['clear'], 'only the latest fn fires; the persist was dropped');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('flushSync with nothing pending is a no-op', () => {
|
||||||
|
const c = fakeClock();
|
||||||
|
const tt = makeThrottleTimer(c);
|
||||||
|
assert.doesNotThrow(() => tt.flushSync());
|
||||||
|
});
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
const { describe, it } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const Throttle = require('../lib/throttle');
|
||||||
|
|
||||||
|
describe('Throttle', () => {
|
||||||
|
it('unlimited mode (0) returns immediately', async () => {
|
||||||
|
const t = new Throttle(0);
|
||||||
|
const start = Date.now();
|
||||||
|
await t.consume(10_000_000);
|
||||||
|
assert.ok(Date.now() - start < 50, 'should be instant');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unlimited with falsy values', async () => {
|
||||||
|
for (const val of [undefined, null, false, 0]) {
|
||||||
|
const t = new Throttle(val);
|
||||||
|
const start = Date.now();
|
||||||
|
await t.consume(1_000_000);
|
||||||
|
assert.ok(Date.now() - start < 50, `should be instant for ${val}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('small consume within initial token budget resolves immediately', async () => {
|
||||||
|
const t = new Throttle(1024 * 1024); // 1 MB/s
|
||||||
|
const start = Date.now();
|
||||||
|
await t.consume(100); // 100 bytes, well within 1MB budget
|
||||||
|
assert.ok(Date.now() - start < 50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('large consume exceeding tokens introduces delay', async () => {
|
||||||
|
const t = new Throttle(1000); // 1000 bytes/sec
|
||||||
|
// Drain initial tokens
|
||||||
|
await t.consume(1000);
|
||||||
|
|
||||||
|
const start = Date.now();
|
||||||
|
await t.consume(500); // needs ~500ms of refill
|
||||||
|
const elapsed = Date.now() - start;
|
||||||
|
assert.ok(elapsed >= 400, `expected >=400ms, got ${elapsed}ms`);
|
||||||
|
assert.ok(elapsed < 2000, `expected <2000ms, got ${elapsed}ms`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aborted signal stops consumption early', async () => {
|
||||||
|
const t = new Throttle(100); // 100 bytes/sec
|
||||||
|
await t.consume(100); // drain budget
|
||||||
|
|
||||||
|
const ac = new AbortController();
|
||||||
|
setTimeout(() => ac.abort(), 100);
|
||||||
|
|
||||||
|
const start = Date.now();
|
||||||
|
await t.consume(10000, ac.signal); // would take ~100s without abort
|
||||||
|
const elapsed = Date.now() - start;
|
||||||
|
assert.ok(elapsed < 1000, `should abort quickly, took ${elapsed}ms`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updateRate changes behavior', async () => {
|
||||||
|
const t = new Throttle(100);
|
||||||
|
await t.consume(100); // drain
|
||||||
|
|
||||||
|
t.updateRate(0); // switch to unlimited
|
||||||
|
const start = Date.now();
|
||||||
|
await t.consume(999999);
|
||||||
|
assert.ok(Date.now() - start < 50, 'should be instant after switching to unlimited');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('_refill does not exceed maxBps', () => {
|
||||||
|
const t = new Throttle(1000);
|
||||||
|
t.tokens = 0;
|
||||||
|
t.lastRefill = Date.now() - 60000; // simulate 60 seconds elapsed
|
||||||
|
t._refill();
|
||||||
|
assert.ok(t.tokens <= 1000, `tokens should not exceed maxBps, got ${t.tokens}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('concurrent consume calls share the token pool', async () => {
|
||||||
|
const t = new Throttle(2000); // 2000 bytes/sec, initial tokens = 2000
|
||||||
|
|
||||||
|
// Two concurrent consumes of 1000 each - should both fit in initial budget
|
||||||
|
const start = Date.now();
|
||||||
|
await Promise.all([t.consume(1000), t.consume(1000)]);
|
||||||
|
assert.ok(Date.now() - start < 100, 'both should resolve from initial budget');
|
||||||
|
|
||||||
|
// Third consume should need to wait for refill
|
||||||
|
const start2 = Date.now();
|
||||||
|
await t.consume(500);
|
||||||
|
const elapsed = Date.now() - start2;
|
||||||
|
assert.ok(elapsed >= 150, `third consume should wait for refill, took ${elapsed}ms`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('consume(0) resolves immediately', async () => {
|
||||||
|
const t = new Throttle(100);
|
||||||
|
const start = Date.now();
|
||||||
|
await t.consume(0);
|
||||||
|
assert.ok(Date.now() - start < 50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updateRate to unlimited (0) makes consume instant', async () => {
|
||||||
|
const t = new Throttle(100); // very slow
|
||||||
|
t.updateRate(0); // unlimited
|
||||||
|
const start = Date.now();
|
||||||
|
await t.consume(1_000_000);
|
||||||
|
assert.ok(Date.now() - start < 50, 'unlimited rate should be instant');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const { makeThrottledCache } = require('../lib/throttled-cache');
|
||||||
|
|
||||||
|
function fakeClock(start = 0) {
|
||||||
|
let t = start;
|
||||||
|
const fn = () => t;
|
||||||
|
fn.advance = (ms) => { t += ms; };
|
||||||
|
fn.set = (ms) => { t = ms; };
|
||||||
|
return fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('returns undefined when empty', () => {
|
||||||
|
const c = makeThrottledCache(100);
|
||||||
|
assert.equal(c.get('any', {}), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns the set value within the window', () => {
|
||||||
|
const clock = fakeClock();
|
||||||
|
const c = makeThrottledCache(100, clock);
|
||||||
|
const input = [1, 2, 3];
|
||||||
|
c.set('sig-a', input, 'value-1');
|
||||||
|
assert.equal(c.get('sig-a', input), 'value-1');
|
||||||
|
clock.advance(50);
|
||||||
|
assert.equal(c.get('sig-a', input), 'value-1', 'still valid at 50/100 ms');
|
||||||
|
clock.advance(49);
|
||||||
|
assert.equal(c.get('sig-a', input), 'value-1', 'still valid at 99/100 ms');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('expires exactly at refreshMs boundary', () => {
|
||||||
|
const clock = fakeClock();
|
||||||
|
const c = makeThrottledCache(100, clock);
|
||||||
|
c.set('s', {}, 'v');
|
||||||
|
clock.advance(100);
|
||||||
|
assert.equal(c.get('s', {}), undefined, '>= refreshMs is a miss');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('miss on different signature', () => {
|
||||||
|
const c = makeThrottledCache(1000, fakeClock());
|
||||||
|
const input = {};
|
||||||
|
c.set('sig-a', input, 'v');
|
||||||
|
assert.equal(c.get('sig-b', input), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('miss on different input identity even with same signature', () => {
|
||||||
|
const c = makeThrottledCache(1000, fakeClock());
|
||||||
|
c.set('sig-a', { a: 1 }, 'v');
|
||||||
|
// Different object identity — the cache compares by ===, not by contents
|
||||||
|
assert.equal(c.get('sig-a', { a: 1 }), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('overwrite by re-setting same signature', () => {
|
||||||
|
const clock = fakeClock();
|
||||||
|
const c = makeThrottledCache(100, clock);
|
||||||
|
const input = [];
|
||||||
|
c.set('s', input, 'old');
|
||||||
|
clock.advance(50);
|
||||||
|
c.set('s', input, 'new');
|
||||||
|
// The new entry has a fresh timestamp → still valid for another 100 ms
|
||||||
|
clock.advance(99);
|
||||||
|
assert.equal(c.get('s', input), 'new');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clear empties the cache', () => {
|
||||||
|
const c = makeThrottledCache(1000, fakeClock());
|
||||||
|
c.set('s', {}, 'v');
|
||||||
|
c.clear();
|
||||||
|
assert.equal(c.get('s', {}), undefined);
|
||||||
|
assert.equal(c.peek(), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('peek reports age and signature', () => {
|
||||||
|
const clock = fakeClock();
|
||||||
|
const c = makeThrottledCache(1000, clock);
|
||||||
|
c.set('mysig', {}, 'v');
|
||||||
|
clock.advance(42);
|
||||||
|
const p = c.peek();
|
||||||
|
assert.equal(p.sig, 'mysig');
|
||||||
|
assert.equal(p.age, 42);
|
||||||
|
assert.equal(p.ts, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('throws on invalid refreshMs', () => {
|
||||||
|
assert.throws(() => makeThrottledCache(-1));
|
||||||
|
assert.throws(() => makeThrottledCache(NaN));
|
||||||
|
assert.throws(() => makeThrottledCache('100'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refreshMs=0 means every call misses', () => {
|
||||||
|
const clock = fakeClock();
|
||||||
|
const c = makeThrottledCache(0, clock);
|
||||||
|
const input = {};
|
||||||
|
c.set('s', input, 'v');
|
||||||
|
// Same tick: 0 - 0 = 0 → not less than refreshMs (0) → miss
|
||||||
|
assert.equal(c.get('s', input), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('default clock is Date.now when none provided', () => {
|
||||||
|
const c = makeThrottledCache(10000);
|
||||||
|
const input = {}; // single ref — get and set must use the SAME identity
|
||||||
|
c.set('x', input, 'v');
|
||||||
|
assert.equal(c.get('x', input), 'v');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('large input arrays are tracked by identity, not value', () => {
|
||||||
|
const c = makeThrottledCache(1000, fakeClock());
|
||||||
|
const arr1 = new Array(10000).fill(0);
|
||||||
|
const arr2 = new Array(10000).fill(0);
|
||||||
|
c.set('s', arr1, 'cached');
|
||||||
|
assert.equal(c.get('s', arr1), 'cached');
|
||||||
|
assert.equal(c.get('s', arr2), undefined, 'different array → miss');
|
||||||
|
});
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
/**
|
||||||
|
* UI smoke test - launches the real app and checks DOM elements via webContents.
|
||||||
|
* Run with: node tests/ui-smoke.js
|
||||||
|
* (This spawns Electron as a child process)
|
||||||
|
*/
|
||||||
|
if (!process.env.RUN_UI_SMOKE) {
|
||||||
|
const { test } = require('node:test');
|
||||||
|
test('ui smoke skipped unless RUN_UI_SMOKE=1', () => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { execFileSync } = require('child_process');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
|
||||||
|
// Create a temp script that the real Electron app will execute via --eval
|
||||||
|
const testScript = `
|
||||||
|
const { app, BrowserWindow } = require('electron');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
async function runAfterDelay(win, delayMs) {
|
||||||
|
await new Promise(r => setTimeout(r, delayMs));
|
||||||
|
return win;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for app to be ready, then wait for the real window to load
|
||||||
|
setTimeout(async () => {
|
||||||
|
const windows = BrowserWindow.getAllWindows();
|
||||||
|
if (windows.length === 0) { console.log('ERROR: No windows found'); process.exit(1); }
|
||||||
|
const win = windows[0];
|
||||||
|
const wc = win.webContents;
|
||||||
|
|
||||||
|
// Wait for renderer init
|
||||||
|
await new Promise(r => setTimeout(r, 2000));
|
||||||
|
|
||||||
|
let passed = 0;
|
||||||
|
let failed = 0;
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
function check(name, condition) {
|
||||||
|
if (condition) { passed++; results.push(' PASS: ' + name); }
|
||||||
|
else { failed++; results.push(' FAIL: ' + name); }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('\\n=== Upload View ===');
|
||||||
|
|
||||||
|
const isolationRoot = process.env.UI_SMOKE_ISOLATION_ROOT || '';
|
||||||
|
const isolatedRootReady = path.isAbsolute(isolationRoot) && fs.existsSync(isolationRoot);
|
||||||
|
const isolatedAppData = isolatedRootReady && path.isAbsolute(process.env.APPDATA || '') && fs.existsSync(process.env.APPDATA) && path.resolve(process.env.APPDATA).toLowerCase() === path.resolve(isolationRoot, 'appdata').toLowerCase();
|
||||||
|
const isolatedLocalAppData = isolatedRootReady && path.isAbsolute(process.env.LOCALAPPDATA || '') && fs.existsSync(process.env.LOCALAPPDATA) && path.resolve(process.env.LOCALAPPDATA).toLowerCase() === path.resolve(isolationRoot, 'localappdata').toLowerCase();
|
||||||
|
const isolatedUserData = isolatedRootReady && path.isAbsolute(app.getPath('userData')) && fs.existsSync(app.getPath('userData')) && path.resolve(app.getPath('userData')).toLowerCase() === path.resolve(isolationRoot, 'user-data').toLowerCase();
|
||||||
|
console.log('Isolation: APPDATA=' + process.env.APPDATA + ' | LOCALAPPDATA=' + process.env.LOCALAPPDATA + ' | userData=' + app.getPath('userData'));
|
||||||
|
check('APPDATA, LOCALAPPDATA and Electron userData use isolated directories', isolatedAppData && isolatedLocalAppData && isolatedUserData);
|
||||||
|
check('Forced failure propagation', process.env.UI_SMOKE_FORCE_FAILURE !== '1');
|
||||||
|
|
||||||
|
const tabCount = await wc.executeJavaScript('document.querySelectorAll(".tab-bar > .tab").length');
|
||||||
|
check('4 main tabs exist', tabCount === 4);
|
||||||
|
|
||||||
|
const tabLabels = await wc.executeJavaScript('Array.from(document.querySelectorAll(".tab-bar > .tab"), el => el.textContent.trim()).join("|")');
|
||||||
|
check('Main tabs expose current views', tabLabels === 'Upload|Accounts|Einstellungen|Verlauf');
|
||||||
|
|
||||||
|
const activeTab = await wc.executeJavaScript('document.querySelector(".tab.active")?.textContent?.trim()');
|
||||||
|
check('Upload tab active by default', activeTab === 'Upload');
|
||||||
|
|
||||||
|
const dropVisible = await wc.executeJavaScript('document.getElementById("dropZone")?.style.display !== "none"');
|
||||||
|
check('Drop zone visible (no files)', dropVisible);
|
||||||
|
|
||||||
|
const queueHidden = await wc.executeJavaScript('document.getElementById("queueShell")?.style.display');
|
||||||
|
check('Queue hidden (no files)', queueHidden === 'none');
|
||||||
|
|
||||||
|
const queueControlCount = await wc.executeJavaScript('document.querySelectorAll("#queueCommandBar .toolbar-btn").length');
|
||||||
|
check('10 queue controls exist', queueControlCount === 10);
|
||||||
|
|
||||||
|
const hosterSummary = await wc.executeJavaScript('document.getElementById("hosterSummary")?.textContent');
|
||||||
|
check('Hoster summary reflects empty account state', hosterSummary === 'Keine Upload-Ziele ausgewählt');
|
||||||
|
|
||||||
|
const hosterOptionCount = await wc.executeJavaScript('document.querySelectorAll("#hosterModalList .hoster-option").length');
|
||||||
|
check('No selectable hosters without accounts', hosterOptionCount === 0);
|
||||||
|
|
||||||
|
const hosterHint = await wc.executeJavaScript('document.getElementById("hosterModalHint")?.textContent');
|
||||||
|
check('Hoster selection explains missing credentials', hosterHint && hosterHint.includes('Keine Hoster mit Zugangsdaten'));
|
||||||
|
|
||||||
|
const startDisabled = await wc.executeJavaScript('document.getElementById("startUploadBtn")?.disabled');
|
||||||
|
check('Start button disabled initially', startDisabled === true);
|
||||||
|
|
||||||
|
const sbState = await wc.executeJavaScript('document.getElementById("sbState")?.textContent');
|
||||||
|
check('Statusbar: Bereit', sbState === 'Bereit');
|
||||||
|
|
||||||
|
const version = await wc.executeJavaScript('document.getElementById("versionLabel")?.textContent');
|
||||||
|
check('Product version label present', version === 'v3.3.108');
|
||||||
|
|
||||||
|
const ctxHidden = await wc.executeJavaScript('document.getElementById("contextMenu")?.style.display');
|
||||||
|
check('Context menu hidden', ctxHidden === 'none');
|
||||||
|
|
||||||
|
console.log('\\n=== Accounts View ===');
|
||||||
|
|
||||||
|
await wc.executeJavaScript('document.querySelector(".tab[data-view=\\'accounts\\']").click()');
|
||||||
|
await new Promise(r => setTimeout(r, 300));
|
||||||
|
|
||||||
|
const accountsActive = await wc.executeJavaScript('document.getElementById("accounts-view")?.classList.contains("active")');
|
||||||
|
check('Accounts tab active', accountsActive);
|
||||||
|
|
||||||
|
const accountsEmpty = await wc.executeJavaScript('document.querySelector("#accountsList .accounts-empty p")?.textContent');
|
||||||
|
check('Accounts show privacy-safe empty state', accountsEmpty === 'Keine Accounts vorhanden');
|
||||||
|
|
||||||
|
await wc.executeJavaScript('document.getElementById("addAccountBtn").click()');
|
||||||
|
await new Promise(r => setTimeout(r, 200));
|
||||||
|
|
||||||
|
const accountModalVisible = await wc.executeJavaScript('document.getElementById("accountModal")?.style.display');
|
||||||
|
check('Add-account modal opens', accountModalVisible === 'flex');
|
||||||
|
|
||||||
|
const accountHosterOptions = await wc.executeJavaScript('document.querySelectorAll("#accountHosterSelect option").length');
|
||||||
|
check('7 current hoster/auth options exist', accountHosterOptions === 7);
|
||||||
|
|
||||||
|
const accountFieldsEmpty = await wc.executeJavaScript('["accField_label","accField_username","accField_password","accField_apiKey"].filter(id => document.getElementById(id)).every(id => document.getElementById(id).value === "")');
|
||||||
|
check('Account fields start empty', accountFieldsEmpty);
|
||||||
|
|
||||||
|
await wc.executeJavaScript('document.getElementById("closeAccountModalBtn").click()');
|
||||||
|
await new Promise(r => setTimeout(r, 100));
|
||||||
|
|
||||||
|
console.log('\\n=== Settings View ===');
|
||||||
|
|
||||||
|
await wc.executeJavaScript('document.querySelector(".tab[data-view=\\'settings\\']").click()');
|
||||||
|
await new Promise(r => setTimeout(r, 300));
|
||||||
|
|
||||||
|
const settingsActive = await wc.executeJavaScript('document.getElementById("settings-view")?.classList.contains("active")');
|
||||||
|
check('Settings tab active', settingsActive);
|
||||||
|
|
||||||
|
const settingsSubtabs = await wc.executeJavaScript('document.querySelectorAll(".settings-subtab").length');
|
||||||
|
check('6 settings subtabs exist', settingsSubtabs === 6);
|
||||||
|
|
||||||
|
const parallel = await wc.executeJavaScript('document.getElementById("parallelUploadCountInput")?.value');
|
||||||
|
check('Global parallel upload default is unlimited', parallel === '0');
|
||||||
|
|
||||||
|
const settingsPointer = await wc.executeJavaScript('document.querySelector(".settings-hoster-pointer")?.textContent');
|
||||||
|
check('Settings points hoster controls to Accounts', settingsPointer && settingsPointer.includes('Accounts'));
|
||||||
|
|
||||||
|
console.log('\\n=== History View ===');
|
||||||
|
|
||||||
|
await wc.executeJavaScript('document.querySelector(".tab[data-view=\\'history\\']").click()');
|
||||||
|
await new Promise(r => setTimeout(r, 1000)); // Wait for async loadHistory
|
||||||
|
|
||||||
|
const historyActive = await wc.executeJavaScript('document.getElementById("history-view")?.classList.contains("active")');
|
||||||
|
check('History tab active', historyActive);
|
||||||
|
|
||||||
|
const emptyState = await wc.executeJavaScript('document.querySelector("#historyContainer .empty-state")?.textContent');
|
||||||
|
check('Empty state or history table shown', emptyState === 'Noch keine Uploads.' || emptyState === undefined);
|
||||||
|
|
||||||
|
console.log('\\n=== Global UI ===');
|
||||||
|
|
||||||
|
const shutdownHidden = await wc.executeJavaScript('document.getElementById("shutdownOverlay")?.style.display');
|
||||||
|
check('Shutdown overlay hidden', shutdownHidden === 'none');
|
||||||
|
|
||||||
|
const toastHidden = await wc.executeJavaScript('!document.getElementById("copyToast")?.classList.contains("show")');
|
||||||
|
check('Copy toast hidden', toastHidden);
|
||||||
|
|
||||||
|
const updateHidden = await wc.executeJavaScript('document.getElementById("updateBanner")?.style.display');
|
||||||
|
check('Update banner hidden', updateHidden === 'none');
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Test error:', err.message);
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\\n=== Results ===');
|
||||||
|
results.forEach(r => console.log(r));
|
||||||
|
console.log('\\nTotal: ' + (passed + failed) + ' | Passed: ' + passed + ' | Failed: ' + failed);
|
||||||
|
|
||||||
|
app.exit(failed > 0 ? 1 : 0);
|
||||||
|
}, 5000);
|
||||||
|
`;
|
||||||
|
|
||||||
|
let injectRoot;
|
||||||
|
let injectPath;
|
||||||
|
let isolationRoot;
|
||||||
|
let runProvenSuccessful = false;
|
||||||
|
let childStarted = false;
|
||||||
|
let childStartTimeMs = 0;
|
||||||
|
let logSnapshots;
|
||||||
|
const appPath = path.resolve(__dirname, '..');
|
||||||
|
const protectedLogPaths = [path.join(appPath, 'crash.log'), path.join(appPath, 'upload-debug.log')];
|
||||||
|
|
||||||
|
function removeTempTree(target, prefix) {
|
||||||
|
if (!target) return;
|
||||||
|
const resolvedTarget = path.resolve(target);
|
||||||
|
const resolvedTemp = path.resolve(os.tmpdir());
|
||||||
|
const validParent = path.dirname(resolvedTarget).toLowerCase() === resolvedTemp.toLowerCase();
|
||||||
|
const validName = path.basename(resolvedTarget).startsWith(prefix);
|
||||||
|
if (!validParent || !validName) {
|
||||||
|
throw new Error('Refusing to remove unexpected UI smoke path: ' + resolvedTarget);
|
||||||
|
}
|
||||||
|
fs.rmSync(resolvedTarget, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function captureLogSnapshot(filePath) {
|
||||||
|
try {
|
||||||
|
const stats = fs.lstatSync(filePath);
|
||||||
|
if (!stats.isFile()) throw new Error('UI smoke protected log is not a regular file: ' + filePath);
|
||||||
|
return {
|
||||||
|
filePath,
|
||||||
|
existed: true,
|
||||||
|
bytes: fs.readFileSync(filePath),
|
||||||
|
mode: stats.mode,
|
||||||
|
atimeMs: stats.atimeMs,
|
||||||
|
mtimeMs: stats.mtimeMs,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === 'ENOENT') return { filePath, existed: false };
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreLogSnapshot(snapshot) {
|
||||||
|
let currentStats;
|
||||||
|
try {
|
||||||
|
currentStats = fs.lstatSync(snapshot.filePath);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code !== 'ENOENT') throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.existed) {
|
||||||
|
if (currentStats && !currentStats.isFile()) throw new Error('UI smoke cannot restore non-file log path: ' + snapshot.filePath);
|
||||||
|
fs.writeFileSync(snapshot.filePath, snapshot.bytes, currentStats ? undefined : { flag: 'wx', mode: snapshot.mode });
|
||||||
|
fs.chmodSync(snapshot.filePath, snapshot.mode);
|
||||||
|
fs.utimesSync(snapshot.filePath, snapshot.atimeMs / 1000, snapshot.mtimeMs / 1000);
|
||||||
|
const restoredBytes = fs.readFileSync(snapshot.filePath);
|
||||||
|
const restoredStats = fs.statSync(snapshot.filePath);
|
||||||
|
if (!restoredBytes.equals(snapshot.bytes)) throw new Error('UI smoke log byte restoration failed: ' + snapshot.filePath);
|
||||||
|
if ((restoredStats.mode & 0o777) !== (snapshot.mode & 0o777)) throw new Error('UI smoke log mode restoration failed: ' + snapshot.filePath);
|
||||||
|
if (Math.abs(restoredStats.mtimeMs - snapshot.mtimeMs) > 1) throw new Error('UI smoke log mtime restoration failed: ' + snapshot.filePath);
|
||||||
|
return 'restored';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentStats) return 'unchanged';
|
||||||
|
const writtenDuringChild = childStarted && childStartTimeMs > 0 && currentStats.mtimeMs >= childStartTimeMs - 1000;
|
||||||
|
if (!writtenDuringChild || !currentStats.isFile()) throw new Error('UI smoke refuses to remove unproven generated log: ' + snapshot.filePath);
|
||||||
|
fs.unlinkSync(snapshot.filePath);
|
||||||
|
return 'removed';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
logSnapshots = protectedLogPaths.map(captureLogSnapshot);
|
||||||
|
isolationRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-ui-smoke-state-'));
|
||||||
|
const appDataDir = path.join(isolationRoot, 'appdata');
|
||||||
|
const localAppDataDir = path.join(isolationRoot, 'localappdata');
|
||||||
|
const userDataDir = path.join(isolationRoot, 'user-data');
|
||||||
|
for (const directory of [appDataDir, localAppDataDir, userDataDir]) {
|
||||||
|
fs.mkdirSync(directory);
|
||||||
|
if (!path.isAbsolute(directory) || fs.readdirSync(directory).length !== 0) {
|
||||||
|
throw new Error('UI smoke isolation directory is not new, empty and absolute: ' + directory);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
injectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-ui-smoke-inject-'));
|
||||||
|
injectPath = path.join(injectRoot, 'ui-inject.js');
|
||||||
|
fs.writeFileSync(injectPath, testScript, 'utf-8');
|
||||||
|
|
||||||
|
if (process.env.UI_SMOKE_FORCE_SETUP_FAILURE === '1') {
|
||||||
|
throw new Error('Forced UI smoke setup failure');
|
||||||
|
}
|
||||||
|
const electronPath = process.env.UI_SMOKE_FORCE_SPAWN_FAILURE === '1'
|
||||||
|
? path.join(isolationRoot, 'missing-electron.exe')
|
||||||
|
: require('electron');
|
||||||
|
const childEnv = {
|
||||||
|
...process.env,
|
||||||
|
APPDATA: appDataDir,
|
||||||
|
LOCALAPPDATA: localAppDataDir,
|
||||||
|
ELECTRON_USER_DATA_DIR: userDataDir,
|
||||||
|
UI_SMOKE_ISOLATION_ROOT: isolationRoot,
|
||||||
|
};
|
||||||
|
childStartTimeMs = Date.now();
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = execFileSync(
|
||||||
|
electronPath,
|
||||||
|
[`--user-data-dir=${userDataDir}`, '--require', injectPath, appPath],
|
||||||
|
{ cwd: isolationRoot, env: childEnv, timeout: process.env.UI_SMOKE_FORCE_TIMEOUT === '1' ? 1000 : 20000, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
|
||||||
|
);
|
||||||
|
childStarted = true;
|
||||||
|
} catch (err) {
|
||||||
|
childStarted = (Number.isInteger(err.pid) && err.pid > 0) || Number.isInteger(err.status) || Boolean(err.signal);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
console.log(result);
|
||||||
|
runProvenSuccessful = true;
|
||||||
|
} catch (err) {
|
||||||
|
if (err.stdout) console.log(err.stdout);
|
||||||
|
if (err.stderr) {
|
||||||
|
const filtered = err.stderr.split('\n')
|
||||||
|
.filter(l => !l.includes('cache_util') && !l.includes('disk_cache') && !l.includes('gpu_disk_cache'))
|
||||||
|
.join('\n');
|
||||||
|
if (filtered.trim()) console.error(filtered);
|
||||||
|
}
|
||||||
|
if (!err.stdout && !err.stderr) console.error(err.message);
|
||||||
|
process.exitCode = Number.isInteger(err.status) && err.status > 0 && err.status <= 255 ? err.status : 1;
|
||||||
|
} finally {
|
||||||
|
if (logSnapshots) {
|
||||||
|
const cleanupResults = [];
|
||||||
|
for (const snapshot of logSnapshots) {
|
||||||
|
try {
|
||||||
|
cleanupResults.push(path.basename(snapshot.filePath) + '=' + restoreLogSnapshot(snapshot));
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err.message);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cleanupResults.length) console.log('UI smoke log cleanup: ' + cleanupResults.join(', '));
|
||||||
|
}
|
||||||
|
for (const [target, prefix] of [[injectRoot, 'mhu-ui-smoke-inject-'], [isolationRoot, 'mhu-ui-smoke-state-']]) {
|
||||||
|
try {
|
||||||
|
removeTempTree(target, prefix);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err.message);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!runProvenSuccessful && (!process.exitCode || process.exitCode === 0)) process.exitCode = 1;
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const { formatUploadLogLine, parseUploadLogLine } = require('../lib/upload-log');
|
||||||
|
const { partitionRestoredJobsByLog } = require('../lib/queue-dedup');
|
||||||
|
|
||||||
|
function previewJob(fileName, hoster) {
|
||||||
|
return { status: 'preview', fileName, hoster, file: `C:/dl/${fileName}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('writer -> reader round trip: parsed ts is the same epoch frame as the source Date getTime', () => {
|
||||||
|
const d = new Date(2026, 5, 19, 12, 0, 30);
|
||||||
|
const line = formatUploadLogLine(d, 'voe.sx', 'https://voe.sx/x', 'a.mkv');
|
||||||
|
const parsed = parseUploadLogLine(line);
|
||||||
|
assert.equal(parsed.hoster, 'voe.sx');
|
||||||
|
assert.equal(parsed.fileName, 'a.mkv');
|
||||||
|
assert.equal(parsed.ts, d.getTime(), 'parser ts must equal the writer Date epoch (no tz shift)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SEAM: a real appendUploadLog-format line drops a preview ghost vs a savedAt taken BEFORE completion', () => {
|
||||||
|
const completion = new Date(2026, 5, 19, 12, 0, 30);
|
||||||
|
const line = formatUploadLogLine(completion, 'voe.sx', 'link', 'a.mkv');
|
||||||
|
const parsed = parseUploadLogLine(line);
|
||||||
|
const savedAt = completion.getTime() - 5000;
|
||||||
|
const { removed, kept } = partitionRestoredJobsByLog([previewJob('a.mkv', 'voe.sx')], [parsed], savedAt);
|
||||||
|
assert.equal(removed.length, 1, 'a file logged after the snapshot is a ghost and must drop');
|
||||||
|
assert.equal(kept.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SEAM: the same real line is KEPT vs a savedAt taken AFTER completion (intentional re-upload)', () => {
|
||||||
|
const completion = new Date(2026, 5, 19, 12, 0, 30);
|
||||||
|
const parsed = parseUploadLogLine(formatUploadLogLine(completion, 'voe.sx', 'link', 'a.mkv'));
|
||||||
|
const savedAt = completion.getTime() + 5000;
|
||||||
|
const { removed, kept } = partitionRestoredJobsByLog([previewJob('a.mkv', 'voe.sx')], [parsed], savedAt);
|
||||||
|
assert.equal(removed.length, 0, 'an older upload than the snapshot is a deliberate re-queue and must survive');
|
||||||
|
assert.equal(kept.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseUploadLogLine skips comments, blanks and malformed lines', () => {
|
||||||
|
assert.equal(parseUploadLogLine('# fileuploader log'), null);
|
||||||
|
assert.equal(parseUploadLogLine(''), null);
|
||||||
|
assert.equal(parseUploadLogLine(' '), null);
|
||||||
|
assert.equal(parseUploadLogLine('only|three|parts|here'), null);
|
||||||
|
assert.equal(parseUploadLogLine(null), null);
|
||||||
|
assert.equal(parseUploadLogLine(42), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseUploadLogLine: missing/garbage timestamp yields ts=undefined (legacy lines still match by name)', () => {
|
||||||
|
const parsed = parseUploadLogLine('|voe.sx|link||a.mkv|');
|
||||||
|
assert.equal(parsed.hoster, 'voe.sx');
|
||||||
|
assert.equal(parsed.fileName, 'a.mkv');
|
||||||
|
assert.equal(parsed.ts, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseUploadLogLine: a pipe in the link does NOT shift the filename field (entry not lost)', () => {
|
||||||
|
const line = formatUploadLogLine(new Date(2026, 5, 19, 12, 0, 0), 'byse.sx', 'https://h.io/a|b', 'movie.mkv');
|
||||||
|
const parsed = parseUploadLogLine(line);
|
||||||
|
assert.equal(parsed.hoster, 'byse.sx');
|
||||||
|
assert.equal(parsed.fileName, 'movie.mkv', 'filename is taken as the last non-empty field, robust to link pipes');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseUploadLogLine: two pipes in the link still parse the correct filename', () => {
|
||||||
|
const line = formatUploadLogLine(new Date(2026, 5, 19, 12, 0, 0), 'byse.sx', 'https://h.io/a|b|c', 'movie.mkv');
|
||||||
|
const parsed = parseUploadLogLine(line);
|
||||||
|
assert.equal(parsed.fileName, 'movie.mkv');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseUploadLogLine: a leading-space filename is preserved (matches the untrimmed queue-job key)', () => {
|
||||||
|
const line = formatUploadLogLine(new Date(2026, 5, 19, 12, 0, 0), 'voe.sx', 'https://h.io/a', ' movie.mkv');
|
||||||
|
const parsed = parseUploadLogLine(line);
|
||||||
|
assert.equal(parsed.fileName, ' movie.mkv', 'filename is NOT trimmed, so it matches the OS basename verbatim');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SEAM: a leading-space filename round-trips and the gate still drops its ghost', () => {
|
||||||
|
const completion = new Date(2026, 5, 19, 12, 0, 30);
|
||||||
|
const parsed = parseUploadLogLine(formatUploadLogLine(completion, 'voe.sx', 'l', ' spaced.mp4'));
|
||||||
|
const savedAt = completion.getTime() - 5000;
|
||||||
|
const job = { status: 'preview', fileName: ' spaced.mp4', hoster: 'voe.sx', file: 'C:/dl/ spaced.mp4' };
|
||||||
|
const { removed } = partitionRestoredJobsByLog([job], [parsed], savedAt);
|
||||||
|
assert.equal(removed.length, 1, 'leading-space filename now matches end-to-end (was a mismatch before)');
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,195 @@
|
|||||||
|
// Pure unit tests for the validate-credentials shape contract — does NOT spin
|
||||||
|
// up Electron or the real per-hoster checkers. Those need network. We verify
|
||||||
|
// the SHAPE the ephemeral hosterConfig is built into (which the per-hoster
|
||||||
|
// checkers consume) plus the snapshot-key/invalidation invariants that the
|
||||||
|
// renderer relies on to enforce "validated creds only".
|
||||||
|
//
|
||||||
|
// The three assertions the advisor called out as the regression guard for the
|
||||||
|
// user's "mehrfach angelegt" complaint:
|
||||||
|
// (a) failed validation persists nothing to config.hosters
|
||||||
|
// (b) a second "Anlegen" click with the guard set persists exactly one entry
|
||||||
|
// (c) OTP-required path persists nothing
|
||||||
|
// are exercised at the state-machine level by simulating the renderer's logic
|
||||||
|
// (re-implemented here as pure functions for testability — the real ones live
|
||||||
|
// in renderer/app.js which can't run under node:test).
|
||||||
|
|
||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
|
||||||
|
// ---- Re-implementations of the renderer's pure helpers ----
|
||||||
|
// These mirror the production code exactly so the tests serve as both a guard
|
||||||
|
// and executable spec for what saveAccount() must do.
|
||||||
|
|
||||||
|
function credsSnapshotKey(authType, creds) {
|
||||||
|
if (authType === 'login') return `login:${creds.username || ''}:${creds.password || ''}`;
|
||||||
|
return `api:${creds.apiKey || ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEphemeralHosterConfig(payload) {
|
||||||
|
return {
|
||||||
|
username: payload.username || '',
|
||||||
|
password: payload.password || '',
|
||||||
|
apiKey: payload.apiKey || '',
|
||||||
|
enabled: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// State-machine simulator that mirrors saveAccount() WITHOUT DOM/IPC.
|
||||||
|
function makeStateMachine({ validateImpl, persistImpl }) {
|
||||||
|
let busy = false;
|
||||||
|
let validated = null; // { hosterName, authType, snapshot, status }
|
||||||
|
const log = []; // log of every persist call, for assertions
|
||||||
|
|
||||||
|
async function click(ctx, creds, otp = '') {
|
||||||
|
if (busy) { log.push({ type: 'click-ignored-busy' }); return; }
|
||||||
|
const snapshot = credsSnapshotKey(ctx.authType, creds);
|
||||||
|
|
||||||
|
// STEP 2: commit if validated matches.
|
||||||
|
if (validated &&
|
||||||
|
validated.hosterName === ctx.hosterName &&
|
||||||
|
validated.authType === ctx.authType &&
|
||||||
|
validated.snapshot === snapshot) {
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
await persistImpl(ctx, creds);
|
||||||
|
log.push({ type: 'persisted', accountId: ctx.accountId || `${ctx.hosterName}-NEW` });
|
||||||
|
} finally { busy = false; }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// STEP 1: ephemeral validate.
|
||||||
|
busy = true;
|
||||||
|
let row;
|
||||||
|
try {
|
||||||
|
row = await validateImpl({ hoster: ctx.hosterName, authType: ctx.authType, ...creds, otp });
|
||||||
|
} finally { busy = false; }
|
||||||
|
if (row && (row.status === 'ok' || row.status === 'warn')) {
|
||||||
|
validated = { hosterName: ctx.hosterName, authType: ctx.authType, snapshot, status: row.status };
|
||||||
|
log.push({ type: 'validated', status: row.status });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (row && row.status === 'otp_required') {
|
||||||
|
log.push({ type: 'otp-required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
log.push({ type: 'validation-failed', message: row && row.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
function editField() { validated = null; log.push({ type: 'invalidated-by-edit' }); }
|
||||||
|
return { click, editField, log: () => log.slice(), getValidated: () => validated };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Tests ----
|
||||||
|
|
||||||
|
test('regression (a): failed validation persists NOTHING to config.hosters', async () => {
|
||||||
|
const persistCalls = [];
|
||||||
|
const sm = makeStateMachine({
|
||||||
|
validateImpl: async () => ({ status: 'error', message: 'Falsches Passwort' }),
|
||||||
|
persistImpl: async (ctx, creds) => persistCalls.push({ ctx, creds })
|
||||||
|
});
|
||||||
|
await sm.click({ hosterName: 'doodstream.com', authType: 'login', isEdit: false }, { username: 'u', password: 'wrong' });
|
||||||
|
assert.equal(persistCalls.length, 0, 'no persist should happen on failed validation');
|
||||||
|
assert.equal(sm.getValidated(), null);
|
||||||
|
assert.deepEqual(sm.log().map(e => e.type), ['validation-failed']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('regression (b): second click with guard set persists exactly ONE entry — no duplication', async () => {
|
||||||
|
const persistCalls = [];
|
||||||
|
let validateCount = 0;
|
||||||
|
const sm = makeStateMachine({
|
||||||
|
validateImpl: async () => { validateCount++; return { status: 'ok' }; },
|
||||||
|
persistImpl: async (ctx, creds) => persistCalls.push({ ctx, creds })
|
||||||
|
});
|
||||||
|
const ctx = { hosterName: 'doodstream.com', authType: 'login', isEdit: false };
|
||||||
|
const creds = { username: 'u', password: 'p' };
|
||||||
|
// Click 1 = validate → green.
|
||||||
|
await sm.click(ctx, creds);
|
||||||
|
// Click 2 = commit (same creds, validated snapshot matches).
|
||||||
|
await sm.click(ctx, creds);
|
||||||
|
// Click 3 = guard prevents a second commit because after persistImpl the
|
||||||
|
// state-machine in real code closes the modal. In this simulator the
|
||||||
|
// validated snapshot is still set — but a real double-click WHILE persistImpl
|
||||||
|
// is in flight would be caught by busy. Simulate that:
|
||||||
|
const sm2 = makeStateMachine({
|
||||||
|
validateImpl: async () => ({ status: 'ok' }),
|
||||||
|
persistImpl: () => new Promise(r => setTimeout(() => { persistCalls.push('slow'); r(); }, 30))
|
||||||
|
});
|
||||||
|
await sm2.click(ctx, creds); // validate
|
||||||
|
const p1 = sm2.click(ctx, creds); // start commit
|
||||||
|
const p2 = sm2.click(ctx, creds); // racing click — must be ignored
|
||||||
|
await Promise.all([p1, p2]);
|
||||||
|
|
||||||
|
assert.equal(persistCalls.length, 2, 'one persist from the deliberate two-step flow + one from sm2; racing click ignored');
|
||||||
|
assert.equal(validateCount, 1, 'second click reused the validated snapshot — no re-validate');
|
||||||
|
// The racing click MUST have been ignored by the busy guard.
|
||||||
|
assert.ok(sm2.log().some(e => e.type === 'click-ignored-busy'), 'busy guard fired on racing click');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('regression (c): OTP-required persists NOTHING — and a follow-up click with OTP re-validates ephemerally', async () => {
|
||||||
|
const persistCalls = [];
|
||||||
|
let calls = 0;
|
||||||
|
const sm = makeStateMachine({
|
||||||
|
validateImpl: async (payload) => {
|
||||||
|
calls++;
|
||||||
|
if (!payload.otp) return { status: 'otp_required', message: 'OTP sent' };
|
||||||
|
if (payload.otp === '123456') return { status: 'ok' };
|
||||||
|
return { status: 'error', message: 'Bad OTP' };
|
||||||
|
},
|
||||||
|
persistImpl: async (ctx, creds) => persistCalls.push({ ctx, creds })
|
||||||
|
});
|
||||||
|
const ctx = { hosterName: 'doodstream.com', authType: 'login', isEdit: false };
|
||||||
|
const creds = { username: 'u', password: 'p' };
|
||||||
|
await sm.click(ctx, creds, ''); // first click → otp_required
|
||||||
|
await sm.click(ctx, creds, '123456'); // retry with otp → ok
|
||||||
|
await sm.click(ctx, creds); // final click → commit
|
||||||
|
assert.equal(persistCalls.length, 1, 'exactly one persist after OTP confirmed');
|
||||||
|
assert.equal(calls, 2, 'validate ran twice (initial + OTP) before commit');
|
||||||
|
assert.deepEqual(
|
||||||
|
sm.log().map(e => e.type),
|
||||||
|
['otp-required', 'validated', 'persisted']
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('field edit after green check invalidates the snapshot — next click is a re-Prüfen, not a commit', async () => {
|
||||||
|
const persistCalls = [];
|
||||||
|
let validateCount = 0;
|
||||||
|
const sm = makeStateMachine({
|
||||||
|
validateImpl: async () => { validateCount++; return { status: 'ok' }; },
|
||||||
|
persistImpl: async (ctx, creds) => persistCalls.push({ ctx, creds })
|
||||||
|
});
|
||||||
|
const ctx = { hosterName: 'doodstream.com', authType: 'login', isEdit: false };
|
||||||
|
await sm.click(ctx, { username: 'u', password: 'p' }); // validate → green
|
||||||
|
sm.editField(); // user edits cred field → snapshot dropped
|
||||||
|
await sm.click(ctx, { username: 'u', password: 'newpw' }); // creds differ → re-validate
|
||||||
|
await sm.click(ctx, { username: 'u', password: 'newpw' }); // now commit the NEW creds
|
||||||
|
assert.equal(persistCalls.length, 1, 'one persist of the new (re-validated) creds');
|
||||||
|
assert.equal(persistCalls[0].creds.password, 'newpw', 'persisted creds match the re-validated set');
|
||||||
|
assert.equal(validateCount, 2, 'second validate was forced by the edit-induced invalidation');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('snapshot key is identical for same creds and DIFFERENT for any cred change (excluding label)', () => {
|
||||||
|
// Label changes must NOT invalidate validation — label is metadata, not a credential.
|
||||||
|
assert.equal(credsSnapshotKey('login', { username: 'u', password: 'p' }),
|
||||||
|
credsSnapshotKey('login', { username: 'u', password: 'p', label: 'XYZ' }));
|
||||||
|
assert.notEqual(credsSnapshotKey('login', { username: 'u', password: 'p' }),
|
||||||
|
credsSnapshotKey('login', { username: 'u', password: 'P' })); // password char-case
|
||||||
|
assert.notEqual(credsSnapshotKey('login', { username: 'u', password: 'p' }),
|
||||||
|
credsSnapshotKey('login', { username: 'U', password: 'p' })); // username diff
|
||||||
|
assert.equal(credsSnapshotKey('api', { apiKey: 'KEY' }),
|
||||||
|
credsSnapshotKey('api', { apiKey: 'KEY', label: 'mein key' }));
|
||||||
|
assert.notEqual(credsSnapshotKey('api', { apiKey: 'KEY' }),
|
||||||
|
credsSnapshotKey('api', { apiKey: 'KEY2' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ephemeral hosterConfig shape matches what per-hoster checkers expect', () => {
|
||||||
|
// The per-hoster checkers in main.js read .username/.password/.apiKey directly.
|
||||||
|
// This guards the validate-credentials IPC contract from drifting.
|
||||||
|
const cfg = buildEphemeralHosterConfig({ hoster: 'doodstream.com', username: 'u', password: 'p' });
|
||||||
|
assert.equal(cfg.username, 'u');
|
||||||
|
assert.equal(cfg.password, 'p');
|
||||||
|
assert.equal(cfg.apiKey, '');
|
||||||
|
assert.equal(cfg.enabled, true);
|
||||||
|
const cfg2 = buildEphemeralHosterConfig({ hoster: 'byse.sx', apiKey: 'K' });
|
||||||
|
assert.equal(cfg2.apiKey, 'K');
|
||||||
|
assert.equal(cfg2.username, '');
|
||||||
|
});
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const { isDiscordWebhook, formatDurationShort, summarizePerHosterFromBatch, buildWebhookRequest, resolveDiscordMention, isAllAborted, clampDiscordContent, DISCORD_CONTENT_LIMIT } = require('../lib/webhook-notify');
|
||||||
|
|
||||||
|
const SAMPLE_SUMMARY = {
|
||||||
|
total: 10,
|
||||||
|
succeeded: 8,
|
||||||
|
failed: 2,
|
||||||
|
files: [
|
||||||
|
{ name: 'a.mkv', results: [
|
||||||
|
{ hoster: 'voe.sx', status: 'done' },
|
||||||
|
{ hoster: 'byse.sx', status: 'error', error: 'x' }
|
||||||
|
] },
|
||||||
|
{ name: 'b.mkv', results: [
|
||||||
|
{ hoster: 'voe.sx', status: 'done' },
|
||||||
|
{ hoster: 'byse.sx', status: 'done' }
|
||||||
|
] }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
test('isDiscordWebhook recognizes discord URLs incl. ptb/canary/discordapp', () => {
|
||||||
|
assert.ok(isDiscordWebhook('https://discord.com/api/webhooks/123/abc'));
|
||||||
|
assert.ok(isDiscordWebhook('https://discordapp.com/api/webhooks/123/abc'));
|
||||||
|
assert.ok(isDiscordWebhook('https://ptb.discord.com/api/webhooks/123/abc'));
|
||||||
|
assert.ok(isDiscordWebhook('https://canary.discord.com/api/webhooks/123/abc'));
|
||||||
|
assert.strictEqual(isDiscordWebhook('https://example.com/hook'), false);
|
||||||
|
assert.strictEqual(isDiscordWebhook(''), false);
|
||||||
|
assert.strictEqual(isDiscordWebhook(null), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isDiscordWebhook REJECTS incomplete discord URLs (no id/token)', () => {
|
||||||
|
assert.strictEqual(isDiscordWebhook('https://discord.com/api/webhooks/'), false);
|
||||||
|
assert.strictEqual(isDiscordWebhook('https://discord.com/api/webhooks'), false);
|
||||||
|
assert.strictEqual(isDiscordWebhook('https://discord.com/api/webhooks/123'), false);
|
||||||
|
assert.strictEqual(isDiscordWebhook('https://discord.com/api/webhooks/123/'), false);
|
||||||
|
assert.ok(isDiscordWebhook('https://discord.com/api/webhooks/123456789/aBc-_token123'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clampDiscordContent caps to the Discord limit with ellipsis', () => {
|
||||||
|
const short = 'hello';
|
||||||
|
assert.strictEqual(clampDiscordContent(short), short);
|
||||||
|
const long = 'x'.repeat(5000);
|
||||||
|
const clamped = clampDiscordContent(long);
|
||||||
|
assert.ok(clamped.length <= DISCORD_CONTENT_LIMIT);
|
||||||
|
assert.ok(clamped.endsWith('…'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildWebhookRequest: many hosters does not exceed Discord limit', () => {
|
||||||
|
const files = [{ name: 'a.mkv', results: [] }];
|
||||||
|
for (let i = 0; i < 60; i++) files[0].results.push({ hoster: `hoster-with-a-really-long-name-${i}.example.com`, status: 'done' });
|
||||||
|
const req = buildWebhookRequest('https://discord.com/api/webhooks/1/x', { total: 60, succeeded: 60, failed: 0, files }, { durationSec: 60 });
|
||||||
|
const body = JSON.parse(req.body);
|
||||||
|
assert.ok(body.content.length <= DISCORD_CONTENT_LIMIT, `content ${body.content.length} must be <= ${DISCORD_CONTENT_LIMIT}`);
|
||||||
|
assert.match(body.content, /\+\d+/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildWebhookRequest: aborted meta changes the headline', () => {
|
||||||
|
const req = buildWebhookRequest('https://discord.com/api/webhooks/1/x', { total: 5, succeeded: 0, failed: 5, files: [] }, { aborted: true });
|
||||||
|
const body = JSON.parse(req.body);
|
||||||
|
assert.match(body.content, /Batch abgebrochen/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isAllAborted: true only when every result is aborted', () => {
|
||||||
|
assert.strictEqual(isAllAborted({ files: [{ results: [{ status: 'aborted' }, { status: 'aborted' }] }] }), true);
|
||||||
|
assert.strictEqual(isAllAborted({ files: [{ results: [{ status: 'aborted' }, { status: 'done' }] }] }), false);
|
||||||
|
assert.strictEqual(isAllAborted({ files: [{ results: [{ status: 'error' }] }] }), false);
|
||||||
|
assert.strictEqual(isAllAborted({ files: [] }), false);
|
||||||
|
assert.strictEqual(isAllAborted(null), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formatDurationShort formats h/m/s tiers', () => {
|
||||||
|
assert.strictEqual(formatDurationShort(45), '45s');
|
||||||
|
assert.strictEqual(formatDurationShort(125), '2m 5s');
|
||||||
|
assert.strictEqual(formatDurationShort(3 * 3600 + 12 * 60), '3h 12m');
|
||||||
|
assert.strictEqual(formatDurationShort(-5), '0s');
|
||||||
|
assert.strictEqual(formatDurationShort(undefined), '0s');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('summarizePerHosterFromBatch counts ok/fail per hoster', () => {
|
||||||
|
const s = summarizePerHosterFromBatch(SAMPLE_SUMMARY);
|
||||||
|
assert.deepStrictEqual(s['voe.sx'], { ok: 2, fail: 0 });
|
||||||
|
assert.deepStrictEqual(s['byse.sx'], { ok: 1, fail: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('summarizePerHosterFromBatch handles malformed input', () => {
|
||||||
|
assert.deepStrictEqual(summarizePerHosterFromBatch(null), {});
|
||||||
|
assert.deepStrictEqual(summarizePerHosterFromBatch({}), {});
|
||||||
|
assert.deepStrictEqual(summarizePerHosterFromBatch({ files: [{ results: null }] }), {});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildWebhookRequest produces Discord content body for discord URLs', () => {
|
||||||
|
const req = buildWebhookRequest('https://discord.com/api/webhooks/1/x', SAMPLE_SUMMARY, { durationSec: 3700, appVersion: '3.3.59', machineName: 'srv-1' });
|
||||||
|
assert.strictEqual(req.method, 'POST');
|
||||||
|
assert.strictEqual(req.headers['Content-Type'], 'application/json');
|
||||||
|
const body = JSON.parse(req.body);
|
||||||
|
assert.ok(typeof body.content === 'string');
|
||||||
|
assert.match(body.content, /Batch fertig/);
|
||||||
|
assert.match(body.content, /srv-1/);
|
||||||
|
assert.match(body.content, /8 ok/);
|
||||||
|
assert.match(body.content, /2 Fehler/);
|
||||||
|
assert.match(body.content, /1h 1m/);
|
||||||
|
assert.match(body.content, /voe\.sx: 2\/2/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildWebhookRequest produces raw JSON payload for generic URLs', () => {
|
||||||
|
const req = buildWebhookRequest('https://example.com/hook', SAMPLE_SUMMARY, { durationSec: 60, appVersion: '3.3.59', timestamp: '2026-06-09T00:00:00Z' });
|
||||||
|
const body = JSON.parse(req.body);
|
||||||
|
assert.strictEqual(body.event, 'batch-done');
|
||||||
|
assert.strictEqual(body.total, 10);
|
||||||
|
assert.strictEqual(body.succeeded, 8);
|
||||||
|
assert.strictEqual(body.failed, 2);
|
||||||
|
assert.strictEqual(body.durationSec, 60);
|
||||||
|
assert.strictEqual(body.version, '3.3.59');
|
||||||
|
assert.deepStrictEqual(body.perHoster['byse.sx'], { ok: 1, fail: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveDiscordMention: @here / @everyone use parse=everyone', () => {
|
||||||
|
assert.deepStrictEqual(resolveDiscordMention('@here'), { token: '@here', allowed: { parse: ['everyone'] } });
|
||||||
|
assert.deepStrictEqual(resolveDiscordMention('everyone'), { token: '@everyone', allowed: { parse: ['everyone'] } });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveDiscordMention: bare numeric id → user mention', () => {
|
||||||
|
assert.deepStrictEqual(resolveDiscordMention('123456789012345'), { token: '<@123456789012345>', allowed: { users: ['123456789012345'] } });
|
||||||
|
assert.deepStrictEqual(resolveDiscordMention('<@!123456789012345>'), { token: '<@123456789012345>', allowed: { users: ['123456789012345'] } });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveDiscordMention: role:id and <@&id> → role mention', () => {
|
||||||
|
assert.deepStrictEqual(resolveDiscordMention('role:99887766'), { token: '<@&99887766>', allowed: { roles: ['99887766'] } });
|
||||||
|
assert.deepStrictEqual(resolveDiscordMention('<@&99887766>'), { token: '<@&99887766>', allowed: { roles: ['99887766'] } });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveDiscordMention: empty / junk → null', () => {
|
||||||
|
assert.strictEqual(resolveDiscordMention(''), null);
|
||||||
|
assert.strictEqual(resolveDiscordMention(' '), null);
|
||||||
|
assert.strictEqual(resolveDiscordMention('not-an-id'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildWebhookRequest: discord with mention prepends token + sets allowed_mentions', () => {
|
||||||
|
const req = buildWebhookRequest('https://discord.com/api/webhooks/1/x', SAMPLE_SUMMARY, { durationSec: 60, mention: '123456789012345' });
|
||||||
|
const body = JSON.parse(req.body);
|
||||||
|
assert.ok(body.content.startsWith('<@123456789012345> '));
|
||||||
|
assert.deepStrictEqual(body.allowed_mentions, { users: ['123456789012345'] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildWebhookRequest: discord without mention blocks all pings (allowed_mentions parse empty)', () => {
|
||||||
|
const req = buildWebhookRequest('https://discord.com/api/webhooks/1/x', SAMPLE_SUMMARY, { durationSec: 60 });
|
||||||
|
const body = JSON.parse(req.body);
|
||||||
|
assert.deepStrictEqual(body.allowed_mentions, { parse: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildWebhookRequest tolerates empty summary', () => {
|
||||||
|
const req = buildWebhookRequest('https://example.com/hook', null, {});
|
||||||
|
const body = JSON.parse(req.body);
|
||||||
|
assert.strictEqual(body.total, 0);
|
||||||
|
assert.strictEqual(body.succeeded, 0);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user