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:
parent
87cbcc8f14
commit
bddb0ba754
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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())
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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);
|
||||
});
|
||||
|
25
ghost/link-tracking/lib/LinkClick.js
Normal file
25
ghost/link-tracking/lib/LinkClick.js
Normal 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;
|
||||
}
|
||||
};
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user