mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-11-27 00:52:36 +03:00
72d7580461
This is a pretty simple way for us to track which webmentions are sent by Ghost. Although it's easily spoofed, so are other approaches like using a header (e.g. User-Agent). If we find that this data is being spoofed we can look at different approach. Becuase our receiving implementation stores the payload of the Webmention, we'll be able to know inside Ghost which Mentions originated from another Ghost installation, which is useful for stats and gives us the possibility to display that information in the feed. Longer term we might want to consider storing this data in a separate column for Mentions, rather than the `payload` column - but that is outside the scope of this change.
158 lines
5.2 KiB
JavaScript
158 lines
5.2 KiB
JavaScript
const errors = require('@tryghost/errors');
|
|
const logging = require('@tryghost/logging');
|
|
|
|
module.exports = class MentionSendingService {
|
|
#discoveryService;
|
|
#externalRequest;
|
|
#getSiteUrl;
|
|
#getPostUrl;
|
|
#isEnabled;
|
|
|
|
constructor({discoveryService, externalRequest, getSiteUrl, getPostUrl, isEnabled}) {
|
|
this.#discoveryService = discoveryService;
|
|
this.#externalRequest = externalRequest;
|
|
this.#getSiteUrl = getSiteUrl;
|
|
this.#getPostUrl = getPostUrl;
|
|
this.#isEnabled = isEnabled;
|
|
}
|
|
|
|
get siteUrl() {
|
|
try {
|
|
return new URL(this.#getSiteUrl());
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Listen for changes in posts and automatically send webmentions.
|
|
* @param {*} events
|
|
*/
|
|
listen(events) {
|
|
// Note: we don't need to listen for post.published (post.edited is also called at that time)
|
|
events.on('post.edited', this.sendForEditedPost.bind(this));
|
|
}
|
|
|
|
async sendForEditedPost(post) {
|
|
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;
|
|
}
|
|
await this.sendAll({
|
|
url: new URL(this.#getPostUrl(post)),
|
|
html: post.get('html'),
|
|
previousHtml: post.previous('status') === 'published' ? post.previous('html') : null
|
|
});
|
|
} catch (e) {
|
|
logging.error('Error in webmention sending service post.added event handler:');
|
|
logging.error(e);
|
|
}
|
|
}
|
|
|
|
async send({source, target, endpoint}) {
|
|
logging.info('[Webmention] Sending webmention from ' + source.href + ' to ' + target.href + ' via ' + endpoint.href);
|
|
const response = await this.#externalRequest.post(endpoint.href, {
|
|
body: {
|
|
source: source.href,
|
|
target: target.href,
|
|
source_is_ghost: true
|
|
},
|
|
form: true,
|
|
throwHttpErrors: false,
|
|
maxRedirects: 10,
|
|
followRedirect: true,
|
|
methodRewriting: false, // WARNING! this setting has a different meaning in got v12!
|
|
timeout: 10000
|
|
});
|
|
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 sendAll(resource) {
|
|
const links = this.getLinks(resource.html);
|
|
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;
|
|
}
|
|
};
|