Added support for deleting mentions (#16238)

refs https://github.com/TryGhost/Team/issues/2534

As we're using soft deletes for mentions we need to store the `deleted` column
as well as enforce a `'deleted:false'` filter on the bookshelf model. 

We've also implemented the handling for deleting mentions. Where we remove a
mention anytime we receive and update from or to a page which no longer exists.

Co-authored-by: Steve Larson <9larsons@gmail.com>
This commit is contained in:
Fabien 'egg' O'Carroll 2023-02-09 17:29:13 +07:00 committed by GitHub
parent d20696805f
commit 4e8af72305
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 210 additions and 16 deletions

View File

@ -4,6 +4,9 @@ const Mention = ghostBookshelf.Model.extend({
tableName: 'mentions',
defaults: {
deleted: false
},
enforcedFilters() {
return 'deleted:false';
}
});

View File

@ -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});

View File

@ -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
};
}
}

View File

@ -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') {

View File

@ -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) {

View File

@ -133,23 +133,35 @@ module.exports = class MentionsAPI {
* @returns {Promise<Mention>}
*/
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,

View File

@ -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);
});
});
});

View File

@ -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);
}
});
});