Files
Home/update-client.js

271 lines
7.8 KiB
JavaScript

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'
];
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;
}
}
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,
agent: httpsAgent
};
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,
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;
}
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. Connection dropped prematurely.`));
} 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() {
const cacheBuster = `?cb=${Date.now()}`;
const releases = await this.fetchJson(RELEASES_API + cacheBuster);
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;