Ghost/ghost/webmentions/lib/MentionsAPI.js
Simon Backx 81c4b46977
Grouped mentions from the same source (#16348)
fixes https://github.com/TryGhost/Team/issues/2625

- Adds an unique option to the mentions API. Enabling this will only
return the latest mention from each source.
- The frontend can fetch the related sources for each page by doing an
extra request to the mentions API.
2023-03-01 12:15:29 +01:00

209 lines
6.1 KiB
JavaScript

const errors = require('@tryghost/errors');
const logging = require('@tryghost/logging');
const Mention = require('./Mention');
/**
* @template Model
* @typedef {object} Page<Model>
* @prop {Model[]} data
* @prop {object} meta
* @prop {object} meta.pagination
* @prop {number} meta.pagination.page - The current page
* @prop {number} meta.pagination.pages - The total number of pages
* @prop {number | 'all'} meta.pagination.limit - The limit of models per page
* @prop {number} meta.pagination.total - The total number of models across all pages
* @prop {number|null} meta.pagination.prev - The number of the previous page, or null if there isn't one
* @prop {number|null} meta.pagination.next - The number of the next page, or null if there isn't one
*/
/**
* @typedef {object} PaginatedOptions
* @prop {string} [filter] A valid NQL string
* @prop {string} [order]
* @prop {number} page
* @prop {number} limit
* @prop {boolean} [unique] Only return unique mentions by source
*/
/**
* @typedef {object} NonPaginatedOptions
* @prop {string} [filter] A valid NQL string
* @prop {string} [order]
* @prop {'all'} limit
* @prop {boolean} [unique] Only return unique mentions by source
*/
/**
* @typedef {PaginatedOptions | NonPaginatedOptions} GetPageOptions
*/
/**
* @typedef {object} IMentionRepository
* @prop {(mention: Mention) => Promise<void>} save
* @prop {(options: GetPageOptions) => Promise<Page<Mention>>} getPage
* @prop {(source: URL, target: URL) => Promise<Mention>} getBySourceAndTarget
*/
/**
* @typedef {object} ResourceResult
* @prop {string | null} type
* @prop {import('bson-objectid').default | null} id
*/
/**
* @typedef {object} IResourceService
* @prop {(url: URL) => Promise<ResourceResult>} getByURL
*/
/**
* @typedef {object} IRoutingService
* @prop {(url: URL) => Promise<boolean>} pageExists
*/
/**
* @typedef {object} WebmentionMetadata
* @prop {string} siteTitle
* @prop {string} title
* @prop {string} excerpt
* @prop {string} author
* @prop {URL} image
* @prop {URL} favicon
* @prop {string} body
*/
/**
* @typedef {object} IWebmentionMetadata
* @prop {(url: URL) => Promise<WebmentionMetadata>} fetch
*/
module.exports = class MentionsAPI {
/** @type {IMentionRepository} */
#repository;
/** @type {IResourceService} */
#resourceService;
/** @type {IRoutingService} */
#routingService;
/** @type {IWebmentionMetadata} */
#webmentionMetadata;
/**
* @param {object} deps
* @param {IMentionRepository} deps.repository
* @param {IResourceService} deps.resourceService
* @param {IRoutingService} deps.routingService
* @param {IWebmentionMetadata} deps.webmentionMetadata
*/
constructor(deps) {
this.#repository = deps.repository;
this.#resourceService = deps.resourceService;
this.#routingService = deps.routingService;
this.#webmentionMetadata = deps.webmentionMetadata;
}
/**
* @param {object} options
* @returns {Promise<Page<Mention>>}
*/
async listMentions(options) {
/** @type {GetPageOptions} */
let pageOptions;
if (options.limit === 'all') {
pageOptions = {
filter: options.filter,
limit: options.limit,
order: options.order,
unique: options.unique ?? false
};
} else {
pageOptions = {
filter: options.filter,
limit: options.limit,
page: options.page,
order: options.order,
unique: options.unique ?? false
};
}
const page = await this.#repository.getPage(pageOptions);
return page;
}
/**
* @param {object} webmention
* @param {URL} webmention.source
* @param {URL} webmention.target
* @param {Object<string, any>} webmention.payload
*
* @returns {Promise<Mention>}
*/
async processWebmention(webmention) {
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);
if (mention) {
mention.setSourceMetadata({
sourceTitle: metadata.title,
sourceSiteTitle: metadata.siteTitle,
sourceAuthor: metadata.author,
sourceExcerpt: metadata.excerpt,
sourceFavicon: metadata.favicon,
sourceFeaturedImage: metadata.image
});
}
} catch (err) {
if (!mention) {
throw err;
}
mention.delete();
}
if (!mention) {
mention = await Mention.create({
source: webmention.source,
target: webmention.target,
timestamp: new Date(),
payload: webmention.payload,
resourceId: resourceInfo.id ? resourceInfo.id.toHexString() : null,
resourceType: resourceInfo.type,
sourceTitle: metadata.title,
sourceSiteTitle: metadata.siteTitle,
sourceAuthor: metadata.author,
sourceExcerpt: metadata.excerpt,
sourceFavicon: metadata.favicon,
sourceFeaturedImage: metadata.image
});
}
if (metadata?.body) {
try {
mention.verify(metadata.body);
} catch (e) {
logging.error(e);
}
}
await this.#repository.save(mention);
return mention;
}
};