Ghost/ghost/webmentions/lib/Mention.js
Simon Backx 1e3232cf82
Improved webmention stability in case of slow external servers (#18242)
refs https://github.com/TryGhost/Product/issues/3850

- Added a recheck for recommendation related webmentions after boot (to
check missed webmentions during down time)
- Increased general timeouts to 15s for all webmention related HTTP
requests. Instead, increased retries to 3.
- Increased timeout for fetching webmention metadata from 2s to 15s
- Added more logging about verification and deletion status of
webmentions
2023-09-20 13:09:47 +02:00

345 lines
8.9 KiB
JavaScript

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;
}
/**
* @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
this.#deleted = true;
this.#verified = true;
}
} 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
this.#deleted = true;
this.#verified = true;
}
} 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;
}
/**
* @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
});
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);
}