A user's server crashed hard during an upload and lost all configured accounts. It
was NOT the v3.3.104 update (a second server updated fine and kept its accounts) —
it was a data-durability hole exposed by the crash:
- Config writes were atomic (tmp + rename) but never fsync'd, so a hard crash could
leave electron-config.json truncated/unflushed on disk.
- On restart, load() reads the truncated file, falls back to .bak, and if that is
also bad returns empty DEFAULTS. The next settings/queue save then persists EMPTY
hosters — permanently wiping the accounts. Worse, the async _atomicWrite blindly
copied the (now truncated) live file over .bak, so an empty live could clobber a
good backup.
Hardening (lib/config-store.js + main.js; no behavior change in the happy path):
- fsync before rename in both write paths — _atomicWrite (openSync/writeSync/
fsyncSync/closeSync) and the synchronous save-global-settings-sync on window close.
A hard crash can no longer leave a truncated config.
- _atomicWrite only refreshes .bak when the current live file is non-trivial
(trim length > 2), so an empty/truncated live can never overwrite a good backup
(the sync-save path already did this).
- Wipe-guard (_guardHosters): save(), saveRotationCursors() and the sync close-save
never intend to change hosters; if after a load() the hosters are all empty and
the write did not explicitly provide hosters, recover them from disk
(_recoverHostersFromDisk: live -> .bak -> .pre-history-split.bak) instead of
persisting the wipe. An explicit save({hosters: {}}) (user deleted all accounts)
is still allowed. Restored hosters are already-encrypted on disk and
encryptCredentials skips already-encrypted fields, so re-serializing is safe.
- load() gained a third fallback tier — the permanent pre-history-split.bak snapshot
(which still holds the accounts) — so load() itself recovers after corruption.
Recovery for the already-affected server: copy
%APPDATA%/multi-hoster-uploader/electron-config.json.pre-history-split.bak (or .bak)
over electron-config.json with the app closed.
2 new regression tests (post-wipe valid-empty live + .bak → guard restores accounts;
an explicit empty-hosters save is not blocked). 409 tests pass; clean boot.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
655 lines
24 KiB
JavaScript
655 lines
24 KiB
JavaScript
const fs = require('fs');
|
|
const path = require('path');
|
|
const secretStore = require('./secret-store');
|
|
const { normalizeLogMode } = require('./log-mode');
|
|
|
|
const HOSTER_SETTINGS_DEFAULTS = {
|
|
retries: 3,
|
|
maxSpeedKbs: 0, // 0 = unlimited
|
|
parallelCount: 2, // 1-100
|
|
restartBelowKbs: 0, // 0 = off
|
|
timeIntervalSec: 0, // delay between jobs
|
|
maxSizeMb: 0, // 0 = unlimited
|
|
logToFile: true, // write this hoster's successful links to fileuploader.log
|
|
rotateAccounts: false,
|
|
sizeMemoEnabled: true
|
|
};
|
|
|
|
// Template for each hoster type (used as defaults for new accounts)
|
|
const HOSTER_ACCOUNT_TEMPLATES = {
|
|
'doodstream.com': { enabled: true, authType: 'login', username: '', password: '' },
|
|
'doodstream.com:api': { enabled: true, authType: 'api', apiKey: '' },
|
|
'voe.sx': { enabled: true, authType: 'login', username: '', password: '' },
|
|
'voe.sx:api': { enabled: true, authType: 'api', apiKey: '' },
|
|
'vidmoly.me': { enabled: true, authType: 'login', username: '', password: '' },
|
|
'byse.sx': { enabled: true, authType: 'api', apiKey: '' },
|
|
'clouddrop.cc': { enabled: true, authType: 'api', apiKey: '' }
|
|
};
|
|
|
|
// All known hoster names (used for iteration)
|
|
const HOSTER_NAMES = ['doodstream.com', 'voe.sx', 'vidmoly.me', 'byse.sx', 'clouddrop.cc'];
|
|
|
|
// Dropdown options for "Add Account" modal: value -> label
|
|
const HOSTER_ADD_OPTIONS = [
|
|
{ value: 'doodstream.com', label: 'Doodstream (Web Login)', hoster: 'doodstream.com', authType: 'login' },
|
|
{ value: 'doodstream.com:api', label: 'Doodstream (API)', hoster: 'doodstream.com', authType: 'api' },
|
|
{ value: 'voe.sx', label: 'Voe (Web Login)', hoster: 'voe.sx', authType: 'login' },
|
|
{ value: 'voe.sx:api', label: 'Voe (API)', hoster: 'voe.sx', authType: 'api' },
|
|
{ value: 'vidmoly.me', label: 'Vidmoly (Web Login)', hoster: 'vidmoly.me', authType: 'login' },
|
|
{ value: 'byse.sx', label: 'Byse (API)', hoster: 'byse.sx', authType: 'api' },
|
|
{ value: 'clouddrop.cc', label: 'Clouddrop (API)', hoster: 'clouddrop.cc', authType: 'api' }
|
|
];
|
|
|
|
const DEFAULTS = {
|
|
hosters: {
|
|
'doodstream.com': [],
|
|
'voe.sx': [],
|
|
'vidmoly.me': [],
|
|
'byse.sx': [],
|
|
'clouddrop.cc': []
|
|
},
|
|
hosterSettings: {
|
|
'doodstream.com': { ...HOSTER_SETTINGS_DEFAULTS },
|
|
'voe.sx': { ...HOSTER_SETTINGS_DEFAULTS },
|
|
'vidmoly.me': { ...HOSTER_SETTINGS_DEFAULTS },
|
|
'byse.sx': { ...HOSTER_SETTINGS_DEFAULTS },
|
|
'clouddrop.cc': { ...HOSTER_SETTINGS_DEFAULTS }
|
|
},
|
|
globalSettings: {
|
|
alwaysOnTop: false,
|
|
shutdownAfterFinish: 'nothing', // nothing | sleep | shutdown | restart
|
|
logFilePath: '',
|
|
sessionLog: false, // legacy boolean (kept for back-compat reads); normalized into logMode on load
|
|
logVerbose: false, // when true, [DEBUG] level entries are written to debug.log
|
|
webhookUrl: '', // POST target on batch-done (Discord or generic JSON)
|
|
webhookMention: '', // optional Discord ping target: user-id, role:id, @here, @everyone
|
|
autoRetryRounds: 0, // 0 = off; 1-5 automatic retry rounds for transient failures after batch end
|
|
autoRetryDelayMin: 5, // base delay in minutes between auto-retry rounds (linear backoff: round N waits N*delay)
|
|
historyRetention: 'all', // 'all' | '7d' | '30d' | '90d' | '1000' | '100' — storage cap for upload history
|
|
// NOTE: logMode is intentionally NOT in DEFAULTS. If it were, the deep-merge
|
|
// would seed logMode='single' for every load, which would beat (and silently
|
|
// erase) the legacy sessionLog:true → "daily" migration. normalizeLogMode in
|
|
// load() sets logMode after the merge, looking at the saved-only data.
|
|
resumeQueueOnLaunch: true,
|
|
parallelUploadCount: 0, // 0 = use per-hoster limits only
|
|
scaleParallelUploads: false,
|
|
removeFromQueueOnDone: false,
|
|
showDropTarget: false,
|
|
globalMaxSpeedKbs: 0, // 0 = unlimited global speed
|
|
pendingQueue: null,
|
|
scramble: {
|
|
active: false,
|
|
prefix: '',
|
|
suffix: '',
|
|
chars: 'both', // 'letters' | 'numbers' | 'both'
|
|
length: 0 // 0 = same as original basename length
|
|
},
|
|
folderMonitor: {
|
|
enabled: false,
|
|
folderPath: '',
|
|
recursive: false,
|
|
filterMode: 'include', // 'include' | 'exclude'
|
|
extensions: '', // comma-separated: 'mp4,mkv,avi'
|
|
skipDuplicates: true,
|
|
delaySec: 3,
|
|
autoStart: true,
|
|
hosters: [] // pre-selected hosters, empty = ask via modal
|
|
},
|
|
remote: {
|
|
enabled: false,
|
|
port: 9100,
|
|
token: '',
|
|
allowInput: true
|
|
},
|
|
diagnostics: {
|
|
enabled: false,
|
|
port: 9110,
|
|
token: '',
|
|
label: '',
|
|
codeIssuedAt: 0,
|
|
bindMode: 'local',
|
|
publicHost: '',
|
|
allowlist: [],
|
|
bindAddress: '127.0.0.1'
|
|
}
|
|
},
|
|
history: [],
|
|
rotationCursors: {}
|
|
};
|
|
|
|
const HISTORY_RETENTION_OPTIONS = [
|
|
{ value: 'all', label: 'Alles behalten' },
|
|
{ value: '7d', label: 'Letzte 7 Tage' },
|
|
{ value: '30d', label: 'Letzte 30 Tage' },
|
|
{ value: '90d', label: 'Letzte 90 Tage' },
|
|
{ value: '1000', label: 'Letzte 1000 Uploads' },
|
|
{ value: '100', label: 'Letzte 100 Uploads' }
|
|
];
|
|
|
|
function batchTimestampMs(batch) {
|
|
const raw = batch && batch.timestamp;
|
|
if (raw === null || raw === undefined || raw === '') return null;
|
|
const ms = typeof raw === 'number' ? raw : Date.parse(raw);
|
|
return Number.isFinite(ms) ? ms : null;
|
|
}
|
|
|
|
function batchRowCount(batch) {
|
|
let n = 0;
|
|
const files = (batch && batch.files) || [];
|
|
for (const file of files) {
|
|
for (const result of (file.results || [])) {
|
|
if (result.status === 'aborted' || result.status === 'error') continue;
|
|
n++;
|
|
}
|
|
}
|
|
return n;
|
|
}
|
|
|
|
function countHistoryRows(history) {
|
|
let n = 0;
|
|
for (const batch of (history || [])) n += batchRowCount(batch);
|
|
return n;
|
|
}
|
|
|
|
function applyHistoryRetention(history, retention, nowMs) {
|
|
if (!Array.isArray(history) || history.length === 0) return history;
|
|
const policy = String(retention || 'all');
|
|
if (policy === 'all') return history;
|
|
|
|
if (/^\d+d$/.test(policy)) {
|
|
const days = parseInt(policy, 10);
|
|
if (!Number.isFinite(days) || days <= 0) return history;
|
|
const cutoff = nowMs - days * 86400000;
|
|
return history.filter(b => {
|
|
const ts = batchTimestampMs(b);
|
|
return ts === null || ts >= cutoff;
|
|
});
|
|
}
|
|
|
|
const maxRows = parseInt(policy, 10);
|
|
if (!Number.isFinite(maxRows) || maxRows <= 0) return history;
|
|
const keptReversed = [];
|
|
let acc = 0;
|
|
for (let i = history.length - 1; i >= 0; i--) {
|
|
keptReversed.push(history[i]);
|
|
acc += batchRowCount(history[i]);
|
|
if (acc >= maxRows) break;
|
|
}
|
|
return keptReversed.reverse();
|
|
}
|
|
|
|
class ConfigStore {
|
|
constructor(app) {
|
|
const dir = app && app.isPackaged
|
|
? app.getPath('userData')
|
|
: path.join(__dirname, '..');
|
|
this.filePath = path.join(dir, 'electron-config.json');
|
|
this.historyPath = path.join(dir, 'electron-history.json');
|
|
this._writeQueue = Promise.resolve(); // Serializes all writes to prevent race conditions
|
|
this._historyWriteQueue = Promise.resolve();
|
|
this._historyMigrated = false;
|
|
this._cache = null;
|
|
this._cacheKey = '';
|
|
this._perfLog = null;
|
|
this._wqDepth = 0;
|
|
|
|
// Migrate config from old location if current doesn't exist
|
|
if (!fs.existsSync(this.filePath) && app && app.isPackaged) {
|
|
this._migrateFromOldPath(app);
|
|
}
|
|
if (app && app.isPackaged) {
|
|
this._migrateHistory();
|
|
}
|
|
}
|
|
|
|
_readHistoryFile() {
|
|
try {
|
|
const raw = fs.readFileSync(this.historyPath, 'utf-8');
|
|
if (!raw || raw.trim().length < 2) return [];
|
|
const parsed = JSON.parse(raw);
|
|
if (Array.isArray(parsed)) return parsed;
|
|
if (parsed && Array.isArray(parsed.history)) return parsed.history;
|
|
return [];
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
_writeHistoryFileDurable(arr) {
|
|
const tmp = this.historyPath + '.tmp';
|
|
const fd = fs.openSync(tmp, 'w');
|
|
try {
|
|
fs.writeSync(fd, JSON.stringify(arr));
|
|
fs.fsyncSync(fd);
|
|
} finally {
|
|
fs.closeSync(fd);
|
|
}
|
|
fs.renameSync(tmp, this.historyPath);
|
|
}
|
|
|
|
_writeHistoryFileAtomic(arr) {
|
|
return new Promise((resolve, reject) => {
|
|
const tmp = this.historyPath + '.tmp';
|
|
fs.writeFile(tmp, JSON.stringify(arr), 'utf-8', (err) => {
|
|
if (err) return reject(err);
|
|
try { fs.renameSync(tmp, this.historyPath); } catch (e) { return reject(e); }
|
|
resolve();
|
|
});
|
|
});
|
|
}
|
|
|
|
_enqueueHistoryWrite(fn) {
|
|
this._historyWriteQueue = this._historyWriteQueue.then(fn, fn);
|
|
return this._historyWriteQueue;
|
|
}
|
|
|
|
_migrateHistory() {
|
|
try {
|
|
if (fs.existsSync(this.historyPath)) {
|
|
this._historyMigrated = Array.isArray(this._readHistoryFile());
|
|
return;
|
|
}
|
|
let cfg = null;
|
|
try { cfg = this._readAndParse(this.filePath); } catch {}
|
|
const hist = (cfg && Array.isArray(cfg.history)) ? cfg.history : [];
|
|
this._writeHistoryFileDurable(hist);
|
|
const check = this._readHistoryFile();
|
|
if (Array.isArray(check) && check.length === hist.length) {
|
|
if (hist.length > 0) {
|
|
try { fs.copyFileSync(this.filePath, this.filePath + '.pre-history-split.bak'); } catch {}
|
|
}
|
|
this._historyMigrated = true;
|
|
} else {
|
|
this._historyMigrated = false;
|
|
}
|
|
} catch {
|
|
this._historyMigrated = false;
|
|
}
|
|
}
|
|
|
|
_migrateFromOldPath(app) {
|
|
try {
|
|
const appDataDir = path.dirname(app.getPath('userData'));
|
|
// Check alternate folder names that may have been used
|
|
const candidates = ['multi-hoster-uploader', 'Multi-Hoster-Upload'];
|
|
for (const name of candidates) {
|
|
const oldPath = path.join(appDataDir, name, 'electron-config.json');
|
|
if (oldPath !== this.filePath && fs.existsSync(oldPath)) {
|
|
fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
|
|
fs.copyFileSync(oldPath, this.filePath);
|
|
return;
|
|
}
|
|
}
|
|
// Also check next to the executable (portable mode previous location)
|
|
const exeDir = path.dirname(app.getPath('exe'));
|
|
const portablePath = path.join(exeDir, 'electron-config.json');
|
|
if (portablePath !== this.filePath && fs.existsSync(portablePath)) {
|
|
fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
|
|
fs.copyFileSync(portablePath, this.filePath);
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
_readAndParse(filePath) {
|
|
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
if (!raw || raw.trim().length < 2) return null;
|
|
return JSON.parse(raw);
|
|
}
|
|
|
|
_clone(obj) {
|
|
try { return structuredClone(obj); }
|
|
catch { return JSON.parse(JSON.stringify(obj)); }
|
|
}
|
|
|
|
setPerfLog(fn) { this._perfLog = typeof fn === 'function' ? fn : null; }
|
|
|
|
_pqLen(globalSettings) {
|
|
const pq = globalSettings && globalSettings.pendingQueue;
|
|
return pq && Array.isArray(pq.queueJobs) ? pq.queueJobs.length : 0;
|
|
}
|
|
|
|
_callerTag() {
|
|
const lines = (new Error().stack || '').split('\n');
|
|
const out = [];
|
|
for (let i = 2; i < lines.length && out.length < 3; i++) {
|
|
const line = lines[i].trim();
|
|
if (/config-store\.js/.test(line)) continue;
|
|
const m = line.match(/at (?:async )?([^ (]+)/);
|
|
if (m) out.push(m[1].split('.').pop());
|
|
}
|
|
return out.join('<') || '?';
|
|
}
|
|
|
|
load() {
|
|
if (!this._perfLog) return this._loadImpl();
|
|
const hadCache = !!this._cache;
|
|
const t0 = performance.now();
|
|
const r = this._loadImpl();
|
|
const dt = performance.now() - t0;
|
|
if (dt >= 20) {
|
|
const q = this._pqLen(r && r.globalSettings);
|
|
const h = (r && r.history || []).length;
|
|
this._perfLog(`config-load wall=${dt.toFixed(0)}ms cache=${hadCache ? 'hit' : 'miss'} hist=${h} queue=${q} via=${this._callerTag()}`);
|
|
}
|
|
return r;
|
|
}
|
|
|
|
_loadImpl() {
|
|
try {
|
|
// In-memory cache keyed on the file's mtime+size. The processed config
|
|
// (merged + credential-decrypted) is reparsed/re-decrypted from disk ONLY
|
|
// when the file actually changes. Our own writes refresh the cache (see
|
|
// _commit), and an external edit changes mtime/size so the cache misses
|
|
// and we reread. Without this, every one of the ~38 main.js load() call
|
|
// sites (incl. the per-500ms log-flush path) re-read disk + JSON.parse the
|
|
// whole growing history + DPAPI-decrypt every credential — the dominant
|
|
// long-running main-thread drag. load() always returns a CLONE so callers
|
|
// can mutate the result without corrupting the cache.
|
|
let stat = null;
|
|
try { stat = fs.statSync(this.filePath); } catch {}
|
|
const statKey = stat ? `${stat.mtimeMs}:${stat.size}` : '';
|
|
if (stat && this._cache && this._cacheKey === statKey) {
|
|
return this._clone(this._cache);
|
|
}
|
|
|
|
let data = null;
|
|
// Try main config
|
|
try { data = this._readAndParse(this.filePath); } catch {}
|
|
// Fallback to backup if main is empty/corrupt
|
|
if (!data) {
|
|
try { data = this._readAndParse(this.filePath + '.bak'); } catch {}
|
|
}
|
|
if (!data) {
|
|
try { data = this._readAndParse(this.filePath + '.pre-history-split.bak'); } catch {}
|
|
}
|
|
if (!data) {
|
|
const fresh = JSON.parse(JSON.stringify(DEFAULTS));
|
|
fresh.globalSettings.logMode = normalizeLogMode(fresh.globalSettings);
|
|
return fresh;
|
|
}
|
|
|
|
// Migrate old single-object format to array format
|
|
for (const [name, val] of Object.entries(data.hosters || {})) {
|
|
if (val && !Array.isArray(val)) {
|
|
if (!val.id) val.id = `${name}-migrated-${Date.now()}`;
|
|
// Infer authType for old format accounts
|
|
if (!val.authType) {
|
|
if (name === 'byse.sx') val.authType = 'api';
|
|
else if (name === 'vidmoly.me') val.authType = 'login';
|
|
else if (val.username && val.password) val.authType = 'login';
|
|
else if (val.apiKey) val.authType = 'api';
|
|
else val.authType = 'login';
|
|
}
|
|
data.hosters[name] = [val];
|
|
}
|
|
}
|
|
|
|
// Merge hosters: ensure all known hosters exist as arrays
|
|
const hosters = {};
|
|
for (const name of HOSTER_NAMES) {
|
|
const saved = data.hosters && data.hosters[name];
|
|
if (Array.isArray(saved) && saved.length > 0) {
|
|
hosters[name] = saved.map((acc, i) => {
|
|
// Ensure authType is set on every account
|
|
if (!acc.authType) {
|
|
if (name === 'byse.sx') acc.authType = 'api';
|
|
else if (name === 'vidmoly.me') acc.authType = 'login';
|
|
else if (acc.username && acc.password) acc.authType = 'login';
|
|
else if (acc.apiKey) acc.authType = 'api';
|
|
else acc.authType = 'login';
|
|
}
|
|
return {
|
|
...acc,
|
|
id: acc.id || `${name}-${Date.now()}-${i}`
|
|
};
|
|
});
|
|
} else {
|
|
hosters[name] = [];
|
|
}
|
|
}
|
|
|
|
// Merge hoster settings with defaults
|
|
const hosterSettings = {};
|
|
for (const name of Object.keys(DEFAULTS.hosterSettings)) {
|
|
hosterSettings[name] = {
|
|
...HOSTER_SETTINGS_DEFAULTS,
|
|
...(data.hosterSettings && data.hosterSettings[name] || {})
|
|
};
|
|
}
|
|
const savedGlobal = data.globalSettings || {};
|
|
const globalSettings = {
|
|
...DEFAULTS.globalSettings,
|
|
...savedGlobal
|
|
};
|
|
// Deep-merge nested objects so new keys are always present
|
|
for (const key of Object.keys(DEFAULTS.globalSettings)) {
|
|
const def = DEFAULTS.globalSettings[key];
|
|
if (def && typeof def === 'object' && !Array.isArray(def)) {
|
|
globalSettings[key] = { ...def, ...(savedGlobal[key] || {}) };
|
|
}
|
|
}
|
|
// Normalize logMode at this single boundary. Legacy sessionLog: true
|
|
// means *daily* (the old field was named after a misnomer); see log-mode.js.
|
|
// Downstream readers consume logMode only and must NOT derive from
|
|
// sessionLog at call sites.
|
|
globalSettings.logMode = normalizeLogMode(globalSettings);
|
|
const rotationCursors = (data.rotationCursors && typeof data.rotationCursors === 'object' && !Array.isArray(data.rotationCursors))
|
|
? data.rotationCursors
|
|
: {};
|
|
const result = { hosters, hosterSettings, globalSettings, history: this._historyMigrated ? [] : (data.history || []), rotationCursors };
|
|
// Decrypt credentials stored with safeStorage so the rest of the app
|
|
// keeps working with plaintext in memory.
|
|
secretStore.decryptCredentials(result);
|
|
if (stat) {
|
|
this._cache = result;
|
|
this._cacheKey = statKey;
|
|
}
|
|
return this._clone(result);
|
|
} catch {
|
|
const fresh = JSON.parse(JSON.stringify(DEFAULTS));
|
|
fresh.globalSettings.logMode = normalizeLogMode(fresh.globalSettings);
|
|
return fresh;
|
|
}
|
|
}
|
|
|
|
// Encrypt credential fields without mutating the caller's plaintext object.
|
|
// Only `hosters` carries credentials, so we clone ONLY that subtree — the rest
|
|
// (history, globalSettings, …) is referenced read-only into the stringified
|
|
// object. Deep-cloning the whole config here (incl. an ever-growing history)
|
|
// on every write was a primary long-running main-thread stall.
|
|
_serializeForDisk(config) {
|
|
const hosters = this._clone(config.hosters || {});
|
|
secretStore.encryptCredentials({ hosters });
|
|
return JSON.stringify({ ...config, hosters }, null, 2);
|
|
}
|
|
|
|
_commit(config) {
|
|
if (!this._perfLog) return this._atomicWrite(this._serializeForDisk(config));
|
|
const t0 = performance.now();
|
|
const data = this._serializeForDisk(config);
|
|
const dt = performance.now() - t0;
|
|
if (dt >= 20) {
|
|
const q = this._pqLen(config.globalSettings);
|
|
const h = (config.history || []).length;
|
|
this._perfLog(`config-serialize wall=${dt.toFixed(0)}ms bytes=${data.length} hist=${h} queue=${q} wqDepth=${this._wqDepth} via=${this._callerTag()}`);
|
|
}
|
|
return this._atomicWrite(data);
|
|
}
|
|
|
|
_enqueueWrite(fn) {
|
|
this._wqDepth++;
|
|
const done = () => { this._wqDepth--; };
|
|
this._writeQueue = this._writeQueue.then(fn, fn).then(done, done);
|
|
return this._writeQueue;
|
|
}
|
|
|
|
_anyHosters(cfg) {
|
|
const h = cfg && cfg.hosters;
|
|
return !!h && typeof h === 'object' && Object.values(h).some(a => Array.isArray(a) && a.length > 0);
|
|
}
|
|
|
|
_recoverHostersFromDisk() {
|
|
for (const p of [this.filePath, this.filePath + '.bak', this.filePath + '.pre-history-split.bak']) {
|
|
try {
|
|
const raw = fs.readFileSync(p, 'utf-8');
|
|
if (!raw || raw.trim().length < 2) continue;
|
|
const data = JSON.parse(raw);
|
|
if (this._anyHosters(data)) return data.hosters;
|
|
} catch {}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
_guardHosters(current, hostersIntentional) {
|
|
if (!hostersIntentional && !this._anyHosters(current)) {
|
|
const recovered = this._recoverHostersFromDisk();
|
|
if (recovered) {
|
|
current.hosters = recovered;
|
|
if (this._perfLog) this._perfLog('config-guard: prevented account wipe — restored hosters from on-disk backup after a corrupt/empty read');
|
|
}
|
|
}
|
|
return current;
|
|
}
|
|
|
|
save(config) {
|
|
return this._enqueueWrite(() => {
|
|
const current = this.load();
|
|
if (config.hosters) current.hosters = config.hosters;
|
|
if (config.hosterSettings) current.hosterSettings = config.hosterSettings;
|
|
if (config.globalSettings) current.globalSettings = config.globalSettings;
|
|
this._guardHosters(current, !!config.hosters);
|
|
return this._commit(current);
|
|
});
|
|
}
|
|
|
|
loadHistory() {
|
|
if (this._historyMigrated) {
|
|
return this._readHistoryFile() || [];
|
|
}
|
|
const config = this.load();
|
|
return config.history || [];
|
|
}
|
|
|
|
_atomicWrite(data) {
|
|
return new Promise((resolve, reject) => {
|
|
const tmpPath = this.filePath + '.tmp';
|
|
const backupPath = this.filePath + '.bak';
|
|
let fd;
|
|
try {
|
|
fd = fs.openSync(tmpPath, 'w');
|
|
fs.writeSync(fd, data);
|
|
fs.fsyncSync(fd);
|
|
} catch (e) {
|
|
try { if (fd !== undefined) fs.closeSync(fd); } catch {}
|
|
return reject(e);
|
|
}
|
|
try { fs.closeSync(fd); } catch {}
|
|
Promise.resolve().then(() => {
|
|
try {
|
|
try {
|
|
if (fs.existsSync(this.filePath)) {
|
|
const cur = fs.readFileSync(this.filePath, 'utf-8');
|
|
if (cur && cur.trim().length > 2) fs.writeFileSync(backupPath, cur, 'utf-8');
|
|
}
|
|
} catch {}
|
|
fs.renameSync(tmpPath, this.filePath);
|
|
} catch (e) { return reject(e); }
|
|
// Invalidate the read cache: the next load() re-reads + re-merges the
|
|
// freshly-written file (the on-disk format is sparse — load() fills
|
|
// defaults — so we must NOT serve a pre-merge in-memory object).
|
|
this._cache = null;
|
|
this._cacheKey = '';
|
|
resolve();
|
|
});
|
|
});
|
|
}
|
|
|
|
appendHistory(entry) {
|
|
if (this._historyMigrated) {
|
|
return this._enqueueHistoryWrite(() => {
|
|
const cur = this._readHistoryFile();
|
|
if (cur === null && fs.existsSync(this.historyPath)) return;
|
|
const arr = cur || [];
|
|
arr.push(entry);
|
|
const gs = this.load().globalSettings;
|
|
const retention = (gs && gs.historyRetention) || 'all';
|
|
const pruned = applyHistoryRetention(arr, retention, Date.now());
|
|
return this._writeHistoryFileAtomic(pruned);
|
|
});
|
|
}
|
|
return this._enqueueWrite(() => {
|
|
const config = this.load();
|
|
config.history.push(entry);
|
|
const retention = (config.globalSettings && config.globalSettings.historyRetention) || 'all';
|
|
config.history = applyHistoryRetention(config.history, retention, Date.now());
|
|
return this._commit(config);
|
|
});
|
|
}
|
|
|
|
pruneHistory(retention, opts = {}) {
|
|
const dryRun = !!opts.dryRun;
|
|
if (this._historyMigrated) {
|
|
return this._enqueueHistoryWrite(() => {
|
|
const current = this._readHistoryFile() || [];
|
|
const beforeBatches = current.length;
|
|
const beforeRows = countHistoryRows(current);
|
|
const pruned = applyHistoryRetention(current, retention, Date.now());
|
|
const result = {
|
|
removedBatches: beforeBatches - pruned.length,
|
|
removedRows: beforeRows - countHistoryRows(pruned),
|
|
keptBatches: pruned.length,
|
|
keptRows: countHistoryRows(pruned)
|
|
};
|
|
if (dryRun) return result;
|
|
return this._writeHistoryFileAtomic(pruned)
|
|
.then(() => this.save({ globalSettings: { ...this.load().globalSettings, historyRetention: String(retention || 'all') } }))
|
|
.then(() => result);
|
|
});
|
|
}
|
|
return this._enqueueWrite(() => {
|
|
const config = this.load();
|
|
const beforeBatches = config.history.length;
|
|
const beforeRows = countHistoryRows(config.history);
|
|
const pruned = applyHistoryRetention(config.history, retention, Date.now());
|
|
const result = {
|
|
removedBatches: beforeBatches - pruned.length,
|
|
removedRows: beforeRows - countHistoryRows(pruned),
|
|
keptBatches: pruned.length,
|
|
keptRows: countHistoryRows(pruned)
|
|
};
|
|
if (dryRun) return result;
|
|
config.history = pruned;
|
|
if (config.globalSettings) config.globalSettings.historyRetention = String(retention || 'all');
|
|
return this._commit(config).then(() => result);
|
|
});
|
|
}
|
|
|
|
clearHistory() {
|
|
if (this._historyMigrated) {
|
|
return this._enqueueHistoryWrite(() => this._writeHistoryFileAtomic([]));
|
|
}
|
|
return this._enqueueWrite(() => {
|
|
const config = this.load();
|
|
config.history = [];
|
|
return this._commit(config);
|
|
});
|
|
}
|
|
|
|
saveRotationCursors(cursors) {
|
|
return this._enqueueWrite(() => {
|
|
const config = this.load();
|
|
config.rotationCursors = (cursors && typeof cursors === 'object' && !Array.isArray(cursors)) ? cursors : {};
|
|
this._guardHosters(config, false);
|
|
return this._commit(config);
|
|
});
|
|
}
|
|
}
|
|
|
|
module.exports = ConfigStore;
|
|
module.exports.HOSTER_ACCOUNT_TEMPLATES = HOSTER_ACCOUNT_TEMPLATES;
|
|
module.exports.HOSTER_NAMES = HOSTER_NAMES;
|
|
module.exports.HOSTER_ADD_OPTIONS = HOSTER_ADD_OPTIONS;
|
|
module.exports.HISTORY_RETENTION_OPTIONS = HISTORY_RETENTION_OPTIONS;
|
|
module.exports.applyHistoryRetention = applyHistoryRetention;
|
|
module.exports.countHistoryRows = countHistoryRows;
|