TryGhost/Ghost

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

Summary

Maintainability
A
0 mins
Test Coverage
const ObjectID = require('bson-objectid').default;
const {ValidationError} = require('@tryghost/errors');
const MentionCreatedEvent = require('./MentionCreatedEvent');
const cheerio = require('cheerio');

module.exports = class Mention {
    /** @type {Array} */
    events = [];

    /** @type {ObjectID} */
    #id;
    get id() {
        return this.#id;
    }

    /** @type {boolean} */
    #verified = false;
    get verified() {
        return this.#verified;
    }

    /** @type {boolean} */
    #deleted = false;

    get deleted() {
        return this.#deleted;
    }

    delete() {
        this.#deleted = true;
    }

    #undelete() {
        // When an earlier mention is deleted, but then it gets verified again, we need to undelete it
        if (this.#deleted) {
            this.#deleted = false;
            this.events.push(MentionCreatedEvent.create({mention: this}));
        }
    }

    /**
     * @param {string} html
     * @param {string} contentType
     */
    verify(html, contentType) {
        const wasVerified = this.#verified;

        if (contentType.includes('text/html')) {
            try {
                const $ = cheerio.load(html);
                const hasTargetUrl = $('a[href*="' + this.target.href + '"], img[src*="' + this.target.href + '"], video[src*="' + this.target.href + '"]').length > 0;
                this.#verified = hasTargetUrl;

                if (wasVerified && !this.#verified) {
                    // Delete the mention, but keep it verified (it was just deleted, because it was verified earlier, so now it is removed from the site according to the spec)
                    this.#deleted = true;
                    this.#verified = true;
                } else {
                    this.#undelete();
                }
            } catch (e) {
                this.#verified = false;
            }
        }

        if (contentType.includes('application/json')) {
            try {
                // Check valid JSON
                JSON.parse(html);

                // Check full text string is present in the json
                this.#verified = !!html.includes(JSON.stringify(this.target.href));

                if (wasVerified && !this.#verified) {
                    // Delete the mention, but keep it verified (it was just deleted, because it was verified earlier, so now it is removed from the site according to the spec)
                    this.#deleted = true;
                    this.#verified = true;
                } else {
                    this.#undelete();
                }
            } catch (e) {
                this.#verified = false;
            }
        }
    }

    /** @type {URL} */
    #source;
    get source() {
        return this.#source;
    }

    /** @type {URL} */
    #target;
    get target() {
        return this.#target;
    }

    /** @type {Date} */
    #timestamp;
    get timestamp() {
        return this.#timestamp;
    }

    /** @type {Object<string, any> | null} */
    #payload;
    get payload() {
        return this.#payload;
    }

    /** @type {ObjectID | null} */
    #resourceId;
    get resourceId() {
        return this.#resourceId;
    }

    /** @type {string | null} */
    #resourceType;
    get resourceType() {
        return this.#resourceType;
    }

    /** @type {string} */
    #sourceTitle;
    get sourceTitle() {
        return this.#sourceTitle;
    }

    /** @type {string | null} */
    #sourceSiteTitle;
    get sourceSiteTitle() {
        return this.#sourceSiteTitle;
    }

    /** @type {string | null} */
    #sourceAuthor;
    get sourceAuthor() {
        return this.#sourceAuthor;
    }

    /** @type {string | null} */
    #sourceExcerpt;
    get sourceExcerpt() {
        return this.#sourceExcerpt;
    }

    /** @type {URL | null} */
    #sourceFavicon;
    get sourceFavicon() {
        return this.#sourceFavicon;
    }

    /** @type {URL | null} */
    #sourceFeaturedImage;
    get sourceFeaturedImage() {
        return this.#sourceFeaturedImage;
    }

    /**
     * @param {object} metadata
     */
    setSourceMetadata(metadata) {
        /** @type {string} */
        let sourceTitle = validateString(metadata.sourceTitle, 2000, 'sourceTitle');
        if (sourceTitle === null) {
            sourceTitle = this.#source.host;
        }
        /** @type {string | null} */
        const sourceExcerpt = validateString(metadata.sourceExcerpt, 2000, 'sourceExcerpt');
        /** @type {string | null} */
        let sourceSiteTitle = validateString(metadata.sourceSiteTitle, 2000, 'sourceSiteTitle');
        if (sourceSiteTitle === null) {
            sourceSiteTitle = this.#source.host;
        }

        /** @type {string | null} */
        const sourceAuthor = validateString(metadata.sourceAuthor, 2000, 'sourceAuthor');

        /** @type {URL | null} */
        let sourceFavicon = null;
        if (metadata.sourceFavicon instanceof URL) {
            sourceFavicon = metadata.sourceFavicon;
        } else if (metadata.sourceFavicon) {
            sourceFavicon = new URL(metadata.sourceFavicon);
        }

        /** @type {URL | null} */
        let sourceFeaturedImage = null;
        if (metadata.sourceFeaturedImage instanceof URL) {
            sourceFeaturedImage = metadata.sourceFeaturedImage;
        } else if (metadata.sourceFeaturedImage) {
            sourceFeaturedImage = new URL(metadata.sourceFeaturedImage);
        }

        this.#sourceTitle = sourceTitle;
        this.#sourceExcerpt = sourceExcerpt;
        this.#sourceSiteTitle = sourceSiteTitle;
        this.#sourceAuthor = sourceAuthor;
        this.#sourceFavicon = sourceFavicon;
        this.#sourceFeaturedImage = sourceFeaturedImage;
    }

    toJSON() {
        return {
            id: this.id,
            source: this.source,
            target: this.target,
            timestamp: this.timestamp,
            payload: this.payload,
            resourceId: this.resourceId,
            resourceType: this.resourceType,
            sourceTitle: this.sourceTitle,
            sourceSiteTitle: this.sourceSiteTitle,
            sourceAuthor: this.sourceAuthor,
            sourceExcerpt: this.sourceExcerpt,
            sourceFavicon: this.sourceFavicon,
            sourceFeaturedImage: this.sourceFeaturedImage,
            verified: this.verified
        };
    }

    /** @private */
    constructor(data) {
        this.#id = data.id;
        this.#source = data.source;
        this.#target = data.target;
        this.#timestamp = data.timestamp;
        this.#payload = data.payload;
        this.#resourceId = data.resourceId;
        this.#resourceType = data.resourceType;
        this.#verified = data.verified;
        this.#deleted = data.deleted || false;
    }

    /**
     * @param {any} data
     * @returns {Promise<Mention>}
     */
    static async create(data) {
        /** @type ObjectID */
        let id;
        let isNew = false;
        if (!data.id) {
            isNew = true;
            id = new ObjectID();
        } else if (typeof data.id === 'string') {
            id = ObjectID.createFromHexString(data.id);
        } else if (data.id instanceof ObjectID) {
            id = data.id;
        } else {
            throw new ValidationError({
                message: 'Invalid ID provided for Mention'
            });
        }

        /** @type URL */
        let source;
        if (data.source instanceof URL) {
            source = data.source;
        } else {
            source = new URL(data.source);
        }

        /** @type URL */
        let target;
        if (data.target instanceof URL) {
            target = data.target;
        } else {
            target = new URL(data.target);
        }

        /** @type Date */
        let timestamp;
        if (data.timestamp instanceof Date) {
            timestamp = data.timestamp;
        } else if (data.timestamp) {
            timestamp = new Date(data.timestamp);
            if (isNaN(timestamp.valueOf())) {
                throw new ValidationError({
                    message: 'Invalid Date'
                });
            }
        } else {
            timestamp = new Date();
        }

        let payload;
        payload = data.payload ? JSON.parse(JSON.stringify(data.payload)) : null;

        /** @type boolean */
        let verified;
        verified = isNew ? false : !!data.verified;

        /** @type {ObjectID | null} */
        let resourceId = null;
        if (data.resourceId) {
            if (data.resourceId instanceof ObjectID) {
                resourceId = data.resourceId;
            } else {
                resourceId = ObjectID.createFromHexString(data.resourceId);
            }
        }

        /** @type {string | null} */
        let resourceType = null;
        if (data.resourceType) {
            resourceType = data.resourceType;
        }

        const mention = new Mention({
            id,
            source,
            target,
            timestamp,
            payload,
            resourceId,
            resourceType,
            verified,
            deleted: isNew ? false : !!data.deleted
        });

        mention.setSourceMetadata(data);

        if (isNew) {
            mention.events.push(MentionCreatedEvent.create({mention}));
        }
        return mention;
    }

    /**
     * @returns {boolean}
     */
    isDeleted() {
        return this.#deleted;
    }

    /**
     * @param {Mention} mention
     * @returns {boolean}
     */
    static isDeleted(mention) {
        return mention.isDeleted();
    }
};

function validateString(value, maxlength, name) {
    if (!value) {
        return null;
    }

    if (typeof value !== 'string') {
        throw new ValidationError({
            message: `${name} must be a string`
        });
    }

    return value.trim().slice(0, maxlength);
}