UNPKG

@fontoxml/fontoxml-development-tools

Version:

Development tools for Fonto.

884 lines (803 loc) 22.4 kB
import fs from 'fs'; import os from 'os'; import path from 'path'; import { FormData, request } from 'undici'; import util from 'util'; import getParentDirectoryContainingFileSync from './getParentDirectoryContainingFileSync.js'; import Version from './Version.js'; /** @typedef {import('./App').default} App */ const writeFile = util.promisify(fs.writeFile); const DEFAULT_LICENSE_FILENAME = 'fonto.lic'; const LICENSE_DATA_SOURCE_ENV = Symbol('LICENSE_DATA_SOURCE_ENV'); 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; /** * @typedef DecodedLicenseData * * @property {string} key * @property {string} licensee * @property { { id: string; label: string; }[] } products * @property {number} version */ /** @typedef {string | LICENSE_DATA_SOURCE_ENV} DecodedLicenseDataSource */ /** @typedef {{ dataBuffer: Buffer, data: DecodedLicenseData, source: DecodedLicenseDataSource }} DecodedLicenseDataSuccessResult */ /** @typedef {{ error: Error }} DecodedLicenseDataErrorResult */ /** @typedef { DecodedLicenseDataSuccessResult | DecodedLicenseDataErrorResult } DecodedLicenseDataResult */ /** * @typedef LicenseInfo * * @property {boolean} sourceIsEnv * @property {boolean} sourceIsFilename * @property {DecodedLicenseDataSource} source * @property {DecodedLicenseData['licensee']} licensee * @property {DecodedLicenseData['products'] } products * @property {DecodedLicenseData['version']} version */ 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'; /** @type {DecodedLicenseDataResult} */ this._license = this._loadLicenseData(); } /** * Determine the license source. * * @return {DecodedLicenseDataSource} */ _getLicenseSource() { if (process.env.FDT_LICENSE_DATA) { return LICENSE_DATA_SOURCE_ENV; } 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; } /** * @param {string} data * * @returns {DecodedLicenseData} * * @throws {ErrorWithSolution} */ _decodeAndValidateLicenseData(data) { let decodedData; try { decodedData = JSON.parse(Buffer.from(data, 'base64').toString('utf8')); } catch (error) { throw new this._app.logger.ErrorWithSolution( 'License has invalid data.', LICENSE_FILE_ERROR_SOLUTION, error, ); } if (typeof decodedData !== 'object') { throw new this._app.logger.ErrorWithSolution( 'License has invalid data.', LICENSE_FILE_ERROR_SOLUTION, ); } // key if (!decodedData.key || typeof decodedData.key !== 'string') { throw new this._app.logger.ErrorWithSolution( 'License has an invalid key.', LICENSE_FILE_ERROR_SOLUTION, ); } // licensee if (!decodedData.licensee || typeof decodedData.licensee !== 'string') { throw new this._app.logger.ErrorWithSolution( 'License has an invalid licensee.', LICENSE_FILE_ERROR_SOLUTION, ); } // products if (!decodedData.products || !Array.isArray(decodedData.products)) { throw new this._app.logger.ErrorWithSolution( 'License has invalid products.', LICENSE_FILE_ERROR_SOLUTION, ); } const hasInvalidProducts = decodedData.products.some((product) => { return ( typeof product !== 'object' || !product.id || typeof product.id !== 'string' || !product.label || typeof product.label !== 'string' ); }); if (hasInvalidProducts) { throw new this._app.logger.ErrorWithSolution( 'License has invalid products.', LICENSE_FILE_ERROR_SOLUTION, ); } // version if (typeof decodedData.version !== 'number') { throw new this._app.logger.ErrorWithSolution( 'License has an invalid version.', LICENSE_FILE_ERROR_SOLUTION, ); } if (decodedData.version < MINIMUM_LICENSE_VERSION) { throw new this._app.logger.ErrorWithSolution( `The license 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 (decodedData.version >= Math.floor(MINIMUM_LICENSE_VERSION) + 1) { throw new this._app.logger.ErrorWithSolution( `The license version is too new for this version of ${ this._app.getInfo().name }.`, `Please upgrade ${this._app.getInfo().name}.`, ); } /** @type {DecodedLicenseData} */ return decodedData; } /** * Load the local license data from disk or environment variable and parse it. * * @return {DecodedLicenseDataResult} The license load data. */ _loadLicenseData() { const source = this._getLicenseSource(); if (!source) { return { error: new this._app.logger.ErrorWithSolution( 'The required license could not be found.', LICENSE_FILE_ERROR_SOLUTION, ), }; } let data; if (source === LICENSE_DATA_SOURCE_ENV) { if (!process.env.FDT_LICENSE_DATA) { return { error: new this._app.logger.ErrorWithSolution( 'The required license data was not set in the environment variable.', LICENSE_FILE_ERROR_SOLUTION, ), }; } data = process.env.FDT_LICENSE_DATA; } else { try { data = fs.readFileSync(source, 'utf8'); } catch (error) { return { error: new this._app.logger.ErrorWithSolution( 'The required license file could not be read.', LICENSE_FILE_ERROR_SOLUTION, error, ), }; } } try { const decodedData = this._decodeAndValidateLicenseData(data); return { dataBuffer: Buffer.from(data, 'utf8'), data: decodedData, source, }; } catch (error) { return { error, }; } } /** * Get all (public) license information. * * @return {LicenseInfo | null} */ getLicenseInfo() { if (!this._license.data) { return null; } return { sourceIsEnv: this._license.source === LICENSE_DATA_SOURCE_ENV, sourceIsFilename: typeof this._license.source === 'string', source: this._license.source, licensee: this._license.data.licensee, products: this._license.data.products.map((product) => ({ ...product, })), version: this._license.data.version, }; } /** * Get a buffer for the license data. * * @return {Promise<Buffer>} */ getLicenseDataBuffer() { this.ensureLicenseDataExists(); return Promise.resolve(this._license.dataBuffer); } /** * Check if specific product licenses are present. This does not validate remotely. * * Validate the license with .validateAndUpdateLicenseData() or 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. * * @return {asserts this is { _license: DecodedLicenseDataSuccessResult }} Asserts there is license data. */ ensureLicenseDataExists() { if (!this._license.data) { throw this._license.error; } } /** * Ensure the user has the specified products licenses by checking the license data. * * Validate the license with .validateAndUpdateLicenseData() or set * .setRequiresLicenseValidation() on your command 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.ensureLicenseDataExists(); 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, }, }; } /** * Create a FormData object. * * @description * There have been some changes around the FormData class in nodejs and undici. This method * tries to create the FormData class and handle instantiation errors when the class cannot be * created. For example by not being implemented due to an older nodejs version. * * @returns {FormData} */ _createFormData() { try { return new FormData(); } catch (error) { // The FormData class is not available 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, ); } } /** * Create a File object. * * @description * There have been some changes around the File class in nodejs and undici. This method * tries to create the File class and handle instantiation errors when the class cannot be * created. For example by not being implemented due to an older nodejs version. * * @param {BlobPart[]} fileBits * @param {string} fileName * @param {FilePropertyBag} [options] * * @returns {File} */ _createFile(fileBits, fileName, options) { try { return new File(fileBits, fileName, options); } catch (error) { // The File class is not available before node version 20, 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, ); } } /** * 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>} */ validateAndUpdateLicenseData() { return this.getLicenseDataBuffer() .then((licenseFileBuffer) => { // Remotely get the required data. const form = this._createFormData(); form.set( 'request', JSON.stringify(this._getSystemTelemetryData()) ); form.set( 'license', this._createFile([licenseFileBuffer], DEFAULT_LICENSE_FILENAME, { type: 'application/octet-stream', }) ); // Remotely validate the license data. 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. this._decodeAndValidateLicenseData(body); } catch (error) { throw new this._app.logger.ErrorWithInnerError( 'Invalid updated license data received while checking license.', error, ); } if (this._license.source === LICENSE_DATA_SOURCE_ENV) { // Update the license file in the environment variable. Note that this // only applies to the current (FDT) process, so we can reload the // license data after this. process.env.FDT_LICENSE_DATA = body; } else { // Update license file on disk. await writeFile(this._license.source, body).catch((error) => { throw new this._app.logger.ErrorWithInnerError( 'Could not update local license file.', error, ); }); } // Update license data in memory. this._license = this._loadLicenseData(); 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.getLicenseDataBuffer() .then((licenseFileBuffer) => { // Remotely get the required data. const form = this._createFormData(); form.set( 'request', JSON.stringify( Object.assign(this._getSystemTelemetryData(), { products: productsWithData, }) ) ); form.set( 'license', this._createFile([licenseFileBuffer], DEFAULT_LICENSE_FILENAME, { type: 'application/octet-stream', }) ); // Remotely validate the license data. 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.getLicenseDataBuffer() .then((licenseFileBuffer) => { // Remotely get the required data. const form = this._createFormData(); form.set( 'request', JSON.stringify( Object.assign(this._getSystemTelemetryData(), { data, }) ) ); form.set( 'license', this._createFile([licenseFileBuffer], DEFAULT_LICENSE_FILENAME, { type: 'application/octet-stream', }) ); // Remotely validate the license data. 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(); } }); } }