Files
Home/patcher.js
2026-10-29 22:19:03 -07:00

209 lines
6.3 KiB
JavaScript

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;