diff --git a/.vscode/settings.json b/.vscode/settings.json index 4d62b5fb..b50f744a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -24,7 +24,13 @@ "ICONNAME", "inputflash", "loadscreen", + "mailhost", + "mailserver", + "MMAUTHTOKEN", + "MMCSRF", "mmjstool", + "MMUSERID", + "mochawesome", "NOSERVERS", "Ochiai", "officedocument", diff --git a/i18n/en.json b/i18n/en.json index a6a1cadd..e6e40545 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -67,6 +67,7 @@ "main.menus.app.help.downloadUpdate": "Download Update", "main.menus.app.help.learnMore": "Learn More...", "main.menus.app.help.restartAndUpdate": "Restart and Update", + "main.menus.app.help.RunDiagnostics": "Run diagnostics", "main.menus.app.help.versionString": "Version {version}{commit}", "main.menus.app.history": "&History", "main.menus.app.history.back": "Back", diff --git a/package.json b/package.json index 0dd78ea4..54dc1f88 100644 --- a/package.json +++ b/package.json @@ -109,7 +109,10 @@ "__SKIP_ONBOARDING_SCREENS__": false }, "setupFiles": [ - "./src/jestSetup.js" + "./src/jest/jestSetup.js" + ], + "setupFilesAfterEnv": [ + "./src/jest/jestSetupAfterEnv.js" ], "reporters": [ "default", diff --git a/src/common/config/index.test.js b/src/common/config/index.test.js index 0a087ff5..6f2be8c6 100644 --- a/src/common/config/index.test.js +++ b/src/common/config/index.test.js @@ -24,6 +24,7 @@ jest.mock('main/Validator', () => ({ validateV1ConfigData: (configData) => (configData.version === 1 ? configData : null), validateV2ConfigData: (configData) => (configData.version === 2 ? configData : null), validateV3ConfigData: (configData) => (configData.version === 3 ? configData : null), + validateConfigData: (configData) => (configData.version === 3 ? configData : null), })); jest.mock('common/tabs/TabView', () => ({ diff --git a/src/common/config/index.ts b/src/common/config/index.ts index 80a86188..d4be610f 100644 --- a/src/common/config/index.ts +++ b/src/common/config/index.ts @@ -357,19 +357,8 @@ export class Config extends EventEmitter { configData = this.readFileSync(this.configFilePath); // validate based on config file version - switch (configData.version) { - case 3: - configData = Validator.validateV3ConfigData(configData)!; - break; - case 2: - configData = Validator.validateV2ConfigData(configData)!; - break; - case 1: - configData = Validator.validateV1ConfigData(configData)!; - break; - default: - configData = Validator.validateV0ConfigData(configData)!; - } + configData = Validator.validateConfigData(configData); + if (!configData) { throw new Error('Provided configuration file does not validate, using defaults instead.'); } diff --git a/src/common/constants.test.js b/src/common/constants.test.js new file mode 100644 index 00000000..3dbc0177 --- /dev/null +++ b/src/common/constants.test.js @@ -0,0 +1,49 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {REGEX_EMAIL} from './constants'; + +const VALID_EMAILS_LIST = [ + ['email@example.com'], + ['firstname.lastname@example.com'], + ['disposable.style.email.with+symbol@example.com'], + ['other.email-with-hyphen@example.com'], + ['fully-qualified-domain@example.com'], + ['user.name+tag+sorting@example.com'], + ['x@example.com'], + ['example-indeed@strange-example.com'], + ['test/test@test.com'], + ['admin@mailserver1'], + ['example@s.example'], + ['john..doe@example.org'], + ['mailhost!username@example.org'], + ['user%example.com@example.org'], + ['user-@example.org'], + ['email@subdomain.example.com'], + ['firstname+lastname@example.com'], + ['email@123.123.123.123'], + ['1234567890@example.com'], + ['email@example-one.com'], + ['_______@example.com'], + ['email@example.name'], + ['email@example.museum'], + ['email@example.co.jp'], + ['firstname-lastname@example.com'], +]; + +const INVALID_EMAILS_LIST = [ + ['Abc.example.com'], + ['QA[icon]CHOCOLATE[icon]@test.com'], +]; + +describe('main/common/constants', () => { + describe('Email regular expression', () => { + it.each(VALID_EMAILS_LIST)('%#:Should be VALID email address: %s', (a) => { + expect(REGEX_EMAIL.test(a)).toBe(true); + }); + + it.each(INVALID_EMAILS_LIST)('%#: Should be INVALID email address: %s', (a) => { + expect(REGEX_EMAIL.test(a)).toBe(false); + }); + }); +}); diff --git a/src/common/constants.ts b/src/common/constants.ts index cf606b71..4e9e4443 100644 --- a/src/common/constants.ts +++ b/src/common/constants.ts @@ -20,3 +20,29 @@ export const UPDATE_DOWNLOAD_ITEM: Omit = receivedBytes: 0, totalBytes: 0, }; + +// Regular expressions +export const REGEX_EMAIL = /[a-zA-Z0-9.!#$%&’*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*/; // based on W3C input type email regex +export const REGEX_IPV4 = /\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/; +export const REGEX_URL = /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/; + +export const REGEX_PATH_WIN32 = /(?:[a-z]:)?[/\\](?:[./\\ ](?![./\\\n])|[^<>:"|?*./\\ \n])+[a-zA-Z0-9]./; +export const REGEX_PATH_DARWIN = /([/]{1}[a-z0-9.]+)+(\/?)|^([/])/; +export const REGEX_PATH_LINUX = /([/]{1}[a-z0-9.]+)+(\/?)|^([/])/; // same as darwin + +export const REGEX_LOG_FILE_LINE = /\[(\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2}.\d{3})\]\s\[(silly|debug|verbose|info|warn|error)\]\s+(.*)/; + +// Masks +export const MASK_EMAIL = 'EMAIL'; +export const MASK_IPV4 = 'IPV4'; +export const MASK_PATH = 'PATH'; +export const MASK_URL = 'URL'; + +export const LOGS_MAX_STRING_LENGTH = 63; + +// We use this URL inside the Diagnostics to check if the computer has internet connectivity +export const IS_ONLINE_ENDPOINT = 'https://community.mattermost.com/api/v4/system/ping'; + +export const COOKIE_NAME_USER_ID = 'MMUSERID'; +export const COOKIE_NAME_CSRF = 'MMCSRF'; +export const COOKIE_NAME_AUTH_TOKEN = 'MMAUTHTOKEN'; diff --git a/src/jestSetup.js b/src/jest/jestSetup.js similarity index 51% rename from src/jestSetup.js rename to src/jest/jestSetup.js index cdaf3226..c8b1b1e8 100644 --- a/src/jestSetup.js +++ b/src/jest/jestSetup.js @@ -12,16 +12,25 @@ jest.mock('main/constants', () => ({ updatePaths: jest.fn(), })); -jest.mock('electron-log', () => ({ - error: jest.fn(), - warn: jest.fn(), - info: jest.fn(), - verbose: jest.fn(), - debug: jest.fn(), - silly: jest.fn(), - transports: { - file: { - level: '', +jest.mock('electron-log', () => { + const logLevelsFn = { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + verbose: jest.fn(), + debug: jest.fn(), + silly: jest.fn(), + }; + return { + create: jest.fn(() => ({ + ...logLevelsFn, + })), + ...logLevelsFn, + transports: { + file: { + level: '', + }, }, - }, -})); + }; +}); + diff --git a/src/jest/jestSetupAfterEnv.js b/src/jest/jestSetupAfterEnv.js new file mode 100644 index 00000000..57b6ebd8 --- /dev/null +++ b/src/jest/jestSetupAfterEnv.js @@ -0,0 +1,25 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +function toContainObject(received, argument) { + const pass = this.equals(received, + expect.arrayContaining([ + expect.objectContaining(argument), + ]), + ); + + if (pass) { + return { + message: () => (`expected ${this.utils.printReceived(received)} not to contain object ${this.utils.printExpected(argument)}`), + pass: true, + }; + } + return { + message: () => (`expected ${this.utils.printReceived(received)} to contain object ${this.utils.printExpected(argument)}`), + pass: false, + }; +} + +expect.extend({ + toContainObject, +}); diff --git a/src/main/Validator.ts b/src/main/Validator.ts index 9e2a1096..43468a78 100644 --- a/src/main/Validator.ts +++ b/src/main/Validator.ts @@ -5,7 +5,7 @@ import log from 'electron-log'; import Joi from 'joi'; import {Args} from 'types/args'; -import {ConfigV0, ConfigV1, ConfigV2, ConfigV3, TeamWithTabs} from 'types/config'; +import {AnyConfig, ConfigV0, ConfigV1, ConfigV2, ConfigV3, TeamWithTabs} from 'types/config'; import {DownloadedItems} from 'types/downloads'; import {SavedWindowState} from 'types/mainWindow'; import {AppState} from 'types/appState'; @@ -260,6 +260,19 @@ export function validateV3ConfigData(data: ConfigV3) { return validateAgainstSchema(data, configDataSchemaV3); } +export function validateConfigData(data: AnyConfig) { + switch (data.version) { + case 3: + return validateV3ConfigData(data)!; + case 2: + return validateV2ConfigData(data)!; + case 1: + return validateV1ConfigData(data)!; + default: + return validateV0ConfigData(data)!; + } +} + // validate certificate.json export function validateCertificateStore(data: string | Record) { const jsonData = (typeof data === 'object' ? data : JSON.parse(data)); diff --git a/src/main/diagnostics/DiagnosticStep.ts b/src/main/diagnostics/DiagnosticStep.ts new file mode 100644 index 00000000..c1368f16 --- /dev/null +++ b/src/main/diagnostics/DiagnosticStep.ts @@ -0,0 +1,25 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {DiagnosticsStepConstructorPayload} from 'types/diagnostics'; + +import {addDurationToFnReturnObject} from './steps/internal/utils'; + +const genericStepName = 'diagnostic-step/generic'; + +class DiagnosticsStep { + name: DiagnosticsStepConstructorPayload['name']; + retries: DiagnosticsStepConstructorPayload['retries']; + run: DiagnosticsStepConstructorPayload['run']; + + constructor({name = genericStepName, retries = 0, run}: DiagnosticsStepConstructorPayload) { + if (typeof run !== 'function') { + throw new Error(`"run" function is missing from step ${name}`); + } + this.name = name; + this.retries = retries; + this.run = addDurationToFnReturnObject(run); + } +} + +export default DiagnosticsStep; diff --git a/src/main/diagnostics/README.md b/src/main/diagnostics/README.md new file mode 100644 index 00000000..59d1cac3 --- /dev/null +++ b/src/main/diagnostics/README.md @@ -0,0 +1,22 @@ +# Desktop Diagnostics + +This directory contains the code for running the diagnostics of the desktop application. (entrypoint `index.ts`) +This readme file's purpose is to explain the code, the structure and how to contribute to it. + +## How it works? + +The class `DiagnosticsModule` in `index.ts` is the "orchestrator" that runs specific steps/tasks one at a time. It keeps track of whether or not a specific +step has succeeded or not and stores the return values of the steps. + +## Diagnostics Steps + +The diagnostic steps at this moment are: + +| Step | Name | Description | +| :---: | :---: | :--- | +| 0 | logger | Validates that the diagnostics write to the correct file and the log level is "debug" | + +## Future enhancements + +- Run steps in parallel (if necessary) +- Background diagnostics monitoring diff --git a/src/main/diagnostics/index.test.js b/src/main/diagnostics/index.test.js new file mode 100644 index 00000000..d03d6756 --- /dev/null +++ b/src/main/diagnostics/index.test.js @@ -0,0 +1,25 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import Diagnostics from '.'; + +jest.mock('main/windows/windowManager', () => ({ + mainWindow: {}, +})); +jest.mock('common/config', () => ({ + configFilePath: 'mock/config/filepath/', +})); + +describe('main/diagnostics/index', () => { + it('should be initialized with correct values', () => { + const d = Diagnostics; + expect(d.stepTotal).toBe(0); + expect(d.stepCurrent).toBe(0); + expect(d.report).toEqual([]); + }); + + it('should count the steps correctly', () => { + const d = Diagnostics; + expect(d.getStepCount()).toBe(12); + }); +}); diff --git a/src/main/diagnostics/index.ts b/src/main/diagnostics/index.ts new file mode 100644 index 00000000..2323fc24 --- /dev/null +++ b/src/main/diagnostics/index.ts @@ -0,0 +1,186 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {shell} from 'electron'; +import log, {ElectronLog} from 'electron-log'; +import {DiagnosticsReport} from 'types/diagnostics'; + +import DiagnosticsStep from './DiagnosticStep'; + +import Step0 from './steps/step0.logLevel'; +import Step1 from './steps/step1.internetConnection'; +import Step10 from './steps/step10.crashReports'; +import Step11 from './steps/step11.auth'; +import Step2 from './steps/step2.configValidation'; +import Step3 from './steps/step3.serverConnectivity'; +import Step4 from './steps/step4.sessionDataValidation'; +import Step5 from './steps/step5.browserWindows'; +import Step6 from './steps/step6.permissions'; +import Step7 from './steps/step7.performance'; +import Step8 from './steps/step8.logHeuristics'; +import Step9 from './steps/step9.config'; + +const SORTED_STEPS: DiagnosticsStep[] = [ + Step0, + Step1, + Step2, + Step3, + Step4, + Step5, + Step6, + Step7, + Step8, + Step9, + Step10, + Step11, +]; +const maxStepNameLength = Math.max(...SORTED_STEPS.map((s) => s.name.length)); +const HASHTAGS = '#'.repeat(20); + +class DiagnosticsModule { + stepCurrent = 0; + stepTotal = 0; + report: DiagnosticsReport = []; + logger: ElectronLog = log.create('diagnostics-logger'); + + run = async () => { + try { + if (this.isRunning()) { + this.logger.warn('Diagnostics is already running'); + return; + } + this.logger.debug('Diagnostics.run'); + + this.initializeValues(); + this.sendNotificationDiagnosticsStarted(); + await this.executeSteps(); + this.printReport(); + this.showLogFile(); + + this.initializeValues(true); + } catch (error) { + this.logger.error('Diagnostics.run Error: ', {error}); + this.initializeValues(true); + } + } + + initializeValues = (clear = false) => { + this.logger.transports.file.level = 'silly'; + this.logger.transports.console.level = 'silly'; + + this.logger.debug('Diagnostics.initializeValues'); + this.stepCurrent = 0; + this.stepTotal = clear ? 0 : this.getStepCount(); + this.report = []; + } + + getStepCount = () => { + const stepsCount = SORTED_STEPS.length; + this.logger.debug('Diagnostics.getStepCount', {stepsCount}); + + return stepsCount; + } + + executeSteps = async () => { + this.logger.info('Diagnostics.executeSteps Started'); + let index = 0; + for (const step of SORTED_STEPS) { + const reportStep = { + name: step.name, + step: index, + }; + if (this.isValidStep(step)) { + // eslint-disable-next-line no-await-in-loop + const stepResult = await step.run(this.logger); + this.printStepStart(step.name); + this.addToReport({ + ...stepResult, + ...reportStep, + payload: stepResult.payload, + }); + this.logger.info({stepResult}); + this.printStepEnd(step.name); + } else { + this.addToReport({ + ...reportStep, + succeeded: false, + duration: 0, + }); + this.logger.warn('Diagnostics.executeSteps UnknownStep', {index, step}); + } + index++; + this.stepCurrent = index; + } + this.logger.info('Diagnostics.executeSteps Finished'); + } + + printReport = () => { + const totalStepsCount = this.getStepCount(); + const successfulStepsCount = this.getSuccessfulStepsCount(); + this.printStepStart('Report'); + this.logger.info({report: this.report}); + this.logger.info(`| index | name${this.fillSpaces(maxStepNameLength - 4)} | succeeded |`); + this.logger.info(`| ---${this.fillSpaces(3)}| ---${this.fillSpaces(maxStepNameLength - 3)} | ---${this.fillSpaces(7)}|`); + + this.report.forEach((step, index) => { + this.logger.info(`| ${index}${this.fillSpaces(6 - index.toString().length)}| ${step.name}${this.fillSpaces(maxStepNameLength - step.name.length)} | ${step.succeeded}${this.fillSpaces(step.succeeded ? 6 : 5)}|`); + }); + + this.logger.info(`| ---${this.fillSpaces(3)}| ---${this.fillSpaces(maxStepNameLength - 3)} | ---${this.fillSpaces(7)}|`); + this.logger.info(`${successfulStepsCount} out of ${totalStepsCount} steps succeeded`); + this.printStepEnd('Report'); + } + + showLogFile = () => { + const pathToFile = this.getLoggerFilePath(); + + this.logger.debug('Diagnostics.showLogFile', {pathToFile}); + shell.showItemInFolder(pathToFile); + } + + sendNotificationDiagnosticsStarted = () => { + this.logger.debug('Diagnostics sendNotification DiagnosticsStarted'); + } + + isValidStep = (step: unknown) => { + return step instanceof DiagnosticsStep; + } + + getLoggerFilePath = () => { + return this.logger.transports.file.getFile()?.path; + } + + isRunning = () => { + return this.stepTotal > 0 && this.stepCurrent >= 0; + } + + getSuccessfulStepsCount = () => { + return this.report.filter((step) => step.succeeded).length; + } + + private printStepStart = (name: string) => { + this.logger.info(`${HASHTAGS} ${name} START ${HASHTAGS}`); + } + + private printStepEnd = (name: string) => { + this.logger.info(`${HASHTAGS} ${name} END ${HASHTAGS}`); + } + + private addToReport(data: DiagnosticsReport[number]): void { + this.report = [ + ...this.report, + data, + ]; + } + + private fillSpaces = (i: number) => { + if (typeof i !== 'number' || i <= 0) { + return ''; + } + return ' '.repeat(i); + } +} + +const Diagnostics = new DiagnosticsModule(); + +export default Diagnostics; diff --git a/src/main/diagnostics/steps/internal/loggerHooks.test.js b/src/main/diagnostics/steps/internal/loggerHooks.test.js new file mode 100644 index 00000000..053834d0 --- /dev/null +++ b/src/main/diagnostics/steps/internal/loggerHooks.test.js @@ -0,0 +1,107 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {MASK_EMAIL, MASK_PATH} from 'common/constants'; + +import {maskMessageDataHook} from './loggerHooks'; + +const loggerMock = { + transports: { + file: 'file', + }, +}; + +function findOccurrencesInString(search, text) { + const regex = new RegExp(search, 'g'); + return (text.match(regex))?.length || 0; +} + +describe('main/diagnostics/loggerHooks', () => { + it('should mask nothing when it prints only to console', () => { + const message = { + data: ['password email@test.com https://192.168.1.1'], + }; + const result = maskMessageDataHook(loggerMock)(message, 'console').data[0]; + expect(result).toBe(message.data[0]); + }); + + it('should return empty "" if the message includes the string "password"', () => { + const message = { + data: ['Password: someRandomPassword'], + }; + const result = maskMessageDataHook(loggerMock)(message, 'file'); + expect(result.data[0]).toBe(''); + }); + + it('should mask emails', () => { + const message = { + data: ['email@test.com email2@test.com'], + }; + const result = maskMessageDataHook(loggerMock)(message, 'file').data[0]; + expect(findOccurrencesInString(MASK_EMAIL, result)).toBe(2); + }); + + it('should mask IPV4 addresses', () => { + const IPs = ['192.168.20.44', '1.1.1.1', '255.255.255.255']; + const message = { + data: [`:${IPs[0]} https://${IPs[1]} networkPc://${IPs[2]}`], + }; + const result = maskMessageDataHook(loggerMock)(message, 'file').data[0]; + expect(IPs.some((ip) => result.includes(ip))).toBe(false); + }); + + it('should mask URLs', () => { + const URLs = ['http://www.google.com', 'https://community.mattermost.com', 'https://someWebsite.com']; + const message = { + data: [`${URLs[0]} https://${URLs[1]} http://${URLs[2]}`], + }; + const result = maskMessageDataHook(loggerMock)(message, 'file').data[0]; + expect(URLs.some((url) => result.includes(url))).toBe(false); + }); + + describe('should mask paths for all OSs', () => { + it('darwin', () => { + const message = { + data: ['/Users/user/Projects/desktop /Users/user/Projects/desktop/file.txt /Users/user/Projects/desktop/folder withSpace/file.txt'], + }; + const result = maskMessageDataHook(loggerMock)(message, 'file').data[0]; + expect(findOccurrencesInString(MASK_PATH, result)).toBe(4); + }); + it('linux', () => { + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { + value: 'linux', + }); + const message = { + data: ['/Users/user/Projects/desktop /Users/user/Projects/desktop/file.txt /Users/user/Projects/desktop/folder withSpace/file.txt'], + }; + const result = maskMessageDataHook(loggerMock)(message, 'file').data[0]; + expect(findOccurrencesInString(MASK_PATH, result)).toBe(4); + Object.defineProperty(process, 'platform', { + value: originalPlatform, + }); + }); + it('windows', () => { + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { + value: 'win32', + }); + const message = { + data: ['C:/Users/user/Desktop/download.pdf C:/Users/user/Desktop/folder withSpace/file.txt'], + }; + const result = maskMessageDataHook(loggerMock)(message, 'file').data[0]; + expect(findOccurrencesInString(MASK_PATH, result)).toBe(3); + Object.defineProperty(process, 'platform', { + value: originalPlatform, + }); + }); + }); + + it('should truncate very longs substrings', () => { + const message = { + data: ['ThisIsAVeryVeryVeryVeryVeryVeryVeryVeryLongStringProbablyAToken'], + }; + const result = maskMessageDataHook(loggerMock)(message, 'file').data[0]; + expect(result).toBe('This...en'); + }); +}); diff --git a/src/main/diagnostics/steps/internal/loggerHooks.ts b/src/main/diagnostics/steps/internal/loggerHooks.ts new file mode 100644 index 00000000..3253a6a2 --- /dev/null +++ b/src/main/diagnostics/steps/internal/loggerHooks.ts @@ -0,0 +1,28 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {ElectronLog} from 'electron-log'; + +import {obfuscateByType} from './obfuscators'; + +type ElectronLogHook = ElectronLog['hooks'][number]; +type ElectronLogHookCreator = (l: ElectronLog) => ElectronLogHook; + +export const maskMessageDataHook: ElectronLogHookCreator = (logger) => (message, transport) => { + if (transport !== logger.transports.file) { + return message; + } + + // iterate the arguments of the log command, eg log.debug(a, b, c, ...); + for (let i = 0; i < message.data.length; i++) { + message.data[i] = obfuscateByType(message.data[i]); + } + + return message; +}; + +const loggerHooks: (logger: ElectronLog) => ElectronLog['hooks'] = (logger) => [ + maskMessageDataHook(logger), +]; + +export default loggerHooks; diff --git a/src/main/diagnostics/steps/internal/obfuscators.ts b/src/main/diagnostics/steps/internal/obfuscators.ts new file mode 100644 index 00000000..e55dcc03 --- /dev/null +++ b/src/main/diagnostics/steps/internal/obfuscators.ts @@ -0,0 +1,83 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {MASK_EMAIL, MASK_IPV4, MASK_PATH, MASK_URL, REGEX_EMAIL, REGEX_IPV4, REGEX_PATH_DARWIN, REGEX_PATH_LINUX, REGEX_PATH_WIN32, REGEX_URL} from 'common/constants'; + +import {truncateString} from './utils'; + +const isDarwin = process.platform === 'darwin'; +const isLinux = process.platform === 'linux'; +const isWin = process.platform === 'win32'; + +function maskDataInString(str: string): string { + let maskedStr = str; + if (!str || typeof str !== 'string') { + return str; + } + + // Specific keywords + if (str?.toLowerCase?.().includes('password')) { + return ''; + } + + // Emails + if (REGEX_EMAIL.test(str)) { + maskedStr = maskedStr.replaceAll(RegExp(REGEX_EMAIL, 'gi'), MASK_EMAIL); + } + + // IP addresses + if (REGEX_IPV4.test(str)) { + maskedStr = maskedStr.replaceAll(RegExp(REGEX_IPV4, 'gi'), MASK_IPV4); + } + + // URLs + if (REGEX_URL.test(str)) { + maskedStr = maskedStr.replaceAll(RegExp(REGEX_URL, 'gi'), MASK_URL); + } + + // Paths + if (isDarwin) { + if (REGEX_PATH_DARWIN.test(str)) { + maskedStr = maskedStr.replaceAll(RegExp(REGEX_PATH_DARWIN, 'gi'), MASK_PATH); + } + } else if (isLinux) { + if (REGEX_PATH_LINUX.test(str)) { + maskedStr = maskedStr.replaceAll(RegExp(REGEX_PATH_LINUX, 'gi'), MASK_PATH); + } + } else if (isWin) { + if (REGEX_PATH_WIN32.test(str)) { + maskedStr = maskedStr.replaceAll(RegExp(REGEX_PATH_WIN32, 'gi'), MASK_PATH); + } + } + + // Very long strings will be truncated (eg tokens) + maskedStr = maskedStr.split(/,| |\r?\n/)?.map?.((str: string) => truncateString(str))?.join?.(' '); + + return maskedStr; +} + +function maskDataInArray(arr: unknown[]): unknown[] { + return arr.map((el) => { + return obfuscateByType(el); + }); +} + +function maskDataInObject(obj: Record): Record { + return Object.keys(obj).reduce>((acc, key) => { + acc[key] = obfuscateByType(obj[key]); + return acc; + }, {}); +} + +export function obfuscateByType(item: unknown): unknown { + const elType = typeof item; + if (elType === 'string') { + return maskDataInString(item as string); + } else if (elType === 'object') { + if (Array.isArray(item)) { + return maskDataInArray(item); + } + return maskDataInObject(item as Record); + } + return item; +} diff --git a/src/main/diagnostics/steps/internal/utils.test.js b/src/main/diagnostics/steps/internal/utils.test.js new file mode 100644 index 00000000..2dc02670 --- /dev/null +++ b/src/main/diagnostics/steps/internal/utils.test.js @@ -0,0 +1,186 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import fs from 'fs'; + +import config from 'common/config'; + +import {addDurationToFnReturnObject, boundsOk, browserWindowVisibilityStatus, checkPathPermissions, truncateString, webContentsCheck} from './utils'; + +const sleep = (ms) => new Promise((resolve) => { + setTimeout(() => resolve(), ms); +}); + +const timeToSleep = 100; + +jest.mock('fs', () => ({ + promises: { + access: jest.fn(), + }, + constants: { + W_OK: 0, + }, +})); +jest.mock('common/config', () => ({ + downloadLocation: 'path/to/file.txt', +})); +describe('main/diagnostics/utils', () => { + describe('addDurationToFnReturnObject', () => { + it('should measure the execution time of a function and include it in the response', async () => { + const functionToMeasure = async () => { + await sleep(timeToSleep); + }; + const fn = addDurationToFnReturnObject(functionToMeasure); + const b = await fn(); + expect(b.duration).toBeGreaterThan(timeToSleep - 10); + expect(b.duration).toBeLessThan(timeToSleep * 1.5); + }); + }); + + describe('truncateString', () => { + it('should truncate very long string', () => { + const str = 'ThisIsAVeryVeryVeryVeryVeryVeryVeryVeryLongStringProbablyAToken'; + expect(truncateString(str)).toBe('This...en'); + }); + }); + + describe('boundsOk', () => { + const bounds = { + x: 17, + y: 42, + width: 800, + height: 600, + }; + const zeroBounds = { + x: 0, + y: 0, + width: 0, + height: 0, + }; + + /** TRUE */ + it('should return true if the bounds Rectangle is valid - not strict', () => { + expect(boundsOk(bounds)).toBe(true); + }); + it('should return true if the bounds Rectangle is valid - strict', () => { + expect(boundsOk(bounds, true)).toBe(true); + }); + it('should return true if the bounds Rectangle is valid - not strict', () => { + expect(boundsOk(zeroBounds)).toBe(true); + }); + + /** FALSE */ + it('should return false if the bounds Rectangle is invalid - strict', () => { + expect(boundsOk(zeroBounds, true)).toBe(false); + }); + it('should return false if the bounds Rectangle is invalid - not strict', () => { + expect(boundsOk({x: 0, y: 0})).toBe(false); + }); + it('should return false if the bounds Rectangle is invalid - not strict', () => { + expect(boundsOk({x: 0, y: 0, width: 0})).toBe(false); + }); + it('should return false if the bounds Rectangle is invalid - not strict', () => { + expect(boundsOk('a_string')).toBe(false); + }); + it('should return false if the bounds Rectangle is invalid - not strict', () => { + expect(boundsOk(42)).toBe(false); + }); + it('should return false if the bounds Rectangle is invalid - not strict', () => { + expect(boundsOk({...bounds, x: '10'})).toBe(false); + }); + }); + + describe('browserWindowVisibilityStatus', () => { + const bWindow = { + getBounds: () => ({x: 0, y: 0, width: 800, height: 600}), + getOpacity: () => 1, + isDestroyed: () => false, + isVisible: () => true, + isEnabled: () => true, + getBrowserViews: () => [{ + getBounds: () => ({ + x: 0, + y: 0, + width: 800, + height: 500, + }), + }], + }; + it('should return true if window ok', () => { + expect(browserWindowVisibilityStatus('testWindow', bWindow).every((check) => check.ok)).toBe(true); + }); + it('should return false if bounds not ok', () => { + expect(browserWindowVisibilityStatus('testWindow', {...bWindow, getBounds: () => ({x: -1, y: -1, width: 200, height: 100})}).every((check) => check.ok)).toBe(false); + }); + it('should return false if opacity is 0', () => { + expect(browserWindowVisibilityStatus('testWindow', {...bWindow, getOpacity: () => 0.0}).every((check) => check.ok)).toBe(false); + }); + it('should return false if window is destroyed', () => { + expect(browserWindowVisibilityStatus('testWindow', {...bWindow, isDestroyed: () => true}).every((check) => check.ok)).toBe(false); + }); + it('should return false if window is not visible', () => { + expect(browserWindowVisibilityStatus('testWindow', {...bWindow, isVisible: () => false}).every((check) => check.ok)).toBe(false); + }); + it('should return false if window is not enabled', () => { + expect(browserWindowVisibilityStatus('testWindow', {...bWindow, isEnabled: () => false}).every((check) => check.ok)).toBe(false); + }); + it('should return false if a child browserView has invalid bounds', () => { + expect(browserWindowVisibilityStatus('testWindow', { + ...bWindow, + getBrowserViews: () => [{ + getBounds: () => ({ + x: -1, + y: -4000, + width: 800, + height: 500, + }), + }], + }).every((check) => check.ok)).toBe(false); + }); + }); + + describe('webContentsCheck', () => { + it('should return true if webContents are ok', () => { + expect(webContentsCheck({ + isCrashed: () => false, + isDestroyed: () => false, + isWaitingForResponse: () => false, + })).toBe(true); + }); + it('should return false if webcontents is undefined', () => { + expect(webContentsCheck()).toBe(false); + }); + it('should return false if webContents has crashed', () => { + expect(webContentsCheck({ + isCrashed: () => true, + isDestroyed: () => false, + isWaitingForResponse: () => false, + })).toBe(false); + }); + it('should return false if webContents is destroyed', () => { + expect(webContentsCheck({ + isCrashed: () => false, + isDestroyed: () => true, + isWaitingForResponse: () => false, + })).toBe(false); + }); + it('should return false if webContents is waiting for response', () => { + expect(webContentsCheck({ + isCrashed: () => false, + isDestroyed: () => false, + isWaitingForResponse: () => true, + })).toBe(false); + }); + }); + + describe('checkPathPermissions', () => { + it('should return {ok: true}', async () => { + fs.promises.access.mockImplementation(() => Promise.resolve(undefined)); + expect(await checkPathPermissions(config.downloadLocation, fs.constants.W_OK)).toStrictEqual({ok: true}); + }); + it('should return {ok: false}', async () => { + const error = new Error('some error'); + fs.promises.access.mockImplementation(() => Promise.reject(error)); + expect(await checkPathPermissions(config.downloadLocation, fs.constants.W_OK)).toStrictEqual({ok: false, error}); + }); + }); +}); diff --git a/src/main/diagnostics/steps/internal/utils.ts b/src/main/diagnostics/steps/internal/utils.ts new file mode 100644 index 00000000..8b52b378 --- /dev/null +++ b/src/main/diagnostics/steps/internal/utils.ts @@ -0,0 +1,242 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import fs from 'fs'; +import https from 'https'; +import readline from 'readline'; + +import {BrowserWindow, Rectangle, WebContents} from 'electron'; +import log, {ElectronLog, LogLevel} from 'electron-log'; +import {AddDurationToFnReturnObject, LogFileLineData, LogLevelAmounts, WindowStatus} from 'types/diagnostics'; + +import {IS_ONLINE_ENDPOINT, LOGS_MAX_STRING_LENGTH, REGEX_LOG_FILE_LINE} from 'common/constants'; + +export function dateTimeInFilename(date?: Date) { + const now = date ?? new Date(); + return `${now.getDate()}-${now.getMonth()}-${now.getFullYear()}_${now.getHours()}-${now.getMinutes()}-${now.getSeconds()}-${now.getMilliseconds()}`; +} + +export function boundsOk(bounds?: Rectangle, strict = false): boolean { + if (!bounds) { + return false; + } + if (typeof bounds !== 'object') { + return false; + } + + const propertiesOk = ['x', 'y', 'width', 'height'].every((key) => Object.prototype.hasOwnProperty.call(bounds, key)); + const valueTypesOk = Object.values(bounds).every((value) => typeof value === 'number'); + + if (!propertiesOk || !valueTypesOk) { + return false; + } + + if (strict) { + return bounds.height > 0 && bounds.width > 0 && bounds.x >= 0 && bounds.y >= 0; + } + + return bounds.height >= 0 && bounds.width >= 0 && bounds.x >= 0 && bounds.y >= 0; +} + +export const addDurationToFnReturnObject: AddDurationToFnReturnObject = (run) => { + return async (logger) => { + const startTime = Date.now(); + const runReturnValues = await run(logger); + return { + ...runReturnValues, + duration: Date.now() - startTime, + }; + }; +}; + +export function truncateString(str: string, maxLength = LOGS_MAX_STRING_LENGTH): string { + if (typeof str === 'string') { + const length = str.length; + if (length >= maxLength) { + return `${str.substring(0, 4)}...${str.substring(length - 2, length)}`; + } + } + return str; +} + +export async function isOnline(logger: ElectronLog = log, url = IS_ONLINE_ENDPOINT): Promise { + return new Promise((resolve) => { + https.get(url, (resp) => { + let data = ''; + + // A chunk of data has been received. + resp.on('data', (chunk) => { + data += chunk; + }); + + // The whole response has been received. Print out the result. + resp.on('end', () => { + logger.debug('resp.on.end', {data}); + const respBody = JSON.parse(data); + if (respBody.status === 'OK') { + resolve(true); + return; + } + resolve(false); + }); + }).on('error', (err) => { + logger.error('diagnostics isOnline Error', {err}); + resolve(false); + }); + }); +} + +export function browserWindowVisibilityStatus(name: string, bWindow?: BrowserWindow): WindowStatus { + const status: WindowStatus = []; + + if (!bWindow) { + status.push({ + name: 'windowExists', + ok: false, + }); + return status; + } + + const bounds = bWindow.getBounds(); + const opacity = bWindow.getOpacity(); + const destroyed = bWindow.isDestroyed(); + const visible = bWindow.isVisible(); + const enabled = bWindow.isEnabled(); + const browserViewsBounds = bWindow.getBrowserViews()?.map((view) => view.getBounds()); + + status.push({ + name: 'windowExists', + ok: true, + }); + + status.push({ + name: 'bounds', + ok: boundsOk(bounds, true), + data: bounds, + }); + + status.push({ + name: 'opacity', + ok: opacity > 0 && opacity <= 1, + data: opacity, + }); + + status.push({ + name: 'destroyed', + ok: !destroyed, + }); + status.push({ + name: 'visible', + ok: visible, + }); + status.push({ + name: 'enabled', + ok: enabled, + }); + status.push({ + name: 'browserViewsBounds', + ok: browserViewsBounds.every((bounds) => boundsOk(bounds)), + data: browserViewsBounds, + }); + + return status; +} + +export function webContentsCheck(webContents?: WebContents) { + if (!webContents) { + return false; + } + + return !webContents.isCrashed() && !webContents.isDestroyed() && !webContents.isWaitingForResponse(); +} + +export async function checkPathPermissions(path?: fs.PathLike, mode?: number) { + try { + if (!path) { + throw new Error('Invalid path'); + } + await fs.promises.access(path, mode); + return { + ok: true, + }; + } catch (error) { + return { + ok: false, + error, + }; + } +} + +function parseLogFileLine(line: string, lineMatchPattern: RegExp): LogFileLineData { + const data = line.match(lineMatchPattern); + return { + text: line, + date: data?.[1], + logLevel: data?.[2] as LogLevel, + }; +} + +/** + * The current setup of `electron-log` rotates the file when it reaches ~1mb. It's safe to assume that the file will not be large enough to cause + * issues reading it in the same process. If this read function ever causes performance issues we should either execute it in a child process or + * read up to X amount of lines (eg 10.000) + */ +export async function readFileLineByLine(path: fs.PathLike, lineMatchPattern = REGEX_LOG_FILE_LINE): Promise<{lines: LogFileLineData[]; logLevelAmounts: LogLevelAmounts}> { + const logLevelAmounts = { + silly: 0, + debug: 0, + verbose: 0, + info: 0, + warn: 0, + error: 0, + }; + const lines: LogFileLineData[] = []; + + if (!path) { + return { + lines, + logLevelAmounts, + }; + } + + const fileStream = fs.createReadStream(path); + const rl = readline.createInterface({ + input: fileStream, + + /** + * Note: we use the crlfDelay option to recognize all instances of CR LF + * ('\r\n') in input.txt as a single line break. + */ + crlfDelay: Infinity, + }); + + let i = -1; + + for await (const line of rl) { + const isValidLine = new RegExp(lineMatchPattern, 'gi').test(line); + + if (isValidLine || i === -1) { + i++; + const lineData = parseLogFileLine(line, lineMatchPattern); + + if (lineData.logLevel) { + logLevelAmounts[lineData.logLevel]++; + } + + //push in array as new line + lines.push(lineData); + } else { + //concat with previous line + lines[i].text = `${lines[i].text}${line}`; + } + + // exit loop in edge case of very large file or infinite loop + if (i >= 100000) { + break; + } + } + + return { + lines, + logLevelAmounts, + }; +} diff --git a/src/main/diagnostics/steps/step.template.ts b/src/main/diagnostics/steps/step.template.ts new file mode 100644 index 00000000..0f64b009 --- /dev/null +++ b/src/main/diagnostics/steps/step.template.ts @@ -0,0 +1,37 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {ElectronLog} from 'electron-log'; +import {DiagnosticStepResponse} from 'types/diagnostics'; + +import DiagnosticsStep from '../DiagnosticStep'; + +const stepName = 'Step-X'; +const stepDescriptiveName = 'Template'; + +// COPY & PASTE this file to create a new step + +const run = async (logger: ElectronLog): Promise => { + try { + await Promise.resolve(); + return { + message: `${stepName} finished successfully`, + succeeded: true, + }; + } catch (error) { + logger.warn(`Diagnostics ${stepName} Failure`, {error}); + return { + message: `${stepName} failed`, + succeeded: false, + payload: error, + }; + } +}; + +const StepTemplate = new DiagnosticsStep({ + name: `diagnostic-${stepName}: ${stepDescriptiveName}`, + retries: 0, + run, +}); + +export default StepTemplate; diff --git a/src/main/diagnostics/steps/step0.logLevel.ts b/src/main/diagnostics/steps/step0.logLevel.ts new file mode 100644 index 00000000..2a273554 --- /dev/null +++ b/src/main/diagnostics/steps/step0.logLevel.ts @@ -0,0 +1,51 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import path from 'path'; + +import {app} from 'electron'; +import {ElectronLog} from 'electron-log'; + +import {DiagnosticStepResponse} from 'types/diagnostics'; + +import DiagnosticsStep from '../DiagnosticStep'; + +import loggerHooks from './internal/loggerHooks'; +import {dateTimeInFilename} from './internal/utils'; + +const stepName = 'Step-0'; +const stepDescriptiveName = 'logConfig'; + +const run = async (logger: ElectronLog): Promise => { + try { + const filename = `diagnostics_${dateTimeInFilename()}.txt`; + const pathToFile = path.join(app.getPath('userData'), `diagnostics/${filename}`); + logger.transports.file.resolvePath = () => pathToFile; + logger.transports.file.fileName = filename; + + logger.hooks.push(...loggerHooks(logger)); + logger.transports.file.level = 'silly'; + logger.transports.console.level = 'silly'; + + logger.debug('ConfigureLogger', {filename, pathToFile}); + return { + message: `${stepName} finished successfully`, + succeeded: true, + }; + } catch (error) { + logger.warn(`Diagnostics ${stepName} Failure`, {error}); + return { + message: `${stepName} failed`, + succeeded: false, + payload: error, + }; + } +}; + +const Step0 = new DiagnosticsStep({ + name: `diagnostic-${stepName}: ${stepDescriptiveName}`, + retries: 0, + run, +}); + +export default Step0; diff --git a/src/main/diagnostics/steps/step1.internetConnection.ts b/src/main/diagnostics/steps/step1.internetConnection.ts new file mode 100644 index 00000000..66f36187 --- /dev/null +++ b/src/main/diagnostics/steps/step1.internetConnection.ts @@ -0,0 +1,43 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {ElectronLog} from 'electron-log'; +import {DiagnosticStepResponse} from 'types/diagnostics'; + +import DiagnosticsStep from '../DiagnosticStep'; + +import {isOnline} from './internal/utils'; + +const stepName = 'Step-1'; +const stepDescriptiveName = 'internetConnection'; + +const run = async (logger: ElectronLog): Promise => { + try { + const success = await isOnline(logger); + if (success) { + return { + message: `${stepName} finished successfully`, + succeeded: true, + }; + } + return { + message: `${stepName} failed`, + succeeded: false, + }; + } catch (error) { + logger.warn(`Diagnostics ${stepName} Failure`, {error}); + return { + message: `${stepName} failed`, + succeeded: false, + payload: error, + }; + } +}; + +const Step1 = new DiagnosticsStep({ + name: `diagnostic-${stepName}: ${stepDescriptiveName}`, + retries: 0, + run, +}); + +export default Step1; diff --git a/src/main/diagnostics/steps/step10.crashReports.ts b/src/main/diagnostics/steps/step10.crashReports.ts new file mode 100644 index 00000000..ea7df246 --- /dev/null +++ b/src/main/diagnostics/steps/step10.crashReports.ts @@ -0,0 +1,54 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import fs from 'fs'; +import path from 'path'; + +import {app} from 'electron'; +import {ElectronLog} from 'electron-log'; +import {DiagnosticStepResponse} from 'types/diagnostics'; + +import DiagnosticsStep from '../DiagnosticStep'; + +const stepName = 'Step-10'; +const stepDescriptiveName = 'CrashReports'; + +const run = async (logger: ElectronLog): Promise => { + try { + const pathOfCrashReports = app.getPath('userData'); + const allDirFiles = await fs.promises.readdir(pathOfCrashReports); + const crashReportFiles = allDirFiles.filter((fileName) => fileName.startsWith('uncaughtException-')); + + const crashReportData = await Promise.all(crashReportFiles.map(async (fileName) => { + return { + data: await fs.promises.readFile(path.join(pathOfCrashReports, fileName), {encoding: 'utf-8'}), + fileName, + }; + })); + + const payload = { + pathOfCrashReports, + crashReportData, + }; + + return { + message: `${stepName} finished successfully`, + succeeded: true, + payload, + }; + } catch (error) { + logger.warn(`Diagnostics ${stepName} Failure`, {error}); + return { + message: `${stepName} failed`, + succeeded: false, + payload: error, + }; + } +}; + +const Step10 = new DiagnosticsStep({ + name: `diagnostic-${stepName}: ${stepDescriptiveName}`, + retries: 0, + run, +}); + +export default Step10; diff --git a/src/main/diagnostics/steps/step11.auth.ts b/src/main/diagnostics/steps/step11.auth.ts new file mode 100644 index 00000000..2af5ef35 --- /dev/null +++ b/src/main/diagnostics/steps/step11.auth.ts @@ -0,0 +1,50 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {session} from 'electron'; +import {ElectronLog} from 'electron-log'; +import {DiagnosticStepResponse} from 'types/diagnostics'; + +import DiagnosticsStep from '../DiagnosticStep'; + +const stepName = 'Step-11'; +const stepDescriptiveName = 'AuthSSO'; + +const run = async (logger: ElectronLog): Promise => { + try { + const cookies = await session.defaultSession.cookies.get({}); + + if (!cookies) { + throw new Error('No cookies found'); + } + + const payload = cookies.map((cookie) => { + return { + name: cookie?.name, + expirationDate: cookie?.expirationDate, + session: cookie?.session, + }; + }); + + return { + message: `${stepName} finished successfully`, + succeeded: true, + payload, + }; + } catch (error) { + logger.warn(`Diagnostics ${stepName} Failure`, {error}); + return { + message: `${stepName} failed`, + succeeded: false, + payload: error, + }; + } +}; + +const Step11 = new DiagnosticsStep({ + name: `diagnostic-${stepName}: ${stepDescriptiveName}`, + retries: 0, + run, +}); + +export default Step11; diff --git a/src/main/diagnostics/steps/step2.configValidation.ts b/src/main/diagnostics/steps/step2.configValidation.ts new file mode 100644 index 00000000..0911ff43 --- /dev/null +++ b/src/main/diagnostics/steps/step2.configValidation.ts @@ -0,0 +1,48 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import fs from 'fs'; + +import {ElectronLog} from 'electron-log'; +import {DiagnosticStepResponse} from 'types/diagnostics'; + +import Config from 'common/config'; +import * as Validator from 'main/Validator'; + +import DiagnosticsStep from '../DiagnosticStep'; + +const stepName = 'Step-2'; +const stepDescriptiveName = 'configValidation'; + +const run = async (logger: ElectronLog): Promise => { + try { + const configData = JSON.parse(fs.readFileSync(Config.configFilePath, 'utf8')); + + // validate based on config file version + const validData = Validator.validateConfigData(configData); + + if (!validData) { + throw new Error(`Config validation failed. Config: ${JSON.stringify(Config.combinedData, null, 4)}`); + } + + return { + message: `${stepName} finished successfully`, + succeeded: true, + }; + } catch (error) { + logger.warn(`Diagnostics ${stepName} Failure`, {error}); + + return { + message: `${stepName} failed`, + succeeded: false, + payload: error, + }; + } +}; + +const Step2 = new DiagnosticsStep({ + name: `diagnostic-${stepName}: ${stepDescriptiveName}`, + retries: 0, + run, +}); + +export default Step2; diff --git a/src/main/diagnostics/steps/step3.serverConnectivity.ts b/src/main/diagnostics/steps/step3.serverConnectivity.ts new file mode 100644 index 00000000..1c7fb40d --- /dev/null +++ b/src/main/diagnostics/steps/step3.serverConnectivity.ts @@ -0,0 +1,56 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {ElectronLog} from 'electron-log'; +import {DiagnosticStepResponse} from 'types/diagnostics'; + +import Config from 'common/config'; + +import DiagnosticsStep from '../DiagnosticStep'; + +import {isOnline} from './internal/utils'; + +const stepName = 'Step-3'; +const stepDescriptiveName = 'serverConnectivity'; + +const run = async (logger: ElectronLog): Promise => { + try { + const teams = Config.combinedData?.teams || []; + + await Promise.all(teams.map(async (team) => { + logger.debug('Pinging server: ', team.url); + + if (!team.name || !team.url) { + throw new Error(`Invalid server configuration. Team Url: ${team.url}, team name: ${team.name}`); + } + + const serverOnline = await isOnline(logger, `${team.url}/api/v4/system/ping`); + + if (!serverOnline) { + throw new Error(`Server appears to be offline. Team url: ${team.url}`); + } + })); + + return { + message: `${stepName} finished successfully`, + succeeded: true, + payload: teams, + }; + } catch (error) { + logger.warn(`Diagnostics ${stepName} Failure`, {error}); + + return { + message: `${stepName} failed`, + succeeded: false, + payload: error, + }; + } +}; + +const Step3 = new DiagnosticsStep({ + name: `diagnostic-${stepName}: ${stepDescriptiveName}`, + retries: 0, + run, +}); + +export default Step3; diff --git a/src/main/diagnostics/steps/step4.sessionDataValidation.ts b/src/main/diagnostics/steps/step4.sessionDataValidation.ts new file mode 100644 index 00000000..272ad5e5 --- /dev/null +++ b/src/main/diagnostics/steps/step4.sessionDataValidation.ts @@ -0,0 +1,54 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {session} from 'electron'; +import {ElectronLog} from 'electron-log'; +import {DiagnosticStepResponse} from 'types/diagnostics'; + +import {COOKIE_NAME_AUTH_TOKEN, COOKIE_NAME_CSRF, COOKIE_NAME_USER_ID} from 'common/constants'; + +import DiagnosticsStep from '../DiagnosticStep'; + +const stepName = 'Step-4'; +const stepDescriptiveName = 'sessionDataValidation'; + +const run = async (logger: ElectronLog): Promise => { + try { + const cookies = await session.defaultSession.cookies.get({}); + if (!cookies) { + logger.error(`${stepName}: No cookies found`); + throw new Error('No cookies found'); + } + + const userId = cookies.find((cookie) => cookie.name === COOKIE_NAME_USER_ID); + const csrf = cookies.find((cookie) => cookie.name === COOKIE_NAME_CSRF); + const authToken = cookies.find((cookie) => cookie.name === COOKIE_NAME_AUTH_TOKEN); + + if (!userId || !csrf || !authToken) { + const errMessage = `Not all required cookies found. "userId": ${Boolean(userId)}, "csrf": ${Boolean(csrf)}, "authToken": ${Boolean(authToken)}`; + logger.error(`${stepName}: ${errMessage}`); + throw new Error(errMessage); + } + + return { + message: `${stepName} finished successfully`, + succeeded: true, + }; + } catch (error) { + logger.warn(`Diagnostics ${stepName} Failure`, {error}); + + return { + message: `${stepName} failed`, + succeeded: false, + payload: error, + }; + } +}; + +const Step4 = new DiagnosticsStep({ + name: `diagnostic-${stepName}: ${stepDescriptiveName}`, + retries: 0, + run, +}); + +export default Step4; diff --git a/src/main/diagnostics/steps/step5.browserWindows.ts b/src/main/diagnostics/steps/step5.browserWindows.ts new file mode 100644 index 00000000..35846431 --- /dev/null +++ b/src/main/diagnostics/steps/step5.browserWindows.ts @@ -0,0 +1,64 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {ElectronLog} from 'electron-log'; + +import {DiagnosticStepResponse} from 'types/diagnostics'; + +import windowManager from 'main/windows/windowManager'; + +import DiagnosticsStep from '../DiagnosticStep'; + +import {browserWindowVisibilityStatus, webContentsCheck} from './internal/utils'; + +const stepName = 'Step-5'; +const stepDescriptiveName = 'BrowserWindowsChecks'; + +const run = async (logger: ElectronLog): Promise => { + try { + /** Main window check */ + if (!windowManager.mainWindowReady) { + throw new Error('Main window not ready'); + } + const mainWindowVisibilityStatus = browserWindowVisibilityStatus('mainWindow', windowManager.mainWindow); + const webContentsOk = webContentsCheck(windowManager.mainWindow?.webContents); + + if (mainWindowVisibilityStatus.some((status) => !status.ok) || !webContentsOk) { + return { + message: `${stepName} failed`, + succeeded: false, + payload: { + message: 'Some checks failed for main window', + data: { + mainWindowVisibilityStatus, + webContentsOk, + }, + }, + }; + } + + return { + message: `${stepName} finished successfully`, + succeeded: true, + payload: { + mainWindowVisibilityStatus, + webContentsOk, + }, + }; + } catch (error) { + logger.warn(`Diagnostics ${stepName} Failure`, {error}); + return { + message: `${stepName} failed`, + succeeded: false, + payload: error, + }; + } +}; + +const Step5 = new DiagnosticsStep({ + name: `diagnostic-${stepName}: ${stepDescriptiveName}`, + retries: 0, + run, +}); + +export default Step5; diff --git a/src/main/diagnostics/steps/step6.permissions.ts b/src/main/diagnostics/steps/step6.permissions.ts new file mode 100644 index 00000000..5c0fbe58 --- /dev/null +++ b/src/main/diagnostics/steps/step6.permissions.ts @@ -0,0 +1,64 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import fs from 'fs'; + +import {Notification, systemPreferences} from 'electron'; +import log, {ElectronLog} from 'electron-log'; +import {DiagnosticStepResponse} from 'types/diagnostics'; + +import DiagnosticsStep from '../DiagnosticStep'; +import config from 'common/config'; + +import {checkPathPermissions} from './internal/utils'; + +const stepName = 'Step-6'; +const stepDescriptiveName = 'PermissionsCheck'; + +const isDarwin = process.platform === 'darwin'; +const isWin32 = process.platform === 'win32'; + +const run = async (logger: ElectronLog): Promise => { + try { + const downloadsFileAccess = await checkPathPermissions(config.downloadLocation, fs.constants.W_OK); + const logsFileAccess = await checkPathPermissions(log.transports.file.getFile().path, fs.constants.W_OK); + + const payload: Record = { + notificationsSupported: Notification.isSupported(), + fileSystem: { + downloadsFileAccess, + logsFileAccess, + }, + }; + + if (isDarwin || isWin32) { + if (isDarwin) { + payload.isTrustedAccessibilityClient = systemPreferences.isTrustedAccessibilityClient(false); + } + payload.mediaAccessStatus = { + mic: systemPreferences.getMediaAccessStatus('microphone'), + screen: systemPreferences.getMediaAccessStatus('screen'), + }; + } + + return { + message: `${stepName} finished successfully`, + succeeded: true, + payload, + }; + } catch (error) { + logger.warn(`Diagnostics ${stepName} Failure`, {error}); + return { + message: `${stepName} failed`, + succeeded: false, + payload: error, + }; + } +}; + +const Step6 = new DiagnosticsStep({ + name: `diagnostic-${stepName}: ${stepDescriptiveName}`, + retries: 0, + run, +}); + +export default Step6; diff --git a/src/main/diagnostics/steps/step7.performance.ts b/src/main/diagnostics/steps/step7.performance.ts new file mode 100644 index 00000000..35a2bf71 --- /dev/null +++ b/src/main/diagnostics/steps/step7.performance.ts @@ -0,0 +1,68 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import path from 'path'; + +import {app, powerMonitor} from 'electron'; +import {ElectronLog} from 'electron-log'; +import {DiagnosticStepResponse} from 'types/diagnostics'; + +import DiagnosticsStep from '../DiagnosticStep'; + +import {dateTimeInFilename} from './internal/utils'; + +const stepName = 'Step-7'; +const stepDescriptiveName = 'PerformanceAndMemory'; + +const run = async (logger: ElectronLog): Promise => { + try { + const heapSnapshotFilepath = path.join(app.getAppPath(), `heapSnapshots/heap_snap_${dateTimeInFilename()}.txt`); + + const payload: Record = { + process: { + creationTime: process.getCreationTime(), + heapStatistics: process.getHeapStatistics(), + blinkMemory: process.getBlinkMemoryInfo(), + processMemory: process.getProcessMemoryInfo(), + systemMemory: process.getSystemMemoryInfo(), + systemVersion: process.getSystemVersion(), + cpuUsage: process.getCPUUsage(), + heapSnapshot: { + path: heapSnapshotFilepath, + success: process.takeHeapSnapshot(heapSnapshotFilepath), + }, + IOCounters: process.getIOCounters(), + uptime: process.uptime(), + platform: process.platform, + sandboxed: process.sandboxed, + contextIsolated: process.contextIsolated, + type: process.type, + versions: process.versions, + version: process.version, + mas: process.mas, + windowsStore: process.windowsStore, + }, + onBattery: powerMonitor.onBatteryPower, + }; + + return { + message: `${stepName} finished successfully`, + succeeded: true, + payload, + }; + } catch (error) { + logger.warn(`Diagnostics ${stepName} Failure`, {error}); + return { + message: `${stepName} failed`, + succeeded: false, + payload: error, + }; + } +}; + +const Step7 = new DiagnosticsStep({ + name: `diagnostic-${stepName}: ${stepDescriptiveName}`, + retries: 0, + run, +}); + +export default Step7; diff --git a/src/main/diagnostics/steps/step8.logHeuristics.ts b/src/main/diagnostics/steps/step8.logHeuristics.ts new file mode 100644 index 00000000..d6abd8c3 --- /dev/null +++ b/src/main/diagnostics/steps/step8.logHeuristics.ts @@ -0,0 +1,56 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import log, {ElectronLog} from 'electron-log'; + +import {DiagnosticStepResponse} from 'types/diagnostics'; + +import {getPercentage} from 'main/utils'; + +import DiagnosticsStep from '../DiagnosticStep'; + +import {readFileLineByLine} from './internal/utils'; + +const stepName = 'Step-8'; +const stepDescriptiveName = 'LogHeuristics'; + +const run = async (logger: ElectronLog): Promise => { + try { + const mainLogFilePath = log.transports.file.getFile().path; + const fileData = await readFileLineByLine(mainLogFilePath); + + const linesCount = fileData.lines.length; + const percentageOfErrors = getPercentage(fileData.logLevelAmounts.error, linesCount); + + /** + * Ideally we could define a threshold for the error % for which this step would return an appropriate message + * and/or return all the errors + */ + const payload = { + logLevels: fileData.logLevelAmounts, + percentageOfErrors, + linesCount, + }; + + return { + message: `${stepName} finished successfully`, + succeeded: true, + payload, + }; + } catch (error) { + logger.warn(`Diagnostics ${stepName} Failure`, {error}); + return { + message: `${stepName} failed`, + succeeded: false, + payload: error, + }; + } +}; + +const Step8 = new DiagnosticsStep({ + name: `diagnostic-${stepName}: ${stepDescriptiveName}`, + retries: 0, + run, +}); + +export default Step8; diff --git a/src/main/diagnostics/steps/step9.config.ts b/src/main/diagnostics/steps/step9.config.ts new file mode 100644 index 00000000..b8ac0ca2 --- /dev/null +++ b/src/main/diagnostics/steps/step9.config.ts @@ -0,0 +1,39 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {ElectronLog} from 'electron-log'; +import {DiagnosticStepResponse} from 'types/diagnostics'; + +import config from 'common/config'; + +import DiagnosticsStep from '../DiagnosticStep'; + +const stepName = 'Step-9'; +const stepDescriptiveName = 'Config'; + +const run = async (logger: ElectronLog): Promise => { + try { + const payload = config.data; + + return { + message: `${stepName} finished successfully`, + succeeded: true, + payload, + }; + } catch (error) { + logger.warn(`Diagnostics ${stepName} Failure`, {error}); + return { + message: `${stepName} failed`, + succeeded: false, + payload: error, + }; + } +}; + +const Step9 = new DiagnosticsStep({ + name: `diagnostic-${stepName}: ${stepDescriptiveName}`, + retries: 0, + run, +}); + +export default Step9; diff --git a/src/main/menus/app.test.js b/src/main/menus/app.test.js index 9b280385..b2d23dff 100644 --- a/src/main/menus/app.test.js +++ b/src/main/menus/app.test.js @@ -316,4 +316,10 @@ describe('main/menus/app', () => { expect(menuItem).toBe(undefined); } }); + + it('should show the "Run diagnostics" item under help', () => { + const menu = createTemplate(config); + const helpSubmenu = menu.find((subMenu) => subMenu.id === 'help')?.submenu; + expect(helpSubmenu).toContainObject({id: 'diagnostics'}); + }); }); diff --git a/src/main/menus/app.ts b/src/main/menus/app.ts index e7bb129b..98fd3159 100644 --- a/src/main/menus/app.ts +++ b/src/main/menus/app.ts @@ -14,6 +14,7 @@ import {localizeMessage} from 'main/i18nManager'; import WindowManager from 'main/windows/windowManager'; import {UpdateManager} from 'main/autoUpdater'; import downloadsManager from 'main/downloadsManager'; +import Diagnostics from 'main/diagnostics'; export function createTemplate(config: Config, updateManager: UpdateManager) { const separatorItem: MenuItemConstructorOptions = { @@ -339,6 +340,15 @@ export function createTemplate(config: Config, updateManager: UpdateManager) { submenu.push(separatorItem); } + submenu.push({ + id: 'diagnostics', + label: localizeMessage('main.menus.app.help.RunDiagnostics', 'Run diagnostics'), + click() { + Diagnostics.run(); + }, + }); + submenu.push(separatorItem); + const version = localizeMessage('main.menus.app.help.versionString', 'Version {version}{commit}', { version: app.getVersion(), // eslint-disable-next-line no-undef diff --git a/src/main/server/serverAPI.ts b/src/main/server/serverAPI.ts index 4665d649..a65376cc 100644 --- a/src/main/server/serverAPI.ts +++ b/src/main/server/serverAPI.ts @@ -4,6 +4,8 @@ import {net, session} from 'electron'; import log from 'electron-log'; +import {COOKIE_NAME_AUTH_TOKEN, COOKIE_NAME_CSRF, COOKIE_NAME_USER_ID} from 'common/constants'; + export async function getServerAPI(url: URL, isAuthenticated: boolean, onSuccess?: (data: T) => void, onAbort?: () => void, onError?: (error: Error) => void) { if (isAuthenticated) { const cookies = await session.defaultSession.cookies.get({}); @@ -15,9 +17,9 @@ export async function getServerAPI(url: URL, isAuthenticated: boolean, onSucc // Filter out cookies that aren't part of our domain const filteredCookies = cookies.filter((cookie) => cookie.domain && url.toString().indexOf(cookie.domain) >= 0); - const userId = filteredCookies.find((cookie) => cookie.name === 'MMUSERID'); - const csrf = filteredCookies.find((cookie) => cookie.name === 'MMCSRF'); - const authToken = filteredCookies.find((cookie) => cookie.name === 'MMAUTHTOKEN'); + const userId = filteredCookies.find((cookie) => cookie.name === COOKIE_NAME_USER_ID); + const csrf = filteredCookies.find((cookie) => cookie.name === COOKIE_NAME_CSRF); + const authToken = filteredCookies.find((cookie) => cookie.name === COOKIE_NAME_AUTH_TOKEN); if (!userId || !csrf || !authToken) { // Missing cookies needed for req diff --git a/src/types/diagnostics.ts b/src/types/diagnostics.ts new file mode 100644 index 00000000..0110793d --- /dev/null +++ b/src/types/diagnostics.ts @@ -0,0 +1,51 @@ +// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {ElectronLog, LogLevel} from 'electron-log'; + +export type DiagnosticsStepConstructorPayload = { + name: string; + retries: number; + run: (logger: ElectronLog) => Promise; +} + +export type DiagnosticStepResponse = { + succeeded: boolean; + message?: string; + payload?: unknown; + duration?: number; +} + +export type DiagnosticsReportObject = DiagnosticStepResponse & { + step: number; + name: DiagnosticsStepConstructorPayload['name']; +} + +export type AddDurationToFnReturnObject = + (run: (logger: ElectronLog) => Promise) + => (logger: ElectronLog) + => Promise & {duration: number}>; + +export type DiagnosticsReport = DiagnosticsReportObject[]; + +export type WindowStatus = Array<{ + name: string; + ok: boolean; + data?: unknown; +}>; + +export type LogFileLineData = { + text: string; + logLevel?: LogLevel; + date?: string; +} + +export type LogLevelAmounts = { + silly: number; + debug: number; + verbose: number; + info: number; + warn: number; + error: number; +} +