diff --git a/ghost/collections/src/Collection.ts b/ghost/collections/src/Collection.ts index 766aaac5af..32460fa672 100644 --- a/ghost/collections/src/Collection.ts +++ b/ghost/collections/src/Collection.ts @@ -22,12 +22,24 @@ export class Collection { featureImage: string | null; createdAt: Date; updatedAt: Date; - deleted: boolean; + deletable: boolean; + _deleted: boolean = false; _posts: string[]; get posts() { return this._posts; } + + public get deleted() { + return this._deleted; + } + + public set deleted(value: boolean) { + if (this.deletable) { + this._deleted = value; + } + } + /** * @param post {{id: string}} - The post to add to the collection * @param index {number} - The index to insert the post at, use negative numbers to count from the end. @@ -69,6 +81,7 @@ export class Collection { this.featureImage = data.featureImage; this.createdAt = data.createdAt; this.updatedAt = data.updatedAt; + this.deletable = data.deletable; this.deleted = data.deleted; this._posts = data.posts; } @@ -135,6 +148,7 @@ export class Collection { createdAt: Collection.validateDateField(data.created_at, 'created_at'), updatedAt: Collection.validateDateField(data.updated_at, 'updated_at'), deleted: data.deleted || false, + deletable: (data.deletable !== false), posts: data.posts || [] }); } diff --git a/ghost/collections/src/CollectionsService.ts b/ghost/collections/src/CollectionsService.ts index 7175e78859..13944133ea 100644 --- a/ghost/collections/src/CollectionsService.ts +++ b/ghost/collections/src/CollectionsService.ts @@ -1,5 +1,14 @@ import {Collection} from './Collection'; import {CollectionRepository} from './CollectionRepository'; +import tpl from '@tryghost/tpl'; +import {MethodNotAllowedError} from '@tryghost/errors'; + +const messages = { + cannotDeleteBuiltInCollectionError: { + message: 'Cannot delete builtin collection', + context: 'The collection {id} is a builtin collection and cannot be deleted' + } +}; type CollectionsServiceDeps = { collectionsRepository: CollectionRepository; @@ -18,6 +27,7 @@ type ManualCollection = { description?: string; feature_image?: string; filter?: null; + deletable?: boolean; }; type AutomaticCollection = { @@ -27,6 +37,7 @@ type AutomaticCollection = { slug?: string; description?: string; feature_image?: string; + deletable?: boolean; }; type CollectionInputDTO = ManualCollection | AutomaticCollection; @@ -107,7 +118,8 @@ export class CollectionsService { description: data.description, type: data.type, filter: data.filter, - featureImage: data.feature_image + featureImage: data.feature_image, + deletable: data.deletable }); if (collection.type === 'automatic' && collection.filter) { @@ -218,6 +230,15 @@ export class CollectionsService { const collection = await this.getById(id); if (collection) { + if (collection.deletable === false) { + throw new MethodNotAllowedError({ + message: tpl(messages.cannotDeleteBuiltInCollectionError.message), + context: tpl(messages.cannotDeleteBuiltInCollectionError.context, { + id: collection.id + }) + }); + } + collection.deleted = true; await this.collectionsRepository.save(collection); } diff --git a/ghost/collections/test/Collection.test.ts b/ghost/collections/test/Collection.test.ts index 452fef2d30..ded24c946a 100644 --- a/ghost/collections/test/Collection.test.ts +++ b/ghost/collections/test/Collection.test.ts @@ -169,4 +169,30 @@ describe('Collection', function () { assert.equal(collection.posts.length, 0); }); + + it('Cannot set non deletable collection to deleted', async function () { + const collection = await Collection.create({ + title: 'Testing adding posts', + deletable: false + }); + + assert.equal(collection.deleted, false); + + collection.deleted = true; + + assert.equal(collection.deleted, false); + }); + + it('Can set deletable collection to deleted', async function () { + const collection = await Collection.create({ + title: 'Testing adding posts', + deletable: true + }); + + assert.equal(collection.deleted, false); + + collection.deleted = true; + + assert.equal(collection.deleted, true); + }); }); diff --git a/ghost/collections/test/collections.test.ts b/ghost/collections/test/collections.test.ts index 73a62a1887..03377ce2e1 100644 --- a/ghost/collections/test/collections.test.ts +++ b/ghost/collections/test/collections.test.ts @@ -61,6 +61,25 @@ describe('CollectionsService', function () { assert.equal(deletedCollection, null, 'Collection should be deleted'); }); + it('Throws when built in collection is attempted to be deleted', async function () { + const collection = await collectionsService.createCollection({ + title: 'Featured Posts', + slug: 'featured', + description: 'Collection of featured posts', + type: 'automatic', + deletable: false, + filter: 'featured:true' + }); + + await assert.rejects(async () => { + await collectionsService.destroy(collection.id); + }, (err: any) => { + assert.equal(err.message, 'Cannot delete builtin collection', 'Error message should match'); + assert.equal(err.context, `The collection ${collection.id} is a builtin collection and cannot be deleted`, 'Error context should match'); + return true; + }); + }); + describe('toDTO', function () { it('Can map Collection entity to DTO object', async function () { const collection = await Collection.create({}); diff --git a/ghost/core/core/server/api/endpoints/collections.js b/ghost/core/core/server/api/endpoints/collections.js index f80426dfcb..9c57dc1078 100644 --- a/ghost/core/core/server/api/endpoints/collections.js +++ b/ghost/core/core/server/api/endpoints/collections.js @@ -13,7 +13,8 @@ module.exports = { options: [ 'limit', 'order', - 'page' + 'page', + 'filter' ], // @NOTE: should have permissions when moving out of Alpha permissions: false, diff --git a/ghost/core/core/server/services/collections/built-in-collections.js b/ghost/core/core/server/services/collections/built-in-collections.js new file mode 100644 index 0000000000..af7f71a67c --- /dev/null +++ b/ghost/core/core/server/services/collections/built-in-collections.js @@ -0,0 +1,8 @@ +module.exports = [{ + title: 'Featured Posts', + slug: 'featured', + description: 'Collection of featured posts', + type: 'automatic', + deletable: false, + filter: 'featured:true' +}]; diff --git a/ghost/core/core/server/services/collections/index.js b/ghost/core/core/server/services/collections/index.js index b06809576e..ee72847a03 100644 --- a/ghost/core/core/server/services/collections/index.js +++ b/ghost/core/core/server/services/collections/index.js @@ -49,12 +49,8 @@ class CollectionsServiceWrapper { const featuredCollections = await this.api.browse({filter: 'slug:featured'}); if (!featuredCollections.data.length) { - this.api.add({ - title: 'Featured Posts', - slug: 'featured', - description: 'Collection of featured posts', - type: 'automatic', - filter: 'featured:true' + require('./built-in-collections').forEach((collection) => { + this.api.add(collection); }); } } diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/collections.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/collections.test.js.snap index e279337fc2..b35659e8ed 100644 --- a/ghost/core/test/e2e-api/admin/__snapshots__/collections.test.js.snap +++ b/ghost/core/test/e2e-api/admin/__snapshots__/collections.test.js.snap @@ -446,6 +446,37 @@ Object { } `; +exports[`Collections API Cannot delete a built in collection 1: [body] 1`] = ` +Object { + "errors": Array [ + Object { + "code": null, + "context": Any, + "details": null, + "ghostErrorCode": null, + "help": null, + "id": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "message": "Method not allowed, cannot delete collection.", + "property": null, + "type": "MethodNotAllowedError", + }, + ], +} +`; + +exports[`Collections API Cannot delete a built in collection 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "http://127.0.0.1:2369", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "355", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + exports[`Collections API Edit Can add Posts and append Post to a Collection 1: [body] 1`] = ` Object { "collections": Array [ diff --git a/ghost/core/test/e2e-api/admin/collections.test.js b/ghost/core/test/e2e-api/admin/collections.test.js index 4e7a6055c1..001edf013c 100644 --- a/ghost/core/test/e2e-api/admin/collections.test.js +++ b/ghost/core/test/e2e-api/admin/collections.test.js @@ -12,7 +12,8 @@ const { anyLocationFor, anyObjectId, anyISODateTime, - anyNumber + anyNumber, + anyString } = matchers; const matchCollection = { @@ -333,6 +334,29 @@ describe('Collections API', function () { }); }); + it('Cannot delete a built in collection', async function () { + const builtInCollection = await agent + .get('/collections/?filter=slug:featured') + .expectStatus(200); + + assert.ok(builtInCollection.body.collections); + assert.equal(builtInCollection.body.collections.length, 1); + + await agent + .delete(`/collections/${builtInCollection.body.collections[0].id}/`) + .expectStatus(405) + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + etag: anyEtag + }) + .matchBodySnapshot({ + errors: [{ + id: anyErrorId, + context: anyString + }] + }); + }); + describe('Automatic Collection Filtering', function () { it('Creates an automatic Collection with a featured filter', async function () { const collection = {