mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-28 05:14:12 +03:00
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 {
|
class LinkTrackingServiceWrapper {
|
||||||
init() {
|
async init() {
|
||||||
if (this.service) {
|
if (this.service) {
|
||||||
// Already done
|
// Already done
|
||||||
return;
|
return;
|
||||||
@ -9,9 +9,16 @@ class LinkTrackingServiceWrapper {
|
|||||||
const LinkTrackingService = require('@tryghost/link-tracking');
|
const LinkTrackingService = require('@tryghost/link-tracking');
|
||||||
|
|
||||||
// Expose the service
|
// 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 {
|
class LinkRedirectsServiceWrapper {
|
||||||
async init() {
|
async init() {
|
||||||
if (this.service) {
|
if (this.service) {
|
||||||
@ -8,8 +10,23 @@ class LinkRedirectsServiceWrapper {
|
|||||||
// Wire up all the dependencies
|
// Wire up all the dependencies
|
||||||
const {LinkRedirectsService} = require('@tryghost/link-redirects');
|
const {LinkRedirectsService} = require('@tryghost/link-redirects');
|
||||||
|
|
||||||
|
const store = [];
|
||||||
// Expose the service
|
// 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');
|
const LinkRedirect = require('./LinkRedirect');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {object} ILinkRedirectRepository
|
||||||
|
* @prop {(url: URL) => Promise<LinkRedirect>} getByURL
|
||||||
|
* @prop {(linkRedirect: LinkRedirect) => Promise<void>} save
|
||||||
|
*/
|
||||||
|
|
||||||
class LinkRedirectsService {
|
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 {URL} to
|
||||||
|
* @param {string} slug
|
||||||
*
|
*
|
||||||
* @returns {Promise<LinkRedirect>}
|
* @returns {Promise<LinkRedirect>}
|
||||||
*/
|
*/
|
||||||
async addRedirect(to) {
|
async addRedirect(to, slug) {
|
||||||
const from = new URL(to);
|
const from = new URL(`r/${slug}`, this.#baseURL);
|
||||||
|
|
||||||
from.searchParams.set('redirected', 'true'); // Dummy for skateboard
|
|
||||||
|
|
||||||
const link = new LinkRedirect({
|
const link = new LinkRedirect({
|
||||||
to,
|
to,
|
||||||
from
|
from
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await this.#linkRedirectRepository.save(link);
|
||||||
|
|
||||||
return link;
|
return link;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -27,7 +68,21 @@ class LinkRedirectsService {
|
|||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
async handleRequest(req, res, next) {
|
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"
|
"sinon": "14.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bson-objectid": "2.0.3"
|
"bson-objectid": "2.0.3",
|
||||||
|
"@tryghost/domain-events": "0.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -6,7 +6,8 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {object} ILinkRedirectService
|
* @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);
|
url = this.#attributionService.addPostAttributionTracking(url, post);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const slug = await this.#linkRedirectService.getSlug();
|
||||||
|
|
||||||
// 2. Add redirect for link click tracking
|
// 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
|
// 3. Add click tracking by members
|
||||||
// Note: we can always add the tracking params (even when isSite === false)
|
// Note: we can always add the tracking params (even when isSite === false)
|
||||||
|
@ -54,6 +54,9 @@ describe('LinkReplacementService', function () {
|
|||||||
const linkRedirectService = {
|
const linkRedirectService = {
|
||||||
addRedirect: (to) => {
|
addRedirect: (to) => {
|
||||||
return Promise.resolve({to, from: 'https://redirected.service/r/ro0sdD92'});
|
return Promise.resolve({to, from: 'https://redirected.service/r/ro0sdD92'});
|
||||||
|
},
|
||||||
|
getSlug: () => {
|
||||||
|
return Promise.resolve('slug');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const service = new LinkReplacementService({
|
const service = new LinkReplacementService({
|
||||||
@ -120,19 +123,19 @@ describe('LinkReplacementService', function () {
|
|||||||
it('returns the redirected URL with uuid', async 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'});
|
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.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 () {
|
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'});
|
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.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 () {
|
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'});
|
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.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 () {
|
it('Replaces hrefs inside links', async function () {
|
||||||
const html = '<a href="http://localhost:2368/dir/path">link</a>';
|
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 expected = '<a href="https://redirected.service/r/ro0sdD92?m=%%{uuid}%%">link</a>';
|
||||||
|
|
||||||
const replaced = await service.replaceLinks(html, {}, {id: 'post_id'});
|
const replaced = await service.replaceLinks(html, {}, {id: 'post_id'});
|
||||||
assert.equal(replaced, expected);
|
assert.equal(replaced, expected);
|
||||||
});
|
});
|
||||||
@ -148,7 +151,7 @@ describe('LinkReplacementService', function () {
|
|||||||
it('Ignores invalid links', async function () {
|
it('Ignores invalid links', async function () {
|
||||||
const html = '<a href="%%{unsubscribe_url}%%">link</a>';
|
const html = '<a href="%%{unsubscribe_url}%%">link</a>';
|
||||||
const expected = '<a href="%%{unsubscribe_url}%%">link</a>';
|
const expected = '<a href="%%{unsubscribe_url}%%">link</a>';
|
||||||
|
|
||||||
const replaced = await service.replaceLinks(html, {}, {id: 'post_id'});
|
const replaced = await service.replaceLinks(html, {}, {id: 'post_id'});
|
||||||
assert.equal(replaced, expected);
|
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 DomainEvents = require('@tryghost/domain-events');
|
||||||
const {RedirectEvent} = require('@tryghost/link-redirects');
|
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 {
|
class LinkClickTrackingService {
|
||||||
#initialised = false;
|
#initialised = false;
|
||||||
|
|
||||||
|
/** @type ILinkClickRepository */
|
||||||
|
#linkClickRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} deps
|
||||||
|
* @param {ILinkClickRepository} deps.linkClickRepository
|
||||||
|
*/
|
||||||
|
constructor(deps) {
|
||||||
|
this.#linkClickRepository = deps.linkClickRepository;
|
||||||
|
}
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
if (this.#initialised) {
|
if (this.#initialised) {
|
||||||
return;
|
return;
|
||||||
@ -13,7 +32,7 @@ class LinkClickTrackingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {import('@tryghost/link-redirects/LinkRedirect')} redirect
|
* @param {import('@tryghost/link-redirects').LinkRedirect} redirect
|
||||||
* @param {string} id
|
* @param {string} id
|
||||||
* @return {Promise<URL>}
|
* @return {Promise<URL>}
|
||||||
*/
|
*/
|
||||||
@ -24,18 +43,25 @@ class LinkClickTrackingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
subscribe() {
|
subscribe() {
|
||||||
DomainEvents.subscribe(RedirectEvent, (event) => {
|
DomainEvents.subscribe(RedirectEvent, async (event) => {
|
||||||
const id = event.data.url.searchParams.get('m');
|
const id = event.data.url.searchParams.get('m');
|
||||||
if (typeof id !== 'string') {
|
if (!id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const clickEvent = {
|
let memberId;
|
||||||
member_id: id,
|
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
|
link_id: event.data.link.link_id
|
||||||
};
|
});
|
||||||
// eslint-disable-next-line no-console
|
await this.#linkClickRepository.save(click);
|
||||||
console.log('Finna store a click event', clickEvent);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -24,7 +24,7 @@
|
|||||||
"sinon": "14.0.0"
|
"sinon": "14.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tryghost/domain-events": "^0.1.14",
|
"@tryghost/domain-events": "0.0.0",
|
||||||
"@tryghost/link-redirects": "0.0.0"
|
"@tryghost/link-redirects": "0.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -3489,11 +3489,6 @@
|
|||||||
"@tryghost/root-utils" "^0.3.16"
|
"@tryghost/root-utils" "^0.3.16"
|
||||||
debug "^4.3.1"
|
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":
|
"@tryghost/elasticsearch@^3.0.3":
|
||||||
version "3.0.3"
|
version "3.0.3"
|
||||||
resolved "https://registry.yarnpkg.com/@tryghost/elasticsearch/-/elasticsearch-3.0.3.tgz#6651298989f38bbe30777ab122d56a43f719d2c2"
|
resolved "https://registry.yarnpkg.com/@tryghost/elasticsearch/-/elasticsearch-3.0.3.tgz#6651298989f38bbe30777ab122d56a43f719d2c2"
|
||||||
|
Loading…
Reference in New Issue
Block a user