diff --git a/packages/strapi-plugin-content-manager/config/routes.json b/packages/strapi-plugin-content-manager/config/routes.json index 5b3cb35569..f541406c10 100644 --- a/packages/strapi-plugin-content-manager/config/routes.json +++ b/packages/strapi-plugin-content-manager/config/routes.json @@ -86,12 +86,83 @@ } }, + { + "method": "GET", + "path": "/single-types/:model", + "handler": "single-types.find", + "config": { + "policies": [ + "routing", + "admin::isAuthenticatedAdmin", + ["plugins::content-manager.hasPermissions", ["plugins::content-manager.explorer.read"]] + ] + } + }, + { + "method": "PUT", + "path": "/single-types/:model", + "handler": "single-types.createOrUpdate", + "config": { + "policies": [ + "routing", + "admin::isAuthenticatedAdmin", + [ + "plugins::content-manager.hasPermissions", + [ + "plugins::content-manager.explorer.create", + "plugins::content-manager.explorer.update" + ], + { "hasAtLeastOne": true } + ] + ] + } + }, + { + "method": "DELETE", + "path": "/single-types/:model", + "handler": "single-types.delete", + "config": { + "policies": [ + "routing", + "admin::isAuthenticatedAdmin", + ["plugins::content-manager.hasPermissions", ["plugins::content-manager.explorer.delete"]] + ] + } + }, + { + "method": "POST", + "path": "/single-types/:model/actions/publish", + "handler": "single-types.publish", + "config": { + "policies": [ + "routing", + "plugins::content-manager.has-draft-and-publish", + "admin::isAuthenticatedAdmin", + ["plugins::content-manager.hasPermissions", ["plugins::content-manager.explorer.publish"]] + ] + } + }, + { + "method": "POST", + "path": "/single-types/:model/actions/unpublish", + "handler": "single-types.unpublish", + "config": { + "policies": [ + "routing", + "plugins::content-manager.has-draft-and-publish", + "admin::isAuthenticatedAdmin", + ["plugins::content-manager.hasPermissions", ["plugins::content-manager.explorer.publish"]] + ] + } + }, + { "method": "GET", "path": "/explorer/:model", "handler": "ContentManager.find", "config": { "policies": ["routing", "admin::isAuthenticatedAdmin"] + } }, { diff --git a/packages/strapi-plugin-content-manager/controllers/__tests__/single-types.test.js b/packages/strapi-plugin-content-manager/controllers/__tests__/single-types.test.js new file mode 100644 index 0000000000..e6293cb943 --- /dev/null +++ b/packages/strapi-plugin-content-manager/controllers/__tests__/single-types.test.js @@ -0,0 +1,61 @@ +'use strict'; + +const createContext = require('../../../../test/helpers/create-context'); +const singleTypes = require('../single-types'); +const { ACTIONS } = require('../constants'); + +describe('Single Types', () => { + test('find', async () => { + const state = { + userAbility: { + can: jest.fn(), + cannot: jest.fn(() => false), + }, + }; + + const notFound = jest.fn(); + const createPermissionsManager = jest.fn(() => ({ + ability: state.userAbility, + })); + + global.strapi = { + admin: { + services: { + permission: { + createPermissionsManager, + }, + }, + }, + plugins: { + 'content-manager': { + services: { + 'single-types': { + fetchEntitiyWithCreatorRoles() { + return null; + }, + }, + }, + }, + }, + entityService: { + find: jest.fn(), + }, + }; + + const modelUid = 'test-model'; + + const ctx = createContext( + { + params: { + model: modelUid, + }, + }, + { state, notFound } + ); + + await singleTypes.find(ctx); + + expect(state.userAbility.cannot).toHaveBeenCalledWith(ACTIONS.create, modelUid); + expect(notFound).toHaveBeenCalled(); + }); +}); diff --git a/packages/strapi-plugin-content-manager/controllers/constants.js b/packages/strapi-plugin-content-manager/controllers/constants.js new file mode 100644 index 0000000000..36838a8e59 --- /dev/null +++ b/packages/strapi-plugin-content-manager/controllers/constants.js @@ -0,0 +1,13 @@ +'use strict'; + +const ACTIONS = { + read: 'plugins::content-manager.explorer.read', + create: 'plugins::content-manager.explorer.create', + edit: 'plugins::content-manager.explorer.update', + delete: 'plugins::content-manager.explorer.delete', + publish: 'plugins::content-manager.explorer.publish', +}; + +module.exports = { + ACTIONS, +}; diff --git a/packages/strapi-plugin-content-manager/controllers/single-types.js b/packages/strapi-plugin-content-manager/controllers/single-types.js new file mode 100644 index 0000000000..c6cbaaf0cc --- /dev/null +++ b/packages/strapi-plugin-content-manager/controllers/single-types.js @@ -0,0 +1,214 @@ +'use strict'; + +const { prop, pipe, assoc, assign } = require('lodash/fp'); +const { contentTypes: contentTypesUtils } = require('strapi-utils'); +const { getService } = require('../utils'); +const parseBody = require('../utils/parse-body'); +const { ACTIONS } = require('./constants'); + +const { + CREATED_BY_ATTRIBUTE, + UPDATED_BY_ATTRIBUTE, + PUBLISHED_AT_ATTRIBUTE, +} = contentTypesUtils.constants; + +const pickPermittedFields = ({ pm, action, model }) => data => { + return pm.pickPermittedFieldsOf(data, { action, subject: model }); +}; + +const setCreatorFields = ({ user, isEdition = false }) => data => { + if (isEdition) { + return assoc(UPDATED_BY_ATTRIBUTE, user.id, data); + } + + return assign(data, { + [CREATED_BY_ATTRIBUTE]: user.id, + [UPDATED_BY_ATTRIBUTE]: user.id, + }); +}; + +module.exports = { + async find(ctx) { + const { userAbility } = ctx.state; + const { model } = ctx.params; + + const pm = strapi.admin.services.permission.createPermissionsManager( + userAbility, + ACTIONS.read, + model + ); + + const singleTypeService = getService('single-types'); + + const entity = await singleTypeService.fetchEntitiyWithCreatorRoles(model); + + // allow user with create permission to know a single type is not created + if (!entity) { + if (pm.ability.cannot(ACTIONS.create, model)) { + return ctx.forbidden(); + } + + return ctx.notFound(); + } + + if (pm.ability.cannot(ACTIONS.read, pm.toSubject(entity))) { + return ctx.forbidden(); + } + + ctx.body = pm.sanitize(entity, { action: ACTIONS.read }); + }, + + async createOrUpdate(ctx) { + const { user, userAbility } = ctx.state; + const { model } = ctx.params; + + const { data, files } = parseBody(ctx); + + const singleTypeService = getService('single-types'); + const existingEntity = await singleTypeService.fetchEntitiyWithCreatorRoles(model); + + try { + if (!existingEntity) { + const pm = strapi.admin.services.permission.createPermissionsManager( + userAbility, + ACTIONS.create, + model + ); + + const sanitizedData = pipe([ + pickPermittedFields({ pm, action: ACTIONS.create, model }), + setCreatorFields({ user }), + ])(data); + + const entity = await singleTypeService.create({ data: sanitizedData, files }, { model }); + + ctx.body = pm.sanitize(entity, { action: ACTIONS.read }); + return; + } + + const pm = strapi.admin.services.permission.createPermissionsManager( + userAbility, + ACTIONS.edit, + model + ); + + if (pm.ability.cannot(ACTIONS.edit, pm.toSubject(existingEntity))) { + return strapi.errors.forbidden(); + } + + const sanitizedData = pipe([ + pickPermittedFields({ pm, action: ACTIONS.edit, model: pm.toSubject(existingEntity) }), + setCreatorFields({ user, isEdition: true }), + ])(data); + + const entity = await singleTypeService.update( + existingEntity, + { data: sanitizedData, files }, + { model } + ); + + ctx.body = pm.sanitize(entity, { action: ACTIONS.read }); + } catch (error) { + strapi.log.error(error); + ctx.badRequest(null, [ + { + messages: [{ id: error.message, message: error.message, field: error.field }], + errors: prop('data.errors', error), + }, + ]); + } + }, + + async delete(ctx) { + const { userAbility } = ctx.state; + const { model } = ctx.params; + + const singleTypeService = getService('single-types'); + + const existingEntity = await singleTypeService.fetchEntitiyWithCreatorRoles(model); + + const pm = strapi.admin.services.permission.createPermissionsManager( + userAbility, + ACTIONS.delete, + model + ); + + if (pm.ability.cannot(ACTIONS.delete, pm.toSubject(existingEntity))) { + return strapi.errors.forbidden(); + } + + const deletedEntity = await singleTypeService.delete(existingEntity, { userAbility, model }); + + ctx.body = pm.sanitize(deletedEntity, { action: ACTIONS.read }); + }, + + async publish(ctx) { + const { userAbility } = ctx.state; + const { model } = ctx.params; + + const singleTypeService = getService('single-types'); + + const existingEntity = await singleTypeService.fetchEntitiyWithCreatorRoles(model); + + if (!existingEntity) { + return ctx.notFound(); + } + + const pm = strapi.admin.services.permission.createPermissionsManager( + userAbility, + ACTIONS.publish, + model + ); + + if (pm.ability.cannot(ACTIONS.publish, pm.toSubject(existingEntity))) { + return ctx.forbidden(); + } + + await strapi.entityValidator.validateEntityCreation(strapi.getModel(model), existingEntity); + + if (existingEntity[PUBLISHED_AT_ATTRIBUTE]) { + return ctx.badRequest('Already published'); + } + + const publishedEntry = await getService('contentmanager').publish( + { id: existingEntity.id }, + model + ); + + ctx.body = pm.sanitize(publishedEntry, { action: ACTIONS.read }); + }, + + async unpublish(ctx) { + const { userAbility } = ctx.state; + const { model } = ctx.params; + + const singleTypeService = getService('single-types'); + + const existingEntity = await singleTypeService.fetchEntitiyWithCreatorRoles(model); + + if (!existingEntity) { + return ctx.notFound(); + } + + const pm = strapi.admin.services.permission.createPermissionsManager( + userAbility, + ACTIONS.publish, + model + ); + + if (pm.ability.cannot(ACTIONS.publish, pm.toSubject(existingEntity))) { + return ctx.forbidden(); + } + + if (!existingEntity[PUBLISHED_AT_ATTRIBUTE]) { + return ctx.badRequest('Already a draft'); + } + + const unpublishedEntry = await getService('contentmanager').unpublish( + { id: existingEntity.id }, + model + ); + + ctx.body = pm.sanitize(unpublishedEntry, { action: ACTIONS.read }); + }, +}; diff --git a/packages/strapi-plugin-content-manager/oas.yml b/packages/strapi-plugin-content-manager/oas.yml index a68e24fdbd..fdb68b0e4d 100644 --- a/packages/strapi-plugin-content-manager/oas.yml +++ b/packages/strapi-plugin-content-manager/oas.yml @@ -305,9 +305,7 @@ paths: type: object properties: id: - oneOf: - - type: string - - type: integer + $ref: '#/components/schemas/id' '[primaryKey]': oneOf: - type: string @@ -338,47 +336,129 @@ paths: tags: - Collection Types content management description: Get one entry + responses: + 200: + content: + application/json: + schema: + $ref: '#/components/schemas/entity' put: tags: - Collection Types content management description: Update one entry + responses: + 200: + content: + application/json: + schema: + $ref: '#/components/schemas/entity' delete: tags: - Collection Types content management description: Delete one entry + responses: + 200: + content: + application/json: + schema: + $ref: '#/components/schemas/entity' /content-manager/collection-type/{model}/{id}/actions/publish: post: tags: - Collection Types content management description: Publish one entry + responses: + 200: + content: + application/json: + schema: + $ref: '#/components/schemas/entity' /content-manager/collection-type/{model}/{id}/actions/unpublish: post: tags: - Collection Types content management description: Unpublish one entry - + responses: + 200: + content: + application/json: + schema: + $ref: '#/components/schemas/entity' # Single type - /content-manager/single-type/{model}: + /content-manager/single-types/{model}: get: tags: - Single Types content management + responses: + 200: + content: + application/json: + schema: + $ref: '#/components/schemas/entity' put: tags: - Single Types content management + responses: + 200: + content: + application/json: + schema: + $ref: '#/components/schemas/entity' delete: tags: - Single Types content management - /content-manager/single-type/{model}/actions/publish: + responses: + 200: + content: + application/json: + schema: + $ref: '#/components/schemas/entity' + + /content-manager/single-types/{model}/actions/publish: post: tags: - Single Types content management - /content-manager/single-type/{model}/actions/unpublish: + responses: + 200: + content: + application/json: + schema: + $ref: '#/components/schemas/entity' + + /content-manager/single-types/{model}/actions/unpublish: post: tags: - Single Types content management + responses: + 200: + content: + application/json: + schema: + $ref: '#/components/schemas/entity' components: schemas: + id: + oneOf: + - type: string + - type: integer + + entity: + type: object + required: + - id + - created_by + - updated_by + properties: + id: + $ref: '#/components/schemas/id' + created_by: + $ref: '#/components/schemas/user' + updated_by: + $ref: '#/components/schemas/user' + additionalProperties: + type: any + contentType: type: object properties: @@ -521,6 +601,20 @@ components: - singleType - collectionType + user: + type: object + properties: + id: + oneOf: + - type: integer + - type: string + firstname: + type: string + lastname: + type: string + email: + type: string + examples: restaurant: uid: application::restaurant.restaurant diff --git a/packages/strapi-plugin-content-manager/services/ContentManager.js b/packages/strapi-plugin-content-manager/services/ContentManager.js index 0268e6bbd4..e5cbc2dae0 100644 --- a/packages/strapi-plugin-content-manager/services/ContentManager.js +++ b/packages/strapi-plugin-content-manager/services/ContentManager.js @@ -13,7 +13,7 @@ const { ENTRY_PUBLISH, ENTRY_UNPUBLISH } = webhookUtils.webhookEvents; * A set of functions called "actions" for `ContentManager` */ module.exports = { - fetchAll(model, query) { + fetchAll(model, query = {}) { const { query: request, populate, ...filters } = query; const queryFilter = !_.isEmpty(request) @@ -113,6 +113,7 @@ module.exports = { { params, data: { [PUBLISHED_AT_ATTRIBUTE]: null } }, { model } ); + strapi.eventHub.emit(ENTRY_UNPUBLISH, { model: modelDef.modelName, entry: sanitizeEntity(unpublishedEntry, { model: modelDef }), diff --git a/packages/strapi-plugin-content-manager/services/single-types.js b/packages/strapi-plugin-content-manager/services/single-types.js new file mode 100644 index 0000000000..c5bde331a0 --- /dev/null +++ b/packages/strapi-plugin-content-manager/services/single-types.js @@ -0,0 +1,66 @@ +'use strict'; + +const { omit, prop, has, assoc } = require('lodash/fp'); +const { contentTypes: contentTypesUtils } = require('strapi-utils'); +const { getService } = require('../utils'); + +const { CREATED_BY_ATTRIBUTE } = contentTypesUtils.constants; + +const pickWritableFields = ({ model }) => { + return omit(contentTypesUtils.getNonWritableAttributes(strapi.getModel(model))); +}; + +const fetchCreatorRoles = entity => { + const createdByPath = `${CREATED_BY_ATTRIBUTE}.id`; + + if (has(createdByPath, entity)) { + const creatorId = prop(createdByPath, entity); + return strapi.query('role', 'admin').find({ 'users.id': creatorId }, []); + } + + return []; +}; + +module.exports = { + async fetchEntitiyWithCreatorRoles(model) { + const entity = await getService('contentmanager').fetchAll(model); + + if (!entity) { + return entity; + } + + const roles = await fetchCreatorRoles(entity); + return assoc(`${CREATED_BY_ATTRIBUTE}.roles`, roles, entity); + }, + + async create(body, { model }) { + const { files } = body; + + const data = pickWritableFields({ model })(body.data); + + const entity = await getService('contentmanager').create({ data, files }, { model }); + + await strapi.telemetry.send('didCreateFirstContentTypeEntry', { model }); + return entity; + }, + + async update(existingEntity, body, { model }) { + const { files } = body; + + const data = pickWritableFields({ model })(body.data); + + const entity = await getService('contentmanager').edit( + { id: existingEntity.id }, + { data, files }, + { model } + ); + + return entity; + }, + + async delete(existingEntity, { model }) { + return getService('contentmanager').delete(model, { + id: existingEntity.id, + }); + }, +}; diff --git a/packages/strapi-plugin-content-manager/test/single-type.test.e2e.js b/packages/strapi-plugin-content-manager/test/single-type.test.e2e.js index 0a86ae0698..38b6f05449 100644 --- a/packages/strapi-plugin-content-manager/test/single-type.test.e2e.js +++ b/packages/strapi-plugin-content-manager/test/single-type.test.e2e.js @@ -31,7 +31,7 @@ describe('Content Manager single types', () => { test('Label is not pluralized', async () => { const res = await rq({ - url: `/content-manager/schemas/content-types?kind=singleType`, + url: `/content-manager/content-types?kind=singleType`, method: 'GET', }); @@ -39,7 +39,9 @@ describe('Content Manager single types', () => { expect(res.body.data).toEqual( expect.arrayContaining([ expect.objectContaining({ - label: 'Single-type-model', + info: expect.objectContaining({ + label: 'Single-type-model', + }), }), ]) ); @@ -47,7 +49,7 @@ describe('Content Manager single types', () => { test('find single type content returns 404 when not created', async () => { const res = await rq({ - url: `/content-manager/explorer/${uid}`, + url: `/content-manager/single-types/${uid}`, method: 'GET', }); @@ -56,8 +58,8 @@ describe('Content Manager single types', () => { test('Create content', async () => { const res = await rq({ - url: `/content-manager/explorer/${uid}`, - method: 'POST', + url: `/content-manager/single-types/${uid}`, + method: 'PUT', body: { title: 'Title', }, @@ -72,7 +74,7 @@ describe('Content Manager single types', () => { test('find single type content returns an object ', async () => { const res = await rq({ - url: `/content-manager/explorer/${uid}`, + url: `/content-manager/single-types/${uid}`, method: 'GET', }); diff --git a/packages/strapi-plugin-content-manager/utils/index.js b/packages/strapi-plugin-content-manager/utils/index.js index bc7ebc0f6c..a60d39c087 100644 --- a/packages/strapi-plugin-content-manager/utils/index.js +++ b/packages/strapi-plugin-content-manager/utils/index.js @@ -3,7 +3,7 @@ const { prop } = require('lodash/fp'); module.exports = { - // retrieve a local service from the contet manager plugin to make the code more readable + // retrieve a local service getService(name) { return prop(`content-manager.services.${name}`, strapi.plugins); }, diff --git a/packages/strapi-plugin-content-manager/utils/parse-body.js b/packages/strapi-plugin-content-manager/utils/parse-body.js new file mode 100644 index 0000000000..bca016ed18 --- /dev/null +++ b/packages/strapi-plugin-content-manager/utils/parse-body.js @@ -0,0 +1,8 @@ +'use strict'; + +const parseMultipartBody = require('./parse-multipart'); + +module.exports = ctx => { + const { body } = ctx.request; + return ctx.is('multipart') ? parseMultipartBody(ctx) : { data: body }; +}; diff --git a/scripts/open-api/public/index.html b/scripts/open-api/public/index.html index 76eb660d7e..5b6f28fbb9 100644 --- a/scripts/open-api/public/index.html +++ b/scripts/open-api/public/index.html @@ -20,8 +20,8 @@ nav-accent-color="" primary-color="" theme="dark" - schema-style="table" + default-schema-tab="example" >