Files
Home/update-client.js

427 lines
13 KiB
JavaScript

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 release = releases.slice().sort((a, b) => cmp(b.tag_name, a.tag_name))[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
};
}
}
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 downloadUpdate(release, destPath, onProgress) {
await this.downloadFile(release.url, destPath, onProgress);
return destPath;
}
}
UpdateClient.canonicalManifest = canonicalManifest;
UpdateClient.verifyManifest = verifyManifest;
module.exports = UpdateClient;