UNPKG

@fontoxml/fontoxml-development-tools

Version:

Development tools for Fonto.

835 lines (762 loc) 20.2 kB
import fs from 'fs'; import os from 'os'; import path from 'path'; import { File, FormData, request } from 'undici'; import util from 'util'; import getParentDirectoryContainingFileSync from './getParentDirectoryContainingFileSync.js'; import Version from './Version.js'; /** @typedef {import('./App').default} App */ const readFile = util.promisify(fs.readFile); const writeFile = util.promisify(fs.writeFile); const DEFAULT_LICENSE_FILENAME = 'fonto.lic'; const LICENSE_FILE_ERROR_SOLUTION = 'Please check if you have a license file installed, or contact support for obtaining a license file.'; const MINIMUM_LICENSE_VERSION = 1.0; class ProductVersions { /** * @param {string} name * @param {Version[]} versions */ constructor(name, versions) { /** @type {string} */ this._name = name; /** @type {Version[]} */ this._productVersions = versions.sort(Version.compareReverse); } /** * Get all versions, both stable, pre-release, nightly, and unparsed literals. * * @return {Version[]} */ getAll() { return this._productVersions.slice(); } /** * Get all stable versions. This excludes pre-release, nightly, and unparsed literals. * * @return {Version[]} */ getAllStable() { return this._productVersions.reduce((acc, productVersion) => { if (productVersion.isStable) { acc.push(productVersion); } return acc; }, []); } /** * Get the latest stable version, if any. This excludes pre-release, nightly, and unparsed literals. * * @return {Version | null} */ getLatestStable() { return ( this._productVersions.find( (productVersion) => productVersion.isStable ) || null ); } /** * Get the latest stable version compatible with the specified version, using the specified * scope or on the minor. This excludes pre-release, nightly, and unparsed literals. * * @param {Version} version * @param {import('./Version.js').CompareScope} [scope='minor'] * * @return {Version | null} */ getLatestStableForVersion(version, scope = 'minor') { return ( this._productVersions.find( (productVersion) => productVersion.isStable && productVersion.compare(version, scope) === 0 ) || null ); } /** * Check if the specified version is included in the list of all versions, both stable, * pre-release, nightly, and unparsed literals. * * @param {Version} version * * @return {boolean} */ includes(version) { return this._productVersions.some( (productVersion) => productVersion.compare(version) === 0 ); } } class ProductRuntimes { /** * @param {string} name * @param {Map<string, string[]>} runtimesByVersion */ constructor(name, runtimesByVersion) { /** @type {string} */ this._name = name; /** @type {Map<string, string[]>} */ this._runtimesByVersion = runtimesByVersion; } /** * @param {string | Version} version * * @return {string[] | null} Returns `null` if the version does not exist. */ getForVersion(version) { const runtimes = this._runtimesByVersion[ version instanceof Version ? version.format() : version ]; if (!runtimes) { return null; } return runtimes.sort(); } } export default class FdtLicense { constructor(app) { /** @type {App} */ this._app = app; this._backendBaseUrl = process.env.FDT_LICENSE_SERVICE_URL || 'https://licenseserver.fontoxml.com'; this._license = this._loadLicenseFile(); } /** * Determine the license file path. * * @return {string|null} */ _getLicenseFilename() { if (process.env.FDT_LICENSE_FILENAME) { return process.env.FDT_LICENSE_FILENAME; } const licenseFilenameInHomedir = path.join( os.homedir(), DEFAULT_LICENSE_FILENAME ); const licenseFilenameInPathAncestry = getParentDirectoryContainingFileSync( process.cwd(), DEFAULT_LICENSE_FILENAME, true ); if (licenseFilenameInPathAncestry) { return path.join( licenseFilenameInPathAncestry, DEFAULT_LICENSE_FILENAME ); } try { fs.accessSync(licenseFilenameInHomedir, fs.constants.R_OK); return licenseFilenameInHomedir; } catch (_error) { // Do nothing } return null; } _decodeAndValidateLicenseFileData(data) { let decryptedData; try { decryptedData = JSON.parse( Buffer.from(data, 'base64').toString('utf8') ); } catch (error) { return { error: new this._app.logger.ErrorWithSolution( 'License file has invalid data.', LICENSE_FILE_ERROR_SOLUTION, error ), }; } if (typeof decryptedData !== 'object') { return { error: new this._app.logger.ErrorWithSolution( 'License file has invalid data.', LICENSE_FILE_ERROR_SOLUTION ), }; } // key if (!decryptedData.key || typeof decryptedData.key !== 'string') { return { error: new this._app.logger.ErrorWithSolution( 'License file has an invalid key.', LICENSE_FILE_ERROR_SOLUTION ), }; } // licensee if ( !decryptedData.licensee || typeof decryptedData.licensee !== 'string' ) { return { error: new this._app.logger.ErrorWithSolution( 'License file has an invalid licensee.', LICENSE_FILE_ERROR_SOLUTION ), }; } // products if (!decryptedData.products || !Array.isArray(decryptedData.products)) { return { error: new this._app.logger.ErrorWithSolution( 'License file has invalid products.', LICENSE_FILE_ERROR_SOLUTION ), }; } const hasInvalidProducts = decryptedData.products.some((product) => { return ( typeof product !== 'object' || !product.id || typeof product.id !== 'string' || !product.label || typeof product.label !== 'string' ); }); if (hasInvalidProducts) { return { error: new this._app.logger.ErrorWithSolution( 'License file has invalid products.', LICENSE_FILE_ERROR_SOLUTION ), }; } // version if (typeof decryptedData.version !== 'number') { return { error: new this._app.logger.ErrorWithSolution( 'License file has an invalid version.', LICENSE_FILE_ERROR_SOLUTION ), }; } if (decryptedData.version < MINIMUM_LICENSE_VERSION) { return { error: new this._app.logger.ErrorWithSolution( `The license file version is too old for this version of ${ this._app.getInfo().name }.`, `Please upgrade your license by running '${ this._app.getInfo().name } license validate'.` ), }; } if (decryptedData.version >= Math.floor(MINIMUM_LICENSE_VERSION) + 1) { return { error: new this._app.logger.ErrorWithSolution( `The license file version is too new for this version of ${ this._app.getInfo().name }.`, `Please upgrade ${this._app.getInfo().name}.` ), }; } return { data: decryptedData, }; } /** * Load the local license file from disk and parse it. * * @return {Object} The license load data. */ _loadLicenseFile() { const filename = this._getLicenseFilename(); if (!filename) { return { error: new this._app.logger.ErrorWithSolution( 'The required license file could not be found.', LICENSE_FILE_ERROR_SOLUTION ), }; } let data; try { data = fs.readFileSync(filename, 'utf8'); } catch (error) { return { error: new this._app.logger.ErrorWithSolution( 'The required license file could not be read.', LICENSE_FILE_ERROR_SOLUTION, error ), }; } const validationResult = this._decodeAndValidateLicenseFileData(data); if (validationResult.data) { validationResult.filename = filename; } return validationResult; } /** * Get all (public) license information. * * @return {Object|null} * @return {string} .licensee * @return {Array<Object>} .products */ getLicenseInfo() { if (!this._license.data) { return null; } return { filepath: this._license.filename, licensee: this._license.data.licensee, products: this._license.data.products.map((product) => ({ ...product, })), version: this._license.data.version, }; } /** * Get a buffer for the license file. * * @return {Promise<Buffer>} */ getLicenseFileBuffer() { this.ensureLicenseFileExists(); return readFile(this._license.filename).catch((error) => { throw new this._app.logger.ErrorWithInnerError( 'Could not read the license file.', error ); }); } /** * Check if specific product licenses are present. This does not validate remotely. * * Set .setRequiresLicenseValidation() on your command first, if you need to make sure the * license is up to date. * * @param {Array<string>} productIds * * @return {boolean} */ hasProductLicenses(productIds) { if (!this._license.data) { return false; } return productIds.every((productId) => { return this._license.data.products.find( (product) => product.id === productId ); }); } /** * @param {string} productId * * @return {string | null} */ getProductLabel(productId) { if (!this._license.data) { return null; } const product = this._license.data.products.find( (product) => product.id === productId ); if (!product || !product.label) { return null; } return product.label; } /** * Check if there is a license file. * * @return {boolean} Returns true if there is a license file, throws otherwise. */ ensureLicenseFileExists() { if (!this._license.data) { throw this._license.error; } return true; } /** * Ensure the user has the specified products licenses by checking the license file. * * Validate the license with .validateAndUpdateLicenseFile() first if you need to make sure the * license is up to date. * * @param {Array<string>} productIds * * @return {boolean} Returns true if the user has the specified products licenses, throws otherwise. */ ensureProductLicenses(productIds) { this.ensureLicenseFileExists(); const missingProductIds = productIds.filter((productId) => { return !this._license.data.products.find( (product) => product.id === productId ); }); if (missingProductIds.length) { throw new this._app.logger.ErrorWithSolution( `You appear to be missing one of the required product licenses (${missingProductIds.join( ', ' )}).`, `Check if your license is valid and have the required product licenses by running '${ this._app.getInfo().name } license validate', and afterwards try to run the current command again.` ); } return true; } /** * Get the telemetry data for the system on which FDT is running. * * @return {Object} */ _getSystemTelemetryData() { return { fdt: { version: this._app.version.format(), }, node: { version: process.version, platform: process.platform, architecture: process.arch, }, }; } /** * Validate the license by checking for validity online. This will also update the local license * file in case the license is changed remotely. * * @return {Promise<boolean>} */ validateAndUpdateLicenseFile() { return this.getLicenseFileBuffer() .then((licenseFileBuffer) => { // Remotely get the required data. let form; try { form = new FormData(); } catch (error) { // The FormData class is not availible before node version 16, and the error // when not meeting that requirement was noninformative. throw new this._app.logger.ErrorWithSolution( 'Could not make a request to the license server.', 'Please make sure you are using a compatible Node.js version.', error ); } form.set( 'request', JSON.stringify(this._getSystemTelemetryData()) ); form.set( 'license', new File([licenseFileBuffer], DEFAULT_LICENSE_FILENAME, { type: 'application/octet-stream', }) ); // Remotely validate the license file. return request(`${this._backendBaseUrl}/license/validate`, { body: form, method: 'POST', }); }) .catch((error) => { if (error.solution) { throw error; } throw new this._app.logger.ErrorWithInnerError( 'Could not get response from license server. Please check if you have a working internet connection.', error ); }) .then(async (response) => { switch (response.statusCode) { case 200: { let body; try { body = await response.body.text(); // Validate response before writing to disk. const validationResult = this._decodeAndValidateLicenseFileData(body); if (validationResult.error) { throw validationResult.error; } } catch (error) { throw new this._app.logger.ErrorWithInnerError( 'Invalid updated license data received while checking license.', error ); } // Update license file on disk. return writeFile(this._license.filename, body) .catch((error) => { throw new this._app.logger.ErrorWithInnerError( 'Could not update local license file.', error ); }) .then(() => { // Update license file in memory. this._license = this._loadLicenseFile(); if (!this._license.data) { throw this._license.error; } return true; }); } case 204: return true; case 400: { const error = new Error( `License is invalid. Run '${ this._app.getInfo().name } license validate' to check your license.` ); error.statusCode = response.statusCode; throw error; } case 401: case 403: { const error = new Error( `License is not valid (anymore). Run '${ this._app.getInfo().name } license validate' to check your license.` ); error.statusCode = response.statusCode; throw error; } default: { const error = new Error( `Error while checking license (${response.statusCode}).` ); error.statusCode = response.statusCode; throw error; } } }); } /** * Get data for a specific product(s). This can be, for example, credentials for specific systems or urls for specific systems. * * @param {Object} productsWithData * @param {Object} productsWithData.<productId> * * @return {Promise<Object>} An Object containing the data for the products, in the same format as the request, but filled with response values instead of request values. */ getDataForProducts(productsWithData) { return this.getLicenseFileBuffer() .then((licenseFileBuffer) => { // Remotely get the required data. let form; try { form = new FormData(); } catch (error) { // The FormData class is not availible before node version 16, and the error // when not meeting that requirement was noninformative. throw new this._app.logger.ErrorWithSolution( 'Could not make a request to the license server.', 'Please make sure you are using a compatible Node.js version.', error ); } form.set( 'request', JSON.stringify( Object.assign(this._getSystemTelemetryData(), { products: productsWithData, }) ) ); form.set( 'license', new File([licenseFileBuffer], DEFAULT_LICENSE_FILENAME, { type: 'application/octet-stream', }) ); // Remotely validate the license file. return request(`${this._backendBaseUrl}/product/data`, { body: form, method: 'POST', }); }) .catch((error) => { if (error.solution) { throw error; } throw new this._app.logger.ErrorWithInnerError( 'Could not get response from license server. Please check if you have a working internet connection.', error ); }) .then(async (response) => { switch (response.statusCode) { case 200: { try { return await response.body.json(); } catch (error) { throw new this._app.logger.ErrorWithInnerError( 'Invalid response data while getting data for products.', error ); } } case 400: { const error = new Error( `License is invalid. Run '${ this._app.getInfo().name } license validate' to check your license.` ); error.statusCode = response.statusCode; throw error; } case 401: case 403: { const error = new Error( `License is not valid (anymore). Run '${ this._app.getInfo().name } license validate' to check your license.` ); error.statusCode = response.statusCode; throw error; } case 404: { const error = new Error( 'Could not get requested data, because one or more of the requested products or their data does not exist.' ); error.statusCode = response.statusCode; throw error; } default: { const error = new Error( `Error while getting data for products (${response.statusCode}).` ); error.statusCode = response.statusCode; throw error; } } }); } /** * Get the versions available of the specified Fonto product. * * @param {string} productName * * @return {Promise<Map<string, string[]>>} */ getRuntimesForProduct(productName) { const requestObj = {}; requestObj[productName] = { runtimes: true, }; return this.getDataForProducts(requestObj).then((productData) => { if ( !productData.products[productName] || !productData.products[productName].runtimes ) { throw new Error(`Could not get runtimes for product "${productName}".`); } return new ProductRuntimes( productName, productData.products[productName].runtimes, ); }); } /** * Get the versions available of the specified Fonto product. * * @param {string} productName * * @return {Promise<ProductVersions>} */ getVersionsForProduct(productName) { const requestObj = {}; requestObj[productName] = { versions: true, }; return this.getDataForProducts(requestObj).then((productData) => { if ( !productData.products[productName] || !productData.products[productName].versions ) { throw new Error( `Could not get versions for product "${productName}".` ); } return new ProductVersions( productName, productData.products[productName].versions.map( (version) => new Version(version, true) ) ); }); } /** * Send telemetry data to the license server. * * @param {Object} data * * @return {Promise<void>} */ sendTelemetry(data) { return this.getLicenseFileBuffer() .then((licenseFileBuffer) => { // Remotely get the required data. let form; try { form = new FormData(); } catch (error) { // The FormData class is not availible before node version 16, and the error // when not meeting that requirement was noninformative. throw new this._app.logger.ErrorWithSolution( 'Could not make a request to the license server.', 'Please make sure you are using a compatible Node.js version.', error ); } form.set( 'request', JSON.stringify( Object.assign(this._getSystemTelemetryData(), { data, }) ) ); form.set( 'license', new File([licenseFileBuffer], DEFAULT_LICENSE_FILENAME, { type: 'application/octet-stream', }) ); // Remotely validate the license file. return request(`${this._backendBaseUrl}/telemetry`, { body: form, method: 'POST', timeout: 10000, }); }) .then((response) => { switch (response.statusCode) { case 200: case 204: break; default: { const error = new Error( `Error while sending telemetry (${response.statusCode}).` ); error.statusCode = response.statusCode; throw error; } } }) .catch((error) => { if (!this._app.hideStacktraceOnErrors) { this._app.logger.notice('Error while sending telemetry.'); this._app.logger.indent(); this._app.logger.debug(error.stack || error); this._app.logger.outdent(); this._app.logger.break(); } }); } }