/* nodejs-poolController. An application to control pool equipment.
Copyright (C) 2016, 2017, 2018, 2019, 2020, 2021, 2022.
Russell Goldin, tagyoureit. russ.goldin@gmail.com
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see .
*/
import { logger } from "../logger/Logger";
// import { https } from "follow-redirects";
import * as https from 'https';
import { state } from "../controller/State";
import { sys } from "../controller/Equipment";
import { Timestamp } from "../controller/Constants";
import { execSync } from 'child_process';
class VersionCheck {
private userAgent: string;
private gitApiHost: string;
private gitLatestReleaseJSONPath: string;
private redirects: number;
private gitAvailable: boolean;
private warnedBranch: boolean = false;
private warnedCommit: boolean = false;
constructor() {
this.userAgent = 'tagyoureit-nodejs-poolController-app';
this.gitApiHost = 'api.github.com';
this.gitLatestReleaseJSONPath = '/repos/tagyoureit/nodejs-poolController/releases/latest';
this.gitAvailable = this.detectGit();
// NOTE:
// * SOURCE_BRANCH / SOURCE_COMMIT env vars (if present) override git commands. These are expected in container builds where .git may be absent.
// * If git is not available (no binary or not a repo) we suppress repeated warnings after the first occurrence.
// * Version comparison is rate-limited via nextCheckTime (every 2 days) to avoid Github API throttling.
}
private detectGit(): boolean {
try {
execSync('git --version', { stdio: 'ignore' });
return true;
} catch { return false; }
}
public checkGitRemote() {
// need to significantly rate limit this because GitHub will start to throw 'too many requests' error
// and we simply don't need to check that often if the app needs to be updated
if (typeof state.appVersion.nextCheckTime === 'undefined' || new Date() > new Date(state.appVersion.nextCheckTime)) setTimeout(() => { this.checkAll(); }, 100);
}
public checkGitLocal() {
const env = process.env;
// Branch
try {
let out: string;
if (typeof env.SOURCE_BRANCH !== 'undefined') {
out = env.SOURCE_BRANCH;
} else if (this.gitAvailable) {
const res = execSync('git rev-parse --abbrev-ref HEAD', { stdio: 'pipe' });
out = res.toString().trim();
} else {
out = '--';
}
if (out !== '--') logger.info(`The current git branch output is ${out}`);
switch (out) {
case 'fatal':
case 'command':
state.appVersion.gitLocalBranch = '--';
break;
default:
state.appVersion.gitLocalBranch = out;
}
} catch (err) {
state.appVersion.gitLocalBranch = '--';
if (!this.warnedBranch) {
logger.warn(`Unable to retrieve local git branch (git missing or not a repo). Further branch warnings suppressed.`);
this.warnedBranch = true;
}
}
// Commit
try {
let out: string;
if (typeof env.SOURCE_COMMIT !== 'undefined') {
out = env.SOURCE_COMMIT;
} else if (this.gitAvailable) {
const res = execSync('git rev-parse HEAD', { stdio: 'pipe' });
out = res.toString().trim();
} else {
out = '--';
}
if (out !== '--') logger.info(`The current git commit output is ${out}`);
switch (out) {
case 'fatal':
case 'command':
state.appVersion.gitLocalCommit = '--';
break;
default:
state.appVersion.gitLocalCommit = out;
}
} catch (err) {
state.appVersion.gitLocalCommit = '--';
if (!this.warnedCommit) {
logger.warn(`Unable to retrieve local git commit (git missing or not a repo). Further commit warnings suppressed.`);
this.warnedCommit = true;
}
}
}
private checkAll() {
try {
this.redirects = 0;
let dt = new Date();
dt.setDate(dt.getDate() + 2); // check every 2 days
state.appVersion.nextCheckTime = Timestamp.toISOLocal(dt);
this.getLatestRelease().then((publishedVersion) => {
state.appVersion.githubRelease = publishedVersion;
this.compare();
});
}
catch (err) {
logger.error(`Error checking latest release: ${err.message}`);
}
}
private async getLatestRelease(redirect?: string): Promise {
var options = {
method: 'GET',
headers: {
'User-Agent': this.userAgent
}
}
let url: string;
if (typeof redirect === 'undefined') {
url = `https://${this.gitApiHost}${this.gitLatestReleaseJSONPath}`;
}
else {
url = redirect;
this.redirects += 1;
}
if (this.redirects >= 20) return Promise.reject(`Too many redirects.`)
return new Promise((resolve, reject) => {
try {
let req = https.request(url, options, async res => {
if (res.statusCode > 300 && res.statusCode < 400 && res.headers.location) {
try {
const redirected = await this.getLatestRelease(res.headers.location);
return resolve(redirected);
} catch (e) { return reject(e); }
}
let data = '';
res.on('data', d => { data += d; });
res.on('end', () => {
try {
let jdata = JSON.parse(data);
if (typeof jdata.tag_name !== 'undefined')
resolve(jdata.tag_name.replace('v', ''));
else
reject(`No data returned.`)
} catch(parseErr: any){
reject(`Error parsing Github response: ${ parseErr.message }`);
}
})
})
.end();
req.on('error', (err) => {
logger.error(`Error getting Github API latest release. ${err.message}`)
})
}
catch (err) {
logger.error('Error contacting Github for latest published release: ' + err);
reject(err);
};
})
}
public compare() {
logger.info(`Checking njsPC versions...`);
if (typeof state.appVersion.githubRelease === 'undefined' || typeof state.appVersion.installed === 'undefined') {
state.appVersion.status = sys.board.valueMaps.appVersionStatus.getValue('unknown');
return;
}
let publishedVersionArr = state.appVersion.githubRelease.split('.');
let installedVersionArr = state.appVersion.installed.split('.');
if (installedVersionArr.length !== publishedVersionArr.length) {
// this is in case local a.b.c doesn't have same # of elements as another version a.b.c.d. We should never get here.
logger.warn(`Cannot check for updated app. Version length of installed app (${installedVersionArr}) and remote (${publishedVersionArr}) do not match.`);
state.appVersion.status = sys.board.valueMaps.appVersionStatus.getValue('unknown');
return;
} else {
for (var i = 0; i < installedVersionArr.length; i++) {
if (publishedVersionArr[i] > installedVersionArr[i]) {
state.appVersion.status = sys.board.valueMaps.appVersionStatus.getValue('behind');
return;
} else if (publishedVersionArr[i] < installedVersionArr[i]) {
state.appVersion.status = sys.board.valueMaps.appVersionStatus.getValue('ahead');
return;
}
}
}
state.appVersion.status = sys.board.valueMaps.appVersionStatus.getValue('current');
}
}
export var versionCheck = new VersionCheck();