mirror of
https://gitlab.com/vyla-entertainment/home.git
synced 2026-11-09 19:57:20 -05:00
454 lines
14 KiB
JavaScript
454 lines
14 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 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;
|