Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
5 changed files
with
189 additions
and
166 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 internals = {}; | ||
const defaultFeatures = { | ||
bronze: [], | ||
silver: [], | ||
gold: ['sso'], | ||
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 EEService = ({ dir, logger = noLog }) => { | ||
if (_.has(internals, 'isEE')) return internals.isEE; | ||
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}`); | ||
|
||
const warnAndReturn = (msg = 'Invalid license. Starting in CE.') => { | ||
logger.warn(msg); | ||
internals.isEE = false; | ||
return false; | ||
}; | ||
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.' | ||
); | ||
} | ||
|
||
if (process.env.STRAPI_DISABLE_EE === 'true') { | ||
internals.isEE = false; | ||
return false; | ||
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 }; | ||
}; | ||
|
||
const licensePath = path.join(dir, 'license.txt'); | ||
let initialized = false; | ||
|
||
let license; | ||
if (_.has(process.env, 'STRAPI_LICENSE')) { | ||
license = process.env.STRAPI_LICENSE; | ||
} else if (fs.existsSync(licensePath)) { | ||
license = fs.readFileSync(licensePath).toString(); | ||
const init = (licenseDir, logger) => { | ||
// Can only be executed once, to prevent any abuse of the optimistic behavior | ||
if (initialized) { | ||
return; | ||
} | ||
|
||
if (_.isNil(license)) { | ||
internals.isEE = false; | ||
return false; | ||
initialized = true; | ||
ee.logger = logger; | ||
|
||
if (process.env.STRAPI_DISABLE_EE?.toLowerCase() === 'true') { | ||
return; | ||
} | ||
|
||
// TODO: optimistically return true if license key is valid | ||
const license = process.env.STRAPI_LICENSE || readLicense(licenseDir); | ||
|
||
try { | ||
const plainLicense = Buffer.from(license, 'base64').toString(); | ||
const [signatureb64, contentb64] = plainLicense.split('\n'); | ||
if (license) { | ||
const { verified, licenseInfo } = verifyLicense(license); | ||
|
||
if (verified) { | ||
ee.enabled = true; // Optimistically enable EE during initialization | ||
ee.licenseInfo = licenseInfo; | ||
} else { | ||
return disable(); | ||
} | ||
} | ||
}; | ||
|
||
const signature = Buffer.from(signatureb64, 'base64'); | ||
const content = Buffer.from(contentb64, 'base64').toString(); | ||
const oneMinute = 1000 * 60; | ||
|
||
const verifier = crypto.createVerify('RSA-SHA256'); | ||
verifier.update(content); | ||
verifier.end(); | ||
const onlineUpdate = async (db) => { | ||
const transaction = await db.transaction(); | ||
|
||
const isValid = verifier.verify(publicKey, signature); | ||
if (!isValid) return warnAndReturn(); | ||
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(); | ||
} | ||
} | ||
|
||
internals.licenseInfo = JSON.parse(content); | ||
internals.licenseInfo.features = | ||
internals.licenseInfo.features || defaultFeatures[internals.licenseInfo.type]; | ||
if (!useStoredLicense) { | ||
const value = { license, lastOnlineCheck: Date.now() }; | ||
const query = db.queryBuilder(coreStoreModel.uid).transacting(transaction); | ||
|
||
const expirationTime = new Date(internals.licenseInfo.expireAt).getTime(); | ||
if (expirationTime < new Date().getTime()) { | ||
return warnAndReturn('License expired. Starting in CE'); | ||
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(); | ||
} | ||
} catch (err) { | ||
return warnAndReturn(); | ||
|
||
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); | ||
} | ||
}; | ||
|
||
internals.isEE = true; | ||
return true; | ||
const defaultFeatures = { | ||
bronze: [], | ||
silver: [], | ||
gold: ['sso'], | ||
}; | ||
|
||
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(); | ||
const validateInfo = () => { | ||
if (ee.licenseInfo.expireAt) { | ||
return; | ||
} | ||
|
||
const expirationTime = new Date(ee.licenseInfo.expireAt).getTime(); | ||
|
||
if (expirationTime < new Date().getTime()) { | ||
return disable('License expired. Starting in CE.'); | ||
} | ||
|
||
ee.enabled = true; | ||
|
||
if (!ee.licenseInfo.features) { | ||
ee.licenseInfo.features = defaultFeatures[ee.licenseInfo.type]; | ||
} | ||
}; | ||
|
||
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; | ||
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 = EEService; | ||
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; | ||
}, | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.