release: v2.0.6
Redesign the desktop workspace with task sidebars, live filters, clearer settings, and an accessible update dialog. Harden encrypted backup imports, configuration persistence, history retention, queue snapshots, shutdown recovery, and update installation ordering. Publish verified Windows artifacts and refreshed English documentation.
This commit is contained in:
+20
@@ -0,0 +1,20 @@
|
||||
node_modules/
|
||||
release/
|
||||
.artifacts/
|
||||
__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,48 @@
|
||||
# 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, automatic rotation, and inline OTP completion.
|
||||
- Filter uploads, accounts, and history from task-focused sidebars without changing the underlying queue.
|
||||
- Add files by drag and drop or file selection and monitor live queue progress.
|
||||
- Control per-hoster concurrency, bandwidth limits, retries, folder monitoring, notifications, and completed-item cleanup.
|
||||
- Keep local upload history and copy completed links in bulk.
|
||||
- Transfer accounts and settings with a 75-character encrypted online key while encryption and decryption stay on the client.
|
||||
- Check for updates from the header and install available releases from an accessible update dialog.
|
||||
|
||||
## 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 Accounts, 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. Online backups are optional, contain accounts and settings only, and are encrypted on the client before the server receives them. Upload history and queue state remain on the original device.
|
||||
|
||||
## 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: 51 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',
|
||||
performance: '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',
|
||||
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,779 @@
|
||||
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) {
|
||||
n += (file.results || []).length;
|
||||
}
|
||||
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 useUserDataDir = app && (
|
||||
app.isPackaged ||
|
||||
(app.commandLine && typeof app.commandLine.hasSwitch === 'function' && app.commandLine.hasSwitch('user-data-dir'))
|
||||
);
|
||||
const dir = useUserDataDir
|
||||
? 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._pendingWriteOperations = new Set();
|
||||
this._writesQuiesced = false;
|
||||
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();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
_quiescedWriteError() {
|
||||
const error = new Error('Die Anwendung wird gerade beendet');
|
||||
error.code = 'CONFIG_WRITES_QUIESCED';
|
||||
return error;
|
||||
}
|
||||
|
||||
setWritesQuiesced(quiesced) {
|
||||
this._writesQuiesced = !!quiesced;
|
||||
}
|
||||
|
||||
_enqueueHistoryWrite(fn, options = {}) {
|
||||
if (this._writesQuiesced && !options.allowDuringQuiesce) return Promise.reject(this._quiescedWriteError());
|
||||
const operation = this._historyWriteQueue.then(fn, fn);
|
||||
this._pendingWriteOperations.add(operation);
|
||||
this._historyWriteQueue = operation.then(() => undefined, () => undefined);
|
||||
operation.then(
|
||||
() => this._pendingWriteOperations.delete(operation),
|
||||
() => this._pendingWriteOperations.delete(operation)
|
||||
);
|
||||
return operation;
|
||||
}
|
||||
|
||||
_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, options = {}) {
|
||||
if (this._writesQuiesced && !options.allowDuringQuiesce) return Promise.reject(this._quiescedWriteError());
|
||||
this._wqDepth++;
|
||||
const operation = this._writeQueue.then(fn, fn);
|
||||
this._pendingWriteOperations.add(operation);
|
||||
this._writeQueue = operation.then(
|
||||
() => { this._wqDepth--; },
|
||||
() => {
|
||||
this._wqDepth--;
|
||||
}
|
||||
);
|
||||
operation.then(
|
||||
() => this._pendingWriteOperations.delete(operation),
|
||||
() => this._pendingWriteOperations.delete(operation)
|
||||
);
|
||||
return operation;
|
||||
}
|
||||
|
||||
async drainWrites() {
|
||||
while (this._pendingWriteOperations.size > 0) {
|
||||
const pending = Array.from(this._pendingWriteOperations);
|
||||
const results = await Promise.allSettled(pending);
|
||||
const failed = results.find(result => result.status === 'rejected');
|
||||
if (failed) throw failed.reason;
|
||||
}
|
||||
}
|
||||
|
||||
_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);
|
||||
});
|
||||
}
|
||||
|
||||
savePendingQueue(pendingQueue, options = {}) {
|
||||
const snapshot = pendingQueue === null || pendingQueue === undefined ? null : this._clone(pendingQueue);
|
||||
return this._enqueueWrite(() => {
|
||||
const current = this.load();
|
||||
current.globalSettings = {
|
||||
...(current.globalSettings || {}),
|
||||
pendingQueue: snapshot
|
||||
};
|
||||
this._guardHosters(current, false);
|
||||
return this._commit(current);
|
||||
}, options);
|
||||
}
|
||||
|
||||
saveRendererGlobalSettings(globalSettings) {
|
||||
const snapshot = this._clone(globalSettings || {});
|
||||
return this._enqueueWrite(() => {
|
||||
const current = this.load();
|
||||
const currentGlobalSettings = current.globalSettings || {};
|
||||
const currentRemote = currentGlobalSettings.remote || {};
|
||||
const incomingRemote = snapshot.remote || {};
|
||||
current.globalSettings = {
|
||||
...snapshot,
|
||||
pendingQueue: currentGlobalSettings.pendingQueue ?? null,
|
||||
diagnostics: this._clone(currentGlobalSettings.diagnostics || {}),
|
||||
historyRetention: currentGlobalSettings.historyRetention || 'all',
|
||||
remote: {
|
||||
...incomingRemote,
|
||||
token: incomingRemote.token || currentRemote.token || ''
|
||||
}
|
||||
};
|
||||
this._guardHosters(current, false);
|
||||
return this._commit(current);
|
||||
});
|
||||
}
|
||||
|
||||
saveRemoteSettings(remoteSettings, createToken) {
|
||||
const incoming = this._clone(remoteSettings || {});
|
||||
return this._enqueueWrite(async () => {
|
||||
const current = this.load();
|
||||
const currentGlobalSettings = current.globalSettings || {};
|
||||
const currentRemote = currentGlobalSettings.remote || {};
|
||||
const token = incoming.token || currentRemote.token || (incoming.enabled && typeof createToken === 'function' ? createToken() : '');
|
||||
const canonical = { ...incoming, token };
|
||||
current.globalSettings = { ...currentGlobalSettings, remote: canonical };
|
||||
this._guardHosters(current, false);
|
||||
await this._commit(current);
|
||||
return this._clone(canonical);
|
||||
});
|
||||
}
|
||||
|
||||
replaceSettings(config) {
|
||||
return this._enqueueWrite(() => {
|
||||
const current = this.load();
|
||||
const globalSettings = this._clone(config.globalSettings);
|
||||
globalSettings.pendingQueue = current.globalSettings.pendingQueue ?? null;
|
||||
return this._commit({
|
||||
hosters: this._clone(config.hosters),
|
||||
hosterSettings: this._clone(config.hosterSettings),
|
||||
globalSettings,
|
||||
history: this._clone(current.history || []),
|
||||
rotationCursors: {}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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(async () => {
|
||||
const storedHistory = this._readHistoryFile();
|
||||
if (storedHistory === null && fs.existsSync(this.historyPath)) {
|
||||
throw new Error('Die Verlaufsdatei ist beschädigt und wurde nicht verändert');
|
||||
}
|
||||
const current = storedHistory || [];
|
||||
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._enqueueWrite(async () => {
|
||||
const config = this.load();
|
||||
const previousGlobalSettings = this._clone(config.globalSettings || {});
|
||||
config.globalSettings = { ...previousGlobalSettings, historyRetention: String(retention || 'all') };
|
||||
this._guardHosters(config, false);
|
||||
await this._commit(config);
|
||||
try {
|
||||
await this._writeHistoryFileAtomic(pruned);
|
||||
} catch (historyError) {
|
||||
config.globalSettings = previousGlobalSettings;
|
||||
try {
|
||||
await this._commit(config);
|
||||
} catch (rollbackError) {
|
||||
throw new AggregateError([historyError, rollbackError], 'Verlauf und Aufbewahrung konnten nicht konsistent gespeichert werden');
|
||||
}
|
||||
throw historyError;
|
||||
}
|
||||
return 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,251 @@
|
||||
const crypto = require('node:crypto');
|
||||
const zlib = require('node:zlib');
|
||||
|
||||
const ONLINE_BACKUP_API_URL = 'https://uploader.24-music.de/backup-api';
|
||||
const KEY_PREFIX = 'MHU2-';
|
||||
const KEY_BODY_LENGTH = 70;
|
||||
const RECORD_ID_LENGTH = 16;
|
||||
const MASTER_KEY_LENGTH = 32;
|
||||
const CHECKSUM_LENGTH = 4;
|
||||
const NONCE_LENGTH = 12;
|
||||
const AUTH_TAG_LENGTH = 16;
|
||||
const BLOB_VERSION = 1;
|
||||
const MAX_BLOB_BYTES = 256 * 1024;
|
||||
const MAX_RESPONSE_BYTES = 512 * 1024;
|
||||
const MAX_PLAINTEXT_BYTES = 512 * 1024;
|
||||
const REQUEST_TIMEOUT_MS = 12_000;
|
||||
const KEY_CONTEXT = Buffer.from('MHU2-ONLINE-KEY-V1', 'utf8');
|
||||
const AAD_CONTEXT = Buffer.from('MHU-ONLINE-BACKUP-V1', 'utf8');
|
||||
|
||||
function checksum(idBytes, masterKey) {
|
||||
return crypto.createHash('sha256').update(KEY_CONTEXT).update(idBytes).update(masterKey).digest().subarray(0, CHECKSUM_LENGTH);
|
||||
}
|
||||
|
||||
function deriveSecret(masterKey, idBytes, purpose) {
|
||||
return Buffer.from(crypto.hkdfSync('sha256', masterKey, idBytes, Buffer.from(`MHU-ONLINE-${purpose}-V1`, 'utf8'), 32));
|
||||
}
|
||||
|
||||
function deriveDeleteSecret(parsed) {
|
||||
return deriveSecret(parsed.masterKey, parsed.idBytes, 'DELETE');
|
||||
}
|
||||
|
||||
function aad(idBytes) {
|
||||
return Buffer.concat([AAD_CONTEXT, idBytes]);
|
||||
}
|
||||
|
||||
function encodeKey(idBytes, masterKey) {
|
||||
const body = Buffer.concat([idBytes, masterKey, checksum(idBytes, masterKey)]).toString('base64url');
|
||||
return `${KEY_PREFIX}${body}`;
|
||||
}
|
||||
|
||||
function validatePayload(value) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error('Online-Sicherung enthält keine gültigen Einstellungen');
|
||||
}
|
||||
if (
|
||||
value.version !== 1
|
||||
|| value.kind !== 'settings-only'
|
||||
|| typeof value.appVersion !== 'string'
|
||||
|| typeof value.exportedAt !== 'string'
|
||||
|| !value.settings
|
||||
|| typeof value.settings !== 'object'
|
||||
|| Array.isArray(value.settings)
|
||||
|| Object.prototype.hasOwnProperty.call(value, 'session')
|
||||
|| Object.prototype.hasOwnProperty.call(value, 'history')
|
||||
) {
|
||||
throw new Error('Online-Sicherung enthält keine gültigen Einstellungen');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function endpoint(baseUrl, relativePath) {
|
||||
const normalized = String(baseUrl || '').trim().replace(/\/+$/, '');
|
||||
const url = new URL(`${normalized}${relativePath}`);
|
||||
if (url.protocol !== 'https:' && !['127.0.0.1', 'localhost', '::1'].includes(url.hostname)) {
|
||||
throw new Error('Online-Sicherungen benötigen eine sichere HTTPS-Verbindung');
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
async function requestText(url, init, options = {}) {
|
||||
const controller = new AbortController();
|
||||
const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : REQUEST_TIMEOUT_MS;
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await (options.fetchImpl || fetch)(url, { ...init, signal: controller.signal });
|
||||
const body = await readLimitedText(response);
|
||||
return { response, body };
|
||||
} catch {
|
||||
if (controller.signal.aborted) throw new Error('Online-Sicherungsdienst antwortet nicht');
|
||||
throw new Error('Online-Sicherungsdienst ist nicht erreichbar');
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function readLimitedText(response) {
|
||||
const contentLength = Number(response.headers.get('content-length') || '0');
|
||||
if (Number.isFinite(contentLength) && contentLength > MAX_RESPONSE_BYTES) {
|
||||
throw new Error('Antwort des Online-Sicherungsdienstes ist zu groß');
|
||||
}
|
||||
if (!response.body) return '';
|
||||
const reader = response.body.getReader();
|
||||
const chunks = [];
|
||||
let total = 0;
|
||||
while (true) {
|
||||
const result = await reader.read();
|
||||
if (result.done) break;
|
||||
total += result.value.byteLength;
|
||||
if (total > MAX_RESPONSE_BYTES) {
|
||||
await reader.cancel();
|
||||
throw new Error('Antwort des Online-Sicherungsdienstes ist zu groß');
|
||||
}
|
||||
chunks.push(Buffer.from(result.value));
|
||||
}
|
||||
return Buffer.concat(chunks).toString('utf8');
|
||||
}
|
||||
|
||||
function parseOnlineBackupKey(key) {
|
||||
const normalized = String(key || '').trim();
|
||||
if (!new RegExp(`^${KEY_PREFIX}[A-Za-z0-9_-]{${KEY_BODY_LENGTH}}$`).test(normalized)) {
|
||||
throw new Error('Online-Sicherungsschlüssel ist ungültig');
|
||||
}
|
||||
const decoded = Buffer.from(normalized.slice(KEY_PREFIX.length), 'base64url');
|
||||
if (decoded.length !== RECORD_ID_LENGTH + MASTER_KEY_LENGTH + CHECKSUM_LENGTH) {
|
||||
throw new Error('Online-Sicherungsschlüssel ist ungültig');
|
||||
}
|
||||
if (decoded.toString('base64url') !== normalized.slice(KEY_PREFIX.length)) {
|
||||
throw new Error('Online-Sicherungsschlüssel ist ungültig');
|
||||
}
|
||||
const idBytes = decoded.subarray(0, RECORD_ID_LENGTH);
|
||||
const masterKey = decoded.subarray(RECORD_ID_LENGTH, RECORD_ID_LENGTH + MASTER_KEY_LENGTH);
|
||||
const actualChecksum = decoded.subarray(RECORD_ID_LENGTH + MASTER_KEY_LENGTH);
|
||||
const expectedChecksum = checksum(idBytes, masterKey);
|
||||
if (!crypto.timingSafeEqual(actualChecksum, expectedChecksum)) {
|
||||
throw new Error('Online-Sicherungsschlüssel ist beschädigt');
|
||||
}
|
||||
return {
|
||||
id: idBytes.toString('base64url'),
|
||||
idBytes: Buffer.from(idBytes),
|
||||
masterKey: Buffer.from(masterKey)
|
||||
};
|
||||
}
|
||||
|
||||
function createOnlineBackup(settings, appVersion, exportedAt = new Date().toISOString()) {
|
||||
const idBytes = crypto.randomBytes(RECORD_ID_LENGTH);
|
||||
const masterKey = crypto.randomBytes(MASTER_KEY_LENGTH);
|
||||
const key = encodeKey(idBytes, masterKey);
|
||||
const encryptionKey = deriveSecret(masterKey, idBytes, 'ENCRYPTION');
|
||||
const nonce = crypto.randomBytes(NONCE_LENGTH);
|
||||
const payload = {
|
||||
version: 1,
|
||||
kind: 'settings-only',
|
||||
appVersion: String(appVersion || ''),
|
||||
exportedAt,
|
||||
settings: JSON.parse(JSON.stringify(settings))
|
||||
};
|
||||
const plaintext = Buffer.from(JSON.stringify(payload), 'utf8');
|
||||
if (plaintext.length > MAX_PLAINTEXT_BYTES) {
|
||||
throw new Error('Einstellungen sind für eine Online-Sicherung zu groß');
|
||||
}
|
||||
const compressed = zlib.gzipSync(plaintext, { level: 9 });
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', encryptionKey, nonce, { authTagLength: AUTH_TAG_LENGTH });
|
||||
cipher.setAAD(aad(idBytes));
|
||||
const ciphertext = Buffer.concat([cipher.update(compressed), cipher.final()]);
|
||||
const blobBytes = Buffer.concat([Buffer.from([BLOB_VERSION]), nonce, cipher.getAuthTag(), ciphertext]);
|
||||
if (blobBytes.length > MAX_BLOB_BYTES) {
|
||||
throw new Error('Einstellungen sind für eine Online-Sicherung zu groß');
|
||||
}
|
||||
const parsed = parseOnlineBackupKey(key);
|
||||
const deleteVerifier = crypto.createHash('sha256').update(deriveDeleteSecret(parsed)).digest('base64url');
|
||||
return {
|
||||
key,
|
||||
record: {
|
||||
id: parsed.id,
|
||||
blob: blobBytes.toString('base64url'),
|
||||
deleteVerifier
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function restoreOnlineBackup(key, blob) {
|
||||
const parsed = parseOnlineBackupKey(key);
|
||||
if (typeof blob !== 'string' || !/^[A-Za-z0-9_-]+$/.test(blob) || blob.length > Math.ceil(MAX_BLOB_BYTES * 4 / 3) + 4) {
|
||||
throw new Error('Online-Sicherung ist beschädigt');
|
||||
}
|
||||
const bytes = Buffer.from(blob, 'base64url');
|
||||
if (bytes.toString('base64url') !== blob || bytes.length < 1 + NONCE_LENGTH + AUTH_TAG_LENGTH || bytes[0] !== BLOB_VERSION) {
|
||||
throw new Error('Online-Sicherung ist beschädigt');
|
||||
}
|
||||
const nonce = bytes.subarray(1, 1 + NONCE_LENGTH);
|
||||
const tag = bytes.subarray(1 + NONCE_LENGTH, 1 + NONCE_LENGTH + AUTH_TAG_LENGTH);
|
||||
const ciphertext = bytes.subarray(1 + NONCE_LENGTH + AUTH_TAG_LENGTH);
|
||||
try {
|
||||
const decipher = crypto.createDecipheriv(
|
||||
'aes-256-gcm',
|
||||
deriveSecret(parsed.masterKey, parsed.idBytes, 'ENCRYPTION'),
|
||||
nonce,
|
||||
{ authTagLength: AUTH_TAG_LENGTH }
|
||||
);
|
||||
decipher.setAAD(aad(parsed.idBytes));
|
||||
decipher.setAuthTag(tag);
|
||||
const compressed = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
||||
const plaintext = zlib.gunzipSync(compressed, { maxOutputLength: MAX_PLAINTEXT_BYTES }).toString('utf8');
|
||||
return validatePayload(JSON.parse(plaintext));
|
||||
} catch (error) {
|
||||
if (error instanceof Error && /keine gültigen Einstellungen/.test(error.message)) throw error;
|
||||
throw new Error('Online-Sicherung konnte nicht entschlüsselt werden oder ist beschädigt');
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadOnlineBackup(record, baseUrl = ONLINE_BACKUP_API_URL, options) {
|
||||
const { response } = await requestText(endpoint(baseUrl, '/v1/backups'), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
||||
body: JSON.stringify(record)
|
||||
}, options);
|
||||
if (response.status !== 201) throw new Error('Online-Sicherung konnte nicht gespeichert werden');
|
||||
}
|
||||
|
||||
async function downloadOnlineBackup(key, baseUrl = ONLINE_BACKUP_API_URL, options) {
|
||||
const parsed = parseOnlineBackupKey(key);
|
||||
const { response, body } = await requestText(endpoint(baseUrl, '/v1/backups/restore'), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
||||
body: JSON.stringify({ id: parsed.id })
|
||||
}, options);
|
||||
if (response.status !== 200) {
|
||||
throw new Error(response.status === 404 ? 'Online-Sicherung wurde nicht gefunden' : 'Online-Sicherung konnte nicht geladen werden');
|
||||
}
|
||||
let value;
|
||||
try {
|
||||
value = JSON.parse(body);
|
||||
} catch {
|
||||
throw new Error('Online-Sicherungsdienst hat ungültige Daten geliefert');
|
||||
}
|
||||
if (typeof value?.blob !== 'string') throw new Error('Online-Sicherungsdienst hat ungültige Daten geliefert');
|
||||
return restoreOnlineBackup(key, value.blob);
|
||||
}
|
||||
|
||||
async function deleteOnlineBackup(key, baseUrl = ONLINE_BACKUP_API_URL, options) {
|
||||
const parsed = parseOnlineBackupKey(key);
|
||||
const deleteSecret = deriveDeleteSecret(parsed).toString('base64url');
|
||||
const { response } = await requestText(endpoint(baseUrl, '/v1/backups/delete'), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
||||
body: JSON.stringify({ id: parsed.id, deleteSecret })
|
||||
}, options);
|
||||
if (response.status !== 204) {
|
||||
throw new Error(response.status === 404 ? 'Online-Sicherung wurde nicht gefunden' : 'Online-Sicherung konnte nicht gelöscht werden');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ONLINE_BACKUP_API_URL,
|
||||
createOnlineBackup,
|
||||
deleteOnlineBackup,
|
||||
downloadOnlineBackup,
|
||||
parseOnlineBackupKey,
|
||||
restoreOnlineBackup,
|
||||
uploadOnlineBackup
|
||||
};
|
||||
@@ -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;
|
||||
@@ -0,0 +1,22 @@
|
||||
(function (root) {
|
||||
'use strict';
|
||||
|
||||
function createSerializedRunner(task) {
|
||||
if (typeof task !== 'function') throw new TypeError('task must be a function');
|
||||
let pending = Promise.resolve();
|
||||
return {
|
||||
run(...args) {
|
||||
const result = pending.catch(() => {}).then(() => task(...args));
|
||||
pending = result;
|
||||
return result;
|
||||
},
|
||||
flush() {
|
||||
return pending;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const api = { createSerializedRunner };
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
else if (root) root.SerializedRunner = api;
|
||||
})(typeof window !== 'undefined' ? window : this);
|
||||
@@ -0,0 +1,57 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
function clone(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function validateSettings(value) {
|
||||
if (
|
||||
!value
|
||||
|| typeof value !== 'object'
|
||||
|| Array.isArray(value)
|
||||
|| !value.hosters
|
||||
|| typeof value.hosters !== 'object'
|
||||
|| Array.isArray(value.hosters)
|
||||
|| !value.hosterSettings
|
||||
|| typeof value.hosterSettings !== 'object'
|
||||
|| Array.isArray(value.hosterSettings)
|
||||
|| !value.globalSettings
|
||||
|| typeof value.globalSettings !== 'object'
|
||||
|| Array.isArray(value.globalSettings)
|
||||
) {
|
||||
throw new Error('Backup hat eine ungültige Struktur');
|
||||
}
|
||||
}
|
||||
|
||||
function createPortableSettingsSnapshot(config) {
|
||||
validateSettings(config);
|
||||
const snapshot = {
|
||||
hosters: clone(config.hosters),
|
||||
hosterSettings: clone(config.hosterSettings),
|
||||
globalSettings: clone(config.globalSettings),
|
||||
history: []
|
||||
};
|
||||
snapshot.globalSettings.pendingQueue = null;
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function prepareImportedSettings(value, options = {}) {
|
||||
validateSettings(value);
|
||||
const imported = createPortableSettingsSnapshot(value);
|
||||
const pathExists = options.pathExists || fs.existsSync;
|
||||
const pathDirname = options.pathDirname || path.dirname;
|
||||
const globalSettings = imported.globalSettings;
|
||||
if (globalSettings.logFilePath && !pathExists(pathDirname(globalSettings.logFilePath))) {
|
||||
globalSettings.logFilePath = '';
|
||||
}
|
||||
if (globalSettings.folderMonitor && typeof globalSettings.folderMonitor === 'object') {
|
||||
if (globalSettings.folderMonitor.folderPath && !pathExists(globalSettings.folderMonitor.folderPath)) {
|
||||
globalSettings.folderMonitor.folderPath = '';
|
||||
globalSettings.folderMonitor.enabled = false;
|
||||
}
|
||||
}
|
||||
return imported;
|
||||
}
|
||||
|
||||
module.exports = { createPortableSettingsSnapshot, prepareImportedSettings };
|
||||
@@ -0,0 +1,19 @@
|
||||
function createSettingsImportGate(isUploadRunning) {
|
||||
if (typeof isUploadRunning !== 'function') throw new TypeError('isUploadRunning must be a function');
|
||||
let importing = false;
|
||||
return {
|
||||
begin() {
|
||||
if (importing) throw new Error('Einstellungen werden bereits importiert');
|
||||
if (isUploadRunning()) throw new Error('Während laufender Uploads können keine Einstellungen importiert werden');
|
||||
importing = true;
|
||||
},
|
||||
end() {
|
||||
importing = false;
|
||||
},
|
||||
canStartUpload() {
|
||||
return !importing;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createSettingsImportGate };
|
||||
@@ -0,0 +1,19 @@
|
||||
function configureStartupRenderer(app) {
|
||||
app.disableHardwareAcceleration();
|
||||
}
|
||||
|
||||
function createStartupWindow(BrowserWindow, options) {
|
||||
const window = new BrowserWindow({ ...options, show: false });
|
||||
window.once('ready-to-show', () => {
|
||||
window.show();
|
||||
});
|
||||
|
||||
return {
|
||||
window,
|
||||
load(target, onLoadError) {
|
||||
return window.loadFile(target).catch(onLoadError);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { configureStartupRenderer, createStartupWindow };
|
||||
+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);
|
||||
+296
@@ -0,0 +1,296 @@
|
||||
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;
|
||||
const launchedInstallerPaths = new Set();
|
||||
|
||||
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 resolveReleaseVersion(release) {
|
||||
for (const value of [release && release.name, release && release.tag_name]) {
|
||||
const match = String(value || '').match(/(?:^|[^\d])v?(\d+\.\d+\.\d+)(?=$|[^\d.])/i);
|
||||
if (match) return match[1];
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
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 = resolveReleaseVersion(release);
|
||||
const transportTag = release.tag_name || '';
|
||||
const currentVersion = getCurrentVersion();
|
||||
|
||||
if (!isNewer(remoteVersion, currentVersion)) {
|
||||
cachedCheck = { available: false, currentVersion, remoteVersion, transportTag };
|
||||
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,
|
||||
transportTag,
|
||||
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, fetchImpl = fetch) {
|
||||
if (!url) return null;
|
||||
try {
|
||||
const res = await fetchImpl(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 prepareUpdate(onProgress, options = {}) {
|
||||
if (activeAbort) activeAbort.abort();
|
||||
activeAbort = new AbortController();
|
||||
const signal = activeAbort.signal;
|
||||
const fetchImpl = options.fetchImpl || fetch;
|
||||
|
||||
try {
|
||||
// Stage: starting
|
||||
if (onProgress) onProgress({ stage: 'starting', percent: 0 });
|
||||
|
||||
// Check or use cached
|
||||
let check = options.checkResult || 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 = options.tempDir || app.getPath('temp');
|
||||
const installerPath = path.join(tmpDir, check.assetName);
|
||||
|
||||
const res = await fetchImpl(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, fetchImpl);
|
||||
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);
|
||||
|
||||
const prepared = {
|
||||
installerPath,
|
||||
assetName: check.assetName,
|
||||
remoteVersion: check.remoteVersion || '',
|
||||
transportTag: check.transportTag || ''
|
||||
};
|
||||
if (onProgress) onProgress({ stage: 'prepared', percent: 100 });
|
||||
return prepared;
|
||||
|
||||
} catch (err) {
|
||||
if (onProgress) onProgress({ stage: 'error', error: err.message });
|
||||
throw err;
|
||||
} finally {
|
||||
activeAbort = null;
|
||||
}
|
||||
}
|
||||
|
||||
function launchPreparedUpdate(prepared, options = {}) {
|
||||
const installerPath = prepared && typeof prepared.installerPath === 'string' ? prepared.installerPath : '';
|
||||
if (!installerPath) throw new Error('Vorbereitetes Update ist unvollständig');
|
||||
const key = path.resolve(installerPath).toLowerCase();
|
||||
if (launchedInstallerPaths.has(key)) return false;
|
||||
const spawnImpl = options.spawnImpl || require('child_process').spawn;
|
||||
launchedInstallerPaths.add(key);
|
||||
try {
|
||||
spawnImpl(installerPath, ['/S', '--updated', '--force-run'], {
|
||||
detached: true,
|
||||
stdio: 'ignore'
|
||||
}).unref();
|
||||
return true;
|
||||
} catch (error) {
|
||||
launchedInstallerPaths.delete(key);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function abortUpdate() {
|
||||
if (activeAbort) {
|
||||
activeAbort.abort();
|
||||
activeAbort = null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { checkForUpdate, prepareUpdate, launchPreparedUpdate, abortUpdate, isNewer, resolveReleaseVersion };
|
||||
@@ -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,58 @@
|
||||
{
|
||||
"name": "multi-hoster-uploader",
|
||||
"version": "2.0.6",
|
||||
"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",
|
||||
"test:backup-api": "npm --prefix services/backup-api test",
|
||||
"lint": "eslint .",
|
||||
"dist": "electron-builder --win",
|
||||
"release:win": "electron-builder --publish never --win nsis portable"
|
||||
},
|
||||
"dependencies": {
|
||||
"chokidar": "^3.6.0",
|
||||
"undici": "^7.29.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)
|
||||
});
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
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),
|
||||
savePendingQueue: (pendingQueue) => ipcRenderer.invoke('save-pending-queue', pendingQueue),
|
||||
finishClosePreparation: (payload = true) => ipcRenderer.invoke('app:finish-close', payload),
|
||||
onPrepareClose: (callback) => {
|
||||
ipcRenderer.on('app:prepare-close', (_event, attempt) => {
|
||||
ipcRenderer.send('app:close-preparation-started', attempt);
|
||||
callback(attempt);
|
||||
});
|
||||
},
|
||||
signalCloseHandshakeReady: () => ipcRenderer.send('app:close-handshake-ready'),
|
||||
|
||||
// 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),
|
||||
createOnlineBackup: () => ipcRenderer.invoke('online-backup:create'),
|
||||
restoreOnlineBackup: (key) => ipcRenderer.invoke('online-backup:restore', key),
|
||||
|
||||
// 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('app:prepare-close');
|
||||
ipcRenderer.removeAllListeners('shutdown-countdown');
|
||||
ipcRenderer.removeAllListeners('folder-monitor:new-files');
|
||||
ipcRenderer.removeAllListeners('drop-target:files');
|
||||
ipcRenderer.removeAllListeners('account-switched');
|
||||
ipcRenderer.removeAllListeners('remote:client-count');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
(function (scope) {
|
||||
const STATUS_PRESENTATIONS = {
|
||||
ok: { statusClass: 'ok', label: 'Bereit', requiresOtp: false },
|
||||
warn: { statusClass: 'warn', label: 'Warnung', requiresOtp: false },
|
||||
checking: { statusClass: 'checking', label: 'Prüfe...', requiresOtp: false },
|
||||
error: { statusClass: 'error', label: 'Fehler', requiresOtp: false },
|
||||
otp_required: { statusClass: 'warn', label: 'OTP erforderlich', requiresOtp: true },
|
||||
unchecked: { statusClass: 'unchecked', label: 'Nicht geprüft', requiresOtp: false },
|
||||
disabled: { statusClass: 'disabled', label: 'Deaktiviert', requiresOtp: false }
|
||||
};
|
||||
|
||||
function getAccountStatusPresentation(status) {
|
||||
const presentation = STATUS_PRESENTATIONS[status] || STATUS_PRESENTATIONS.unchecked;
|
||||
return { ...presentation };
|
||||
}
|
||||
|
||||
function getAccountGroupStatus(summary) {
|
||||
const total = Math.max(0, Number(summary && summary.total) || 0);
|
||||
const disabled = Math.min(total, Math.max(0, Number(summary && summary.disabled) || 0));
|
||||
const active = total - disabled;
|
||||
const errors = Math.max(0, Number(summary && summary.error) || 0);
|
||||
const ok = Math.max(0, Number(summary && summary.ok) || 0);
|
||||
const warning = Math.max(0, Number(summary && summary.warn) || 0);
|
||||
const checking = Math.max(0, Number(summary && summary.checking) || 0);
|
||||
const unchecked = Math.max(0, Number(summary && summary.unchecked) || 0);
|
||||
if (active === 0) return 'unchecked';
|
||||
if (errors >= active) return 'error';
|
||||
if (errors > 0 || warning > 0 || checking > 0 || unchecked > 0) return 'warn';
|
||||
if (ok >= active) return 'ok';
|
||||
return 'warn';
|
||||
}
|
||||
|
||||
const accountStatus = { getAccountGroupStatus, getAccountStatusPresentation };
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = accountStatus;
|
||||
if (scope) scope.AccountStatus = accountStatus;
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
@@ -0,0 +1,73 @@
|
||||
(function (scope) {
|
||||
function getAccountSubmitLabel() {
|
||||
return 'Prüfen und speichern';
|
||||
}
|
||||
|
||||
async function submitValidatedAccount({ validate, commit, afterCommit, isCurrent }) {
|
||||
let validation;
|
||||
try {
|
||||
validation = await validate();
|
||||
} catch (error) {
|
||||
return { status: 'error', error };
|
||||
}
|
||||
|
||||
try {
|
||||
if (!isCurrent()) return { status: 'stale', validation };
|
||||
} catch (error) {
|
||||
return { status: 'error', error, validation };
|
||||
}
|
||||
if (validation && validation.status === 'otp_required') {
|
||||
return { status: 'otp_required', validation };
|
||||
}
|
||||
if (!validation || (validation.status !== 'ok' && validation.status !== 'warn')) {
|
||||
return { status: 'rejected', validation };
|
||||
}
|
||||
|
||||
let value;
|
||||
try {
|
||||
value = await commit(validation);
|
||||
} catch (error) {
|
||||
return { status: 'error', error, validation };
|
||||
}
|
||||
|
||||
let postCommitError;
|
||||
if (typeof afterCommit === 'function') {
|
||||
try {
|
||||
await afterCommit(value, validation);
|
||||
} catch (error) {
|
||||
postCommitError = error;
|
||||
}
|
||||
}
|
||||
|
||||
const committedResult = { status: 'committed', committed: true, validation, value };
|
||||
if (postCommitError) committedResult.postCommitError = postCommitError;
|
||||
try {
|
||||
if (!isCurrent()) return { ...committedResult, status: 'stale' };
|
||||
} catch {
|
||||
return { ...committedResult, status: 'stale' };
|
||||
}
|
||||
return committedResult;
|
||||
}
|
||||
|
||||
function createAccountSubmitter() {
|
||||
let pending = null;
|
||||
return {
|
||||
isBusy() {
|
||||
return pending !== null;
|
||||
},
|
||||
submit(options) {
|
||||
if (pending) return null;
|
||||
const operation = submitValidatedAccount(options);
|
||||
const tracked = operation.finally(() => {
|
||||
if (pending === tracked) pending = null;
|
||||
});
|
||||
pending = tracked;
|
||||
return tracked;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const accountSubmit = { createAccountSubmitter, getAccountSubmitLabel, submitValidatedAccount };
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = accountSubmit;
|
||||
if (scope) scope.AccountSubmit = accountSubmit;
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
+6751
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,92 @@
|
||||
<!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: 1px dashed rgba(186, 208, 252, 0.62);
|
||||
border-radius: 8px;
|
||||
background: rgba(35, 35, 35, 0.94);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.025);
|
||||
transition: border-color 0.15s, background-color 0.15s, transform 0.15s;
|
||||
}
|
||||
.target.drag-over {
|
||||
border-color: #bad0fc;
|
||||
background: rgba(51, 52, 54, 0.98);
|
||||
transform: scale(0.98);
|
||||
}
|
||||
.icon {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 8px;
|
||||
background: #2b2b2b;
|
||||
color: #bad0fc;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
.icon svg {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.65;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.target {
|
||||
transition: none;
|
||||
}
|
||||
.target.drag-over {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="target" id="target">
|
||||
<div class="icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24"><path d="M12 16V4m0 0L7.5 8.5M12 4l4.5 4.5M5 14v4.5A1.5 1.5 0 0 0 6.5 20h11a1.5 1.5 0 0 0 1.5-1.5V14"/></svg>
|
||||
</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,622 @@
|
||||
<!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';">
|
||||
<meta name="theme-color" content="#0f0f0f">
|
||||
<title>Multi-Hoster-Upload</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<svg class="icon-sprite" aria-hidden="true">
|
||||
<symbol id="icon-upload" viewBox="0 0 24 24"><path d="M12 16V4m0 0L7.5 8.5M12 4l4.5 4.5M5 14v4.5A1.5 1.5 0 0 0 6.5 20h11a1.5 1.5 0 0 0 1.5-1.5V14"/></symbol>
|
||||
<symbol id="icon-accounts" viewBox="0 0 24 24"><path d="M16.5 20v-1.5a4 4 0 0 0-4-4h-5a4 4 0 0 0-4 4V20M10 10.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Zm6-1a3 3 0 0 0 0-5.8m2.5 10.9a4 4 0 0 1 2 3.4v2"/></symbol>
|
||||
<symbol id="icon-settings" viewBox="0 0 24 24"><path d="M12 15.25A3.25 3.25 0 1 0 12 8.75a3.25 3.25 0 0 0 0 6.5Zm7.2-3.25c0-.5-.05-.98-.15-1.45l2.05-1.6-2-3.46-2.52 1a8.12 8.12 0 0 0-2.5-1.44L13.7 2.4h-4l-.38 2.65a8.12 8.12 0 0 0-2.5 1.44l-2.52-1-2 3.46 2.05 1.6a7.1 7.1 0 0 0 0 2.9l-2.05 1.6 2 3.46 2.52-1a8.12 8.12 0 0 0 2.5 1.44l.38 2.65h4l.38-2.65a8.12 8.12 0 0 0 2.5-1.44l2.52 1 2-3.46-2.05-1.6c.1-.47.15-.95.15-1.45Z"/></symbol>
|
||||
<symbol id="icon-history" viewBox="0 0 24 24"><path d="M4.25 7.75A9 9 0 1 1 3 12m1.25-4.25H8m-3.75 0V4M12 7.5V12l3 2"/></symbol>
|
||||
<symbol id="icon-menu" viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h16"/></symbol>
|
||||
<symbol id="icon-sliders" viewBox="0 0 24 24"><path d="M4 7h10m4 0h2M4 17h2m4 0h10M14 4v6M10 14v6"/></symbol>
|
||||
<symbol id="icon-help" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M9.8 9a2.3 2.3 0 1 1 3.6 1.9c-.9.6-1.4 1.1-1.4 2.1m0 3.5h.01"/></symbol>
|
||||
<symbol id="icon-download" viewBox="0 0 24 24"><path d="M12 4v11m0 0 4-4m-4 4-4-4M5 19h14"/></symbol>
|
||||
<symbol id="icon-files" viewBox="0 0 24 24"><path d="M7 3h7l4 4v12a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2Zm7 0v5h4M9 12h6m-6 4h6"/></symbol>
|
||||
<symbol id="icon-check" viewBox="0 0 24 24"><path d="m5 12 4 4L19 6"/></symbol>
|
||||
<symbol id="icon-clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></symbol>
|
||||
<symbol id="icon-alert" viewBox="0 0 24 24"><path d="M12 3 2.8 20h18.4L12 3Zm0 6v5m0 3h.01"/></symbol>
|
||||
<symbol id="icon-cloud" viewBox="0 0 24 24"><path d="M7.5 18H18a4 4 0 0 0 .5-7.97A6.5 6.5 0 0 0 6 8.5v.25A4.75 4.75 0 0 0 7.5 18Z"/></symbol>
|
||||
<symbol id="icon-close" viewBox="0 0 24 24"><path d="m6 6 12 12M18 6 6 18"/></symbol>
|
||||
</svg>
|
||||
|
||||
<header class="app-header">
|
||||
<div class="header-cluster header-primary">
|
||||
<div class="app-brand" aria-label="Multi-Hoster Upload">
|
||||
<img src="../assets/app_icon.png" alt="" class="app-brand-icon" width="24" height="24">
|
||||
<span class="app-brand-name">MULTI-HOSTER UPLOAD</span>
|
||||
</div>
|
||||
<span class="header-divider" aria-hidden="true"></span>
|
||||
<nav class="tab-bar" role="tablist" aria-label="Hauptbereiche">
|
||||
<button class="tab active" id="upload-tab" role="tab" aria-selected="true" aria-controls="upload-view" tabindex="0" data-view="upload" title="Upload">
|
||||
<svg class="top-nav-icon" aria-hidden="true"><use href="#icon-upload"></use></svg>
|
||||
<span class="top-nav-label">Upload</span>
|
||||
</button>
|
||||
<button class="tab" id="accounts-tab" role="tab" aria-selected="false" aria-controls="accounts-view" tabindex="-1" data-view="accounts" title="Accounts">
|
||||
<svg class="top-nav-icon" aria-hidden="true"><use href="#icon-accounts"></use></svg>
|
||||
<span class="top-nav-label">Accounts</span>
|
||||
</button>
|
||||
<button class="tab" id="settings-tab" role="tab" aria-selected="false" aria-controls="settings-view" tabindex="-1" data-view="settings" title="Einstellungen">
|
||||
<svg class="top-nav-icon" aria-hidden="true"><use href="#icon-settings"></use></svg>
|
||||
<span class="top-nav-label">Einstellungen</span>
|
||||
</button>
|
||||
<button class="tab" id="history-tab" role="tab" aria-selected="false" aria-controls="history-view" tabindex="-1" data-view="history" title="Verlauf">
|
||||
<svg class="top-nav-icon" aria-hidden="true"><use href="#icon-history"></use></svg>
|
||||
<span class="top-nav-label">Verlauf</span>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="header-spacer" aria-hidden="true"></div>
|
||||
<div class="header-cluster header-utilities">
|
||||
<button class="header-update-button" id="headerUpdateBtn" title="Nach Aktualisierungen suchen" aria-label="Nach Aktualisierungen suchen" data-tooltip="Nach Aktualisierungen suchen">
|
||||
<svg class="header-action-icon" aria-hidden="true"><use href="#icon-download"></use></svg>
|
||||
<span class="header-update-label">Update</span>
|
||||
</button>
|
||||
<span class="header-divider" aria-hidden="true"></span>
|
||||
<nav class="menu-bar" id="menuBar" aria-label="Anwendungsmenüs">
|
||||
<div class="menu-bar-item" data-menu="datei">
|
||||
<button class="menu-bar-trigger" data-menu-trigger="datei" title="Datei" aria-label="Datei">
|
||||
<svg class="header-action-icon" aria-hidden="true"><use href="#icon-menu"></use></svg>
|
||||
<span class="menu-label">Datei</span>
|
||||
</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>
|
||||
<button class="menu-dropdown-item" data-menu-action="online-backup-create"><span>Online-Schlüssel erstellen</span></button>
|
||||
<button class="menu-dropdown-item" data-menu-action="online-backup-restore"><span>Online-Schlüssel 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" title="Schnelleinstellungen" aria-label="Schnelleinstellungen">
|
||||
<svg class="header-action-icon" aria-hidden="true"><use href="#icon-sliders"></use></svg>
|
||||
<span class="menu-label">Einstellungen</span>
|
||||
</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" name="parallelUploads" autocomplete="off" aria-label="Maximale parallele Uploads">
|
||||
<div class="menu-spinner-arrows">
|
||||
<button data-spin="parallel-up" aria-label="Parallele Uploads erhöhen">▲</button>
|
||||
<button data-spin="parallel-down" aria-label="Parallele Uploads verringern">▼</button>
|
||||
</div>
|
||||
</div>
|
||||
<span></span>
|
||||
<span>Geschwindigkeitslimit</span>
|
||||
<input type="checkbox" id="menuSpeedLimitCheck" aria-label="Geschwindigkeitslimit aktivieren">
|
||||
<div class="menu-spinner" id="menuSpeedSpinner">
|
||||
<input type="text" inputmode="decimal" id="menuSpeedInput" name="speedLimit" autocomplete="off" aria-label="Geschwindigkeitslimit in Megabyte pro Sekunde">
|
||||
<div class="menu-spinner-arrows">
|
||||
<button data-spin="speed-up" aria-label="Geschwindigkeitslimit erhöhen">▲</button>
|
||||
<button data-spin="speed-down" aria-label="Geschwindigkeitslimit verringern">▼</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" title="Hilfe und Support" aria-label="Hilfe und Support">
|
||||
<svg class="header-action-icon" aria-hidden="true"><use href="#icon-help"></use></svg>
|
||||
<span class="menu-label">Hilfe</span>
|
||||
</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>
|
||||
<div class="version-badge" title="Installierte Version">
|
||||
<span class="version-monogram" aria-hidden="true">U</span>
|
||||
<span class="version-label" id="versionLabel"></span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="updateBanner" class="update-overlay" style="display:none" aria-hidden="true">
|
||||
<section class="update-dialog" role="dialog" aria-modal="true" aria-labelledby="updateDialogTitle" aria-describedby="updateMessage" tabindex="-1">
|
||||
<button class="update-close-button" id="updateCloseBtn" aria-label="Schließen">
|
||||
<svg aria-hidden="true"><use href="#icon-close"></use></svg>
|
||||
</button>
|
||||
<div class="update-dialog-icon" aria-hidden="true">
|
||||
<svg><use href="#icon-download"></use></svg>
|
||||
</div>
|
||||
<div class="update-dialog-copy">
|
||||
<h2 id="updateDialogTitle">Eine neue Version ist verfügbar</h2>
|
||||
<p id="updateMessage"></p>
|
||||
</div>
|
||||
<div class="update-release-notes" id="updateReleaseNotes" hidden></div>
|
||||
<div class="update-progress" aria-live="polite">
|
||||
<div class="update-progress-track"><span id="updateProgressBar" role="progressbar" aria-label="Update-Fortschritt" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" aria-valuetext="0%"></span></div>
|
||||
<span id="updateProgressText"></span>
|
||||
</div>
|
||||
<div class="update-dialog-actions">
|
||||
<button class="btn btn-secondary" id="dismissUpdateBtn">Später erinnern</button>
|
||||
<button class="btn btn-primary" id="installUpdateBtn">Jetzt updaten</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="upload-view" class="view active" role="tabpanel" aria-labelledby="upload-tab">
|
||||
<aside class="view-sidebar" aria-label="Upload-Übersicht">
|
||||
<div class="view-sidebar-header">
|
||||
<span class="view-sidebar-kicker">Arbeitsbereich</span>
|
||||
<h1 class="view-sidebar-title">Uploads</h1>
|
||||
</div>
|
||||
<nav class="view-sidebar-navigation" aria-label="Upload-Status">
|
||||
<button class="view-sidebar-item active" data-upload-sidebar-target="all" aria-label="Alle Dateien anzeigen">
|
||||
<svg class="view-sidebar-icon" aria-hidden="true"><use href="#icon-files"></use></svg>
|
||||
<span class="view-sidebar-copy">Alle Dateien</span>
|
||||
<span class="view-sidebar-badge" id="uploadSidebarAllCount">0</span>
|
||||
</button>
|
||||
<button class="view-sidebar-item" data-upload-sidebar-target="active" aria-label="Aktive Uploads anzeigen">
|
||||
<svg class="view-sidebar-icon" aria-hidden="true"><use href="#icon-upload"></use></svg>
|
||||
<span class="view-sidebar-copy">Aktiv</span>
|
||||
<span class="view-sidebar-badge" id="uploadSidebarActiveCount">0</span>
|
||||
</button>
|
||||
<button class="view-sidebar-item" data-upload-sidebar-target="waiting" aria-label="Wartende Uploads anzeigen">
|
||||
<svg class="view-sidebar-icon" aria-hidden="true"><use href="#icon-clock"></use></svg>
|
||||
<span class="view-sidebar-copy">Warteschlange</span>
|
||||
<span class="view-sidebar-badge" id="uploadSidebarWaitingCount">0</span>
|
||||
</button>
|
||||
<button class="view-sidebar-item" data-upload-sidebar-target="done" aria-label="Fertige Uploads anzeigen">
|
||||
<svg class="view-sidebar-icon" aria-hidden="true"><use href="#icon-check"></use></svg>
|
||||
<span class="view-sidebar-copy">Fertig</span>
|
||||
<span class="view-sidebar-badge" id="uploadSidebarDoneCount">0</span>
|
||||
</button>
|
||||
<button class="view-sidebar-item" data-upload-sidebar-target="error" aria-label="Fehlgeschlagene Uploads anzeigen">
|
||||
<svg class="view-sidebar-icon" aria-hidden="true"><use href="#icon-alert"></use></svg>
|
||||
<span class="view-sidebar-copy">Fehler</span>
|
||||
<span class="view-sidebar-badge" id="uploadSidebarErrorCount">0</span>
|
||||
</button>
|
||||
</nav>
|
||||
<div class="view-sidebar-section">
|
||||
<span class="view-sidebar-section-label">Verfügbarkeit</span>
|
||||
<div class="view-sidebar-summary">
|
||||
<span>Bereite Accounts</span>
|
||||
<strong id="uploadSidebarAccountsCount">0</strong>
|
||||
</div>
|
||||
<div class="view-sidebar-summary view-sidebar-summary-block hoster-summary" id="hosterSummary">Keine Upload-Ziele ausgewählt</div>
|
||||
</div>
|
||||
<div class="view-sidebar-footnote">Dateien ablegen, Ziele wählen und Uploads zentral steuern.</div>
|
||||
</aside>
|
||||
<main class="view-main upload-main">
|
||||
<div class="upload-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<div class="page-heading">
|
||||
<h2>Upload-Aufträge</h2>
|
||||
<p>Dateien hinzufügen, Ziele auswählen und Fortschritt verfolgen</p>
|
||||
</div>
|
||||
</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" aria-hidden="true"><svg><use href="#icon-cloud"></use></svg></div>
|
||||
<p>Dateien hierher ziehen oder klicken</p>
|
||||
<span>Dateien und Ordner werden vor dem Upload geprüft.</span>
|
||||
</div>
|
||||
|
||||
<div class="queue-shell" id="queueShell" style="display:none">
|
||||
<div class="queue-command-bar" id="queueCommandBar">
|
||||
<button class="toolbar-btn" id="startUploadBtn" title="Alle Uploads starten" aria-label="Alle Uploads starten" disabled>
|
||||
<svg aria-hidden="true" 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="Ausgewählte Uploads starten" aria-label="Ausgewählte Uploads starten" disabled>
|
||||
<svg aria-hidden="true" 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="Ausgewählte Datei erneut hochladen" aria-label="Ausgewählte Datei erneut hochladen">
|
||||
<svg aria-hidden="true" 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="Ausgewählten Upload abbrechen" aria-label="Ausgewählten Upload abbrechen">
|
||||
<svg aria-hidden="true" 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="Aktive Uploads beenden und stoppen" aria-label="Aktive Uploads beenden und stoppen">
|
||||
<svg aria-hidden="true" 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="Alle Uploads abbrechen" aria-label="Alle Uploads abbrechen">
|
||||
<svg aria-hidden="true" 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="Ganz nach oben" aria-label="Ganz nach oben">
|
||||
<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="Nach oben" aria-label="Nach oben">
|
||||
<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="Nach unten" aria-label="Nach unten">
|
||||
<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="Ganz nach unten" aria-label="Ganz nach unten">
|
||||
<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">Dateiname<span class="col-resizer"></span></th>
|
||||
<th class="col-size sortable" data-col="size" data-sort="size">Hochgeladen / Größe<span class="col-resizer"></span></th>
|
||||
<th class="col-host sortable" data-col="host" data-sort="host">Hoster<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">Geschwindigkeit<span class="col-resizer"></span></th>
|
||||
<th class="col-progress sortable" data-col="progress" data-sort="progress">Fortschritt</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">Dateien</button>
|
||||
<button class="recent-tab" data-panel="statsTab">Statistik</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">Dateiname<span class="sort-indicator">↕</span></th>
|
||||
<th class="col-host sortable" data-recent-sort="host">Hoster<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>Dateien in der Warteschlange</h4>
|
||||
<div class="stats-row"><span>Gesamt:</span><span id="statQueueTotal">0</span></div>
|
||||
<div class="stats-row"><span>Fertig:</span><span id="statQueueDone">0</span></div>
|
||||
<div class="stats-row"><span>Verbleibend:</span><span id="statQueueRemaining">0</span></div>
|
||||
<div class="stats-row"><span>Läuft:</span><span id="statQueueInProgress">0</span></div>
|
||||
<div class="stats-row"><span>Fehler:</span><span id="statQueueError">0</span></div>
|
||||
</div>
|
||||
<div class="stats-col">
|
||||
<h4>Dateigröße in der Warteschlange</h4>
|
||||
<div class="stats-row"><span>Gesamt:</span><span id="statSizeTotal">0 B</span></div>
|
||||
<div class="stats-row"><span>Verbleibend:</span><span id="statSizeRemaining">0 B</span></div>
|
||||
</div>
|
||||
<div class="stats-col">
|
||||
<h4>Sitzung</h4>
|
||||
<div class="stats-row"><span>Upload-Geschwindigkeit:</span><span id="statSpeed">0 B/s</span></div>
|
||||
<div class="stats-row"><span>Restzeit:</span><span id="statEta">--:--</span></div>
|
||||
<div class="stats-row"><span>Laufzeit:</span><span id="statRunTime">00:00:00</span></div>
|
||||
<div class="stats-row"><span>In diesem Lauf hochgeladen:</span><span id="statSessionBytes">0 B</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div id="accounts-view" class="view" role="tabpanel" aria-labelledby="accounts-tab">
|
||||
<aside class="view-sidebar" aria-label="Account-Übersicht">
|
||||
<div class="view-sidebar-header">
|
||||
<span class="view-sidebar-kicker">Zugänge</span>
|
||||
<h1 class="view-sidebar-title">Accounts</h1>
|
||||
</div>
|
||||
<nav class="view-sidebar-navigation" aria-label="Account-Status">
|
||||
<button class="view-sidebar-item active" data-accounts-sidebar-filter="all" aria-label="Alle Accounts anzeigen">
|
||||
<svg class="view-sidebar-icon" aria-hidden="true"><use href="#icon-accounts"></use></svg>
|
||||
<span class="view-sidebar-copy">Alle Accounts</span>
|
||||
<span class="view-sidebar-badge" id="accountsSidebarAllCount">0</span>
|
||||
</button>
|
||||
<button class="view-sidebar-item" data-accounts-sidebar-filter="ready" aria-label="Bereite Accounts anzeigen">
|
||||
<svg class="view-sidebar-icon" aria-hidden="true"><use href="#icon-check"></use></svg>
|
||||
<span class="view-sidebar-copy">Bereit</span>
|
||||
<span class="view-sidebar-badge" id="accountsSidebarReadyCount">0</span>
|
||||
</button>
|
||||
<button class="view-sidebar-item" data-accounts-sidebar-filter="warning" aria-label="Accounts mit Handlungsbedarf anzeigen">
|
||||
<svg class="view-sidebar-icon" aria-hidden="true"><use href="#icon-clock"></use></svg>
|
||||
<span class="view-sidebar-copy">Aktion nötig</span>
|
||||
<span class="view-sidebar-badge" id="accountsSidebarWarningCount">0</span>
|
||||
</button>
|
||||
<button class="view-sidebar-item" data-accounts-sidebar-filter="error" aria-label="Fehlerhafte Accounts anzeigen">
|
||||
<svg class="view-sidebar-icon" aria-hidden="true"><use href="#icon-alert"></use></svg>
|
||||
<span class="view-sidebar-copy">Fehler</span>
|
||||
<span class="view-sidebar-badge" id="accountsSidebarErrorCount">0</span>
|
||||
</button>
|
||||
</nav>
|
||||
<div class="view-sidebar-section view-sidebar-hoster-section">
|
||||
<span class="view-sidebar-section-label">Hoster</span>
|
||||
<div class="view-sidebar-hosters" id="accountsSidebarHosters"></div>
|
||||
</div>
|
||||
<div class="view-sidebar-footnote">Zugänge prüfen, priorisieren und für Uploads bereitstellen.</div>
|
||||
</aside>
|
||||
<main class="accounts-container view-main accounts-main">
|
||||
<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>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="accountModal" style="display:none">
|
||||
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="accountModalTitle" aria-describedby="accountModalSubtitle">
|
||||
<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 for="accountHosterSelect">Hoster</label>
|
||||
<select class="key-input" id="accountHosterSelect" name="hoster" autocomplete="off" style="max-width:300px"></select>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<label for="accField_label">Label (optional)</label>
|
||||
<input type="text" class="key-input" id="accField_label" name="accountLabel" autocomplete="off" placeholder="z. B. Hauptaccount, Premium, Kunde XY" maxlength="60">
|
||||
</div>
|
||||
<div id="accountCredsFields"></div>
|
||||
<div class="account-modal-status" id="accountModalStatus" role="status" aria-live="polite"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" id="cancelAccountModalBtn">Abbrechen</button>
|
||||
<button class="btn btn-primary" id="saveAccountBtn">Prüfen und anlegen</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" role="tabpanel" aria-labelledby="settings-tab">
|
||||
<div class="settings-container">
|
||||
<div class="settings-header">
|
||||
<div>
|
||||
<h2>Einstellungen</h2>
|
||||
<p class="settings-hint">Alle Optionen nach Aufgaben sortiert. Änderungen werden automatisch gespeichert.</p>
|
||||
</div>
|
||||
<div class="settings-save-row">
|
||||
<button class="btn btn-secondary" id="saveSettingsBtn">Jetzt speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-hosters" id="settingsHosters"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="history-view" class="view" role="tabpanel" aria-labelledby="history-tab">
|
||||
<aside class="view-sidebar" aria-label="Verlaufsübersicht">
|
||||
<div class="view-sidebar-header">
|
||||
<span class="view-sidebar-kicker">Archiv</span>
|
||||
<h1 class="view-sidebar-title">Verlauf</h1>
|
||||
</div>
|
||||
<nav class="view-sidebar-navigation" aria-label="Verlaufsstatus">
|
||||
<button class="view-sidebar-item active" data-history-filter="all" aria-label="Gesamten Verlauf anzeigen">
|
||||
<svg class="view-sidebar-icon" aria-hidden="true"><use href="#icon-history"></use></svg>
|
||||
<span class="view-sidebar-copy">Alle Uploads</span>
|
||||
<span class="view-sidebar-badge" id="historySidebarAllCount">0</span>
|
||||
</button>
|
||||
<button class="view-sidebar-item" data-history-filter="success" aria-label="Erfolgreiche Uploads anzeigen">
|
||||
<svg class="view-sidebar-icon" aria-hidden="true"><use href="#icon-check"></use></svg>
|
||||
<span class="view-sidebar-copy">Erfolgreich</span>
|
||||
<span class="view-sidebar-badge" id="historySidebarSuccessCount">0</span>
|
||||
</button>
|
||||
<button class="view-sidebar-item" data-history-filter="error" aria-label="Fehlgeschlagene Uploads anzeigen">
|
||||
<svg class="view-sidebar-icon" aria-hidden="true"><use href="#icon-alert"></use></svg>
|
||||
<span class="view-sidebar-copy">Fehler</span>
|
||||
<span class="view-sidebar-badge" id="historySidebarErrorCount">0</span>
|
||||
</button>
|
||||
</nav>
|
||||
<div class="view-sidebar-section">
|
||||
<span class="view-sidebar-section-label">Aufbewahrung</span>
|
||||
<div class="view-sidebar-summary">
|
||||
<span>Aktive Regel</span>
|
||||
<strong id="historySidebarRetention">Alles behalten</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="view-sidebar-footnote">Links wiederfinden, kopieren oder als Datei exportieren.</div>
|
||||
</aside>
|
||||
<main class="history-container view-main history-main">
|
||||
<div class="history-header">
|
||||
<h2>Upload-Verlauf</h2>
|
||||
<div class="history-header-actions">
|
||||
<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>
|
||||
</main>
|
||||
</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">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">Verbleibend 0</span>
|
||||
<span class="sb-separator">|</span>
|
||||
<span class="sb-progress-count" id="sbInProgressCount">Läuft 0</span>
|
||||
<span class="sb-separator">|</span>
|
||||
<span class="sb-done-count" id="sbDoneCount">Fertig 0</span>
|
||||
<span class="sb-separator">|</span>
|
||||
<span class="sb-error-count" id="sbErrorCount">Fehler 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="../lib/serialized-runner.js"></script>
|
||||
<script src="account-submit.js"></script>
|
||||
<script src="account-status.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+3213
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,47 @@
|
||||
const PRODUCT_NAME = 'Multi-Hoster-Upload';
|
||||
|
||||
export function parseReleaseArgs(args) {
|
||||
const version = Array.isArray(args) ? args[0] : '';
|
||||
if (!/^\d+\.\d+\.\d+$/.test(version || '')) {
|
||||
throw new Error('Usage: <version> --transport-tag <vX.Y.Z> [release notes] [--dry-run]');
|
||||
}
|
||||
|
||||
const transportTagIndex = args.indexOf('--transport-tag');
|
||||
const transportTag = transportTagIndex >= 0 ? args[transportTagIndex + 1] : '';
|
||||
if (!/^v\d+\.\d+\.\d+$/.test(transportTag)) {
|
||||
throw new Error('--transport-tag must match vX.Y.Z');
|
||||
}
|
||||
|
||||
const excludedIndexes = new Set([0, transportTagIndex, transportTagIndex + 1]);
|
||||
const notes = args.filter((arg, index) => !excludedIndexes.has(index) && arg !== '--dry-run').join(' ');
|
||||
return { version, transportTag, notes, dryRun: args.includes('--dry-run') };
|
||||
}
|
||||
|
||||
export function createReleasePlan(options) {
|
||||
const releaseTitle = `${PRODUCT_NAME} v${options.version}`;
|
||||
const setupName = `${PRODUCT_NAME} Setup ${options.version}.exe`;
|
||||
const portableName = `${PRODUCT_NAME} ${options.version}.exe`;
|
||||
const blockmapName = `${setupName}.blockmap`;
|
||||
return {
|
||||
...options,
|
||||
tag: options.transportTag,
|
||||
releaseTitle,
|
||||
releaseBody: options.notes || releaseTitle,
|
||||
setupName,
|
||||
portableName,
|
||||
blockmapName,
|
||||
expectedArtifacts: [setupName, portableName, blockmapName, 'latest.yml']
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveExistingReleaseId(plan, release) {
|
||||
const existingTitle = typeof release?.name === 'string' ? release.name : '';
|
||||
if (existingTitle !== plan.releaseTitle) {
|
||||
throw new Error(`Refusing recovery for ${plan.tag}: existing release title "${existingTitle}" does not match "${plan.releaseTitle}"`);
|
||||
}
|
||||
return release.id;
|
||||
}
|
||||
|
||||
export function renderLatestYml(plan, sha, size, releaseDate = new Date().toISOString()) {
|
||||
return `version: ${plan.version}\nfiles:\n - url: ${plan.setupName}\n sha512: ${sha}\n size: ${size}\npath: ${plan.setupName}\nsha512: ${sha}\nreleaseDate: '${releaseDate}'\n`;
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
import { lstat, 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 failures = new Map();
|
||||
const sourceFiles = [
|
||||
'.gitignore',
|
||||
'README.md',
|
||||
'SECURITY.md',
|
||||
'assets/app_icon.ico',
|
||||
'assets/app_icon.png',
|
||||
'eslint.config.mjs',
|
||||
'lib/account-auth.js',
|
||||
'lib/account-rotation.js',
|
||||
'lib/backup-crypto.js',
|
||||
'lib/clouddrop-upload.js',
|
||||
'lib/coalesced-set.js',
|
||||
'lib/config-store.js',
|
||||
'lib/diagnostics-agent.js',
|
||||
'lib/diagnostics-collectors.js',
|
||||
'lib/doodstream-upload.js',
|
||||
'lib/file-probe.js',
|
||||
'lib/folder-monitor.js',
|
||||
'lib/hosters.js',
|
||||
'lib/ip-allowlist.js',
|
||||
'lib/log-mode.js',
|
||||
'lib/log-policy.js',
|
||||
'lib/log-rotation.js',
|
||||
'lib/online-backup.js',
|
||||
'lib/orphan-tmp.js',
|
||||
'lib/queue-dedup.js',
|
||||
'lib/queue-prune.js',
|
||||
'lib/remote-capture-preload.js',
|
||||
'lib/remote-capture.html',
|
||||
'lib/remote-server.js',
|
||||
'lib/secret-store.js',
|
||||
'lib/semaphore.js',
|
||||
'lib/serialized-runner.js',
|
||||
'lib/settings-backup.js',
|
||||
'lib/settings-import-gate.js',
|
||||
'lib/startup-renderer.js',
|
||||
'lib/stats.js',
|
||||
'lib/support-bundle.js',
|
||||
'lib/throttle-timer.js',
|
||||
'lib/throttle.js',
|
||||
'lib/throttled-cache.js',
|
||||
'lib/updater.js',
|
||||
'lib/upload-log.js',
|
||||
'lib/upload-manager.js',
|
||||
'lib/vidmoly-upload.js',
|
||||
'lib/voe-upload.js',
|
||||
'lib/webhook-notify.js',
|
||||
'main.js',
|
||||
'package-lock.json',
|
||||
'package.json',
|
||||
'preload-drop-target.js',
|
||||
'preload.js',
|
||||
'renderer/account-status.js',
|
||||
'renderer/account-submit.js',
|
||||
'renderer/app.js',
|
||||
'renderer/drop-target.html',
|
||||
'renderer/index.html',
|
||||
'renderer/styles.css',
|
||||
'scripts/afterPack.cjs',
|
||||
'scripts/release-plan.mjs',
|
||||
'scripts/verify-public-release.mjs',
|
||||
'services/backup-api/package-lock.json',
|
||||
'services/backup-api/package.json',
|
||||
'services/backup-api/src/cli.mjs',
|
||||
'services/backup-api/src/server.mjs',
|
||||
'services/backup-api/test/server.test.mjs',
|
||||
'tests/account-auth.test.js',
|
||||
'tests/account-rotation.test.js',
|
||||
'tests/account-status.test.js',
|
||||
'tests/backup-crypto.test.js',
|
||||
'tests/byse-reject-recovery.test.js',
|
||||
'tests/coalesced-set.test.js',
|
||||
'tests/config-store.test.js',
|
||||
'tests/diagnostics-agent.test.js',
|
||||
'tests/diagnostics-collectors.test.js',
|
||||
'tests/diagnostics-protocol.test.js',
|
||||
'tests/doodstream-api-upload.test.js',
|
||||
'tests/doodstream-upload.test.js',
|
||||
'tests/file-probe.test.js',
|
||||
'tests/history-retention.test.js',
|
||||
'tests/hosters.test.js',
|
||||
'tests/ip-allowlist.test.js',
|
||||
'tests/log-mode.test.js',
|
||||
'tests/log-policy.test.js',
|
||||
'tests/log-rotation.test.js',
|
||||
'tests/online-backup-service.test.js',
|
||||
'tests/online-backup.test.js',
|
||||
'tests/orphan-tmp.test.js',
|
||||
'tests/package-build-files.test.js',
|
||||
'tests/public-release-verifier.test.js',
|
||||
'tests/queue-dedup-property.test.js',
|
||||
'tests/queue-dedup.test.js',
|
||||
'tests/queue-persistence-scenario.test.js',
|
||||
'tests/queue-prune.test.js',
|
||||
'tests/remote-config.test.js',
|
||||
'tests/remote-server.test.js',
|
||||
'tests/semaphore.test.js',
|
||||
'tests/serialized-runner.test.js',
|
||||
'tests/settings-backup.test.js',
|
||||
'tests/settings-import-gate.test.js',
|
||||
'tests/startup-renderer.test.js',
|
||||
'tests/stats.test.js',
|
||||
'tests/support-bundle.test.js',
|
||||
'tests/suspect-reject-alternates.test.js',
|
||||
'tests/throttle-timer.test.js',
|
||||
'tests/throttle.test.js',
|
||||
'tests/throttled-cache.test.js',
|
||||
'tests/ui-smoke.js',
|
||||
'tests/updater-version.test.js',
|
||||
'tests/upload-log.test.js',
|
||||
'tests/upload-manager.test.js',
|
||||
'tests/validate-credentials.test.js',
|
||||
'tests/webhook-notify.test.js'
|
||||
];
|
||||
const screenshotFile = 'assets/product-overview.png';
|
||||
const allowedFiles = new Set([...sourceFiles, screenshotFile]);
|
||||
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',
|
||||
'test:backup-api': 'npm --prefix services/backup-api test',
|
||||
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 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',
|
||||
['release_', ['gi', 'tea'].join(''), '.mjs'].join('')
|
||||
]);
|
||||
const aiTerms = [
|
||||
['clau', 'de'].join(''),
|
||||
['co', 'dex'].join(''),
|
||||
['chat', 'gpt'].join('')
|
||||
].join('|');
|
||||
const personalTerms = [
|
||||
['pl', 'oet'].join(''),
|
||||
['baker', 'edwin318'].join('')
|
||||
].join('|');
|
||||
const internalTerms = [
|
||||
['internal', ' investigation'].join(''),
|
||||
['interne', ' untersuchung'].join(''),
|
||||
['audit', ' method'].join(''),
|
||||
['test', ' chronicle'].join(''),
|
||||
['generated', ' by'].join(''),
|
||||
['co-authored', '-by'].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 forbiddenInternalPattern = new RegExp(`\\b(?:${internalTerms})\\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 buildAllowedDirectories(files) {
|
||||
const directories = new Set();
|
||||
for (const file of files) {
|
||||
let current = path.posix.dirname(file);
|
||||
while (current && current !== '.') {
|
||||
directories.add(current);
|
||||
current = path.posix.dirname(current);
|
||||
}
|
||||
}
|
||||
return directories;
|
||||
}
|
||||
|
||||
const allowedDirectories = buildAllowedDirectories(allowedFiles);
|
||||
|
||||
function isDeniedBasename(basename) {
|
||||
const lower = basename.toLowerCase();
|
||||
return deniedBasenames.has(lower)
|
||||
|| /^\.env(?:\.|$)/i.test(basename)
|
||||
|| /\.(?:bak|db|log|sqlite|sqlite3|tmp)$/i.test(basename);
|
||||
}
|
||||
|
||||
function parseArguments() {
|
||||
const sourceOnlyCount = args.filter((arg) => arg === '--source-only').length;
|
||||
const versionFlagIndexes = args.map((arg, index) => arg === '--version' ? index : -1).filter((index) => index >= 0);
|
||||
const versionIndex = versionFlagIndexes[0] ?? -1;
|
||||
const expectedVersion = versionIndex >= 0 ? args[versionIndex + 1] : '';
|
||||
const consumed = new Set();
|
||||
|
||||
if (sourceOnlyCount === 1) consumed.add(args.indexOf('--source-only'));
|
||||
if (sourceOnlyCount > 1) addFailure('scripts/verify-public-release.mjs', 'duplicate-source-only');
|
||||
if (versionFlagIndexes.length !== 1 || !/^\d+\.\d+\.\d+$/.test(expectedVersion || '')) {
|
||||
addFailure('scripts/verify-public-release.mjs', 'expected-version-argument');
|
||||
} else {
|
||||
consumed.add(versionIndex);
|
||||
consumed.add(versionIndex + 1);
|
||||
}
|
||||
|
||||
for (let index = 0; index < args.length; index++) {
|
||||
if (!consumed.has(index)) addFailure('scripts/verify-public-release.mjs', 'argument-allowlist');
|
||||
}
|
||||
|
||||
return { sourceOnly: sourceOnlyCount === 1, expectedVersion };
|
||||
}
|
||||
|
||||
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));
|
||||
if (relativePath === '.git') continue;
|
||||
const absolutePath = path.join(directory, entry.name);
|
||||
const stats = await lstat(absolutePath);
|
||||
|
||||
if (stats.isSymbolicLink()) {
|
||||
addFailure(relativePath, 'unsupported-file-type');
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
if (!allowedDirectories.has(relativePath)) {
|
||||
addFailure(relativePath, 'source-layout-allowlist');
|
||||
continue;
|
||||
}
|
||||
files.push(...await enumerate(absolutePath, relativePath));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!entry.isFile()) {
|
||||
addFailure(relativePath, 'unsupported-file-type');
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isDeniedBasename(entry.name)) addFailure(relativePath, 'denied-basename');
|
||||
if (!allowedFiles.has(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 value = await readFile(path.join(root, relativePath), 'utf8');
|
||||
if (forbiddenPersonalPattern.test(value)) addFailure(relativePath, 'forbidden-personal-term');
|
||||
if (forbiddenAiPattern.test(value)) addFailure(relativePath, 'forbidden-ai-term');
|
||||
if (forbiddenInternalPattern.test(value)) addFailure(relativePath, 'forbidden-internal-term');
|
||||
if (relativePath !== 'lib/updater.js' && updaterOnlyPattern.test(value)) addFailure(relativePath, 'updater-endpoint-scope');
|
||||
}
|
||||
}
|
||||
|
||||
function validatePackage(packageJson, packageLock, files, expectedVersion) {
|
||||
if (!packageJson) return;
|
||||
if (packageJson.version !== expectedVersion) addFailure('package.json', 'package-version-target');
|
||||
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');
|
||||
}
|
||||
if (packageJson.build?.afterPack !== 'scripts/afterPack.cjs') addFailure('scripts/afterPack.cjs', 'build-hook-entry');
|
||||
|
||||
if (packageLock) {
|
||||
const lockRoot = packageLock.packages?.[''];
|
||||
if (packageLock.version !== expectedVersion || lockRoot?.version !== expectedVersion) {
|
||||
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 validateServicePackage(packageJson, packageLock) {
|
||||
if (!packageJson || !packageLock) return;
|
||||
const lockRoot = packageLock.packages?.[''];
|
||||
if (packageLock.version !== packageJson.version || lockRoot?.version !== packageJson.version) {
|
||||
addFailure('services/backup-api/package-lock.json', 'service-lock-version');
|
||||
}
|
||||
if (!lockRoot || lockRoot.name !== packageJson.name || stableJson(lockRoot.dependencies) !== stableJson(packageJson.dependencies)) {
|
||||
addFailure('services/backup-api/package-lock.json', 'service-lock-root-metadata');
|
||||
}
|
||||
}
|
||||
|
||||
async function validateScreenshot(sourceOnly) {
|
||||
if (sourceOnly) return;
|
||||
try {
|
||||
const data = await readFile(path.join(root, screenshotFile));
|
||||
const signature = data.subarray(0, 8).toString('hex');
|
||||
const width = data.length >= 24 ? data.readUInt32BE(16) : 0;
|
||||
const height = data.length >= 24 ? data.readUInt32BE(20) : 0;
|
||||
if (signature !== '89504e470d0a1a0a' || width < 1000 || height < 650) {
|
||||
addFailure(screenshotFile, 'product-screenshot');
|
||||
}
|
||||
} catch {
|
||||
addFailure(screenshotFile, 'required-screenshot');
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
const { sourceOnly, expectedVersion } = parseArguments();
|
||||
const files = await enumerate();
|
||||
const requiredFiles = sourceOnly ? sourceFiles : [...sourceFiles, screenshotFile];
|
||||
for (const requiredFile of requiredFiles) {
|
||||
if (!files.includes(requiredFile)) addFailure(requiredFile, 'required-source-file');
|
||||
}
|
||||
|
||||
await validateTextFiles(files);
|
||||
const packageJson = await readJson('package.json', 'package-json');
|
||||
const packageLock = await readJson('package-lock.json', 'package-lock-json');
|
||||
const servicePackage = await readJson('services/backup-api/package.json', 'service-package-json');
|
||||
const serviceLock = await readJson('services/backup-api/package-lock.json', 'service-package-lock-json');
|
||||
validatePackage(packageJson, packageLock, files, expectedVersion);
|
||||
validateServicePackage(servicePackage, serviceLock);
|
||||
await validateScreenshot(sourceOnly);
|
||||
|
||||
if (failures.size > 0) {
|
||||
printFailures();
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
process.stdout.write(`public-release-source-ok files=${files.length} denied-paths=0 internal-terms=0 version=${packageJson.version} scripts=${Object.keys(packageJson.scripts).length} build-files=${packageJson.build.files.length} layout=exact screenshot=${sourceOnly ? 'deferred' : 'valid'}\n`);
|
||||
}
|
||||
|
||||
main().catch(() => {
|
||||
process.stderr.write('scripts/verify-public-release.mjs\tverifier-runtime\n');
|
||||
process.exitCode = 1;
|
||||
});
|
||||
Generated
+50
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "multi-hoster-uploader-backup-api",
|
||||
"version": "2.0.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "multi-hoster-uploader-backup-api",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"proper-lockfile": "4.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/graceful-fs": {
|
||||
"version": "4.2.11",
|
||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/proper-lockfile": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz",
|
||||
"integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.4",
|
||||
"retry": "^0.12.0",
|
||||
"signal-exit": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/retry": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
|
||||
"integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/signal-exit": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
|
||||
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
|
||||
"license": "ISC"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "multi-hoster-uploader-backup-api",
|
||||
"version": "2.0.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node src/cli.mjs",
|
||||
"test": "node --test"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"dependencies": {
|
||||
"proper-lockfile": "4.1.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { resolve } from 'node:path'
|
||||
import { createBackupServer } from './server.mjs'
|
||||
|
||||
const port = Number.parseInt(process.env.PORT ?? '8788', 10)
|
||||
const host = process.env.HOST ?? '127.0.0.1'
|
||||
const rootDir = resolve(process.env.BACKUP_DATA_DIR ?? './data')
|
||||
const allowedOrigins = (process.env.ALLOWED_ORIGINS ?? '')
|
||||
.split(',')
|
||||
.map((origin) => origin.trim())
|
||||
.filter(Boolean)
|
||||
const rateLimit = {
|
||||
max: Number.parseInt(process.env.RATE_LIMIT_MAX ?? '60', 10),
|
||||
windowMs: Number.parseInt(process.env.RATE_LIMIT_WINDOW_MS ?? '60000', 10)
|
||||
}
|
||||
const uploadRateLimit = {
|
||||
max: Number.parseInt(process.env.UPLOAD_RATE_LIMIT_MAX ?? '10', 10),
|
||||
windowMs: Number.parseInt(process.env.UPLOAD_RATE_LIMIT_WINDOW_MS ?? '3600000', 10)
|
||||
}
|
||||
const requestRateLimit = {
|
||||
max: Number.parseInt(process.env.REQUEST_RATE_LIMIT_MAX ?? '120', 10),
|
||||
windowMs: Number.parseInt(process.env.REQUEST_RATE_LIMIT_WINDOW_MS ?? '60000', 10)
|
||||
}
|
||||
const maxStorageBytes = Number.parseInt(process.env.MAX_STORAGE_BYTES ?? String(10 * 1024 * 1024 * 1024), 10)
|
||||
const maxRecords = Number.parseInt(process.env.MAX_RECORDS ?? '10000', 10)
|
||||
const bodyTimeoutMs = Number.parseInt(process.env.BODY_TIMEOUT_MS ?? '10000', 10)
|
||||
const healthCacheMs = Number.parseInt(process.env.HEALTH_CACHE_MS ?? '5000', 10)
|
||||
const maxConcurrentPerClient = Number.parseInt(process.env.MAX_CONCURRENT_PER_CLIENT ?? '8', 10)
|
||||
const maxConcurrentTotal = Number.parseInt(process.env.MAX_CONCURRENT_TOTAL ?? '64', 10)
|
||||
const trustedProxy = process.env.TRUST_PROXY === 'true'
|
||||
const trustedProxyAddresses = (process.env.TRUSTED_PROXY_ADDRESSES ?? '127.0.0.1,::1,::ffff:127.0.0.1')
|
||||
.split(',')
|
||||
.map((address) => address.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) throw new Error('Invalid PORT')
|
||||
|
||||
const server = createBackupServer({
|
||||
rootDir,
|
||||
allowedOrigins,
|
||||
rateLimit,
|
||||
uploadRateLimit,
|
||||
requestRateLimit,
|
||||
maxStorageBytes,
|
||||
maxRecords,
|
||||
bodyTimeoutMs,
|
||||
healthCacheMs,
|
||||
maxConcurrentPerClient,
|
||||
maxConcurrentTotal,
|
||||
trustedProxy,
|
||||
trustedProxyAddresses
|
||||
})
|
||||
|
||||
server.listen(port, host, () => {
|
||||
process.stdout.write(`Backup API listening on ${host}:${port}\n`)
|
||||
})
|
||||
|
||||
function shutdown() {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
process.stderr.write('Backup API shutdown failed\n')
|
||||
process.exitCode = 1
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
process.on('SIGINT', shutdown)
|
||||
process.on('SIGTERM', shutdown)
|
||||
@@ -0,0 +1,552 @@
|
||||
import { createHash, timingSafeEqual, randomBytes } from 'node:crypto'
|
||||
import { createServer } from 'node:http'
|
||||
import { link, mkdir, open, readFile, readdir, stat, unlink } from 'node:fs/promises'
|
||||
import { isIP } from 'node:net'
|
||||
import { join } from 'node:path'
|
||||
import lockfile from 'proper-lockfile'
|
||||
|
||||
const maxBlobBytes = 256 * 1024
|
||||
const maxBodyBytes = 384 * 1024
|
||||
const idPattern = /^[A-Za-z0-9_-]{22}$/
|
||||
const verifierPattern = /^[A-Za-z0-9_-]{43}$/
|
||||
const blobPattern = /^[A-Za-z0-9_-]+$/
|
||||
const notFoundBody = '{"error":"not_found"}'
|
||||
|
||||
function isCanonicalBase64Url(value, byteLength, pattern) {
|
||||
if (typeof value !== 'string' || !pattern.test(value)) return false
|
||||
const decoded = Buffer.from(value, 'base64url')
|
||||
return decoded.length === byteLength && decoded.toString('base64url') === value
|
||||
}
|
||||
|
||||
function isValidBackup(payload) {
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return false
|
||||
const keys = Object.keys(payload).sort()
|
||||
if (keys.join(',') !== 'blob,deleteVerifier,id') return false
|
||||
if (!isCanonicalBase64Url(payload.id, 16, idPattern)) return false
|
||||
if (!isCanonicalBase64Url(payload.deleteVerifier, 32, verifierPattern)) return false
|
||||
if (typeof payload.blob !== 'string' || !blobPattern.test(payload.blob)) return false
|
||||
const decoded = Buffer.from(payload.blob, 'base64url')
|
||||
return decoded.length <= maxBlobBytes && decoded.toString('base64url') === payload.blob
|
||||
}
|
||||
|
||||
function createRateLimiter({ max, windowMs }) {
|
||||
const clients = new Map()
|
||||
let requestCount = 0
|
||||
return (address) => {
|
||||
const now = Date.now()
|
||||
requestCount += 1
|
||||
if (requestCount % 1024 === 0) {
|
||||
for (const [key, value] of clients) {
|
||||
if (now - value.startedAt >= windowMs) clients.delete(key)
|
||||
}
|
||||
}
|
||||
const current = clients.get(address)
|
||||
if (!current || now - current.startedAt >= windowMs) {
|
||||
clients.set(address, { startedAt: now, count: 1 })
|
||||
return null
|
||||
}
|
||||
if (current.count >= max) return Math.max(1, Math.ceil((windowMs - (now - current.startedAt)) / 1000))
|
||||
current.count += 1
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function createConcurrencyLimiter({ perClient, total }) {
|
||||
const clients = new Map()
|
||||
let active = 0
|
||||
return {
|
||||
enter(address) {
|
||||
const clientActive = clients.get(address) ?? 0
|
||||
if (active >= total || clientActive >= perClient) return false
|
||||
active += 1
|
||||
clients.set(address, clientActive + 1)
|
||||
return true
|
||||
},
|
||||
leave(address) {
|
||||
const clientActive = clients.get(address) ?? 0
|
||||
active = Math.max(0, active - 1)
|
||||
if (clientActive <= 1) clients.delete(address)
|
||||
else clients.set(address, clientActive - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function readJsonBody(request, timeoutMs) {
|
||||
const declaredLength = Number.parseInt(request.headers['content-length'] ?? '', 10)
|
||||
if (Number.isFinite(declaredLength) && declaredLength > maxBodyBytes) {
|
||||
request.resume()
|
||||
return Promise.resolve({ error: 413 })
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
let size = 0
|
||||
let settled = false
|
||||
const chunks = []
|
||||
const cleanup = () => {
|
||||
clearTimeout(timer)
|
||||
request.off('data', onData)
|
||||
request.off('end', onEnd)
|
||||
request.off('aborted', onAborted)
|
||||
request.off('error', onError)
|
||||
}
|
||||
const finish = (result) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
cleanup()
|
||||
resolve(result)
|
||||
}
|
||||
const onData = (chunk) => {
|
||||
size += chunk.length
|
||||
if (size > maxBodyBytes) {
|
||||
finish({ error: 413 })
|
||||
request.resume()
|
||||
return
|
||||
}
|
||||
chunks.push(chunk)
|
||||
}
|
||||
const onEnd = () => {
|
||||
try {
|
||||
finish({ value: JSON.parse(Buffer.concat(chunks).toString('utf8')) })
|
||||
} catch {
|
||||
finish({ error: 400 })
|
||||
}
|
||||
}
|
||||
const onAborted = () => reject(new Error('Request aborted'))
|
||||
const onError = (error) => reject(error)
|
||||
const timer = setTimeout(() => {
|
||||
finish({ error: 408 })
|
||||
request.resume()
|
||||
}, timeoutMs)
|
||||
request.on('data', onData)
|
||||
request.on('end', onEnd)
|
||||
request.on('aborted', onAborted)
|
||||
request.on('error', onError)
|
||||
})
|
||||
}
|
||||
|
||||
function recordPath(rootDir, id) {
|
||||
return join(rootDir, `${id}.json`)
|
||||
}
|
||||
|
||||
function createMutationQueue() {
|
||||
let pending = Promise.resolve()
|
||||
return (operation) => {
|
||||
const result = pending.then(operation, operation)
|
||||
pending = result.catch(() => {})
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupTemporaryFiles(rootDir) {
|
||||
const entries = await readdir(rootDir, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !/^\.[a-f0-9]{32}\.tmp$/.test(entry.name)) continue
|
||||
try {
|
||||
await unlink(join(rootDir, entry.name))
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function directoryUsage(rootDir) {
|
||||
let bytes = 0
|
||||
let records = 0
|
||||
const entries = await readdir(rootDir, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !entry.name.endsWith('.json')) continue
|
||||
try {
|
||||
bytes += (await stat(join(rootDir, entry.name))).size
|
||||
records += 1
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') throw error
|
||||
}
|
||||
}
|
||||
return { bytes, records }
|
||||
}
|
||||
|
||||
async function syncDirectory(rootDir) {
|
||||
let handle
|
||||
try {
|
||||
handle = await open(rootDir, 'r')
|
||||
await handle.sync()
|
||||
} catch (error) {
|
||||
if (!['EISDIR', 'EINVAL', 'ENOTSUP', 'EPERM', 'EBADF'].includes(error.code)) throw error
|
||||
} finally {
|
||||
await handle?.close().catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
async function withStorageLock(rootDir, operation) {
|
||||
await mkdir(rootDir, { recursive: true })
|
||||
const release = await lockfile.lock(rootDir, {
|
||||
realpath: false,
|
||||
lockfilePath: join(rootDir, '.storage.lock'),
|
||||
stale: 30_000,
|
||||
update: 10_000,
|
||||
retries: {
|
||||
retries: 100,
|
||||
factor: 1.1,
|
||||
minTimeout: 10,
|
||||
maxTimeout: 100,
|
||||
randomize: true
|
||||
}
|
||||
})
|
||||
try {
|
||||
return await operation()
|
||||
} finally {
|
||||
let releaseError
|
||||
try {
|
||||
await release()
|
||||
} catch (error) {
|
||||
releaseError = error
|
||||
}
|
||||
try {
|
||||
await syncDirectory(rootDir)
|
||||
} catch (error) {
|
||||
releaseError ??= error
|
||||
}
|
||||
if (releaseError) throw releaseError
|
||||
}
|
||||
}
|
||||
|
||||
async function recordExists(rootDir, id) {
|
||||
try {
|
||||
await stat(recordPath(rootDir, id))
|
||||
return true
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') return false
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function createRecord(rootDir, payload, maxStorageBytes, maxRecords) {
|
||||
await mkdir(rootDir, { recursive: true })
|
||||
await cleanupTemporaryFiles(rootDir)
|
||||
if (await recordExists(rootDir, payload.id)) return 'duplicate'
|
||||
const contents = Buffer.from(JSON.stringify({
|
||||
version: 1,
|
||||
blob: payload.blob,
|
||||
deleteVerifier: payload.deleteVerifier,
|
||||
createdAt: new Date().toISOString()
|
||||
}), 'utf8')
|
||||
const usage = await directoryUsage(rootDir)
|
||||
if (usage.bytes + contents.length > maxStorageBytes || usage.records >= maxRecords) return 'full'
|
||||
const temporaryPath = join(rootDir, `.${randomBytes(16).toString('hex')}.tmp`)
|
||||
let handle
|
||||
let temporaryCreated = false
|
||||
let published = false
|
||||
try {
|
||||
handle = await open(temporaryPath, 'wx', 0o600)
|
||||
temporaryCreated = true
|
||||
try {
|
||||
await handle.writeFile(contents)
|
||||
await handle.sync()
|
||||
} finally {
|
||||
await handle.close()
|
||||
handle = undefined
|
||||
}
|
||||
try {
|
||||
await link(temporaryPath, recordPath(rootDir, payload.id))
|
||||
} catch (error) {
|
||||
if (error.code === 'EEXIST') return 'duplicate'
|
||||
throw error
|
||||
}
|
||||
published = true
|
||||
return 'created'
|
||||
} finally {
|
||||
let cleanupError
|
||||
try {
|
||||
await handle?.close()
|
||||
} catch (error) {
|
||||
cleanupError = error
|
||||
}
|
||||
if (temporaryCreated) {
|
||||
try {
|
||||
await unlink(temporaryPath)
|
||||
} catch (error) {
|
||||
cleanupError ??= error
|
||||
}
|
||||
}
|
||||
if (published) {
|
||||
try {
|
||||
await syncDirectory(rootDir)
|
||||
} catch (error) {
|
||||
cleanupError ??= error
|
||||
}
|
||||
}
|
||||
if (cleanupError) throw cleanupError
|
||||
}
|
||||
}
|
||||
|
||||
async function readRecord(rootDir, id) {
|
||||
try {
|
||||
const raw = await readFile(recordPath(rootDir, id), 'utf8')
|
||||
const record = JSON.parse(raw)
|
||||
if (record?.version !== 1 || typeof record.blob !== 'string' || !isCanonicalBase64Url(record.deleteVerifier, 32, verifierPattern)) {
|
||||
throw new Error('Invalid stored record')
|
||||
}
|
||||
return record
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') return null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function securityHeaders(response) {
|
||||
response.setHeader('cache-control', 'no-store')
|
||||
response.setHeader('x-content-type-options', 'nosniff')
|
||||
response.setHeader('content-security-policy', "default-src 'none'")
|
||||
response.setHeader('referrer-policy', 'no-referrer')
|
||||
}
|
||||
|
||||
function sendJson(response, status, body) {
|
||||
response.statusCode = status
|
||||
response.setHeader('content-type', 'application/json; charset=utf-8')
|
||||
response.end(JSON.stringify(body))
|
||||
}
|
||||
|
||||
function sendNotFound(response) {
|
||||
response.statusCode = 404
|
||||
response.setHeader('content-type', 'application/json; charset=utf-8')
|
||||
response.end(notFoundBody)
|
||||
}
|
||||
|
||||
function authorizeOrigin(request, response, allowedOrigins) {
|
||||
const origin = request.headers.origin
|
||||
if (!origin) return true
|
||||
if (!allowedOrigins.has(origin)) {
|
||||
sendJson(response, 403, { error: 'origin_denied' })
|
||||
return false
|
||||
}
|
||||
response.setHeader('access-control-allow-origin', origin)
|
||||
response.setHeader('vary', 'Origin')
|
||||
return true
|
||||
}
|
||||
|
||||
function verifierMatches(secret, expectedVerifier) {
|
||||
const actual = createHash('sha256').update(Buffer.from(secret, 'base64url')).digest()
|
||||
const expected = Buffer.from(expectedVerifier, 'base64url')
|
||||
return actual.length === expected.length && timingSafeEqual(actual, expected)
|
||||
}
|
||||
|
||||
async function storageIsReady(rootDir) {
|
||||
const probePath = join(rootDir, `.${randomBytes(16).toString('hex')}.health`)
|
||||
try {
|
||||
await mkdir(rootDir, { recursive: true })
|
||||
const handle = await open(probePath, 'wx', 0o600)
|
||||
await handle.close()
|
||||
await unlink(probePath)
|
||||
return true
|
||||
} catch {
|
||||
await unlink(probePath).catch(() => {})
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function createReadinessProbe(rootDir, cacheMs) {
|
||||
let cached = null
|
||||
let cachedAt = 0
|
||||
let pending = null
|
||||
return async () => {
|
||||
const now = Date.now()
|
||||
if (cached !== null && now - cachedAt < cacheMs) return cached
|
||||
if (pending) return pending
|
||||
pending = storageIsReady(rootDir).then((value) => {
|
||||
cached = value
|
||||
cachedAt = Date.now()
|
||||
return value
|
||||
}).finally(() => { pending = null })
|
||||
return pending
|
||||
}
|
||||
}
|
||||
|
||||
function clientAddress(request, trustedProxy, trustedProxyAddresses) {
|
||||
if (trustedProxy && trustedProxyAddresses.has(request.socket.remoteAddress ?? '')) {
|
||||
const forwarded = request.headers['x-forwarded-for']
|
||||
const value = Array.isArray(forwarded) ? forwarded.at(-1) : forwarded
|
||||
const candidate = value?.split(',').at(-1)?.trim()
|
||||
if (candidate && isIP(candidate)) return candidate
|
||||
}
|
||||
return request.socket.remoteAddress ?? 'unknown'
|
||||
}
|
||||
|
||||
export function createBackupServer(options) {
|
||||
if (!options?.rootDir) throw new Error('rootDir is required')
|
||||
const allowedOrigins = new Set(options.allowedOrigins ?? [])
|
||||
const rateLimit = options.rateLimit ?? { max: 60, windowMs: 60_000 }
|
||||
const uploadRateLimit = options.uploadRateLimit ?? { max: 10, windowMs: 3_600_000 }
|
||||
const requestRateLimit = options.requestRateLimit ?? { max: 120, windowMs: 60_000 }
|
||||
const maxStorageBytes = options.maxStorageBytes ?? 10 * 1024 * 1024 * 1024
|
||||
const maxRecords = options.maxRecords ?? 10_000
|
||||
const bodyTimeoutMs = options.bodyTimeoutMs ?? 10_000
|
||||
const healthCacheMs = options.healthCacheMs ?? 5_000
|
||||
const maxConcurrentPerClient = options.maxConcurrentPerClient ?? 8
|
||||
const maxConcurrentTotal = options.maxConcurrentTotal ?? 64
|
||||
const trustedProxyAddresses = new Set(options.trustedProxyAddresses ?? [])
|
||||
if (!Number.isSafeInteger(rateLimit.max) || rateLimit.max < 1 || !Number.isSafeInteger(rateLimit.windowMs) || rateLimit.windowMs < 1) {
|
||||
throw new Error('Invalid rate limit')
|
||||
}
|
||||
if (!Number.isSafeInteger(uploadRateLimit.max) || uploadRateLimit.max < 1 || !Number.isSafeInteger(uploadRateLimit.windowMs) || uploadRateLimit.windowMs < 1) {
|
||||
throw new Error('Invalid upload rate limit')
|
||||
}
|
||||
if (!Number.isSafeInteger(requestRateLimit.max) || requestRateLimit.max < 1 || !Number.isSafeInteger(requestRateLimit.windowMs) || requestRateLimit.windowMs < 1) {
|
||||
throw new Error('Invalid request rate limit')
|
||||
}
|
||||
if (!Number.isSafeInteger(maxStorageBytes) || maxStorageBytes < 1) throw new Error('Invalid max storage size')
|
||||
if (!Number.isSafeInteger(maxRecords) || maxRecords < 1) throw new Error('Invalid max records')
|
||||
if (!Number.isSafeInteger(bodyTimeoutMs) || bodyTimeoutMs < 1) throw new Error('Invalid body timeout')
|
||||
if (!Number.isSafeInteger(healthCacheMs) || healthCacheMs < 1) throw new Error('Invalid health cache')
|
||||
if (!Number.isSafeInteger(maxConcurrentPerClient) || maxConcurrentPerClient < 1) throw new Error('Invalid per-client concurrency')
|
||||
if (!Number.isSafeInteger(maxConcurrentTotal) || maxConcurrentTotal < maxConcurrentPerClient) throw new Error('Invalid total concurrency')
|
||||
const consumeRateLimit = createRateLimiter(rateLimit)
|
||||
const consumeUploadRateLimit = createRateLimiter(uploadRateLimit)
|
||||
const consumeRequestRateLimit = createRateLimiter(requestRateLimit)
|
||||
const bodyConcurrency = createConcurrencyLimiter({ perClient: maxConcurrentPerClient, total: maxConcurrentTotal })
|
||||
const runStorageMutation = createMutationQueue()
|
||||
const checkReadiness = createReadinessProbe(options.rootDir, healthCacheMs)
|
||||
|
||||
const server = createServer(async (request, response) => {
|
||||
securityHeaders(response)
|
||||
try {
|
||||
const url = new URL(request.url, 'http://localhost')
|
||||
if (!authorizeOrigin(request, response, allowedOrigins)) return
|
||||
if (url.search) {
|
||||
sendNotFound(response)
|
||||
return
|
||||
}
|
||||
if (request.method === 'OPTIONS') {
|
||||
const requestedMethod = request.headers['access-control-request-method']
|
||||
if (!request.headers.origin || requestedMethod !== 'POST') {
|
||||
sendJson(response, 400, { error: 'invalid_preflight' })
|
||||
return
|
||||
}
|
||||
response.statusCode = 204
|
||||
response.setHeader('access-control-allow-methods', 'POST, OPTIONS')
|
||||
response.setHeader('access-control-allow-headers', 'content-type')
|
||||
response.setHeader('access-control-max-age', '600')
|
||||
response.end()
|
||||
return
|
||||
}
|
||||
if (request.method === 'GET' && url.pathname === '/health') {
|
||||
const ready = await checkReadiness()
|
||||
sendJson(response, ready ? 200 : 503, { status: ready ? 'ok' : 'unavailable' })
|
||||
return
|
||||
}
|
||||
const address = clientAddress(request, options.trustedProxy === true, trustedProxyAddresses)
|
||||
if (url.pathname === '/v1/backups/restore' || url.pathname === '/v1/backups/delete') {
|
||||
const retryAfter = consumeRateLimit(address)
|
||||
if (retryAfter !== null) {
|
||||
response.setHeader('retry-after', String(retryAfter))
|
||||
sendJson(response, 429, { error: 'rate_limited' })
|
||||
return
|
||||
}
|
||||
}
|
||||
if (request.method === 'POST' && ['/v1/backups', '/v1/backups/restore', '/v1/backups/delete'].includes(url.pathname)) {
|
||||
const requestRetryAfter = consumeRequestRateLimit(address)
|
||||
if (requestRetryAfter !== null) {
|
||||
response.setHeader('retry-after', String(requestRetryAfter))
|
||||
sendJson(response, 429, { error: 'rate_limited' })
|
||||
return
|
||||
}
|
||||
if (request.headers['content-type']?.split(';', 1)[0].trim().toLowerCase() !== 'application/json') {
|
||||
sendJson(response, 415, { error: 'unsupported_media_type' })
|
||||
return
|
||||
}
|
||||
if (!bodyConcurrency.enter(address)) {
|
||||
sendJson(response, 429, { error: 'too_many_requests' })
|
||||
return
|
||||
}
|
||||
try {
|
||||
const parsed = await readJsonBody(request, bodyTimeoutMs)
|
||||
if (parsed.error) {
|
||||
if (parsed.error === 413) response.setHeader('connection', 'close')
|
||||
const error = parsed.error === 413 ? 'payload_too_large' : parsed.error === 408 ? 'request_timeout' : 'invalid_request'
|
||||
sendJson(response, parsed.error, { error })
|
||||
return
|
||||
}
|
||||
if (url.pathname === '/v1/backups/restore') {
|
||||
const keys = parsed.value && typeof parsed.value === 'object' && !Array.isArray(parsed.value) ? Object.keys(parsed.value) : []
|
||||
if (keys.length !== 1 || keys[0] !== 'id' || !isCanonicalBase64Url(parsed.value.id, 16, idPattern)) {
|
||||
sendNotFound(response)
|
||||
return
|
||||
}
|
||||
const record = await readRecord(options.rootDir, parsed.value.id)
|
||||
if (!record) {
|
||||
sendNotFound(response)
|
||||
return
|
||||
}
|
||||
sendJson(response, 200, { blob: record.blob })
|
||||
return
|
||||
}
|
||||
if (url.pathname === '/v1/backups/delete') {
|
||||
const keys = parsed.value && typeof parsed.value === 'object' && !Array.isArray(parsed.value) ? Object.keys(parsed.value).sort() : []
|
||||
const valid = keys.join(',') === 'deleteSecret,id'
|
||||
&& isCanonicalBase64Url(parsed.value.id, 16, idPattern)
|
||||
&& isCanonicalBase64Url(parsed.value.deleteSecret, 32, verifierPattern)
|
||||
if (!valid) {
|
||||
sendNotFound(response)
|
||||
return
|
||||
}
|
||||
const deleted = await runStorageMutation(() => withStorageLock(options.rootDir, async () => {
|
||||
const record = await readRecord(options.rootDir, parsed.value.id)
|
||||
if (!record || !verifierMatches(parsed.value.deleteSecret, record.deleteVerifier)) return false
|
||||
try {
|
||||
await unlink(recordPath(options.rootDir, parsed.value.id))
|
||||
return true
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') return false
|
||||
throw error
|
||||
}
|
||||
}))
|
||||
if (!deleted) {
|
||||
sendNotFound(response)
|
||||
return
|
||||
}
|
||||
response.statusCode = 204
|
||||
response.end()
|
||||
return
|
||||
}
|
||||
if (typeof parsed.value?.blob === 'string' && blobPattern.test(parsed.value.blob) && Buffer.from(parsed.value.blob, 'base64url').length > maxBlobBytes) {
|
||||
sendJson(response, 413, { error: 'payload_too_large' })
|
||||
return
|
||||
}
|
||||
if (!isValidBackup(parsed.value)) {
|
||||
sendJson(response, 400, { error: 'invalid_request' })
|
||||
return
|
||||
}
|
||||
const uploadRetryAfter = consumeUploadRateLimit(address)
|
||||
if (uploadRetryAfter !== null) {
|
||||
response.setHeader('retry-after', String(uploadRetryAfter))
|
||||
sendJson(response, 429, { error: 'rate_limited' })
|
||||
return
|
||||
}
|
||||
const result = await runStorageMutation(() => withStorageLock(
|
||||
options.rootDir,
|
||||
() => createRecord(options.rootDir, parsed.value, maxStorageBytes, maxRecords)
|
||||
))
|
||||
if (result === 'duplicate') {
|
||||
sendJson(response, 409, { error: 'already_exists' })
|
||||
return
|
||||
}
|
||||
if (result === 'full') {
|
||||
sendJson(response, 507, { error: 'insufficient_storage' })
|
||||
return
|
||||
}
|
||||
sendJson(response, 201, { created: true })
|
||||
return
|
||||
} finally {
|
||||
bodyConcurrency.leave(address)
|
||||
}
|
||||
}
|
||||
sendNotFound(response)
|
||||
} catch {
|
||||
if (!response.headersSent) sendJson(response, 500, { error: 'internal_error' })
|
||||
else response.destroy()
|
||||
}
|
||||
})
|
||||
server.requestTimeout = bodyTimeoutMs + 5_000
|
||||
server.headersTimeout = Math.min(10_000, bodyTimeoutMs)
|
||||
server.keepAliveTimeout = 5_000
|
||||
server.maxRequestsPerSocket = 100
|
||||
return server
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { createHash, randomBytes } from 'node:crypto'
|
||||
import { mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { createConnection } from 'node:net'
|
||||
import test from 'node:test'
|
||||
import lockfile from 'proper-lockfile'
|
||||
import { createBackupServer } from '../src/server.mjs'
|
||||
|
||||
const allowedOrigin = 'https://uploader.24-music.de'
|
||||
|
||||
function fixture() {
|
||||
const deleteSecret = randomBytes(32).toString('base64url')
|
||||
return {
|
||||
deleteSecret,
|
||||
payload: {
|
||||
id: randomBytes(16).toString('base64url'),
|
||||
blob: randomBytes(96).toString('base64url'),
|
||||
deleteVerifier: createHash('sha256').update(Buffer.from(deleteSecret, 'base64url')).digest('base64url')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function startApi(options = {}) {
|
||||
const rootDir = await mkdtemp(join(tmpdir(), 'mhu-backup-api-'))
|
||||
const server = createBackupServer({
|
||||
rootDir,
|
||||
allowedOrigins: [allowedOrigin],
|
||||
rateLimit: { max: 100, windowMs: 60_000 },
|
||||
...options
|
||||
})
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(0, '127.0.0.1', resolve)
|
||||
})
|
||||
return {
|
||||
rootDir,
|
||||
server,
|
||||
baseUrl: `http://127.0.0.1:${server.address().port}`,
|
||||
async close() {
|
||||
await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()))
|
||||
await rm(rootDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function request(api, path, options = {}) {
|
||||
return fetch(`${api.baseUrl}${path}`, options)
|
||||
}
|
||||
|
||||
test('health reports readiness without storage details and sends security headers', async (t) => {
|
||||
const api = await startApi()
|
||||
t.after(() => api.close())
|
||||
|
||||
const response = await request(api, '/health')
|
||||
|
||||
assert.equal(response.status, 200)
|
||||
assert.deepEqual(await response.json(), { status: 'ok' })
|
||||
assert.equal(response.headers.get('cache-control'), 'no-store')
|
||||
assert.equal(response.headers.get('x-content-type-options'), 'nosniff')
|
||||
assert.equal(response.headers.get('content-security-policy'), "default-src 'none'")
|
||||
})
|
||||
|
||||
test('creates immutable ciphertext records and restores them after a restart', async (t) => {
|
||||
const api = await startApi()
|
||||
const backup = fixture()
|
||||
t.after(async () => {
|
||||
if (api.server.listening) await new Promise((resolve) => api.server.close(resolve))
|
||||
await rm(api.rootDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
const created = await request(api, '/v1/backups', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(backup.payload)
|
||||
})
|
||||
assert.equal(created.status, 201)
|
||||
await new Promise((resolve) => api.server.close(resolve))
|
||||
|
||||
api.server = createBackupServer({ rootDir: api.rootDir, allowedOrigins: [allowedOrigin] })
|
||||
await new Promise((resolve, reject) => {
|
||||
api.server.once('error', reject)
|
||||
api.server.listen(0, '127.0.0.1', resolve)
|
||||
})
|
||||
api.baseUrl = `http://127.0.0.1:${api.server.address().port}`
|
||||
|
||||
const restored = await request(api, '/v1/backups/restore', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ id: backup.payload.id })
|
||||
})
|
||||
assert.equal(restored.status, 200)
|
||||
assert.deepEqual(await restored.json(), { blob: backup.payload.blob })
|
||||
|
||||
const duplicate = await request(api, '/v1/backups', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ ...backup.payload, blob: randomBytes(96).toString('base64url') })
|
||||
})
|
||||
assert.equal(duplicate.status, 409)
|
||||
})
|
||||
|
||||
test('validates payload shape, content type and decoded blob size', async (t) => {
|
||||
const api = await startApi()
|
||||
t.after(() => api.close())
|
||||
const valid = fixture()
|
||||
const invalid = [
|
||||
{ ...valid.payload, id: 'short' },
|
||||
{ ...valid.payload, blob: 'not+base64url' },
|
||||
{ ...valid.payload, deleteVerifier: 'short' },
|
||||
{ id: valid.payload.id, blob: valid.payload.blob },
|
||||
{ ...valid.payload, extra: true }
|
||||
]
|
||||
|
||||
for (const body of invalid) {
|
||||
const response = await request(api, '/v1/backups', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
assert.equal(response.status, 400)
|
||||
}
|
||||
|
||||
const wrongType = await request(api, '/v1/backups', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'text/plain' },
|
||||
body: JSON.stringify(valid.payload)
|
||||
})
|
||||
assert.equal(wrongType.status, 415)
|
||||
|
||||
const oversized = fixture()
|
||||
oversized.payload.blob = randomBytes(262_145).toString('base64url')
|
||||
const tooLarge = await request(api, '/v1/backups', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(oversized.payload)
|
||||
})
|
||||
assert.equal(tooLarge.status, 413)
|
||||
})
|
||||
|
||||
test('deletes only with the matching client secret and returns constant not-found responses', async (t) => {
|
||||
const api = await startApi()
|
||||
t.after(() => api.close())
|
||||
const backup = fixture()
|
||||
await request(api, '/v1/backups', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(backup.payload)
|
||||
})
|
||||
|
||||
const missing = await request(api, '/v1/backups/restore', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ id: randomBytes(16).toString('base64url') })
|
||||
})
|
||||
const wrong = await request(api, '/v1/backups/delete', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ id: backup.payload.id, deleteSecret: randomBytes(32).toString('base64url') })
|
||||
})
|
||||
assert.equal(missing.status, 404)
|
||||
assert.equal(wrong.status, 404)
|
||||
assert.equal(await missing.text(), await wrong.text())
|
||||
|
||||
const deleted = await request(api, '/v1/backups/delete', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ id: backup.payload.id, deleteSecret: backup.deleteSecret })
|
||||
})
|
||||
assert.equal(deleted.status, 204)
|
||||
assert.equal((await readdir(api.rootDir)).length, 0)
|
||||
})
|
||||
|
||||
test('allows only configured origins and supports preflight', async (t) => {
|
||||
const api = await startApi()
|
||||
t.after(() => api.close())
|
||||
|
||||
const allowed = await request(api, '/health', { headers: { origin: allowedOrigin } })
|
||||
assert.equal(allowed.headers.get('access-control-allow-origin'), allowedOrigin)
|
||||
const denied = await request(api, '/health', { headers: { origin: 'https://attacker.example' } })
|
||||
assert.equal(denied.status, 403)
|
||||
const preflight = await request(api, '/v1/backups', {
|
||||
method: 'OPTIONS',
|
||||
headers: {
|
||||
origin: allowedOrigin,
|
||||
'access-control-request-method': 'POST',
|
||||
'access-control-request-headers': 'content-type'
|
||||
}
|
||||
})
|
||||
assert.equal(preflight.status, 204)
|
||||
})
|
||||
|
||||
test('separately rate limits uploads while restores and health remain available', async (t) => {
|
||||
const api = await startApi({ uploadRateLimit: { max: 1, windowMs: 60_000 } })
|
||||
t.after(() => api.close())
|
||||
const first = fixture()
|
||||
const second = fixture()
|
||||
const create = (backup) => request(api, '/v1/backups', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(backup.payload)
|
||||
})
|
||||
|
||||
assert.equal((await create(first)).status, 201)
|
||||
assert.equal((await create(second)).status, 429)
|
||||
const restored = await request(api, '/v1/backups/restore', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ id: first.payload.id })
|
||||
})
|
||||
assert.equal(restored.status, 200)
|
||||
assert.equal((await request(api, '/health')).status, 200)
|
||||
})
|
||||
|
||||
test('rate limits invalid request bodies before validation', async (t) => {
|
||||
const api = await startApi({ requestRateLimit: { max: 1, windowMs: 60_000 } })
|
||||
t.after(() => api.close())
|
||||
const sendInvalid = () => request(api, '/v1/backups', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ invalid: 'x'.repeat(300_000) })
|
||||
})
|
||||
|
||||
assert.equal((await sendInvalid()).status, 400)
|
||||
assert.equal((await sendInvalid()).status, 429)
|
||||
})
|
||||
|
||||
test('ignores forwarded client addresses from untrusted socket peers', async (t) => {
|
||||
const api = await startApi({
|
||||
trustedProxy: true,
|
||||
trustedProxyAddresses: [],
|
||||
rateLimit: { max: 1, windowMs: 60_000 }
|
||||
})
|
||||
t.after(() => api.close())
|
||||
const body = JSON.stringify({ id: randomBytes(16).toString('base64url') })
|
||||
const restore = (forwarded) => request(api, '/v1/backups/restore', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', 'x-forwarded-for': forwarded },
|
||||
body
|
||||
})
|
||||
|
||||
assert.equal((await restore('198.51.100.1')).status, 404)
|
||||
assert.equal((await restore('198.51.100.2')).status, 429)
|
||||
const created = await request(api, '/v1/backups', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', 'x-forwarded-for': '198.51.100.3' },
|
||||
body: JSON.stringify(fixture().payload)
|
||||
})
|
||||
assert.equal(created.status, 201)
|
||||
})
|
||||
|
||||
test('uses the last forwarded address from an explicitly trusted proxy', async (t) => {
|
||||
const api = await startApi({
|
||||
trustedProxy: true,
|
||||
trustedProxyAddresses: ['127.0.0.1'],
|
||||
rateLimit: { max: 1, windowMs: 60_000 }
|
||||
})
|
||||
t.after(() => api.close())
|
||||
const body = JSON.stringify({ id: randomBytes(16).toString('base64url') })
|
||||
const restore = (forwarded) => request(api, '/v1/backups/restore', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', 'x-forwarded-for': forwarded },
|
||||
body
|
||||
})
|
||||
|
||||
assert.equal((await restore('198.51.100.1, 203.0.113.9')).status, 404)
|
||||
assert.equal((await restore('198.51.100.2, 203.0.113.9')).status, 429)
|
||||
})
|
||||
|
||||
test('keeps concurrency leases until storage mutations finish', async (t) => {
|
||||
const api = await startApi({ maxConcurrentPerClient: 1, maxConcurrentTotal: 1 })
|
||||
t.after(() => api.close())
|
||||
const release = await lockfile.lock(api.rootDir, {
|
||||
realpath: false,
|
||||
lockfilePath: join(api.rootDir, '.storage.lock')
|
||||
})
|
||||
const create = (backup) => request(api, '/v1/backups', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(backup.payload)
|
||||
})
|
||||
const first = create(fixture())
|
||||
await new Promise((resolve) => setTimeout(resolve, 40))
|
||||
const second = create(fixture())
|
||||
let secondStatus
|
||||
try {
|
||||
secondStatus = await Promise.race([
|
||||
second.then((response) => response.status),
|
||||
new Promise((resolve) => setTimeout(() => resolve('pending'), 100))
|
||||
])
|
||||
} finally {
|
||||
await release()
|
||||
}
|
||||
assert.equal((await first).status, 201)
|
||||
if (secondStatus === 'pending') await second
|
||||
assert.equal(secondStatus, 429)
|
||||
})
|
||||
|
||||
test('times out incomplete request bodies', async (t) => {
|
||||
const api = await startApi({ bodyTimeoutMs: 30 })
|
||||
t.after(() => api.close())
|
||||
const response = await new Promise((resolve, reject) => {
|
||||
const socket = createConnection(new URL(api.baseUrl).port, '127.0.0.1')
|
||||
let data = ''
|
||||
socket.setEncoding('utf8')
|
||||
socket.once('error', reject)
|
||||
socket.on('data', (chunk) => { data += chunk })
|
||||
socket.on('end', () => resolve(data))
|
||||
socket.once('connect', () => {
|
||||
socket.write('POST /v1/backups HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: 10\r\nConnection: close\r\n\r\n{')
|
||||
})
|
||||
})
|
||||
|
||||
assert.match(response, /^HTTP\/1\.1 408 /)
|
||||
assert.match(response, /request_timeout/)
|
||||
})
|
||||
|
||||
test('caches health readiness instead of writing on every request', async (t) => {
|
||||
const api = await startApi({ healthCacheMs: 60_000 })
|
||||
t.after(() => api.close())
|
||||
|
||||
assert.equal((await request(api, '/health')).status, 200)
|
||||
await rm(api.rootDir, { recursive: true, force: true })
|
||||
assert.equal((await request(api, '/health')).status, 200)
|
||||
await assert.rejects(stat(api.rootDir), { code: 'ENOENT' })
|
||||
})
|
||||
|
||||
test('cleans orphaned temporary files and enforces a record limit', async (t) => {
|
||||
const api = await startApi({ maxRecords: 1 })
|
||||
t.after(() => api.close())
|
||||
const orphan = join(api.rootDir, `.${randomBytes(16).toString('hex')}.tmp`)
|
||||
await writeFile(orphan, 'orphan')
|
||||
const create = (backup) => request(api, '/v1/backups', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(backup.payload)
|
||||
})
|
||||
|
||||
assert.equal((await create(fixture())).status, 201)
|
||||
assert.equal((await create(fixture())).status, 507)
|
||||
const files = await readdir(api.rootDir)
|
||||
assert.equal(files.some((name) => name.endsWith('.tmp')), false)
|
||||
assert.equal(files.filter((name) => name.endsWith('.json')).length, 1)
|
||||
})
|
||||
|
||||
test('enforces atomic storage capacity without blocking existing restores', async (t) => {
|
||||
const api = await startApi({ maxStorageBytes: 420 })
|
||||
const secondServer = createBackupServer({ rootDir: api.rootDir, allowedOrigins: [allowedOrigin], maxStorageBytes: 420 })
|
||||
await new Promise((resolve, reject) => {
|
||||
secondServer.once('error', reject)
|
||||
secondServer.listen(0, '127.0.0.1', resolve)
|
||||
})
|
||||
t.after(() => api.close())
|
||||
t.after(() => new Promise((resolve) => secondServer.close(resolve)))
|
||||
const first = fixture()
|
||||
const second = fixture()
|
||||
const create = (backup, baseUrl = api.baseUrl) => fetch(`${baseUrl}/v1/backups`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(backup.payload)
|
||||
})
|
||||
|
||||
const results = await Promise.all([create(first), create(second, `http://127.0.0.1:${secondServer.address().port}`)])
|
||||
|
||||
assert.deepEqual(results.map((response) => response.status).sort(), [201, 507])
|
||||
assert.equal((await readdir(api.rootDir)).length, 1)
|
||||
})
|
||||
|
||||
test('never accepts record ids in URLs and stores no client delete secret', async (t) => {
|
||||
const api = await startApi()
|
||||
t.after(() => api.close())
|
||||
const backup = fixture()
|
||||
|
||||
assert.equal((await request(api, `/v1/backups/${backup.payload.id}`)).status, 404)
|
||||
assert.equal((await request(api, `/v1/backups?backup=${backup.payload.id}`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(backup.payload)
|
||||
})).status, 404)
|
||||
|
||||
await request(api, '/v1/backups', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(backup.payload)
|
||||
})
|
||||
const files = await readdir(api.rootDir)
|
||||
const stored = await readFile(join(api.rootDir, files[0]), 'utf8')
|
||||
assert.equal(stored.includes(backup.deleteSecret), false)
|
||||
})
|
||||
@@ -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,30 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const {
|
||||
getAccountGroupStatus,
|
||||
getAccountStatusPresentation
|
||||
} = require('../renderer/account-status');
|
||||
|
||||
test('mixed account results use warning group status', () => {
|
||||
assert.equal(getAccountGroupStatus({ total: 3, disabled: 0, ok: 2, error: 1, checking: 0, unchecked: 0 }), 'warn');
|
||||
});
|
||||
|
||||
test('group is red only when every active account has an error', () => {
|
||||
assert.equal(getAccountGroupStatus({ total: 3, disabled: 0, ok: 0, error: 3, checking: 0, unchecked: 0 }), 'error');
|
||||
});
|
||||
|
||||
test('group is green when every active account is ready', () => {
|
||||
assert.equal(getAccountGroupStatus({ total: 3, disabled: 0, ok: 3, error: 0, checking: 0, unchecked: 0 }), 'ok');
|
||||
});
|
||||
|
||||
test('warning-only accounts keep the group orange', () => {
|
||||
assert.equal(getAccountGroupStatus({ total: 2, disabled: 0, ok: 0, warn: 2, error: 0, checking: 0, unchecked: 0 }), 'warn');
|
||||
});
|
||||
|
||||
test('OTP-required account exposes warning presentation', () => {
|
||||
assert.deepEqual(getAccountStatusPresentation('otp_required'), {
|
||||
statusClass: 'warn',
|
||||
label: 'OTP erforderlich',
|
||||
requiresOtp: true
|
||||
});
|
||||
});
|
||||
@@ -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,770 @@
|
||||
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('explicit user-data-dir isolates development config writes from the project', async () => {
|
||||
const isolatedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cfg-user-data-'));
|
||||
const projectConfigPath = path.join(__dirname, '..', 'electron-config.json');
|
||||
const projectConfigExisted = fs.existsSync(projectConfigPath);
|
||||
const projectConfigBefore = projectConfigExisted ? fs.readFileSync(projectConfigPath) : null;
|
||||
const explicitStore = new ConfigStore({
|
||||
isPackaged: false,
|
||||
commandLine: {
|
||||
hasSwitch: (name) => name === 'user-data-dir'
|
||||
},
|
||||
getPath: (name) => {
|
||||
if (name === 'userData') return isolatedDir;
|
||||
if (name === 'exe') return path.join(isolatedDir, 'Multi-Hoster-Upload.exe');
|
||||
throw new Error(`Unexpected app path: ${name}`);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
assert.equal(explicitStore.filePath, path.join(isolatedDir, 'electron-config.json'));
|
||||
await explicitStore.save({ globalSettings: { alwaysOnTop: true } });
|
||||
assert.equal(fs.existsSync(path.join(isolatedDir, 'electron-config.json')), true);
|
||||
assert.equal(fs.existsSync(projectConfigPath), projectConfigExisted);
|
||||
if (projectConfigBefore) {
|
||||
assert.equal(fs.readFileSync(projectConfigPath).equals(projectConfigBefore), true);
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(isolatedDir, { 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('drainWrites waits for config and history writes appended while draining', async () => {
|
||||
assert.equal(typeof store.drainWrites, 'function');
|
||||
await store.save({ globalSettings: { alwaysOnTop: false } });
|
||||
store._historyMigrated = true;
|
||||
fs.writeFileSync(store.historyPath, '[]', 'utf-8');
|
||||
|
||||
const originalAtomicWrite = store._atomicWrite.bind(store);
|
||||
const originalHistoryWrite = store._writeHistoryFileAtomic.bind(store);
|
||||
const configReleases = [];
|
||||
const historyReleases = [];
|
||||
const block = (releases, operation) => new Promise((resolve, reject) => {
|
||||
releases.push(() => Promise.resolve().then(operation).then(resolve, reject));
|
||||
});
|
||||
store._atomicWrite = (data) => block(configReleases, () => originalAtomicWrite(data));
|
||||
store._writeHistoryFileAtomic = (history) => block(historyReleases, () => originalHistoryWrite(history));
|
||||
|
||||
const configWrites = store.save({ globalSettings: { alwaysOnTop: true } })
|
||||
.then(() => store.save({ hosterSettings: { 'byse.sx': { retries: 8 } } }));
|
||||
const historyWrites = store.appendHistory({ id: 'first', files: [] })
|
||||
.then(() => store.appendHistory({ id: 'second', files: [] }));
|
||||
|
||||
while (configReleases.length < 1 || historyReleases.length < 1) await new Promise(resolve => setImmediate(resolve));
|
||||
let drained = false;
|
||||
const draining = store.drainWrites().then(() => { drained = true; });
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assert.equal(drained, false);
|
||||
|
||||
configReleases.shift()();
|
||||
historyReleases.shift()();
|
||||
while (configReleases.length < 1 || historyReleases.length < 1) await new Promise(resolve => setImmediate(resolve));
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assert.equal(drained, false);
|
||||
|
||||
configReleases.shift()();
|
||||
historyReleases.shift()();
|
||||
await Promise.all([configWrites, historyWrites, draining]);
|
||||
assert.equal(store.load().globalSettings.alwaysOnTop, true);
|
||||
assert.equal(store.load().hosterSettings['byse.sx'].retries, 8);
|
||||
assert.deepEqual(store.loadHistory().map(entry => entry.id), ['first', 'second']);
|
||||
});
|
||||
|
||||
it('drainWrites ignores caller-handled failures but propagates a write failing during the drain', async () => {
|
||||
const originalAtomicWrite = store._atomicWrite.bind(store);
|
||||
store._atomicWrite = () => Promise.reject(new Error('config write failed'));
|
||||
await assert.rejects(store.save({ globalSettings: { alwaysOnTop: true } }), /config write failed/);
|
||||
await store.drainWrites();
|
||||
|
||||
store._atomicWrite = originalAtomicWrite;
|
||||
store._historyMigrated = true;
|
||||
fs.writeFileSync(store.historyPath, '[]', 'utf-8');
|
||||
let rejectHistoryWrite;
|
||||
store._writeHistoryFileAtomic = () => new Promise((_resolve, reject) => { rejectHistoryWrite = reject; });
|
||||
const pendingWrite = store.appendHistory({ id: 'failed', files: [] });
|
||||
pendingWrite.catch(() => {});
|
||||
while (!rejectHistoryWrite) await new Promise(resolve => setImmediate(resolve));
|
||||
const draining = store.drainWrites();
|
||||
rejectHistoryWrite(new Error('history write failed'));
|
||||
await assert.rejects(draining, /history write failed/);
|
||||
await assert.rejects(pendingWrite, /history write failed/);
|
||||
await store.drainWrites();
|
||||
});
|
||||
|
||||
it('surfaces a failed queued write and keeps later writes usable', async () => {
|
||||
const originalAtomicWrite = store._atomicWrite.bind(store);
|
||||
let failNextWrite = true;
|
||||
store._atomicWrite = (data) => {
|
||||
if (failNextWrite) {
|
||||
failNextWrite = false;
|
||||
return Promise.reject(new Error('write failed'));
|
||||
}
|
||||
return originalAtomicWrite(data);
|
||||
};
|
||||
|
||||
await assert.rejects(store.appendHistory({ id: 'failed', files: [] }), /write failed/);
|
||||
await store.appendHistory({ id: 'saved', files: [] });
|
||||
assert.deepEqual(store.loadHistory().map(entry => entry.id), ['saved']);
|
||||
});
|
||||
|
||||
it('serializes a complete settings replacement with pending saves', async () => {
|
||||
const originalAtomicWrite = store._atomicWrite.bind(store);
|
||||
let activeWrites = 0;
|
||||
let maximumActiveWrites = 0;
|
||||
store._atomicWrite = async (data) => {
|
||||
activeWrites += 1;
|
||||
maximumActiveWrites = Math.max(maximumActiveWrites, activeWrites);
|
||||
await new Promise((resolve) => setTimeout(resolve, 15));
|
||||
try {
|
||||
await originalAtomicWrite(data);
|
||||
} finally {
|
||||
activeWrites -= 1;
|
||||
}
|
||||
};
|
||||
|
||||
const save = store.save({ globalSettings: { alwaysOnTop: true, pendingQueue: { savedAt: 123, queueJobs: [{ id: 'local' }] } } });
|
||||
const replace = store.replaceSettings({
|
||||
hosters: { 'byse.sx': [{ id: 'imported', enabled: true, authType: 'api', apiKey: 'imported-key' }] },
|
||||
hosterSettings: { 'byse.sx': { retries: 9 } },
|
||||
globalSettings: { alwaysOnTop: false, pendingQueue: null },
|
||||
history: [],
|
||||
rotationCursors: {}
|
||||
});
|
||||
|
||||
await Promise.all([save, replace]);
|
||||
const config = store.load();
|
||||
assert.equal(maximumActiveWrites, 1);
|
||||
assert.equal(config.hosters['byse.sx'][0].apiKey, 'imported-key');
|
||||
assert.equal(config.hosterSettings['byse.sx'].retries, 9);
|
||||
assert.equal(config.globalSettings.alwaysOnTop, false);
|
||||
assert.deepEqual(config.globalSettings.pendingQueue, { savedAt: 123, queueJobs: [{ id: 'local' }] });
|
||||
assert.deepEqual(config.rotationCursors, {});
|
||||
});
|
||||
|
||||
it('patches the pending queue after an import without reverting imported settings', async () => {
|
||||
await store.save({
|
||||
globalSettings: {
|
||||
alwaysOnTop: true,
|
||||
webhookUrl: 'https://before.invalid',
|
||||
pendingQueue: { savedAt: 1, queueJobs: [{ id: 'before' }] }
|
||||
}
|
||||
});
|
||||
|
||||
const replace = store.replaceSettings({
|
||||
hosters: { 'byse.sx': [{ id: 'imported', enabled: true, authType: 'api', apiKey: 'imported-key' }] },
|
||||
hosterSettings: { 'byse.sx': { retries: 9 } },
|
||||
globalSettings: {
|
||||
alwaysOnTop: false,
|
||||
webhookUrl: 'https://imported.invalid',
|
||||
pendingQueue: null
|
||||
},
|
||||
history: [],
|
||||
rotationCursors: {}
|
||||
});
|
||||
const pendingQueue = {
|
||||
savedAt: 2,
|
||||
queueJobs: [{ id: 'live', status: 'done' }]
|
||||
};
|
||||
const saveQueue = store.savePendingQueue(pendingQueue);
|
||||
|
||||
await Promise.all([replace, saveQueue]);
|
||||
const config = store.load();
|
||||
assert.equal(config.hosters['byse.sx'][0].id, 'imported');
|
||||
assert.equal(config.globalSettings.alwaysOnTop, false);
|
||||
assert.equal(config.globalSettings.webhookUrl, 'https://imported.invalid');
|
||||
assert.deepEqual(config.globalSettings.pendingQueue, pendingQueue);
|
||||
});
|
||||
|
||||
it('preserves main-owned global state when saving a renderer snapshot', async () => {
|
||||
await store.save({
|
||||
globalSettings: {
|
||||
alwaysOnTop: false,
|
||||
pendingQueue: { savedAt: 3, queueJobs: [{ id: 'local' }] },
|
||||
diagnostics: { enabled: true, port: 7777 },
|
||||
historyRetention: '7d',
|
||||
remote: { enabled: true, port: 9100, token: 'main-token', allowInput: true }
|
||||
}
|
||||
});
|
||||
|
||||
await store.saveRendererGlobalSettings({
|
||||
alwaysOnTop: true,
|
||||
pendingQueue: null,
|
||||
diagnostics: { enabled: false, port: 1 },
|
||||
historyRetention: 'all',
|
||||
remote: { enabled: true, port: 9200, token: '', allowInput: false }
|
||||
});
|
||||
|
||||
const config = store.load();
|
||||
assert.equal(config.globalSettings.alwaysOnTop, true);
|
||||
assert.deepEqual(config.globalSettings.pendingQueue, { savedAt: 3, queueJobs: [{ id: 'local' }] });
|
||||
assert.equal(config.globalSettings.diagnostics.enabled, true);
|
||||
assert.equal(config.globalSettings.diagnostics.port, 7777);
|
||||
assert.equal(config.globalSettings.historyRetention, '7d');
|
||||
assert.deepEqual(config.globalSettings.remote, { enabled: true, port: 9200, token: 'main-token', allowInput: false });
|
||||
});
|
||||
|
||||
it('merges remote settings in the write queue and returns the canonical token', async () => {
|
||||
await store.save({
|
||||
globalSettings: {
|
||||
remote: { enabled: false, port: 9100, token: 'canonical-token', allowInput: true }
|
||||
}
|
||||
});
|
||||
|
||||
const rendererSave = store.saveRendererGlobalSettings({
|
||||
alwaysOnTop: true,
|
||||
remote: { enabled: false, port: 9200, token: '', allowInput: false }
|
||||
});
|
||||
const remoteSave = store.saveRemoteSettings(
|
||||
{ enabled: true, port: 9300, token: '', allowInput: false },
|
||||
() => 'generated-token'
|
||||
);
|
||||
|
||||
const [, canonical] = await Promise.all([rendererSave, remoteSave]);
|
||||
assert.deepEqual(canonical, { enabled: true, port: 9300, token: 'canonical-token', allowInput: false });
|
||||
assert.deepEqual(store.load().globalSettings.remote, canonical);
|
||||
});
|
||||
|
||||
it('rejects ordinary writes while quiesced and permits only the final pending queue snapshot', async () => {
|
||||
store.setWritesQuiesced(true);
|
||||
|
||||
await assert.rejects(store.save({ globalSettings: { alwaysOnTop: true } }), /beendet/);
|
||||
await assert.rejects(store.appendHistory({ id: 'late-history', files: [] }), /beendet/);
|
||||
await store.savePendingQueue({ savedAt: 4, queueJobs: [{ id: 'final' }] }, { allowDuringQuiesce: true });
|
||||
|
||||
assert.equal(store.load().globalSettings.alwaysOnTop, false);
|
||||
assert.deepEqual(store.load().globalSettings.pendingQueue, { savedAt: 4, queueJobs: [{ id: 'final' }] });
|
||||
store.setWritesQuiesced(false);
|
||||
await store.save({ globalSettings: { ...store.load().globalSettings, alwaysOnTop: true } });
|
||||
assert.equal(store.load().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('migrated prune refuses to overwrite a corrupted history file', async () => {
|
||||
writeConfigWithHistory(12);
|
||||
s._migrateHistory();
|
||||
fs.writeFileSync(s.historyPath, '{broken-history', 'utf-8');
|
||||
const historyBefore = fs.readFileSync(s.historyPath, 'utf-8');
|
||||
|
||||
await assert.rejects(s.pruneHistory('7d', { dryRun: false }), /Verlaufsdatei ist beschädigt/);
|
||||
|
||||
assert.equal(fs.readFileSync(s.historyPath, 'utf-8'), historyBefore);
|
||||
assert.equal(JSON.parse(fs.readFileSync(s.filePath, 'utf-8')).globalSettings.historyRetention, 'all');
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it('migrated prune leaves history unchanged when the retention commit fails', async () => {
|
||||
writeConfigWithHistory(12);
|
||||
s._migrateHistory();
|
||||
const historyBefore = fs.readFileSync(s.historyPath, 'utf-8');
|
||||
s._atomicWrite = () => Promise.reject(new Error('retention commit failed'));
|
||||
|
||||
await assert.rejects(s.pruneHistory('7d', { dryRun: false }), /retention commit failed/);
|
||||
|
||||
assert.equal(fs.readFileSync(s.historyPath, 'utf-8'), historyBefore);
|
||||
assert.equal(JSON.parse(fs.readFileSync(s.filePath, 'utf-8')).globalSettings.historyRetention, 'all');
|
||||
});
|
||||
|
||||
it('migrated prune restores the previous retention when the history write fails', async () => {
|
||||
writeConfigWithHistory(12);
|
||||
s._migrateHistory();
|
||||
const historyBefore = fs.readFileSync(s.historyPath, 'utf-8');
|
||||
const originalAtomicWrite = s._atomicWrite.bind(s);
|
||||
const retentionWrites = [];
|
||||
s._atomicWrite = (data) => {
|
||||
retentionWrites.push(JSON.parse(data).globalSettings.historyRetention);
|
||||
return originalAtomicWrite(data);
|
||||
};
|
||||
s._writeHistoryFileAtomic = () => Promise.reject(new Error('history prune failed'));
|
||||
|
||||
await assert.rejects(s.pruneHistory('7d', { dryRun: false }), /history prune failed/);
|
||||
|
||||
assert.deepEqual(retentionWrites, ['7d', 'all']);
|
||||
assert.equal(JSON.parse(fs.readFileSync(s.filePath, 'utf-8')).globalSettings.historyRetention, 'all');
|
||||
assert.equal(fs.readFileSync(s.historyPath, 'utf-8'), historyBefore);
|
||||
});
|
||||
|
||||
it('migrated prune serializes surrounding global settings saves across an internal rollback', async () => {
|
||||
writeConfigWithHistory(12);
|
||||
s._migrateHistory();
|
||||
const historyBefore = fs.readFileSync(s.historyPath, 'utf-8');
|
||||
const originalAtomicWrite = s._atomicWrite.bind(s);
|
||||
const configWrites = [];
|
||||
let priorWriteStarted = false;
|
||||
let releasePriorWrite;
|
||||
s._atomicWrite = (data) => {
|
||||
const settings = JSON.parse(data).globalSettings;
|
||||
configWrites.push({ webhookUrl: settings.webhookUrl || '', alwaysOnTop: !!settings.alwaysOnTop, historyRetention: settings.historyRetention });
|
||||
if (!priorWriteStarted && settings.webhookUrl === 'https://prune-race.invalid/prior') {
|
||||
priorWriteStarted = true;
|
||||
return new Promise((resolve, reject) => {
|
||||
releasePriorWrite = () => originalAtomicWrite(data).then(resolve, reject);
|
||||
});
|
||||
}
|
||||
return originalAtomicWrite(data);
|
||||
};
|
||||
|
||||
let rejectHistoryWrite;
|
||||
s._writeHistoryFileAtomic = () => new Promise((_resolve, reject) => { rejectHistoryWrite = reject; });
|
||||
const priorSettings = { ...s.load().globalSettings, alwaysOnTop: true, webhookUrl: 'https://prune-race.invalid/prior', historyRetention: 'all' };
|
||||
const priorSave = s.save({ globalSettings: priorSettings });
|
||||
while (!releasePriorWrite) await new Promise(resolve => setImmediate(resolve));
|
||||
|
||||
let pruneError = null;
|
||||
const pruning = s.pruneHistory('7d', { dryRun: false }).catch(error => { pruneError = error; });
|
||||
releasePriorWrite();
|
||||
await priorSave;
|
||||
while (!rejectHistoryWrite) await new Promise(resolve => setImmediate(resolve));
|
||||
|
||||
const laterSettings = { ...priorSettings, alwaysOnTop: false, webhookUrl: 'https://prune-race.invalid/later', historyRetention: 'all' };
|
||||
const laterSave = s.save({ globalSettings: laterSettings });
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
const laterCommittedBeforeRollback = configWrites.some(write => write.webhookUrl === 'https://prune-race.invalid/later');
|
||||
rejectHistoryWrite(new Error('history prune race failed'));
|
||||
await pruning;
|
||||
await laterSave;
|
||||
|
||||
assert.match(pruneError?.message || '', /history prune race failed/);
|
||||
assert.equal(laterCommittedBeforeRollback, false);
|
||||
assert.deepEqual(configWrites.map(write => `${write.webhookUrl}:${write.historyRetention}`), [
|
||||
'https://prune-race.invalid/prior:all',
|
||||
'https://prune-race.invalid/prior:7d',
|
||||
'https://prune-race.invalid/prior:all',
|
||||
'https://prune-race.invalid/later:all'
|
||||
]);
|
||||
const finalSettings = JSON.parse(fs.readFileSync(s.filePath, 'utf-8')).globalSettings;
|
||||
assert.equal(finalSettings.webhookUrl, 'https://prune-race.invalid/later');
|
||||
assert.equal(finalSettings.alwaysOnTop, false);
|
||||
assert.equal(finalSettings.historyRetention, 'all');
|
||||
assert.equal(fs.readFileSync(s.historyPath, 'utf-8'), historyBefore);
|
||||
});
|
||||
|
||||
it('renderer snapshots cannot revert retention after a successful prune', async () => {
|
||||
writeConfigWithHistory(12);
|
||||
s._migrateHistory();
|
||||
const originalHistoryWrite = s._writeHistoryFileAtomic.bind(s);
|
||||
let releaseHistoryWrite;
|
||||
s._writeHistoryFileAtomic = (history) => new Promise((resolve, reject) => {
|
||||
releaseHistoryWrite = () => originalHistoryWrite(history).then(resolve, reject);
|
||||
});
|
||||
|
||||
const pruning = s.pruneHistory('7d', { dryRun: false });
|
||||
while (!releaseHistoryWrite) await new Promise(resolve => setImmediate(resolve));
|
||||
const staleRendererSave = s.saveRendererGlobalSettings({
|
||||
...s.load().globalSettings,
|
||||
alwaysOnTop: true,
|
||||
historyRetention: 'all'
|
||||
});
|
||||
releaseHistoryWrite();
|
||||
await Promise.all([pruning, staleRendererSave]);
|
||||
|
||||
assert.equal(s.load().globalSettings.historyRetention, '7d');
|
||||
assert.equal(s.load().globalSettings.alwaysOnTop, true);
|
||||
});
|
||||
});
|
||||
@@ -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,162 @@
|
||||
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');
|
||||
|
||||
function makeFixture() {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-diag-'));
|
||||
const fixtureAlpha = ['SECRET', 'TOKEN', '123456'].join('');
|
||||
const fixtureBeta = ['abcdef', '123456'].join('');
|
||||
const fixtureGamma = ['LIVE', 'KEY', '99999'].join('');
|
||||
const fixtureDelta = ['HUNTER', '2', 'SECRET'].join('');
|
||||
const fixtureEpsilon = ['BYSE', 'KEY', '1234567'].join('');
|
||||
const fixtureZeta = ['WBHOOK', 'SECRET', 'TOKEN'].join('');
|
||||
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 ${fixtureAlpha} inline\nAuthorization: Bearer ${fixtureBeta}\n`);
|
||||
fs.writeFileSync(paths.doodstreamDebug, `api_key=${fixtureGamma} sess=abc\n`);
|
||||
fs.writeFileSync(paths.crashLog, 'CRASH at 12:00\n');
|
||||
const config = {
|
||||
hosters: { 'voe.sx': [{ id: 'a1', username: 'u', password: fixtureDelta }], 'byse.sx': [{ id: 'b1', apiKey: fixtureEpsilon }] },
|
||||
hosterSettings: {},
|
||||
globalSettings: {
|
||||
webhookUrl: ['https://discord.com/api/webhooks/', '12345', fixtureZeta].join('/'),
|
||||
diagnostics: { enabled: true, port: 9110, token: fixtureAlpha, 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, fixtureAlpha, fixtureDelta, fixtureEpsilon, fixtureZeta };
|
||||
}
|
||||
|
||||
test('getConfigRedacted strips password/apiKey/token/webhookUrl and value-scrubs the token mid-string', () => {
|
||||
const { collectors, fixtureAlpha, fixtureDelta, fixtureEpsilon, fixtureZeta } = makeFixture();
|
||||
const out = collectors.getConfigRedacted({ section: 'all' });
|
||||
const json = JSON.stringify(out);
|
||||
assert.ok(!json.includes(fixtureDelta), 'password must be redacted');
|
||||
assert.ok(!json.includes(fixtureEpsilon), 'apiKey must be redacted');
|
||||
assert.ok(!json.includes(fixtureAlpha), 'diag token must be redacted');
|
||||
assert.ok(!json.includes(fixtureZeta), '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('SECRETTOKEN123456'), 'value-scrub removes the live diag token from logs');
|
||||
assert.ok(!/Bearer abcdef123456/.test(dbg.content), '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 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=OPAQUE_NONconfig_TOKEN_9988' }
|
||||
] } },
|
||||
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('OPAQUE_NONconfig_TOKEN_9988'), '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('HUNTER2SECRET') && !json.includes('SECRETTOKEN123456') && !json.includes('WBHOOKSECRETTOKEN'), '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,214 @@
|
||||
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 html = `
|
||||
<input type="text" name="csrf" value="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa">
|
||||
<div class="panel">API Key <input readonly value="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"></div>
|
||||
`;
|
||||
const cands = up._extractApiKeyCandidates(html);
|
||||
// The token whose preceding context mentions "API" must rank first.
|
||||
assert.equal(cands[0], 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb');
|
||||
assert.ok(cands.includes('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'));
|
||||
});
|
||||
|
||||
test('_extractApiKeyCandidates handles textarea + api_key: "x" shapes and empty input', () => {
|
||||
const up = new DoodstreamUploader();
|
||||
assert.deepEqual(up._extractApiKeyCandidates(''), []);
|
||||
const ta = up._extractApiKeyCandidates('<textarea id="k">cccccccccccccccccccccccccccccccc</textarea>');
|
||||
assert.ok(ta.includes('cccccccccccccccccccccccccccccccc'));
|
||||
const js = up._extractApiKeyCandidates('var x = {"api_key":"dddddddddddddddddddddddddddddddd"};');
|
||||
assert.ok(js.includes('dddddddddddddddddddddddddddddddd'));
|
||||
});
|
||||
|
||||
test('deriveApiKey returns the candidate that validates against the API', async () => {
|
||||
const up = new DoodstreamUploader();
|
||||
up._fetch = async () => ({ text: async () => '<div>API Key <input value="REALKEY1234567890abcdefGHIJK"></div><input value="notthekey000000000000000000">' });
|
||||
up._validateApiKey = async (key) => key === 'REALKEY1234567890abcdefGHIJK';
|
||||
const key = await up.deriveApiKey();
|
||||
assert.equal(key, 'REALKEY1234567890abcdefGHIJK');
|
||||
assert.equal(up.apiKey, 'REALKEY1234567890abcdefGHIJK'); // cached on the instance
|
||||
});
|
||||
|
||||
test('deriveApiKey returns null when no candidate validates (→ caller uses web fallback)', async () => {
|
||||
const up = new DoodstreamUploader();
|
||||
up._fetch = async () => ({ text: async () => '<input value="bogustoken0000000000000000000">' });
|
||||
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,95 @@
|
||||
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 every visible history result', () => {
|
||||
const h = [batch('2026-01-01', 3, { aborted: 2, error: 1 })];
|
||||
assert.strictEqual(countHistoryRows(h), 6);
|
||||
});
|
||||
|
||||
test('count policy prunes histories made only from failed or aborted uploads', () => {
|
||||
const h = [
|
||||
batch('2026-01-01', 0, { error: 60 }),
|
||||
batch('2026-01-02', 0, { aborted: 60 }),
|
||||
batch('2026-01-03', 0, { error: 60 })
|
||||
];
|
||||
const pruned = applyHistoryRetention(h, '100', Date.parse('2026-06-01'));
|
||||
assert.deepStrictEqual(pruned.map(b => b.timestamp), ['2026-01-02', '2026-01-03']);
|
||||
assert.strictEqual(countHistoryRows(pruned), 120);
|
||||
});
|
||||
|
||||
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,71 @@
|
||||
const { once } = require('node:events');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { pathToFileURL } = require('node:url');
|
||||
const { after, before, describe, it } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const {
|
||||
createOnlineBackup,
|
||||
deleteOnlineBackup,
|
||||
downloadOnlineBackup,
|
||||
uploadOnlineBackup
|
||||
} = require('../lib/online-backup');
|
||||
|
||||
let rootDir;
|
||||
let server;
|
||||
let baseUrl;
|
||||
|
||||
before(async () => {
|
||||
rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-backup-contract-'));
|
||||
const moduleUrl = pathToFileURL(path.join(__dirname, '..', 'services', 'backup-api', 'src', 'server.mjs')).href;
|
||||
const { createBackupServer } = await import(moduleUrl);
|
||||
server = createBackupServer({ rootDir });
|
||||
server.listen(0, '127.0.0.1');
|
||||
await once(server, 'listening');
|
||||
baseUrl = `http://127.0.0.1:${server.address().port}`;
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
if (server) await new Promise((resolve) => server.close(resolve));
|
||||
if (rootDir) fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('online backup client and service contract', () => {
|
||||
it('keeps older keys valid and stores ciphertext only', async () => {
|
||||
const firstSettings = {
|
||||
hosters: { 'byse.sx': [{ id: 'first', apiKey: 'first-secret' }] },
|
||||
hosterSettings: { 'byse.sx': { retries: 3 } },
|
||||
globalSettings: { alwaysOnTop: false },
|
||||
history: []
|
||||
};
|
||||
const secondSettings = {
|
||||
hosters: { 'byse.sx': [{ id: 'second', apiKey: 'second-secret' }] },
|
||||
hosterSettings: { 'byse.sx': { retries: 7 } },
|
||||
globalSettings: { alwaysOnTop: true },
|
||||
history: []
|
||||
};
|
||||
const first = createOnlineBackup(firstSettings, '2.0.3');
|
||||
const second = createOnlineBackup(secondSettings, '2.0.3');
|
||||
|
||||
await uploadOnlineBackup(first.record, baseUrl);
|
||||
await uploadOnlineBackup(second.record, baseUrl);
|
||||
|
||||
assert.deepEqual((await downloadOnlineBackup(first.key, baseUrl)).settings, firstSettings);
|
||||
assert.deepEqual((await downloadOnlineBackup(second.key, baseUrl)).settings, secondSettings);
|
||||
const stored = fs.readdirSync(rootDir)
|
||||
.filter((name) => name.endsWith('.json'))
|
||||
.map((name) => fs.readFileSync(path.join(rootDir, name), 'utf8'))
|
||||
.join('\n');
|
||||
assert.equal(stored.includes('first-secret'), false);
|
||||
assert.equal(stored.includes('second-secret'), false);
|
||||
assert.equal(stored.includes(first.key), false);
|
||||
assert.equal(stored.includes(second.key), false);
|
||||
|
||||
await deleteOnlineBackup(first.key, baseUrl);
|
||||
await assert.rejects(downloadOnlineBackup(first.key, baseUrl), /nicht gefunden/i);
|
||||
assert.deepEqual((await downloadOnlineBackup(second.key, baseUrl)).settings, secondSettings);
|
||||
await deleteOnlineBackup(second.key, baseUrl);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
const http = require('node:http');
|
||||
const { once } = require('node:events');
|
||||
const { afterEach, describe, it } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const servers = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(servers.splice(0).map((server) => new Promise((resolve) => server.close(resolve))));
|
||||
});
|
||||
|
||||
function settings() {
|
||||
return {
|
||||
hosters: {
|
||||
'doodstream.com': [{ id: 'account-1', authType: 'api', apiKey: 'secret-api-key', enabled: true }]
|
||||
},
|
||||
hosterSettings: {
|
||||
'doodstream.com': { retries: 3, parallelCount: 5 }
|
||||
},
|
||||
globalSettings: {
|
||||
alwaysOnTop: true,
|
||||
webhookUrl: 'https://example.invalid/private-webhook'
|
||||
},
|
||||
history: []
|
||||
};
|
||||
}
|
||||
|
||||
describe('online backup key', () => {
|
||||
it('creates a unique 75-character MHU key and restores every snapshot independently', () => {
|
||||
const { createOnlineBackup, restoreOnlineBackup } = require('../lib/online-backup');
|
||||
const first = createOnlineBackup(settings(), '2.0.3', '2026-08-09T00:00:00.000Z');
|
||||
const secondSettings = settings();
|
||||
secondSettings.globalSettings.alwaysOnTop = false;
|
||||
const second = createOnlineBackup(secondSettings, '2.0.3', '2026-08-09T00:01:00.000Z');
|
||||
|
||||
assert.match(first.key, /^MHU2-[A-Za-z0-9_-]{70}$/);
|
||||
assert.equal(first.key.length, 75);
|
||||
assert.notEqual(second.key, first.key);
|
||||
assert.deepEqual(restoreOnlineBackup(first.key, first.record.blob).settings, settings());
|
||||
assert.equal(restoreOnlineBackup(second.key, second.record.blob).settings.globalSettings.alwaysOnTop, false);
|
||||
});
|
||||
|
||||
it('never places credentials or the decryption secret in the server record', () => {
|
||||
const { createOnlineBackup, parseOnlineBackupKey } = require('../lib/online-backup');
|
||||
const created = createOnlineBackup(settings(), '2.0.3');
|
||||
const serialized = JSON.stringify(created.record);
|
||||
const parsed = parseOnlineBackupKey(created.key);
|
||||
|
||||
assert.equal(serialized.includes('secret-api-key'), false);
|
||||
assert.equal(serialized.includes('private-webhook'), false);
|
||||
assert.equal(serialized.includes(parsed.masterKey.toString('base64url')), false);
|
||||
assert.deepEqual(Object.keys(created.record).sort(), ['blob', 'deleteVerifier', 'id']);
|
||||
});
|
||||
|
||||
it('rejects corrupted keys, ciphertext and oversized settings', () => {
|
||||
const { createOnlineBackup, parseOnlineBackupKey, restoreOnlineBackup } = require('../lib/online-backup');
|
||||
const created = createOnlineBackup(settings(), '2.0.3');
|
||||
const keyTail = created.key.endsWith('A') ? 'B' : 'A';
|
||||
const blobTail = created.record.blob.endsWith('A') ? 'B' : 'A';
|
||||
|
||||
assert.throws(() => parseOnlineBackupKey(`${created.key.slice(0, -1)}${keyTail}`), /Schlüssel/i);
|
||||
assert.throws(() => restoreOnlineBackup(created.key, `${created.record.blob.slice(0, -1)}${blobTail}`), /entschlüsselt|beschädigt/i);
|
||||
assert.throws(() => createOnlineBackup({ huge: 'x'.repeat(600_000) }, '2.0.3'), /zu groß/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('online backup transport', () => {
|
||||
it('uses only POST bodies and never sends the master key or record id in URLs', async () => {
|
||||
const {
|
||||
createOnlineBackup,
|
||||
deleteOnlineBackup,
|
||||
downloadOnlineBackup,
|
||||
parseOnlineBackupKey,
|
||||
uploadOnlineBackup
|
||||
} = require('../lib/online-backup');
|
||||
let stored = null;
|
||||
let deleteRequest = null;
|
||||
const requestedUrls = [];
|
||||
const server = http.createServer(async (request, response) => {
|
||||
requestedUrls.push(String(request.url || ''));
|
||||
const chunks = [];
|
||||
for await (const chunk of request) chunks.push(Buffer.from(chunk));
|
||||
const body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : {};
|
||||
if (request.method === 'POST' && request.url === '/v1/backups') {
|
||||
stored = body;
|
||||
response.writeHead(201, { 'content-type': 'application/json' });
|
||||
response.end('{"created":true}');
|
||||
return;
|
||||
}
|
||||
if (request.method === 'POST' && request.url === '/v1/backups/restore' && stored) {
|
||||
assert.equal(body.id, stored.id);
|
||||
response.writeHead(200, { 'content-type': 'application/json' });
|
||||
response.end(JSON.stringify({ blob: stored.blob }));
|
||||
return;
|
||||
}
|
||||
if (request.method === 'POST' && request.url === '/v1/backups/delete' && stored) {
|
||||
deleteRequest = body;
|
||||
response.writeHead(204);
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
response.writeHead(404, { 'content-type': 'application/json' });
|
||||
response.end('{"error":"not_found"}');
|
||||
});
|
||||
servers.push(server);
|
||||
server.listen(0, '127.0.0.1');
|
||||
await once(server, 'listening');
|
||||
const baseUrl = `http://127.0.0.1:${server.address().port}`;
|
||||
const created = createOnlineBackup(settings(), '2.0.3');
|
||||
|
||||
await uploadOnlineBackup(created.record, baseUrl);
|
||||
const restored = await downloadOnlineBackup(created.key, baseUrl);
|
||||
await deleteOnlineBackup(created.key, baseUrl);
|
||||
|
||||
assert.deepEqual(restored.settings, settings());
|
||||
assert.equal(JSON.stringify(stored).includes(parseOnlineBackupKey(created.key).masterKey.toString('base64url')), false);
|
||||
assert.match(deleteRequest.deleteSecret, /^[A-Za-z0-9_-]{43}$/);
|
||||
assert.deepEqual(requestedUrls, ['/v1/backups', '/v1/backups/restore', '/v1/backups/delete']);
|
||||
assert.equal(requestedUrls.join(' ').includes(stored.id), false);
|
||||
});
|
||||
|
||||
it('does not reflect server response bodies into client errors', async () => {
|
||||
const { createOnlineBackup, uploadOnlineBackup } = require('../lib/online-backup');
|
||||
const server = http.createServer((_request, response) => {
|
||||
response.writeHead(500, { 'content-type': 'application/json' });
|
||||
response.end('{"leaked":"server-secret-value"}');
|
||||
});
|
||||
servers.push(server);
|
||||
server.listen(0, '127.0.0.1');
|
||||
await once(server, 'listening');
|
||||
const created = createOnlineBackup(settings(), '2.0.3');
|
||||
|
||||
await assert.rejects(
|
||||
uploadOnlineBackup(created.record, `http://127.0.0.1:${server.address().port}`),
|
||||
(error) => !String(error.message).includes('server-secret-value')
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the timeout active until the response body is fully read', async () => {
|
||||
const { createOnlineBackup, downloadOnlineBackup } = require('../lib/online-backup');
|
||||
const created = createOnlineBackup(settings(), '2.0.3');
|
||||
const fetchImpl = async (_url, options) => ({
|
||||
status: 200,
|
||||
headers: new Headers({ 'content-type': 'application/json' }),
|
||||
body: {
|
||||
getReader: () => ({
|
||||
read: () => new Promise((_resolve, reject) => {
|
||||
options.signal.addEventListener('abort', () => reject(new Error('aborted')), { once: true });
|
||||
}),
|
||||
cancel: async () => {}
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
const outcome = await Promise.race([
|
||||
assert.rejects(
|
||||
downloadOnlineBackup(created.key, 'http://127.0.0.1:8788', { fetchImpl, timeoutMs: 20 }),
|
||||
/antwortet nicht/i
|
||||
).then(() => 'timed-out'),
|
||||
new Promise((resolve) => setTimeout(() => resolve('hung'), 120))
|
||||
]);
|
||||
|
||||
assert.equal(outcome, 'timed-out');
|
||||
});
|
||||
});
|
||||
@@ -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,55 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const Module = require('node:module');
|
||||
const packageJson = require('../package.json');
|
||||
|
||||
test('packages every Electron preload referenced by the main process', () => {
|
||||
assert.ok(packageJson.build.files.includes('preload.js'));
|
||||
assert.ok(packageJson.build.files.includes('preload-drop-target.js'));
|
||||
});
|
||||
|
||||
test('close readiness is signaled only after the renderer explicitly finishes initialization', () => {
|
||||
const listeners = new Map();
|
||||
const sent = [];
|
||||
let exposedApi = null;
|
||||
const electronMock = {
|
||||
contextBridge: {
|
||||
exposeInMainWorld: (_name, api) => { exposedApi = api; }
|
||||
},
|
||||
ipcRenderer: {
|
||||
invoke: () => Promise.resolve(),
|
||||
on: (channel, listener) => { listeners.set(channel, listener); },
|
||||
send: (...args) => { sent.push(args); },
|
||||
removeAllListeners: () => {}
|
||||
},
|
||||
webUtils: {
|
||||
getPathForFile: () => ''
|
||||
}
|
||||
};
|
||||
const originalLoad = Module._load;
|
||||
const preloadPath = require.resolve('../preload');
|
||||
delete require.cache[preloadPath];
|
||||
Module._load = function (request, parent, isMain) {
|
||||
if (request === 'electron') return electronMock;
|
||||
return originalLoad.call(this, request, parent, isMain);
|
||||
};
|
||||
try {
|
||||
require(preloadPath);
|
||||
} finally {
|
||||
Module._load = originalLoad;
|
||||
}
|
||||
|
||||
let closeAttempt = null;
|
||||
exposedApi.onPrepareClose(attempt => { closeAttempt = attempt; });
|
||||
assert.deepEqual(sent, []);
|
||||
|
||||
listeners.get('app:prepare-close')({}, 7);
|
||||
assert.equal(closeAttempt, 7);
|
||||
assert.deepEqual(sent, [['app:close-preparation-started', 7]]);
|
||||
|
||||
exposedApi.signalCloseHandshakeReady();
|
||||
assert.deepEqual(sent, [
|
||||
['app:close-preparation-started', 7],
|
||||
['app:close-handshake-ready']
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const rootFiles = [
|
||||
'.gitignore',
|
||||
'README.md',
|
||||
'SECURITY.md',
|
||||
'eslint.config.mjs',
|
||||
'main.js',
|
||||
'package-lock.json',
|
||||
'package.json',
|
||||
'preload-drop-target.js',
|
||||
'preload.js'
|
||||
];
|
||||
const directoryRoots = ['assets', 'lib', 'renderer', 'services/backup-api', 'tests'];
|
||||
const scriptFiles = ['scripts/afterPack.cjs', 'scripts/release-plan.mjs', 'scripts/verify-public-release.mjs'];
|
||||
|
||||
function copyDirectory(source, destination) {
|
||||
fs.mkdirSync(destination, { recursive: true });
|
||||
for (const entry of fs.readdirSync(source, { withFileTypes: true })) {
|
||||
if (/^_ui-inject\..+\.tmp\.js$/.test(entry.name)) continue;
|
||||
const sourcePath = path.join(source, entry.name);
|
||||
const destinationPath = path.join(destination, entry.name);
|
||||
if (entry.isDirectory()) copyDirectory(sourcePath, destinationPath);
|
||||
else if (entry.isFile()) fs.copyFileSync(sourcePath, destinationPath);
|
||||
}
|
||||
}
|
||||
|
||||
function createStage() {
|
||||
const stage = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-public-verifier-'));
|
||||
for (const relativePath of rootFiles) {
|
||||
const destination = path.join(stage, relativePath);
|
||||
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
||||
fs.copyFileSync(path.join(root, relativePath), destination);
|
||||
}
|
||||
for (const relativePath of directoryRoots) copyDirectory(path.join(root, relativePath), path.join(stage, relativePath));
|
||||
for (const relativePath of scriptFiles) {
|
||||
const destination = path.join(stage, relativePath);
|
||||
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
||||
fs.copyFileSync(path.join(root, relativePath), destination);
|
||||
}
|
||||
fs.rmSync(path.join(stage, 'assets', 'product-overview.png'), { force: true });
|
||||
return stage;
|
||||
}
|
||||
|
||||
function verify(stage, version = '2.0.6') {
|
||||
return spawnSync(process.execPath, ['scripts/verify-public-release.mjs', '--source-only', '--version', version], {
|
||||
cwd: stage,
|
||||
encoding: 'utf8'
|
||||
});
|
||||
}
|
||||
|
||||
test('public release verifier accepts only the exact source manifest and target version', (t) => {
|
||||
const stage = createStage();
|
||||
t.after(() => fs.rmSync(stage, { recursive: true, force: true }));
|
||||
|
||||
const baseline = verify(stage);
|
||||
assert.equal(baseline.status, 0, baseline.stderr);
|
||||
assert.match(baseline.stdout, /layout=exact/);
|
||||
|
||||
fs.writeFileSync(path.join(stage, 'tests', 'unexpected.json'), '{}');
|
||||
const extra = verify(stage);
|
||||
assert.equal(extra.status, 1);
|
||||
assert.match(extra.stderr, /tests\/unexpected\.json\tsource-layout-allowlist/);
|
||||
fs.rmSync(path.join(stage, 'tests', 'unexpected.json'));
|
||||
|
||||
const wrongVersion = verify(stage, '2.0.5');
|
||||
assert.equal(wrongVersion.status, 1);
|
||||
assert.match(wrongVersion.stderr, /package\.json\tpackage-version-target/);
|
||||
});
|
||||
@@ -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,32 @@
|
||||
const { describe, it } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
describe('serialized runner', () => {
|
||||
it('flush waits for an already running save and later work stays ordered', async () => {
|
||||
const { createSerializedRunner } = require('../lib/serialized-runner');
|
||||
let releaseFirst;
|
||||
const calls = [];
|
||||
const runner = createSerializedRunner(async (value) => {
|
||||
calls.push(`start:${value}`);
|
||||
if (value === 'first') await new Promise((resolve) => { releaseFirst = resolve; });
|
||||
calls.push(`end:${value}`);
|
||||
return value;
|
||||
});
|
||||
|
||||
const first = runner.run('first');
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
const second = runner.run('second');
|
||||
let flushed = false;
|
||||
const flush = runner.flush().then(() => { flushed = true; });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(flushed, false);
|
||||
assert.deepEqual(calls, ['start:first']);
|
||||
|
||||
releaseFirst();
|
||||
assert.equal(await first, 'first');
|
||||
assert.equal(await second, 'second');
|
||||
await flush;
|
||||
assert.equal(flushed, true);
|
||||
assert.deepEqual(calls, ['start:first', 'end:first', 'start:second', 'end:second']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
const { describe, it } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
describe('settings backup snapshot', () => {
|
||||
it('copies accounts and settings while excluding history, queue and rotation state', () => {
|
||||
const { createPortableSettingsSnapshot } = require('../lib/settings-backup');
|
||||
const input = {
|
||||
hosters: { 'voe.sx': [{ id: 'v1', username: 'user', password: 'secret', enabled: true }] },
|
||||
hosterSettings: { 'voe.sx': { retries: 7 } },
|
||||
globalSettings: { alwaysOnTop: true, pendingQueue: [{ file: 'private.mkv' }] },
|
||||
history: [{ file: 'done.mkv' }],
|
||||
rotationCursors: { 'voe.sx': 4 }
|
||||
};
|
||||
|
||||
const snapshot = createPortableSettingsSnapshot(input);
|
||||
|
||||
assert.deepEqual(snapshot, {
|
||||
hosters: input.hosters,
|
||||
hosterSettings: input.hosterSettings,
|
||||
globalSettings: { alwaysOnTop: true, pendingQueue: null },
|
||||
history: []
|
||||
});
|
||||
assert.notEqual(snapshot.hosters, input.hosters);
|
||||
});
|
||||
|
||||
it('validates imports and clears only source-machine paths that do not exist locally', () => {
|
||||
const { prepareImportedSettings } = require('../lib/settings-backup');
|
||||
const snapshot = {
|
||||
hosters: { 'byse.sx': [{ id: 'b1', apiKey: 'secret', enabled: true }] },
|
||||
hosterSettings: { 'byse.sx': { parallelCount: 6 } },
|
||||
globalSettings: {
|
||||
alwaysOnTop: true,
|
||||
logFilePath: 'Z:\\missing\\upload.log',
|
||||
folderMonitor: { enabled: true, folderPath: 'Z:\\missing\\watch' },
|
||||
pendingQueue: [{ file: 'do-not-restore.mkv' }]
|
||||
}
|
||||
};
|
||||
|
||||
const imported = prepareImportedSettings(snapshot, { pathExists: () => false, pathDirname: (value) => value });
|
||||
|
||||
assert.equal(imported.globalSettings.logFilePath, '');
|
||||
assert.deepEqual(imported.globalSettings.folderMonitor, { enabled: false, folderPath: '' });
|
||||
assert.equal(imported.globalSettings.pendingQueue, null);
|
||||
assert.deepEqual(imported.history, []);
|
||||
assert.throws(() => prepareImportedSettings({ hosters: {} }), /ungültige Struktur/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
const { describe, it } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
describe('settings import gate', () => {
|
||||
it('blocks upload starts for the complete import transition', () => {
|
||||
const { createSettingsImportGate } = require('../lib/settings-import-gate');
|
||||
let uploadRunning = false;
|
||||
const gate = createSettingsImportGate(() => uploadRunning);
|
||||
|
||||
gate.begin();
|
||||
assert.equal(gate.canStartUpload(), false);
|
||||
assert.throws(() => gate.begin(), /bereits importiert/i);
|
||||
gate.end();
|
||||
assert.equal(gate.canStartUpload(), true);
|
||||
|
||||
uploadRunning = true;
|
||||
assert.throws(() => gate.begin(), /laufender Uploads/i);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user