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 MAX_FILE = 50 * 1024 * 1024; const LIVE_HOSTS = ['gitlab.com']; const REPO_API = 'https://gitlab.com/api/v4/projects/vyla-entertainment%2F'; 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)); const gitBlobSha = (buf) => crypto.createHash('sha1').update(Buffer.concat([Buffer.from(`blob ${buf.length}\0`), buf])).digest('hex'); const hooks = { notice: null }; function fetchBuffer(url, { headers = {}, maxBytes, hosts, redirects = 0, retries = 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 === 429 && retries < 4) { res.resume(); const wait = Math.min(parseInt(res.headers['retry-after'], 10) || 5, 30) * 1000; if (hooks.notice) hooks.notice(`GitLab rate limit (HTTP 429). Retrying in ${Math.round(wait / 1000)}s (${retries + 1}/4)...`); setTimeout(() => { fetchBuffer(url, { headers, maxBytes, hosts, redirects, retries: retries + 1 }).then(resolve, reject); }, wait); 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.statePath = path.join(appDataPath, 'file-state.json'); } set onNotice(fn) { hooks.notice = fn; } sdkHeaders() { const h = { Accept: 'application/json' }; if (process.env.NPM_TOKEN) h.Authorization = `Bearer ${process.env.NPM_TOKEN}`; return h; } liveHeaders(extra = {}) { const t = this.updateClient && this.updateClient.token; return t ? { ...extra, 'PRIVATE-TOKEN': t } : extra; } 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 : {}, remote: s.remote && typeof s.remote === 'object' ? s.remote : {}, local: s.local && typeof s.local === 'object' ? s.local : {}, commits: s.commits && typeof s.commits === 'object' ? s.commits : {} }; } catch (e) { return { seq: null, base: {}, remote: {}, local: {}, commits: {} }; } } writeState(state) { const cur = this.readState(); const next = { seq: state.seq !== undefined ? state.seq : cur.seq, base: state.base || cur.base, remote: state.remote || cur.remote, local: state.local || cur.local, commits: state.commits || cur.commits }; fs.mkdirSync(this.appDataPath, { recursive: true }); const tmp = `${this.statePath}.tmp`; fs.writeFileSync(tmp, JSON.stringify(next), '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, remote: {}, local: {}, commits: {} }); } 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; } async loadArchive(root) { const buf = await fetchBuffer(`${REPO_API}${root}/repository/archive.zip?sha=main`, { headers: this.liveHeaders(), maxBytes: 600 * 1024 * 1024, hosts: LIVE_HOSTS }); const zip = new AdmZip(buf); const map = new Map(); for (const entry of zip.getEntries()) { if (entry.isDirectory) continue; const name = entry.entryName; const i = name.indexOf('/'); if (i < 0) continue; map.set(`${root}/${name.slice(i + 1)}`, entry); } return map; } fetchRepoFile(rel) { const hit = this.archive && this.archive.get(rel); if (hit) return Promise.resolve(hit.getData()); const parts = rel.split('/'); const root = parts.shift(); const file = encodeURIComponent(parts.join('/')); return fetchBuffer(`${REPO_API}${root}/repository/files/${file}/raw?ref=main`, { headers: this.liveHeaders(), maxBytes: MAX_FILE, hosts: LIVE_HOSTS }); } async headCommit(root) { try { const raw = await fetchBuffer(`${REPO_API}${root}/repository/commits/main`, { headers: this.liveHeaders({ Accept: 'application/json' }), maxBytes: 1024 * 1024, hosts: LIVE_HOSTS, retries: 3 }); const id = JSON.parse(raw.toString('utf8')).id; return /^[0-9a-f]{40}$/.test(id) ? id : null; } catch (e) { return null; } } localIntact(root, state) { const prefix = `${root}/`; const keys = Object.keys(state.remote).filter((k) => k.startsWith(prefix)); if (keys.length === 0) return false; for (const rel of keys) { try { if (sha256File(this.target(rel)) !== state.local[rel]) return false; } catch (e) { return false; } } return true; } async checkRepos() { const state = this.readState(); const changes = []; const remoteIds = {}; const checkedRoots = []; const errors = []; const verified = { remote: {}, local: {} }; const commits = {}; this.archive = new Map(); for (const root of ALLOWED_ROOTS) { const head = await this.headCommit(root); if (this.localIntact(root, state) && (!head || head === state.commits[root])) continue; let map; try { map = await this.loadArchive(root); } catch (e) { errors.push(`${root}: ${e.message}`); continue; } if (map.size === 0) { errors.push(`${root}: empty archive`); continue; } checkedRoots.push(root); if (head) commits[root] = head; for (const [key, entry] of map) { const rel = safeRel(key); if (!rel) continue; const data = entry.getData(); const id = gitBlobSha(data); remoteIds[rel] = id; const full = this.target(rel); let ok = false; let localHash = null; if (fs.existsSync(full)) { const buf = fs.readFileSync(full); localHash = sha256Buf(buf); ok = gitBlobSha(buf) === id || (state.remote[rel] === id && state.local[rel] === localHash); } if (ok) { verified.remote[rel] = id; verified.local[rel] = localHash; } else { changes.push({ path: rel, sha256: id, size: data.length }); this.archive.set(rel, entry); } } } if (checkedRoots.length === 0) { if (errors.length) throw new Error(errors.join(' | ')); return { seq: state.seq === null ? 0 : state.seq, changes: [], removed: [], verified, commits }; } const tracked = new Set([...Object.keys(state.base), ...Object.keys(state.remote)]); const removed = [...tracked].filter((rel) => checkedRoots.includes(rel.split('/')[0]) && !remoteIds[rel] && safeRel(rel) && fs.existsSync(this.target(rel)) ); if (changes.length === 0 && removed.length === 0) { for (const k of Object.keys(verified.remote)) { state.remote[k] = verified.remote[k]; state.local[k] = verified.local[k]; } this.writeState({ remote: state.remote, local: state.local, commits: { ...state.commits, ...commits } }); this.archive = null; } return { seq: state.seq === null ? 0 : state.seq, changes, removed, verified, commits }; } async applyLive(info, replacements, onFile) { const staged = []; const total = info.changes.length; let next = 0; if (!this.archive) this.archive = new Map(); let finished = 0; let failure = null; const worker = async () => { while (next < total && !failure) { const c = info.changes[next++]; try { const data = await this.fetchRepoFile(c.path); if (gitBlobSha(data) !== c.sha256) throw new Error(`Checksum mismatch: ${c.path}`); staged.push({ c, data: this.applyReplacements(c.path, data, replacements) }); finished++; if (onFile) onFile(c.path, finished, total); } catch (e) { failure = e; } } }; await Promise.all(Array.from({ length: Math.min(4, total) }, worker)); this.archive = null; if (failure) throw failure; 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 }); } for (const rel of info.removed) { const full = this.target(rel); if (!fs.existsSync(full)) continue; const prev = fs.readFileSync(full); fs.rmSync(full, { force: true }); 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; state.remote[s.c.path] = s.c.sha256; state.local[s.c.path] = sha256Buf(s.data); } for (const rel of info.removed) { delete state.base[rel]; delete state.remote[rel]; delete state.local[rel]; } const v = info.verified || { remote: {}, local: {} }; for (const k of Object.keys(v.remote)) { state.remote[k] = v.remote[k]; state.local[k] = v.local[k]; } state.commits = { ...state.commits, ...(info.commits || {}) }; this.writeState({ seq: info.seq, base: state.base, remote: state.remote, local: state.local, commits: state.commits }); } 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; } recoverSdk() { for (const root of [this.appDataPath, path.join(this.appDataPath, 'frontend'), path.join(this.appDataPath, 'player')]) { const dir = path.join(root, 'node_modules', ...SDK_NAME.split('/')); const old = `${dir}.vyla-old`; const stage = `${dir}.vyla-new`; try { if (!fs.existsSync(dir) && fs.existsSync(old)) fs.renameSync(old, dir); fs.rmSync(stage, { recursive: true, force: true }); if (fs.existsSync(dir)) fs.rmSync(old, { recursive: true, force: true }); } catch (e) { } } } 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;