const errors = require('@tryghost/errors'); const logging = require('@tryghost/logging'); const Mention = require('./Mention'); /** * @template Model * @typedef {object} Page * @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} GetAllOptions * @prop {string} [filter] A valid NQL string */ /** * @typedef {object} IMentionRepository * @prop {(mention: Mention) => Promise} save * @prop {(options: GetPageOptions) => Promise>} getPage * @prop {(options: GetAllOptions) => Promise} getAll * @prop {(source: URL, target: URL) => Promise} getBySourceAndTarget */ /** * @typedef {object} ResourceResult * @prop {string | null} type * @prop {import('bson-objectid').default | null} id */ /** * @typedef {object} IResourceService * @prop {(url: URL) => Promise} getByURL */ /** * @typedef {object} IRoutingService * @prop {(url: URL) => Promise} pageExists */ /** * @typedef {object} WebmentionMetadata * @prop {string|null} siteTitle * @prop {string|null} title * @prop {string|null} excerpt * @prop {string|null} author * @prop {URL|null} image * @prop {URL|null} favicon * @prop {string} body * @prop {string|undefined} contentType */ /** * @typedef {object} MentionReport * @prop {Date} startDate * @prop {Date} endDate * @prop {Mention[]} mentions */ /** * @typedef {object} IWebmentionMetadata * @prop {(url: URL) => Promise} 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 {Date} startDate * @param {Date} endDate * @returns {Promise} */ async getMentionReport(startDate, endDate) { const mentions = await this.#repository.getAll({ filter: `created_at:>${startDate.toISOString()}+created_at:<${endDate.toISOString()}` }); const report = { startDate: new Date(startDate), endDate: new Date(endDate), mentions }; return report; } /** * @param {object} options * @returns {Promise>} */ 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; } /** * Update the metadata of the webmentions in the database, and delete them if they are no longer valid. * @param {object} options * @param {number|'all'} [options.limit] * @param {number} [options.page] * @param {string} [options.filter] */ async refreshMentions(options) { const mentions = await this.#repository.getAll(options); for (const mention of mentions) { await this.#updateWebmention(mention, { source: mention.source, target: mention.target }); } } async #updateWebmention(mention, webmention) { const isNew = !mention; const wasDeleted = mention?.deleted ?? false; 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(); } } if (targetExists) { 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) { mention.verify(metadata.body, metadata.contentType); } } await this.#repository.save(mention); if (isNew) { logging.info('[Webmention] Created ' + webmention.source + ' to ' + webmention.target + ', verified: ' + mention.verified + ', deleted: ' + mention.deleted); } else { if (mention.deleted && !wasDeleted) { logging.info('[Webmention] Deleted ' + webmention.source + ' to ' + webmention.target + ', verified: ' + mention.verified); } else { if (!mention.deleted && wasDeleted) { logging.info('[Webmention] Restored ' + webmention.source + ' to ' + webmention.target + ', verified: ' + mention.verified); } else { logging.info('[Webmention] Updated ' + webmention.source + ' to ' + webmention.target + ', verified: ' + mention.verified + ', deleted: ' + mention.deleted); } } } return mention; } /** * @param {object} webmention * @param {URL} webmention.source * @param {URL} webmention.target * @param {Object} webmention.payload * * @returns {Promise} */ async processWebmention(webmention) { let mention = await this.#repository.getBySourceAndTarget( webmention.source, webmention.target ); return await this.#updateWebmention(mention, webmention); } };