const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const AdmZip = require('adm-zip'); const CryptoUtils = require('./crypto-utils'); class Patcher { constructor(appDataPath) { this.appDataPath = appDataPath; } getCurrentVersion() { const versionFile = path.join(this.appDataPath, 'version.json'); if (fs.existsSync(versionFile)) { try { return JSON.parse(fs.readFileSync(versionFile, 'utf8')).version || '0.0.0'; } catch (e) { return '0.0.0'; } } return '0.0.0'; } getCurrentSha() { const versionFile = path.join(this.appDataPath, 'version.json'); if (fs.existsSync(versionFile)) { try { return JSON.parse(fs.readFileSync(versionFile, 'utf8')).sha256 || null; } catch (e) { return null; } } return null; } bridgeCredentials(credentialManager) { const encryptedPath = path.join(this.appDataPath, 'encrypted-credentials.json'); if (!fs.existsSync(encryptedPath)) { return false; } const buildKey = CryptoUtils.deriveKey('vyla-home-build-key', 'vyla-home-payload-salt-v1'); const encryptedData = fs.readFileSync(encryptedPath, 'utf8'); const credentials = CryptoUtils.decryptObject(encryptedData, buildKey); for (const [service, keys] of Object.entries(credentials)) { credentialManager.setServiceCredentials(service, keys); } fs.unlinkSync(encryptedPath); return true; } saveCurrentVersion(version, sha256 = null) { const versionFile = path.join(this.appDataPath, 'version.json'); fs.mkdirSync(this.appDataPath, { recursive: true }); fs.writeFileSync(versionFile, JSON.stringify({ version, sha256 }, null, 2), 'utf8'); } purgeOldCode() { const dirs = ['frontend', 'player', 'stream-api', 'auth-proxy', 'config']; for (const dir of dirs) { const fullPath = path.join(this.appDataPath, dir); if (fs.existsSync(fullPath)) { try { fs.rmSync(fullPath, { recursive: true, force: true }); } catch (e) { } } } } extractZipPayload(zipPath) { const zip = new AdmZip(zipPath); const entries = zip.getEntries(); if (!entries || entries.length === 0) { throw new Error('Archive is empty, invalid, or truncated.'); } const targetDir = path.resolve(this.appDataPath); for (const entry of entries) { const entryPath = path.normalize(entry.entryName); const resolvedPath = path.resolve(targetDir, entryPath); if (!resolvedPath.startsWith(targetDir)) { throw new Error(`Directory traversal attempt detected in archive: ${entry.entryName}`); } } zip.extractAllTo(this.appDataPath, true); } async extractToStaging(zipPath, onFile) { const stagingDir = path.join(this.appDataPath, '.staging'); fs.rmSync(stagingDir, { recursive: true, force: true }); fs.mkdirSync(stagingDir, { recursive: true }); const zip = new AdmZip(zipPath); const entries = zip.getEntries().filter((e) => !e.isDirectory); if (entries.length === 0) { throw new Error('Downloaded archive is empty, invalid, or truncated.'); } const resolvedStaging = path.resolve(stagingDir); for (const entry of entries) { const entryPath = path.normalize(entry.entryName); const resolvedPath = path.resolve(resolvedStaging, entryPath); if (!resolvedPath.startsWith(resolvedStaging)) { throw new Error(`Directory traversal attempt detected: ${entry.entryName}`); } } for (let i = 0; i < entries.length; i++) { const entry = entries[i]; zip.extractEntryTo(entry, stagingDir, true, true); if (onFile) onFile(entry.entryName, i + 1, entries.length); if (i % 20 === 0) await new Promise((resolve) => setImmediate(resolve)); } return stagingDir; } commitStaging(stagingDir) { this.purgeOldCode(); for (const name of fs.readdirSync(stagingDir)) { const dest = path.join(this.appDataPath, name); fs.rmSync(dest, { recursive: true, force: true }); fs.renameSync(path.join(stagingDir, name), dest); } fs.rmSync(stagingDir, { recursive: true, force: true }); } verifyChecksum(zipPath, expectedSha256) { if (!expectedSha256) return false; const hash = crypto.createHash('sha256'); const data = fs.readFileSync(zipPath); hash.update(data); const calculated = hash.digest('hex'); return calculated === String(expectedSha256).trim().toLowerCase(); } verifyExtraction(root = this.appDataPath) { const requiredFiles = [ 'frontend/server.js', 'player/server.js' ]; for (const file of requiredFiles) { const fullPath = path.join(root, file); if (!fs.existsSync(fullPath)) { return false; } const stat = fs.statSync(fullPath); if (stat.size === 0) { return false; } try { const sampleSize = Math.min(stat.size, 4096); const fd = fs.openSync(fullPath, 'r'); const buffer = Buffer.alloc(sampleSize); fs.readSync(fd, buffer, 0, sampleSize, 0); fs.closeSync(fd); if (buffer.every(byte => byte === 0)) { return false; } } catch (e) { return false; } } return true; } processDirectory(dir, replacementList = []) { const files = fs.readdirSync(dir); for (const file of files) { const fullPath = path.join(dir, file); const stat = fs.statSync(fullPath); if (stat.isDirectory()) { if (file === 'node_modules' || file === '.git') continue; this.processDirectory(fullPath, replacementList); } else if (stat.isFile()) { let content = fs.readFileSync(fullPath, 'utf8'); let modified = false; for (const rep of replacementList) { if (content.includes(rep.find)) { content = content.split(rep.find).join(rep.replace); modified = true; } } if (modified) { fs.writeFileSync(fullPath, content, 'utf8'); } } } } applyPatches(replacements) { const targetDirs = ['frontend', 'player']; for (const dirName of targetDirs) { const dirPath = path.join(this.appDataPath, dirName); if (!fs.existsSync(dirPath)) continue; const replacementList = replacements[dirName] || []; this.processDirectory(dirPath, replacementList); } } } module.exports = Patcher;