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