// Copyright 2019-2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only /* eslint-disable no-console */ import { createWriteStream, statSync, writeFile as writeFileCallback, } from 'fs'; import { join, normalize, dirname } from 'path'; import { tmpdir } from 'os'; import { throttle } from 'lodash'; import type { ParserConfiguration } from 'dashdash'; import { createParser } from 'dashdash'; import ProxyAgent from 'proxy-agent'; import { FAILSAFE_SCHEMA, safeLoad } from 'js-yaml'; import { gt } from 'semver'; import config from 'config'; import type { StrictOptions as GotOptions } from 'got'; import got from 'got'; import { v4 as getGuid } from 'uuid'; import pify from 'pify'; import mkdirp from 'mkdirp'; import rimraf from 'rimraf'; import type { BrowserWindow } from 'electron'; import { app, ipcMain } from 'electron'; import * as durations from '../util/durations'; import { getTempPath } from '../util/attachments'; import { DialogType } from '../types/Dialogs'; import * as Errors from '../types/errors'; import { getUserAgent } from '../util/getUserAgent'; import { isAlpha, isBeta } from '../util/version'; import * as packageJson from '../../package.json'; import { hexToBinary, verifySignature, getSignatureFileName, } from './signature'; import { isPathInside } from '../util/isPathInside'; import type { SettingsChannel } from '../main/settingsChannel'; import type { LoggerType } from '../types/Logging'; const writeFile = pify(writeFileCallback); const mkdirpPromise = pify(mkdirp); const rimrafPromise = pify(rimraf); const { platform } = process; export const GOT_CONNECT_TIMEOUT = 2 * 60 * 1000; export const GOT_LOOKUP_TIMEOUT = 2 * 60 * 1000; export const GOT_SOCKET_TIMEOUT = 2 * 60 * 1000; const INTERVAL = 30 * durations.MINUTE; type JSONUpdateSchema = { version: string; files: Array<{ url: string; sha512: string; size: string; blockMapSize?: string; }>; path: string; sha512: string; releaseDate: string; }; export type UpdateInformationType = { fileName: string; size: number; version: string; }; export abstract class Updater { protected fileName: string | undefined; protected version: string | undefined; protected updateFilePath: string | undefined; constructor( protected readonly logger: LoggerType, private readonly settingsChannel: SettingsChannel, protected readonly getMainWindow: () => BrowserWindow | undefined ) {} // // Public APIs // public async force(): Promise { return this.checkForUpdatesMaybeInstall(true); } public async start(): Promise { this.logger.info('updater/start: starting checks...'); app.once('quit', () => this.quitHandler()); setInterval(async () => { try { await this.checkForUpdatesMaybeInstall(); } catch (error) { this.logger.error(`updater/start: ${Errors.toLogFormat(error)}`); } }, INTERVAL); await this.deletePreviousInstallers(); await this.checkForUpdatesMaybeInstall(); } public quitHandler(): void { if (this.updateFilePath) { this.deleteCache(this.updateFilePath); } } // // Abstract methods // protected abstract deletePreviousInstallers(): Promise; protected abstract installUpdate(updateFilePath: string): Promise; // // Protected methods // protected setUpdateListener(performUpdateCallback: () => void): void { ipcMain.removeAllListeners('start-update'); ipcMain.once('start-update', performUpdateCallback); } // // Private methods // private async downloadAndInstall( newFileName: string, newVersion: string, updateOnProgress?: boolean ): Promise { const { logger } = this; try { const oldFileName = this.fileName; const oldVersion = this.version; if (this.updateFilePath) { this.deleteCache(this.updateFilePath); } this.fileName = newFileName; this.version = newVersion; try { this.updateFilePath = await this.downloadUpdate( this.fileName, updateOnProgress ); } catch (error) { // Restore state in case of download error this.fileName = oldFileName; this.version = oldVersion; throw error; } const publicKey = hexToBinary(config.get('updatesPublicKey')); const verified = await verifySignature( this.updateFilePath, this.version, publicKey ); if (!verified) { // Note: We don't delete the cache here, because we don't want to continually // re-download the broken release. We will download it only once per launch. throw new Error( 'Downloaded update did not pass signature verification ' + `(version: '${this.version}'; fileName: '${this.fileName}')` ); } await this.installUpdate(this.updateFilePath); const mainWindow = this.getMainWindow(); if (mainWindow) { mainWindow.webContents.send('show-update-dialog', DialogType.Update, { version: this.version, }); } else { logger.warn( 'downloadAndInstall: no mainWindow, cannot show update dialog' ); } } catch (error) { logger.error(`downloadAndInstall: ${Errors.toLogFormat(error)}`); } } private async checkForUpdatesMaybeInstall(force = false): Promise { const { logger } = this; logger.info('checkForUpdatesMaybeInstall: checking for update...'); const result = await this.checkForUpdates(force); if (!result) { return; } const { fileName: newFileName, version: newVersion } = result; if ( force || this.fileName !== newFileName || !this.version || gt(newVersion, this.version) ) { const autoDownloadUpdates = await this.getAutoDownloadUpdateSetting(); if (!autoDownloadUpdates) { this.setUpdateListener(async () => { logger.info( 'checkForUpdatesMaybeInstall: have not downloaded update, going to download' ); await this.downloadAndInstall(newFileName, newVersion, true); }); const mainWindow = this.getMainWindow(); if (mainWindow) { mainWindow.webContents.send( 'show-update-dialog', DialogType.DownloadReady, { downloadSize: result.size, version: result.version, } ); } else { logger.warn( 'checkForUpdatesMaybeInstall: no mainWindow, cannot show update dialog' ); } return; } await this.downloadAndInstall(newFileName, newVersion); } } private async checkForUpdates( forceUpdate = false ): Promise { const yaml = await getUpdateYaml(); const parsedYaml = parseYaml(yaml); const version = getVersion(parsedYaml); if (!version) { this.logger.warn( 'checkForUpdates: no version extracted from downloaded yaml' ); return null; } if (forceUpdate || isVersionNewer(version)) { this.logger.info( `checkForUpdates: found newer version ${version} ` + `forceUpdate=${forceUpdate}` ); const fileName = getUpdateFileName(parsedYaml); return { fileName, size: getSize(parsedYaml, fileName), version, }; } this.logger.info( `checkForUpdates: ${version} is not newer; no new update available` ); return null; } private async downloadUpdate( fileName: string, updateOnProgress?: boolean ): Promise { const baseUrl = getUpdatesBase(); const updateFileUrl = `${baseUrl}/${fileName}`; const signatureFileName = getSignatureFileName(fileName); const signatureUrl = `${baseUrl}/${signatureFileName}`; let tempDir; try { tempDir = await createTempDir(); const targetUpdatePath = join(tempDir, fileName); const targetSignaturePath = join(tempDir, getSignatureFileName(fileName)); validatePath(tempDir, targetUpdatePath); validatePath(tempDir, targetSignaturePath); this.logger.info(`downloadUpdate: Downloading signature ${signatureUrl}`); const { body } = await got.get(signatureUrl, getGotOptions()); await writeFile(targetSignaturePath, body); this.logger.info(`downloadUpdate: Downloading update ${updateFileUrl}`); const downloadStream = got.stream(updateFileUrl, getGotOptions()); const writeStream = createWriteStream(targetUpdatePath); await new Promise((resolve, reject) => { const mainWindow = this.getMainWindow(); if (updateOnProgress && mainWindow) { let downloadedSize = 0; const throttledSend = throttle(() => { mainWindow.webContents.send( 'show-update-dialog', DialogType.Downloading, { downloadedSize } ); }, 500); downloadStream.on('data', data => { downloadedSize += data.length; throttledSend(); }); } downloadStream.on('error', error => { reject(error); }); downloadStream.on('end', () => { resolve(); }); writeStream.on('error', error => { reject(error); }); downloadStream.pipe(writeStream); }); return targetUpdatePath; } catch (error) { if (tempDir) { await deleteTempDir(tempDir); } throw error; } } private async getAutoDownloadUpdateSetting(): Promise { try { return await this.settingsChannel.getSettingFromMainWindow( 'autoDownloadUpdate' ); } catch (error) { this.logger.warn( 'getAutoDownloadUpdateSetting: Failed to fetch, returning false', Errors.toLogFormat(error) ); return false; } } private async deleteCache(filePath: string | null): Promise { if (!filePath) { return; } const tempDir = dirname(filePath); try { await deleteTempDir(tempDir); } catch (error) { this.logger.error(`quitHandler: ${Errors.toLogFormat(error)}`); } } } export function validatePath(basePath: string, targetPath: string): void { const normalized = normalize(targetPath); if (!isPathInside(normalized, basePath)) { throw new Error( `validatePath: Path ${normalized} is not under base path ${basePath}` ); } } // Helper functions export function getUpdateCheckUrl(): string { return `${getUpdatesBase()}/${getUpdatesFileName()}`; } export function getUpdatesBase(): string { return config.get('updatesUrl'); } export function getCertificateAuthority(): string { return config.get('certificateAuthority'); } export function getProxyUrl(): string | undefined { return process.env.HTTPS_PROXY || process.env.https_proxy; } export function getUpdatesFileName(): string { const prefix = getChannel(); if (platform === 'darwin') { return `${prefix}-mac.yml`; } return `${prefix}.yml`; } function getChannel(): string { const { version } = packageJson; if (isAlpha(version)) { return 'alpha'; } if (isBeta(version)) { return 'beta'; } return 'latest'; } function isVersionNewer(newVersion: string): boolean { const { version } = packageJson; return gt(newVersion, version); } export function getVersion(info: JSONUpdateSchema): string | null { return info && info.version; } const validFile = /^[A-Za-z0-9.-]+$/; export function isUpdateFileNameValid(name: string): boolean { return validFile.test(name); } export function getUpdateFileName(info: JSONUpdateSchema): string { if (!info || !info.path) { throw new Error('getUpdateFileName: No path present in YAML file'); } const { path } = info; if (!isUpdateFileNameValid(path)) { throw new Error( `getUpdateFileName: Path '${path}' contains invalid characters` ); } return path; } function getSize(info: JSONUpdateSchema, fileName: string): number { if (!info || !info.files) { throw new Error('getUpdateFileName: No files present in YAML file'); } const foundFile = info.files.find(file => file.url === fileName); return Number(foundFile?.size) || 0; } export function parseYaml(yaml: string): JSONUpdateSchema { return safeLoad(yaml, { schema: FAILSAFE_SCHEMA, json: true }); } async function getUpdateYaml(): Promise { const targetUrl = getUpdateCheckUrl(); const body = await got(targetUrl, getGotOptions()).text(); if (!body) { throw new Error('Got unexpected response back from update check'); } return body; } function getGotOptions(): GotOptions { const certificateAuthority = getCertificateAuthority(); const proxyUrl = getProxyUrl(); const agent = proxyUrl ? { http: new ProxyAgent(proxyUrl), https: new ProxyAgent(proxyUrl), } : undefined; return { agent, https: { certificateAuthority, }, headers: { 'Cache-Control': 'no-cache', 'User-Agent': getUserAgent(packageJson.version), }, timeout: { connect: GOT_CONNECT_TIMEOUT, lookup: GOT_LOOKUP_TIMEOUT, // This timeout is reset whenever we get new data on the socket socket: GOT_SOCKET_TIMEOUT, }, }; } function getBaseTempDir() { // We only use tmpdir() when this code is run outside of an Electron app (as in: tests) return app ? getTempPath(app.getPath('userData')) : tmpdir(); } export async function createTempDir(): Promise { const baseTempDir = getBaseTempDir(); const uniqueName = getGuid(); const targetDir = join(baseTempDir, uniqueName); await mkdirpPromise(targetDir); return targetDir; } export async function deleteTempDir(targetDir: string): Promise { const pathInfo = statSync(targetDir); if (!pathInfo.isDirectory()) { throw new Error( `deleteTempDir: Cannot delete path '${targetDir}' because it is not a directory` ); } const baseTempDir = getBaseTempDir(); if (!isPathInside(targetDir, baseTempDir)) { throw new Error( `deleteTempDir: Cannot delete path '${targetDir}' since it is not within base temp dir` ); } await rimrafPromise(targetDir); } export function getCliOptions(options: ParserConfiguration['options']): T { const parser = createParser({ options }); const cliOptions = parser.parse(process.argv); if (cliOptions.help) { const help = parser.help().trimRight(); console.log(help); process.exit(0); } return (cliOptions as unknown) as T; }