Files
Home/file-updater.js

467 lines
18 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 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');
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.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;
}
async repoTree(root) {
const out = [];
for (let page = 1; page <= 50; page++) {
const url = `${REPO_API}${root}/repository/tree?recursive=true&per_page=100&ref=main&page=${page}`;
const raw = await fetchBuffer(url, { headers: { Accept: 'application/json' }, maxBytes: 5 * 1024 * 1024, hosts: LIVE_HOSTS });
const items = JSON.parse(raw.toString('utf8'));
if (!Array.isArray(items) || items.length === 0) break;
out.push(...items);
if (items.length < 100) break;
}
return out;
}
fetchRepoFile(rel) {
const parts = rel.split('/');
const root = parts.shift();
const file = encodeURIComponent(parts.join('/'));
return fetchBuffer(`${REPO_API}${root}/repository/files/${file}/raw?ref=main`, { maxBytes: MAX_FILE, hosts: LIVE_HOSTS });
}
async checkRepos() {
const state = this.readState();
const changes = [];
for (const root of ALLOWED_ROOTS) {
const tree = await this.repoTree(root);
for (const item of tree) {
if (item.type !== 'blob') continue;
const rel = safeRel(`${root}/${item.path}`);
if (!rel || !/^[0-9a-f]{40}$/.test(item.id)) continue;
const full = this.target(rel);
if (fs.existsSync(full) && (state.base[rel] === item.id || gitBlobSha(fs.readFileSync(full)) === item.id)) continue;
changes.push({ path: rel, sha256: item.id, size: 0 });
}
}
return { seq: state.seq === null ? 0 : state.seq, changes, removed: [] };
}
async applyLive(info, replacements, onFile) {
const staged = [];
const total = info.changes.length;
let next = 0;
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(8, total) }, worker));
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;
for (const rel of info.removed) delete state.base[rel];
this.writeState({ seq: info.seq, base: state.base });
}
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;