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:
@@ -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 };
|
||||
Reference in New Issue
Block a user