const https = require('https'); const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const UPDATE_PUBLIC_KEY = `-----BEGIN PUBLIC KEY----- MCowBQYDK2VwAyEAKIuPPzTCGdTksi7hp+V4urau6mMQt2XhNlEfRVbjNZ4= -----END PUBLIC KEY-----`; const MIN_CHECK_INTERVAL_MS = 15 * 60 * 1000; const MAX_RETRY_AFTER_MS = 120000; const MAX_BACKOFF_MS = 6 * 60 * 60 * 1000; const GITLAB_GROUP = 'vyla-entertainment'; const GITLAB_REPO = 'home'; const RELEASES_API = `https://gitlab.com/api/v4/projects/${GITLAB_GROUP}%2F${GITLAB_REPO}/releases`; const ALLOWED_REDIRECT_HOSTS = [ 'gitlab.com', 'storage.googleapis.com' ]; const httpsAgent = new https.Agent({ keepAlive: true, maxSockets: 32 }); function isAllowedHost(url) { try { const hostname = new URL(url).hostname; return ALLOWED_REDIRECT_HOSTS.includes(hostname) || hostname.endsWith('.gitlab.com') || hostname.endsWith('.storage.googleapis.com') || hostname.endsWith('.amazonaws.com') || hostname.endsWith('.cloudfront.net'); } catch (e) { return false; } } function normalizeTag(v) { return String(v || '').replace(/^v/, '').trim(); } function canonicalManifest(manifest) { const parts = Object.keys(manifest.components || {}).sort().map((name) => { const c = manifest.components[name]; return `${name}:${c.file}:${c.sha256}:${c.id}:${c.size}`; }); const lines = ['vyla-manifest-v2', normalizeTag(manifest.version), manifest.sha256, ...parts]; if (manifest.installer) { const i = manifest.installer; lines.push(`installer:${i.file}:${i.sha256}:${i.size}:${normalizeTag(i.version)}`); } return lines.join('\n'); } function verifyManifest(manifest) { if (!manifest || typeof manifest.signature !== 'string' || !manifest.components) return false; try { return crypto.verify(null, Buffer.from(canonicalManifest(manifest)), UPDATE_PUBLIC_KEY, Buffer.from(manifest.signature, 'base64')); } catch (e) { return false; } } class UpdateClient { constructor(appDataPath, token = null) { this.appDataPath = appDataPath; this.token = token || process.env.GITLAB_TOKEN || process.env.VYLA_GITLAB_TOKEN || null; } cachePath() { return path.join(this.appDataPath, 'update-cache.json'); } readCache() { try { return JSON.parse(fs.readFileSync(this.cachePath(), 'utf8')); } catch (e) { return {}; } } writeCache(patch) { try { fs.mkdirSync(this.appDataPath, { recursive: true }); fs.writeFileSync(this.cachePath(), JSON.stringify({ ...this.readCache(), ...patch }), 'utf8'); } catch (e) { } } recordFailure() { const failures = (this.readCache().failures || 0) + 1; const delay = Math.min(5 * 60 * 1000 * 2 ** (failures - 1), MAX_BACKOFF_MS); this.writeCache({ failures, blockedUntil: Date.now() + delay }); } recordSuccess() { this.writeCache({ failures: 0, blockedUntil: 0 }); } cacheUsable(release) { return !!release && !!release.components && verifyManifest(release.manifest); } fetchJson(url, etag = null, attempt = 0) { return new Promise((resolve, reject) => { const urlObj = new URL(url); const headers = { 'User-Agent': 'VylaHome-Client', 'Accept': 'application/json', 'Cache-Control': 'no-cache' }; if (this.token) { headers['PRIVATE-TOKEN'] = this.token; } if (etag) { headers['If-None-Match'] = etag; } const requestOptions = { hostname: urlObj.hostname, port: 443, path: urlObj.pathname + urlObj.search, method: 'GET', headers, agent: httpsAgent }; const req = https.request(requestOptions, (res) => { let data = ''; res.on('data', (chunk) => { data += chunk; }); res.on('end', () => { if (res.statusCode === 304) { resolve({ notModified: true, etag }); return; } if ((res.statusCode === 429 || res.statusCode >= 500) && attempt < 3) { const retryAfter = parseInt(res.headers['retry-after'], 10); const base = Number.isFinite(retryAfter) ? retryAfter * 1000 : 1000 * 2 ** attempt; const wait = Math.min(base, MAX_RETRY_AFTER_MS) + Math.floor(Math.random() * 1000); setTimeout(() => this.fetchJson(url, etag, attempt + 1).then(resolve, reject), wait); return; } if (res.statusCode !== 200) { reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`)); return; } try { resolve({ data: JSON.parse(data), etag: res.headers.etag || null }); } catch (e) { reject(new Error('Invalid JSON response')); } }); }); req.on('error', reject); req.setTimeout(30000, () => { req.destroy(); reject(new Error('Request timeout')); }); req.end(); }); } downloadFile(url, destPath, onProgress) { return new Promise((resolve, reject) => { const doRequest = (requestUrl, redirectCount) => { if (redirectCount > 5) { reject(new Error('Too many redirects')); return; } const urlObj = new URL(requestUrl); const headers = { 'User-Agent': 'VylaHome-Client', 'Accept': 'application/octet-stream' }; const isGitLabHost = urlObj.hostname === 'gitlab.com' || urlObj.hostname.endsWith('.gitlab.com'); if (this.token && isGitLabHost) { headers['PRIVATE-TOKEN'] = this.token; } const requestOptions = { hostname: urlObj.hostname, port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80), path: urlObj.pathname + urlObj.search, headers, agent: httpsAgent }; const req = https.get(requestOptions, (res) => { if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { res.resume(); const redirectUrl = new URL(res.headers.location, requestUrl).toString(); if (!isAllowedHost(redirectUrl)) { reject(new Error(`Redirect to disallowed host: ${redirectUrl}`)); return; } doRequest(redirectUrl, redirectCount + 1); return; } if (res.statusCode !== 200) { res.resume(); const err = new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`); const retryAfter = parseInt(res.headers['retry-after'], 10); if (Number.isFinite(retryAfter)) { err.retryAfterMs = Math.min(retryAfter * 1000, MAX_RETRY_AFTER_MS); } reject(err); return; } const totalSize = parseInt(res.headers['content-length'], 10); let downloadedSize = 0; const file = fs.createWriteStream(destPath); res.pipe(file); res.on('data', (chunk) => { downloadedSize += chunk.length; if (onProgress && totalSize) { onProgress(downloadedSize, totalSize); } }); file.on('finish', () => { file.close(() => { if (totalSize && downloadedSize < totalSize) { fs.unlink(destPath, () => { }); reject(new Error(`Download truncated: Received ${downloadedSize} of ${totalSize} bytes.`)); } else { resolve(); } }); }); file.on('error', (err) => { fs.unlink(destPath, () => { }); reject(err); }); }); req.on('error', (err) => { fs.unlink(destPath, () => { }); reject(err); }); req.setTimeout(900000, () => { req.destroy(); fs.unlink(destPath, () => { }); reject(new Error('Download timeout (15 minutes reached)')); }); }; doRequest(url, 0); }); } async fetchAssetJson(url) { return new Promise((resolve, reject) => { const doRequest = (requestUrl, redirectCount) => { if (redirectCount > 5) { reject(new Error('Too many redirects')); return; } const urlObj = new URL(requestUrl); const headers = { 'User-Agent': 'VylaHome-Client', 'Accept': 'application/octet-stream' }; const isGitLabHost = urlObj.hostname === 'gitlab.com' || urlObj.hostname.endsWith('.gitlab.com'); if (this.token && isGitLabHost) { headers['PRIVATE-TOKEN'] = this.token; } const requestOptions = { hostname: urlObj.hostname, port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80), path: urlObj.pathname + urlObj.search, headers, agent: httpsAgent }; const req = https.get(requestOptions, (res) => { if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { res.resume(); const redirectUrl = new URL(res.headers.location, requestUrl).toString(); if (!isAllowedHost(redirectUrl)) { reject(new Error(`Redirect to disallowed host: ${redirectUrl}`)); return; } doRequest(redirectUrl, redirectCount + 1); return; } if (res.statusCode !== 200) { reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`)); return; } let data = ''; res.on('data', (chunk) => { data += chunk; }); res.on('end', () => { try { resolve(JSON.parse(data)); } catch (e) { reject(new Error('Invalid JSON in manifest asset')); } }); }); req.on('error', reject); req.setTimeout(30000, () => { req.destroy(); reject(new Error('Request timeout')); }); }; doRequest(url, 0); }); } async checkForUpdates(force = false) { const cache = this.readCache(); const now = Date.now(); if (!force && cache.blockedUntil && now < cache.blockedUntil) { return null; } if (!force && cache.checkedAt && now - cache.checkedAt < MIN_CHECK_INTERVAL_MS && this.cacheUsable(cache.release)) { return cache.release; } const listUrl = `${RELEASES_API}?per_page=20&order_by=released_at&sort=desc`; let result = await this.fetchJson(listUrl, force ? null : cache.etag); if (result.notModified) { if (this.cacheUsable(cache.release)) { this.writeCache({ checkedAt: now }); return cache.release; } result = await this.fetchJson(listUrl, null); } const releases = result.data; if (!Array.isArray(releases) || releases.length === 0) { return null; } const verParts = (v) => String(v || '').replace(/^v/, '').split(/[.\-+]/).map((n) => parseInt(n, 10) || 0); const cmp = (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 ordered = releases.slice().sort((a, b) => cmp(b.tag_name, a.tag_name)); const release = ordered[0]; const links = release.assets && release.assets.links ? release.assets.links : []; const manifestAsset = links.find((a) => a.name === 'manifest.json'); if (!manifestAsset) { throw new Error(`Release ${release.tag_name} has no manifest.json.`); } const manifest = await this.fetchAssetJson(manifestAsset.url); if (!manifest.components) { throw new Error(`Release ${release.tag_name} has no signed component manifest.`); } if (!verifyManifest(manifest)) { throw new Error('Update manifest signature is invalid.'); } if (normalizeTag(manifest.version) !== normalizeTag(release.tag_name)) { throw new Error('Update manifest version does not match the release tag.'); } const components = {}; for (const [name, c] of Object.entries(manifest.components)) { const direct = typeof c.url === 'string' && c.url.startsWith('https://') && isAllowedHost(c.url) && c.url.includes('/packages/generic/vyla-home/'); const url = direct ? c.url : null; if (!url) { throw new Error(`Release ${release.tag_name} manifest has no download URL for ${c.file}.`); } components[name] = { url, sha256: c.sha256, id: c.id, size: c.size }; } let installer = null; if (manifest.installer) { const installerLink = links.find((a) => a.name === manifest.installer.file); if (installerLink) { installer = { url: installerLink.url, sha256: manifest.installer.sha256, size: manifest.installer.size, version: manifest.installer.version }; } } if (!installer) { installer = await this.findInstaller(ordered, release.tag_name); } const out = { version: release.tag_name, sha256: manifest.sha256 || null, manifest, components, installer }; this.writeCache({ checkedAt: now, etag: result.etag || null, release: out }); return out; } async findInstaller(ordered, skipTag) { for (const r of ordered.slice(0, 6)) { if (r.tag_name === skipTag) continue; const rl = r.assets && r.assets.links ? r.assets.links : []; const ma = rl.find((a) => a.name === 'manifest.json'); if (!ma) continue; try { const m = await this.fetchAssetJson(ma.url); if (!m.installer || !verifyManifest(m)) continue; const link = rl.find((a) => a.name === m.installer.file); if (!link) continue; return { url: link.url, sha256: m.installer.sha256, size: m.installer.size, version: m.installer.version }; } catch (e) { } } return null; } async downloadUpdate(release, destPath, onProgress) { await this.downloadFile(release.url, destPath, onProgress); return destPath; } } UpdateClient.canonicalManifest = canonicalManifest; UpdateClient.verifyManifest = verifyManifest; module.exports = UpdateClient;