Ghost/ghost/webmentions/lib/MentionSendingService.js

214 lines
7.4 KiB
JavaScript
Raw Normal View History

const errors = require('@tryghost/errors');
const logging = require('@tryghost/logging');
module.exports = class MentionSendingService {
#discoveryService;
#externalRequest;
#getSiteUrl;
#getPostUrl;
#isEnabled;
#jobService;
constructor({discoveryService, externalRequest, getSiteUrl, getPostUrl, isEnabled, jobService}) {
this.#discoveryService = discoveryService;
this.#externalRequest = externalRequest;
this.#getSiteUrl = getSiteUrl;
this.#getPostUrl = getPostUrl;
this.#isEnabled = isEnabled;
this.#jobService = jobService;
}
get siteUrl() {
try {
return new URL(this.#getSiteUrl());
} catch (e) {
return null;
}
}
/**
* 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.
* @param {*} events
*/
listen(events) {
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));
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));
}
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;
}
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;
}
// make sure we have something to parse before we create a job
let html = post.get('status') === 'published' ? post.get('html') : null;
let previousHtml = post.previous('status') === 'published' ? post.previous('html') : null;
if (html || previousHtml) {
await this.#jobService.addJob('sendWebmentions', async () => {
await this.sendForHTMLResource({
url: new URL(this.#getPostUrl(post)),
html: html,
previousHtml: previousHtml
});
});
}
} catch (e) {
logging.error('Error in webmention sending service post update event handler:');
logging.error(e);
}
}
/**
* @param {{source: URL, target: URL, endpoint: URL}} options
* @returns
*/
async send({source, target, endpoint}) {
logging.info('[Webmention] Sending webmention from ' + source.href + ' to ' + target.href + ' via ' + endpoint.href);
// default content type is application/x-www-form-encoded which is what we need for the webmentions spec
const response = await this.#externalRequest.post(endpoint.href, {
form: {
source: source.href,
target: target.href,
source_is_ghost: true
},
throwHttpErrors: false,
maxRedirects: 10,
followRedirect: true,
timeout: 15000,
retry: {
// Only retry on network issues, or specific HTTP status codes
limit: 3
}
});
if (response.statusCode >= 200 && response.statusCode < 300) {
return;
}
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 sendForHTMLResource(resource) {
let links = resource.html ? this.getLinks(resource.html) : [];
if (resource.previousHtml) {
// Only send for NEW or DELETED links (to avoid spamming when lots of small changes happen to a post)
const existingLinks = links;
links = [];
const oldLinks = this.getLinks(resource.previousHtml);
for (const link of oldLinks) {
if (!existingLinks.find(l => l.href === link.href)) {
// Deleted link
links.push(link);
}
}
for (const link of existingLinks) {
if (!oldLinks.find(l => l.href === link.href)) {
// New link
links.push(link);
}
}
}
if (links.length) {
logging.info('[Webmention] Sending all webmentions for ' + resource.url.href);
}
await this.sendAll({
url: resource.url,
links
});
}
/**
* Send a webmention call for the links in a resource.
* @param {object} resource
* @param {URL} resource.url
* @param {URL[]} resource.links
*/
async sendAll({url, links}) {
for (const target of links) {
const endpoint = await this.#discoveryService.getEndpoint(target);
if (endpoint) {
// Send webmention call
try {
await this.send({source: 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;
}
};