Added database storage for link redirects and click events (#15423)
closes https://github.com/TryGhost/Team/issues/1916 closes https://github.com/TryGhost/Team/issues/1917 - Added database storage for link redirects and click events via repositories (hides away database layer) defined in the wrapper services - Added LinkClickRepository to store click events to database - Added LinkRedirectRepository to store link redirects to database - Added PostLinkRepository to link LinkRedirects with posts - Renamed link-replacement package to link-replacer, and made it dependency less (it only replaces links now, doesn't do anything else) - The link-tracking service has a new `addTrackingToUrl` which returns a new URL that includes tracking. The new `addRedirectToUrl` method does the same but without tracking for now. - MEGA service now uses the link-replacer to replace links in the emails using a combination of different services (member attribution + link-tracking service)
This commit is contained in:
parent
201d4ef228
commit
4c5ba4ed7d
@ -287,7 +287,6 @@ async function initServices({config}) {
|
||||
const staffService = require('./server/services/staff');
|
||||
const memberAttribution = require('./server/services/member-attribution');
|
||||
const membersEvents = require('./server/services/members-events');
|
||||
const linkReplacement = require('./server/services/link-replacement');
|
||||
const linkTracking = require('./server/services/link-click-tracking');
|
||||
|
||||
const urlUtils = require('./shared/url-utils');
|
||||
@ -316,8 +315,7 @@ async function initServices({config}) {
|
||||
apiUrl: urlUtils.urlFor('api', {type: 'admin'}, true)
|
||||
}),
|
||||
comments.init(),
|
||||
linkTracking.init(),
|
||||
linkReplacement.init()
|
||||
linkTracking.init()
|
||||
]);
|
||||
debug('End: Services');
|
||||
|
||||
|
@ -0,0 +1,39 @@
|
||||
const ObjectID = require('bson-objectid').default;
|
||||
|
||||
module.exports = class LinkClickRepository {
|
||||
/** @type {Object} */
|
||||
#MemberLinkClickEvent;
|
||||
|
||||
/** @type {object} */
|
||||
#Member;
|
||||
|
||||
/**
|
||||
* @param {object} deps
|
||||
* @param {object} deps.MemberLinkClickEvent Bookshelf Model
|
||||
* @param {object} deps.Member Bookshelf Model
|
||||
*/
|
||||
constructor(deps) {
|
||||
this.#MemberLinkClickEvent = deps.MemberLinkClickEvent;
|
||||
this.#Member = deps.Member;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('@tryghost/link-tracking').LinkClick} linkClick
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async save(linkClick) {
|
||||
// Convert uuid to id
|
||||
const member = await this.#Member.findOne({uuid: linkClick.member_uuid});
|
||||
if (!member) {
|
||||
return;
|
||||
}
|
||||
|
||||
const model = await this.#MemberLinkClickEvent.add({
|
||||
// Only store the parthname (no support for variable query strings)
|
||||
link_id: linkClick.link_id.toHexString(),
|
||||
member_id: member.id
|
||||
}, {});
|
||||
|
||||
linkClick.event_id = ObjectID.createFromHexString(model.id);
|
||||
}
|
||||
};
|
@ -0,0 +1,28 @@
|
||||
/**
|
||||
* @typedef {import('bson-objectid').default} ObjectID
|
||||
*/
|
||||
|
||||
module.exports = class PostLinkRepository {
|
||||
/** @type {Object} */
|
||||
#LinkRedirect;
|
||||
|
||||
/**
|
||||
* @param {object} deps
|
||||
* @param {object} deps.LinkRedirect Bookshelf Model
|
||||
*/
|
||||
constructor(deps) {
|
||||
this.#LinkRedirect = deps.LinkRedirect;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('@tryghost/link-tracking/lib/PostLink')} postLink
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async save(postLink) {
|
||||
await this.#LinkRedirect.edit({
|
||||
post_id: postLink.post_id.toHexString()
|
||||
}, {
|
||||
id: postLink.link_id.toHexString()
|
||||
});
|
||||
}
|
||||
};
|
@ -1,3 +1,6 @@
|
||||
const LinkClickRepository = require('./LinkClickRepository');
|
||||
const PostLinkRepository = require('./PostLinkRepository');
|
||||
|
||||
class LinkTrackingServiceWrapper {
|
||||
async init() {
|
||||
if (this.service) {
|
||||
@ -6,16 +9,23 @@ class LinkTrackingServiceWrapper {
|
||||
}
|
||||
|
||||
// Wire up all the dependencies
|
||||
const LinkTrackingService = require('@tryghost/link-tracking');
|
||||
const models = require('../../models');
|
||||
const {LinkTrackingService} = require('@tryghost/link-tracking');
|
||||
|
||||
const postLinkRepository = new PostLinkRepository({
|
||||
LinkRedirect: models.LinkRedirect
|
||||
});
|
||||
|
||||
const linkClickRepository = new LinkClickRepository({
|
||||
MemberLinkClickEvent: models.MemberLinkClickEvent,
|
||||
Member: models.Member
|
||||
});
|
||||
|
||||
// Expose the service
|
||||
this.service = new LinkTrackingService({
|
||||
linkClickRepository: {
|
||||
async save(linkClick) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Saving link click', linkClick);
|
||||
}
|
||||
}
|
||||
linkRedirectService: require('../link-redirection').service,
|
||||
linkClickRepository,
|
||||
postLinkRepository
|
||||
});
|
||||
|
||||
await this.service.init();
|
||||
|
@ -0,0 +1,54 @@
|
||||
const LinkRedirect = require('@tryghost/link-redirects').LinkRedirect;
|
||||
const ObjectID = require('bson-objectid').default;
|
||||
|
||||
module.exports = class LinkRedirectRepository {
|
||||
/** @type {Object} */
|
||||
#LinkRedirect;
|
||||
/** @type {Object} */
|
||||
#urlUtils;
|
||||
|
||||
/**
|
||||
* @param {object} deps
|
||||
* @param {object} deps.LinkRedirect Bookshelf Model
|
||||
* @param {object} deps.urlUtils
|
||||
*/
|
||||
constructor(deps) {
|
||||
this.#LinkRedirect = deps.LinkRedirect;
|
||||
this.#urlUtils = deps.urlUtils;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {InstanceType<LinkRedirect>} linkRedirect
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async save(linkRedirect) {
|
||||
const model = await this.#LinkRedirect.add({
|
||||
// Only store the parthname (no support for variable query strings)
|
||||
from: linkRedirect.from.pathname,
|
||||
to: this.#urlUtils.absoluteToTransformReady(linkRedirect.to.href)
|
||||
}, {});
|
||||
|
||||
linkRedirect.link_id = ObjectID.createFromHexString(model.id);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {URL} url
|
||||
* @returns {Promise<InstanceType<LinkRedirect>|undefined>} linkRedirect
|
||||
*/
|
||||
async getByURL(url) {
|
||||
// TODO: strip subdirectory from url.pathname
|
||||
|
||||
const linkRedirect = await this.#LinkRedirect.findOne({
|
||||
from: url.pathname
|
||||
}, {});
|
||||
|
||||
if (linkRedirect) {
|
||||
return new LinkRedirect({
|
||||
id: linkRedirect.id,
|
||||
from: url,
|
||||
to: new URL(this.#urlUtils.transformReadyToAbsolute(linkRedirect.get('to')))
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
@ -1,4 +1,5 @@
|
||||
const urlUtils = require('../../../shared/url-utils');
|
||||
const LinkRedirectRepository = require('./LinkRedirectRepository');
|
||||
|
||||
class LinkRedirectsServiceWrapper {
|
||||
async init() {
|
||||
@ -8,21 +9,18 @@ class LinkRedirectsServiceWrapper {
|
||||
}
|
||||
|
||||
// Wire up all the dependencies
|
||||
const models = require('../../models');
|
||||
|
||||
const {LinkRedirectsService} = require('@tryghost/link-redirects');
|
||||
|
||||
const store = [];
|
||||
const linkRedirectRepository = new LinkRedirectRepository({
|
||||
LinkRedirect: models.LinkRedirect,
|
||||
urlUtils
|
||||
});
|
||||
|
||||
// Expose the service
|
||||
this.service = new LinkRedirectsService({
|
||||
linkRedirectRepository: {
|
||||
async save(linkRedirect) {
|
||||
store.push(linkRedirect);
|
||||
},
|
||||
async getByURL(url) {
|
||||
return store.find((link) => {
|
||||
return link.from.pathname === url.pathname;
|
||||
});
|
||||
}
|
||||
},
|
||||
linkRedirectRepository,
|
||||
config: {
|
||||
baseURL: new URL(urlUtils.getSiteUrl())
|
||||
}
|
||||
|
@ -1,24 +0,0 @@
|
||||
class LinkReplacementServiceWrapper {
|
||||
init() {
|
||||
if (this.service) {
|
||||
// Already done
|
||||
return;
|
||||
}
|
||||
|
||||
// Wire up all the dependencies
|
||||
const LinkReplacementService = require('@tryghost/link-replacement');
|
||||
const urlUtils = require('../../../shared/url-utils');
|
||||
const settingsCache = require('../../../shared/settings-cache');
|
||||
|
||||
// Expose the service
|
||||
this.service = new LinkReplacementService({
|
||||
linkRedirectService: require('../link-redirection').service,
|
||||
linkClickTrackingService: require('../link-click-tracking').service,
|
||||
attributionService: require('../member-attribution').service,
|
||||
urlUtils,
|
||||
settingsCache
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new LinkReplacementServiceWrapper();
|
@ -14,7 +14,9 @@ const {isUnsplashImage, isLocalContentImage} = require('@tryghost/kg-default-car
|
||||
const {textColorForBackgroundColor, darkenToContrastThreshold} = require('@tryghost/color-utils');
|
||||
const logging = require('@tryghost/logging');
|
||||
const urlService = require('../../services/url');
|
||||
const linkReplacement = require('../link-replacement');
|
||||
const linkReplacer = require('@tryghost/link-replacer');
|
||||
const linkTracking = require('../link-click-tracking');
|
||||
const memberAttribution = require('../member-attribution');
|
||||
|
||||
const ALLOWED_REPLACEMENTS = ['first_name', 'uuid'];
|
||||
|
||||
@ -357,7 +359,31 @@ const PostEmailSerializer = {
|
||||
// Now replace the links in the HTML version
|
||||
if (labs.isSet('emailClicks')) {
|
||||
if ((!options.isBrowserPreview && !options.isTestEmail) || process.env.NODE_ENV === 'development') {
|
||||
result.html = await linkReplacement.service.replaceLinks(result.html, newsletter, postModel);
|
||||
const enableTracking = settingsCache.get('email_track_clicks');
|
||||
result.html = await linkReplacer.replace(result.html, async (url) => {
|
||||
// Add newsletter source attribution
|
||||
url = memberAttribution.service.addEmailSourceAttributionTracking(url, newsletter);
|
||||
const isSite = urlUtils.isSiteUrl(url);
|
||||
|
||||
// Add post attribution tracking
|
||||
if (isSite && enableTracking) {
|
||||
// Only add attribution links to our own site (except for the newsletter referrer)
|
||||
url = memberAttribution.service.addPostAttributionTracking(url, post);
|
||||
}
|
||||
|
||||
// Add link click tracking
|
||||
if (enableTracking) {
|
||||
url = await linkTracking.service.addTrackingToUrl(url, post, '--uuid--');
|
||||
|
||||
// We need to convert to a string at this point, because we need invalid string characters in the URL
|
||||
const str = url.toString().replace(/--uuid--/g, '%%{uuid}%%');
|
||||
return str;
|
||||
}
|
||||
|
||||
// Replace the URL with a normal redirect so we can change it later, but don't include tracking
|
||||
url = linkTracking.service.addRedirectToUrl(url, post);
|
||||
return url;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -83,7 +83,7 @@
|
||||
"@tryghost/kg-mobiledoc-html-renderer": "5.3.7",
|
||||
"@tryghost/limit-service": "1.2.3",
|
||||
"@tryghost/link-redirects": "0.0.0",
|
||||
"@tryghost/link-replacement": "0.0.0",
|
||||
"@tryghost/link-replacer": "0.0.0",
|
||||
"@tryghost/link-tracking": "0.0.0",
|
||||
"@tryghost/logging": "2.3.1",
|
||||
"@tryghost/magic-link": "0.0.0",
|
||||
@ -120,7 +120,7 @@
|
||||
"@tryghost/string": "0.2.1",
|
||||
"@tryghost/tpl": "0.1.18",
|
||||
"@tryghost/update-check-service": "0.0.0",
|
||||
"@tryghost/url-utils": "4.1.0",
|
||||
"@tryghost/url-utils": "4.2.0",
|
||||
"@tryghost/validator": "0.1.28",
|
||||
"@tryghost/verification-trigger": "0.0.0",
|
||||
"@tryghost/version": "0.1.16",
|
||||
|
@ -5,7 +5,7 @@ const LinkRedirect = require('./LinkRedirect');
|
||||
|
||||
/**
|
||||
* @typedef {object} ILinkRedirectRepository
|
||||
* @prop {(url: URL) => Promise<LinkRedirect>} getByURL
|
||||
* @prop {(url: URL) => Promise<LinkRedirect|undefined>} getByURL
|
||||
* @prop {(linkRedirect: LinkRedirect) => Promise<void>} save
|
||||
*/
|
||||
|
||||
|
@ -1 +0,0 @@
|
||||
module.exports = require('./lib/link-replacement');
|
@ -1,137 +0,0 @@
|
||||
/**
|
||||
* @typedef {object} ILinkRedirect
|
||||
* @prop {URL} to
|
||||
* @prop {URL} from
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} ILinkRedirectService
|
||||
* @prop {(to: URL, slug: string) => Promise<ILinkRedirect>} addRedirect
|
||||
* @prop {() => Promise<string>} getSlug
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} ILinkClickTrackingService
|
||||
* @prop {(link: ILinkRedirect, uuid: string) => Promise<URL>} addTrackingToRedirect
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {import('@tryghost/member-attribution/lib/service')} IAttributionService
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} UrlUtils
|
||||
* @prop {(context: string, absolute?: boolean) => string} urlFor
|
||||
* @prop {(...parts: string[]) => string} urlJoin
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} SettingsCache
|
||||
* @prop {(key: string, options?: any) => any} get
|
||||
*/
|
||||
|
||||
class LinkReplacementService {
|
||||
/** @type ILinkRedirectService */
|
||||
#linkRedirectService;
|
||||
/** @type ILinkClickTrackingService */
|
||||
#linkClickTrackingService;
|
||||
/** @type IAttributionService */
|
||||
#attributionService;
|
||||
/** @type UrlUtils */
|
||||
#urlUtils;
|
||||
/** @type SettingsCache */
|
||||
#settingsCache;
|
||||
|
||||
/**
|
||||
* @param {object} deps
|
||||
* @param {ILinkRedirectService} deps.linkRedirectService
|
||||
* @param {ILinkClickTrackingService} deps.linkClickTrackingService
|
||||
* @param {IAttributionService} deps.attributionService
|
||||
* @param {UrlUtils} deps.urlUtils
|
||||
* @param {SettingsCache} deps.settingsCache
|
||||
*/
|
||||
constructor(deps) {
|
||||
this.#linkRedirectService = deps.linkRedirectService;
|
||||
this.#linkClickTrackingService = deps.linkClickTrackingService;
|
||||
this.#attributionService = deps.attributionService;
|
||||
this.#urlUtils = deps.urlUtils;
|
||||
this.#settingsCache = deps.settingsCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private (# doesn't work because this method is being tested)
|
||||
* Return whether the provided URL is a link to the site
|
||||
* @param {URL} url
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isSiteDomain(url) {
|
||||
const siteUrl = new URL(this.#urlUtils.urlFor('home', true));
|
||||
if (siteUrl.host === url.host) {
|
||||
if (url.pathname.startsWith(siteUrl.pathname)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async replaceLink(url, newsletter, post) {
|
||||
// Can probably happen in one call to the MemberAttributionService (but just to make clear what happens here)
|
||||
const isSite = this.isSiteDomain(url);
|
||||
const enableTracking = this.#settingsCache.get('email_track_clicks');
|
||||
|
||||
// 1. Add attribution
|
||||
url = this.#attributionService.addEmailSourceAttributionTracking(url, newsletter);
|
||||
|
||||
if (isSite && enableTracking) {
|
||||
// Only add attribution links to our own site (except for the newsletter referrer)
|
||||
url = this.#attributionService.addPostAttributionTracking(url, post);
|
||||
}
|
||||
|
||||
const slug = await this.#linkRedirectService.getSlug();
|
||||
|
||||
// 2. Add redirect for link click tracking
|
||||
const redirect = await this.#linkRedirectService.addRedirect(url, slug);
|
||||
|
||||
// 3. Add click tracking by members
|
||||
// Note: we can always add the tracking params (even when isSite === false)
|
||||
// because they are added to the redirect and not the destination URL
|
||||
|
||||
if (enableTracking) {
|
||||
return this.#linkClickTrackingService.addTrackingToRedirect(redirect, '--uuid--');
|
||||
}
|
||||
|
||||
return redirect.from;
|
||||
}
|
||||
|
||||
/**
|
||||
Replaces the links in the provided HTML
|
||||
@param {string} html
|
||||
@param {Object} newsletter
|
||||
@param {Object} post
|
||||
@returns {Promise<string>}
|
||||
*/
|
||||
async replaceLinks(html, newsletter, post) {
|
||||
const cheerio = require('cheerio');
|
||||
const $ = cheerio.load(html);
|
||||
|
||||
for (const el of $('a').toArray()) {
|
||||
const href = $(el).attr('href');
|
||||
try {
|
||||
if (href) {
|
||||
let url = new URL(href);
|
||||
url = await this.replaceLink(url, newsletter, post);
|
||||
|
||||
// Replace the replacement placeholder with a string that is not a valid URL but that will get replaced later on
|
||||
const str = url.toString().replace(/--uuid--/g, '%%{uuid}%%');
|
||||
$(el).attr('href', str);
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore invalid URLs
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.resolve($.html());
|
||||
}
|
||||
}
|
||||
module.exports = LinkReplacementService;
|
@ -1,160 +0,0 @@
|
||||
// Switch these lines once there are useful utils
|
||||
// const testUtils = require('./utils');
|
||||
const sinon = require('sinon');
|
||||
const assert = require('assert');
|
||||
const LinkReplacementService = require('../lib/link-replacement');
|
||||
|
||||
describe('LinkReplacementService', function () {
|
||||
it('exported', function () {
|
||||
assert.equal(require('../index'), LinkReplacementService);
|
||||
});
|
||||
|
||||
describe('isSiteDomain', function () {
|
||||
const serviceWithout = new LinkReplacementService({
|
||||
urlUtils: {
|
||||
urlFor: () => 'http://localhost:2368'
|
||||
},
|
||||
settingsCache: {
|
||||
get: () => true
|
||||
}
|
||||
});
|
||||
|
||||
const serviceWith = new LinkReplacementService({
|
||||
urlUtils: {
|
||||
urlFor: () => 'http://localhost:2368/dir'
|
||||
},
|
||||
settingsCache: {
|
||||
get: () => true
|
||||
}
|
||||
});
|
||||
|
||||
it('returns true for the root domain', function () {
|
||||
assert(serviceWithout.isSiteDomain(new URL('http://localhost:2368')));
|
||||
assert(serviceWith.isSiteDomain(new URL('http://localhost:2368/dir')));
|
||||
});
|
||||
|
||||
it('returns true for a path along the root domain', function () {
|
||||
assert(serviceWithout.isSiteDomain(new URL('http://localhost:2368/path')));
|
||||
assert(serviceWith.isSiteDomain(new URL('http://localhost:2368/dir/path')));
|
||||
});
|
||||
|
||||
it('returns false for a different domain', function () {
|
||||
assert(!serviceWithout.isSiteDomain(new URL('https://google.com/path')));
|
||||
assert(!serviceWith.isSiteDomain(new URL('https://google.com/dir/path')));
|
||||
});
|
||||
|
||||
it('returns false if not on same subdirectory', function () {
|
||||
assert(!serviceWith.isSiteDomain(new URL('http://localhost:2368/different-dir')));
|
||||
// Check if the matching is not dumb and only matches at the start
|
||||
assert(!serviceWith.isSiteDomain(new URL('http://localhost:2368/different/dir')));
|
||||
});
|
||||
});
|
||||
|
||||
describe('replacing links', function () {
|
||||
const linkRedirectService = {
|
||||
addRedirect: (to) => {
|
||||
return Promise.resolve({to, from: 'https://redirected.service/r/ro0sdD92'});
|
||||
},
|
||||
getSlug: () => {
|
||||
return Promise.resolve('slug');
|
||||
}
|
||||
};
|
||||
const service = new LinkReplacementService({
|
||||
urlUtils: {
|
||||
urlFor: () => 'http://localhost:2368/dir'
|
||||
},
|
||||
linkRedirectService,
|
||||
linkClickTrackingService: {
|
||||
addTrackingToRedirect: (link, uuid) => {
|
||||
return Promise.resolve(new URL(`${link.from}?m=${uuid}`));
|
||||
}
|
||||
},
|
||||
attributionService: {
|
||||
addEmailSourceAttributionTracking: (url) => {
|
||||
url.searchParams.append('rel', 'newsletter');
|
||||
return url;
|
||||
},
|
||||
addPostAttributionTracking: (url, post) => {
|
||||
url.searchParams.append('attribution_id', post.id);
|
||||
return url;
|
||||
}
|
||||
},
|
||||
settingsCache: {
|
||||
get: () => true
|
||||
}
|
||||
});
|
||||
|
||||
const disabledService = new LinkReplacementService({
|
||||
urlUtils: {
|
||||
urlFor: () => 'http://localhost:2368/dir'
|
||||
},
|
||||
linkRedirectService,
|
||||
linkClickTrackingService: {
|
||||
addTrackingToRedirect: (link, uuid) => {
|
||||
return Promise.resolve(new URL(`${link.from}?m=${uuid}`));
|
||||
}
|
||||
},
|
||||
attributionService: {
|
||||
addEmailSourceAttributionTracking: (url) => {
|
||||
url.searchParams.append('rel', 'newsletter');
|
||||
return url;
|
||||
},
|
||||
addPostAttributionTracking: (url, post) => {
|
||||
url.searchParams.append('attribution_id', post.id);
|
||||
return url;
|
||||
}
|
||||
},
|
||||
settingsCache: {
|
||||
get: () => false
|
||||
}
|
||||
});
|
||||
|
||||
let redirectSpy;
|
||||
|
||||
beforeEach(function () {
|
||||
redirectSpy = sinon.spy(linkRedirectService, 'addRedirect');
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
sinon.restore();
|
||||
});
|
||||
|
||||
describe('replaceLink', function () {
|
||||
it('returns the redirected URL with uuid', async function () {
|
||||
const replaced = await service.replaceLink(new URL('http://localhost:2368/dir/path'), {}, {id: 'post_id'});
|
||||
assert.equal(replaced.toString(), 'https://redirected.service/r/ro0sdD92?m=--uuid--');
|
||||
assert(redirectSpy.calledOnceWithExactly(new URL('http://localhost:2368/dir/path?rel=newsletter&attribution_id=post_id'), 'slug'));
|
||||
});
|
||||
|
||||
it('does not add attribution for external sites', async function () {
|
||||
const replaced = await service.replaceLink(new URL('http://external.domain/dir/path'), {}, {id: 'post_id'});
|
||||
assert.equal(replaced.toString(), 'https://redirected.service/r/ro0sdD92?m=--uuid--');
|
||||
assert(redirectSpy.calledOnceWithExactly(new URL('http://external.domain/dir/path?rel=newsletter'), 'slug'));
|
||||
});
|
||||
|
||||
it('does not add attribution or member tracking if click tracking is disabled', async function () {
|
||||
const replaced = await disabledService.replaceLink(new URL('http://external.domain/dir/path'), {}, {id: 'post_id'});
|
||||
assert.equal(replaced.toString(), 'https://redirected.service/r/ro0sdD92');
|
||||
assert(redirectSpy.calledOnceWithExactly(new URL('http://external.domain/dir/path?rel=newsletter'), 'slug'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('replaceLinks', function () {
|
||||
it('Replaces hrefs inside links', async function () {
|
||||
const html = '<a href="http://localhost:2368/dir/path">link</a>';
|
||||
const expected = '<a href="https://redirected.service/r/ro0sdD92?m=%%{uuid}%%">link</a>';
|
||||
|
||||
const replaced = await service.replaceLinks(html, {}, {id: 'post_id'});
|
||||
assert.equal(replaced, expected);
|
||||
});
|
||||
|
||||
it('Ignores invalid links', async function () {
|
||||
const html = '<a href="%%{unsubscribe_url}%%">link</a>';
|
||||
const expected = '<a href="%%{unsubscribe_url}%%">link</a>';
|
||||
|
||||
const replaced = await service.replaceLinks(html, {}, {id: 'post_id'});
|
||||
assert.equal(replaced, expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
1
ghost/link-replacer/index.js
Normal file
1
ghost/link-replacer/index.js
Normal file
@ -0,0 +1 @@
|
||||
module.exports = require('./lib/LinkReplacer');
|
33
ghost/link-replacer/lib/LinkReplacer.js
Normal file
33
ghost/link-replacer/lib/LinkReplacer.js
Normal file
@ -0,0 +1,33 @@
|
||||
class LinkReplacer {
|
||||
/**
|
||||
* Replaces the links in the provided HTML
|
||||
* @param {string} html
|
||||
* @param {(url: URL): Promise<URL|string>} replaceLink
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async replace(html, replaceLink) {
|
||||
const cheerio = require('cheerio');
|
||||
const $ = cheerio.load(html);
|
||||
|
||||
for (const el of $('a').toArray()) {
|
||||
const href = $(el).attr('href');
|
||||
if (href) {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(href);
|
||||
} catch (e) {
|
||||
// Ignore invalid URLs
|
||||
}
|
||||
if (url) {
|
||||
url = await replaceLink(url);
|
||||
const str = url.toString();
|
||||
$(el).attr('href', str);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $.html();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new LinkReplacer();
|
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@tryghost/link-replacement",
|
||||
"name": "@tryghost/link-replacer",
|
||||
"version": "0.0.0",
|
||||
"repository": "https://github.com/TryGhost/Ghost/tree/main/packages/link-replacement",
|
||||
"repository": "https://github.com/TryGhost/Ghost/tree/main/packages/link-replacer",
|
||||
"author": "Ghost Foundation",
|
||||
"private": true,
|
||||
"main": "index.js",
|
34
ghost/link-replacer/test/LinkReplacer.test.js
Normal file
34
ghost/link-replacer/test/LinkReplacer.test.js
Normal file
@ -0,0 +1,34 @@
|
||||
const assert = require('assert');
|
||||
const linkReplacer = require('../lib/LinkReplacer');
|
||||
|
||||
describe('LinkReplacementService', function () {
|
||||
it('exported', function () {
|
||||
assert.equal(require('../index'), linkReplacer);
|
||||
});
|
||||
|
||||
describe('replace', function () {
|
||||
it('Can replace to URL', async function () {
|
||||
const html = '<a href="http://localhost:2368/dir/path">link</a>';
|
||||
const expected = '<a href="https://google.com/test-dir?test-query">link</a>';
|
||||
|
||||
const replaced = await linkReplacer.replace(html, () => new URL('https://google.com/test-dir?test-query'));
|
||||
assert.equal(replaced, expected);
|
||||
});
|
||||
|
||||
it('Can replace to string', async function () {
|
||||
const html = '<a href="http://localhost:2368/dir/path">link</a>';
|
||||
const expected = '<a href="#valid-string">link</a>';
|
||||
|
||||
const replaced = await linkReplacer.replace(html, () => '#valid-string');
|
||||
assert.equal(replaced, expected);
|
||||
});
|
||||
|
||||
it('Ignores invalid links', async function () {
|
||||
const html = '<a href="invalid">link</a>';
|
||||
const expected = '<a href="invalid">link</a>';
|
||||
|
||||
const replaced = await linkReplacer.replace(html, () => 'valid');
|
||||
assert.equal(replaced, expected);
|
||||
});
|
||||
});
|
||||
});
|
@ -3,8 +3,8 @@ const ObjectID = require('bson-objectid').default;
|
||||
module.exports = class ClickEvent {
|
||||
/** @type {ObjectID} */
|
||||
event_id;
|
||||
/** @type {ObjectID} */
|
||||
member_id;
|
||||
/** @type {string} */
|
||||
member_uuid;
|
||||
/** @type {ObjectID} */
|
||||
link_id;
|
||||
|
||||
@ -19,7 +19,7 @@ module.exports = class ClickEvent {
|
||||
this.event_id = data.id;
|
||||
}
|
||||
|
||||
this.member_id = data.member_id;
|
||||
this.member_uuid = data.member_uuid;
|
||||
this.link_id = data.link_id;
|
||||
}
|
||||
};
|
||||
|
114
ghost/link-tracking/lib/LinkClickTrackingService.js
Normal file
114
ghost/link-tracking/lib/LinkClickTrackingService.js
Normal file
@ -0,0 +1,114 @@
|
||||
const DomainEvents = require('@tryghost/domain-events');
|
||||
const {RedirectEvent} = require('@tryghost/link-redirects');
|
||||
const LinkClick = require('./LinkClick');
|
||||
const PostLink = require('./PostLink');
|
||||
const ObjectID = require('bson-objectid').default;
|
||||
|
||||
/**
|
||||
* @typedef {object} ILinkClickRepository
|
||||
* @prop {(event: LinkClick) => Promise<void>} save
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} ILinkRedirect
|
||||
* @prop {ObjectID} link_id
|
||||
* @prop {URL} to
|
||||
* @prop {URL} from
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} ILinkRedirectService
|
||||
* @prop {(to: URL, slug: string) => Promise<ILinkRedirect>} addRedirect
|
||||
* @prop {() => Promise<string>} getSlug
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} ILinkClickTrackingService
|
||||
* @prop {(link: ILinkRedirect, uuid: string) => Promise<URL>} addTrackingToRedirect
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} IPostLinkRepository
|
||||
* @prop {(postLink: PostLink) => Promise<void>} save
|
||||
*/
|
||||
|
||||
class LinkClickTrackingService {
|
||||
#initialised = false;
|
||||
|
||||
/** @type ILinkClickRepository */
|
||||
#linkClickRepository;
|
||||
/** @type ILinkRedirectService */
|
||||
#linkRedirectService;
|
||||
/** @type IPostLinkRepository */
|
||||
#postLinkRepository;
|
||||
|
||||
/**
|
||||
* @param {object} deps
|
||||
* @param {ILinkClickRepository} deps.linkClickRepository
|
||||
* @param {ILinkRedirectService} deps.linkRedirectService
|
||||
* @param {IPostLinkRepository} deps.postLinkRepository
|
||||
*/
|
||||
constructor(deps) {
|
||||
this.#linkClickRepository = deps.linkClickRepository;
|
||||
this.#linkRedirectService = deps.linkRedirectService;
|
||||
this.#postLinkRepository = deps.postLinkRepository;
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (this.#initialised) {
|
||||
return;
|
||||
}
|
||||
this.subscribe();
|
||||
this.#initialised = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 slug = await this.#linkRedirectService.getSlug();
|
||||
|
||||
// Add redirect for link click tracking
|
||||
const redirect = await this.#linkRedirectService.addRedirect(url, slug);
|
||||
|
||||
// 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() {
|
||||
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;
|
29
ghost/link-tracking/lib/PostLink.js
Normal file
29
ghost/link-tracking/lib/PostLink.js
Normal file
@ -0,0 +1,29 @@
|
||||
const ObjectID = require('bson-objectid').default;
|
||||
|
||||
/**
|
||||
* Stores the connection between a LinkRedirect and a Post
|
||||
*/
|
||||
module.exports = class PostLink {
|
||||
/** @type {ObjectID} */
|
||||
post_id;
|
||||
/** @type {ObjectID} */
|
||||
link_id;
|
||||
|
||||
/**
|
||||
* @param {object} data
|
||||
* @param {string|ObjectID} data.post_id
|
||||
* @param {string|ObjectID} data.link_id
|
||||
*/
|
||||
constructor(data) {
|
||||
if (typeof data.post_id === 'string') {
|
||||
this.post_id = ObjectID.createFromHexString(data.post_id);
|
||||
} else {
|
||||
this.post_id = data.post_id;
|
||||
}
|
||||
if (typeof data.link_id === 'string') {
|
||||
this.link_id = ObjectID.createFromHexString(data.link_id);
|
||||
} else {
|
||||
this.link_id = data.link_id;
|
||||
}
|
||||
}
|
||||
};
|
@ -1,69 +1,5 @@
|
||||
const ObjectID = require('bson-objectid').default;
|
||||
const DomainEvents = require('@tryghost/domain-events');
|
||||
const {RedirectEvent} = require('@tryghost/link-redirects');
|
||||
const logging = require('@tryghost/logging');
|
||||
const LinkClick = require('./LinkClick');
|
||||
|
||||
/**
|
||||
* @typedef {object} ILinkClickRepository
|
||||
* @prop {(event: LinkClick) => Promise<void>} save
|
||||
*/
|
||||
|
||||
class LinkClickTrackingService {
|
||||
#initialised = false;
|
||||
|
||||
/** @type ILinkClickRepository */
|
||||
#linkClickRepository;
|
||||
|
||||
/**
|
||||
* @param {object} deps
|
||||
* @param {ILinkClickRepository} deps.linkClickRepository
|
||||
*/
|
||||
constructor(deps) {
|
||||
this.#linkClickRepository = deps.linkClickRepository;
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (this.#initialised) {
|
||||
return;
|
||||
}
|
||||
this.subscribe();
|
||||
this.#initialised = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('@tryghost/link-redirects').LinkRedirect} redirect
|
||||
* @param {string} id
|
||||
* @return {Promise<URL>}
|
||||
*/
|
||||
async addTrackingToRedirect(redirect, id){
|
||||
const trackingUrl = new URL(redirect.from);
|
||||
trackingUrl.searchParams.set('m', id);
|
||||
return trackingUrl;
|
||||
}
|
||||
|
||||
subscribe() {
|
||||
DomainEvents.subscribe(RedirectEvent, async (event) => {
|
||||
const id = event.data.url.searchParams.get('m');
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
|
||||
let memberId;
|
||||
try {
|
||||
memberId = ObjectID.createFromHexString(id);
|
||||
} catch (err) {
|
||||
logging.warn(`Invalid member_id "${id}" found during redirect`);
|
||||
return;
|
||||
}
|
||||
|
||||
const click = new LinkClick({
|
||||
member_id: memberId,
|
||||
link_id: event.data.link.link_id
|
||||
});
|
||||
await this.#linkClickRepository.save(click);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = LinkClickTrackingService;
|
||||
module.exports = {
|
||||
LinkTrackingService: require('./LinkClickTrackingService'),
|
||||
LinkClick: require('./LinkClick'),
|
||||
PostLink: require('./PostLink')
|
||||
};
|
||||
|
@ -4194,10 +4194,10 @@
|
||||
dependencies:
|
||||
lodash.template "^4.5.0"
|
||||
|
||||
"@tryghost/url-utils@4.1.0":
|
||||
version "4.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@tryghost/url-utils/-/url-utils-4.1.0.tgz#b07aa36dee52322fe989617a8cbff530dc12fbda"
|
||||
integrity sha512-ou0F8/7ql8d53FqoJWCmYZx1I0NgmiWOxWvlzyfZviSwzpw2qYcIj56FOYMcdTxYCShjQel6lP1jgoFEURUpHA==
|
||||
"@tryghost/url-utils@4.2.0":
|
||||
version "4.2.0"
|
||||
resolved "https://registry.yarnpkg.com/@tryghost/url-utils/-/url-utils-4.2.0.tgz#342c73c840dda4f2ba2316fc581167404e219554"
|
||||
integrity sha512-ipeGBj6CJau/17J+M1t2vUJvdptoTnCPVgph2mbKGclOEdAMv9h3/S15cNMnxIqoSNURAvwMKD+QxkOboM/7zg==
|
||||
dependencies:
|
||||
cheerio "^0.22.0"
|
||||
moment "^2.27.0"
|
||||
|
Loading…
Reference in New Issue
Block a user