bec647412f
fixes https://linear.app/tryghost/issue/ENG-447/🐛-urls-sent-in-emails-containing-a-percent-can-not-be-updated URLs were decoded before making a search query to the db. This is the reason the `%2F` character gets converted to `/`. This decoding is not required.
238 lines
7.5 KiB
JavaScript
238 lines
7.5 KiB
JavaScript
const {RedirectEvent} = require('@tryghost/link-redirects');
|
|
const LinkClick = require('./ClickEvent');
|
|
const PostLink = require('./PostLink');
|
|
const ObjectID = require('bson-objectid').default;
|
|
const errors = require('@tryghost/errors');
|
|
const nql = require('@tryghost/nql');
|
|
const _ = require('lodash');
|
|
const tpl = require('@tryghost/tpl');
|
|
const moment = require('moment');
|
|
|
|
/**
|
|
* @typedef {object} ILinkClickRepository
|
|
* @prop {(event: LinkClick) => Promise<void>} save
|
|
* @prop {({filter: string}) => Promise<LinkClick[]>} getAll
|
|
*/
|
|
|
|
/**
|
|
* @typedef {object} ILinkRedirect
|
|
* @prop {ObjectID} link_id
|
|
* @prop {URL} to
|
|
* @prop {URL} from
|
|
*/
|
|
|
|
/**
|
|
* @typedef {import('./FullPostLink')} FullPostLink
|
|
*/
|
|
|
|
/**
|
|
* @typedef {object} ILinkRedirectService
|
|
* @prop {(to: URL, slug: string) => Promise<ILinkRedirect>} addRedirect
|
|
* @prop {() => Promise<string>} getSlug
|
|
* @prop {({filter: string}) => Promise<ILinkRedirect[]>} getAll
|
|
* @prop {({filter: string}) => Promise<string[]>} getFilteredIds
|
|
*/
|
|
|
|
/**
|
|
* @typedef {object} IPostLinkRepository
|
|
* @prop {(postLink: PostLink) => Promise<void>} save
|
|
* @prop {({filter: string}) => Promise<FullPostLink[]>} getAll
|
|
* @prop {(linkIds: array, data, options) => Promise<FullPostLink[]>} updateLinks
|
|
*/
|
|
|
|
const messages = {
|
|
invalidFilter: 'Invalid filter value received',
|
|
unsupportedBulkAction: 'Unsupported bulk action',
|
|
invalidRedirectUrl: 'Invalid redirect URL value'
|
|
};
|
|
|
|
class LinkClickTrackingService {
|
|
#initialised = false;
|
|
|
|
/** @type ILinkClickRepository */
|
|
#linkClickRepository;
|
|
/** @type ILinkRedirectService */
|
|
#linkRedirectService;
|
|
/** @type IPostLinkRepository */
|
|
#postLinkRepository;
|
|
/** @type DomainEvents */
|
|
#DomainEvents;
|
|
/** @type {Object} */
|
|
#LinkRedirect;
|
|
/** @type {Object} */
|
|
#urlUtils;
|
|
|
|
/**
|
|
* @param {object} deps
|
|
* @param {ILinkClickRepository} deps.linkClickRepository
|
|
* @param {ILinkRedirectService} deps.linkRedirectService
|
|
* @param {IPostLinkRepository} deps.postLinkRepository
|
|
* @param {DomainEvents} deps.DomainEvents
|
|
* @param {urlUtils} deps.urlUtils
|
|
*/
|
|
constructor(deps) {
|
|
this.#linkClickRepository = deps.linkClickRepository;
|
|
this.#linkRedirectService = deps.linkRedirectService;
|
|
this.#postLinkRepository = deps.postLinkRepository;
|
|
this.#DomainEvents = deps.DomainEvents;
|
|
this.#urlUtils = deps.urlUtils;
|
|
}
|
|
|
|
async init() {
|
|
if (this.#initialised) {
|
|
return;
|
|
}
|
|
this.subscribe();
|
|
this.#initialised = true;
|
|
}
|
|
|
|
/**
|
|
* @param {object} options
|
|
* @param {string} options.filter
|
|
* @return {Promise<FullPostLink[]>}
|
|
*/
|
|
async getLinks(options) {
|
|
return await this.#postLinkRepository.getAll({
|
|
filter: options.filter
|
|
});
|
|
}
|
|
|
|
/**
|
|
* validate and manage the new redirect url in filter
|
|
* `to` url needs decoding and transformation to relative url for comparision
|
|
* @param {string} filter
|
|
* @returns {Object} parsed filter
|
|
* @throws {errors.BadRequestError}
|
|
*/
|
|
#parseLinkFilter(filter) {
|
|
try {
|
|
const filterJson = nql(filter).parse();
|
|
const postId = filterJson?.$and?.[0]?.post_id;
|
|
const redirectUrl = new URL(filterJson?.$and?.[1]?.to);
|
|
if (!postId || !redirectUrl) {
|
|
throw new errors.BadRequestError({
|
|
message: tpl(messages.invalidFilter)
|
|
});
|
|
}
|
|
return {
|
|
postId,
|
|
redirectUrl
|
|
};
|
|
} catch (e) {
|
|
throw new errors.BadRequestError({
|
|
message: tpl(messages.invalidFilter),
|
|
context: e.message
|
|
});
|
|
}
|
|
}
|
|
|
|
#getRedirectLinkWithAttribution({newLink, oldLink, postId}) {
|
|
const newUrl = new URL(newLink);
|
|
const oldUrl = new URL(oldLink);
|
|
// append newsletter ref query param from oldUrl to newUrl
|
|
if (oldUrl.searchParams.has('ref')) {
|
|
newUrl.searchParams.set('ref', oldUrl.searchParams.get('ref'));
|
|
}
|
|
|
|
// append post attribution to site urls
|
|
const isSite = this.#urlUtils.isSiteUrl(newUrl);
|
|
if (isSite) {
|
|
newUrl.searchParams.set('attribution_type', 'post');
|
|
newUrl.searchParams.set('attribution_id', postId);
|
|
}
|
|
return newUrl;
|
|
}
|
|
|
|
async #updateLinks(data, options) {
|
|
const filterOptions = _.pick(options, ['transacting', 'context', 'filter']);
|
|
|
|
// decode and parse filter to manage new redirect url
|
|
const {postId, redirectUrl} = this.#parseLinkFilter(filterOptions.filter);
|
|
|
|
// manages transformation of current url to relative for comparision
|
|
const transformedOldUrl = this.#urlUtils.absoluteToTransformReady(redirectUrl.href);
|
|
const filterQuery = `post_id:'${postId}'+to:'${transformedOldUrl}'`;
|
|
|
|
const updatedFilterOptions = {
|
|
...filterOptions,
|
|
filter: filterQuery
|
|
};
|
|
|
|
// get new redirect link with proper attribution
|
|
const newRedirectUrl = this.#getRedirectLinkWithAttribution({
|
|
newLink: data.meta?.link?.to,
|
|
oldLink: redirectUrl.href,
|
|
postId
|
|
});
|
|
const linkIds = await this.#linkRedirectService.getFilteredIds(updatedFilterOptions);
|
|
|
|
const bulkUpdateOptions = _.pick(options, ['transacting']);
|
|
const updateData = {
|
|
to: this.#urlUtils.absoluteToTransformReady(newRedirectUrl.href),
|
|
updated_at: moment().format('YYYY-MM-DD HH:mm:ss')
|
|
};
|
|
|
|
return await this.#postLinkRepository.updateLinks(linkIds, updateData, bulkUpdateOptions);
|
|
}
|
|
|
|
async bulkEdit(data, options) {
|
|
if (data.action === 'updateLink') {
|
|
return await this.#updateLinks(data, options);
|
|
}
|
|
throw new errors.IncorrectUsageError({
|
|
message: tpl(messages.unsupportedBulkAction)
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @private (not using # to allow tests)
|
|
* Replace URL with a redirect that redirects to the original URL, and link that redirect with the given post
|
|
*/
|
|
async addRedirectToUrl(url, post) {
|
|
// Generate a unique redirect slug
|
|
const slugUrl = await this.#linkRedirectService.getSlugUrl();
|
|
|
|
// Add redirect for link click tracking
|
|
const redirect = await this.#linkRedirectService.addRedirect(slugUrl, url);
|
|
|
|
// Store a reference of the link against the post
|
|
const postLink = new PostLink({
|
|
link_id: redirect.link_id,
|
|
post_id: ObjectID.createFromHexString(post.id)
|
|
});
|
|
await this.#postLinkRepository.save(postLink);
|
|
|
|
return redirect.from;
|
|
}
|
|
|
|
/**
|
|
* Add tracking to a URL and returns a new URL (if link click tracking is enabled)
|
|
* @param {URL} url
|
|
* @param {Post} post
|
|
* @param {string} memberUuid
|
|
* @return {Promise<URL>}
|
|
*/
|
|
async addTrackingToUrl(url, post, memberUuid) {
|
|
url = await this.addRedirectToUrl(url, post);
|
|
url.searchParams.set('m', memberUuid);
|
|
return url;
|
|
}
|
|
|
|
subscribe() {
|
|
this.#DomainEvents.subscribe(RedirectEvent, async (event) => {
|
|
const uuid = event.data.url.searchParams.get('m');
|
|
if (!uuid) {
|
|
return;
|
|
}
|
|
|
|
const click = new LinkClick({
|
|
member_uuid: uuid,
|
|
link_id: event.data.link.link_id
|
|
});
|
|
await this.#linkClickRepository.save(click);
|
|
});
|
|
}
|
|
}
|
|
|
|
module.exports = LinkClickTrackingService;
|