2023-01-18 10:43:57 +03:00
|
|
|
const errors = require('@tryghost/errors');
|
2023-02-22 18:19:09 +03:00
|
|
|
const logging = require('@tryghost/logging');
|
2023-01-17 10:55:53 +03:00
|
|
|
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
|
2023-02-02 08:25:09 +03:00
|
|
|
* @prop {string} [order]
|
2023-01-17 10:55:53 +03:00
|
|
|
* @prop {number} page
|
|
|
|
* @prop {number} limit
|
|
|
|
*/
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @typedef {object} NonPaginatedOptions
|
|
|
|
* @prop {string} [filter] A valid NQL string
|
2023-02-02 08:25:09 +03:00
|
|
|
* @prop {string} [order]
|
2023-01-17 10:55:53 +03:00
|
|
|
* @prop {'all'} limit
|
|
|
|
*/
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @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
|
|
|
|
*/
|
|
|
|
|
2023-01-18 10:43:57 +03:00
|
|
|
/**
|
|
|
|
* @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
|
2023-02-28 17:39:28 +03:00
|
|
|
* @prop {string} body
|
2023-01-18 10:43:57 +03:00
|
|
|
*/
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @typedef {object} IWebmentionMetadata
|
|
|
|
* @prop {(url: URL) => Promise<WebmentionMetadata>} fetch
|
|
|
|
*/
|
|
|
|
|
2023-01-17 10:55:53 +03:00
|
|
|
module.exports = class MentionsAPI {
|
|
|
|
/** @type {IMentionRepository} */
|
|
|
|
#repository;
|
2023-01-18 10:43:57 +03:00
|
|
|
/** @type {IResourceService} */
|
|
|
|
#resourceService;
|
|
|
|
/** @type {IRoutingService} */
|
|
|
|
#routingService;
|
|
|
|
/** @type {IWebmentionMetadata} */
|
|
|
|
#webmentionMetadata;
|
2023-01-17 10:55:53 +03:00
|
|
|
|
2023-01-18 10:43:57 +03:00
|
|
|
/**
|
|
|
|
* @param {object} deps
|
|
|
|
* @param {IMentionRepository} deps.repository
|
|
|
|
* @param {IResourceService} deps.resourceService
|
|
|
|
* @param {IRoutingService} deps.routingService
|
|
|
|
* @param {IWebmentionMetadata} deps.webmentionMetadata
|
|
|
|
*/
|
2023-01-17 10:55:53 +03:00
|
|
|
constructor(deps) {
|
|
|
|
this.#repository = deps.repository;
|
2023-01-18 10:43:57 +03:00
|
|
|
this.#resourceService = deps.resourceService;
|
|
|
|
this.#routingService = deps.routingService;
|
|
|
|
this.#webmentionMetadata = deps.webmentionMetadata;
|
2023-01-17 10:55:53 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @param {object} options
|
|
|
|
* @returns {Promise<Page<Mention>>}
|
|
|
|
*/
|
|
|
|
async listMentions(options) {
|
|
|
|
/** @type {GetPageOptions} */
|
|
|
|
let pageOptions;
|
|
|
|
|
|
|
|
if (options.limit === 'all') {
|
|
|
|
pageOptions = {
|
|
|
|
filter: options.filter,
|
2023-02-02 08:25:09 +03:00
|
|
|
limit: options.limit,
|
|
|
|
order: options.order
|
2023-01-17 10:55:53 +03:00
|
|
|
};
|
|
|
|
} else {
|
|
|
|
pageOptions = {
|
|
|
|
filter: options.filter,
|
|
|
|
limit: options.limit,
|
2023-02-02 08:25:09 +03:00
|
|
|
page: options.page,
|
|
|
|
order: options.order
|
2023-01-17 10:55:53 +03:00
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
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) {
|
2023-02-09 13:29:13 +03:00
|
|
|
let mention = await this.#repository.getBySourceAndTarget(
|
|
|
|
webmention.source,
|
|
|
|
webmention.target
|
|
|
|
);
|
|
|
|
|
2023-01-18 10:43:57 +03:00
|
|
|
const targetExists = await this.#routingService.pageExists(webmention.target);
|
|
|
|
|
|
|
|
if (!targetExists) {
|
2023-02-09 13:29:13 +03:00
|
|
|
if (!mention) {
|
|
|
|
throw new errors.BadRequestError({
|
|
|
|
message: `${webmention.target} is not a valid URL for this site.`
|
|
|
|
});
|
|
|
|
} else {
|
|
|
|
mention.delete();
|
|
|
|
}
|
2023-01-18 10:43:57 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
const resourceInfo = await this.#resourceService.getByURL(webmention.target);
|
2023-02-09 13:29:13 +03:00
|
|
|
let metadata;
|
|
|
|
try {
|
|
|
|
metadata = await this.#webmentionMetadata.fetch(webmention.source);
|
2023-02-09 13:50:10 +03:00
|
|
|
if (mention) {
|
|
|
|
mention.setSourceMetadata({
|
|
|
|
sourceTitle: metadata.title,
|
|
|
|
sourceSiteTitle: metadata.siteTitle,
|
|
|
|
sourceAuthor: metadata.author,
|
|
|
|
sourceExcerpt: metadata.excerpt,
|
|
|
|
sourceFavicon: metadata.favicon,
|
|
|
|
sourceFeaturedImage: metadata.image
|
|
|
|
});
|
|
|
|
}
|
2023-02-09 13:29:13 +03:00
|
|
|
} catch (err) {
|
|
|
|
if (!mention) {
|
|
|
|
throw err;
|
|
|
|
}
|
|
|
|
mention.delete();
|
|
|
|
}
|
2023-01-17 10:55:53 +03:00
|
|
|
|
2023-01-18 10:43:57 +03:00
|
|
|
if (!mention) {
|
|
|
|
mention = await Mention.create({
|
|
|
|
source: webmention.source,
|
|
|
|
target: webmention.target,
|
|
|
|
timestamp: new Date(),
|
|
|
|
payload: webmention.payload,
|
2023-02-21 08:02:47 +03:00
|
|
|
resourceId: resourceInfo.id ? resourceInfo.id.toHexString() : null,
|
|
|
|
resourceType: resourceInfo.type,
|
2023-01-18 10:43:57 +03:00
|
|
|
sourceTitle: metadata.title,
|
|
|
|
sourceSiteTitle: metadata.siteTitle,
|
|
|
|
sourceAuthor: metadata.author,
|
|
|
|
sourceExcerpt: metadata.excerpt,
|
|
|
|
sourceFavicon: metadata.favicon,
|
|
|
|
sourceFeaturedImage: metadata.image
|
|
|
|
});
|
2023-01-17 10:55:53 +03:00
|
|
|
}
|
|
|
|
|
2023-02-28 17:39:28 +03:00
|
|
|
if (metadata?.body) {
|
2023-02-22 18:19:09 +03:00
|
|
|
try {
|
2023-02-28 17:39:28 +03:00
|
|
|
mention.verify(metadata.body);
|
2023-02-22 18:19:09 +03:00
|
|
|
} catch (e) {
|
|
|
|
logging.error(e);
|
|
|
|
}
|
2023-02-17 14:53:06 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
await this.#repository.save(mention);
|
2023-01-17 10:55:53 +03:00
|
|
|
return mention;
|
|
|
|
}
|
|
|
|
};
|