const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const zlib = require('zlib'); const https = require('https'); const AdmZip = require('adm-zip'); const SDK_NAME = '@vyla-entertainment/sdk'; const SDK_LATEST_URL = 'https://registry.npmjs.org/@vyla-entertainment%2Fsdk/latest'; const ALLOWED_ROOTS = ['frontend', 'player']; const MAX_TARBALL = 100 * 1024 * 1024; const verParts = (v) => String(v || '').replace(/^v/, '').split(/[.\-+]/).map((n) => parseInt(n, 10) || 0); const cmpVer = (a, b) => { const x = verParts(a); const y = verParts(b); for (let i = 0; i < Math.max(x.length, y.length); i++) { if ((x[i] || 0) !== (y[i] || 0)) return (x[i] || 0) - (y[i] || 0); } return 0; }; const sha256Buf = (buf) => crypto.createHash('sha256').update(buf).digest('hex'); const sha256File = (p) => sha256Buf(fs.readFileSync(p)); function fetchBuffer(url, { headers = {}, maxBytes, hosts, redirects = 0 }) { return new Promise((resolve, reject) => { if (redirects > 5) return reject(new Error('Too many redirects')); let u; try { u = new URL(url); } catch (e) { return reject(new Error('Invalid URL')); } if (u.protocol !== 'https:' || !hosts.includes(u.hostname)) { return reject(new Error(`Host not allowed: ${u.hostname}`)); } const req = https.get({ hostname: u.hostname, port: 443, path: u.pathname + u.search, headers: { 'User-Agent': 'VylaHome-Client', ...headers } }, (res) => { if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { res.resume(); const next = new URL(res.headers.location, url).toString(); const keep = new URL(next).hostname === u.hostname ? headers : {}; fetchBuffer(next, { headers: keep, maxBytes, hosts, redirects: redirects + 1 }).then(resolve, reject); return; } if (res.statusCode !== 200) { res.resume(); reject(new Error(`HTTP ${res.statusCode}`)); return; } const chunks = []; let size = 0; res.on('data', (c) => { size += c.length; if (size > maxBytes) { req.destroy(); reject(new Error('Response too large')); return; } chunks.push(c); }); res.on('end', () => resolve(Buffer.concat(chunks))); res.on('error', reject); }); req.on('error', reject); req.setTimeout(30000, () => { req.destroy(); reject(new Error('Request timeout')); }); }); } function safeRel(rel) { if (typeof rel !== 'string' || !rel || rel.includes('\0') || rel.includes('\\')) return null; const norm = path.posix.normalize(rel); if (norm !== rel || norm.startsWith('/') || norm.endsWith('/')) return null; const parts = norm.split('/'); if (parts.length < 2 || !ALLOWED_ROOTS.includes(parts[0])) return null; if (parts.some((p) => p === '..' || p === '.' || p === 'node_modules' || p === '.git')) return null; return norm; } function extractTar(tar, destDir) { const root = path.resolve(destDir); let offset = 0; while (offset + 512 <= tar.length) { const header = tar.subarray(offset, offset + 512); if (header.every((b) => b === 0)) break; let name = header.toString('utf8', 0, 100).replace(/\0.*$/, ''); const prefix = header.toString('utf8', 345, 500).replace(/\0.*$/, ''); if (prefix) name = `${prefix}/${name}`; const size = parseInt(header.toString('utf8', 124, 136).replace(/\0.*$/, '').trim(), 8) || 0; const type = String.fromCharCode(header[156]); offset += 512; const data = tar.subarray(offset, offset + size); offset += Math.ceil(size / 512) * 512; if ((type !== '0' && type !== '\0') || !name.startsWith('package/')) continue; const rel = path.posix.normalize(name.slice('package/'.length)); if (!rel || rel.startsWith('..') || rel.startsWith('/') || rel.endsWith('/')) { throw new Error(`Unsafe path in tarball: ${name}`); } const full = path.resolve(root, rel); if (!full.startsWith(root + path.sep)) { throw new Error(`Unsafe path in tarball: ${name}`); } fs.mkdirSync(path.dirname(full), { recursive: true }); fs.writeFileSync(full, data); } } class FileUpdater { constructor(appDataPath, updateClient) { this.appDataPath = appDataPath; this.updateClient = updateClient; this.onProgress = null; this.pending = null; this.statePath = path.join(appDataPath, 'file-state.json'); } sdkHeaders() { const h = { Accept: 'application/json' }; if (process.env.NPM_TOKEN) h.Authorization = `Bearer ${process.env.NPM_TOKEN}`; return h; } target(rel) { const root = path.resolve(this.appDataPath); const full = path.resolve(root, ...rel.split('/')); if (!full.startsWith(root + path.sep)) throw new Error(`Path escape: ${rel}`); return full; } readState() { try { const s = JSON.parse(fs.readFileSync(this.statePath, 'utf8')); return { seq: typeof s.seq === 'number' ? s.seq : null, base: s.base && typeof s.base === 'object' ? s.base : {} }; } catch (e) { return { seq: null, base: {} }; } } writeState(state) { fs.mkdirSync(this.appDataPath, { recursive: true }); const tmp = `${this.statePath}.tmp`; fs.writeFileSync(tmp, JSON.stringify(state), 'utf8'); fs.renameSync(tmp, this.statePath); } snapshotBase() { const base = {}; const walk = (dir, rel) => { for (const name of fs.readdirSync(dir)) { if (name === 'node_modules' || name === '.git') continue; const full = path.join(dir, name); const r = `${rel}/${name}`; const st = fs.lstatSync(full); if (st.isDirectory()) walk(full, r); else if (st.isFile()) base[r] = sha256File(full); } }; for (const root of ALLOWED_ROOTS) { const dir = path.join(this.appDataPath, root); if (fs.existsSync(dir)) walk(dir, root); } this.writeState({ seq: this.readState().seq, base }); } zipMatches(zipPath, sha256) { try { return sha256File(zipPath) === String(sha256).trim().toLowerCase(); } catch (e) { return false; } } async checkFiles() { const versionFile = path.join(this.appDataPath, 'version.json'); let local; try { local = JSON.parse(fs.readFileSync(versionFile, 'utf8')); } catch (e) { return { seq: 0, changes: [] }; } const release = await this.updateClient.checkForUpdates(); const norm = (v) => String(v || '').replace(/^v/, '').trim(); if (!release || !release.sha256 || !local.sha256) return { seq: 0, changes: [] }; if (norm(release.version) !== norm(local.version) || release.sha256 === local.sha256) { return { seq: 0, changes: [] }; } const zipPath = path.join(this.appDataPath, 'pending-ui.zip'); if (!this.zipMatches(zipPath, release.sha256)) { fs.rmSync(zipPath, { force: true }); await this.updateClient.downloadUpdate(release, zipPath, this.onProgress); if (!this.zipMatches(zipPath, release.sha256)) { fs.rmSync(zipPath, { force: true }); throw new Error('Update archive checksum mismatch'); } } const state = this.readState(); const zip = new AdmZip(zipPath); const changes = []; for (const entry of zip.getEntries()) { if (entry.isDirectory) continue; const rel = safeRel(entry.entryName); if (!rel) continue; const data = entry.getData(); const hash = sha256Buf(data); const full = this.target(rel); if (!fs.existsSync(full)) { changes.push({ path: rel, sha256: hash, size: data.length, status: 'added' }); continue; } const baseline = state.base[rel] || sha256File(full); if (baseline !== hash) { changes.push({ path: rel, sha256: hash, size: data.length, status: 'modified' }); } } if (changes.length === 0) { fs.writeFileSync(versionFile, JSON.stringify({ version: release.version, sha256: release.sha256 }, null, 2), 'utf8'); fs.rmSync(zipPath, { force: true }); return { seq: 0, changes: [] }; } this.pending = { release, zipPath, total: changes.length }; return { seq: 0, changes }; } async applyFiles(changes, seq, replacements, onFile) { if (!this.pending) throw new Error('No pending update'); const { release, zipPath, total } = this.pending; const zip = new AdmZip(zipPath); const staged = []; for (let i = 0; i < changes.length; i++) { const c = changes[i]; const entry = zip.getEntry(c.path); if (!entry) throw new Error(`Missing in archive: ${c.path}`); const data = entry.getData(); if (sha256Buf(data) !== c.sha256) throw new Error(`Checksum mismatch: ${c.path}`); staged.push({ c, data: this.applyReplacements(c.path, data, replacements) }); if (onFile) onFile(c.path, i + 1, changes.length); } const done = []; try { for (const s of staged) { const full = this.target(s.c.path); const prev = fs.existsSync(full) ? fs.readFileSync(full) : null; fs.mkdirSync(path.dirname(full), { recursive: true }); fs.writeFileSync(`${full}.vyla-tmp`, s.data); fs.renameSync(`${full}.vyla-tmp`, full); done.push({ full, prev }); } } catch (e) { for (const d of done.reverse()) { try { if (d.prev === null) fs.rmSync(d.full, { force: true }); else fs.writeFileSync(d.full, d.prev); } catch (x) { } } throw e; } const state = this.readState(); for (const s of staged) state.base[s.c.path] = s.c.sha256; this.writeState({ seq: state.seq, base: state.base }); if (changes.length === total) { fs.writeFileSync( path.join(this.appDataPath, 'version.json'), JSON.stringify({ version: release.version, sha256: release.sha256 }, null, 2), 'utf8' ); fs.rmSync(zipPath, { force: true }); this.pending = null; } } applyReplacements(rel, buf, replacements) { const list = (replacements && replacements[rel.split('/')[0]]) || []; if (!list.length) return buf; let text = buf.toString('utf8'); let changed = false; for (const rep of list) { if (rep && rep.find && text.includes(rep.find)) { text = text.split(rep.find).join(rep.replace); changed = true; } } return changed ? Buffer.from(text, 'utf8') : buf; } installedSdk() { const roots = [this.appDataPath, path.join(this.appDataPath, 'frontend'), path.join(this.appDataPath, 'player')]; const found = []; for (const root of roots) { const dir = path.join(root, 'node_modules', ...SDK_NAME.split('/')); try { const version = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')).version; if (version) found.push({ dir, version }); } catch (e) { } } return found; } async checkSdk() { const raw = await fetchBuffer(SDK_LATEST_URL, { headers: this.sdkHeaders(), maxBytes: 5 * 1024 * 1024, hosts: ['registry.npmjs.org'] }); const meta = JSON.parse(raw.toString('utf8')); const integrity = String((meta.dist && meta.dist.integrity) || '').split(' ').find((s) => s.startsWith('sha512-')); if (!meta.version || !meta.dist || !meta.dist.tarball || !integrity) { throw new Error('Registry response missing version or integrity'); } const installed = this.installedSdk(); const targets = installed.filter((i) => cmpVer(i.version, meta.version) < 0); const current = targets.length ? targets.map((t) => t.version).sort(cmpVer)[0] : meta.version; return { outdated: targets.length > 0, current, latest: meta.version, tarball: meta.dist.tarball, integrity, dirs: targets.map((t) => t.dir) }; } async installSdk(info) { const tgz = await fetchBuffer(info.tarball, { headers: this.sdkHeaders(), maxBytes: MAX_TARBALL, hosts: ['registry.npmjs.org'] }); const digest = `sha512-${crypto.createHash('sha512').update(tgz).digest('base64')}`; if (digest !== info.integrity) throw new Error('SDK integrity check failed'); const tar = zlib.gunzipSync(tgz, { maxOutputLength: 300 * 1024 * 1024 }); for (const dir of info.dirs) { const stage = `${dir}.vyla-new`; const old = `${dir}.vyla-old`; fs.rmSync(stage, { recursive: true, force: true }); fs.rmSync(old, { recursive: true, force: true }); fs.mkdirSync(stage, { recursive: true }); extractTar(tar, stage); const version = JSON.parse(fs.readFileSync(path.join(stage, 'package.json'), 'utf8')).version; if (version !== info.latest) { fs.rmSync(stage, { recursive: true, force: true }); throw new Error('SDK version mismatch in tarball'); } const had = fs.existsSync(dir); if (had) fs.renameSync(dir, old); try { fs.renameSync(stage, dir); } catch (e) { if (had) fs.renameSync(old, dir); throw e; } fs.rmSync(old, { recursive: true, force: true }); } } } module.exports = FileUpdater;