const https = require('https'); const fs = require('fs'); 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' ]; 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; } } class UpdateClient { constructor(appDataPath, token = null) { this.appDataPath = appDataPath; this.token = token || process.env.GITLAB_TOKEN || process.env.VYLA_GITLAB_TOKEN || null; } fetchJson(url) { return new Promise((resolve, reject) => { const urlObj = new URL(url); const headers = { 'User-Agent': 'VylaHome-Client', 'Accept': 'application/json' }; if (this.token) { headers['PRIVATE-TOKEN'] = this.token; } const requestOptions = { hostname: urlObj.hostname, port: 443, path: urlObj.pathname + urlObj.search, method: 'GET', headers }; const req = https.request(requestOptions, (res) => { let data = ''; res.on('data', (chunk) => { data += chunk; }); res.on('end', () => { if (res.statusCode !== 200) { reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`)); return; } try { resolve(JSON.parse(data)); } 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 }; 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; } 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(resolve); }); file.on('error', (err) => { fs.unlink(destPath, () => { }); reject(err); }); }); req.on('error', (err) => { fs.unlink(destPath, () => { }); reject(err); }); req.setTimeout(120000, () => { req.destroy(); fs.unlink(destPath, () => { }); reject(new Error('Download timeout')); }); }; 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 }; 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() { const releases = await this.fetchJson(RELEASES_API); if (!releases || releases.length === 0) { return null; } const release = releases[0]; const payloadAsset = (release.assets && release.assets.links ? release.assets.links : []).find(a => a.name === 'update-payload.zip'); const manifestAsset = (release.assets && release.assets.links ? release.assets.links : []).find(a => a.name === 'manifest.json'); if (!payloadAsset) { return null; } let sha256 = null; if (manifestAsset) { const manifest = await this.fetchAssetJson(manifestAsset.url); sha256 = manifest.sha256 || null; } return { version: release.tag_name, url: payloadAsset.url, sha256 }; } async downloadUpdate(release, destPath, onProgress) { await this.downloadFile(release.url, destPath, onProgress); return destPath; } } module.exports = UpdateClient;