mirror of
https://gitlab.com/vyla-entertainment/home.git
synced 2026-11-09 19:57:20 -05:00
435 lines
13 KiB
JavaScript
435 lines
13 KiB
JavaScript
const fs = require('fs');
|
|
const path = require('path');
|
|
const crypto = require('crypto');
|
|
const AdmZip = require('adm-zip');
|
|
const CryptoUtils = require('./crypto-utils');
|
|
|
|
const COMPONENT_PROBES = {
|
|
frontend: 'frontend/server.js',
|
|
player: 'player/server.js',
|
|
config: 'config/env.json',
|
|
secrets: 'encrypted-credentials.json'
|
|
};
|
|
const COMMIT_ALLOWED = new Set(['frontend', 'player', 'config', 'encrypted-credentials.json']);
|
|
|
|
function renameSafe(from, to) {
|
|
let last;
|
|
for (let i = 0; i < 6; i++) {
|
|
try {
|
|
fs.renameSync(from, to);
|
|
return;
|
|
} catch (e) {
|
|
last = e;
|
|
if (!['EPERM', 'EBUSY', 'EACCES', 'ENOTEMPTY'].includes(e.code)) throw e;
|
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 250 * (i + 1));
|
|
}
|
|
}
|
|
throw last;
|
|
}
|
|
|
|
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, components = null) {
|
|
const versionFile = path.join(this.appDataPath, 'version.json');
|
|
fs.mkdirSync(this.appDataPath, { recursive: true });
|
|
const tmp = `${versionFile}.tmp`;
|
|
fs.writeFileSync(tmp, JSON.stringify({ version, sha256, components: components || {} }, null, 2), 'utf8');
|
|
renameSafe(tmp, versionFile);
|
|
}
|
|
|
|
getCurrentComponents() {
|
|
try {
|
|
const c = JSON.parse(fs.readFileSync(path.join(this.appDataPath, 'version.json'), 'utf8')).components;
|
|
return c && typeof c === 'object' ? c : {};
|
|
} catch (e) {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
integrityPath() {
|
|
return path.join(this.appDataPath, 'integrity.json');
|
|
}
|
|
|
|
readIntegrity() {
|
|
try {
|
|
return JSON.parse(fs.readFileSync(this.integrityPath(), 'utf8'));
|
|
} catch (e) {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
writeIntegrity(names) {
|
|
const all = this.readIntegrity();
|
|
for (const name of names) {
|
|
if (name !== 'frontend' && name !== 'player') continue;
|
|
const files = {};
|
|
const walk = (dir, rel) => {
|
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
const full = path.join(dir, entry.name);
|
|
const r = `${rel}/${entry.name}`;
|
|
if (r.includes('/node_modules/@vyla-entertainment/sdk')) continue;
|
|
if (entry.isDirectory()) walk(full, r);
|
|
else if (entry.isFile()) files[r] = fs.statSync(full).size;
|
|
}
|
|
};
|
|
walk(path.join(this.appDataPath, name), name);
|
|
all[name] = files;
|
|
}
|
|
const tmp = `${this.integrityPath()}.tmp`;
|
|
fs.writeFileSync(tmp, JSON.stringify(all), 'utf8');
|
|
renameSafe(tmp, this.integrityPath());
|
|
}
|
|
|
|
integrityOk(name) {
|
|
const files = this.readIntegrity()[name];
|
|
if (!files) return true;
|
|
for (const [rel, size] of Object.entries(files)) {
|
|
try {
|
|
if (fs.statSync(path.join(this.appDataPath, rel)).size !== size) return false;
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
componentIntact(name) {
|
|
const probe = COMPONENT_PROBES[name];
|
|
if (!probe || name === 'secrets') return true;
|
|
try {
|
|
if (fs.statSync(path.join(this.appDataPath, probe)).size <= 0) return false;
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
return this.integrityOk(name);
|
|
}
|
|
|
|
staleComponents(release) {
|
|
const local = this.getCurrentComponents();
|
|
const names = Object.keys(release.components);
|
|
const stale = new Set(names.filter((n) => !this.componentIntact(n) || local[n] !== release.components[n].id));
|
|
if (stale.has('config')) {
|
|
for (const n of ['frontend', 'player']) {
|
|
if (release.components[n]) stale.add(n);
|
|
}
|
|
}
|
|
return [...stale];
|
|
}
|
|
|
|
verifyStaged(stagingDir, names) {
|
|
return names.every((name) => {
|
|
const probe = COMPONENT_PROBES[name];
|
|
if (!probe) return true;
|
|
try {
|
|
return fs.statSync(path.join(stagingDir, probe)).size > 0;
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
});
|
|
}
|
|
|
|
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, keep = false) {
|
|
const stagingDir = path.join(this.appDataPath, '.staging');
|
|
if (!keep) 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 + path.sep)) {
|
|
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;
|
|
}
|
|
|
|
journalPath() {
|
|
return path.join(this.appDataPath, '.update-journal.json');
|
|
}
|
|
|
|
backupDir() {
|
|
return path.join(this.appDataPath, '.backup');
|
|
}
|
|
|
|
commitStaging(stagingDir) {
|
|
const names = fs.readdirSync(stagingDir);
|
|
const unexpected = names.filter((n) => !COMMIT_ALLOWED.has(n));
|
|
if (unexpected.length > 0) {
|
|
throw new Error(`Unexpected top-level entries in update: ${unexpected.join(', ')}`);
|
|
}
|
|
|
|
const backup = this.backupDir();
|
|
fs.rmSync(backup, { recursive: true, force: true });
|
|
fs.mkdirSync(backup, { recursive: true });
|
|
|
|
const versionFile = path.join(this.appDataPath, 'version.json');
|
|
if (fs.existsSync(versionFile)) {
|
|
fs.copyFileSync(versionFile, path.join(backup, 'version.json.bak'));
|
|
}
|
|
|
|
const journal = { names, hadOld: {}, installed: [] };
|
|
for (const name of names) {
|
|
journal.hadOld[name] = fs.existsSync(path.join(this.appDataPath, name));
|
|
}
|
|
const save = () => {
|
|
const tmp = `${this.journalPath()}.tmp`;
|
|
fs.writeFileSync(tmp, JSON.stringify(journal), 'utf8');
|
|
renameSafe(tmp, this.journalPath());
|
|
};
|
|
save();
|
|
|
|
try {
|
|
for (const name of names) {
|
|
const dest = path.join(this.appDataPath, name);
|
|
if (journal.hadOld[name]) {
|
|
renameSafe(dest, path.join(backup, name));
|
|
}
|
|
journal.installed.push(name);
|
|
save();
|
|
renameSafe(path.join(stagingDir, name), dest);
|
|
}
|
|
} catch (e) {
|
|
try { this.rollback(); } catch (x) { }
|
|
throw e;
|
|
}
|
|
fs.rmSync(stagingDir, { recursive: true, force: true });
|
|
}
|
|
|
|
rollback() {
|
|
let journal;
|
|
try {
|
|
journal = JSON.parse(fs.readFileSync(this.journalPath(), 'utf8'));
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
|
|
const backup = this.backupDir();
|
|
const hadOld = journal.hadOld || {};
|
|
const installed = journal.installed || [];
|
|
for (const name of journal.names || []) {
|
|
const dest = path.join(this.appDataPath, name);
|
|
const saved = path.join(backup, name);
|
|
if (hadOld[name]) {
|
|
if (fs.existsSync(saved)) {
|
|
fs.rmSync(dest, { recursive: true, force: true });
|
|
renameSafe(saved, dest);
|
|
}
|
|
} else if (installed.includes(name)) {
|
|
fs.rmSync(dest, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
const versionFile = path.join(this.appDataPath, 'version.json');
|
|
const savedVersion = path.join(backup, 'version.json.bak');
|
|
if (fs.existsSync(savedVersion)) {
|
|
fs.copyFileSync(savedVersion, versionFile);
|
|
} else {
|
|
fs.rmSync(versionFile, { force: true });
|
|
}
|
|
|
|
fs.rmSync(backup, { recursive: true, force: true });
|
|
fs.rmSync(this.journalPath(), { force: true });
|
|
fs.rmSync(path.join(this.appDataPath, '.staging'), { recursive: true, force: true });
|
|
return true;
|
|
}
|
|
|
|
finalizeCommit() {
|
|
fs.rmSync(this.backupDir(), { recursive: true, force: true });
|
|
fs.rmSync(this.journalPath(), { force: true });
|
|
for (const dir of ['stream-api', 'auth-proxy']) {
|
|
fs.rmSync(path.join(this.appDataPath, dir), { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
recoverInterrupted() {
|
|
const had = fs.existsSync(this.journalPath());
|
|
if (had && !this.rollback()) {
|
|
fs.rmSync(this.journalPath(), { force: true });
|
|
fs.rmSync(this.backupDir(), { recursive: true, force: true });
|
|
}
|
|
fs.rmSync(path.join(this.appDataPath, '.staging'), { recursive: true, force: true });
|
|
try {
|
|
for (const f of fs.readdirSync(this.appDataPath)) {
|
|
if (/^download-.*\.zip$/.test(f)) fs.rmSync(path.join(this.appDataPath, f), { force: true });
|
|
}
|
|
} catch (e) { }
|
|
return had;
|
|
}
|
|
|
|
verifyChecksum(zipPath, expectedSha256) {
|
|
if (!expectedSha256) return false;
|
|
const hash = crypto.createHash('sha256');
|
|
const fd = fs.openSync(zipPath, 'r');
|
|
const buf = Buffer.alloc(1024 * 1024);
|
|
try {
|
|
let n;
|
|
while ((n = fs.readSync(fd, buf, 0, buf.length, null)) > 0) {
|
|
hash.update(buf.subarray(0, n));
|
|
}
|
|
} finally {
|
|
fs.closeSync(fd);
|
|
}
|
|
return hash.digest('hex') === 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, only = null) {
|
|
const targetDirs = ['frontend', 'player'].filter((d) => !only || only.includes(d));
|
|
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;
|