@fontoxml/fontoxml-development-tools
Version:
Development tools for Fonto.
884 lines (803 loc) • 22.4 kB
JavaScript
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();
}
});
}
}