mirror of
https://github.com/strapi/strapi.git
synced 2025-11-01 10:23:34 +00:00
Add online license check
This commit is contained in:
parent
5a2b379fa0
commit
b37a8be684
@ -8,7 +8,7 @@ const mergeRoutes = (a, b, key) => {
|
||||
return _.isArray(a) && _.isArray(b) && key === 'routes' ? a.concat(b) : undefined;
|
||||
};
|
||||
|
||||
if (process.env.STRAPI_DISABLE_EE !== 'true' && strapi.EE) {
|
||||
if (strapi.EE) {
|
||||
const eeAdmin = require('./ee/strapi-server');
|
||||
|
||||
module.exports = _.mergeWith({}, admin, eeAdmin, mergeRoutes);
|
||||
|
||||
@ -1,132 +1,211 @@
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { join } = require('path');
|
||||
const crypto = require('crypto');
|
||||
const _ = require('lodash');
|
||||
const fetch = require('node-fetch');
|
||||
const { pick } = require('lodash/fp');
|
||||
|
||||
const publicKey = fs.readFileSync(path.join(__dirname, 'resources/key.pub'));
|
||||
const { coreStoreModel } = require('../lib/services/core-store');
|
||||
|
||||
const noop = () => {};
|
||||
const publicKey = fs.readFileSync(join(__dirname, 'resources/key.pub'));
|
||||
|
||||
const noLog = {
|
||||
warn: noop,
|
||||
info: noop,
|
||||
const ee = {
|
||||
enabled: false,
|
||||
licenseInfo: {},
|
||||
};
|
||||
|
||||
const disable = (message = 'Invalid license. Starting in CE.') => {
|
||||
ee.logger?.warn(message);
|
||||
// Only keep the license key for potential re-enabling during a later check
|
||||
ee.licenseInfo = pick('licenseKey', ee.licenseInfo);
|
||||
ee.enabled = false;
|
||||
};
|
||||
|
||||
const readLicense = (directory) => {
|
||||
try {
|
||||
const path = join(directory, 'license.txt');
|
||||
return fs.readFileSync(path).toString();
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') {
|
||||
// Permission denied, directory found instead of file, etc.
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const fetchLicense = async (key, fallback) => {
|
||||
try {
|
||||
const response = await fetch(`https://license.strapi.io/api/licenses/${key}`);
|
||||
|
||||
if (response.status !== 200) {
|
||||
disable();
|
||||
return null;
|
||||
}
|
||||
|
||||
return response.text();
|
||||
} catch (error) {
|
||||
if (error instanceof fetch.FetchError) {
|
||||
if (fallback) {
|
||||
ee.logger(
|
||||
'Could not proceed to the online verification of your license. We will try to use your locally stored one as a potential fallback.'
|
||||
);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
disable(
|
||||
'Could not proceed to the online verification of your license, sorry for the inconvenience. Starting in CE.'
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const verifyLicense = (license) => {
|
||||
const [signature, base64Content] = Buffer.from(license, 'base64').toString().split('\n');
|
||||
const stringifiedContent = Buffer.from(base64Content, 'base64').toString();
|
||||
|
||||
const verifier = crypto.createVerify('RSA-SHA256');
|
||||
verifier.update(stringifiedContent);
|
||||
verifier.end();
|
||||
|
||||
const verified = verifier.verify(publicKey, signature, 'base64');
|
||||
|
||||
return { verified, licenseInfo: verified ? JSON.parse(stringifiedContent) : null };
|
||||
};
|
||||
|
||||
let initialized = false;
|
||||
|
||||
const init = (licenseDir, logger) => {
|
||||
// Can only be executed once, to prevent any abuse of the optimistic behavior
|
||||
if (initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
initialized = true;
|
||||
ee.logger = logger;
|
||||
|
||||
if (process.env.STRAPI_DISABLE_EE?.toLowerCase() === 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
const license = process.env.STRAPI_LICENSE || readLicense(licenseDir);
|
||||
|
||||
if (license) {
|
||||
const { verified, licenseInfo } = verifyLicense(license);
|
||||
|
||||
if (verified) {
|
||||
ee.enabled = true; // Optimistically enable EE during initialization
|
||||
ee.licenseInfo = licenseInfo;
|
||||
} else {
|
||||
return disable();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const oneMinute = 1000 * 60;
|
||||
|
||||
const onlineUpdate = async (db) => {
|
||||
const transaction = await db.transaction();
|
||||
|
||||
try {
|
||||
// TODO: Use the core store interface instead, it does not support transactions and "FOR UPDATE" at the moment
|
||||
const eeInfo = await db
|
||||
.queryBuilder(coreStoreModel.uid)
|
||||
.where({ key: 'ee_information' })
|
||||
.select('value')
|
||||
.first()
|
||||
.transacting(transaction)
|
||||
.forUpdate()
|
||||
.execute()
|
||||
.then((result) => (result ? JSON.parse(result.value) : result));
|
||||
|
||||
const useStoredLicense = eeInfo?.lastOnlineCheck > Date.now() - oneMinute;
|
||||
const license = useStoredLicense
|
||||
? eeInfo.license
|
||||
: await fetchLicense(ee.licenseInfo.licenseKey, eeInfo?.license);
|
||||
|
||||
if (license) {
|
||||
const { verified, licenseInfo } = verifyLicense(license);
|
||||
|
||||
if (verified) {
|
||||
ee.licenseInfo = licenseInfo;
|
||||
} else {
|
||||
disable();
|
||||
}
|
||||
}
|
||||
|
||||
if (!useStoredLicense) {
|
||||
const value = { license, lastOnlineCheck: Date.now() };
|
||||
const query = db.queryBuilder(coreStoreModel.uid).transacting(transaction);
|
||||
|
||||
if (!eeInfo) {
|
||||
query.insert({ key: 'ee_information', value: JSON.stringify(value), type: typeof value });
|
||||
} else {
|
||||
query.update({ value: JSON.stringify(value) }).where({ key: 'ee_information' });
|
||||
}
|
||||
|
||||
await query.execute();
|
||||
} else if (!license) {
|
||||
disable();
|
||||
}
|
||||
|
||||
await transaction.commit();
|
||||
} catch (error) {
|
||||
// TODO: The database can be locked at the time of writing, could just a SQLite issue only
|
||||
await transaction.rollback();
|
||||
return disable(error.message);
|
||||
}
|
||||
};
|
||||
|
||||
const internals = {};
|
||||
const defaultFeatures = {
|
||||
bronze: [],
|
||||
silver: [],
|
||||
gold: ['sso'],
|
||||
};
|
||||
|
||||
const EEService = ({ dir, logger = noLog }) => {
|
||||
if (_.has(internals, 'isEE')) return internals.isEE;
|
||||
|
||||
const warnAndReturn = (msg = 'Invalid license. Starting in CE.') => {
|
||||
logger.warn(msg);
|
||||
internals.isEE = false;
|
||||
return false;
|
||||
};
|
||||
|
||||
if (process.env.STRAPI_DISABLE_EE === 'true') {
|
||||
internals.isEE = false;
|
||||
return false;
|
||||
const validateInfo = () => {
|
||||
if (ee.licenseInfo.expireAt) {
|
||||
return;
|
||||
}
|
||||
|
||||
const licensePath = path.join(dir, 'license.txt');
|
||||
const expirationTime = new Date(ee.licenseInfo.expireAt).getTime();
|
||||
|
||||
let license;
|
||||
if (_.has(process.env, 'STRAPI_LICENSE')) {
|
||||
license = process.env.STRAPI_LICENSE;
|
||||
} else if (fs.existsSync(licensePath)) {
|
||||
license = fs.readFileSync(licensePath).toString();
|
||||
if (expirationTime < new Date().getTime()) {
|
||||
return disable('License expired. Starting in CE.');
|
||||
}
|
||||
|
||||
if (_.isNil(license)) {
|
||||
internals.isEE = false;
|
||||
return false;
|
||||
}
|
||||
ee.enabled = true;
|
||||
|
||||
// TODO: optimistically return true if license key is valid
|
||||
|
||||
try {
|
||||
const plainLicense = Buffer.from(license, 'base64').toString();
|
||||
const [signatureb64, contentb64] = plainLicense.split('\n');
|
||||
|
||||
const signature = Buffer.from(signatureb64, 'base64');
|
||||
const content = Buffer.from(contentb64, 'base64').toString();
|
||||
|
||||
const verifier = crypto.createVerify('RSA-SHA256');
|
||||
verifier.update(content);
|
||||
verifier.end();
|
||||
|
||||
const isValid = verifier.verify(publicKey, signature);
|
||||
if (!isValid) return warnAndReturn();
|
||||
|
||||
internals.licenseInfo = JSON.parse(content);
|
||||
internals.licenseInfo.features =
|
||||
internals.licenseInfo.features || defaultFeatures[internals.licenseInfo.type];
|
||||
|
||||
const expirationTime = new Date(internals.licenseInfo.expireAt).getTime();
|
||||
if (expirationTime < new Date().getTime()) {
|
||||
return warnAndReturn('License expired. Starting in CE');
|
||||
}
|
||||
} catch (err) {
|
||||
return warnAndReturn();
|
||||
}
|
||||
|
||||
internals.isEE = true;
|
||||
return true;
|
||||
};
|
||||
|
||||
EEService.checkLicense = async () => {
|
||||
// TODO: online / DB check of the license info
|
||||
// TODO: refresh info if the DB info is outdated
|
||||
// TODO: register cron
|
||||
// internals.licenseInfo = await db.getLicense();
|
||||
};
|
||||
|
||||
Object.defineProperty(EEService, 'licenseInfo', {
|
||||
get() {
|
||||
mustHaveKey('licenseInfo');
|
||||
return internals.licenseInfo;
|
||||
},
|
||||
configurable: false,
|
||||
enumerable: false,
|
||||
});
|
||||
|
||||
Object.defineProperty(EEService, 'isEE', {
|
||||
get() {
|
||||
mustHaveKey('isEE');
|
||||
return internals.isEE;
|
||||
},
|
||||
configurable: false,
|
||||
enumerable: false,
|
||||
});
|
||||
|
||||
Object.defineProperty(EEService, 'features', {
|
||||
get() {
|
||||
return {
|
||||
isEnabled(feature) {
|
||||
return internals.licenseInfo.features.includes(feature);
|
||||
},
|
||||
getEnabled() {
|
||||
return internals.licenseInfo.features;
|
||||
},
|
||||
};
|
||||
},
|
||||
configurable: false,
|
||||
enumerable: false,
|
||||
});
|
||||
|
||||
const mustHaveKey = (key) => {
|
||||
if (!_.has(internals, key)) {
|
||||
const err = new Error('Tampering with license');
|
||||
// err.stack = null;
|
||||
throw err;
|
||||
if (!ee.licenseInfo.features) {
|
||||
ee.licenseInfo.features = defaultFeatures[ee.licenseInfo.type];
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = EEService;
|
||||
const shouldStayOffline = process.env.STRAPI_DISABLE_LICENSE_PING?.toLowerCase() === 'true';
|
||||
|
||||
const checkLicense = async (db) => {
|
||||
if (!shouldStayOffline) {
|
||||
await onlineUpdate(db);
|
||||
// TODO: Register cron, try to spread it out across projects to avoid regular request spikes
|
||||
} else if (!ee.licenseInfo.expireAt) {
|
||||
return disable('Your license does not have offline support. Starting in CE.');
|
||||
}
|
||||
|
||||
if (ee.enabled) {
|
||||
validateInfo();
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
init,
|
||||
disable,
|
||||
features: {
|
||||
isEnabled: (feature) => (ee.enabled && ee.licenseInfo.features?.includes(feature)) || false,
|
||||
getEnabled: () => (ee.enabled && Object.freeze(ee.licenseInfo.features)) || [],
|
||||
},
|
||||
checkLicense,
|
||||
get isEE() {
|
||||
return ee.enabled;
|
||||
},
|
||||
};
|
||||
|
||||
@ -126,6 +126,10 @@ class Strapi {
|
||||
return this.container.get('config');
|
||||
}
|
||||
|
||||
get EE() {
|
||||
return ee.isEE;
|
||||
}
|
||||
|
||||
get services() {
|
||||
return this.container.get('services').getAll();
|
||||
}
|
||||
@ -365,7 +369,7 @@ class Strapi {
|
||||
}
|
||||
|
||||
async register() {
|
||||
await this.loadEE();
|
||||
ee.init(this.dirs.app.root, this.log);
|
||||
|
||||
await Promise.all([
|
||||
this.loadApp(),
|
||||
@ -445,6 +449,10 @@ class Strapi {
|
||||
|
||||
await this.db.schema.sync();
|
||||
|
||||
if (this.EE) {
|
||||
await ee.checkLicense(this.db);
|
||||
}
|
||||
|
||||
await this.hook('strapi::content-types.afterSync').call({
|
||||
oldContentTypes,
|
||||
contentTypes: strapi.contentTypes,
|
||||
@ -457,8 +465,6 @@ class Strapi {
|
||||
value: strapi.contentTypes,
|
||||
});
|
||||
|
||||
await ee.checkLicense();
|
||||
|
||||
await this.startWebhooks();
|
||||
|
||||
await this.server.initMiddlewares();
|
||||
@ -473,16 +479,6 @@ class Strapi {
|
||||
return this;
|
||||
}
|
||||
|
||||
loadEE() {
|
||||
Object.defineProperty(this, 'EE', {
|
||||
get() {
|
||||
return ee({ dir: this.dirs.app.root, logger: this.log });
|
||||
},
|
||||
configurable: false,
|
||||
enumerable: false,
|
||||
});
|
||||
}
|
||||
|
||||
async load() {
|
||||
await this.register();
|
||||
await this.bootstrap();
|
||||
|
||||
@ -30,7 +30,7 @@ module.exports = async ({ buildDestDir, forceBuild = true, optimization, srcDir
|
||||
// Always remove the .cache and build folders
|
||||
await strapiAdmin.clean({ appDir: srcDir, buildDestDir });
|
||||
|
||||
ee({ dir: srcDir });
|
||||
ee.init(srcDir);
|
||||
|
||||
return strapiAdmin
|
||||
.build({
|
||||
|
||||
@ -5,12 +5,8 @@
|
||||
* You can learn more at https://docs.strapi.io/developer-docs/latest/getting-started/usage-information.html
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { scheduleJob } = require('node-schedule');
|
||||
|
||||
const ee = require('../../utils/ee');
|
||||
const wrapWithRateLimit = require('./rate-limiter');
|
||||
const createSender = require('./sender');
|
||||
const createMiddleware = require('./middleware');
|
||||
@ -46,42 +42,14 @@ const createTelemetryInstance = (strapi) => {
|
||||
strapi.server.use(createMiddleware({ sendEvent }));
|
||||
}
|
||||
},
|
||||
bootstrap() {
|
||||
// TODO: remove
|
||||
if (strapi.EE === true && ee.isEE === true) {
|
||||
const pingDisabled =
|
||||
isTruthy(process.env.STRAPI_LICENSE_PING_DISABLED) && ee.licenseInfo.type === 'gold';
|
||||
|
||||
const sendLicenseCheck = () => {
|
||||
return sendEvent(
|
||||
'didCheckLicense',
|
||||
{
|
||||
groupProperties: {
|
||||
licenseInfo: {
|
||||
...ee.licenseInfo,
|
||||
projectHash: hashProject(strapi),
|
||||
dependencyHash: hashDep(strapi),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
headers: { 'x-strapi-project': 'enterprise' },
|
||||
}
|
||||
);
|
||||
};
|
||||
bootstrap() {},
|
||||
|
||||
if (!pingDisabled) {
|
||||
const licenseCron = scheduleJob('0 0 0 * * 7', () => sendLicenseCheck());
|
||||
crons.push(licenseCron);
|
||||
|
||||
sendLicenseCheck();
|
||||
}
|
||||
}
|
||||
},
|
||||
destroy() {
|
||||
// clear open handles
|
||||
// Clear open handles
|
||||
crons.forEach((cron) => cron.cancel());
|
||||
},
|
||||
|
||||
async send(event, payload) {
|
||||
if (isDisabled) return true;
|
||||
return sendEvent(event, payload);
|
||||
@ -89,24 +57,4 @@ const createTelemetryInstance = (strapi) => {
|
||||
};
|
||||
};
|
||||
|
||||
const hash = (str) => crypto.createHash('sha256').update(str).digest('hex');
|
||||
|
||||
const hashProject = (strapi) =>
|
||||
hash(`${strapi.config.get('info.name')}${strapi.config.get('info.description')}`);
|
||||
|
||||
const hashDep = (strapi) => {
|
||||
const depStr = JSON.stringify(strapi.config.info.dependencies);
|
||||
const readmePath = path.join(strapi.dirs.app.root, 'README.md');
|
||||
|
||||
try {
|
||||
if (fs.existsSync(readmePath)) {
|
||||
return hash(`${depStr}${fs.readFileSync(readmePath)}`);
|
||||
}
|
||||
} catch (err) {
|
||||
return hash(`${depStr}`);
|
||||
}
|
||||
|
||||
return hash(`${depStr}`);
|
||||
};
|
||||
|
||||
module.exports = createTelemetryInstance;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user