TryGhost/Ghost

View on GitHub
ghost/webmentions/lib/MentionSendingService.js

Summary

Maintainability
A
0 mins
Test Coverage
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;
    }
};