mirror of
https://gitlab.com/vyla-entertainment/home.git
synced 2026-11-13 13:47:21 -05:00
371 lines
14 KiB
JavaScript
371 lines
14 KiB
JavaScript
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; |