Wired up link redirects & tracking (#15418)

refs https://github.com/TryGhost/Team/issues/1910
refs https://github.com/TryGhost/Team/issues/1888

- Uses an in-memory repository for now whilst in development
- Updates the LinkReplacementService to choose the slug
- Exposes a `getSlug` method so we can ensure uniqueness
- Emits the RedirectEvent for use by LinkTracking
This commit is contained in:
Fabien 'egg' O'Carroll 2022-09-16 04:42:21 -04:00 committed by GitHub
parent 87cbcc8f14
commit bddb0ba754
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 163 additions and 31 deletions

View File

@ -1,5 +1,5 @@
class LinkTrackingServiceWrapper {
init() {
async init() {
if (this.service) {
// Already done
return;
@ -9,9 +9,16 @@ class LinkTrackingServiceWrapper {
const LinkTrackingService = require('@tryghost/link-tracking');
// Expose the service
this.service = new LinkTrackingService();
this.service = new LinkTrackingService({
linkClickRepository: {
async save(linkClick) {
// eslint-disable-next-line no-console
console.log('Saving link click', linkClick);
}
}
});
return this.service.init();
await this.service.init();
}
}

View File

@ -1,3 +1,5 @@
const urlUtils = require('../../../shared/url-utils');
class LinkRedirectsServiceWrapper {
async init() {
if (this.service) {
@ -8,8 +10,23 @@ class LinkRedirectsServiceWrapper {
// Wire up all the dependencies
const {LinkRedirectsService} = require('@tryghost/link-redirects');
const store = [];
// Expose the service
this.service = new LinkRedirectsService();
this.service = new LinkRedirectsService({
linkRedirectRepository: {
async save(linkRedirect) {
store.push(linkRedirect);
},
async getByURL(url) {
return store.find((link) => {
return link.from.pathname === url.pathname;
});
}
},
config: {
baseURL: new URL(urlUtils.getSiteUrl())
}
});
}
}

View File

@ -1,21 +1,62 @@
const crypto = require('crypto');
const DomainEvents = require('@tryghost/domain-events');
const RedirectEvent = require('./RedirectEvent');
const LinkRedirect = require('./LinkRedirect');
/**
* @typedef {object} ILinkRedirectRepository
* @prop {(url: URL) => Promise<LinkRedirect>} getByURL
* @prop {(linkRedirect: LinkRedirect) => Promise<void>} save
*/
class LinkRedirectsService {
/** @type ILinkRedirectRepository */
#linkRedirectRepository;
/** @type URL */
#baseURL;
/**
* @param {object} deps
* @param {ILinkRedirectRepository} deps.linkRedirectRepository
* @param {object} deps.config
* @param {URL} deps.config.baseURL
*/
constructor(deps) {
this.#linkRedirectRepository = deps.linkRedirectRepository;
if (!deps.config.baseURL.pathname.endsWith('/')) {
this.#baseURL = new URL(deps.config.baseURL);
this.#baseURL.pathname += '/';
} else {
this.#baseURL = deps.config.baseURL;
}
this.handleRequest = this.handleRequest.bind(this);
}
/**
* Get a unique slug for a redirect which hasn't already been taken
*
* @returns {Promise<string>}
*/
async getSlug() {
return crypto.randomBytes(4).toString('hex');
}
/**
* @param {URL} to
* @param {string} slug
*
* @returns {Promise<LinkRedirect>}
*/
async addRedirect(to) {
const from = new URL(to);
from.searchParams.set('redirected', 'true'); // Dummy for skateboard
async addRedirect(to, slug) {
const from = new URL(`r/${slug}`, this.#baseURL);
const link = new LinkRedirect({
to,
from
});
await this.#linkRedirectRepository.save(link);
return link;
}
@ -27,7 +68,21 @@ class LinkRedirectsService {
* @returns {Promise<void>}
*/
async handleRequest(req, res, next) {
return next();
const url = new URL(req.originalUrl, this.#baseURL);
const link = await this.#linkRedirectRepository.getByURL(url);
if (!link) {
return next();
}
const event = RedirectEvent.create({
url,
link
});
DomainEvents.dispatch(event);
return res.redirect(link.to.href);
}
}

View File

@ -25,6 +25,7 @@
"sinon": "14.0.0"
},
"dependencies": {
"bson-objectid": "2.0.3"
"bson-objectid": "2.0.3",
"@tryghost/domain-events": "0.0.0"
}
}

View File

@ -6,7 +6,8 @@
/**
* @typedef {object} ILinkRedirectService
* @prop {(to: URL) => Promise<ILinkRedirect>} addRedirect
* @prop {(to: URL, slug: string) => Promise<ILinkRedirect>} addRedirect
* @prop {() => Promise<string>} getSlug
*/
/**
@ -87,8 +88,10 @@ class LinkReplacementService {
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);
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)

View File

@ -54,6 +54,9 @@ describe('LinkReplacementService', function () {
const linkRedirectService = {
addRedirect: (to) => {
return Promise.resolve({to, from: 'https://redirected.service/r/ro0sdD92'});
},
getSlug: () => {
return Promise.resolve('slug');
}
};
const service = new LinkReplacementService({
@ -120,19 +123,19 @@ describe('LinkReplacementService', 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')));
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')));
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')));
assert(redirectSpy.calledOnceWithExactly(new URL('http://external.domain/dir/path?rel=newsletter'), 'slug'));
});
});
@ -140,7 +143,7 @@ describe('LinkReplacementService', 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);
});
@ -148,7 +151,7 @@ describe('LinkReplacementService', function () {
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,25 @@
const ObjectID = require('bson-objectid').default;
module.exports = class ClickEvent {
/** @type {ObjectID} */
event_id;
/** @type {ObjectID} */
member_id;
/** @type {ObjectID} */
link_id;
constructor(data) {
if (!data.id) {
this.event_id = new ObjectID();
}
if (typeof data.id === 'string') {
this.event_id = ObjectID.createFromHexString(data.id);
} else {
this.event_id = data.id;
}
this.member_id = data.member_id;
this.link_id = data.link_id;
}
};

View File

@ -1,9 +1,28 @@
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;
@ -13,7 +32,7 @@ class LinkClickTrackingService {
}
/**
* @param {import('@tryghost/link-redirects/LinkRedirect')} redirect
* @param {import('@tryghost/link-redirects').LinkRedirect} redirect
* @param {string} id
* @return {Promise<URL>}
*/
@ -24,18 +43,25 @@ class LinkClickTrackingService {
}
subscribe() {
DomainEvents.subscribe(RedirectEvent, (event) => {
DomainEvents.subscribe(RedirectEvent, async (event) => {
const id = event.data.url.searchParams.get('m');
if (typeof id !== 'string') {
if (!id) {
return;
}
const clickEvent = {
member_id: id,
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
};
// eslint-disable-next-line no-console
console.log('Finna store a click event', clickEvent);
});
await this.#linkClickRepository.save(click);
});
}
}

View File

@ -24,7 +24,7 @@
"sinon": "14.0.0"
},
"dependencies": {
"@tryghost/domain-events": "^0.1.14",
"@tryghost/domain-events": "0.0.0",
"@tryghost/link-redirects": "0.0.0"
}
}

View File

@ -3489,11 +3489,6 @@
"@tryghost/root-utils" "^0.3.16"
debug "^4.3.1"
"@tryghost/domain-events@^0.1.14":
version "0.1.14"
resolved "https://registry.yarnpkg.com/@tryghost/domain-events/-/domain-events-0.1.14.tgz#a0206b21981d8e3337dfc0ed56df182d6e7188b3"
integrity sha512-SoJMvrwBXFDciQwjobpuZae0AQ/pVB+RgSj+QEuKNqg6V6CAhNlLrI1rAhkHtXkuKaLDDzH8tKWQEeeApXdBng==
"@tryghost/elasticsearch@^3.0.3":
version "3.0.3"
resolved "https://registry.yarnpkg.com/@tryghost/elasticsearch/-/elasticsearch-3.0.3.tgz#6651298989f38bbe30777ab122d56a43f719d2c2"