ghost 5.14.2 → 5.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/tryghost-adapter-manager-5.16.0.tgz +0 -0
- package/components/tryghost-api-framework-5.16.0.tgz +0 -0
- package/components/tryghost-api-version-compatibility-service-5.16.0.tgz +0 -0
- package/components/tryghost-bootstrap-socket-5.16.0.tgz +0 -0
- package/components/tryghost-constants-5.16.0.tgz +0 -0
- package/components/tryghost-custom-theme-settings-service-5.16.0.tgz +0 -0
- package/components/tryghost-domain-events-5.16.0.tgz +0 -0
- package/components/tryghost-email-analytics-provider-mailgun-5.16.0.tgz +0 -0
- package/components/{tryghost-email-analytics-service-5.14.2.tgz → tryghost-email-analytics-service-5.16.0.tgz} +0 -0
- package/components/tryghost-email-content-generator-5.16.0.tgz +0 -0
- package/components/tryghost-express-dynamic-redirects-5.16.0.tgz +0 -0
- package/components/tryghost-extract-api-key-5.16.0.tgz +0 -0
- package/components/tryghost-html-to-plaintext-5.16.0.tgz +0 -0
- package/components/{tryghost-job-manager-5.14.2.tgz → tryghost-job-manager-5.16.0.tgz} +0 -0
- package/components/tryghost-link-redirects-5.16.0.tgz +0 -0
- package/components/tryghost-link-replacer-5.16.0.tgz +0 -0
- package/components/tryghost-link-tracking-5.16.0.tgz +0 -0
- package/components/tryghost-magic-link-5.16.0.tgz +0 -0
- package/components/{tryghost-mailgun-client-5.14.2.tgz → tryghost-mailgun-client-5.16.0.tgz} +0 -0
- package/components/tryghost-member-analytics-service-5.16.0.tgz +0 -0
- package/components/tryghost-member-attribution-5.16.0.tgz +0 -0
- package/components/tryghost-member-events-5.16.0.tgz +0 -0
- package/components/tryghost-members-analytics-ingress-5.16.0.tgz +0 -0
- package/components/tryghost-members-api-5.16.0.tgz +0 -0
- package/components/tryghost-members-csv-5.16.0.tgz +0 -0
- package/components/tryghost-members-events-service-5.16.0.tgz +0 -0
- package/components/tryghost-members-importer-5.16.0.tgz +0 -0
- package/components/tryghost-members-offers-5.16.0.tgz +0 -0
- package/components/{tryghost-members-payments-5.14.2.tgz → tryghost-members-payments-5.16.0.tgz} +0 -0
- package/components/{tryghost-members-ssr-5.14.2.tgz → tryghost-members-ssr-5.16.0.tgz} +0 -0
- package/components/tryghost-members-stripe-service-5.16.0.tgz +0 -0
- package/components/tryghost-minifier-5.16.0.tgz +0 -0
- package/components/tryghost-mw-api-version-mismatch-5.16.0.tgz +0 -0
- package/components/tryghost-mw-cache-control-5.16.0.tgz +0 -0
- package/components/{tryghost-mw-error-handler-5.14.2.tgz → tryghost-mw-error-handler-5.16.0.tgz} +0 -0
- package/components/tryghost-mw-session-from-token-5.16.0.tgz +0 -0
- package/components/tryghost-mw-update-user-last-seen-5.16.0.tgz +0 -0
- package/components/tryghost-mw-vhost-5.16.0.tgz +0 -0
- package/components/{tryghost-oembed-service-5.14.2.tgz → tryghost-oembed-service-5.16.0.tgz} +0 -0
- package/components/{tryghost-package-json-5.14.2.tgz → tryghost-package-json-5.16.0.tgz} +0 -0
- package/components/tryghost-referrers-5.16.0.tgz +0 -0
- package/components/{tryghost-security-5.14.2.tgz → tryghost-security-5.16.0.tgz} +0 -0
- package/components/tryghost-session-service-5.16.0.tgz +0 -0
- package/components/tryghost-settings-path-manager-5.16.0.tgz +0 -0
- package/components/tryghost-staff-service-5.16.0.tgz +0 -0
- package/components/tryghost-stats-service-5.16.0.tgz +0 -0
- package/components/tryghost-update-check-service-5.16.0.tgz +0 -0
- package/components/{tryghost-verification-trigger-5.14.2.tgz → tryghost-verification-trigger-5.16.0.tgz} +0 -0
- package/components/{tryghost-version-notifications-data-service-5.14.2.tgz → tryghost-version-notifications-data-service-5.16.0.tgz} +0 -0
- package/content/themes/casper/default.hbs +2 -2
- package/core/boot.js +10 -3
- package/core/built/admin/assets/{chunk.143.a5ef705453da0d58b75a.js → chunk.143.a281d460e6059cd0210a.js} +21 -21
- package/core/built/admin/assets/{chunk.174.2edaa0869bfc2d88cf90.js → chunk.174.e1e89637eab79fdd5c5d.js} +68 -68
- package/core/built/admin/assets/{chunk.178.579a6edabc75a2d7378f.js → chunk.178.68eca2346b6f343991e7.js} +4 -4
- package/core/built/admin/assets/{chunk.579.2de3f4300baf25f9a0db.js → chunk.579.d14c3688558f34afeb3e.js} +8872 -7851
- package/core/built/admin/assets/{chunk.579.2de3f4300baf25f9a0db.js.LICENSE.txt → chunk.579.d14c3688558f34afeb3e.js.LICENSE.txt} +45 -0
- package/core/built/admin/assets/fonts/{Inter.ttf → Inter-e19174fb2c0e19b1fa67492a07886c75.ttf} +0 -0
- package/core/built/admin/assets/ghost-6491d134c450ca676911ea17e16cd7d4.css +1 -0
- package/core/built/admin/assets/ghost-dark-297ab2fcf4cadd1c950b84089a38c5e2.css +1 -0
- package/core/built/admin/assets/{ghost-8919656440ad4617a07bb31069b1f71b.js → ghost-f2bf99b26aee662cf37fe59f87b1ceb5.js} +593 -511
- package/core/built/admin/assets/img/{amp.svg → amp-d7b72aae3315fda95921fb575dfca100.svg} +0 -0
- package/core/built/admin/assets/img/{disqus.svg → disqus-43503a3fa4f38dc8c61c7358b811f343.svg} +0 -0
- package/core/built/admin/assets/img/{favicon.ico → favicon-a9c6dbdcdc3ae568f4e0dad92149a0e3.ico} +0 -0
- package/core/built/admin/assets/img/{github.svg → github-c3a739c59df26fed12c10ffb00b33bd4.svg} +0 -0
- package/core/built/admin/assets/img/{google-docs.svg → google-docs-1e42cc272fc088da49e4b0ddfb01b006.svg} +0 -0
- package/core/built/admin/assets/img/{mailchimp.svg → mailchimp-f22b1e130aac764965b9306d7265a6b2.svg} +0 -0
- package/core/built/admin/assets/img/marketing/analytics-1-aa2d72c4e7347a3cb5666d07916b92aa.jpg +0 -0
- package/core/built/admin/assets/img/marketing/analytics-2-389d53f80041ff98111cce79facf66b8.jpg +0 -0
- package/core/built/admin/assets/img/{patreon.svg → patreon-b19a5e6418a72977a16b30039d374d04.svg} +0 -0
- package/core/built/admin/assets/img/{paypal.svg → paypal-38e9448ce7549ea4caf8e7753ae661d6.svg} +0 -0
- package/core/built/admin/assets/img/{twitter.svg → twitter-7a7a0ba12d9b5bfb8a2058764a827c31.svg} +0 -0
- package/core/built/admin/assets/img/{typeform.svg → typeform-9f23f8712d776a7515594676285266f5.svg} +0 -0
- package/core/built/admin/assets/img/{unsplash.svg → unsplash-5b329eef0b11447b4117eaf817ebad6f.svg} +0 -0
- package/core/built/admin/assets/img/{zapier.svg → zapier-bf93bc440a3fd43b73489a63c215cdc7.svg} +0 -0
- package/core/built/admin/assets/img/{zapier-logo.svg → zapier-logo-a125f24313dfe01ef49af01fc90061fb.svg} +0 -0
- package/core/built/admin/assets/{vendor-eb76d0236a09b8b6f44675dba45b1fc6.js → vendor-b2375e2f383cbc3fd73340c4b656c993.js} +59 -47
- package/core/built/admin/assets/videos/logo-loader.mp4 +0 -0
- package/core/built/admin/index.html +11 -8
- package/core/frontend/helpers/search.js +1 -15
- package/core/frontend/src/member-attribution/member-attribution.js +64 -3
- package/core/frontend/web/site.js +10 -7
- package/core/server/api/endpoints/index.js +4 -0
- package/core/server/api/endpoints/links.js +25 -0
- package/core/server/api/endpoints/posts.js +2 -1
- package/core/server/api/endpoints/redirects.js +6 -8
- package/core/server/api/endpoints/stats.js +24 -0
- package/core/server/api/endpoints/utils/permissions.js +2 -16
- package/core/server/api/endpoints/utils/serializers/input/pages.js +5 -5
- package/core/server/api/endpoints/utils/serializers/input/posts.js +13 -8
- package/core/server/api/endpoints/utils/serializers/input/settings.js +1 -0
- package/core/server/api/endpoints/utils/serializers/output/mappers/activity-feed-events.js +51 -0
- package/core/server/api/endpoints/utils/serializers/output/mappers/comments.js +10 -1
- package/core/server/api/endpoints/utils/serializers/output/mappers/posts.js +1 -1
- package/core/server/api/endpoints/utils/validators/input/pages.js +24 -9
- package/core/server/api/endpoints/utils/validators/input/posts.js +24 -9
- package/core/server/data/exporter/table-lists.js +4 -1
- package/core/server/data/migrations/utils/settings.js +1 -3
- package/core/server/data/migrations/versions/5.15/2022-09-12-16-10-add-posts-lexical-column.js +8 -0
- package/core/server/data/migrations/versions/5.15/2022-09-14-12-46-add-email-track-clicks-setting.js +8 -0
- package/core/server/data/migrations/versions/5.15/2022-09-16-08-22-add-post-revisions-table.js +9 -0
- package/core/server/data/migrations/versions/5.16/2022-09-19-09-04-add-link-redirects-table.js +10 -0
- package/core/server/data/migrations/versions/5.16/2022-09-19-09-05-add-members-link-click-events-table.js +8 -0
- package/core/server/data/migrations/versions/5.16/2022-09-19-17-44-add-referrer-columns-to-member-events-table.js +21 -0
- package/core/server/data/migrations/versions/5.16/2022-09-19-17-44-add-referrer-columns-to-subscription-events-table.js +21 -0
- package/core/server/data/schema/default-settings/default-settings.json +8 -0
- package/core/server/data/schema/schema.js +29 -1
- package/core/server/lib/lexical.js +12 -0
- package/core/server/models/base/plugins/user-type.js +4 -6
- package/core/server/models/link-redirect.js +65 -0
- package/core/server/models/member-link-click-event.js +26 -0
- package/core/server/models/post-revision.js +35 -0
- package/core/server/models/post.js +90 -9
- package/core/server/services/bulk-email/bulk-email-processor.js +9 -10
- package/core/server/services/{redirects → custom-redirects}/api.js +0 -0
- package/core/server/services/{redirects → custom-redirects}/index.js +0 -0
- package/core/server/services/{redirects → custom-redirects}/utils.js +0 -0
- package/core/server/services/{redirects → custom-redirects}/validation.js +0 -0
- package/core/server/services/explore/service.js +5 -3
- package/core/server/services/link-redirection/LinkRedirectRepository.js +88 -0
- package/core/server/services/link-redirection/index.js +31 -0
- package/core/server/services/link-tracking/LinkClickRepository.js +69 -0
- package/core/server/services/link-tracking/PostLinkRepository.js +62 -0
- package/core/server/services/link-tracking/index.js +48 -0
- package/core/server/services/mega/email-preview.js +7 -0
- package/core/server/services/mega/mega.js +1 -1
- package/core/server/services/mega/post-email-serializer.js +101 -27
- package/core/server/services/member-attribution/index.js +12 -5
- package/core/server/services/members/api.js +1 -2
- package/core/server/services/permissions/index.js +1 -2
- package/core/server/services/posts/posts-service.js +7 -16
- package/core/server/services/posts/stats/post-stats.js +35 -0
- package/core/server/services/staff/index.js +10 -1
- package/core/server/services/url/config.js +2 -0
- package/core/server/web/admin/app.js +8 -2
- package/core/server/web/api/endpoints/admin/routes.js +5 -0
- package/core/shared/config/defaults.json +7 -7
- package/core/shared/config/overrides.json +3 -2
- package/core/shared/labs.js +4 -2
- package/package.json +115 -107
- package/yarn.lock +828 -414
- package/components/tryghost-adapter-manager-5.14.2.tgz +0 -0
- package/components/tryghost-api-framework-5.14.2.tgz +0 -0
- package/components/tryghost-api-version-compatibility-service-5.14.2.tgz +0 -0
- package/components/tryghost-bootstrap-socket-5.14.2.tgz +0 -0
- package/components/tryghost-constants-5.14.2.tgz +0 -0
- package/components/tryghost-custom-theme-settings-service-5.14.2.tgz +0 -0
- package/components/tryghost-domain-events-5.14.2.tgz +0 -0
- package/components/tryghost-email-analytics-provider-mailgun-5.14.2.tgz +0 -0
- package/components/tryghost-email-content-generator-5.14.2.tgz +0 -0
- package/components/tryghost-express-dynamic-redirects-5.14.2.tgz +0 -0
- package/components/tryghost-extract-api-key-5.14.2.tgz +0 -0
- package/components/tryghost-html-to-plaintext-5.14.2.tgz +0 -0
- package/components/tryghost-magic-link-5.14.2.tgz +0 -0
- package/components/tryghost-member-analytics-service-5.14.2.tgz +0 -0
- package/components/tryghost-member-attribution-5.14.2.tgz +0 -0
- package/components/tryghost-member-events-5.14.2.tgz +0 -0
- package/components/tryghost-members-analytics-ingress-5.14.2.tgz +0 -0
- package/components/tryghost-members-api-5.14.2.tgz +0 -0
- package/components/tryghost-members-csv-5.14.2.tgz +0 -0
- package/components/tryghost-members-events-service-5.14.2.tgz +0 -0
- package/components/tryghost-members-importer-5.14.2.tgz +0 -0
- package/components/tryghost-members-offers-5.14.2.tgz +0 -0
- package/components/tryghost-members-stripe-service-5.14.2.tgz +0 -0
- package/components/tryghost-minifier-5.14.2.tgz +0 -0
- package/components/tryghost-mw-api-version-mismatch-5.14.2.tgz +0 -0
- package/components/tryghost-mw-cache-control-5.14.2.tgz +0 -0
- package/components/tryghost-mw-session-from-token-5.14.2.tgz +0 -0
- package/components/tryghost-mw-update-user-last-seen-5.14.2.tgz +0 -0
- package/components/tryghost-mw-vhost-5.14.2.tgz +0 -0
- package/components/tryghost-session-service-5.14.2.tgz +0 -0
- package/components/tryghost-settings-path-manager-5.14.2.tgz +0 -0
- package/components/tryghost-staff-service-5.14.2.tgz +0 -0
- package/components/tryghost-update-check-service-5.14.2.tgz +0 -0
- package/core/built/admin/assets/ghost-40adc8310dcdd0be163cbf7b9d89c59a.css +0 -1
- package/core/built/admin/assets/ghost-dark-13b669d50f494edf24d832b32ece2177.css +0 -1
- package/core/server/services/permissions/public.js +0 -76
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -46,11 +46,13 @@ module.exports = class ExploreService {
|
|
|
46
46
|
}
|
|
47
47
|
};
|
|
48
48
|
|
|
49
|
-
const mostRecentlyPublishedPost = await this.PostsService.
|
|
50
|
-
|
|
49
|
+
const mostRecentlyPublishedPost = await this.PostsService.stats.getMostRecentlyPublishedPostDate();
|
|
50
|
+
const totalPostsPublished = await this.PostsService.stats.getTotalPostsPublished();
|
|
51
|
+
exploreProperties.most_recently_published_at = mostRecentlyPublishedPost ?? null;
|
|
52
|
+
exploreProperties.total_posts_published = totalPostsPublished ?? null;
|
|
51
53
|
|
|
52
54
|
const owner = await this.UserModel.findOne({role: 'Owner', status: 'all'});
|
|
53
|
-
exploreProperties.owner_email = owner?.get('email')
|
|
55
|
+
exploreProperties.owner_email = owner?.get('email') ?? null;
|
|
54
56
|
|
|
55
57
|
return exploreProperties;
|
|
56
58
|
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
const LinkRedirect = require('@tryghost/link-redirects').LinkRedirect;
|
|
2
|
+
const ObjectID = require('bson-objectid').default;
|
|
3
|
+
|
|
4
|
+
module.exports = class LinkRedirectRepository {
|
|
5
|
+
/** @type {Object} */
|
|
6
|
+
#LinkRedirect;
|
|
7
|
+
/** @type {Object} */
|
|
8
|
+
#urlUtils;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {object} deps
|
|
12
|
+
* @param {object} deps.LinkRedirect Bookshelf Model
|
|
13
|
+
* @param {object} deps.urlUtils
|
|
14
|
+
*/
|
|
15
|
+
constructor(deps) {
|
|
16
|
+
this.#LinkRedirect = deps.LinkRedirect;
|
|
17
|
+
this.#urlUtils = deps.urlUtils;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {InstanceType<LinkRedirect>} linkRedirect
|
|
22
|
+
* @returns {Promise<void>}
|
|
23
|
+
*/
|
|
24
|
+
async save(linkRedirect) {
|
|
25
|
+
const model = await this.#LinkRedirect.add({
|
|
26
|
+
// Only store the parthname (no support for variable query strings)
|
|
27
|
+
from: this.stripSubdirectoryFromPath(linkRedirect.from.pathname),
|
|
28
|
+
to: linkRedirect.to.href
|
|
29
|
+
}, {});
|
|
30
|
+
|
|
31
|
+
linkRedirect.link_id = ObjectID.createFromHexString(model.id);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
#trimLeadingSlash(url) {
|
|
35
|
+
return url.replace(/^\//, '');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
fromModel(model) {
|
|
39
|
+
return new LinkRedirect({
|
|
40
|
+
id: model.id,
|
|
41
|
+
from: new URL(this.#trimLeadingSlash(model.get('from')), this.#urlUtils.urlFor('home', true)),
|
|
42
|
+
to: new URL(model.get('to'))
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async getAll(options) {
|
|
47
|
+
const collection = await this.#LinkRedirect.findAll(options);
|
|
48
|
+
|
|
49
|
+
const result = [];
|
|
50
|
+
|
|
51
|
+
for (const model of collection.models) {
|
|
52
|
+
result.push(this.fromModel(model));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return result;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
*
|
|
60
|
+
* @param {URL} url
|
|
61
|
+
* @returns {Promise<InstanceType<LinkRedirect>|undefined>} linkRedirect
|
|
62
|
+
*/
|
|
63
|
+
async getByURL(url) {
|
|
64
|
+
// Strip subdirectory from path
|
|
65
|
+
const from = this.stripSubdirectoryFromPath(url.pathname);
|
|
66
|
+
|
|
67
|
+
const linkRedirect = await this.#LinkRedirect.findOne({
|
|
68
|
+
from
|
|
69
|
+
}, {});
|
|
70
|
+
|
|
71
|
+
if (linkRedirect) {
|
|
72
|
+
return this.fromModel(linkRedirect);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Convert root relative URLs to subdirectory relative URLs
|
|
78
|
+
*/
|
|
79
|
+
stripSubdirectoryFromPath(path) {
|
|
80
|
+
// Bit weird, but only way to do it with the urlUtils atm
|
|
81
|
+
|
|
82
|
+
// First convert path to an absolute path
|
|
83
|
+
const absolute = this.#urlUtils.relativeToAbsolute(path);
|
|
84
|
+
|
|
85
|
+
// Then convert it to a relative path, but without subdirectory
|
|
86
|
+
return this.#urlUtils.absoluteToRelative(absolute, {withoutSubdirectory: true});
|
|
87
|
+
}
|
|
88
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
const urlUtils = require('../../../shared/url-utils');
|
|
2
|
+
const LinkRedirectRepository = require('./LinkRedirectRepository');
|
|
3
|
+
|
|
4
|
+
class LinkRedirectsServiceWrapper {
|
|
5
|
+
async init() {
|
|
6
|
+
if (this.service) {
|
|
7
|
+
// Already done
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Wire up all the dependencies
|
|
12
|
+
const models = require('../../models');
|
|
13
|
+
|
|
14
|
+
const {LinkRedirectsService} = require('@tryghost/link-redirects');
|
|
15
|
+
|
|
16
|
+
this.linkRedirectRepository = new LinkRedirectRepository({
|
|
17
|
+
LinkRedirect: models.LinkRedirect,
|
|
18
|
+
urlUtils
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// Expose the service
|
|
22
|
+
this.service = new LinkRedirectsService({
|
|
23
|
+
linkRedirectRepository: this.linkRedirectRepository,
|
|
24
|
+
config: {
|
|
25
|
+
baseURL: new URL(urlUtils.getSiteUrl())
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
module.exports = new LinkRedirectsServiceWrapper();
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
const {LinkClick} = require('@tryghost/link-tracking');
|
|
2
|
+
const ObjectID = require('bson-objectid').default;
|
|
3
|
+
|
|
4
|
+
module.exports = class LinkClickRepository {
|
|
5
|
+
/** @type {Object} */
|
|
6
|
+
#MemberLinkClickEventModel;
|
|
7
|
+
|
|
8
|
+
/** @type {Object} */
|
|
9
|
+
#MemberLinkClickEvent;
|
|
10
|
+
|
|
11
|
+
/** @type {object} */
|
|
12
|
+
#Member;
|
|
13
|
+
|
|
14
|
+
/** @type {object} */
|
|
15
|
+
#DomainEvents;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {object} deps
|
|
19
|
+
* @param {object} deps.MemberLinkClickEventModel Bookshelf Model
|
|
20
|
+
* @param {object} deps.Member Bookshelf Model
|
|
21
|
+
* @param {object} deps.MemberLinkClickEvent Event
|
|
22
|
+
* @param {object} deps.DomainEvents
|
|
23
|
+
*/
|
|
24
|
+
constructor(deps) {
|
|
25
|
+
this.#MemberLinkClickEventModel = deps.MemberLinkClickEventModel;
|
|
26
|
+
this.#Member = deps.Member;
|
|
27
|
+
this.#MemberLinkClickEvent = deps.MemberLinkClickEvent;
|
|
28
|
+
this.#DomainEvents = deps.DomainEvents;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async getAll(options) {
|
|
32
|
+
const collection = await this.#MemberLinkClickEventModel.findAll(options);
|
|
33
|
+
|
|
34
|
+
const result = [];
|
|
35
|
+
|
|
36
|
+
for (const model of collection.models) {
|
|
37
|
+
const member = await this.#Member.findOne({id: model.get('member_id')});
|
|
38
|
+
result.push(new LinkClick({
|
|
39
|
+
link_id: model.get('link_id'),
|
|
40
|
+
member_uuid: member.get('uuid')
|
|
41
|
+
}));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return result;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param {LinkClick} linkClick
|
|
49
|
+
* @returns {Promise<void>}
|
|
50
|
+
*/
|
|
51
|
+
async save(linkClick) {
|
|
52
|
+
// Convert uuid to id
|
|
53
|
+
const member = await this.#Member.findOne({uuid: linkClick.member_uuid});
|
|
54
|
+
if (!member) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const model = await this.#MemberLinkClickEventModel.add({
|
|
59
|
+
// Only store the parthname (no support for variable query strings)
|
|
60
|
+
link_id: linkClick.link_id.toHexString(),
|
|
61
|
+
member_id: member.id
|
|
62
|
+
}, {});
|
|
63
|
+
|
|
64
|
+
linkClick.event_id = ObjectID.createFromHexString(model.id);
|
|
65
|
+
|
|
66
|
+
// Dispatch event
|
|
67
|
+
this.#DomainEvents.dispatch(this.#MemberLinkClickEvent.create({memberId: member.id, memberLastSeenAt: member.get('last_seen_at'), linkId: linkClick.link_id.toHexString()}, new Date()));
|
|
68
|
+
}
|
|
69
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
const {FullPostLink} = require('@tryghost/link-tracking');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @typedef {import('bson-objectid').default} ObjectID
|
|
5
|
+
* @typedef {import('@tryghost/link-tracking/lib/PostLink')} PostLink
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
module.exports = class PostLinkRepository {
|
|
9
|
+
/** @type {Object} */
|
|
10
|
+
#LinkRedirect;
|
|
11
|
+
/** @type {Object} */
|
|
12
|
+
#linkRedirectRepository;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {object} deps
|
|
16
|
+
* @param {object} deps.LinkRedirect Bookshelf Model
|
|
17
|
+
* @param {object} deps.linkRedirectRepository Bookshelf Model
|
|
18
|
+
*/
|
|
19
|
+
constructor(deps) {
|
|
20
|
+
this.#LinkRedirect = deps.LinkRedirect;
|
|
21
|
+
this.#linkRedirectRepository = deps.linkRedirectRepository;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
*
|
|
26
|
+
* @param {*} options
|
|
27
|
+
* @returns {Promise<InstanceType<FullPostLink>[]>}
|
|
28
|
+
*/
|
|
29
|
+
async getAll(options) {
|
|
30
|
+
const collection = await this.#LinkRedirect.findAll({...options, withRelated: ['count.clicks']});
|
|
31
|
+
|
|
32
|
+
const result = [];
|
|
33
|
+
|
|
34
|
+
for (const model of collection.models) {
|
|
35
|
+
const link = this.#linkRedirectRepository.fromModel(model);
|
|
36
|
+
|
|
37
|
+
result.push(
|
|
38
|
+
new FullPostLink({
|
|
39
|
+
post_id: model.get('post_id'),
|
|
40
|
+
link,
|
|
41
|
+
count: {
|
|
42
|
+
clicks: model.get('count__clicks')
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return result;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @param {PostLink} postLink
|
|
53
|
+
* @returns {Promise<void>}
|
|
54
|
+
*/
|
|
55
|
+
async save(postLink) {
|
|
56
|
+
await this.#LinkRedirect.edit({
|
|
57
|
+
post_id: postLink.post_id.toHexString()
|
|
58
|
+
}, {
|
|
59
|
+
id: postLink.link_id.toHexString()
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
const LinkClickRepository = require('./LinkClickRepository');
|
|
2
|
+
const PostLinkRepository = require('./PostLinkRepository');
|
|
3
|
+
const errors = require('@tryghost/errors');
|
|
4
|
+
|
|
5
|
+
class LinkTrackingServiceWrapper {
|
|
6
|
+
async init() {
|
|
7
|
+
if (this.service) {
|
|
8
|
+
// Already done
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const linkRedirection = require('../link-redirection');
|
|
13
|
+
if (!linkRedirection.service) {
|
|
14
|
+
throw new errors.InternalServerError({message: 'LinkRedirectionService should be initialised before LinkTrackingService'});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Wire up all the dependencies
|
|
18
|
+
const models = require('../../models');
|
|
19
|
+
const {MemberLinkClickEvent} = require('@tryghost/member-events');
|
|
20
|
+
const DomainEvents = require('@tryghost/domain-events');
|
|
21
|
+
|
|
22
|
+
const {LinkClickTrackingService} = require('@tryghost/link-tracking');
|
|
23
|
+
|
|
24
|
+
const postLinkRepository = new PostLinkRepository({
|
|
25
|
+
LinkRedirect: models.LinkRedirect,
|
|
26
|
+
linkRedirectRepository: linkRedirection.linkRedirectRepository
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const linkClickRepository = new LinkClickRepository({
|
|
30
|
+
MemberLinkClickEventModel: models.MemberLinkClickEvent,
|
|
31
|
+
Member: models.Member,
|
|
32
|
+
MemberLinkClickEvent: MemberLinkClickEvent,
|
|
33
|
+
DomainEvents
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// Expose the service
|
|
37
|
+
this.service = new LinkClickTrackingService({
|
|
38
|
+
linkRedirectService: linkRedirection.service,
|
|
39
|
+
linkClickRepository,
|
|
40
|
+
postLinkRepository,
|
|
41
|
+
DomainEvents
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
await this.service.init();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
module.exports = new LinkTrackingServiceWrapper();
|
|
@@ -27,6 +27,7 @@ class EmailPreview {
|
|
|
27
27
|
emailContent = postEmailSerializer.renderEmailForSegment(emailContent, memberSegment);
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
// Do fake replacements, just like a normal email, but use fallbacks and empty values
|
|
30
31
|
const replacements = postEmailSerializer.parseReplacements(emailContent);
|
|
31
32
|
|
|
32
33
|
replacements.forEach((replacement) => {
|
|
@@ -36,6 +37,12 @@ class EmailPreview {
|
|
|
36
37
|
);
|
|
37
38
|
});
|
|
38
39
|
|
|
40
|
+
// Replace unsubscribe URL (%recipient.unsubscribe_url% replacement)
|
|
41
|
+
// We should do this only here because replacements should happen at the very end only, just like when an actual email would be send
|
|
42
|
+
const previewUnsubscribeUrl = postEmailSerializer.createUnsubscribeUrl(null);
|
|
43
|
+
emailContent.html = emailContent.html.replace('%recipient.unsubscribe_url%', previewUnsubscribeUrl);
|
|
44
|
+
emailContent.plaintext = emailContent.plaintext.replace('%recipient.unsubscribe_url%', previewUnsubscribeUrl);
|
|
45
|
+
|
|
39
46
|
return {
|
|
40
47
|
subject: emailContent.subject,
|
|
41
48
|
html: emailContent.html,
|
|
@@ -85,7 +85,7 @@ const getEmailData = async (postModel, options) => {
|
|
|
85
85
|
* @param {ValidMemberSegment} [memberSegment]
|
|
86
86
|
*/
|
|
87
87
|
const sendTestEmail = async (postModel, toEmails, memberSegment) => {
|
|
88
|
-
let emailData = await getEmailData(postModel);
|
|
88
|
+
let emailData = await getEmailData(postModel, {isTestEmail: true});
|
|
89
89
|
emailData.subject = `[Test] ${emailData.subject}`;
|
|
90
90
|
|
|
91
91
|
// fetch any matching members so that replacements use expected values
|
|
@@ -14,8 +14,11 @@ const {isUnsplashImage, isLocalContentImage} = require('@tryghost/kg-default-car
|
|
|
14
14
|
const {textColorForBackgroundColor, darkenToContrastThreshold} = require('@tryghost/color-utils');
|
|
15
15
|
const logging = require('@tryghost/logging');
|
|
16
16
|
const urlService = require('../../services/url');
|
|
17
|
+
const linkReplacer = require('@tryghost/link-replacer');
|
|
18
|
+
const linkTracking = require('../link-tracking');
|
|
19
|
+
const memberAttribution = require('../member-attribution');
|
|
17
20
|
|
|
18
|
-
const ALLOWED_REPLACEMENTS = ['first_name'];
|
|
21
|
+
const ALLOWED_REPLACEMENTS = ['first_name', 'uuid'];
|
|
19
22
|
|
|
20
23
|
const PostEmailSerializer = {
|
|
21
24
|
|
|
@@ -243,7 +246,7 @@ const PostEmailSerializer = {
|
|
|
243
246
|
return templateSettings;
|
|
244
247
|
},
|
|
245
248
|
|
|
246
|
-
async serialize(postModel, newsletter, options = {isBrowserPreview: false}) {
|
|
249
|
+
async serialize(postModel, newsletter, options = {isBrowserPreview: false, isTestEmail: false}) {
|
|
247
250
|
const post = await this.serializePostModel(postModel);
|
|
248
251
|
|
|
249
252
|
const timezone = settingsCache.get('timezone');
|
|
@@ -276,7 +279,7 @@ const PostEmailSerializer = {
|
|
|
276
279
|
// perform any email specific adjustments to the mobiledoc->HTML render output
|
|
277
280
|
// body wrapper is required so we can get proper top-level selections
|
|
278
281
|
const cheerio = require('cheerio');
|
|
279
|
-
|
|
282
|
+
const _cheerio = cheerio.load(`<body>${post.html}</body>`);
|
|
280
283
|
// remove leading/trailing HRs
|
|
281
284
|
_cheerio(`
|
|
282
285
|
body > hr:first-child,
|
|
@@ -284,8 +287,10 @@ const PostEmailSerializer = {
|
|
|
284
287
|
body > div:first-child > hr:first-child,
|
|
285
288
|
body > div:last-child > hr:last-child
|
|
286
289
|
`).remove();
|
|
287
|
-
post.html = _cheerio('body').html();
|
|
290
|
+
post.html = _cheerio('body').html(); // () (added this comment because of a bug in the syntax highlighter in VSCode)
|
|
288
291
|
|
|
292
|
+
// Note: we don't need to do link replacements on the plaintext here
|
|
293
|
+
// because the plaintext will get recalculated on the updated post html (which already includes link replacements) in renderEmailForSegment
|
|
289
294
|
post.plaintext = htmlToPlaintext.email(post.html);
|
|
290
295
|
|
|
291
296
|
// Outlook will render feature images at full-size breaking the layout.
|
|
@@ -325,24 +330,73 @@ const PostEmailSerializer = {
|
|
|
325
330
|
|
|
326
331
|
let htmlTemplate = render({post, site: this.getSite(), templateSettings, newsletter: newsletter.toJSON()});
|
|
327
332
|
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
333
|
+
// The plaintext version that is returned here is actually never really used for sending because we'll use htmlToPlaintext again later
|
|
334
|
+
let result = {
|
|
335
|
+
html: this.formatHtmlForEmail(htmlTemplate),
|
|
336
|
+
plaintext: post.plaintext
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* If a part of the email is members-only and the post is paid-only, add a paywall:
|
|
341
|
+
* - Just before sending the email, we'll hide the paywall or paid content depending on the member segment it is sent to.
|
|
342
|
+
* - We already need to do URL-replacement on the HTML here
|
|
343
|
+
* - Link replacement cannot happen later because renderEmailForSegment is called multiple times for a single email (which would result in duplicate redirects)
|
|
344
|
+
*/
|
|
345
|
+
const isPaidPost = post.visibility === 'paid' || post.visibility === 'tiers';
|
|
346
|
+
|
|
347
|
+
const paywallIndex = (result.html || '').indexOf('<!--members-only-->');
|
|
348
|
+
if (paywallIndex !== -1 && isPaidPost) {
|
|
349
|
+
const postContentEndIdx = result.html.indexOf('<!-- POST CONTENT END -->');
|
|
350
|
+
|
|
351
|
+
if (postContentEndIdx !== -1) {
|
|
352
|
+
const paywallHTML = '<!-- PAYWALL -->' + this.renderPaywallCTA(post);
|
|
353
|
+
|
|
354
|
+
// Append it just before the end of the post content
|
|
355
|
+
result.html = result.html.slice(0, postContentEndIdx) + paywallHTML + result.html.slice(postContentEndIdx);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Now replace the links in the HTML version
|
|
360
|
+
if (labs.isSet('emailClicks')) {
|
|
361
|
+
if ((!options.isBrowserPreview && !options.isTestEmail) || process.env.NODE_ENV === 'development') {
|
|
362
|
+
const enableTracking = settingsCache.get('email_track_clicks');
|
|
363
|
+
result.html = await linkReplacer.replace(result.html, async (url) => {
|
|
364
|
+
// Add newsletter source attribution
|
|
365
|
+
url = memberAttribution.service.addEmailSourceAttributionTracking(url, newsletter);
|
|
366
|
+
const isSite = urlUtils.isSiteUrl(url);
|
|
367
|
+
|
|
368
|
+
// Add post attribution tracking
|
|
369
|
+
if (isSite && enableTracking) {
|
|
370
|
+
// Only add attribution links to our own site (except for the newsletter referrer)
|
|
371
|
+
url = memberAttribution.service.addPostAttributionTracking(url, post);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Add link click tracking
|
|
375
|
+
if (enableTracking) {
|
|
376
|
+
url = await linkTracking.service.addTrackingToUrl(url, post, '--uuid--');
|
|
377
|
+
|
|
378
|
+
// We need to convert to a string at this point, because we need invalid string characters in the URL
|
|
379
|
+
const str = url.toString().replace(/--uuid--/g, '%%{uuid}%%');
|
|
380
|
+
return str;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Replace the URL with a normal redirect so we can change it later, but don't include tracking
|
|
384
|
+
url = linkTracking.service.addRedirectToUrl(url, post);
|
|
385
|
+
return url;
|
|
386
|
+
});
|
|
387
|
+
}
|
|
331
388
|
}
|
|
332
389
|
|
|
333
390
|
// Clean up any unknown replacements strings to get our final content
|
|
334
|
-
const {html, plaintext} = this.normalizeReplacementStrings(
|
|
335
|
-
html: this.formatHtmlForEmail(htmlTemplate),
|
|
336
|
-
plaintext: post.plaintext
|
|
337
|
-
});
|
|
391
|
+
const {html, plaintext} = this.normalizeReplacementStrings(result);
|
|
338
392
|
const data = {
|
|
339
393
|
subject: post.email_subject || post.title,
|
|
340
394
|
html,
|
|
341
395
|
plaintext
|
|
342
396
|
};
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
397
|
+
|
|
398
|
+
// Add post for checking access in renderEmailForSegment (only for previews)
|
|
399
|
+
data.post = post;
|
|
346
400
|
return data;
|
|
347
401
|
},
|
|
348
402
|
|
|
@@ -392,25 +446,45 @@ const PostEmailSerializer = {
|
|
|
392
446
|
|
|
393
447
|
const result = {...email};
|
|
394
448
|
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
449
|
+
// Note about link tracking:
|
|
450
|
+
// Don't add new HTML in here, but add it in the serialize method and surround it with the required HTML comments or attributes
|
|
451
|
+
// This is because we can't replace links at this point (this is executed multiple times, once per batch and we don't want to generate duplicate links for the same email)
|
|
452
|
+
|
|
453
|
+
// Remove the paywall or members-only content based on the current member segment
|
|
454
|
+
const startMembersOnlyContent = (result.html || '').indexOf('<!--members-only-->');
|
|
455
|
+
const startPaywall = result.html.indexOf('<!-- PAYWALL -->');
|
|
456
|
+
let endPost = result.html.indexOf('<!-- POST CONTENT END -->');
|
|
457
|
+
|
|
458
|
+
if (endPost === -1) {
|
|
459
|
+
// Default to the end of the HTML (shouldn't happen, but just in case if we have members-only content that should get removed)
|
|
460
|
+
endPost = result.html.length;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// We support the cases where there is no <!--members-only--> but there is a paywall (in case of bugs)
|
|
464
|
+
// We also support the case where there is no <!-- PAYWALL --> but there is a <!--members-only--> (in case of bugs)
|
|
465
|
+
if (startMembersOnlyContent !== -1 || startPaywall !== -1) {
|
|
466
|
+
// By default remove the paywall if no memberSegment is passed
|
|
467
|
+
let memberHasAccess = true;
|
|
468
|
+
|
|
469
|
+
if (memberSegment && result.post) {
|
|
402
470
|
let statusFilter = memberSegment === 'status:free' ? {status: 'free'} : {status: 'paid'};
|
|
403
471
|
const postVisiblity = result.post.visibility;
|
|
404
472
|
|
|
405
473
|
// For newsletter paywall, specific tiers visibility is considered on par to paid tiers
|
|
406
474
|
result.post.visibility = postVisiblity === 'tiers' ? 'paid' : postVisiblity;
|
|
407
475
|
|
|
408
|
-
|
|
476
|
+
memberHasAccess = membersService.contentGating.checkPostAccess(result.post, statusFilter);
|
|
477
|
+
}
|
|
409
478
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
result.
|
|
479
|
+
if (!memberHasAccess) {
|
|
480
|
+
if (startMembersOnlyContent !== -1) {
|
|
481
|
+
// Remove the members-only content, but keep the paywall (if there is a paywall)
|
|
482
|
+
result.html = result.html.slice(0, startMembersOnlyContent) + result.html.slice(startPaywall === -1 ? endPost : startPaywall);
|
|
483
|
+
}
|
|
484
|
+
} else {
|
|
485
|
+
if (startPaywall !== -1) {
|
|
486
|
+
// Remove the paywall
|
|
487
|
+
result.html = result.html.slice(0, startPaywall) + result.html.slice(endPost);
|
|
414
488
|
}
|
|
415
489
|
}
|
|
416
490
|
}
|
|
@@ -427,7 +501,7 @@ const PostEmailSerializer = {
|
|
|
427
501
|
});
|
|
428
502
|
|
|
429
503
|
result.html = this.formatHtmlForEmail($.html());
|
|
430
|
-
result.plaintext = htmlToPlaintext.email(result.html);
|
|
504
|
+
result.plaintext = htmlToPlaintext.email(result.html);
|
|
431
505
|
delete result.post;
|
|
432
506
|
|
|
433
507
|
return result;
|
|
@@ -9,20 +9,27 @@ class MemberAttributionServiceWrapper {
|
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
// Wire up all the dependencies
|
|
12
|
-
const {
|
|
12
|
+
const {
|
|
13
|
+
MemberAttributionService, UrlTranslator, ReferrerTranslator, AttributionBuilder
|
|
14
|
+
} = require('@tryghost/member-attribution');
|
|
13
15
|
const models = require('../../models');
|
|
14
16
|
|
|
15
17
|
const urlTranslator = new UrlTranslator({
|
|
16
|
-
urlService,
|
|
18
|
+
urlService,
|
|
17
19
|
urlUtils,
|
|
18
20
|
models: {
|
|
19
|
-
Post: models.Post,
|
|
20
|
-
User: models.User,
|
|
21
|
+
Post: models.Post,
|
|
22
|
+
User: models.User,
|
|
21
23
|
Tag: models.Tag
|
|
22
24
|
}
|
|
23
25
|
});
|
|
24
26
|
|
|
25
|
-
|
|
27
|
+
const referrerTranslator = new ReferrerTranslator({
|
|
28
|
+
siteUrl: urlUtils.urlFor('home', true),
|
|
29
|
+
adminUrl: urlUtils.urlFor('admin', true)
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
this.attributionBuilder = new AttributionBuilder({urlTranslator, referrerTranslator});
|
|
26
33
|
|
|
27
34
|
// Expose the service
|
|
28
35
|
this.service = new MemberAttributionService({
|
|
@@ -13,7 +13,6 @@ const SingleUseTokenProvider = require('./SingleUseTokenProvider');
|
|
|
13
13
|
const urlUtils = require('../../../shared/url-utils');
|
|
14
14
|
const labsService = require('../../../shared/labs');
|
|
15
15
|
const offersService = require('../offers');
|
|
16
|
-
const staffService = require('../staff');
|
|
17
16
|
const newslettersService = require('../newsletters');
|
|
18
17
|
const memberAttributionService = require('../member-attribution');
|
|
19
18
|
|
|
@@ -188,6 +187,7 @@ function createApiInstance(config) {
|
|
|
188
187
|
MemberAnalyticEvent: models.MemberAnalyticEvent,
|
|
189
188
|
MemberCreatedEvent: models.MemberCreatedEvent,
|
|
190
189
|
SubscriptionCreatedEvent: models.SubscriptionCreatedEvent,
|
|
190
|
+
MemberLinkClickEvent: models.MemberLinkClickEvent,
|
|
191
191
|
OfferRedemption: models.OfferRedemption,
|
|
192
192
|
Offer: models.Offer,
|
|
193
193
|
StripeProduct: models.StripeProduct,
|
|
@@ -198,7 +198,6 @@ function createApiInstance(config) {
|
|
|
198
198
|
},
|
|
199
199
|
stripeAPIService: stripeService.api,
|
|
200
200
|
offersAPI: offersService.api,
|
|
201
|
-
staffService: staffService.api,
|
|
202
201
|
labsService: labsService,
|
|
203
202
|
newslettersService: newslettersService,
|
|
204
203
|
memberAttributionService: memberAttributionService.service
|