Two issues: 1. Verlauf-Export CSV put the opaque file_code in the Link column when the upload had no real URL, so the column looked like just a bunch of IDs. Now only real http(s) URLs land in that column. 2. Hoster passwords and API keys were stored as plaintext in electron-config.json. Now wrapped with Electron's safeStorage (DPAPI on Windows, Keychain on macOS, libsecret on Linux) and stored as 'enc:v1:<base64>'. Credentials are decrypted on load so in-memory flows stay unchanged, and backups still export plaintext inside the existing .mhu envelope so they remain portable between machines/users. Legacy plaintext configs auto-migrate on next write.
288 lines
10 KiB
JavaScript
288 lines
10 KiB
JavaScript
const fs = require('fs');
|
|
const path = require('path');
|
|
const secretStore = require('./secret-store');
|
|
|
|
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
|
|
};
|
|
|
|
// 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,
|
|
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
|
|
}
|
|
},
|
|
history: []
|
|
};
|
|
|
|
class ConfigStore {
|
|
constructor(app) {
|
|
const dir = app && app.isPackaged
|
|
? app.getPath('userData')
|
|
: path.join(__dirname, '..');
|
|
this.filePath = path.join(dir, 'electron-config.json');
|
|
this._writeQueue = Promise.resolve(); // Serializes all writes to prevent race conditions
|
|
|
|
// Migrate config from old location if current doesn't exist
|
|
if (!fs.existsSync(this.filePath) && app && app.isPackaged) {
|
|
this._migrateFromOldPath(app);
|
|
}
|
|
}
|
|
|
|
_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);
|
|
}
|
|
|
|
load() {
|
|
try {
|
|
let data = null;
|
|
// Try main config
|
|
try { data = this._readAndParse(this.filePath); } catch {}
|
|
// Fallback to backup if main is empty/corrupt
|
|
if (!data) {
|
|
const backupPath = this.filePath + '.bak';
|
|
try { data = this._readAndParse(backupPath); } catch {}
|
|
}
|
|
if (!data) return JSON.parse(JSON.stringify(DEFAULTS));
|
|
|
|
// 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] || {}) };
|
|
}
|
|
}
|
|
const result = { hosters, hosterSettings, globalSettings, history: data.history || [] };
|
|
// Decrypt credentials stored with safeStorage so the rest of the app
|
|
// keeps working with plaintext in memory.
|
|
secretStore.decryptCredentials(result);
|
|
return result;
|
|
} catch {
|
|
return JSON.parse(JSON.stringify(DEFAULTS));
|
|
}
|
|
}
|
|
|
|
// Deep-clone a config and encrypt its credential fields. Never mutate the
|
|
// caller's object — the rest of the app holds plaintext references.
|
|
_serializeForDisk(config) {
|
|
const clone = JSON.parse(JSON.stringify(config));
|
|
secretStore.encryptCredentials(clone);
|
|
return JSON.stringify(clone, null, 2);
|
|
}
|
|
|
|
_enqueueWrite(fn) {
|
|
this._writeQueue = this._writeQueue.then(fn, fn);
|
|
return this._writeQueue;
|
|
}
|
|
|
|
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;
|
|
return this._atomicWrite(this._serializeForDisk(current));
|
|
});
|
|
}
|
|
|
|
loadHistory() {
|
|
const config = this.load();
|
|
return config.history || [];
|
|
}
|
|
|
|
_atomicWrite(data) {
|
|
return new Promise((resolve, reject) => {
|
|
const tmpPath = this.filePath + '.tmp';
|
|
const backupPath = this.filePath + '.bak';
|
|
fs.writeFile(tmpPath, data, 'utf-8', (err) => {
|
|
if (err) return reject(err);
|
|
try {
|
|
if (fs.existsSync(this.filePath)) {
|
|
const existing = fs.readFileSync(this.filePath, 'utf-8');
|
|
if (existing && existing.trim().length > 2) {
|
|
fs.writeFileSync(backupPath, existing, 'utf-8');
|
|
}
|
|
}
|
|
fs.renameSync(tmpPath, this.filePath);
|
|
} catch (e) { return reject(e); }
|
|
resolve();
|
|
});
|
|
});
|
|
}
|
|
|
|
appendHistory(entry) {
|
|
return this._enqueueWrite(() => {
|
|
const config = this.load();
|
|
config.history.push(entry);
|
|
return this._atomicWrite(this._serializeForDisk(config));
|
|
});
|
|
}
|
|
|
|
clearHistory() {
|
|
return this._enqueueWrite(() => {
|
|
const config = this.load();
|
|
config.history = [];
|
|
return this._atomicWrite(this._serializeForDisk(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;
|