const nql = require('@tryghost/nql'); const _ = require('lodash'); const semver = require('semver'); const isEmail = require('validator/lib/isEmail'); const _private = {}; const packageJSONFileName = 'package.json'; const versions = require('../utils').versions; const isSnakeCase = (str) => { return /^[a-z0-9]+[a-z0-9_]*$/.test(str); }; const v1PackageJSONConditionalRules = { configPPIsInteger: 'GS010-PJ-CONF-PPP-INT' }; const v1PackageJSONValidationRules = _.extend({ isPresent: 'GS010-PJ-REQ', canBeParsed: 'GS010-PJ-PARSE', nameIsRequired: 'GS010-PJ-NAME-REQ', nameIsLowerCase: 'GS010-PJ-NAME-LC', nameIsHyphenated: 'GS010-PJ-NAME-HY', versionIsSemverCompliant: 'GS010-PJ-VERSION-SEM', versionIsRequired: 'GS010-PJ-VERSION-REQ', authorEmailIsValid: 'GS010-PJ-AUT-EM-VAL', authorEmailIsRequired: 'GS010-PJ-AUT-EM-REQ', configPPPIsRequired: 'GS010-PJ-CONF-PPP' }, v1PackageJSONConditionalRules); const v2PackageJSONConditionalRules = {}; const v2PackageJSONValidationRules = _.extend({}, {isMarkedGhostTheme: 'GS010-PJ-KEYWORDS'}, v2PackageJSONConditionalRules); const v3PackageJSONConditionalRules = {}; const v3PackageJSONValidationRules = _.extend({}, {isNotPresentEngineGhostAPI: 'GS010-PJ-GHOST-API'}, {isv01EngineGhostAPI: 'GS010-PJ-GHOST-API-V01'}, v3PackageJSONConditionalRules ); const v4PackageJSONConditionalRules = {}; const v4PackageJSONValidationRules = _.extend({}, {isv2EngineGhostAPI: 'GS010-PJ-GHOST-API-V2'}, {isPresentEngineGhostAPI: 'GS010-PJ-GHOST-API-PRESENT'}, {hasTooManyCustomThemeSettings: 'GS010-PJ-CUST-THEME-TOTAL-SETTINGS'}, {customThemeSettingsMustBeSnakecased: 'GS010-PJ-CUST-THEME-SETTINGS-CASE'}, {unknownCustomThemeSettingsType: 'GS010-PJ-CUST-THEME-SETTINGS-TYPE'}, {unknownCustomThemeSettingsGroup: 'GS010-PJ-CUST-THEME-SETTINGS-GROUP'}, {missingCustomThemeSettingsSelectOptions: 'GS010-PJ-CUST-THEME-SETTINGS-SELECT-OPTIONS'}, {missingCustomThemeSettingsSelectDefault: 'GS010-PJ-CUST-THEME-SETTINGS-SELECT-DEFAULT'}, {invalidCustomThemeSetingBooleanDefault: 'GS010-PJ-CUST-THEME-SETTINGS-BOOLEAN-DEFAULT'}, {invalidCustomThemeSetingColorDefault: 'GS010-PJ-CUST-THEME-SETTINGS-COLOR-DEFAULT'}, {invalidCustomThemeSetingImageDefault: 'GS010-PJ-CUST-THEME-SETTINGS-IMAGE-DEFAULT'}, v4PackageJSONConditionalRules ); const canaryPackageJSONConditionalRules = { invalidCustomThemeSettingVisibilitySyntax: 'GS010-PJ-CUST-THEME-SETTINGS-VISIBILITY-SYNTAX', invalidCustomThemeSettingVisibilityValue: 'GS010-PJ-CUST-THEME-SETTINGS-VISIBILITY-VALUE' }; const canaryPackageJSONValidationRules = _.extend({}, {isNotPresentCardAssets: 'GS010-PJ-GHOST-CARD-ASSETS-NOT-PRESENT'}, {invalidCustomThemeSettingDescriptionLength: 'GS010-PJ-CUST-THEME-SETTINGS-DESCRIPTION-LENGTH'}, canaryPackageJSONConditionalRules ); _private.validatePackageJSONFields = function validatePackageJSONFields(packageJSON, theme, packageJSONValidationRules) { let failed = []; const passedRulesToOmit = []; // Only add this rule to the failed list if it's defined in packageJSONValidationRules function markFailed(rule) { if (packageJSONValidationRules[rule]) { failed.push(packageJSONValidationRules[rule]); } } if (!packageJSON.name) { markFailed('nameIsRequired'); passedRulesToOmit.push('nameIsLowerCase'); passedRulesToOmit.push('nameIsHyphenated'); } if (!packageJSON.version) { markFailed('versionIsRequired'); passedRulesToOmit.push('versionIsSemverCompliant'); } if (!packageJSON.config || _.isNil(packageJSON.config.posts_per_page)) { markFailed('configPPPIsRequired'); passedRulesToOmit.push('configPPIsInteger'); } else if (!_.isNumber(packageJSON.config.posts_per_page) || packageJSON.config.posts_per_page < 1) { failed = _.without(failed, packageJSONValidationRules.configPPPIsRequired); markFailed('configPPIsInteger'); } if (!packageJSON.author || !packageJSON.author.email) { markFailed('authorEmailIsRequired'); passedRulesToOmit.push('authorEmailIsValid'); } if (!packageJSON.keywords || !_.isArray(packageJSON.keywords) || !_.includes(packageJSON.keywords, 'ghost-theme')) { markFailed('isMarkedGhostTheme'); } if (packageJSON.name && packageJSON.name !== packageJSON.name.toLowerCase()) { markFailed('nameIsLowerCase'); } if (packageJSON.name && !packageJSON.name.match(/^([a-z0-9]+-)*[a-z0-9]+$/gi)) { markFailed('nameIsHyphenated'); } if (packageJSON.version && !semver.valid(packageJSON.version)) { markFailed('versionIsSemverCompliant'); } if (packageJSON.author && packageJSON.author.email && !isEmail(packageJSON.author.email)) { markFailed('authorEmailIsValid'); } if (!packageJSON.engines || !packageJSON.engines['ghost-api']) { markFailed('isNotPresentEngineGhostAPI'); } if (packageJSON.engines && packageJSON.engines['ghost-api']) { markFailed('isPresentEngineGhostAPI'); } if (packageJSON.engines && packageJSON.engines['ghost-api']) { // NOTE: checks for same versions as were available in Ghost 2.0 (v0.1, ^0.1 etc.) // ref.: https://github.com/TryGhost/Ghost/blob/bc41550/core/frontend/services/themes/engines/create.js#L10-L16 const coerced = semver.coerce(packageJSON.engines['ghost-api']); if (coerced !== null) { const major = semver.major(coerced); if (major === 0) { markFailed('isv01EngineGhostAPI'); } if (major === 2) { markFailed('isv2EngineGhostAPI'); } } } // @TODO: validate that the card_assets config is valid if (!packageJSON.config || (_.isEmpty(packageJSON.config.card_assets) && !_.isBoolean(packageJSON.config.card_assets))) { markFailed('isNotPresentCardAssets'); } if (packageJSON.config && packageJSON.config.custom) { const customSettingsKeys = Object.keys(packageJSON.config.custom); if (customSettingsKeys.length > 20) { markFailed('hasTooManyCustomThemeSettings'); } if (customSettingsKeys.some(key => !isSnakeCase(key))) { markFailed('customThemeSettingsMustBeSnakecased'); } const knownSettingsTypes = new Set(['select', 'boolean', 'color', 'image', 'text']); if (customSettingsKeys.some(key => !knownSettingsTypes.has(packageJSON.config.custom[key].type))) { markFailed('unknownCustomThemeSettingsType'); } const knownSettingsGroups = new Set(['post', 'homepage']); // Ignore undefined values as "groups" is an optional property if (customSettingsKeys.some(key => typeof packageJSON.config.custom[key].group !== 'undefined' && !knownSettingsGroups.has(packageJSON.config.custom[key].group))) { markFailed('unknownCustomThemeSettingsGroup'); } for (const key of customSettingsKeys) { const entry = packageJSON.config.custom[key]; switch (entry.type) { case 'select': if (!Array.isArray(entry.options) || entry.options.length < 2) { markFailed('missingCustomThemeSettingsSelectOptions'); } else if (!entry.options.find(option => option === entry.default)) { markFailed('missingCustomThemeSettingsSelectDefault'); } break; case 'boolean': if (![true, false].includes(entry.default)) { markFailed('invalidCustomThemeSetingBooleanDefault'); } break; case 'color': if (!/^#[0-9a-f]{6}$/i.test(entry.default)) { markFailed('invalidCustomThemeSetingColorDefault'); } break; case 'image': if (entry.default) { markFailed('invalidCustomThemeSetingImageDefault'); } break; default: //do nothing here } if (entry.description && entry.description.length > 100) { markFailed('invalidCustomThemeSettingDescriptionLength'); } if (entry.visibility) { // Validate that the provided visibility value is valid nql try { nql(entry.visibility).parse(); } catch (err) { markFailed('invalidCustomThemeSettingVisibilitySyntax'); } // Validate that the provided visibility value only references known custom settings const customSettingKeys = Object.keys(packageJSON.config.custom); const referencedKeys = entry.visibility.match(/[a-zA-Z_][a-zA-Z0-9_.]+:/).map(k => k.slice(0, -1)); const unknownKeys = referencedKeys.filter(k => !customSettingKeys.includes(k)); if (unknownKeys.length > 0) { markFailed('invalidCustomThemeSettingVisibilityValue'); } } } } const failedRules = _private.getFailedRules(failed); _.each(failedRules, (rule, key) => { theme.results.fail[key] = {}; theme.results.fail[key].failures = [ { ref: 'package.json' } ]; }); // add intersection as passed theme.results.pass = theme.results.pass.concat(_.xor(failed, _.values(_.omit(packageJSONValidationRules, passedRulesToOmit)))); return theme; }; /** * Sucks! * The current implementation expects: * * { 'rule-code': {} } */ _private.getFailedRules = function getFailedRules(keys) { return _.zipObject(keys, _.map(keys, function () { return {}; })); }; module.exports = function checkPackageJSON(theme, options) { const checkVersion = _.get(options, 'checkVersion', versions.default); let packageJSONValidationRules; let packageJSONConditionalRules; if (checkVersion === 'v1') { packageJSONValidationRules = v1PackageJSONValidationRules; packageJSONConditionalRules = v1PackageJSONConditionalRules; } else if (checkVersion === 'v2') { packageJSONValidationRules = _.merge( {}, v1PackageJSONValidationRules, v2PackageJSONValidationRules ); packageJSONConditionalRules = _.merge( {}, v1PackageJSONConditionalRules, v2PackageJSONConditionalRules ); } else if (checkVersion === 'v3') { packageJSONValidationRules = _.merge( {}, v1PackageJSONValidationRules, v2PackageJSONValidationRules, v3PackageJSONValidationRules ); packageJSONConditionalRules = _.merge( {}, v1PackageJSONConditionalRules, v2PackageJSONConditionalRules, v3PackageJSONConditionalRules ); } else if (checkVersion === 'v5') { packageJSONValidationRules = _.merge( {}, v1PackageJSONValidationRules, v2PackageJSONValidationRules, v3PackageJSONValidationRules, v4PackageJSONValidationRules, canaryPackageJSONValidationRules ); // Make these delete more declarative as the list grows delete packageJSONValidationRules.isNotPresentEngineGhostAPI; delete packageJSONValidationRules.isv01EngineGhostAPI; delete packageJSONValidationRules.isv2EngineGhostAPI; packageJSONConditionalRules = _.merge( {}, v1PackageJSONConditionalRules, v2PackageJSONConditionalRules, v3PackageJSONConditionalRules, v4PackageJSONConditionalRules, canaryPackageJSONConditionalRules ); } else { // default check for current version 'v4' rules packageJSONValidationRules = _.merge( {}, v1PackageJSONValidationRules, v2PackageJSONValidationRules, v3PackageJSONValidationRules, v4PackageJSONValidationRules ); delete packageJSONValidationRules.isNotPresentEngineGhostAPI; packageJSONConditionalRules = _.merge( {}, v1PackageJSONConditionalRules, v2PackageJSONConditionalRules, v3PackageJSONConditionalRules, v4PackageJSONConditionalRules ); } let [packageJSON] = _.filter(theme.files, {file: packageJSONFileName}); // CASE: package.json must be valid if (packageJSON && packageJSON.content) { try { let packageJSONParsed = JSON.parse(packageJSON.content); theme = _private.validatePackageJSONFields(packageJSONParsed, theme, packageJSONValidationRules); theme.name = packageJSONParsed.name; theme.version = packageJSONParsed.version; return theme; } catch (err) { _.extend(theme.results.fail, _private.getFailedRules(_.values(_.omit(packageJSONValidationRules, ['isPresent'].concat(_.keys(packageJSONConditionalRules)))))); theme.results.fail[packageJSONValidationRules.canBeParsed].failures = [ { ref: 'package.json', message: err.message } ]; return theme; } } else { // CASE: package.json must be present (if not, all validation rules fail) const rules = _private.getFailedRules(_.values(_.omit(packageJSONValidationRules, _.keys(packageJSONConditionalRules)))); _.each(rules, (rule, key) => { theme.results.fail[key] = {}; theme.results.fail[key].failures = [ { ref: packageJSONFileName } ]; }); return theme; } };