2023-01-19 19:35:10 +03:00
|
|
|
const errors = require('@tryghost/errors');
|
|
|
|
const logging = require('@tryghost/logging');
|
|
|
|
|
|
|
|
module.exports = class MentionSendingService {
|
|
|
|
#discoveryService;
|
|
|
|
#externalRequest;
|
|
|
|
#getSiteUrl;
|
|
|
|
#getPostUrl;
|
|
|
|
#isEnabled;
|
2023-02-09 01:29:12 +03:00
|
|
|
#jobService;
|
2023-01-19 19:35:10 +03:00
|
|
|
|
2023-02-09 01:29:12 +03:00
|
|
|
constructor({discoveryService, externalRequest, getSiteUrl, getPostUrl, isEnabled, jobService}) {
|
2023-01-19 19:35:10 +03:00
|
|
|
this.#discoveryService = discoveryService;
|
|
|
|
this.#externalRequest = externalRequest;
|
|
|
|
this.#getSiteUrl = getSiteUrl;
|
|
|
|
this.#getPostUrl = getPostUrl;
|
|
|
|
this.#isEnabled = isEnabled;
|
2023-02-09 01:29:12 +03:00
|
|
|
this.#jobService = jobService;
|
2023-01-19 19:35:10 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
get siteUrl() {
|
|
|
|
try {
|
|
|
|
return new URL(this.#getSiteUrl());
|
|
|
|
} catch (e) {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
2023-02-02 01:49:58 +03:00
|
|
|
* Listen for new and edited published posts and automatically send webmentions. Unpublished posts should send mentions
|
|
|
|
* so the receiver can discover a 404 response and remove the mentions.
|
2023-01-19 19:35:10 +03:00
|
|
|
* @param {*} events
|
|
|
|
*/
|
|
|
|
listen(events) {
|
2023-02-02 01:49:58 +03:00
|
|
|
events.on('post.published', this.sendForPost.bind(this));
|
|
|
|
events.on('post.published.edited', this.sendForPost.bind(this));
|
|
|
|
events.on('post.unpublished', this.sendForPost.bind(this));
|
2023-03-10 02:31:29 +03:00
|
|
|
events.on('page.published', this.sendForPost.bind(this));
|
|
|
|
events.on('page.published.edited', this.sendForPost.bind(this));
|
|
|
|
events.on('page.unpublished', this.sendForPost.bind(this));
|
2023-01-19 19:35:10 +03:00
|
|
|
}
|
|
|
|
|
2023-02-02 01:49:58 +03:00
|
|
|
async sendForPost(post, options) {
|
|
|
|
// NOTE: this is not ideal and shouldn't really be handled within the package...
|
|
|
|
// for now we don't want to evaluate mentions when importing data (at least needs queueing set up)
|
|
|
|
// we do not want to evaluate mentions with fixture (internal) data, e.g. generating posts
|
|
|
|
// TODO: real solution is likely suppressing event emission when building fixture data
|
|
|
|
if (options && (options.importing || options.context.internal)) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2023-01-19 19:35:10 +03:00
|
|
|
try {
|
|
|
|
if (!this.#isEnabled()) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
// TODO: we need to check old url and send webmentions in case the url changed of a post
|
|
|
|
if (post.get('status') === post.previous('status') && post.get('html') === post.previous('html')) {
|
|
|
|
// Not changed
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
if (post.get('status') !== 'published' && post.previous('status') !== 'published') {
|
|
|
|
// Post should be or should have been published
|
|
|
|
return;
|
|
|
|
}
|
2023-02-16 20:07:04 +03:00
|
|
|
// make sure we have something to parse before we create a job
|
|
|
|
let html = post.get('html');
|
|
|
|
let previousHtml = post.previous('status') === 'published' ? post.previous('html') : null;
|
|
|
|
if (html || previousHtml) {
|
|
|
|
await this.#jobService.addJob('sendWebmentions', async () => {
|
|
|
|
await this.sendAll({
|
|
|
|
url: new URL(this.#getPostUrl(post)),
|
|
|
|
html: html,
|
|
|
|
previousHtml: previousHtml
|
|
|
|
});
|
2023-02-09 01:29:12 +03:00
|
|
|
});
|
2023-02-16 20:07:04 +03:00
|
|
|
}
|
2023-01-19 19:35:10 +03:00
|
|
|
} catch (e) {
|
2023-02-02 01:49:58 +03:00
|
|
|
logging.error('Error in webmention sending service post update event handler:');
|
2023-01-19 19:35:10 +03:00
|
|
|
logging.error(e);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
async send({source, target, endpoint}) {
|
|
|
|
logging.info('[Webmention] Sending webmention from ' + source.href + ' to ' + target.href + ' via ' + endpoint.href);
|
2023-03-07 18:08:40 +03:00
|
|
|
|
2023-02-20 18:33:11 +03:00
|
|
|
// default content type is application/x-www-form-encoded which is what we need for the webmentions spec
|
2023-01-19 19:35:10 +03:00
|
|
|
const response = await this.#externalRequest.post(endpoint.href, {
|
2023-02-20 18:33:11 +03:00
|
|
|
form: {
|
2023-01-19 19:35:10 +03:00
|
|
|
source: source.href,
|
2023-02-01 09:44:55 +03:00
|
|
|
target: target.href,
|
|
|
|
source_is_ghost: true
|
2023-01-19 19:35:10 +03:00
|
|
|
},
|
|
|
|
throwHttpErrors: false,
|
|
|
|
maxRedirects: 10,
|
|
|
|
followRedirect: true,
|
2023-01-20 13:46:18 +03:00
|
|
|
timeout: 10000
|
2023-01-19 19:35:10 +03:00
|
|
|
});
|
2023-02-20 18:33:11 +03:00
|
|
|
|
2023-01-19 19:35:10 +03:00
|
|
|
if (response.statusCode >= 200 && response.statusCode < 300) {
|
|
|
|
return;
|
|
|
|
}
|
2023-03-07 18:08:40 +03:00
|
|
|
|
2023-01-19 19:35:10 +03:00
|
|
|
throw new errors.BadRequestError({
|
|
|
|
message: 'Webmention sending failed with status code ' + response.statusCode,
|
|
|
|
statusCode: response.statusCode
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Send a webmention call for the links in a resource.
|
|
|
|
* @param {object} resource
|
|
|
|
* @param {URL} resource.url
|
|
|
|
* @param {string} resource.html
|
|
|
|
* @param {string|null} [resource.previousHtml]
|
|
|
|
*/
|
|
|
|
async sendAll(resource) {
|
2023-02-16 20:07:04 +03:00
|
|
|
const links = resource.html ? this.getLinks(resource.html) : [];
|
2023-01-19 19:35:10 +03:00
|
|
|
if (resource.previousHtml) {
|
|
|
|
// We also need to send webmentions for removed links
|
|
|
|
const oldLinks = this.getLinks(resource.previousHtml);
|
|
|
|
for (const link of oldLinks) {
|
|
|
|
if (!links.find(l => l.href === link.href)) {
|
|
|
|
links.push(link);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if (links.length) {
|
|
|
|
logging.info('[Webmention] Sending all webmentions for ' + resource.url.href);
|
|
|
|
}
|
|
|
|
|
|
|
|
for (const target of links) {
|
|
|
|
const endpoint = await this.#discoveryService.getEndpoint(target);
|
|
|
|
if (endpoint) {
|
|
|
|
// Send webmention call
|
|
|
|
try {
|
|
|
|
await this.send({source: resource.url, target, endpoint});
|
|
|
|
} catch (e) {
|
|
|
|
logging.error('[Webmention] Failed sending via ' + endpoint.href + ': ' + e.message);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @private
|
|
|
|
* Get all external links in a HTML document.
|
|
|
|
* Excludes the site's own domain.
|
|
|
|
* @param {string} html
|
|
|
|
* @returns {URL[]}
|
|
|
|
*/
|
|
|
|
getLinks(html) {
|
|
|
|
const cheerio = require('cheerio');
|
|
|
|
const $ = cheerio.load(html);
|
|
|
|
const urls = [];
|
|
|
|
const siteUrl = this.siteUrl;
|
|
|
|
|
|
|
|
for (const el of $('a').toArray()) {
|
|
|
|
const href = $(el).attr('href');
|
|
|
|
if (href) {
|
|
|
|
let url;
|
|
|
|
try {
|
|
|
|
url = new URL(href);
|
|
|
|
|
|
|
|
if (siteUrl && url.hostname === siteUrl.hostname) {
|
|
|
|
// Ignore links to the site's own domain
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (['http:', 'https:'].includes(url.protocol) && !urls.find(u => u.href === url.href)) {
|
|
|
|
// Ignore duplicate URLs
|
|
|
|
urls.push(url);
|
|
|
|
}
|
|
|
|
} catch (e) {
|
|
|
|
// Ignore invalid URLs
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return urls;
|
|
|
|
}
|
|
|
|
};
|