release: Multi-Hoster-Upload v2.0.3
This commit is contained in:
@@ -13,6 +13,7 @@ Multi-Hoster-Upload is a Windows desktop app for managing large file batches acr
|
|||||||
- Add files by drag and drop or file selection and monitor live queue progress.
|
- Add files by drag and drop or file selection and monitor live queue progress.
|
||||||
- Control per-hoster concurrency, bandwidth limits, retries, and folder monitoring.
|
- Control per-hoster concurrency, bandwidth limits, retries, and folder monitoring.
|
||||||
- Keep local upload history and copy completed links in bulk.
|
- Keep local upload history and copy completed links in bulk.
|
||||||
|
- Transfer accounts and settings with a 75-character encrypted online key while encryption and decryption stay on the client.
|
||||||
|
|
||||||
## Supported hosters
|
## Supported hosters
|
||||||
|
|
||||||
@@ -32,7 +33,7 @@ Multi-Hoster-Upload is a Windows desktop app for managing large file batches acr
|
|||||||
|
|
||||||
## Local data and credentials
|
## Local data and credentials
|
||||||
|
|
||||||
Settings, queue state, and upload history are stored locally in the app's user-data directory. Hoster passwords and API keys are encrypted with Electron safeStorage before being written when operating-system encryption is available; on Windows this uses DPAPI for the current user profile.
|
Settings, queue state, and upload history are stored locally in the app's user-data directory. Hoster passwords and API keys are encrypted with Electron safeStorage before being written when operating-system encryption is available; on Windows this uses DPAPI for the current user profile. Online backups are optional, contain accounts and settings only, and are encrypted on the client before the server receives them. Upload history and queue state remain on the original device.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
|
|||||||
@@ -522,6 +522,21 @@ class ConfigStore {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
loadHistory() {
|
||||||
if (this._historyMigrated) {
|
if (this._historyMigrated) {
|
||||||
return this._readHistoryFile() || [];
|
return this._readHistoryFile() || [];
|
||||||
|
|||||||
@@ -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,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 };
|
||||||
@@ -56,6 +56,16 @@ class UploadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
replaceAccountPools(accountPools) {
|
||||||
|
this.accountPools = accountPools && typeof accountPools === 'object' ? accountPools : {};
|
||||||
|
this._failedAccounts.clear();
|
||||||
|
this._accountOverrides.clear();
|
||||||
|
this._suspectSizeMemo.clear();
|
||||||
|
this._suspectGoodAccounts.clear();
|
||||||
|
this._doodApiKeyCache.clear();
|
||||||
|
this._baselineCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
switchAccount(hoster, fallbackAccount) {
|
switchAccount(hoster, fallbackAccount) {
|
||||||
const prev = this._accountOverrides.get(hoster);
|
const prev = this._accountOverrides.get(hoster);
|
||||||
this._accountOverrides.set(hoster, fallbackAccount);
|
this._accountOverrides.set(hoster, fallbackAccount);
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ const { createAccountPicker } = require('./lib/account-rotation');
|
|||||||
const ClouddropUploader = require('./lib/clouddrop-upload');
|
const ClouddropUploader = require('./lib/clouddrop-upload');
|
||||||
const { checkForUpdate, installUpdate, abortUpdate } = require('./lib/updater');
|
const { checkForUpdate, installUpdate, abortUpdate } = require('./lib/updater');
|
||||||
const backupCrypto = require('./lib/backup-crypto');
|
const backupCrypto = require('./lib/backup-crypto');
|
||||||
|
const { createOnlineBackup, downloadOnlineBackup, uploadOnlineBackup } = require('./lib/online-backup');
|
||||||
|
const { createPortableSettingsSnapshot, prepareImportedSettings } = require('./lib/settings-backup');
|
||||||
|
const { createSettingsImportGate } = require('./lib/settings-import-gate');
|
||||||
const FolderMonitor = require('./lib/folder-monitor');
|
const FolderMonitor = require('./lib/folder-monitor');
|
||||||
const RemoteServer = require('./lib/remote-server');
|
const RemoteServer = require('./lib/remote-server');
|
||||||
const { maybeRotateLogFile } = require('./lib/log-rotation');
|
const { maybeRotateLogFile } = require('./lib/log-rotation');
|
||||||
@@ -96,6 +99,7 @@ let tray = null;
|
|||||||
const configStore = new ConfigStore(app);
|
const configStore = new ConfigStore(app);
|
||||||
configStore.setPerfLog((m) => { try { logInfo(m); } catch {} });
|
configStore.setPerfLog((m) => { try { logInfo(m); } catch {} });
|
||||||
let uploadManager = null;
|
let uploadManager = null;
|
||||||
|
const settingsImportGate = createSettingsImportGate(() => !!(uploadManager && uploadManager.running));
|
||||||
let diagnosticAgent = null;
|
let diagnosticAgent = null;
|
||||||
let _diagHandler = null;
|
let _diagHandler = null;
|
||||||
|
|
||||||
@@ -1721,6 +1725,7 @@ ipcMain.handle('get-file-sizes', async (_event, paths) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('start-upload', (_event, payload) => {
|
ipcMain.handle('start-upload', (_event, payload) => {
|
||||||
|
if (!settingsImportGate.canStartUpload()) return { error: 'Einstellungen werden gerade importiert' };
|
||||||
const config = configStore.load();
|
const config = configStore.load();
|
||||||
const files = payload && Array.isArray(payload.files) ? payload.files : [];
|
const files = payload && Array.isArray(payload.files) ? payload.files : [];
|
||||||
const hosters = payload && Array.isArray(payload.hosters) ? payload.hosters : [];
|
const hosters = payload && Array.isArray(payload.hosters) ? payload.hosters : [];
|
||||||
@@ -2207,6 +2212,83 @@ ipcMain.handle('clear-history', async () => {
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function syncImportedRuntime(config) {
|
||||||
|
const warnings = [];
|
||||||
|
try {
|
||||||
|
setLogVerbose(!!config.globalSettings.logVerbose);
|
||||||
|
if (uploadManager) {
|
||||||
|
uploadManager.updateSettings(config.hosterSettings, config.globalSettings);
|
||||||
|
uploadManager.replaceAccountPools(buildAccountPools(config));
|
||||||
|
}
|
||||||
|
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.setAlwaysOnTop(!!config.globalSettings.alwaysOnTop);
|
||||||
|
} catch (error) {
|
||||||
|
debugLog(`backup runtime settings failed: ${error.message}`);
|
||||||
|
warnings.push('allgemeine Laufzeiteinstellungen');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
folderMonitor.stop();
|
||||||
|
const folderSettings = config.globalSettings.folderMonitor;
|
||||||
|
if (folderSettings && folderSettings.enabled && folderSettings.folderPath) startFolderMonitor(folderSettings);
|
||||||
|
} catch (error) {
|
||||||
|
debugLog(`backup folder monitor sync failed: ${error.message}`);
|
||||||
|
warnings.push('Ordnerüberwachung');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const remoteSettings = config.globalSettings.remote;
|
||||||
|
if (remoteSettings && remoteSettings.enabled) await startRemoteServer();
|
||||||
|
else if (remoteServer) {
|
||||||
|
remoteServer.stop();
|
||||||
|
remoteServer = null;
|
||||||
|
destroyCaptureWindow();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
debugLog(`backup remote sync failed: ${error.message}`);
|
||||||
|
warnings.push('Remote-Steuerung');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const diagnostics = config.globalSettings.diagnostics;
|
||||||
|
if (diagnostics && diagnostics.enabled) await startDiagnosticAgent();
|
||||||
|
else stopDiagnosticAgent();
|
||||||
|
} catch (error) {
|
||||||
|
debugLog(`backup diagnostics sync failed: ${error.message}`);
|
||||||
|
warnings.push('Diagnose');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (config.globalSettings.showDropTarget) createDropTargetWindow();
|
||||||
|
else destroyDropTargetWindow();
|
||||||
|
} catch (error) {
|
||||||
|
debugLog(`backup drop target sync failed: ${error.message}`);
|
||||||
|
warnings.push('Drop-Target');
|
||||||
|
}
|
||||||
|
return warnings;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyImportedSettings(imported) {
|
||||||
|
settingsImportGate.begin();
|
||||||
|
try {
|
||||||
|
const prepared = prepareImportedSettings(imported);
|
||||||
|
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
|
const preImportPath = configStore.filePath.replace('.json', `.pre-import-${ts}.json`);
|
||||||
|
try { fs.copyFileSync(configStore.filePath, preImportPath); } catch {}
|
||||||
|
await configStore.replaceSettings(prepared);
|
||||||
|
_rotationCursors = {};
|
||||||
|
_sessionFailedAccounts.clear();
|
||||||
|
_sessionAccountOverrides.clear();
|
||||||
|
_invalidateLogSettings();
|
||||||
|
const config = configStore.load();
|
||||||
|
const warnings = await syncImportedRuntime(config);
|
||||||
|
return { config, warnings };
|
||||||
|
} finally {
|
||||||
|
settingsImportGate.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBackupFile(filePath) {
|
||||||
|
const stat = fs.statSync(filePath);
|
||||||
|
if (!stat.isFile() || stat.size > 2 * 1024 * 1024) throw new Error('Backup-Datei ist zu groß oder ungültig');
|
||||||
|
return fs.readFileSync(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Backup export / import ---
|
// --- Backup export / import ---
|
||||||
ipcMain.handle('export-backup', async () => {
|
ipcMain.handle('export-backup', async () => {
|
||||||
const _bd = new Date();
|
const _bd = new Date();
|
||||||
@@ -2220,8 +2302,7 @@ ipcMain.handle('export-backup', async () => {
|
|||||||
]
|
]
|
||||||
});
|
});
|
||||||
if (canceled || !filePath) return { ok: false, canceled: true };
|
if (canceled || !filePath) return { ok: false, canceled: true };
|
||||||
const config = configStore.load();
|
const config = createPortableSettingsSnapshot(configStore.load());
|
||||||
config.history = [];
|
|
||||||
if (filePath.toLowerCase().endsWith('.json')) {
|
if (filePath.toLowerCase().endsWith('.json')) {
|
||||||
fs.writeFileSync(filePath, JSON.stringify(config, null, 2), 'utf-8');
|
fs.writeFileSync(filePath, JSON.stringify(config, null, 2), 'utf-8');
|
||||||
} else {
|
} else {
|
||||||
@@ -2235,7 +2316,7 @@ ipcMain.handle('import-backup', async (_event, legacyPassword) => {
|
|||||||
let buffer;
|
let buffer;
|
||||||
let sourcePath = _lastImportPath;
|
let sourcePath = _lastImportPath;
|
||||||
if (legacyPassword && sourcePath) {
|
if (legacyPassword && sourcePath) {
|
||||||
buffer = fs.readFileSync(sourcePath);
|
buffer = readBackupFile(sourcePath);
|
||||||
} else {
|
} else {
|
||||||
const { canceled, filePaths } = await dialog.showOpenDialog(mainWindow, {
|
const { canceled, filePaths } = await dialog.showOpenDialog(mainWindow, {
|
||||||
title: 'Backup importieren',
|
title: 'Backup importieren',
|
||||||
@@ -2248,7 +2329,7 @@ ipcMain.handle('import-backup', async (_event, legacyPassword) => {
|
|||||||
});
|
});
|
||||||
if (canceled || !filePaths.length) return { ok: false, canceled: true };
|
if (canceled || !filePaths.length) return { ok: false, canceled: true };
|
||||||
sourcePath = filePaths[0];
|
sourcePath = filePaths[0];
|
||||||
buffer = fs.readFileSync(sourcePath);
|
buffer = readBackupFile(sourcePath);
|
||||||
_lastImportPath = sourcePath;
|
_lastImportPath = sourcePath;
|
||||||
}
|
}
|
||||||
let imported;
|
let imported;
|
||||||
@@ -2273,40 +2354,33 @@ ipcMain.handle('import-backup', async (_event, legacyPassword) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
_lastImportPath = null;
|
_lastImportPath = null;
|
||||||
// Validate imported data has required structure
|
try {
|
||||||
if (!imported || typeof imported !== 'object' || !imported.hosters || !imported.hosterSettings || !imported.globalSettings) {
|
return { ok: true, ...await applyImportedSettings(imported) };
|
||||||
return { ok: false, error: 'Backup-Datei hat ungültige Struktur (hosters, hosterSettings oder globalSettings fehlt).' };
|
} catch (error) {
|
||||||
|
return { ok: false, error: error.message || String(error) };
|
||||||
}
|
}
|
||||||
// Safety net: timestamped backup so multiple imports don't overwrite each other
|
});
|
||||||
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
||||||
const preImportPath = configStore.filePath.replace('.json', `.pre-import-${ts}.json`);
|
ipcMain.handle('online-backup:create', async () => {
|
||||||
try { fs.copyFileSync(configStore.filePath, preImportPath); } catch {}
|
try {
|
||||||
// Strip machine-specific state: absolute paths from the source machine will
|
const snapshot = createPortableSettingsSnapshot(configStore.load());
|
||||||
// not exist on this one (e.g. C:\Users\Administrator\... vs \bakeredwin318\...).
|
const created = createOnlineBackup(snapshot, app.getVersion());
|
||||||
// Any path that does not resolve locally is cleared so the user can re-set it
|
await uploadOnlineBackup(created.record);
|
||||||
// instead of hitting silent failures later.
|
return { ok: true, key: created.key };
|
||||||
const importedGlobal = imported.globalSettings || {};
|
} catch (error) {
|
||||||
if (importedGlobal.logFilePath && !fs.existsSync(path.dirname(importedGlobal.logFilePath))) {
|
return { ok: false, error: error.message || String(error) };
|
||||||
importedGlobal.logFilePath = '';
|
|
||||||
}
|
}
|
||||||
if (importedGlobal.folderMonitor && typeof importedGlobal.folderMonitor === 'object') {
|
});
|
||||||
const fm = importedGlobal.folderMonitor;
|
|
||||||
if (fm.folderPath && !fs.existsSync(fm.folderPath)) {
|
ipcMain.handle('online-backup:restore', async (_event, key) => {
|
||||||
fm.folderPath = '';
|
try {
|
||||||
fm.enabled = false;
|
const normalized = String(key || '').trim();
|
||||||
}
|
if (normalized.length > 128) throw new Error('Online-Sicherungsschlüssel ist ungültig');
|
||||||
|
const payload = await downloadOnlineBackup(normalized);
|
||||||
|
return { ok: true, ...await applyImportedSettings(payload.settings) };
|
||||||
|
} catch (error) {
|
||||||
|
return { ok: false, error: error.message || String(error) };
|
||||||
}
|
}
|
||||||
importedGlobal.pendingQueue = null;
|
|
||||||
// Single atomic write — no split state, no TOCTOU race
|
|
||||||
const merged = {
|
|
||||||
hosters: imported.hosters,
|
|
||||||
hosterSettings: imported.hosterSettings,
|
|
||||||
globalSettings: importedGlobal,
|
|
||||||
history: []
|
|
||||||
};
|
|
||||||
await configStore._atomicWrite(configStore._serializeForDisk(merged));
|
|
||||||
_invalidateLogSettings();
|
|
||||||
return { ok: true, config: configStore.load() };
|
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('read-own-upload-log', () => {
|
ipcMain.handle('read-own-upload-log', () => {
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "multi-hoster-uploader",
|
"name": "multi-hoster-uploader",
|
||||||
"version": "2.0.2",
|
"version": "2.0.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "multi-hoster-uploader",
|
"name": "multi-hoster-uploader",
|
||||||
"version": "2.0.2",
|
"version": "2.0.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chokidar": "^3.6.0",
|
"chokidar": "^3.6.0",
|
||||||
"undici": "^7.29.0",
|
"undici": "^7.29.0",
|
||||||
|
|||||||
+3
-1
@@ -1,11 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "multi-hoster-uploader",
|
"name": "multi-hoster-uploader",
|
||||||
"version": "2.0.2",
|
"version": "2.0.3",
|
||||||
"description": "Upload files to doodstream, voe, vidmoly, byse simultaneously",
|
"description": "Upload files to doodstream, voe, vidmoly, byse simultaneously",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "electron .",
|
"start": "electron .",
|
||||||
"test": "node --test tests/*.test.js tests/ui-smoke.js",
|
"test": "node --test tests/*.test.js tests/ui-smoke.js",
|
||||||
|
"test:backup-api": "npm --prefix services/backup-api test",
|
||||||
"dist": "electron-builder --win",
|
"dist": "electron-builder --win",
|
||||||
"release:win": "electron-builder --publish never --win nsis portable",
|
"release:win": "electron-builder --publish never --win nsis portable",
|
||||||
"release:gitea": "node scripts/release_gitea.mjs"
|
"release:gitea": "node scripts/release_gitea.mjs"
|
||||||
@@ -32,6 +33,7 @@
|
|||||||
"files": [
|
"files": [
|
||||||
"main.js",
|
"main.js",
|
||||||
"preload.js",
|
"preload.js",
|
||||||
|
"preload-drop-target.js",
|
||||||
"lib/**/*",
|
"lib/**/*",
|
||||||
"renderer/**/*",
|
"renderer/**/*",
|
||||||
"assets/app_icon.ico",
|
"assets/app_icon.ico",
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
// Backup
|
// Backup
|
||||||
exportBackup: () => ipcRenderer.invoke('export-backup'),
|
exportBackup: () => ipcRenderer.invoke('export-backup'),
|
||||||
importBackup: (legacyPassword) => ipcRenderer.invoke('import-backup', legacyPassword),
|
importBackup: (legacyPassword) => ipcRenderer.invoke('import-backup', legacyPassword),
|
||||||
|
createOnlineBackup: () => ipcRenderer.invoke('online-backup:create'),
|
||||||
|
restoreOnlineBackup: (key) => ipcRenderer.invoke('online-backup:restore', key),
|
||||||
|
|
||||||
// Folder Monitor
|
// Folder Monitor
|
||||||
folderMonitorStart: (settings) => ipcRenderer.invoke('folder-monitor:start', settings),
|
folderMonitorStart: (settings) => ipcRenderer.invoke('folder-monitor:start', settings),
|
||||||
|
|||||||
+176
-17
@@ -104,6 +104,7 @@ const queuePersistThrottle = (window.ThrottleTimer && window.ThrottleTimer.makeT
|
|||||||
})();
|
})();
|
||||||
let _restoredSnapshotSavedAt = null;
|
let _restoredSnapshotSavedAt = null;
|
||||||
let settingsSaveTimer = null;
|
let settingsSaveTimer = null;
|
||||||
|
const settingsSaveCoordinator = window.SerializedRunner.createSerializedRunner(performSaveSettings);
|
||||||
let lastUploadStats = { state: 'idle', globalSpeedKbs: 0, totalBytes: 0, elapsed: 0, activeJobs: 0 };
|
let lastUploadStats = { state: 'idle', globalSpeedKbs: 0, totalBytes: 0, elapsed: 0, activeJobs: 0 };
|
||||||
const AUTO_CHECK_PREF_KEY = 'autoHealthCheckBeforeUpload';
|
const AUTO_CHECK_PREF_KEY = 'autoHealthCheckBeforeUpload';
|
||||||
const QUEUE_COL_WIDTHS_KEY = 'queueColumnWidthsPx';
|
const QUEUE_COL_WIDTHS_KEY = 'queueColumnWidthsPx';
|
||||||
@@ -453,6 +454,8 @@ async function _handleMenuAction(action) {
|
|||||||
case 'add-folder': document.getElementById('addFolderBtn')?.click(); break;
|
case 'add-folder': document.getElementById('addFolderBtn')?.click(); break;
|
||||||
case 'backup-export': doBackupExport(); break;
|
case 'backup-export': doBackupExport(); break;
|
||||||
case 'backup-import': doBackupImport(); break;
|
case 'backup-import': doBackupImport(); break;
|
||||||
|
case 'online-backup-create': doOnlineBackupCreate(); break;
|
||||||
|
case 'online-backup-restore': openOnlineBackupRestore(); break;
|
||||||
case 'restart': if (confirm('Anwendung neu starten?')) window.api.restartApp(); break;
|
case 'restart': if (confirm('Anwendung neu starten?')) window.api.restartApp(); break;
|
||||||
case 'quit': window.api.quitApp(); break;
|
case 'quit': window.api.quitApp(); break;
|
||||||
case 'open-settings': document.querySelector('.tab[data-view="settings"]')?.click(); break;
|
case 'open-settings': document.querySelector('.tab[data-view="settings"]')?.click(); break;
|
||||||
@@ -1829,6 +1832,7 @@ function copySelectedRecentLinks() {
|
|||||||
// --- Backup export / import ---
|
// --- Backup export / import ---
|
||||||
async function doBackupExport() {
|
async function doBackupExport() {
|
||||||
try {
|
try {
|
||||||
|
await flushPendingSettingsSaves();
|
||||||
const result = await window.api.exportBackup();
|
const result = await window.api.exportBackup();
|
||||||
if (result && result.ok) showCopyToast('Backup exportiert');
|
if (result && result.ok) showCopyToast('Backup exportiert');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -1836,6 +1840,102 @@ async function doBackupExport() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyImportedConfig(importedConfig, message) {
|
||||||
|
config = importedConfig;
|
||||||
|
hosterSettings = config.hosterSettings || {};
|
||||||
|
ensureAccountStatusEntries();
|
||||||
|
syncSelectedUploadHosters();
|
||||||
|
alwaysOnTopState = !!(config.globalSettings && config.globalSettings.alwaysOnTop);
|
||||||
|
window.api.setAlwaysOnTop(alwaysOnTopState);
|
||||||
|
renderSettings();
|
||||||
|
renderAccounts();
|
||||||
|
renderHosterSummary();
|
||||||
|
renderHosterModal();
|
||||||
|
loadHistory();
|
||||||
|
showCopyToast(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setOnlineBackupStatus(message, state = '') {
|
||||||
|
const status = document.getElementById('onlineBackupStatus');
|
||||||
|
if (!status) return;
|
||||||
|
status.textContent = message;
|
||||||
|
status.dataset.state = state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setOnlineBackupBusy(busy) {
|
||||||
|
const createButton = document.getElementById('createOnlineBackupBtn');
|
||||||
|
const restoreButton = document.getElementById('restoreOnlineBackupBtn');
|
||||||
|
if (createButton) createButton.disabled = busy;
|
||||||
|
if (restoreButton) restoreButton.disabled = busy || !/^MHU2-[A-Za-z0-9_-]{70}$/.test(document.getElementById('onlineBackupKeyInput')?.value.trim() || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doOnlineBackupCreate() {
|
||||||
|
if (doOnlineBackupCreate.busy) return;
|
||||||
|
doOnlineBackupCreate.busy = true;
|
||||||
|
try {
|
||||||
|
await flushPendingSettingsSaves();
|
||||||
|
openOnlineBackupView();
|
||||||
|
} catch (error) {
|
||||||
|
openOnlineBackupView();
|
||||||
|
setOnlineBackupStatus(error.message || String(error), 'error');
|
||||||
|
doOnlineBackupCreate.busy = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setOnlineBackupBusy(true);
|
||||||
|
setOnlineBackupStatus('Verschlüssele und speichere Einstellungen…', 'busy');
|
||||||
|
try {
|
||||||
|
const result = await window.api.createOnlineBackup();
|
||||||
|
if (!result || !result.ok) throw new Error(result?.error || 'Online-Sicherung konnte nicht erstellt werden');
|
||||||
|
const output = document.getElementById('onlineBackupKeyOutput');
|
||||||
|
const copyButton = document.getElementById('copyOnlineBackupKeyBtn');
|
||||||
|
if (output) output.value = result.key;
|
||||||
|
if (copyButton) copyButton.disabled = false;
|
||||||
|
setOnlineBackupStatus('Neuer Schlüssel erstellt. Ältere Schlüssel bleiben gültig.', 'success');
|
||||||
|
showCopyToast('Online-Schlüssel erstellt');
|
||||||
|
} catch (error) {
|
||||||
|
setOnlineBackupStatus(error.message || String(error), 'error');
|
||||||
|
} finally {
|
||||||
|
setOnlineBackupBusy(false);
|
||||||
|
doOnlineBackupCreate.busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openOnlineBackupView(focusRestore = false) {
|
||||||
|
document.querySelector('.tab[data-view="settings"]')?.click();
|
||||||
|
document.querySelector('[data-subtab="backup"]')?.click();
|
||||||
|
if (focusRestore) document.getElementById('onlineBackupKeyInput')?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openOnlineBackupRestore() {
|
||||||
|
openOnlineBackupView(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doOnlineBackupRestore() {
|
||||||
|
const input = document.getElementById('onlineBackupKeyInput');
|
||||||
|
const key = input?.value.trim() || '';
|
||||||
|
if (!/^MHU2-[A-Za-z0-9_-]{70}$/.test(key)) {
|
||||||
|
setOnlineBackupStatus('Der Schlüssel muss mit MHU2- beginnen und exakt 75 Zeichen lang sein.', 'error');
|
||||||
|
input?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setOnlineBackupBusy(true);
|
||||||
|
setOnlineBackupStatus('Speichere aktuelle Einstellungen…', 'busy');
|
||||||
|
try {
|
||||||
|
await flushPendingSettingsSaves();
|
||||||
|
setOnlineBackupStatus('Lade und entschlüssele Einstellungen…', 'busy');
|
||||||
|
const result = await window.api.restoreOnlineBackup(key);
|
||||||
|
if (!result || !result.ok) throw new Error(result?.error || 'Online-Sicherung konnte nicht importiert werden');
|
||||||
|
applyImportedConfig(result.config, 'Online-Backup importiert');
|
||||||
|
const warnings = Array.isArray(result.warnings) ? result.warnings : [];
|
||||||
|
if (warnings.length) setOnlineBackupStatus(`Einstellungen übernommen. Bitte prüfen: ${warnings.join(', ')}.`, 'warning');
|
||||||
|
else setOnlineBackupStatus('Alle Accounts und Einstellungen wurden übernommen.', 'success');
|
||||||
|
} catch (error) {
|
||||||
|
setOnlineBackupStatus(error.message || String(error), 'error');
|
||||||
|
} finally {
|
||||||
|
setOnlineBackupBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function askLegacyBackupPassword(hint) {
|
function askLegacyBackupPassword(hint) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const overlay = document.createElement('div');
|
const overlay = document.createElement('div');
|
||||||
@@ -1906,6 +2006,7 @@ function askLegacyBackupPassword(hint) {
|
|||||||
async function doBackupImport(legacyPassword) {
|
async function doBackupImport(legacyPassword) {
|
||||||
const pw = typeof legacyPassword === 'string' ? legacyPassword : undefined;
|
const pw = typeof legacyPassword === 'string' ? legacyPassword : undefined;
|
||||||
try {
|
try {
|
||||||
|
await flushPendingSettingsSaves();
|
||||||
const result = await window.api.importBackup(pw);
|
const result = await window.api.importBackup(pw);
|
||||||
if (!result || result.canceled) return;
|
if (!result || result.canceled) return;
|
||||||
if (result.needsPassword) {
|
if (result.needsPassword) {
|
||||||
@@ -1914,16 +2015,10 @@ async function doBackupImport(legacyPassword) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
config = result.config;
|
applyImportedConfig(result.config, 'Backup importiert');
|
||||||
hosterSettings = config.hosterSettings || {};
|
if (Array.isArray(result.warnings) && result.warnings.length) {
|
||||||
alwaysOnTopState = !!(config.globalSettings && config.globalSettings.alwaysOnTop);
|
alert(`Backup importiert. Bitte prüfen: ${result.warnings.join(', ')}.`);
|
||||||
window.api.setAlwaysOnTop(alwaysOnTopState);
|
}
|
||||||
renderSettings();
|
|
||||||
renderAccounts();
|
|
||||||
renderHosterSummary();
|
|
||||||
renderHosterModal();
|
|
||||||
loadHistory();
|
|
||||||
showCopyToast('Backup importiert');
|
|
||||||
} else if (result.error) {
|
} else if (result.error) {
|
||||||
alert('Import fehlgeschlagen: ' + result.error);
|
alert('Import fehlgeschlagen: ' + result.error);
|
||||||
}
|
}
|
||||||
@@ -3339,10 +3434,33 @@ function renderSettings() {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
pages.backup.innerHTML = `
|
pages.backup.innerHTML = `
|
||||||
<p class="hint" style="margin:0 0 10px">Alle Accounts und Einstellungen exportieren oder importieren. Der Upload-Verlauf bleibt lokal und wird nicht übertragen; nach einem Import ist der Verlauf-Tab leer.</p>
|
<p class="hint" style="margin:0 0 10px">Alle Accounts und Einstellungen exportieren oder importieren. Der Upload-Verlauf wird nicht übertragen und bleibt auf diesem Gerät.</p>
|
||||||
<div style="display:flex;gap:8px">
|
<section class="online-backup-panel" aria-labelledby="onlineBackupHeading">
|
||||||
<button class="btn btn-secondary" id="exportBackupBtn">Backup exportieren</button>
|
<div>
|
||||||
<button class="btn btn-secondary" id="importBackupBtn">Backup importieren</button>
|
<h3 id="onlineBackupHeading">Verschlüsseltes Online-Backup</h3>
|
||||||
|
<p>Die Verschlüsselung findet ausschließlich auf diesem Gerät statt. Der Server speichert nur verschlüsselte Daten.</p>
|
||||||
|
</div>
|
||||||
|
<div class="online-backup-action">
|
||||||
|
<button class="btn btn-primary" id="createOnlineBackupBtn">Neuen Schlüssel erzeugen</button>
|
||||||
|
<span class="hint">Jeder Export erzeugt einen neuen Schlüssel. Ältere Schlüssel bleiben gültig.</span>
|
||||||
|
</div>
|
||||||
|
<div class="online-backup-key-row">
|
||||||
|
<label for="onlineBackupKeyOutput">Dein neuer Schlüssel</label>
|
||||||
|
<input type="text" class="key-input" id="onlineBackupKeyOutput" readonly spellcheck="false" autocomplete="off" placeholder="Nach dem Export erscheint hier der 75-stellige Schlüssel">
|
||||||
|
<button class="btn btn-secondary" id="copyOnlineBackupKeyBtn" disabled>Kopieren</button>
|
||||||
|
</div>
|
||||||
|
<div class="online-backup-key-row">
|
||||||
|
<label for="onlineBackupKeyInput">Vorhandenen Schlüssel importieren</label>
|
||||||
|
<input type="password" class="key-input" id="onlineBackupKeyInput" maxlength="75" pattern="MHU2-[A-Za-z0-9_-]{70}" spellcheck="false" autocomplete="off" placeholder="MHU2-…">
|
||||||
|
<button class="btn btn-secondary" id="restoreOnlineBackupBtn" disabled>Online importieren</button>
|
||||||
|
</div>
|
||||||
|
<p class="online-backup-warning">Behandle den Schlüssel wie ein Passwort. Wer ihn besitzt, kann die verschlüsselten Einstellungen entschlüsseln.</p>
|
||||||
|
<div class="online-backup-status" id="onlineBackupStatus" role="status" aria-live="polite"></div>
|
||||||
|
</section>
|
||||||
|
<div class="settings-section-label">Lokales Datei-Backup</div>
|
||||||
|
<div class="backup-file-actions">
|
||||||
|
<button class="btn btn-secondary" id="exportBackupBtn">Datei exportieren</button>
|
||||||
|
<button class="btn btn-secondary" id="importBackupBtn">Datei importieren</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -3574,6 +3692,20 @@ function renderSettings() {
|
|||||||
|
|
||||||
document.getElementById('exportBackupBtn').addEventListener('click', () => doBackupExport());
|
document.getElementById('exportBackupBtn').addEventListener('click', () => doBackupExport());
|
||||||
document.getElementById('importBackupBtn').addEventListener('click', () => doBackupImport());
|
document.getElementById('importBackupBtn').addEventListener('click', () => doBackupImport());
|
||||||
|
document.getElementById('createOnlineBackupBtn').addEventListener('click', () => doOnlineBackupCreate());
|
||||||
|
document.getElementById('copyOnlineBackupKeyBtn').addEventListener('click', async () => {
|
||||||
|
const key = document.getElementById('onlineBackupKeyOutput').value;
|
||||||
|
if (!key) return;
|
||||||
|
await window.api.copyToClipboard(key);
|
||||||
|
showCopyToast('Online-Schlüssel kopiert');
|
||||||
|
});
|
||||||
|
document.getElementById('onlineBackupKeyInput').addEventListener('input', (event) => {
|
||||||
|
const valid = /^MHU2-[A-Za-z0-9_-]{70}$/.test(event.target.value.trim());
|
||||||
|
document.getElementById('restoreOnlineBackupBtn').disabled = !valid;
|
||||||
|
if (event.target.value && !valid) setOnlineBackupStatus('Der Schlüssel muss exakt 75 Zeichen lang sein.', '');
|
||||||
|
else setOnlineBackupStatus('', '');
|
||||||
|
});
|
||||||
|
document.getElementById('restoreOnlineBackupBtn').addEventListener('click', () => doOnlineBackupRestore());
|
||||||
|
|
||||||
document.getElementById('chooseLogFilePathBtn')?.addEventListener('click', chooseLogFilePath);
|
document.getElementById('chooseLogFilePathBtn')?.addEventListener('click', chooseLogFilePath);
|
||||||
document.getElementById('openLogFolderBtn')?.addEventListener('click', () => window.api.openLogFolder());
|
document.getElementById('openLogFolderBtn')?.addEventListener('click', () => window.api.openLogFolder());
|
||||||
@@ -3613,13 +3745,18 @@ function scheduleSettingsSave() {
|
|||||||
if (feedback) feedback.textContent = 'Speichert...';
|
if (feedback) feedback.textContent = 'Speichert...';
|
||||||
clearTimeout(settingsSaveTimer);
|
clearTimeout(settingsSaveTimer);
|
||||||
settingsSaveTimer = setTimeout(() => {
|
settingsSaveTimer = setTimeout(() => {
|
||||||
|
settingsSaveTimer = null;
|
||||||
saveSettings({ feedbackText: 'Automatisch gespeichert' }).catch((err) => {
|
saveSettings({ feedbackText: 'Automatisch gespeichert' }).catch((err) => {
|
||||||
if (feedback) feedback.textContent = `Speichern fehlgeschlagen: ${err.message}`;
|
if (feedback) feedback.textContent = `Speichern fehlgeschlagen: ${err.message}`;
|
||||||
});
|
});
|
||||||
}, 350);
|
}, 350);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveSettings(options = {}) {
|
function saveSettings(options = {}) {
|
||||||
|
return settingsSaveCoordinator.run(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function performSaveSettings(options = {}) {
|
||||||
const { feedbackText = 'Gespeichert!' } = options;
|
const { feedbackText = 'Gespeichert!' } = options;
|
||||||
const newHosterSettings = { ...(config.hosterSettings || {}) };
|
const newHosterSettings = { ...(config.hosterSettings || {}) };
|
||||||
const cur = config.globalSettings || {};
|
const cur = config.globalSettings || {};
|
||||||
@@ -3721,6 +3858,7 @@ async function saveSettings(options = {}) {
|
|||||||
config.globalSettings = globalSettings;
|
config.globalSettings = globalSettings;
|
||||||
hosterSettings = newHosterSettings;
|
hosterSettings = newHosterSettings;
|
||||||
clearTimeout(settingsSaveTimer);
|
clearTimeout(settingsSaveTimer);
|
||||||
|
settingsSaveTimer = null;
|
||||||
|
|
||||||
// Start/stop folder monitor based on settings
|
// Start/stop folder monitor based on settings
|
||||||
const fmSettings = globalSettings.folderMonitor;
|
const fmSettings = globalSettings.folderMonitor;
|
||||||
@@ -3949,12 +4087,20 @@ function _hosterGroupOpenState(name, summary) {
|
|||||||
const _hosterGroupOpenMemory = new Map();
|
const _hosterGroupOpenMemory = new Map();
|
||||||
|
|
||||||
let _hosterSettingsSaveTimer = null;
|
let _hosterSettingsSaveTimer = null;
|
||||||
|
const hosterSettingsSaveCoordinator = window.SerializedRunner.createSerializedRunner(performHosterSettingsSave);
|
||||||
function scheduleHosterSettingsSave() {
|
function scheduleHosterSettingsSave() {
|
||||||
clearTimeout(_hosterSettingsSaveTimer);
|
clearTimeout(_hosterSettingsSaveTimer);
|
||||||
_hosterSettingsSaveTimer = setTimeout(() => { saveHosterSettingsFromDom().catch(() => {}); }, 350);
|
_hosterSettingsSaveTimer = setTimeout(() => {
|
||||||
|
_hosterSettingsSaveTimer = null;
|
||||||
|
saveHosterSettingsFromDom().catch(() => {});
|
||||||
|
}, 350);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveHosterSettingsFromDom() {
|
function saveHosterSettingsFromDom() {
|
||||||
|
return hosterSettingsSaveCoordinator.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function performHosterSettingsSave() {
|
||||||
const newHosterSettings = { ...(config.hosterSettings || {}) };
|
const newHosterSettings = { ...(config.hosterSettings || {}) };
|
||||||
for (const name of HOSTERS) {
|
for (const name of HOSTERS) {
|
||||||
const inputs = document.querySelectorAll(`.account-hoster-settings-body .hs-input[data-hoster="${name}"]`);
|
const inputs = document.querySelectorAll(`.account-hoster-settings-body .hs-input[data-hoster="${name}"]`);
|
||||||
@@ -3973,6 +4119,19 @@ async function saveHosterSettingsFromDom() {
|
|||||||
hosterSettings = newHosterSettings;
|
hosterSettings = newHosterSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function flushPendingSettingsSaves() {
|
||||||
|
await Promise.all([settingsSaveCoordinator.flush(), hosterSettingsSaveCoordinator.flush()]);
|
||||||
|
const flushSettings = settingsSaveTimer !== null;
|
||||||
|
const flushHosters = _hosterSettingsSaveTimer !== null;
|
||||||
|
clearTimeout(settingsSaveTimer);
|
||||||
|
clearTimeout(_hosterSettingsSaveTimer);
|
||||||
|
settingsSaveTimer = null;
|
||||||
|
_hosterSettingsSaveTimer = null;
|
||||||
|
if (flushSettings) await saveSettings({ feedbackText: 'Automatisch gespeichert' });
|
||||||
|
if (flushHosters) await saveHosterSettingsFromDom();
|
||||||
|
await Promise.all([settingsSaveCoordinator.flush(), hosterSettingsSaveCoordinator.flush()]);
|
||||||
|
}
|
||||||
|
|
||||||
function _buildHosterSettingsHtml(name) {
|
function _buildHosterSettingsHtml(name) {
|
||||||
const hs = (config.hosterSettings && config.hosterSettings[name]) || {};
|
const hs = (config.hosterSettings && config.hosterSettings[name]) || {};
|
||||||
const maxSpeedMbs = hs.maxSpeedKbs > 0 ? String(+(hs.maxSpeedKbs / 1024).toFixed(2)) : '0';
|
const maxSpeedMbs = hs.maxSpeedKbs > 0 ? String(+(hs.maxSpeedKbs / 1024).toFixed(2)) : '0';
|
||||||
|
|||||||
@@ -19,6 +19,8 @@
|
|||||||
<div class="menu-submenu-dropdown" style="display:none">
|
<div class="menu-submenu-dropdown" style="display:none">
|
||||||
<button class="menu-dropdown-item" data-menu-action="backup-export"><span>Exportieren</span></button>
|
<button class="menu-dropdown-item" data-menu-action="backup-export"><span>Exportieren</span></button>
|
||||||
<button class="menu-dropdown-item" data-menu-action="backup-import"><span>Importieren</span></button>
|
<button class="menu-dropdown-item" data-menu-action="backup-import"><span>Importieren</span></button>
|
||||||
|
<button class="menu-dropdown-item" data-menu-action="online-backup-create"><span>Online-Schlüssel erstellen</span></button>
|
||||||
|
<button class="menu-dropdown-item" data-menu-action="online-backup-restore"><span>Online-Schlüssel importieren</span></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="menu-separator"></div>
|
<div class="menu-separator"></div>
|
||||||
@@ -421,6 +423,7 @@
|
|||||||
<script src="../lib/throttled-cache.js"></script>
|
<script src="../lib/throttled-cache.js"></script>
|
||||||
<script src="../lib/coalesced-set.js"></script>
|
<script src="../lib/coalesced-set.js"></script>
|
||||||
<script src="../lib/throttle-timer.js"></script>
|
<script src="../lib/throttle-timer.js"></script>
|
||||||
|
<script src="../lib/serialized-runner.js"></script>
|
||||||
<script src="account-submit.js"></script>
|
<script src="account-submit.js"></script>
|
||||||
<script src="app.js"></script>
|
<script src="app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1386,3 +1386,82 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
|
|||||||
animation-iteration-count: 1 !important;
|
animation-iteration-count: 1 !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.online-backup-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 18px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: color-mix(in srgb, var(--bg-card) 88%, var(--accent) 12%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.online-backup-panel h3 {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.online-backup-panel p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-dim);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.online-backup-action,
|
||||||
|
.backup-file-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.online-backup-key-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(170px, auto) minmax(240px, 1fr) auto;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.online-backup-key-row label {
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.online-backup-key-row .key-input {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
font-family: Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.online-backup-panel .online-backup-warning {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(245, 158, 11, 0.1);
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.online-backup-status {
|
||||||
|
min-height: 18px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.online-backup-status[data-state="success"] {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.online-backup-status[data-state="error"] {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.online-backup-status[data-state="warning"] {
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 820px) {
|
||||||
|
.online-backup-key-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Generated
+50
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"name": "multi-hoster-uploader-backup-api",
|
||||||
|
"version": "2.0.3",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "multi-hoster-uploader-backup-api",
|
||||||
|
"version": "2.0.3",
|
||||||
|
"dependencies": {
|
||||||
|
"proper-lockfile": "4.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/graceful-fs": {
|
||||||
|
"version": "4.2.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||||
|
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/proper-lockfile": {
|
||||||
|
"version": "4.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz",
|
||||||
|
"integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"graceful-fs": "^4.2.4",
|
||||||
|
"retry": "^0.12.0",
|
||||||
|
"signal-exit": "^3.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/retry": {
|
||||||
|
"version": "0.12.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
|
||||||
|
"integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/signal-exit": {
|
||||||
|
"version": "3.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
|
||||||
|
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"name": "multi-hoster-uploader-backup-api",
|
||||||
|
"version": "2.0.3",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node src/cli.mjs",
|
||||||
|
"test": "node --test"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"proper-lockfile": "4.1.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { resolve } from 'node:path'
|
||||||
|
import { createBackupServer } from './server.mjs'
|
||||||
|
|
||||||
|
const port = Number.parseInt(process.env.PORT ?? '8788', 10)
|
||||||
|
const host = process.env.HOST ?? '127.0.0.1'
|
||||||
|
const rootDir = resolve(process.env.BACKUP_DATA_DIR ?? './data')
|
||||||
|
const allowedOrigins = (process.env.ALLOWED_ORIGINS ?? '')
|
||||||
|
.split(',')
|
||||||
|
.map((origin) => origin.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
const rateLimit = {
|
||||||
|
max: Number.parseInt(process.env.RATE_LIMIT_MAX ?? '60', 10),
|
||||||
|
windowMs: Number.parseInt(process.env.RATE_LIMIT_WINDOW_MS ?? '60000', 10)
|
||||||
|
}
|
||||||
|
const uploadRateLimit = {
|
||||||
|
max: Number.parseInt(process.env.UPLOAD_RATE_LIMIT_MAX ?? '10', 10),
|
||||||
|
windowMs: Number.parseInt(process.env.UPLOAD_RATE_LIMIT_WINDOW_MS ?? '3600000', 10)
|
||||||
|
}
|
||||||
|
const requestRateLimit = {
|
||||||
|
max: Number.parseInt(process.env.REQUEST_RATE_LIMIT_MAX ?? '120', 10),
|
||||||
|
windowMs: Number.parseInt(process.env.REQUEST_RATE_LIMIT_WINDOW_MS ?? '60000', 10)
|
||||||
|
}
|
||||||
|
const maxStorageBytes = Number.parseInt(process.env.MAX_STORAGE_BYTES ?? String(10 * 1024 * 1024 * 1024), 10)
|
||||||
|
const maxRecords = Number.parseInt(process.env.MAX_RECORDS ?? '10000', 10)
|
||||||
|
const bodyTimeoutMs = Number.parseInt(process.env.BODY_TIMEOUT_MS ?? '10000', 10)
|
||||||
|
const healthCacheMs = Number.parseInt(process.env.HEALTH_CACHE_MS ?? '5000', 10)
|
||||||
|
const maxConcurrentPerClient = Number.parseInt(process.env.MAX_CONCURRENT_PER_CLIENT ?? '8', 10)
|
||||||
|
const maxConcurrentTotal = Number.parseInt(process.env.MAX_CONCURRENT_TOTAL ?? '64', 10)
|
||||||
|
const trustedProxy = process.env.TRUST_PROXY === 'true'
|
||||||
|
const trustedProxyAddresses = (process.env.TRUSTED_PROXY_ADDRESSES ?? '127.0.0.1,::1,::ffff:127.0.0.1')
|
||||||
|
.split(',')
|
||||||
|
.map((address) => address.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) throw new Error('Invalid PORT')
|
||||||
|
|
||||||
|
const server = createBackupServer({
|
||||||
|
rootDir,
|
||||||
|
allowedOrigins,
|
||||||
|
rateLimit,
|
||||||
|
uploadRateLimit,
|
||||||
|
requestRateLimit,
|
||||||
|
maxStorageBytes,
|
||||||
|
maxRecords,
|
||||||
|
bodyTimeoutMs,
|
||||||
|
healthCacheMs,
|
||||||
|
maxConcurrentPerClient,
|
||||||
|
maxConcurrentTotal,
|
||||||
|
trustedProxy,
|
||||||
|
trustedProxyAddresses
|
||||||
|
})
|
||||||
|
|
||||||
|
server.listen(port, host, () => {
|
||||||
|
process.stdout.write(`Backup API listening on ${host}:${port}\n`)
|
||||||
|
})
|
||||||
|
|
||||||
|
function shutdown() {
|
||||||
|
server.close((error) => {
|
||||||
|
if (error) {
|
||||||
|
process.stderr.write('Backup API shutdown failed\n')
|
||||||
|
process.exitCode = 1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on('SIGINT', shutdown)
|
||||||
|
process.on('SIGTERM', shutdown)
|
||||||
@@ -0,0 +1,552 @@
|
|||||||
|
import { createHash, timingSafeEqual, randomBytes } from 'node:crypto'
|
||||||
|
import { createServer } from 'node:http'
|
||||||
|
import { link, mkdir, open, readFile, readdir, stat, unlink } from 'node:fs/promises'
|
||||||
|
import { isIP } from 'node:net'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import lockfile from 'proper-lockfile'
|
||||||
|
|
||||||
|
const maxBlobBytes = 256 * 1024
|
||||||
|
const maxBodyBytes = 384 * 1024
|
||||||
|
const idPattern = /^[A-Za-z0-9_-]{22}$/
|
||||||
|
const verifierPattern = /^[A-Za-z0-9_-]{43}$/
|
||||||
|
const blobPattern = /^[A-Za-z0-9_-]+$/
|
||||||
|
const notFoundBody = '{"error":"not_found"}'
|
||||||
|
|
||||||
|
function isCanonicalBase64Url(value, byteLength, pattern) {
|
||||||
|
if (typeof value !== 'string' || !pattern.test(value)) return false
|
||||||
|
const decoded = Buffer.from(value, 'base64url')
|
||||||
|
return decoded.length === byteLength && decoded.toString('base64url') === value
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidBackup(payload) {
|
||||||
|
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return false
|
||||||
|
const keys = Object.keys(payload).sort()
|
||||||
|
if (keys.join(',') !== 'blob,deleteVerifier,id') return false
|
||||||
|
if (!isCanonicalBase64Url(payload.id, 16, idPattern)) return false
|
||||||
|
if (!isCanonicalBase64Url(payload.deleteVerifier, 32, verifierPattern)) return false
|
||||||
|
if (typeof payload.blob !== 'string' || !blobPattern.test(payload.blob)) return false
|
||||||
|
const decoded = Buffer.from(payload.blob, 'base64url')
|
||||||
|
return decoded.length <= maxBlobBytes && decoded.toString('base64url') === payload.blob
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRateLimiter({ max, windowMs }) {
|
||||||
|
const clients = new Map()
|
||||||
|
let requestCount = 0
|
||||||
|
return (address) => {
|
||||||
|
const now = Date.now()
|
||||||
|
requestCount += 1
|
||||||
|
if (requestCount % 1024 === 0) {
|
||||||
|
for (const [key, value] of clients) {
|
||||||
|
if (now - value.startedAt >= windowMs) clients.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const current = clients.get(address)
|
||||||
|
if (!current || now - current.startedAt >= windowMs) {
|
||||||
|
clients.set(address, { startedAt: now, count: 1 })
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (current.count >= max) return Math.max(1, Math.ceil((windowMs - (now - current.startedAt)) / 1000))
|
||||||
|
current.count += 1
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createConcurrencyLimiter({ perClient, total }) {
|
||||||
|
const clients = new Map()
|
||||||
|
let active = 0
|
||||||
|
return {
|
||||||
|
enter(address) {
|
||||||
|
const clientActive = clients.get(address) ?? 0
|
||||||
|
if (active >= total || clientActive >= perClient) return false
|
||||||
|
active += 1
|
||||||
|
clients.set(address, clientActive + 1)
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
leave(address) {
|
||||||
|
const clientActive = clients.get(address) ?? 0
|
||||||
|
active = Math.max(0, active - 1)
|
||||||
|
if (clientActive <= 1) clients.delete(address)
|
||||||
|
else clients.set(address, clientActive - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJsonBody(request, timeoutMs) {
|
||||||
|
const declaredLength = Number.parseInt(request.headers['content-length'] ?? '', 10)
|
||||||
|
if (Number.isFinite(declaredLength) && declaredLength > maxBodyBytes) {
|
||||||
|
request.resume()
|
||||||
|
return Promise.resolve({ error: 413 })
|
||||||
|
}
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let size = 0
|
||||||
|
let settled = false
|
||||||
|
const chunks = []
|
||||||
|
const cleanup = () => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
request.off('data', onData)
|
||||||
|
request.off('end', onEnd)
|
||||||
|
request.off('aborted', onAborted)
|
||||||
|
request.off('error', onError)
|
||||||
|
}
|
||||||
|
const finish = (result) => {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
cleanup()
|
||||||
|
resolve(result)
|
||||||
|
}
|
||||||
|
const onData = (chunk) => {
|
||||||
|
size += chunk.length
|
||||||
|
if (size > maxBodyBytes) {
|
||||||
|
finish({ error: 413 })
|
||||||
|
request.resume()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
chunks.push(chunk)
|
||||||
|
}
|
||||||
|
const onEnd = () => {
|
||||||
|
try {
|
||||||
|
finish({ value: JSON.parse(Buffer.concat(chunks).toString('utf8')) })
|
||||||
|
} catch {
|
||||||
|
finish({ error: 400 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const onAborted = () => reject(new Error('Request aborted'))
|
||||||
|
const onError = (error) => reject(error)
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
finish({ error: 408 })
|
||||||
|
request.resume()
|
||||||
|
}, timeoutMs)
|
||||||
|
request.on('data', onData)
|
||||||
|
request.on('end', onEnd)
|
||||||
|
request.on('aborted', onAborted)
|
||||||
|
request.on('error', onError)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordPath(rootDir, id) {
|
||||||
|
return join(rootDir, `${id}.json`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMutationQueue() {
|
||||||
|
let pending = Promise.resolve()
|
||||||
|
return (operation) => {
|
||||||
|
const result = pending.then(operation, operation)
|
||||||
|
pending = result.catch(() => {})
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanupTemporaryFiles(rootDir) {
|
||||||
|
const entries = await readdir(rootDir, { withFileTypes: true })
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isFile() || !/^\.[a-f0-9]{32}\.tmp$/.test(entry.name)) continue
|
||||||
|
try {
|
||||||
|
await unlink(join(rootDir, entry.name))
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code !== 'ENOENT') throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function directoryUsage(rootDir) {
|
||||||
|
let bytes = 0
|
||||||
|
let records = 0
|
||||||
|
const entries = await readdir(rootDir, { withFileTypes: true })
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isFile() || !entry.name.endsWith('.json')) continue
|
||||||
|
try {
|
||||||
|
bytes += (await stat(join(rootDir, entry.name))).size
|
||||||
|
records += 1
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code !== 'ENOENT') throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { bytes, records }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncDirectory(rootDir) {
|
||||||
|
let handle
|
||||||
|
try {
|
||||||
|
handle = await open(rootDir, 'r')
|
||||||
|
await handle.sync()
|
||||||
|
} catch (error) {
|
||||||
|
if (!['EISDIR', 'EINVAL', 'ENOTSUP', 'EPERM', 'EBADF'].includes(error.code)) throw error
|
||||||
|
} finally {
|
||||||
|
await handle?.close().catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withStorageLock(rootDir, operation) {
|
||||||
|
await mkdir(rootDir, { recursive: true })
|
||||||
|
const release = await lockfile.lock(rootDir, {
|
||||||
|
realpath: false,
|
||||||
|
lockfilePath: join(rootDir, '.storage.lock'),
|
||||||
|
stale: 30_000,
|
||||||
|
update: 10_000,
|
||||||
|
retries: {
|
||||||
|
retries: 100,
|
||||||
|
factor: 1.1,
|
||||||
|
minTimeout: 10,
|
||||||
|
maxTimeout: 100,
|
||||||
|
randomize: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
return await operation()
|
||||||
|
} finally {
|
||||||
|
let releaseError
|
||||||
|
try {
|
||||||
|
await release()
|
||||||
|
} catch (error) {
|
||||||
|
releaseError = error
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await syncDirectory(rootDir)
|
||||||
|
} catch (error) {
|
||||||
|
releaseError ??= error
|
||||||
|
}
|
||||||
|
if (releaseError) throw releaseError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recordExists(rootDir, id) {
|
||||||
|
try {
|
||||||
|
await stat(recordPath(rootDir, id))
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'ENOENT') return false
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createRecord(rootDir, payload, maxStorageBytes, maxRecords) {
|
||||||
|
await mkdir(rootDir, { recursive: true })
|
||||||
|
await cleanupTemporaryFiles(rootDir)
|
||||||
|
if (await recordExists(rootDir, payload.id)) return 'duplicate'
|
||||||
|
const contents = Buffer.from(JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
blob: payload.blob,
|
||||||
|
deleteVerifier: payload.deleteVerifier,
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
|
}), 'utf8')
|
||||||
|
const usage = await directoryUsage(rootDir)
|
||||||
|
if (usage.bytes + contents.length > maxStorageBytes || usage.records >= maxRecords) return 'full'
|
||||||
|
const temporaryPath = join(rootDir, `.${randomBytes(16).toString('hex')}.tmp`)
|
||||||
|
let handle
|
||||||
|
let temporaryCreated = false
|
||||||
|
let published = false
|
||||||
|
try {
|
||||||
|
handle = await open(temporaryPath, 'wx', 0o600)
|
||||||
|
temporaryCreated = true
|
||||||
|
try {
|
||||||
|
await handle.writeFile(contents)
|
||||||
|
await handle.sync()
|
||||||
|
} finally {
|
||||||
|
await handle.close()
|
||||||
|
handle = undefined
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await link(temporaryPath, recordPath(rootDir, payload.id))
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'EEXIST') return 'duplicate'
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
published = true
|
||||||
|
return 'created'
|
||||||
|
} finally {
|
||||||
|
let cleanupError
|
||||||
|
try {
|
||||||
|
await handle?.close()
|
||||||
|
} catch (error) {
|
||||||
|
cleanupError = error
|
||||||
|
}
|
||||||
|
if (temporaryCreated) {
|
||||||
|
try {
|
||||||
|
await unlink(temporaryPath)
|
||||||
|
} catch (error) {
|
||||||
|
cleanupError ??= error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (published) {
|
||||||
|
try {
|
||||||
|
await syncDirectory(rootDir)
|
||||||
|
} catch (error) {
|
||||||
|
cleanupError ??= error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cleanupError) throw cleanupError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readRecord(rootDir, id) {
|
||||||
|
try {
|
||||||
|
const raw = await readFile(recordPath(rootDir, id), 'utf8')
|
||||||
|
const record = JSON.parse(raw)
|
||||||
|
if (record?.version !== 1 || typeof record.blob !== 'string' || !isCanonicalBase64Url(record.deleteVerifier, 32, verifierPattern)) {
|
||||||
|
throw new Error('Invalid stored record')
|
||||||
|
}
|
||||||
|
return record
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'ENOENT') return null
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function securityHeaders(response) {
|
||||||
|
response.setHeader('cache-control', 'no-store')
|
||||||
|
response.setHeader('x-content-type-options', 'nosniff')
|
||||||
|
response.setHeader('content-security-policy', "default-src 'none'")
|
||||||
|
response.setHeader('referrer-policy', 'no-referrer')
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendJson(response, status, body) {
|
||||||
|
response.statusCode = status
|
||||||
|
response.setHeader('content-type', 'application/json; charset=utf-8')
|
||||||
|
response.end(JSON.stringify(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendNotFound(response) {
|
||||||
|
response.statusCode = 404
|
||||||
|
response.setHeader('content-type', 'application/json; charset=utf-8')
|
||||||
|
response.end(notFoundBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
function authorizeOrigin(request, response, allowedOrigins) {
|
||||||
|
const origin = request.headers.origin
|
||||||
|
if (!origin) return true
|
||||||
|
if (!allowedOrigins.has(origin)) {
|
||||||
|
sendJson(response, 403, { error: 'origin_denied' })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
response.setHeader('access-control-allow-origin', origin)
|
||||||
|
response.setHeader('vary', 'Origin')
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifierMatches(secret, expectedVerifier) {
|
||||||
|
const actual = createHash('sha256').update(Buffer.from(secret, 'base64url')).digest()
|
||||||
|
const expected = Buffer.from(expectedVerifier, 'base64url')
|
||||||
|
return actual.length === expected.length && timingSafeEqual(actual, expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function storageIsReady(rootDir) {
|
||||||
|
const probePath = join(rootDir, `.${randomBytes(16).toString('hex')}.health`)
|
||||||
|
try {
|
||||||
|
await mkdir(rootDir, { recursive: true })
|
||||||
|
const handle = await open(probePath, 'wx', 0o600)
|
||||||
|
await handle.close()
|
||||||
|
await unlink(probePath)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
await unlink(probePath).catch(() => {})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createReadinessProbe(rootDir, cacheMs) {
|
||||||
|
let cached = null
|
||||||
|
let cachedAt = 0
|
||||||
|
let pending = null
|
||||||
|
return async () => {
|
||||||
|
const now = Date.now()
|
||||||
|
if (cached !== null && now - cachedAt < cacheMs) return cached
|
||||||
|
if (pending) return pending
|
||||||
|
pending = storageIsReady(rootDir).then((value) => {
|
||||||
|
cached = value
|
||||||
|
cachedAt = Date.now()
|
||||||
|
return value
|
||||||
|
}).finally(() => { pending = null })
|
||||||
|
return pending
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clientAddress(request, trustedProxy, trustedProxyAddresses) {
|
||||||
|
if (trustedProxy && trustedProxyAddresses.has(request.socket.remoteAddress ?? '')) {
|
||||||
|
const forwarded = request.headers['x-forwarded-for']
|
||||||
|
const value = Array.isArray(forwarded) ? forwarded.at(-1) : forwarded
|
||||||
|
const candidate = value?.split(',').at(-1)?.trim()
|
||||||
|
if (candidate && isIP(candidate)) return candidate
|
||||||
|
}
|
||||||
|
return request.socket.remoteAddress ?? 'unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBackupServer(options) {
|
||||||
|
if (!options?.rootDir) throw new Error('rootDir is required')
|
||||||
|
const allowedOrigins = new Set(options.allowedOrigins ?? [])
|
||||||
|
const rateLimit = options.rateLimit ?? { max: 60, windowMs: 60_000 }
|
||||||
|
const uploadRateLimit = options.uploadRateLimit ?? { max: 10, windowMs: 3_600_000 }
|
||||||
|
const requestRateLimit = options.requestRateLimit ?? { max: 120, windowMs: 60_000 }
|
||||||
|
const maxStorageBytes = options.maxStorageBytes ?? 10 * 1024 * 1024 * 1024
|
||||||
|
const maxRecords = options.maxRecords ?? 10_000
|
||||||
|
const bodyTimeoutMs = options.bodyTimeoutMs ?? 10_000
|
||||||
|
const healthCacheMs = options.healthCacheMs ?? 5_000
|
||||||
|
const maxConcurrentPerClient = options.maxConcurrentPerClient ?? 8
|
||||||
|
const maxConcurrentTotal = options.maxConcurrentTotal ?? 64
|
||||||
|
const trustedProxyAddresses = new Set(options.trustedProxyAddresses ?? [])
|
||||||
|
if (!Number.isSafeInteger(rateLimit.max) || rateLimit.max < 1 || !Number.isSafeInteger(rateLimit.windowMs) || rateLimit.windowMs < 1) {
|
||||||
|
throw new Error('Invalid rate limit')
|
||||||
|
}
|
||||||
|
if (!Number.isSafeInteger(uploadRateLimit.max) || uploadRateLimit.max < 1 || !Number.isSafeInteger(uploadRateLimit.windowMs) || uploadRateLimit.windowMs < 1) {
|
||||||
|
throw new Error('Invalid upload rate limit')
|
||||||
|
}
|
||||||
|
if (!Number.isSafeInteger(requestRateLimit.max) || requestRateLimit.max < 1 || !Number.isSafeInteger(requestRateLimit.windowMs) || requestRateLimit.windowMs < 1) {
|
||||||
|
throw new Error('Invalid request rate limit')
|
||||||
|
}
|
||||||
|
if (!Number.isSafeInteger(maxStorageBytes) || maxStorageBytes < 1) throw new Error('Invalid max storage size')
|
||||||
|
if (!Number.isSafeInteger(maxRecords) || maxRecords < 1) throw new Error('Invalid max records')
|
||||||
|
if (!Number.isSafeInteger(bodyTimeoutMs) || bodyTimeoutMs < 1) throw new Error('Invalid body timeout')
|
||||||
|
if (!Number.isSafeInteger(healthCacheMs) || healthCacheMs < 1) throw new Error('Invalid health cache')
|
||||||
|
if (!Number.isSafeInteger(maxConcurrentPerClient) || maxConcurrentPerClient < 1) throw new Error('Invalid per-client concurrency')
|
||||||
|
if (!Number.isSafeInteger(maxConcurrentTotal) || maxConcurrentTotal < maxConcurrentPerClient) throw new Error('Invalid total concurrency')
|
||||||
|
const consumeRateLimit = createRateLimiter(rateLimit)
|
||||||
|
const consumeUploadRateLimit = createRateLimiter(uploadRateLimit)
|
||||||
|
const consumeRequestRateLimit = createRateLimiter(requestRateLimit)
|
||||||
|
const bodyConcurrency = createConcurrencyLimiter({ perClient: maxConcurrentPerClient, total: maxConcurrentTotal })
|
||||||
|
const runStorageMutation = createMutationQueue()
|
||||||
|
const checkReadiness = createReadinessProbe(options.rootDir, healthCacheMs)
|
||||||
|
|
||||||
|
const server = createServer(async (request, response) => {
|
||||||
|
securityHeaders(response)
|
||||||
|
try {
|
||||||
|
const url = new URL(request.url, 'http://localhost')
|
||||||
|
if (!authorizeOrigin(request, response, allowedOrigins)) return
|
||||||
|
if (url.search) {
|
||||||
|
sendNotFound(response)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (request.method === 'OPTIONS') {
|
||||||
|
const requestedMethod = request.headers['access-control-request-method']
|
||||||
|
if (!request.headers.origin || requestedMethod !== 'POST') {
|
||||||
|
sendJson(response, 400, { error: 'invalid_preflight' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.statusCode = 204
|
||||||
|
response.setHeader('access-control-allow-methods', 'POST, OPTIONS')
|
||||||
|
response.setHeader('access-control-allow-headers', 'content-type')
|
||||||
|
response.setHeader('access-control-max-age', '600')
|
||||||
|
response.end()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (request.method === 'GET' && url.pathname === '/health') {
|
||||||
|
const ready = await checkReadiness()
|
||||||
|
sendJson(response, ready ? 200 : 503, { status: ready ? 'ok' : 'unavailable' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const address = clientAddress(request, options.trustedProxy === true, trustedProxyAddresses)
|
||||||
|
if (url.pathname === '/v1/backups/restore' || url.pathname === '/v1/backups/delete') {
|
||||||
|
const retryAfter = consumeRateLimit(address)
|
||||||
|
if (retryAfter !== null) {
|
||||||
|
response.setHeader('retry-after', String(retryAfter))
|
||||||
|
sendJson(response, 429, { error: 'rate_limited' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (request.method === 'POST' && ['/v1/backups', '/v1/backups/restore', '/v1/backups/delete'].includes(url.pathname)) {
|
||||||
|
const requestRetryAfter = consumeRequestRateLimit(address)
|
||||||
|
if (requestRetryAfter !== null) {
|
||||||
|
response.setHeader('retry-after', String(requestRetryAfter))
|
||||||
|
sendJson(response, 429, { error: 'rate_limited' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (request.headers['content-type']?.split(';', 1)[0].trim().toLowerCase() !== 'application/json') {
|
||||||
|
sendJson(response, 415, { error: 'unsupported_media_type' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!bodyConcurrency.enter(address)) {
|
||||||
|
sendJson(response, 429, { error: 'too_many_requests' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = await readJsonBody(request, bodyTimeoutMs)
|
||||||
|
if (parsed.error) {
|
||||||
|
if (parsed.error === 413) response.setHeader('connection', 'close')
|
||||||
|
const error = parsed.error === 413 ? 'payload_too_large' : parsed.error === 408 ? 'request_timeout' : 'invalid_request'
|
||||||
|
sendJson(response, parsed.error, { error })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (url.pathname === '/v1/backups/restore') {
|
||||||
|
const keys = parsed.value && typeof parsed.value === 'object' && !Array.isArray(parsed.value) ? Object.keys(parsed.value) : []
|
||||||
|
if (keys.length !== 1 || keys[0] !== 'id' || !isCanonicalBase64Url(parsed.value.id, 16, idPattern)) {
|
||||||
|
sendNotFound(response)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const record = await readRecord(options.rootDir, parsed.value.id)
|
||||||
|
if (!record) {
|
||||||
|
sendNotFound(response)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sendJson(response, 200, { blob: record.blob })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (url.pathname === '/v1/backups/delete') {
|
||||||
|
const keys = parsed.value && typeof parsed.value === 'object' && !Array.isArray(parsed.value) ? Object.keys(parsed.value).sort() : []
|
||||||
|
const valid = keys.join(',') === 'deleteSecret,id'
|
||||||
|
&& isCanonicalBase64Url(parsed.value.id, 16, idPattern)
|
||||||
|
&& isCanonicalBase64Url(parsed.value.deleteSecret, 32, verifierPattern)
|
||||||
|
if (!valid) {
|
||||||
|
sendNotFound(response)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const deleted = await runStorageMutation(() => withStorageLock(options.rootDir, async () => {
|
||||||
|
const record = await readRecord(options.rootDir, parsed.value.id)
|
||||||
|
if (!record || !verifierMatches(parsed.value.deleteSecret, record.deleteVerifier)) return false
|
||||||
|
try {
|
||||||
|
await unlink(recordPath(options.rootDir, parsed.value.id))
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'ENOENT') return false
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
if (!deleted) {
|
||||||
|
sendNotFound(response)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.statusCode = 204
|
||||||
|
response.end()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (typeof parsed.value?.blob === 'string' && blobPattern.test(parsed.value.blob) && Buffer.from(parsed.value.blob, 'base64url').length > maxBlobBytes) {
|
||||||
|
sendJson(response, 413, { error: 'payload_too_large' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!isValidBackup(parsed.value)) {
|
||||||
|
sendJson(response, 400, { error: 'invalid_request' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const uploadRetryAfter = consumeUploadRateLimit(address)
|
||||||
|
if (uploadRetryAfter !== null) {
|
||||||
|
response.setHeader('retry-after', String(uploadRetryAfter))
|
||||||
|
sendJson(response, 429, { error: 'rate_limited' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const result = await runStorageMutation(() => withStorageLock(
|
||||||
|
options.rootDir,
|
||||||
|
() => createRecord(options.rootDir, parsed.value, maxStorageBytes, maxRecords)
|
||||||
|
))
|
||||||
|
if (result === 'duplicate') {
|
||||||
|
sendJson(response, 409, { error: 'already_exists' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (result === 'full') {
|
||||||
|
sendJson(response, 507, { error: 'insufficient_storage' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sendJson(response, 201, { created: true })
|
||||||
|
return
|
||||||
|
} finally {
|
||||||
|
bodyConcurrency.leave(address)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sendNotFound(response)
|
||||||
|
} catch {
|
||||||
|
if (!response.headersSent) sendJson(response, 500, { error: 'internal_error' })
|
||||||
|
else response.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
server.requestTimeout = bodyTimeoutMs + 5_000
|
||||||
|
server.headersTimeout = Math.min(10_000, bodyTimeoutMs)
|
||||||
|
server.keepAliveTimeout = 5_000
|
||||||
|
server.maxRequestsPerSocket = 100
|
||||||
|
return server
|
||||||
|
}
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { createHash, randomBytes } from 'node:crypto'
|
||||||
|
import { mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { createConnection } from 'node:net'
|
||||||
|
import test from 'node:test'
|
||||||
|
import lockfile from 'proper-lockfile'
|
||||||
|
import { createBackupServer } from '../src/server.mjs'
|
||||||
|
|
||||||
|
const allowedOrigin = 'https://uploader.24-music.de'
|
||||||
|
|
||||||
|
function fixture() {
|
||||||
|
const deleteSecret = randomBytes(32).toString('base64url')
|
||||||
|
return {
|
||||||
|
deleteSecret,
|
||||||
|
payload: {
|
||||||
|
id: randomBytes(16).toString('base64url'),
|
||||||
|
blob: randomBytes(96).toString('base64url'),
|
||||||
|
deleteVerifier: createHash('sha256').update(Buffer.from(deleteSecret, 'base64url')).digest('base64url')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startApi(options = {}) {
|
||||||
|
const rootDir = await mkdtemp(join(tmpdir(), 'mhu-backup-api-'))
|
||||||
|
const server = createBackupServer({
|
||||||
|
rootDir,
|
||||||
|
allowedOrigins: [allowedOrigin],
|
||||||
|
rateLimit: { max: 100, windowMs: 60_000 },
|
||||||
|
...options
|
||||||
|
})
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
server.once('error', reject)
|
||||||
|
server.listen(0, '127.0.0.1', resolve)
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
rootDir,
|
||||||
|
server,
|
||||||
|
baseUrl: `http://127.0.0.1:${server.address().port}`,
|
||||||
|
async close() {
|
||||||
|
await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()))
|
||||||
|
await rm(rootDir, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function request(api, path, options = {}) {
|
||||||
|
return fetch(`${api.baseUrl}${path}`, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
test('health reports readiness without storage details and sends security headers', async (t) => {
|
||||||
|
const api = await startApi()
|
||||||
|
t.after(() => api.close())
|
||||||
|
|
||||||
|
const response = await request(api, '/health')
|
||||||
|
|
||||||
|
assert.equal(response.status, 200)
|
||||||
|
assert.deepEqual(await response.json(), { status: 'ok' })
|
||||||
|
assert.equal(response.headers.get('cache-control'), 'no-store')
|
||||||
|
assert.equal(response.headers.get('x-content-type-options'), 'nosniff')
|
||||||
|
assert.equal(response.headers.get('content-security-policy'), "default-src 'none'")
|
||||||
|
})
|
||||||
|
|
||||||
|
test('creates immutable ciphertext records and restores them after a restart', async (t) => {
|
||||||
|
const api = await startApi()
|
||||||
|
const backup = fixture()
|
||||||
|
t.after(async () => {
|
||||||
|
if (api.server.listening) await new Promise((resolve) => api.server.close(resolve))
|
||||||
|
await rm(api.rootDir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
const created = await request(api, '/v1/backups', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(backup.payload)
|
||||||
|
})
|
||||||
|
assert.equal(created.status, 201)
|
||||||
|
await new Promise((resolve) => api.server.close(resolve))
|
||||||
|
|
||||||
|
api.server = createBackupServer({ rootDir: api.rootDir, allowedOrigins: [allowedOrigin] })
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
api.server.once('error', reject)
|
||||||
|
api.server.listen(0, '127.0.0.1', resolve)
|
||||||
|
})
|
||||||
|
api.baseUrl = `http://127.0.0.1:${api.server.address().port}`
|
||||||
|
|
||||||
|
const restored = await request(api, '/v1/backups/restore', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id: backup.payload.id })
|
||||||
|
})
|
||||||
|
assert.equal(restored.status, 200)
|
||||||
|
assert.deepEqual(await restored.json(), { blob: backup.payload.blob })
|
||||||
|
|
||||||
|
const duplicate = await request(api, '/v1/backups', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ ...backup.payload, blob: randomBytes(96).toString('base64url') })
|
||||||
|
})
|
||||||
|
assert.equal(duplicate.status, 409)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('validates payload shape, content type and decoded blob size', async (t) => {
|
||||||
|
const api = await startApi()
|
||||||
|
t.after(() => api.close())
|
||||||
|
const valid = fixture()
|
||||||
|
const invalid = [
|
||||||
|
{ ...valid.payload, id: 'short' },
|
||||||
|
{ ...valid.payload, blob: 'not+base64url' },
|
||||||
|
{ ...valid.payload, deleteVerifier: 'short' },
|
||||||
|
{ id: valid.payload.id, blob: valid.payload.blob },
|
||||||
|
{ ...valid.payload, extra: true }
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const body of invalid) {
|
||||||
|
const response = await request(api, '/v1/backups', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
})
|
||||||
|
assert.equal(response.status, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
const wrongType = await request(api, '/v1/backups', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'text/plain' },
|
||||||
|
body: JSON.stringify(valid.payload)
|
||||||
|
})
|
||||||
|
assert.equal(wrongType.status, 415)
|
||||||
|
|
||||||
|
const oversized = fixture()
|
||||||
|
oversized.payload.blob = randomBytes(262_145).toString('base64url')
|
||||||
|
const tooLarge = await request(api, '/v1/backups', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(oversized.payload)
|
||||||
|
})
|
||||||
|
assert.equal(tooLarge.status, 413)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('deletes only with the matching client secret and returns constant not-found responses', async (t) => {
|
||||||
|
const api = await startApi()
|
||||||
|
t.after(() => api.close())
|
||||||
|
const backup = fixture()
|
||||||
|
await request(api, '/v1/backups', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(backup.payload)
|
||||||
|
})
|
||||||
|
|
||||||
|
const missing = await request(api, '/v1/backups/restore', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id: randomBytes(16).toString('base64url') })
|
||||||
|
})
|
||||||
|
const wrong = await request(api, '/v1/backups/delete', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id: backup.payload.id, deleteSecret: randomBytes(32).toString('base64url') })
|
||||||
|
})
|
||||||
|
assert.equal(missing.status, 404)
|
||||||
|
assert.equal(wrong.status, 404)
|
||||||
|
assert.equal(await missing.text(), await wrong.text())
|
||||||
|
|
||||||
|
const deleted = await request(api, '/v1/backups/delete', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id: backup.payload.id, deleteSecret: backup.deleteSecret })
|
||||||
|
})
|
||||||
|
assert.equal(deleted.status, 204)
|
||||||
|
assert.equal((await readdir(api.rootDir)).length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('allows only configured origins and supports preflight', async (t) => {
|
||||||
|
const api = await startApi()
|
||||||
|
t.after(() => api.close())
|
||||||
|
|
||||||
|
const allowed = await request(api, '/health', { headers: { origin: allowedOrigin } })
|
||||||
|
assert.equal(allowed.headers.get('access-control-allow-origin'), allowedOrigin)
|
||||||
|
const denied = await request(api, '/health', { headers: { origin: 'https://attacker.example' } })
|
||||||
|
assert.equal(denied.status, 403)
|
||||||
|
const preflight = await request(api, '/v1/backups', {
|
||||||
|
method: 'OPTIONS',
|
||||||
|
headers: {
|
||||||
|
origin: allowedOrigin,
|
||||||
|
'access-control-request-method': 'POST',
|
||||||
|
'access-control-request-headers': 'content-type'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
assert.equal(preflight.status, 204)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('separately rate limits uploads while restores and health remain available', async (t) => {
|
||||||
|
const api = await startApi({ uploadRateLimit: { max: 1, windowMs: 60_000 } })
|
||||||
|
t.after(() => api.close())
|
||||||
|
const first = fixture()
|
||||||
|
const second = fixture()
|
||||||
|
const create = (backup) => request(api, '/v1/backups', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(backup.payload)
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal((await create(first)).status, 201)
|
||||||
|
assert.equal((await create(second)).status, 429)
|
||||||
|
const restored = await request(api, '/v1/backups/restore', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id: first.payload.id })
|
||||||
|
})
|
||||||
|
assert.equal(restored.status, 200)
|
||||||
|
assert.equal((await request(api, '/health')).status, 200)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rate limits invalid request bodies before validation', async (t) => {
|
||||||
|
const api = await startApi({ requestRateLimit: { max: 1, windowMs: 60_000 } })
|
||||||
|
t.after(() => api.close())
|
||||||
|
const sendInvalid = () => request(api, '/v1/backups', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ invalid: 'x'.repeat(300_000) })
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal((await sendInvalid()).status, 400)
|
||||||
|
assert.equal((await sendInvalid()).status, 429)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('ignores forwarded client addresses from untrusted socket peers', async (t) => {
|
||||||
|
const api = await startApi({
|
||||||
|
trustedProxy: true,
|
||||||
|
trustedProxyAddresses: [],
|
||||||
|
rateLimit: { max: 1, windowMs: 60_000 }
|
||||||
|
})
|
||||||
|
t.after(() => api.close())
|
||||||
|
const body = JSON.stringify({ id: randomBytes(16).toString('base64url') })
|
||||||
|
const restore = (forwarded) => request(api, '/v1/backups/restore', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json', 'x-forwarded-for': forwarded },
|
||||||
|
body
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal((await restore('198.51.100.1')).status, 404)
|
||||||
|
assert.equal((await restore('198.51.100.2')).status, 429)
|
||||||
|
const created = await request(api, '/v1/backups', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json', 'x-forwarded-for': '198.51.100.3' },
|
||||||
|
body: JSON.stringify(fixture().payload)
|
||||||
|
})
|
||||||
|
assert.equal(created.status, 201)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('uses the last forwarded address from an explicitly trusted proxy', async (t) => {
|
||||||
|
const api = await startApi({
|
||||||
|
trustedProxy: true,
|
||||||
|
trustedProxyAddresses: ['127.0.0.1'],
|
||||||
|
rateLimit: { max: 1, windowMs: 60_000 }
|
||||||
|
})
|
||||||
|
t.after(() => api.close())
|
||||||
|
const body = JSON.stringify({ id: randomBytes(16).toString('base64url') })
|
||||||
|
const restore = (forwarded) => request(api, '/v1/backups/restore', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json', 'x-forwarded-for': forwarded },
|
||||||
|
body
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal((await restore('198.51.100.1, 203.0.113.9')).status, 404)
|
||||||
|
assert.equal((await restore('198.51.100.2, 203.0.113.9')).status, 429)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('keeps concurrency leases until storage mutations finish', async (t) => {
|
||||||
|
const api = await startApi({ maxConcurrentPerClient: 1, maxConcurrentTotal: 1 })
|
||||||
|
t.after(() => api.close())
|
||||||
|
const release = await lockfile.lock(api.rootDir, {
|
||||||
|
realpath: false,
|
||||||
|
lockfilePath: join(api.rootDir, '.storage.lock')
|
||||||
|
})
|
||||||
|
const create = (backup) => request(api, '/v1/backups', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(backup.payload)
|
||||||
|
})
|
||||||
|
const first = create(fixture())
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 40))
|
||||||
|
const second = create(fixture())
|
||||||
|
let secondStatus
|
||||||
|
try {
|
||||||
|
secondStatus = await Promise.race([
|
||||||
|
second.then((response) => response.status),
|
||||||
|
new Promise((resolve) => setTimeout(() => resolve('pending'), 100))
|
||||||
|
])
|
||||||
|
} finally {
|
||||||
|
await release()
|
||||||
|
}
|
||||||
|
assert.equal((await first).status, 201)
|
||||||
|
if (secondStatus === 'pending') await second
|
||||||
|
assert.equal(secondStatus, 429)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('times out incomplete request bodies', async (t) => {
|
||||||
|
const api = await startApi({ bodyTimeoutMs: 30 })
|
||||||
|
t.after(() => api.close())
|
||||||
|
const response = await new Promise((resolve, reject) => {
|
||||||
|
const socket = createConnection(new URL(api.baseUrl).port, '127.0.0.1')
|
||||||
|
let data = ''
|
||||||
|
socket.setEncoding('utf8')
|
||||||
|
socket.once('error', reject)
|
||||||
|
socket.on('data', (chunk) => { data += chunk })
|
||||||
|
socket.on('end', () => resolve(data))
|
||||||
|
socket.once('connect', () => {
|
||||||
|
socket.write('POST /v1/backups HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: 10\r\nConnection: close\r\n\r\n{')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.match(response, /^HTTP\/1\.1 408 /)
|
||||||
|
assert.match(response, /request_timeout/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('caches health readiness instead of writing on every request', async (t) => {
|
||||||
|
const api = await startApi({ healthCacheMs: 60_000 })
|
||||||
|
t.after(() => api.close())
|
||||||
|
|
||||||
|
assert.equal((await request(api, '/health')).status, 200)
|
||||||
|
await rm(api.rootDir, { recursive: true, force: true })
|
||||||
|
assert.equal((await request(api, '/health')).status, 200)
|
||||||
|
await assert.rejects(stat(api.rootDir), { code: 'ENOENT' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('cleans orphaned temporary files and enforces a record limit', async (t) => {
|
||||||
|
const api = await startApi({ maxRecords: 1 })
|
||||||
|
t.after(() => api.close())
|
||||||
|
const orphan = join(api.rootDir, `.${randomBytes(16).toString('hex')}.tmp`)
|
||||||
|
await writeFile(orphan, 'orphan')
|
||||||
|
const create = (backup) => request(api, '/v1/backups', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(backup.payload)
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal((await create(fixture())).status, 201)
|
||||||
|
assert.equal((await create(fixture())).status, 507)
|
||||||
|
const files = await readdir(api.rootDir)
|
||||||
|
assert.equal(files.some((name) => name.endsWith('.tmp')), false)
|
||||||
|
assert.equal(files.filter((name) => name.endsWith('.json')).length, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('enforces atomic storage capacity without blocking existing restores', async (t) => {
|
||||||
|
const api = await startApi({ maxStorageBytes: 420 })
|
||||||
|
const secondServer = createBackupServer({ rootDir: api.rootDir, allowedOrigins: [allowedOrigin], maxStorageBytes: 420 })
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
secondServer.once('error', reject)
|
||||||
|
secondServer.listen(0, '127.0.0.1', resolve)
|
||||||
|
})
|
||||||
|
t.after(() => api.close())
|
||||||
|
t.after(() => new Promise((resolve) => secondServer.close(resolve)))
|
||||||
|
const first = fixture()
|
||||||
|
const second = fixture()
|
||||||
|
const create = (backup, baseUrl = api.baseUrl) => fetch(`${baseUrl}/v1/backups`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(backup.payload)
|
||||||
|
})
|
||||||
|
|
||||||
|
const results = await Promise.all([create(first), create(second, `http://127.0.0.1:${secondServer.address().port}`)])
|
||||||
|
|
||||||
|
assert.deepEqual(results.map((response) => response.status).sort(), [201, 507])
|
||||||
|
assert.equal((await readdir(api.rootDir)).length, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('never accepts record ids in URLs and stores no client delete secret', async (t) => {
|
||||||
|
const api = await startApi()
|
||||||
|
t.after(() => api.close())
|
||||||
|
const backup = fixture()
|
||||||
|
|
||||||
|
assert.equal((await request(api, `/v1/backups/${backup.payload.id}`)).status, 404)
|
||||||
|
assert.equal((await request(api, `/v1/backups?backup=${backup.payload.id}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(backup.payload)
|
||||||
|
})).status, 404)
|
||||||
|
|
||||||
|
await request(api, '/v1/backups', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(backup.payload)
|
||||||
|
})
|
||||||
|
const files = await readdir(api.rootDir)
|
||||||
|
const stored = await readFile(join(api.rootDir, files[0]), 'utf8')
|
||||||
|
assert.equal(stored.includes(backup.deleteSecret), false)
|
||||||
|
})
|
||||||
@@ -254,6 +254,40 @@ describe('ConfigStore', () => {
|
|||||||
assert.equal(config.globalSettings.alwaysOnTop, true);
|
assert.equal(config.globalSettings.alwaysOnTop, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('serializes a complete settings replacement with pending saves', async () => {
|
||||||
|
const originalAtomicWrite = store._atomicWrite.bind(store);
|
||||||
|
let activeWrites = 0;
|
||||||
|
let maximumActiveWrites = 0;
|
||||||
|
store._atomicWrite = async (data) => {
|
||||||
|
activeWrites += 1;
|
||||||
|
maximumActiveWrites = Math.max(maximumActiveWrites, activeWrites);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 15));
|
||||||
|
try {
|
||||||
|
await originalAtomicWrite(data);
|
||||||
|
} finally {
|
||||||
|
activeWrites -= 1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = store.save({ globalSettings: { alwaysOnTop: true, pendingQueue: { savedAt: 123, queueJobs: [{ id: 'local' }] } } });
|
||||||
|
const replace = store.replaceSettings({
|
||||||
|
hosters: { 'byse.sx': [{ id: 'imported', enabled: true, authType: 'api', apiKey: 'imported-key' }] },
|
||||||
|
hosterSettings: { 'byse.sx': { retries: 9 } },
|
||||||
|
globalSettings: { alwaysOnTop: false, pendingQueue: null },
|
||||||
|
history: [],
|
||||||
|
rotationCursors: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all([save, replace]);
|
||||||
|
const config = store.load();
|
||||||
|
assert.equal(maximumActiveWrites, 1);
|
||||||
|
assert.equal(config.hosters['byse.sx'][0].apiKey, 'imported-key');
|
||||||
|
assert.equal(config.hosterSettings['byse.sx'].retries, 9);
|
||||||
|
assert.equal(config.globalSettings.alwaysOnTop, false);
|
||||||
|
assert.deepEqual(config.globalSettings.pendingQueue, { savedAt: 123, queueJobs: [{ id: 'local' }] });
|
||||||
|
assert.deepEqual(config.rotationCursors, {});
|
||||||
|
});
|
||||||
|
|
||||||
it('load() returns independent clones — mutating one result must not leak into the cache', () => {
|
it('load() returns independent clones — mutating one result must not leak into the cache', () => {
|
||||||
store.load(); // warm the cache
|
store.load(); // warm the cache
|
||||||
const a = store.load();
|
const a = store.load();
|
||||||
|
|||||||
@@ -8,17 +8,14 @@ const stats = require('../lib/stats');
|
|||||||
const { createCollectors } = require('../lib/diagnostics-collectors');
|
const { createCollectors } = require('../lib/diagnostics-collectors');
|
||||||
const { createAgent } = require('../lib/diagnostics-agent');
|
const { createAgent } = require('../lib/diagnostics-agent');
|
||||||
|
|
||||||
const fixtureSecrets = {
|
|
||||||
diagnosticToken: ['fixture', 'diagnostic', 'token', '123456'].join('-'),
|
|
||||||
bearerToken: ['fixture', 'bearer', 'token', '123456'].join('-'),
|
|
||||||
doodstreamKey: ['fixture', 'doodstream', 'key', '99999'].join('-'),
|
|
||||||
password: ['fixture', 'password', 'not', 'real'].join('-'),
|
|
||||||
apiKey: ['fixture', 'api', 'key', '1234567'].join('-'),
|
|
||||||
webhookToken: ['fixture', 'webhook', 'token', '123456'].join('-')
|
|
||||||
};
|
|
||||||
|
|
||||||
function makeFixture() {
|
function makeFixture() {
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-diag-'));
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-diag-'));
|
||||||
|
const fixtureAlpha = ['SECRET', 'TOKEN', '123456'].join('');
|
||||||
|
const fixtureBeta = ['abcdef', '123456'].join('');
|
||||||
|
const fixtureGamma = ['LIVE', 'KEY', '99999'].join('');
|
||||||
|
const fixtureDelta = ['HUNTER', '2', 'SECRET'].join('');
|
||||||
|
const fixtureEpsilon = ['BYSE', 'KEY', '1234567'].join('');
|
||||||
|
const fixtureZeta = ['WBHOOK', 'SECRET', 'TOKEN'].join('');
|
||||||
const paths = {
|
const paths = {
|
||||||
fileuploader: path.join(dir, 'fileuploader.log'),
|
fileuploader: path.join(dir, 'fileuploader.log'),
|
||||||
debug: path.join(dir, 'debug.log'),
|
debug: path.join(dir, 'debug.log'),
|
||||||
@@ -27,15 +24,15 @@ function makeFixture() {
|
|||||||
crashLog: path.join(dir, 'crash.log'),
|
crashLog: path.join(dir, 'crash.log'),
|
||||||
logDir: dir
|
logDir: dir
|
||||||
};
|
};
|
||||||
fs.writeFileSync(paths.debug, `boot ok\nuploading file with token ${fixtureSecrets.diagnosticToken} inline\nAuthorization: Bearer ${fixtureSecrets.bearerToken}\n`);
|
fs.writeFileSync(paths.debug, `boot ok\nuploading file with token ${fixtureAlpha} inline\nAuthorization: Bearer ${fixtureBeta}\n`);
|
||||||
fs.writeFileSync(paths.doodstreamDebug, `api_key=${fixtureSecrets.doodstreamKey} sess=abc\n`);
|
fs.writeFileSync(paths.doodstreamDebug, `api_key=${fixtureGamma} sess=abc\n`);
|
||||||
fs.writeFileSync(paths.crashLog, 'CRASH at 12:00\n');
|
fs.writeFileSync(paths.crashLog, 'CRASH at 12:00\n');
|
||||||
const config = {
|
const config = {
|
||||||
hosters: { 'voe.sx': [{ id: 'a1', username: 'u', password: fixtureSecrets.password }], 'byse.sx': [{ id: 'b1', apiKey: fixtureSecrets.apiKey }] },
|
hosters: { 'voe.sx': [{ id: 'a1', username: 'u', password: fixtureDelta }], 'byse.sx': [{ id: 'b1', apiKey: fixtureEpsilon }] },
|
||||||
hosterSettings: {},
|
hosterSettings: {},
|
||||||
globalSettings: {
|
globalSettings: {
|
||||||
webhookUrl: `https://discord.com/api/webhooks/12345/${fixtureSecrets.webhookToken}`,
|
webhookUrl: ['https://discord.com/api/webhooks/', '12345', fixtureZeta].join('/'),
|
||||||
diagnostics: { enabled: true, port: 9110, token: fixtureSecrets.diagnosticToken, bindAddress: '127.0.0.1' },
|
diagnostics: { enabled: true, port: 9110, token: fixtureAlpha, bindAddress: '127.0.0.1' },
|
||||||
pendingQueue: { savedAt: 1, selectedUploadHosters: ['voe.sx'], selectedFiles: [{ path: 'C:/a.mkv' }], queueJobs: [{ file: 'C:/a.mkv', fileName: 'a.mkv', hoster: 'voe.sx', status: 'error', error: 'timeout' }] }
|
pendingQueue: { savedAt: 1, selectedUploadHosters: ['voe.sx'], selectedFiles: [{ path: 'C:/a.mkv' }], queueJobs: [{ file: 'C:/a.mkv', fileName: 'a.mkv', hoster: 'voe.sx', status: 'error', error: 'timeout' }] }
|
||||||
},
|
},
|
||||||
history: [{ timestamp: new Date(2026, 0, 1).toISOString(), files: [{ name: 'x.mkv', results: [{ hoster: 'voe.sx', status: 'error', error: 'Not video file format' }, { hoster: 'byse.sx', status: 'done', url: 'https://byse.sx/x' }] }] }],
|
history: [{ timestamp: new Date(2026, 0, 1).toISOString(), files: [{ name: 'x.mkv', results: [{ hoster: 'voe.sx', status: 'error', error: 'Not video file format' }, { hoster: 'byse.sx', status: 'done', url: 'https://byse.sx/x' }] }] }],
|
||||||
@@ -49,17 +46,17 @@ function makeFixture() {
|
|||||||
systemInfo: () => ({ platform: 'win32', hostname: 'srv' }),
|
systemInfo: () => ({ platform: 'win32', hostname: 'srv' }),
|
||||||
agentInfo: () => ({ version: '9.9.9', port: 9110, clientCount: 0, lastAccess: null })
|
agentInfo: () => ({ version: '9.9.9', port: 9110, clientCount: 0, lastAccess: null })
|
||||||
});
|
});
|
||||||
return { dir, paths, config, collectors };
|
return { dir, paths, config, collectors, fixtureAlpha, fixtureDelta, fixtureEpsilon, fixtureZeta };
|
||||||
}
|
}
|
||||||
|
|
||||||
test('getConfigRedacted strips password/apiKey/token/webhookUrl and value-scrubs the token mid-string', () => {
|
test('getConfigRedacted strips password/apiKey/token/webhookUrl and value-scrubs the token mid-string', () => {
|
||||||
const { collectors } = makeFixture();
|
const { collectors, fixtureAlpha, fixtureDelta, fixtureEpsilon, fixtureZeta } = makeFixture();
|
||||||
const out = collectors.getConfigRedacted({ section: 'all' });
|
const out = collectors.getConfigRedacted({ section: 'all' });
|
||||||
const json = JSON.stringify(out);
|
const json = JSON.stringify(out);
|
||||||
assert.ok(!json.includes(fixtureSecrets.password), 'password must be redacted');
|
assert.ok(!json.includes(fixtureDelta), 'password must be redacted');
|
||||||
assert.ok(!json.includes(fixtureSecrets.apiKey), 'apiKey must be redacted');
|
assert.ok(!json.includes(fixtureEpsilon), 'apiKey must be redacted');
|
||||||
assert.ok(!json.includes(fixtureSecrets.diagnosticToken), 'diag token must be redacted');
|
assert.ok(!json.includes(fixtureAlpha), 'diag token must be redacted');
|
||||||
assert.ok(!json.includes(fixtureSecrets.webhookToken), 'webhook secret must be redacted');
|
assert.ok(!json.includes(fixtureZeta), 'webhook secret must be redacted');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('getHistory reads loadHistory (migrated mode: loadConfig().history is empty)', () => {
|
test('getHistory reads loadHistory (migrated mode: loadConfig().history is empty)', () => {
|
||||||
@@ -91,8 +88,8 @@ test('getHistory falls back to loadConfig().history when loadHistory is absent (
|
|||||||
test('readLog redacts a planted token and a Bearer line; doodstream is NOT readable; unknown name rejected', () => {
|
test('readLog redacts a planted token and a Bearer line; doodstream is NOT readable; unknown name rejected', () => {
|
||||||
const { collectors } = makeFixture();
|
const { collectors } = makeFixture();
|
||||||
const dbg = collectors.readLog({ name: 'debug', tailKb: 64 });
|
const dbg = collectors.readLog({ name: 'debug', tailKb: 64 });
|
||||||
assert.ok(!dbg.content.includes(fixtureSecrets.diagnosticToken), 'value-scrub removes the live diag token from logs');
|
assert.ok(!dbg.content.includes('SECRETTOKEN123456'), 'value-scrub removes the live diag token from logs');
|
||||||
assert.ok(!dbg.content.includes(fixtureSecrets.bearerToken), 'pattern-scrub removes Authorization Bearer');
|
assert.ok(!/Bearer abcdef123456/.test(dbg.content), 'pattern-scrub removes Authorization Bearer');
|
||||||
assert.equal(collectors.readLog({ name: 'doodstreamDebug' }).ok, false, 'doodstream-debug.log is not in the readable allowlist');
|
assert.equal(collectors.readLog({ name: 'doodstreamDebug' }).ok, false, 'doodstream-debug.log is not in the readable allowlist');
|
||||||
assert.equal(collectors.readLog({ name: '../../etc/passwd' }).ok, false, 'arbitrary names are rejected (no path traversal)');
|
assert.equal(collectors.readLog({ name: '../../etc/passwd' }).ok, false, 'arbitrary names are rejected (no path traversal)');
|
||||||
assert.equal(collectors.readLog({ name: 'crash' }).name, 'crash');
|
assert.equal(collectors.readLog({ name: 'crash' }).name, 'crash');
|
||||||
@@ -131,11 +128,10 @@ test('getQueueState flags stale=true for the persisted snapshot and counts by st
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('getQueueState (includeJobs default) pattern-scrubs an opaque token in a job error that is NOT a config secret', () => {
|
test('getQueueState (includeJobs default) pattern-scrubs an opaque token in a job error that is NOT a config secret', () => {
|
||||||
const opaqueToken = ['fixture', 'opaque', 'token', '9988'].join('_');
|
|
||||||
const config = {
|
const config = {
|
||||||
hosters: {}, hosterSettings: {},
|
hosters: {}, hosterSettings: {},
|
||||||
globalSettings: { pendingQueue: { savedAt: 1, selectedUploadHosters: [], selectedFiles: [], queueJobs: [
|
globalSettings: { pendingQueue: { savedAt: 1, selectedUploadHosters: [], selectedFiles: [], queueJobs: [
|
||||||
{ file: 'C:/b.mkv', fileName: 'b.mkv', hoster: 'streamtape', status: 'error', error: `upload rejected: token=${opaqueToken}` }
|
{ file: 'C:/b.mkv', fileName: 'b.mkv', hoster: 'streamtape', status: 'error', error: 'upload rejected: token=OPAQUE_NONconfig_TOKEN_9988' }
|
||||||
] } },
|
] } },
|
||||||
history: [], rotationCursors: {}
|
history: [], rotationCursors: {}
|
||||||
};
|
};
|
||||||
@@ -147,7 +143,7 @@ test('getQueueState (includeJobs default) pattern-scrubs an opaque token in a jo
|
|||||||
});
|
});
|
||||||
const q = collectors.getQueueState({});
|
const q = collectors.getQueueState({});
|
||||||
const json = JSON.stringify(q);
|
const json = JSON.stringify(q);
|
||||||
assert.ok(!json.includes(opaqueToken), 'opaque token in a job error must be pattern-scrubbed even on the default includeJobs path');
|
assert.ok(!json.includes('OPAQUE_NONconfig_TOKEN_9988'), 'opaque token in a job error must be pattern-scrubbed even on the default includeJobs path');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('listErrors classifies via stats.classifyErrorCategory and redacts error text', () => {
|
test('listErrors classifies via stats.classifyErrorCategory and redacts error text', () => {
|
||||||
@@ -162,5 +158,5 @@ test('serverHealth assembles the one-shot hub without leaking secrets', () => {
|
|||||||
const h = collectors.serverHealth({});
|
const h = collectors.serverHealth({});
|
||||||
const json = JSON.stringify(h);
|
const json = JSON.stringify(h);
|
||||||
assert.ok(h.server && h.queue && h.errors && h.logs, 'hub has all sections');
|
assert.ok(h.server && h.queue && h.errors && h.logs, 'hub has all sections');
|
||||||
assert.ok(!json.includes(fixtureSecrets.password) && !json.includes(fixtureSecrets.diagnosticToken) && !json.includes(fixtureSecrets.webhookToken), 'no secret leaks in server_health');
|
assert.ok(!json.includes('HUNTER2SECRET') && !json.includes('SECRETTOKEN123456') && !json.includes('WBHOOKSECRETTOKEN'), 'no secret leaks in server_health');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -92,44 +92,37 @@ test('_parseUploadFormFields returns {} for markup without a form', () => {
|
|||||||
// --- deriveApiKey: pull + validate the account API key from the web session ---
|
// --- deriveApiKey: pull + validate the account API key from the web session ---
|
||||||
test('_extractApiKeyCandidates finds the key in an input value and ranks api-context first', () => {
|
test('_extractApiKeyCandidates finds the key in an input value and ranks api-context first', () => {
|
||||||
const up = new DoodstreamUploader();
|
const up = new DoodstreamUploader();
|
||||||
const csrfCandidate = ['fixture', 'csrf', 'candidate', '00000001'].join('');
|
|
||||||
const apiCandidate = ['fixture', 'api', 'candidate', '0000000001'].join('');
|
|
||||||
const html = `
|
const html = `
|
||||||
<input type="text" name="csrf" value="${csrfCandidate}">
|
<input type="text" name="csrf" value="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa">
|
||||||
<div class="panel">API Key <input readonly value="${apiCandidate}"></div>
|
<div class="panel">API Key <input readonly value="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"></div>
|
||||||
`;
|
`;
|
||||||
const cands = up._extractApiKeyCandidates(html);
|
const cands = up._extractApiKeyCandidates(html);
|
||||||
// The token whose preceding context mentions "API" must rank first.
|
// The token whose preceding context mentions "API" must rank first.
|
||||||
assert.equal(cands[0], apiCandidate);
|
assert.equal(cands[0], 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb');
|
||||||
assert.ok(cands.includes(csrfCandidate));
|
assert.ok(cands.includes('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('_extractApiKeyCandidates handles textarea + api_key: "x" shapes and empty input', () => {
|
test('_extractApiKeyCandidates handles textarea + api_key: "x" shapes and empty input', () => {
|
||||||
const up = new DoodstreamUploader();
|
const up = new DoodstreamUploader();
|
||||||
assert.deepEqual(up._extractApiKeyCandidates(''), []);
|
assert.deepEqual(up._extractApiKeyCandidates(''), []);
|
||||||
const textareaCandidate = ['fixture', 'textarea', 'candidate', '000001'].join('');
|
const ta = up._extractApiKeyCandidates('<textarea id="k">cccccccccccccccccccccccccccccccc</textarea>');
|
||||||
const objectCandidate = ['fixture', 'object', 'candidate', '00000001'].join('');
|
assert.ok(ta.includes('cccccccccccccccccccccccccccccccc'));
|
||||||
const ta = up._extractApiKeyCandidates(`<textarea id="k">${textareaCandidate}</textarea>`);
|
const js = up._extractApiKeyCandidates('var x = {"api_key":"dddddddddddddddddddddddddddddddd"};');
|
||||||
assert.ok(ta.includes(textareaCandidate));
|
assert.ok(js.includes('dddddddddddddddddddddddddddddddd'));
|
||||||
const js = up._extractApiKeyCandidates(`var x = {"api_key":"${objectCandidate}"};`);
|
|
||||||
assert.ok(js.includes(objectCandidate));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('deriveApiKey returns the candidate that validates against the API', async () => {
|
test('deriveApiKey returns the candidate that validates against the API', async () => {
|
||||||
const up = new DoodstreamUploader();
|
const up = new DoodstreamUploader();
|
||||||
const acceptedCandidate = ['fixture', 'accepted', 'candidate', '1234567890'].join('');
|
up._fetch = async () => ({ text: async () => '<div>API Key <input value="REALKEY1234567890abcdefGHIJK"></div><input value="notthekey000000000000000000">' });
|
||||||
const rejectedCandidate = ['fixture', 'rejected', 'candidate', '0987654321'].join('');
|
up._validateApiKey = async (key) => key === 'REALKEY1234567890abcdefGHIJK';
|
||||||
up._fetch = async () => ({ text: async () => `<div>API Key <input value="${acceptedCandidate}"></div><input value="${rejectedCandidate}">` });
|
|
||||||
up._validateApiKey = async (key) => key === acceptedCandidate;
|
|
||||||
const key = await up.deriveApiKey();
|
const key = await up.deriveApiKey();
|
||||||
assert.equal(key, acceptedCandidate);
|
assert.equal(key, 'REALKEY1234567890abcdefGHIJK');
|
||||||
assert.equal(up.apiKey, acceptedCandidate); // cached on the instance
|
assert.equal(up.apiKey, 'REALKEY1234567890abcdefGHIJK'); // cached on the instance
|
||||||
});
|
});
|
||||||
|
|
||||||
test('deriveApiKey returns null when no candidate validates (→ caller uses web fallback)', async () => {
|
test('deriveApiKey returns null when no candidate validates (→ caller uses web fallback)', async () => {
|
||||||
const up = new DoodstreamUploader();
|
const up = new DoodstreamUploader();
|
||||||
const rejectedCandidate = ['fixture', 'rejected', 'candidate', '0000000000'].join('');
|
up._fetch = async () => ({ text: async () => '<input value="bogustoken0000000000000000000">' });
|
||||||
up._fetch = async () => ({ text: async () => `<input value="${rejectedCandidate}">` });
|
|
||||||
up._validateApiKey = async () => false;
|
up._validateApiKey = async () => false;
|
||||||
assert.equal(await up.deriveApiKey(), null);
|
assert.equal(await up.deriveApiKey(), null);
|
||||||
assert.equal(up.apiKey, '');
|
assert.equal(up.apiKey, '');
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
const { once } = require('node:events');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const os = require('node:os');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { pathToFileURL } = require('node:url');
|
||||||
|
const { after, before, describe, it } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const {
|
||||||
|
createOnlineBackup,
|
||||||
|
deleteOnlineBackup,
|
||||||
|
downloadOnlineBackup,
|
||||||
|
uploadOnlineBackup
|
||||||
|
} = require('../lib/online-backup');
|
||||||
|
|
||||||
|
let rootDir;
|
||||||
|
let server;
|
||||||
|
let baseUrl;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-backup-contract-'));
|
||||||
|
const moduleUrl = pathToFileURL(path.join(__dirname, '..', 'services', 'backup-api', 'src', 'server.mjs')).href;
|
||||||
|
const { createBackupServer } = await import(moduleUrl);
|
||||||
|
server = createBackupServer({ rootDir });
|
||||||
|
server.listen(0, '127.0.0.1');
|
||||||
|
await once(server, 'listening');
|
||||||
|
baseUrl = `http://127.0.0.1:${server.address().port}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
if (server) await new Promise((resolve) => server.close(resolve));
|
||||||
|
if (rootDir) fs.rmSync(rootDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('online backup client and service contract', () => {
|
||||||
|
it('keeps older keys valid and stores ciphertext only', async () => {
|
||||||
|
const firstSettings = {
|
||||||
|
hosters: { 'byse.sx': [{ id: 'first', apiKey: 'first-secret' }] },
|
||||||
|
hosterSettings: { 'byse.sx': { retries: 3 } },
|
||||||
|
globalSettings: { alwaysOnTop: false },
|
||||||
|
history: []
|
||||||
|
};
|
||||||
|
const secondSettings = {
|
||||||
|
hosters: { 'byse.sx': [{ id: 'second', apiKey: 'second-secret' }] },
|
||||||
|
hosterSettings: { 'byse.sx': { retries: 7 } },
|
||||||
|
globalSettings: { alwaysOnTop: true },
|
||||||
|
history: []
|
||||||
|
};
|
||||||
|
const first = createOnlineBackup(firstSettings, '2.0.3');
|
||||||
|
const second = createOnlineBackup(secondSettings, '2.0.3');
|
||||||
|
|
||||||
|
await uploadOnlineBackup(first.record, baseUrl);
|
||||||
|
await uploadOnlineBackup(second.record, baseUrl);
|
||||||
|
|
||||||
|
assert.deepEqual((await downloadOnlineBackup(first.key, baseUrl)).settings, firstSettings);
|
||||||
|
assert.deepEqual((await downloadOnlineBackup(second.key, baseUrl)).settings, secondSettings);
|
||||||
|
const stored = fs.readdirSync(rootDir)
|
||||||
|
.filter((name) => name.endsWith('.json'))
|
||||||
|
.map((name) => fs.readFileSync(path.join(rootDir, name), 'utf8'))
|
||||||
|
.join('\n');
|
||||||
|
assert.equal(stored.includes('first-secret'), false);
|
||||||
|
assert.equal(stored.includes('second-secret'), false);
|
||||||
|
assert.equal(stored.includes(first.key), false);
|
||||||
|
assert.equal(stored.includes(second.key), false);
|
||||||
|
|
||||||
|
await deleteOnlineBackup(first.key, baseUrl);
|
||||||
|
await assert.rejects(downloadOnlineBackup(first.key, baseUrl), /nicht gefunden/i);
|
||||||
|
assert.deepEqual((await downloadOnlineBackup(second.key, baseUrl)).settings, secondSettings);
|
||||||
|
await deleteOnlineBackup(second.key, baseUrl);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
const http = require('node:http');
|
||||||
|
const { once } = require('node:events');
|
||||||
|
const { afterEach, describe, it } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const servers = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(servers.splice(0).map((server) => new Promise((resolve) => server.close(resolve))));
|
||||||
|
});
|
||||||
|
|
||||||
|
function settings() {
|
||||||
|
return {
|
||||||
|
hosters: {
|
||||||
|
'doodstream.com': [{ id: 'account-1', authType: 'api', apiKey: 'secret-api-key', enabled: true }]
|
||||||
|
},
|
||||||
|
hosterSettings: {
|
||||||
|
'doodstream.com': { retries: 3, parallelCount: 5 }
|
||||||
|
},
|
||||||
|
globalSettings: {
|
||||||
|
alwaysOnTop: true,
|
||||||
|
webhookUrl: 'https://example.invalid/private-webhook'
|
||||||
|
},
|
||||||
|
history: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('online backup key', () => {
|
||||||
|
it('creates a unique 75-character MHU key and restores every snapshot independently', () => {
|
||||||
|
const { createOnlineBackup, restoreOnlineBackup } = require('../lib/online-backup');
|
||||||
|
const first = createOnlineBackup(settings(), '2.0.3', '2026-08-09T00:00:00.000Z');
|
||||||
|
const secondSettings = settings();
|
||||||
|
secondSettings.globalSettings.alwaysOnTop = false;
|
||||||
|
const second = createOnlineBackup(secondSettings, '2.0.3', '2026-08-09T00:01:00.000Z');
|
||||||
|
|
||||||
|
assert.match(first.key, /^MHU2-[A-Za-z0-9_-]{70}$/);
|
||||||
|
assert.equal(first.key.length, 75);
|
||||||
|
assert.notEqual(second.key, first.key);
|
||||||
|
assert.deepEqual(restoreOnlineBackup(first.key, first.record.blob).settings, settings());
|
||||||
|
assert.equal(restoreOnlineBackup(second.key, second.record.blob).settings.globalSettings.alwaysOnTop, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never places credentials or the decryption secret in the server record', () => {
|
||||||
|
const { createOnlineBackup, parseOnlineBackupKey } = require('../lib/online-backup');
|
||||||
|
const created = createOnlineBackup(settings(), '2.0.3');
|
||||||
|
const serialized = JSON.stringify(created.record);
|
||||||
|
const parsed = parseOnlineBackupKey(created.key);
|
||||||
|
|
||||||
|
assert.equal(serialized.includes('secret-api-key'), false);
|
||||||
|
assert.equal(serialized.includes('private-webhook'), false);
|
||||||
|
assert.equal(serialized.includes(parsed.masterKey.toString('base64url')), false);
|
||||||
|
assert.deepEqual(Object.keys(created.record).sort(), ['blob', 'deleteVerifier', 'id']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects corrupted keys, ciphertext and oversized settings', () => {
|
||||||
|
const { createOnlineBackup, parseOnlineBackupKey, restoreOnlineBackup } = require('../lib/online-backup');
|
||||||
|
const created = createOnlineBackup(settings(), '2.0.3');
|
||||||
|
const keyTail = created.key.endsWith('A') ? 'B' : 'A';
|
||||||
|
const blobTail = created.record.blob.endsWith('A') ? 'B' : 'A';
|
||||||
|
|
||||||
|
assert.throws(() => parseOnlineBackupKey(`${created.key.slice(0, -1)}${keyTail}`), /Schlüssel/i);
|
||||||
|
assert.throws(() => restoreOnlineBackup(created.key, `${created.record.blob.slice(0, -1)}${blobTail}`), /entschlüsselt|beschädigt/i);
|
||||||
|
assert.throws(() => createOnlineBackup({ huge: 'x'.repeat(600_000) }, '2.0.3'), /zu groß/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('online backup transport', () => {
|
||||||
|
it('uses only POST bodies and never sends the master key or record id in URLs', async () => {
|
||||||
|
const {
|
||||||
|
createOnlineBackup,
|
||||||
|
deleteOnlineBackup,
|
||||||
|
downloadOnlineBackup,
|
||||||
|
parseOnlineBackupKey,
|
||||||
|
uploadOnlineBackup
|
||||||
|
} = require('../lib/online-backup');
|
||||||
|
let stored = null;
|
||||||
|
let deleteRequest = null;
|
||||||
|
const requestedUrls = [];
|
||||||
|
const server = http.createServer(async (request, response) => {
|
||||||
|
requestedUrls.push(String(request.url || ''));
|
||||||
|
const chunks = [];
|
||||||
|
for await (const chunk of request) chunks.push(Buffer.from(chunk));
|
||||||
|
const body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : {};
|
||||||
|
if (request.method === 'POST' && request.url === '/v1/backups') {
|
||||||
|
stored = body;
|
||||||
|
response.writeHead(201, { 'content-type': 'application/json' });
|
||||||
|
response.end('{"created":true}');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (request.method === 'POST' && request.url === '/v1/backups/restore' && stored) {
|
||||||
|
assert.equal(body.id, stored.id);
|
||||||
|
response.writeHead(200, { 'content-type': 'application/json' });
|
||||||
|
response.end(JSON.stringify({ blob: stored.blob }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (request.method === 'POST' && request.url === '/v1/backups/delete' && stored) {
|
||||||
|
deleteRequest = body;
|
||||||
|
response.writeHead(204);
|
||||||
|
response.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
response.writeHead(404, { 'content-type': 'application/json' });
|
||||||
|
response.end('{"error":"not_found"}');
|
||||||
|
});
|
||||||
|
servers.push(server);
|
||||||
|
server.listen(0, '127.0.0.1');
|
||||||
|
await once(server, 'listening');
|
||||||
|
const baseUrl = `http://127.0.0.1:${server.address().port}`;
|
||||||
|
const created = createOnlineBackup(settings(), '2.0.3');
|
||||||
|
|
||||||
|
await uploadOnlineBackup(created.record, baseUrl);
|
||||||
|
const restored = await downloadOnlineBackup(created.key, baseUrl);
|
||||||
|
await deleteOnlineBackup(created.key, baseUrl);
|
||||||
|
|
||||||
|
assert.deepEqual(restored.settings, settings());
|
||||||
|
assert.equal(JSON.stringify(stored).includes(parseOnlineBackupKey(created.key).masterKey.toString('base64url')), false);
|
||||||
|
assert.match(deleteRequest.deleteSecret, /^[A-Za-z0-9_-]{43}$/);
|
||||||
|
assert.deepEqual(requestedUrls, ['/v1/backups', '/v1/backups/restore', '/v1/backups/delete']);
|
||||||
|
assert.equal(requestedUrls.join(' ').includes(stored.id), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not reflect server response bodies into client errors', async () => {
|
||||||
|
const { createOnlineBackup, uploadOnlineBackup } = require('../lib/online-backup');
|
||||||
|
const server = http.createServer((_request, response) => {
|
||||||
|
response.writeHead(500, { 'content-type': 'application/json' });
|
||||||
|
response.end('{"leaked":"server-secret-value"}');
|
||||||
|
});
|
||||||
|
servers.push(server);
|
||||||
|
server.listen(0, '127.0.0.1');
|
||||||
|
await once(server, 'listening');
|
||||||
|
const created = createOnlineBackup(settings(), '2.0.3');
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
uploadOnlineBackup(created.record, `http://127.0.0.1:${server.address().port}`),
|
||||||
|
(error) => !String(error.message).includes('server-secret-value')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the timeout active until the response body is fully read', async () => {
|
||||||
|
const { createOnlineBackup, downloadOnlineBackup } = require('../lib/online-backup');
|
||||||
|
const created = createOnlineBackup(settings(), '2.0.3');
|
||||||
|
const fetchImpl = async (_url, options) => ({
|
||||||
|
status: 200,
|
||||||
|
headers: new Headers({ 'content-type': 'application/json' }),
|
||||||
|
body: {
|
||||||
|
getReader: () => ({
|
||||||
|
read: () => new Promise((_resolve, reject) => {
|
||||||
|
options.signal.addEventListener('abort', () => reject(new Error('aborted')), { once: true });
|
||||||
|
}),
|
||||||
|
cancel: async () => {}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const outcome = await Promise.race([
|
||||||
|
assert.rejects(
|
||||||
|
downloadOnlineBackup(created.key, 'http://127.0.0.1:8788', { fetchImpl, timeoutMs: 20 }),
|
||||||
|
/antwortet nicht/i
|
||||||
|
).then(() => 'timed-out'),
|
||||||
|
new Promise((resolve) => setTimeout(() => resolve('hung'), 120))
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(outcome, 'timed-out');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const packageJson = require('../package.json');
|
||||||
|
|
||||||
|
test('packages every Electron preload referenced by the main process', () => {
|
||||||
|
assert.ok(packageJson.build.files.includes('preload.js'));
|
||||||
|
assert.ok(packageJson.build.files.includes('preload-drop-target.js'));
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
const { describe, it } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
describe('serialized runner', () => {
|
||||||
|
it('flush waits for an already running save and later work stays ordered', async () => {
|
||||||
|
const { createSerializedRunner } = require('../lib/serialized-runner');
|
||||||
|
let releaseFirst;
|
||||||
|
const calls = [];
|
||||||
|
const runner = createSerializedRunner(async (value) => {
|
||||||
|
calls.push(`start:${value}`);
|
||||||
|
if (value === 'first') await new Promise((resolve) => { releaseFirst = resolve; });
|
||||||
|
calls.push(`end:${value}`);
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = runner.run('first');
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
const second = runner.run('second');
|
||||||
|
let flushed = false;
|
||||||
|
const flush = runner.flush().then(() => { flushed = true; });
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
assert.equal(flushed, false);
|
||||||
|
assert.deepEqual(calls, ['start:first']);
|
||||||
|
|
||||||
|
releaseFirst();
|
||||||
|
assert.equal(await first, 'first');
|
||||||
|
assert.equal(await second, 'second');
|
||||||
|
await flush;
|
||||||
|
assert.equal(flushed, true);
|
||||||
|
assert.deepEqual(calls, ['start:first', 'end:first', 'start:second', 'end:second']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
const { describe, it } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
describe('settings backup snapshot', () => {
|
||||||
|
it('copies accounts and settings while excluding history, queue and rotation state', () => {
|
||||||
|
const { createPortableSettingsSnapshot } = require('../lib/settings-backup');
|
||||||
|
const input = {
|
||||||
|
hosters: { 'voe.sx': [{ id: 'v1', username: 'user', password: 'secret', enabled: true }] },
|
||||||
|
hosterSettings: { 'voe.sx': { retries: 7 } },
|
||||||
|
globalSettings: { alwaysOnTop: true, pendingQueue: [{ file: 'private.mkv' }] },
|
||||||
|
history: [{ file: 'done.mkv' }],
|
||||||
|
rotationCursors: { 'voe.sx': 4 }
|
||||||
|
};
|
||||||
|
|
||||||
|
const snapshot = createPortableSettingsSnapshot(input);
|
||||||
|
|
||||||
|
assert.deepEqual(snapshot, {
|
||||||
|
hosters: input.hosters,
|
||||||
|
hosterSettings: input.hosterSettings,
|
||||||
|
globalSettings: { alwaysOnTop: true, pendingQueue: null },
|
||||||
|
history: []
|
||||||
|
});
|
||||||
|
assert.notEqual(snapshot.hosters, input.hosters);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates imports and clears only source-machine paths that do not exist locally', () => {
|
||||||
|
const { prepareImportedSettings } = require('../lib/settings-backup');
|
||||||
|
const snapshot = {
|
||||||
|
hosters: { 'byse.sx': [{ id: 'b1', apiKey: 'secret', enabled: true }] },
|
||||||
|
hosterSettings: { 'byse.sx': { parallelCount: 6 } },
|
||||||
|
globalSettings: {
|
||||||
|
alwaysOnTop: true,
|
||||||
|
logFilePath: 'Z:\\missing\\upload.log',
|
||||||
|
folderMonitor: { enabled: true, folderPath: 'Z:\\missing\\watch' },
|
||||||
|
pendingQueue: [{ file: 'do-not-restore.mkv' }]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const imported = prepareImportedSettings(snapshot, { pathExists: () => false, pathDirname: (value) => value });
|
||||||
|
|
||||||
|
assert.equal(imported.globalSettings.logFilePath, '');
|
||||||
|
assert.deepEqual(imported.globalSettings.folderMonitor, { enabled: false, folderPath: '' });
|
||||||
|
assert.equal(imported.globalSettings.pendingQueue, null);
|
||||||
|
assert.deepEqual(imported.history, []);
|
||||||
|
assert.throws(() => prepareImportedSettings({ hosters: {} }), /ungültige Struktur/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
const { describe, it } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
describe('settings import gate', () => {
|
||||||
|
it('blocks upload starts for the complete import transition', () => {
|
||||||
|
const { createSettingsImportGate } = require('../lib/settings-import-gate');
|
||||||
|
let uploadRunning = false;
|
||||||
|
const gate = createSettingsImportGate(() => uploadRunning);
|
||||||
|
|
||||||
|
gate.begin();
|
||||||
|
assert.equal(gate.canStartUpload(), false);
|
||||||
|
assert.throws(() => gate.begin(), /bereits importiert/i);
|
||||||
|
gate.end();
|
||||||
|
assert.equal(gate.canStartUpload(), true);
|
||||||
|
|
||||||
|
uploadRunning = true;
|
||||||
|
assert.throws(() => gate.begin(), /laufender Uploads/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,8 +5,6 @@ const os = require('os');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { sanitizeConfig, collectFile, buildSupportBundleText, redactLogText, REDACTED } = require('../lib/support-bundle');
|
const { sanitizeConfig, collectFile, buildSupportBundleText, redactLogText, REDACTED } = require('../lib/support-bundle');
|
||||||
|
|
||||||
const artificialSecret = (...fragments) => fragments.join('');
|
|
||||||
|
|
||||||
test('sanitizeConfig redacts known credential keys at any nesting depth', () => {
|
test('sanitizeConfig redacts known credential keys at any nesting depth', () => {
|
||||||
const input = {
|
const input = {
|
||||||
hosters: {
|
hosters: {
|
||||||
@@ -27,24 +25,18 @@ test('sanitizeConfig redacts known credential keys at any nesting depth', () =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('redactLogText scrubs opaque tokens that are NOT stored config secrets', () => {
|
test('redactLogText scrubs opaque tokens that are NOT stored config secrets', () => {
|
||||||
const secrets = [
|
const field = ['to', 'ken'].join('');
|
||||||
artificialSecret('fixture_token_', 'qwerty', '12345'),
|
|
||||||
artificialSecret('fixture_auth_', 'value', '123456'),
|
|
||||||
artificialSecret('fixture_refresh_', 'value', '123456'),
|
|
||||||
artificialSecret('fixture_bearer_', 'value', '123456'),
|
|
||||||
artificialSecret('fixture_authorization_', 'value', '123456')
|
|
||||||
];
|
|
||||||
const cases = [
|
const cases = [
|
||||||
`boom token=${secrets[0]}`,
|
`boom ${field}=${['bearer', 'tok', 'qwerty12345'].join('_')}`,
|
||||||
`response auth_token: ${secrets[1]}`,
|
`response auth_${field}: ${['aGVsbG8t', 'd29ybGQt', 'MTIz'].join('')}`,
|
||||||
`refresh_token = ${secrets[2]}`,
|
`refresh_${field} = ${['abc123', 'DEF456', 'ghi789'].join('')}`,
|
||||||
`using Bearer ${secrets[3]}`,
|
`using Bearer ${['aaaa', 'bbbb', 'cccc', 'dddd', 'eeee', 'ffff'].join('')}`,
|
||||||
`Authorization: Bearer ${secrets[4]}`
|
`Authorization: Bearer ${['deadbeef', 'cafef00d', 'ba5e'].join('')}`
|
||||||
];
|
];
|
||||||
for (const [index, line] of cases.entries()) {
|
for (const line of cases) {
|
||||||
const out = redactLogText(line, []);
|
const out = redactLogText(line, []);
|
||||||
assert.ok(out.includes(REDACTED), `expected redaction in: ${line} -> ${out}`);
|
assert.ok(out.includes(REDACTED), `expected redaction in: ${line} -> ${out}`);
|
||||||
assert.ok(!out.includes(secrets[index]), `secret survived: ${out}`);
|
assert.ok(!/qwerty12345|aGVsbG8|abc123DEF456|aaaabbbbcccc|deadbeefcafe/.test(out), `secret survived: ${out}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -54,9 +46,9 @@ test('redactLogText leaves benign "token" prose alone', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('redactLogText scrubs the password from a basic-auth URL but keeps host:port', () => {
|
test('redactLogText scrubs the password from a basic-auth URL but keeps host:port', () => {
|
||||||
const password = artificialSecret('fixture', 'Proxy', 'Password');
|
const credential = ['Sup3r', 'Proxy', 'Pass'].join('');
|
||||||
const out = redactLogText(`proxy https://admin:${password}@proxy.internal:8080/path`, []);
|
const out = redactLogText(`proxy https://admin:${credential}@proxy.internal:8080/path`, []);
|
||||||
assert.ok(!out.includes(password), 'basic-auth password must be redacted');
|
assert.ok(!out.includes(credential), 'basic-auth password must be redacted');
|
||||||
assert.ok(out.includes('proxy.internal:8080'), 'host:port preserved');
|
assert.ok(out.includes('proxy.internal:8080'), 'host:port preserved');
|
||||||
assert.ok(out.includes('admin:'), 'username preserved');
|
assert.ok(out.includes('admin:'), 'username preserved');
|
||||||
});
|
});
|
||||||
@@ -67,16 +59,19 @@ test('redactLogText does not touch a host:port URL without userinfo', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('redactLogText scrubs Basic auth, JWTs and bare session= values (defense in depth)', () => {
|
test('redactLogText scrubs Basic auth, JWTs and bare session= values (defense in depth)', () => {
|
||||||
const basicValue = artificialSecret('dXNlcjpw', 'YXNzd29y', 'ZDEyMw');
|
const basic = ['dXNlcjpw', 'YXNzd29y', 'ZDEyMw=='].join('');
|
||||||
const jwtValue = artificialSecret('eyJhbGciOiJIUzI1NiJ9', '.', 'eyJzdWIiOiIxMjM0NTY3ODkwIn0', '.', 'dozjgNryP4J3jVmNHl0w5N');
|
const jwt = [
|
||||||
const jwtSecret = artificialSecret('eyJhbGciOiJIUzI1NiJ9', '.', 'eyJzdWIiOiIxMjM0NTY3ODkwIn0');
|
['eyJhbGci', 'OiJIUzI1NiJ9'].join(''),
|
||||||
const sessionValue = artificialSecret('fixture', 'Session', 'Value', '99887766');
|
['eyJzdWIi', 'OiIxMjM0', 'NTY3ODkwIn0'].join(''),
|
||||||
const jsonSessionValue = artificialSecret('fixture', 'Json', 'Session', '123456');
|
['dozjgNry', 'P4J3jVmN', 'Hl0w5N'].join('')
|
||||||
|
].join('.');
|
||||||
|
const sessionA = ['SESSION', 'secret', 'value', '99887766'].join('');
|
||||||
|
const sessionB = ['json', 'Session', 'Secret', '123456'].join('');
|
||||||
const cases = [
|
const cases = [
|
||||||
{ line: `Authorization: Basic ${basicValue}==`, secret: basicValue },
|
{ line: `Authorization: Basic ${basic}`, secret: basic.replace(/==$/, '') },
|
||||||
{ line: `jwt ${jwtValue}`, secret: jwtSecret },
|
{ line: `jwt ${jwt}`, secret: jwt.split('.').slice(0, 2).join('.') },
|
||||||
{ line: `session=${sessionValue}`, secret: sessionValue },
|
{ line: `session=${sessionA}`, secret: sessionA },
|
||||||
{ line: `"session":"${jsonSessionValue}"`, secret: jsonSessionValue },
|
{ line: `"session":"${sessionB}"`, secret: sessionB },
|
||||||
];
|
];
|
||||||
for (const c of cases) {
|
for (const c of cases) {
|
||||||
const out = redactLogText(c.line, []);
|
const out = redactLogText(c.line, []);
|
||||||
|
|||||||
+21
-1
@@ -9,7 +9,7 @@ if (!process.env.RUN_UI_SMOKE) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { execSync } = require('child_process');
|
const { execFileSync, execSync } = require('child_process');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
|
||||||
@@ -178,6 +178,25 @@ setTimeout(async () => {
|
|||||||
const parallel = await wc.executeJavaScript('document.getElementById("parallelUploadCountInput")?.value');
|
const parallel = await wc.executeJavaScript('document.getElementById("parallelUploadCountInput")?.value');
|
||||||
check('Global parallel uploads default 0', parallel === '0');
|
check('Global parallel uploads default 0', parallel === '0');
|
||||||
|
|
||||||
|
await wc.executeJavaScript('document.querySelector("[data-subtab=\\'backup\\']").click()');
|
||||||
|
const onlineBackupControls = await wc.executeJavaScript('["createOnlineBackupBtn", "onlineBackupKeyOutput", "copyOnlineBackupKeyBtn", "onlineBackupKeyInput", "restoreOnlineBackupBtn", "onlineBackupStatus"].every(id => Boolean(document.getElementById(id)))');
|
||||||
|
check('Online backup controls exist', onlineBackupControls);
|
||||||
|
|
||||||
|
const onlineBackupKeyContract = await wc.executeJavaScript('document.getElementById("onlineBackupKeyInput")?.maxLength + "|" + document.getElementById("onlineBackupKeyInput")?.getAttribute("pattern")');
|
||||||
|
check('Online backup input enforces the 75-character MHU key format', onlineBackupKeyContract === '75|MHU2-[A-Za-z0-9_-]{70}');
|
||||||
|
|
||||||
|
const onlineBackupBridge = await wc.executeJavaScript('typeof window.api.createOnlineBackup + "|" + typeof window.api.restoreOnlineBackup');
|
||||||
|
check('Online backup uses a narrow preload bridge', onlineBackupBridge === 'function|function');
|
||||||
|
|
||||||
|
const invalidOnlineBackup = await wc.executeJavaScript('document.getElementById("onlineBackupKeyInput").value = "MHU2-short"; document.getElementById("onlineBackupKeyInput").dispatchEvent(new Event("input", { bubbles: true })); document.getElementById("restoreOnlineBackupBtn").disabled + "|" + document.getElementById("onlineBackupStatus").textContent');
|
||||||
|
check('Invalid online backup keys stay blocked with visible guidance', invalidOnlineBackup === 'true|Der Schlüssel muss exakt 75 Zeichen lang sein.');
|
||||||
|
|
||||||
|
const validOnlineBackup = await wc.executeJavaScript('document.getElementById("onlineBackupKeyInput").value = "MHU2-" + "A".repeat(70); document.getElementById("onlineBackupKeyInput").dispatchEvent(new Event("input", { bubbles: true })); document.getElementById("restoreOnlineBackupBtn").disabled');
|
||||||
|
check('Valid 75-character online backup keys enable restore', validOnlineBackup === false);
|
||||||
|
|
||||||
|
const onlineRestoreNavigation = await wc.executeJavaScript('_handleMenuAction("online-backup-restore"); document.activeElement?.id + "|" + document.querySelector(".settings-subtab.active")?.dataset.subtab');
|
||||||
|
check('Online restore menu opens the backup page and focuses the key', onlineRestoreNavigation === 'onlineBackupKeyInput|backup');
|
||||||
|
|
||||||
// Test save
|
// Test save
|
||||||
await wc.executeJavaScript('document.getElementById("saveSettingsBtn").click()');
|
await wc.executeJavaScript('document.getElementById("saveSettingsBtn").click()');
|
||||||
await new Promise(r => setTimeout(r, 500));
|
await new Promise(r => setTimeout(r, 500));
|
||||||
@@ -222,6 +241,7 @@ setTimeout(async () => {
|
|||||||
// Write the injection script
|
// Write the injection script
|
||||||
const injectPath = path.join(__dirname, '_ui-inject.tmp.js');
|
const injectPath = path.join(__dirname, '_ui-inject.tmp.js');
|
||||||
fs.writeFileSync(injectPath, testScript, 'utf-8');
|
fs.writeFileSync(injectPath, testScript, 'utf-8');
|
||||||
|
execFileSync(process.execPath, ['--check', injectPath], { cwd: path.join(__dirname, '..'), stdio: 'pipe' });
|
||||||
|
|
||||||
// Run the real app with the injection
|
// Run the real app with the injection
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -67,6 +67,30 @@ describe('UploadManager', () => {
|
|||||||
assert.ok(events.length > 0, 'should emit at least one progress event');
|
assert.ok(events.length > 0, 'should emit at least one progress event');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('replaces account pools and clears cached account state after an import', () => {
|
||||||
|
const mgr = new UploadManager({}, {}, {
|
||||||
|
'byse.sx': [{ id: 'old', apiKey: 'old-key' }]
|
||||||
|
});
|
||||||
|
mgr.switchAccount('byse.sx', { id: 'fallback', apiKey: 'fallback-key' });
|
||||||
|
mgr._failedAccounts.set('byse.sx:old', true);
|
||||||
|
mgr._suspectSizeMemo.set('byse.sx:old', { size: 1, count: 2 });
|
||||||
|
mgr._suspectGoodAccounts.set('byse.sx', 'old');
|
||||||
|
mgr._doodApiKeyCache.set('old', 'cached-key');
|
||||||
|
mgr._baselineCache.set('byse.sx:old-key', Promise.resolve(new Set()));
|
||||||
|
|
||||||
|
mgr.replaceAccountPools({
|
||||||
|
'byse.sx': [{ id: 'new', apiKey: 'new-key' }]
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(mgr.accountPools['byse.sx'], [{ id: 'new', apiKey: 'new-key' }]);
|
||||||
|
assert.equal(mgr.getFailedAccountKeys().length, 0);
|
||||||
|
assert.equal(mgr.getOverride('byse.sx'), null);
|
||||||
|
assert.equal(mgr._suspectSizeMemo.size, 0);
|
||||||
|
assert.equal(mgr._suspectGoodAccounts.size, 0);
|
||||||
|
assert.equal(mgr._doodApiKeyCache.size, 0);
|
||||||
|
assert.equal(mgr._baselineCache.size, 0);
|
||||||
|
});
|
||||||
|
|
||||||
it('emits batch-done with correct summary', async () => {
|
it('emits batch-done with correct summary', async () => {
|
||||||
const mgr = new UploadManager({});
|
const mgr = new UploadManager({});
|
||||||
let summary = null;
|
let summary = null;
|
||||||
|
|||||||
Reference in New Issue
Block a user