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:
Simon Backx 2022-09-19 17:12:54 +02:00 committed by GitHub
parent 201d4ef228
commit 4c5ba4ed7d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 404 additions and 426 deletions

View File

@ -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');

View File

@ -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);
}
};

View File

@ -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()
});
}
};

View File

@ -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();

View File

@ -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')))
});
}
}
};

View File

@ -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())
}

View File

@ -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();

View File

@ -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;
});
}
}

View File

@ -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",

View File

@ -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
*/

View File

@ -1 +0,0 @@
module.exports = require('./lib/link-replacement');

View File

@ -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;

View File

@ -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);
});
});
});
});

View File

@ -0,0 +1 @@
module.exports = require('./lib/LinkReplacer');

View 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();

View File

@ -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",

View 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);
});
});
});

View File

@ -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;
}
};

View 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;

View 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;
}
}
};

View File

@ -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')
};

View File

@ -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"