diff --git a/core/server/api/v2/utils/validators/input/posts.js b/core/server/api/v2/utils/validators/input/posts.js index 450e6ccf35..ebe54ccfa4 100644 --- a/core/server/api/v2/utils/validators/input/posts.js +++ b/core/server/api/v2/utils/validators/input/posts.js @@ -1,7 +1,7 @@ -const _ = require('lodash'); const Promise = require('bluebird'); const common = require('../../../../../lib/common'); const utils = require('../../index'); +const jsonSchema = require('../utils/json-schema'); module.exports = { add(apiConfig, frame) { @@ -42,24 +42,14 @@ module.exports = { * * @TODO: remove `id` restriction in Ghost 3.0 */ - if (frame.data.posts[0].hasOwnProperty('authors')) { - if (!_.isArray(frame.data.posts[0].authors) || - (frame.data.posts[0].authors.length && _.filter(frame.data.posts[0].authors, 'id').length !== frame.data.posts[0].authors.length)) { - return Promise.reject(new common.errors.BadRequestError({ - message: common.i18n.t('errors.api.utils.invalidStructure', {key: 'posts[*].authors'}) - })); - } - } + const schema = require(`./schemas/posts-add`); + const definitions = require('./schemas/posts'); + return jsonSchema.validate(schema, definitions, frame.data); }, edit(apiConfig, frame) { - if (frame.data.posts[0].hasOwnProperty('authors')) { - if (!_.isArray(frame.data.posts[0].authors) || - (frame.data.posts[0].authors.length && _.filter(frame.data.posts[0].authors, 'id').length !== frame.data.posts[0].authors.length)) { - return Promise.reject(new common.errors.BadRequestError({ - message: common.i18n.t('errors.api.utils.invalidStructure', {key: 'posts[*].authors'}) - })); - } - } + const schema = require(`./schemas/posts-edit`); + const definitions = require('./schemas/posts'); + return jsonSchema.validate(schema, definitions, frame.data); } }; diff --git a/core/server/api/v2/utils/validators/input/schemas/posts-add.json b/core/server/api/v2/utils/validators/input/schemas/posts-add.json new file mode 100644 index 0000000000..ae5724a7c0 --- /dev/null +++ b/core/server/api/v2/utils/validators/input/schemas/posts-add.json @@ -0,0 +1,20 @@ + +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "posts.add", + "title": "posts.add", + "description": "Schema for posts.add", + "type": "object", + "additionalProperties": false, + "properties": { + "posts": { + "type": "array", + "items": { + "type": "object", + "allOf": [{"$ref": "posts#/definitions/post"}], + "required": ["title"] + } + } + }, + "required": [ "posts" ] + } diff --git a/core/server/api/v2/utils/validators/input/schemas/posts-edit.json b/core/server/api/v2/utils/validators/input/schemas/posts-edit.json new file mode 100644 index 0000000000..29dacce0a2 --- /dev/null +++ b/core/server/api/v2/utils/validators/input/schemas/posts-edit.json @@ -0,0 +1,16 @@ + +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "posts.edit", + "title": "posts.edit", + "description": "Schema for posts.edit", + "type": "object", + "additionalProperties": false, + "properties": { + "posts": { + "type": "array", + "items": {"$ref": "posts#/definitions/post"} + } + }, + "required": [ "posts" ] + } diff --git a/core/server/api/v2/utils/validators/input/schemas/posts.json b/core/server/api/v2/utils/validators/input/schemas/posts.json new file mode 100644 index 0000000000..74189a60c5 --- /dev/null +++ b/core/server/api/v2/utils/validators/input/schemas/posts.json @@ -0,0 +1,162 @@ + +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "posts", + "title": "posts", + "description": "Base posts definitions", + "definitions": { + "post": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "maxLength": 24 + }, + "title": { + "type": "string", + "maxLength": 2000 + }, + "slug": { + "type": "string", + "maxLength": 191 + }, + "mobiledoc": { + "type": ["string", "null"], + "maxLength": 1000000000 + }, + "feature_image": { + "type": ["string", "null"], + "format": "uri", + "maxLength": 2000 + }, + "featured": { + "type": "boolean" + }, + "page": { + "type": "boolean" + }, + "status": { + "type": "string", + "enum": ["published", "draft", "scheduled"] + }, + "locale": { + "type": ["string", "null"], + "maxLength": 6 + }, + "visibility": { + "type": ["string", "null"], + "enum": ["public"] + }, + "meta_title": { + "type": ["string", "null"], + "maxLength": 300 + }, + "meta_description": { + "type": ["string", "null"], + "maxLength": 500 + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "created_by": { + "type": "string", + "maxLength": 24 + }, + "updated_at": { + "type": ["string", "null"], + "format": "date-time" + }, + "updated_by": { + "type": ["string", "null"], + "maxLength": 24 + }, + "published_at": { + "type": ["string", "null"], + "format": "date-time" + }, + "published_by": { + "type": ["string", "null"], + "maxLength": 24 + }, + "custom_excerpt": { + "type": ["string", "null"], + "maxLength": 300 + }, + "codeinjection_head": { + "type": ["string", "null"], + "maxLength": 65535 + }, + "codeinjection_foot": { + "type": ["string", "null"], + "maxLength": 65535 + }, + "og_image": { + "type": ["string", "null"], + "format": "uri", + "maxLength": 2000 + }, + "og_title": { + "type": ["string", "null"], + "maxLength": 300 + }, + "og_description": { + "type": ["string", "null"], + "maxLength": 500 + }, + "twitter_image": { + "type": ["string", "null"], + "format": "uri", + "maxLength": 2000 + }, + "twitter_title": { + "type": ["string", "null"], + "maxLength": 300 + }, + "twitter_description": { + "type": ["string", "null"], + "maxLength": 500 + }, + "custom_template": { + "type": ["string", "null"], + "maxLength": 100 + }, + "authors": { + "$ref": "#/definitions/post-authors" + }, + "tags": { + "$ref": "#/definitions/post-tags" + } + } + }, + "post-authors": { + "description": "Authors of the post", + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "maxLength": 24 + } + }, + "required": ["id"] + } + }, + "post-tags": { + "description": "Tags of the post", + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "maxLength": 24 + } + }, + "required": ["id"] + } + } + } + } diff --git a/core/server/api/v2/utils/validators/utils/json-schema.js b/core/server/api/v2/utils/validators/utils/json-schema.js new file mode 100644 index 0000000000..b6ef96437b --- /dev/null +++ b/core/server/api/v2/utils/validators/utils/json-schema.js @@ -0,0 +1,24 @@ +const Ajv = require('ajv'); +const common = require('../../../../../lib/common'); + +const validate = (schema, definitions, json) => { + const ajv = new Ajv({ + allErrors: true + }); + + const validation = ajv.addSchema(definitions).compile(schema); + + validation(json); + + if (validation.errors) { + return Promise.reject(new common.errors.ValidationError({ + message: common.i18n.t('notices.data.validation.index.validationFailed', { + errorDetails: validation.errors + }) + })); + } + + return Promise.resolve(); +}; + +module.exports.validate = validate; diff --git a/core/test/unit/api/v2/utils/validators/input/posts_spec.js b/core/test/unit/api/v2/utils/validators/input/posts_spec.js index 7bb7f1eda2..6057d6c5f0 100644 --- a/core/test/unit/api/v2/utils/validators/input/posts_spec.js +++ b/core/test/unit/api/v2/utils/validators/input/posts_spec.js @@ -1,3 +1,4 @@ +const _ = require('lodash'); const should = require('should'); const sinon = require('sinon'); const Promise = require('bluebird'); @@ -10,74 +11,337 @@ describe('Unit: v2/utils/validators/input/posts', function () { }); describe('add', function () { - it('authors structure', function () { - const apiConfig = { - docName: 'posts' - }; + const apiConfig = { + docName: 'posts' + }; - const frame = { - options: {}, - data: { - posts: [ - { - authors: {} - } - ] - } - }; + describe('required fields', function () { + it('should fail with no data', function () { + const frame = { + options: {}, + data: {} + }; - return validators.input.posts.add(apiConfig, frame) - .then(Promise.reject) - .catch((err) => { - (err instanceof common.errors.BadRequestError).should.be.true(); - }); + return validators.input.posts.add(apiConfig, frame) + .then(Promise.reject) + .catch((err) => { + (err instanceof common.errors.ValidationError).should.be.true(); + }); + }); + + it('should fail with no posts', function () { + const frame = { + options: {}, + data: { + tags: [] + } + }; + + return validators.input.posts.add(apiConfig, frame) + .then(Promise.reject) + .catch((err) => { + (err instanceof common.errors.ValidationError).should.be.true(); + }); + }); + + it('should fail with more than post', function () { + const frame = { + options: {}, + data: { + posts: [], + tags: [] + } + }; + + return validators.input.posts.add(apiConfig, frame) + .then(Promise.reject) + .catch((err) => { + (err instanceof common.errors.ValidationError).should.be.true(); + }); + }); + + it('should fail without required fields', function () { + const frame = { + options: {}, + data: { + posts: [{ + what: 'a fail' + }], + } + }; + + return validators.input.posts.add(apiConfig, frame) + .then(Promise.reject) + .catch((err) => { + (err instanceof common.errors.ValidationError).should.be.true(); + }); + }); + + it('should pass with required fields', function () { + const frame = { + options: {}, + data: { + posts: [{ + title: 'pass', + authors: [{id: 'correct'}] + }], + } + }; + + return validators.input.posts.add(apiConfig, frame); + }); }); - it('authors structure', function () { - const apiConfig = { - docName: 'posts' + describe('field formats', function () { + const fieldMap = { + title: [123, new Date(), _.repeat('a', 2001)], + slug: [123, new Date(), _.repeat('a', 192)], + mobiledoc: [123, new Date()], + feature_image: [123, new Date(), 'abc'], + featured: [123, new Date(), 'abc'], + page: [123, new Date(), 'abc'], + status: [123, new Date(), 'abc'], + locale: [123, new Date(), _.repeat('a', 7)], + visibility: [123, new Date(), 'abc'], + meta_title: [123, new Date(), _.repeat('a', 301)], + meta_description: [123, new Date(), _.repeat('a', 501)], }; - const frame = { - options: {}, - data: { - posts: [ - { - authors: [{ - name: 'hey' - }] + Object.keys(fieldMap).forEach(key => { + it(`should fail for bad ${key}`, function () { + const badValues = fieldMap[key]; + + const checks = badValues.map((value) => { + const post = {}; + post[key] = value; + + if (key !== 'title') { + post.title = 'abc'; } - ] - } - }; - return validators.input.posts.add(apiConfig, frame) - .then(Promise.reject) - .catch((err) => { - (err instanceof common.errors.BadRequestError).should.be.true(); + const frame = { + options: {}, + data: { + posts: [post] + } + }; + + return validators.input.posts.add(apiConfig, frame) + .then(Promise.reject) + .catch((err) => { + (err instanceof common.errors.ValidationError).should.be.true(); + }); + }); + + return Promise.all(checks); }); + }); }); - it('authors structure', function () { - const apiConfig = { - docName: 'posts' - }; + describe('authors structure', function () { + it('should require properties', function () { + const frame = { + options: {}, + data: { + posts: [ + { + title: 'cool', + authors: {} + } + ] + } + }; - const frame = { - options: {}, - data: { - posts: [ - { - authors: [{ - id: 'correct', - name: 'ja' - }] - } - ] - } - }; + return validators.input.posts.add(apiConfig, frame) + .then(Promise.reject) + .catch((err) => { + (err instanceof common.errors.ValidationError).should.be.true(); + }); + }); - return validators.input.posts.add(apiConfig, frame); + it('should require id', function () { + const frame = { + options: {}, + data: { + posts: [ + { + title: 'cool', + authors: [{ + name: 'hey' + }] + } + ] + } + }; + + return validators.input.posts.add(apiConfig, frame) + .then(Promise.reject) + .catch((err) => { + (err instanceof common.errors.ValidationError).should.be.true(); + }); + }); + + it('should pass', function () { + const frame = { + options: {}, + data: { + posts: [ + { + title: 'cool', + authors: [{ + id: 'correct', + name: 'ja' + }] + } + ] + } + }; + + return validators.input.posts.add(apiConfig, frame); + }); + }); + }); + + describe('edit', function () { + const apiConfig = { + docName: 'posts' + }; + + describe('required fields', function () { + it('should fail with no data', function () { + const frame = { + options: {}, + data: {} + }; + + return validators.input.posts.edit(apiConfig, frame) + .then(Promise.reject) + .catch((err) => { + (err instanceof common.errors.ValidationError).should.be.true(); + }); + }); + + it('should fail with no posts', function () { + const frame = { + options: {}, + data: { + tags: [] + } + }; + + return validators.input.posts.edit(apiConfig, frame) + .then(Promise.reject) + .catch((err) => { + (err instanceof common.errors.ValidationError).should.be.true(); + }); + }); + + it('should fail with more than post', function () { + const frame = { + options: {}, + data: { + posts: [], + tags: [] + } + }; + + return validators.input.posts.edit(apiConfig, frame) + .then(Promise.reject) + .catch((err) => { + (err instanceof common.errors.ValidationError).should.be.true(); + }); + }); + + it('should pass with some fields', function () { + const frame = { + options: {}, + data: { + posts: [{ + title: 'pass' + }], + } + }; + + return validators.input.posts.edit(apiConfig, frame); + }); + }); + + describe('authors structure', function () { + it('should require properties', function () { + const frame = { + options: {}, + data: { + posts: [ + { + title: 'cool', + authors: {} + } + ] + } + }; + + return validators.input.posts.edit(apiConfig, frame) + .then(Promise.reject) + .catch((err) => { + (err instanceof common.errors.ValidationError).should.be.true(); + }); + }); + + it('should require id', function () { + const frame = { + options: {}, + data: { + posts: [ + { + title: 'cool', + authors: [{ + name: 'hey' + }] + } + ] + } + }; + + return validators.input.posts.edit(apiConfig, frame) + .then(Promise.reject) + .catch((err) => { + (err instanceof common.errors.ValidationError).should.be.true(); + }); + }); + + it('should pass with valid authors', function () { + const frame = { + options: {}, + data: { + posts: [ + { + title: 'cool', + authors: [{ + id: 'correct', + name: 'ja' + }] + } + ] + } + }; + + return validators.input.posts.edit(apiConfig, frame); + }); + + it('should pass without authors', function () { + const frame = { + options: {}, + data: { + posts: [ + { + title: 'cool' + } + ] + } + }; + + return validators.input.posts.edit(apiConfig, frame); + }); }); }); }); diff --git a/package.json b/package.json index 2a60ad3aa6..85ed65244d 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ }, "dependencies": { "@nexes/nql": "0.2.1", + "ajv": "6.8.1", "amperize": "0.3.8", "analytics-node": "3.3.0", "archiver": "3.0.0", diff --git a/yarn.lock b/yarn.lock index 679af24dbf..39700d873f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -144,6 +144,16 @@ ajv-keywords@^2.1.0: version "2.1.1" resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-2.1.1.tgz#617997fc5f60576894c435f940d819e135b80762" +ajv@6.8.1: + version "6.8.1" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.8.1.tgz#0890b93742985ebf8973cd365c5b23920ce3cb20" + integrity sha512-eqxCp82P+JfqL683wwsL73XmFs1eG6qjw+RD3YHx+Jll1r0jNd4dh8QG9NYAeNGA/hnZjeEDgtTskgJULbxpWQ== + dependencies: + fast-deep-equal "^2.0.1" + fast-json-stable-stringify "^2.0.0" + json-schema-traverse "^0.4.1" + uri-js "^4.2.2" + ajv@^5.2.3, ajv@^5.3.0: version "5.5.2" resolved "https://registry.yarnpkg.com/ajv/-/ajv-5.5.2.tgz#73b5eeca3fab653e3d3f9422b341ad42205dc965"