UNPKG

16.5 kBJavaScriptView Raw
1import compareVersions from 'compare-versions';
2import fs from 'fs';
3import os from 'os';
4import path from 'path';
5import requestPromise from 'request-promise-native';
6import util from 'util';
7
8import getParentDirectoryContainingFileSync from './getParentDirectoryContainingFileSync.js';
9
10const readFile = util.promisify(fs.readFile);
11const writeFile = util.promisify(fs.writeFile);
12
13const DEFAULT_LICENSE_FILENAME = 'fonto.lic';
14
15const LICENSE_FILE_ERROR_SOLUTION =
16 'Please check if you have a license file installed, or contact support for obtaining a license file.';
17
18const MINIMUM_LICENSE_VERSION = 1.0;
19
20const VERSION_TYPES = {
21 NIGHTLY: 'nightly',
22 PRERELEASE: 'prerelease',
23 STABLE: 'stable',
24};
25
26class ProductVersions {
27 constructor(name, versions) {
28 this._name = name;
29
30 this._productVersions = versions
31 .sort((a, b) => {
32 if (a === VERSION_TYPES.NIGHTLY) {
33 return -1;
34 }
35 if (b === VERSION_TYPES.NIGHTLY) {
36 return 1;
37 }
38
39 try {
40 return compareVersions(b, a);
41 } catch (_error) {
42 return String(b).localeCompare(a);
43 }
44 })
45 .map((version) => {
46 const type =
47 version === VERSION_TYPES.NIGHTLY
48 ? VERSION_TYPES.NIGHTLY
49 : version.includes('-')
50 ? VERSION_TYPES.PRERELEASE
51 : VERSION_TYPES.STABLE;
52
53 return { version, type };
54 });
55 }
56
57 getAll() {
58 return this._productVersions.map(
59 (productVersion) => productVersion.version
60 );
61 }
62
63 getAllStable() {
64 return this._productVersions.reduce((acc, productVersion) => {
65 if (productVersion.type === VERSION_TYPES.STABLE) {
66 acc.push(productVersion.version);
67 }
68 return acc;
69 }, []);
70 }
71
72 getLatestStable() {
73 const latestStableproductVersion = this._productVersions.find(
74 (productVersion) => productVersion.type === VERSION_TYPES.STABLE
75 );
76
77 return latestStableproductVersion
78 ? latestStableproductVersion.version
79 : null;
80 }
81
82 includes(version) {
83 return this._productVersions.some(
84 (productVersion) => productVersion.version === version
85 );
86 }
87}
88
89export default class FdtLicense {
90 constructor(app) {
91 this.app = app;
92
93 this._backendBaseUrl =
94 process.env.LICENSE_SERVICE_URL ||
95 'https://licenseserver.fontoxml.com';
96
97 this._license = this._loadLicenseFile();
98 }
99
100 /**
101 * Determine the license file path.
102 *
103 * @return {string|null}
104 */
105 _getLicenseFilename() {
106 if (process.env.FDT_LICENSE_FILENAME) {
107 return process.env.FDT_LICENSE_FILENAME;
108 }
109
110 const licenseFilenameInHomedir = path.join(
111 os.homedir(),
112 DEFAULT_LICENSE_FILENAME
113 );
114 const licenseFilenameInPathAncestry =
115 getParentDirectoryContainingFileSync(
116 process.cwd(),
117 DEFAULT_LICENSE_FILENAME,
118 true
119 );
120
121 if (licenseFilenameInPathAncestry) {
122 return path.join(
123 licenseFilenameInPathAncestry,
124 DEFAULT_LICENSE_FILENAME
125 );
126 }
127
128 try {
129 fs.accessSync(licenseFilenameInHomedir, fs.constants.R_OK);
130 return licenseFilenameInHomedir;
131 } catch (_error) {
132 // Do nothing
133 }
134
135 return null;
136 }
137
138 _decodeAndValidateLicenseFileData(data) {
139 let decryptedData;
140
141 try {
142 decryptedData = JSON.parse(
143 Buffer.from(data, 'base64').toString('utf8')
144 );
145 } catch (error) {
146 return {
147 error: new this.app.logger.ErrorWithSolution(
148 'License file has invalid data.',
149 LICENSE_FILE_ERROR_SOLUTION,
150 error
151 ),
152 };
153 }
154
155 if (typeof decryptedData !== 'object') {
156 return {
157 error: new this.app.logger.ErrorWithSolution(
158 'License file has invalid data.',
159 LICENSE_FILE_ERROR_SOLUTION
160 ),
161 };
162 }
163
164 // key
165 if (!decryptedData.key || typeof decryptedData.key !== 'string') {
166 return {
167 error: new this.app.logger.ErrorWithSolution(
168 'License file has an invalid key.',
169 LICENSE_FILE_ERROR_SOLUTION
170 ),
171 };
172 }
173
174 // licensee
175 if (
176 !decryptedData.licensee ||
177 typeof decryptedData.licensee !== 'string'
178 ) {
179 return {
180 error: new this.app.logger.ErrorWithSolution(
181 'License file has an invalid licensee.',
182 LICENSE_FILE_ERROR_SOLUTION
183 ),
184 };
185 }
186
187 // products
188 if (!decryptedData.products || !Array.isArray(decryptedData.products)) {
189 return {
190 error: new this.app.logger.ErrorWithSolution(
191 'License file has invalid products.',
192 LICENSE_FILE_ERROR_SOLUTION
193 ),
194 };
195 }
196
197 const hasInvalidProducts = decryptedData.products.some((product) => {
198 return (
199 typeof product !== 'object' ||
200 !product.id ||
201 typeof product.id !== 'string' ||
202 !product.label ||
203 typeof product.label !== 'string'
204 );
205 });
206 if (hasInvalidProducts) {
207 return {
208 error: new this.app.logger.ErrorWithSolution(
209 'License file has invalid products.',
210 LICENSE_FILE_ERROR_SOLUTION
211 ),
212 };
213 }
214
215 // version
216 if (typeof decryptedData.version !== 'number') {
217 return {
218 error: new this.app.logger.ErrorWithSolution(
219 'License file has an invalid version.',
220 LICENSE_FILE_ERROR_SOLUTION
221 ),
222 };
223 }
224
225 if (decryptedData.version < MINIMUM_LICENSE_VERSION) {
226 return {
227 error: new this.app.logger.ErrorWithSolution(
228 `The license file version is too old for this version of ${
229 this.app.getInfo().name
230 }.`,
231 `Please upgrade your license by running '${
232 this.app.getInfo().name
233 } license validate'.`
234 ),
235 };
236 }
237
238 if (decryptedData.version >= Math.floor(MINIMUM_LICENSE_VERSION) + 1) {
239 return {
240 error: new this.app.logger.ErrorWithSolution(
241 `The license file version is too new for this version of ${
242 this.app.getInfo().name
243 }.`,
244 `Please upgrade ${this.app.getInfo().name}.`
245 ),
246 };
247 }
248
249 return {
250 data: decryptedData,
251 };
252 }
253
254 /**
255 * Load the local license file from disk and parse it.
256 *
257 * @return {Object} The license load data.
258 */
259 _loadLicenseFile() {
260 const filename = this._getLicenseFilename();
261 if (!filename) {
262 return {
263 error: new this.app.logger.ErrorWithSolution(
264 'The required license file could not be found.',
265 LICENSE_FILE_ERROR_SOLUTION
266 ),
267 };
268 }
269
270 let data;
271 try {
272 data = fs.readFileSync(filename, 'utf8');
273 } catch (error) {
274 return {
275 error: new this.app.logger.ErrorWithSolution(
276 'The required license file could not be read.',
277 LICENSE_FILE_ERROR_SOLUTION,
278 error
279 ),
280 };
281 }
282
283 const validationResult = this._decodeAndValidateLicenseFileData(data);
284
285 if (validationResult.data) {
286 validationResult.filename = filename;
287 }
288
289 return validationResult;
290 }
291
292 /**
293 * Get all (public) license information.
294 *
295 * @return {Object|null}
296 * @return {string} .licensee
297 * @return {Array<Object>} .products
298 */
299 getLicenseInfo() {
300 if (!this._license.data) {
301 return null;
302 }
303
304 return {
305 filepath: this._license.filename,
306 licensee: this._license.data.licensee,
307 products: this._license.data.products.map((product) => ({
308 ...product,
309 })),
310 version: this._license.data.version,
311 };
312 }
313
314 /**
315 * Get a buffer for the license file.
316 *
317 * @return {Promise<Buffer>}
318 */
319 getLicenseFileBuffer() {
320 this.ensureLicenseFileExists();
321 return readFile(this._license.filename).catch(() => {
322 throw new Error('Could not read the license file.');
323 });
324 }
325
326 /**
327 * Check if specific product licenses are present. This does not validate remotely.
328 *
329 * Set .setRequiresLicenseValidation() on your command first, if you need to make sure the
330 * license is up to date.
331 *
332 * @param {Array<string>} productIds
333 *
334 * @return {boolean}
335 */
336 hasProductLicenses(productIds) {
337 if (!this._license.data) {
338 return false;
339 }
340
341 return productIds.every((productId) => {
342 return this._license.data.products.find(
343 (product) => product.id === productId
344 );
345 });
346 }
347
348 /**
349 * Check if there is a license file.
350 *
351 * @return {boolean} Returns true if there is a license file, throws otherwise.
352 */
353 ensureLicenseFileExists() {
354 if (!this._license.data) {
355 throw this._license.error;
356 }
357
358 return true;
359 }
360
361 /**
362 * Ensure the user has the specified products licenses by checking the license file.
363 *
364 * Validate the license with .validateAndUpdateLicenseFile() first if you need to make sure the
365 * license is up to date.
366 *
367 * @param {Array<string>} productIds
368 *
369 * @return {boolean} Returns true if the user has the specified products licenses, throws otherwise.
370 */
371 ensureProductLicenses(productIds) {
372 this.ensureLicenseFileExists();
373
374 const missingProductIds = productIds.filter((productId) => {
375 return !this._license.data.products.find(
376 (product) => product.id === productId
377 );
378 });
379
380 if (missingProductIds.length) {
381 throw new this.app.logger.ErrorWithSolution(
382 `You appear to be missing one of the required product licenses (${missingProductIds.join(
383 ', '
384 )}).`,
385 `Check if your license is valid and have the required product licenses by running '${
386 this.app.getInfo().name
387 } license validate', and afterwards try to run the current command again.`
388 );
389 }
390
391 return true;
392 }
393
394 /**
395 * Get the telemetry data for the system on which FDT is running.
396 *
397 * @return {Object}
398 */
399 _getSystemTelemetryData() {
400 return {
401 fdt: {
402 version: this.app.version,
403 },
404 node: {
405 version: process.version,
406 platform: process.platform,
407 architecture: process.arch,
408 },
409 };
410 }
411
412 /**
413 * Validate the license by checking for validity online. This will also update the local license
414 * file in case the license is changed remotely.
415 *
416 * @return {Promise<boolean>}
417 */
418 validateAndUpdateLicenseFile() {
419 return this.getLicenseFileBuffer()
420 .then((licenseFileBuffer) => {
421 // Remotely validate the license file.
422 return requestPromise.post(
423 `${this._backendBaseUrl}/license/validate`,
424 {
425 formData: {
426 request: {
427 value: JSON.stringify(
428 this._getSystemTelemetryData()
429 ),
430 options: {
431 contentType: 'application/json',
432 },
433 },
434 license: {
435 value: licenseFileBuffer,
436 options: {
437 filename: DEFAULT_LICENSE_FILENAME,
438 contentType: 'application/octet-stream',
439 },
440 },
441 },
442 resolveWithFullResponse: true,
443 simple: false,
444 }
445 );
446 })
447 .catch((error) => {
448 if (error.hasOwnProperty('solution')) {
449 throw error;
450 }
451
452 throw new Error(
453 `Could not get response from license server. Please check if you have a working internet connection.`
454 );
455 })
456 .then((response) => {
457 switch (response.statusCode) {
458 case 200: {
459 // Validate response before writing to disk.
460 const validationResult =
461 this._decodeAndValidateLicenseFileData(
462 response.body
463 );
464 if (validationResult.error) {
465 throw new Error(
466 'Invalid updated license data received while checking license.'
467 );
468 }
469
470 // Update license file on disk.
471 return writeFile(this._license.filename, response.body)
472 .catch((_error) => {
473 throw new Error(
474 'Could not update local license file.'
475 );
476 })
477 .then(() => {
478 // Update license file in memory.
479 this._license = this._loadLicenseFile();
480
481 if (!this._license.data) {
482 throw this._license.error;
483 }
484
485 return true;
486 });
487 }
488 case 204:
489 return true;
490 case 400: {
491 const error = new Error(
492 `License is invalid. Run '${
493 this.app.getInfo().name
494 } license validate' to check your license.`
495 );
496 error.statusCode = response.statusCode;
497 throw error;
498 }
499 case 401:
500 case 403: {
501 const error = new Error(
502 `License is not valid (anymore). Run '${
503 this.app.getInfo().name
504 } license validate' to check your license.`
505 );
506 error.statusCode = response.statusCode;
507 throw error;
508 }
509 default: {
510 const error = new Error(
511 `Error while checking license (${response.statusCode}).`
512 );
513 error.statusCode = response.statusCode;
514 throw error;
515 }
516 }
517 });
518 }
519
520 /**
521 * Get data for a specific product(s). This can be, for example, credentials for specific systems or urls for specific systems.
522 *
523 * @param {Object} productsWithData
524 * @param {Object} productsWithData.<productId>
525 *
526 * @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.
527 */
528 getDataForProducts(productsWithData) {
529 return this.getLicenseFileBuffer()
530 .then((licenseFileBuffer) => {
531 // Remotely get the required data.
532 return requestPromise.post(
533 `${this._backendBaseUrl}/product/data`,
534 {
535 formData: {
536 request: {
537 value: JSON.stringify(
538 Object.assign(
539 this._getSystemTelemetryData(),
540 {
541 products: productsWithData,
542 }
543 )
544 ),
545 options: {
546 contentType: 'application/json',
547 },
548 },
549 license: {
550 value: licenseFileBuffer,
551 options: {
552 filename: DEFAULT_LICENSE_FILENAME,
553 contentType: 'application/octet-stream',
554 },
555 },
556 },
557 resolveWithFullResponse: true,
558 simple: false,
559 }
560 );
561 })
562 .catch((error) => {
563 if (error.hasOwnProperty('solution')) {
564 throw error;
565 }
566
567 throw new Error(
568 `Could not get response from license server. Please check if you have a working internet connection.`
569 );
570 })
571 .then((response) => {
572 switch (response.statusCode) {
573 case 200: {
574 let data;
575 try {
576 data = JSON.parse(response.body);
577 } catch (_error) {
578 throw new Error(
579 'Invalid response data while getting data for products.'
580 );
581 }
582 return data;
583 }
584 case 400: {
585 const error = new Error(
586 `License is invalid. Run '${
587 this.app.getInfo().name
588 } license validate' to check your license.`
589 );
590 error.statusCode = response.statusCode;
591 throw error;
592 }
593 case 401:
594 case 403: {
595 const error = new Error(
596 `License is not valid (anymore). Run '${
597 this.app.getInfo().name
598 } license validate' to check your license.`
599 );
600 error.statusCode = response.statusCode;
601 throw error;
602 }
603 case 404: {
604 const error = new Error(
605 'Could not get requested data, because one or more of the requested products or their data does not exist.'
606 );
607 error.statusCode = response.statusCode;
608 throw error;
609 }
610 default: {
611 const error = new Error(
612 `Error while getting data for products (${response.statusCode}).`
613 );
614 error.statusCode = response.statusCode;
615 throw error;
616 }
617 }
618 });
619 }
620
621 /**
622 * Get the versions available of the specified Fonto product.
623 *
624 * @param {string} productName
625 *
626 * @return {Promise<ProductVersions>}
627 */
628 getVersionsForProduct(productName) {
629 const requestObj = {};
630 requestObj[productName] = {
631 versions: true,
632 };
633
634 return this.getDataForProducts(requestObj).then((productData) => {
635 return new ProductVersions(
636 productName,
637 productData.products[productName].versions
638 );
639 });
640 }
641
642 /**
643 * Send telemetry data to the license server.
644 *
645 * @param {Object} data
646 *
647 * @return {Promise<void>}
648 */
649 sendTelemetry(data) {
650 return this.getLicenseFileBuffer()
651 .then((licenseFileBuffer) => {
652 return requestPromise.post(
653 `${this._backendBaseUrl}/telemetry`,
654 {
655 formData: {
656 request: {
657 value: JSON.stringify(
658 Object.assign(
659 this._getSystemTelemetryData(),
660 {
661 data,
662 }
663 )
664 ),
665 options: {
666 contentType: 'application/json',
667 },
668 },
669 license: {
670 value: licenseFileBuffer,
671 options: {
672 filename: DEFAULT_LICENSE_FILENAME,
673 contentType: 'application/octet-stream',
674 },
675 },
676 },
677 resolveWithFullResponse: true,
678 simple: false,
679 timeout: 10000,
680 }
681 );
682 })
683 .then((response) => {
684 switch (response.statusCode) {
685 case 200:
686 case 204:
687 break;
688 default: {
689 const error = new Error(
690 `Error while sending telemetry (${response.statusCode}).`
691 );
692 error.statusCode = response.statusCode;
693 throw error;
694 }
695 }
696 })
697 .catch((_error) => {
698 if (!this.app.hideStacktraceOnErrors) {
699 this.app.logger.notice('Error while sending telemetry.');
700 }
701 });
702 }
703}