diff --git a/ghost/core/core/server/models/mention.js b/ghost/core/core/server/models/mention.js index f7294c6b84..d82eb41a67 100644 --- a/ghost/core/core/server/models/mention.js +++ b/ghost/core/core/server/models/mention.js @@ -4,6 +4,9 @@ const Mention = ghostBookshelf.Model.extend({ tableName: 'mentions', defaults: { deleted: false + }, + enforcedFilters() { + return 'deleted:false'; } }); diff --git a/ghost/core/core/server/services/mentions/BookshelfMentionRepository.js b/ghost/core/core/server/services/mentions/BookshelfMentionRepository.js index 168d9899c9..d697e294be 100644 --- a/ghost/core/core/server/services/mentions/BookshelfMentionRepository.js +++ b/ghost/core/core/server/services/mentions/BookshelfMentionRepository.js @@ -106,7 +106,8 @@ module.exports = class BookshelfMentionRepository { target: mention.target.href, resource_id: mention.resourceId?.toHexString(), resource_type: mention.resourceId ? 'post' : null, - payload: mention.payload ? JSON.stringify(mention.payload) : null + payload: mention.payload ? JSON.stringify(mention.payload) : null, + deleted: Mention.isDeleted(mention) }; const existing = await this.#MentionModel.findOne({id: data.id}, {require: false}); diff --git a/ghost/data-generator/lib/tables/mentions.js b/ghost/data-generator/lib/tables/mentions.js index 29608747ec..43396ff3c9 100644 --- a/ghost/data-generator/lib/tables/mentions.js +++ b/ghost/data-generator/lib/tables/mentions.js @@ -52,7 +52,8 @@ class WebMentionsImporter extends TableImporter { created_at: dateToDatabaseString(timestamp), payload: JSON.stringify({ // TODO: Add some random payload - }) + }), + deleted: Math.floor(Math.random() * 2) ? true : false }; } } diff --git a/ghost/webmentions/lib/InMemoryMentionRepository.js b/ghost/webmentions/lib/InMemoryMentionRepository.js index b9551b2a76..d3d8ac8777 100644 --- a/ghost/webmentions/lib/InMemoryMentionRepository.js +++ b/ghost/webmentions/lib/InMemoryMentionRepository.js @@ -1,4 +1,5 @@ const nql = require('@tryghost/nql'); +const Mention = require('./Mention'); /** * @typedef {import('./Mention')} Mention @@ -55,7 +56,7 @@ module.exports = class InMemoryMentionRepository { */ async getBySourceAndTarget(source, target) { return this.#store.find((item) => { - return item.source.href === source.href && item.target.href === target.href; + return item.source.href === source.href && item.target.href === target.href && !Mention.isDeleted(item); }); } @@ -72,7 +73,7 @@ module.exports = class InMemoryMentionRepository { const data = this.#store.slice(); const results = data.slice().filter((item) => { - return filter.queryJSON(this.toPrimitive(item)); + return filter.queryJSON(this.toPrimitive(item)) && !Mention.isDeleted(item); }); if (options.order === 'created_at desc') { diff --git a/ghost/webmentions/lib/Mention.js b/ghost/webmentions/lib/Mention.js index e0d7c21ca4..8a989c66d1 100644 --- a/ghost/webmentions/lib/Mention.js +++ b/ghost/webmentions/lib/Mention.js @@ -78,6 +78,11 @@ module.exports = class Mention { return this.#sourceFeaturedImage; } + #deleted = false; + delete() { + this.#deleted = true; + } + toJSON() { return { id: this.id, @@ -224,6 +229,14 @@ module.exports = class Mention { } return mention; } + + /** + * @param {Mention} mention + * @returns {boolean} + */ + static isDeleted(mention) { + return mention.#deleted; + } }; function validateString(value, maxlength, name) { diff --git a/ghost/webmentions/lib/MentionsAPI.js b/ghost/webmentions/lib/MentionsAPI.js index 9f52cd2d29..9304d6e70f 100644 --- a/ghost/webmentions/lib/MentionsAPI.js +++ b/ghost/webmentions/lib/MentionsAPI.js @@ -133,23 +133,35 @@ module.exports = class MentionsAPI { * @returns {Promise} */ async processWebmention(webmention) { - const targetExists = await this.#routingService.pageExists(webmention.target); - - if (!targetExists) { - throw new errors.BadRequestError({ - message: `${webmention.target} is not a valid URL for this site.` - }); - } - - const resourceInfo = await this.#resourceService.getByURL(webmention.target); - - const metadata = await this.#webmentionMetadata.fetch(webmention.source); - let mention = await this.#repository.getBySourceAndTarget( webmention.source, webmention.target ); + const targetExists = await this.#routingService.pageExists(webmention.target); + + if (!targetExists) { + if (!mention) { + throw new errors.BadRequestError({ + message: `${webmention.target} is not a valid URL for this site.` + }); + } else { + mention.delete(); + } + } + + const resourceInfo = await this.#resourceService.getByURL(webmention.target); + + let metadata; + try { + metadata = await this.#webmentionMetadata.fetch(webmention.source); + } catch (err) { + if (!mention) { + throw err; + } + mention.delete(); + } + if (!mention) { mention = await Mention.create({ source: webmention.source, diff --git a/ghost/webmentions/test/InMemoryMentionRepository.test.js b/ghost/webmentions/test/InMemoryMentionRepository.test.js index b702a042a6..f87476cad5 100644 --- a/ghost/webmentions/test/InMemoryMentionRepository.test.js +++ b/ghost/webmentions/test/InMemoryMentionRepository.test.js @@ -76,4 +76,35 @@ describe('InMemoryMentionRepository', function () { assert(pageThree.meta.pagination.prev === 2); assert(pageThree.meta.pagination.next === null); }); + + describe(`GetPage`, function () { + it(`Doesn't return deleted mentions`, async function () { + const repository = new InMemoryMentionRepository(); + + const validInput = { + source: 'https://source.com', + target: 'https://target.com', + sourceTitle: 'Title!', + sourceExcerpt: 'Excerpt!' + }; + + const mentions = await Promise.all([ + Mention.create(validInput), + Mention.create(validInput) + ]); + + for (const mention of mentions) { + await repository.save(mention); + } + + const pageOne = await repository.getPage({page: 1, limit: 'all'}); + assert(pageOne.meta.pagination.total === 2); + + mentions[0].delete(); + await repository.save(mentions[0]); + + const pageTwo = await repository.getPage({page: 1, limit: 'all'}); + assert(pageTwo.meta.pagination.total === 1); + }); + }); }); diff --git a/ghost/webmentions/test/MentionsAPI.test.js b/ghost/webmentions/test/MentionsAPI.test.js index 2417dedac4..499e99d008 100644 --- a/ghost/webmentions/test/MentionsAPI.test.js +++ b/ghost/webmentions/test/MentionsAPI.test.js @@ -285,4 +285,136 @@ describe('MentionsAPI', function () { assert.equal(page.data[0].id, mention.id); }); + + it('Will delete an existing mention if the target page does not exist', async function () { + const repository = new InMemoryMentionRepository(); + const api = new MentionsAPI({ + repository, + routingService: { + pageExists: sinon.stub().onFirstCall().resolves(true).onSecondCall().resolves(false) + }, + resourceService: { + async getByURL() { + return { + type: 'post', + id: new ObjectID + }; + } + }, + webmentionMetadata: mockWebmentionMetadata + }); + + checkFirstMention: { + const mention = await api.processWebmention({ + source: new URL('https://source.com'), + target: new URL('https://target.com'), + payload: {} + }); + + const page = await api.listMentions({ + limit: 'all' + }); + + assert.equal(page.data[0].id, mention.id); + break checkFirstMention; + } + + checkMentionDeleted: { + await api.processWebmention({ + source: new URL('https://source.com'), + target: new URL('https://target.com'), + payload: {} + }); + + const page = await api.listMentions({ + limit: 'all' + }); + + assert.equal(page.data.length, 0); + break checkMentionDeleted; + } + }); + + it('Will delete an existing mention if the source page does not exist', async function () { + const repository = new InMemoryMentionRepository(); + const api = new MentionsAPI({ + repository, + routingService: mockRoutingService, + resourceService: { + async getByURL() { + return { + type: 'post', + id: new ObjectID + }; + } + }, + webmentionMetadata: { + fetch: sinon.stub() + .onFirstCall().resolves(mockWebmentionMetadata.fetch()) + .onSecondCall().rejects() + } + }); + + checkFirstMention: { + const mention = await api.processWebmention({ + source: new URL('https://source.com'), + target: new URL('https://target.com'), + payload: {} + }); + + const page = await api.listMentions({ + limit: 'all' + }); + + assert.equal(page.data[0].id, mention.id); + break checkFirstMention; + } + + checkMentionDeleted: { + await api.processWebmention({ + source: new URL('https://source.com'), + target: new URL('https://target.com'), + payload: {} + }); + + const page = await api.listMentions({ + limit: 'all' + }); + + assert.equal(page.data.length, 0); + break checkMentionDeleted; + } + }); + + it('Will throw for new mentions if the source page is not found', async function () { + const repository = new InMemoryMentionRepository(); + const api = new MentionsAPI({ + repository, + routingService: mockRoutingService, + resourceService: { + async getByURL() { + return { + type: 'post', + id: new ObjectID + }; + } + }, + webmentionMetadata: { + fetch: sinon.stub().rejects(new Error('')) + } + }); + + let error = null; + try { + await api.processWebmention({ + source: new URL('https://source.com'), + target: new URL('https://target.com'), + payload: {} + }); + } catch (err) { + error = err; + } finally { + assert(error); + } + }); });