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.
Files changed (176) hide show
  1. package/components/tryghost-adapter-manager-5.16.0.tgz +0 -0
  2. package/components/tryghost-api-framework-5.16.0.tgz +0 -0
  3. package/components/tryghost-api-version-compatibility-service-5.16.0.tgz +0 -0
  4. package/components/tryghost-bootstrap-socket-5.16.0.tgz +0 -0
  5. package/components/tryghost-constants-5.16.0.tgz +0 -0
  6. package/components/tryghost-custom-theme-settings-service-5.16.0.tgz +0 -0
  7. package/components/tryghost-domain-events-5.16.0.tgz +0 -0
  8. package/components/tryghost-email-analytics-provider-mailgun-5.16.0.tgz +0 -0
  9. package/components/{tryghost-email-analytics-service-5.14.2.tgz → tryghost-email-analytics-service-5.16.0.tgz} +0 -0
  10. package/components/tryghost-email-content-generator-5.16.0.tgz +0 -0
  11. package/components/tryghost-express-dynamic-redirects-5.16.0.tgz +0 -0
  12. package/components/tryghost-extract-api-key-5.16.0.tgz +0 -0
  13. package/components/tryghost-html-to-plaintext-5.16.0.tgz +0 -0
  14. package/components/{tryghost-job-manager-5.14.2.tgz → tryghost-job-manager-5.16.0.tgz} +0 -0
  15. package/components/tryghost-link-redirects-5.16.0.tgz +0 -0
  16. package/components/tryghost-link-replacer-5.16.0.tgz +0 -0
  17. package/components/tryghost-link-tracking-5.16.0.tgz +0 -0
  18. package/components/tryghost-magic-link-5.16.0.tgz +0 -0
  19. package/components/{tryghost-mailgun-client-5.14.2.tgz → tryghost-mailgun-client-5.16.0.tgz} +0 -0
  20. package/components/tryghost-member-analytics-service-5.16.0.tgz +0 -0
  21. package/components/tryghost-member-attribution-5.16.0.tgz +0 -0
  22. package/components/tryghost-member-events-5.16.0.tgz +0 -0
  23. package/components/tryghost-members-analytics-ingress-5.16.0.tgz +0 -0
  24. package/components/tryghost-members-api-5.16.0.tgz +0 -0
  25. package/components/tryghost-members-csv-5.16.0.tgz +0 -0
  26. package/components/tryghost-members-events-service-5.16.0.tgz +0 -0
  27. package/components/tryghost-members-importer-5.16.0.tgz +0 -0
  28. package/components/tryghost-members-offers-5.16.0.tgz +0 -0
  29. package/components/{tryghost-members-payments-5.14.2.tgz → tryghost-members-payments-5.16.0.tgz} +0 -0
  30. package/components/{tryghost-members-ssr-5.14.2.tgz → tryghost-members-ssr-5.16.0.tgz} +0 -0
  31. package/components/tryghost-members-stripe-service-5.16.0.tgz +0 -0
  32. package/components/tryghost-minifier-5.16.0.tgz +0 -0
  33. package/components/tryghost-mw-api-version-mismatch-5.16.0.tgz +0 -0
  34. package/components/tryghost-mw-cache-control-5.16.0.tgz +0 -0
  35. package/components/{tryghost-mw-error-handler-5.14.2.tgz → tryghost-mw-error-handler-5.16.0.tgz} +0 -0
  36. package/components/tryghost-mw-session-from-token-5.16.0.tgz +0 -0
  37. package/components/tryghost-mw-update-user-last-seen-5.16.0.tgz +0 -0
  38. package/components/tryghost-mw-vhost-5.16.0.tgz +0 -0
  39. package/components/{tryghost-oembed-service-5.14.2.tgz → tryghost-oembed-service-5.16.0.tgz} +0 -0
  40. package/components/{tryghost-package-json-5.14.2.tgz → tryghost-package-json-5.16.0.tgz} +0 -0
  41. package/components/tryghost-referrers-5.16.0.tgz +0 -0
  42. package/components/{tryghost-security-5.14.2.tgz → tryghost-security-5.16.0.tgz} +0 -0
  43. package/components/tryghost-session-service-5.16.0.tgz +0 -0
  44. package/components/tryghost-settings-path-manager-5.16.0.tgz +0 -0
  45. package/components/tryghost-staff-service-5.16.0.tgz +0 -0
  46. package/components/tryghost-stats-service-5.16.0.tgz +0 -0
  47. package/components/tryghost-update-check-service-5.16.0.tgz +0 -0
  48. package/components/{tryghost-verification-trigger-5.14.2.tgz → tryghost-verification-trigger-5.16.0.tgz} +0 -0
  49. package/components/{tryghost-version-notifications-data-service-5.14.2.tgz → tryghost-version-notifications-data-service-5.16.0.tgz} +0 -0
  50. package/content/themes/casper/default.hbs +2 -2
  51. package/core/boot.js +10 -3
  52. package/core/built/admin/assets/{chunk.143.a5ef705453da0d58b75a.js → chunk.143.a281d460e6059cd0210a.js} +21 -21
  53. package/core/built/admin/assets/{chunk.174.2edaa0869bfc2d88cf90.js → chunk.174.e1e89637eab79fdd5c5d.js} +68 -68
  54. package/core/built/admin/assets/{chunk.178.579a6edabc75a2d7378f.js → chunk.178.68eca2346b6f343991e7.js} +4 -4
  55. package/core/built/admin/assets/{chunk.579.2de3f4300baf25f9a0db.js → chunk.579.d14c3688558f34afeb3e.js} +8872 -7851
  56. package/core/built/admin/assets/{chunk.579.2de3f4300baf25f9a0db.js.LICENSE.txt → chunk.579.d14c3688558f34afeb3e.js.LICENSE.txt} +45 -0
  57. package/core/built/admin/assets/fonts/{Inter.ttf → Inter-e19174fb2c0e19b1fa67492a07886c75.ttf} +0 -0
  58. package/core/built/admin/assets/ghost-6491d134c450ca676911ea17e16cd7d4.css +1 -0
  59. package/core/built/admin/assets/ghost-dark-297ab2fcf4cadd1c950b84089a38c5e2.css +1 -0
  60. package/core/built/admin/assets/{ghost-8919656440ad4617a07bb31069b1f71b.js → ghost-f2bf99b26aee662cf37fe59f87b1ceb5.js} +593 -511
  61. package/core/built/admin/assets/img/{amp.svg → amp-d7b72aae3315fda95921fb575dfca100.svg} +0 -0
  62. package/core/built/admin/assets/img/{disqus.svg → disqus-43503a3fa4f38dc8c61c7358b811f343.svg} +0 -0
  63. package/core/built/admin/assets/img/{favicon.ico → favicon-a9c6dbdcdc3ae568f4e0dad92149a0e3.ico} +0 -0
  64. package/core/built/admin/assets/img/{github.svg → github-c3a739c59df26fed12c10ffb00b33bd4.svg} +0 -0
  65. package/core/built/admin/assets/img/{google-docs.svg → google-docs-1e42cc272fc088da49e4b0ddfb01b006.svg} +0 -0
  66. package/core/built/admin/assets/img/{mailchimp.svg → mailchimp-f22b1e130aac764965b9306d7265a6b2.svg} +0 -0
  67. package/core/built/admin/assets/img/marketing/analytics-1-aa2d72c4e7347a3cb5666d07916b92aa.jpg +0 -0
  68. package/core/built/admin/assets/img/marketing/analytics-2-389d53f80041ff98111cce79facf66b8.jpg +0 -0
  69. package/core/built/admin/assets/img/{patreon.svg → patreon-b19a5e6418a72977a16b30039d374d04.svg} +0 -0
  70. package/core/built/admin/assets/img/{paypal.svg → paypal-38e9448ce7549ea4caf8e7753ae661d6.svg} +0 -0
  71. package/core/built/admin/assets/img/{twitter.svg → twitter-7a7a0ba12d9b5bfb8a2058764a827c31.svg} +0 -0
  72. package/core/built/admin/assets/img/{typeform.svg → typeform-9f23f8712d776a7515594676285266f5.svg} +0 -0
  73. package/core/built/admin/assets/img/{unsplash.svg → unsplash-5b329eef0b11447b4117eaf817ebad6f.svg} +0 -0
  74. package/core/built/admin/assets/img/{zapier.svg → zapier-bf93bc440a3fd43b73489a63c215cdc7.svg} +0 -0
  75. package/core/built/admin/assets/img/{zapier-logo.svg → zapier-logo-a125f24313dfe01ef49af01fc90061fb.svg} +0 -0
  76. package/core/built/admin/assets/{vendor-eb76d0236a09b8b6f44675dba45b1fc6.js → vendor-b2375e2f383cbc3fd73340c4b656c993.js} +59 -47
  77. package/core/built/admin/assets/videos/logo-loader.mp4 +0 -0
  78. package/core/built/admin/index.html +11 -8
  79. package/core/frontend/helpers/search.js +1 -15
  80. package/core/frontend/src/member-attribution/member-attribution.js +64 -3
  81. package/core/frontend/web/site.js +10 -7
  82. package/core/server/api/endpoints/index.js +4 -0
  83. package/core/server/api/endpoints/links.js +25 -0
  84. package/core/server/api/endpoints/posts.js +2 -1
  85. package/core/server/api/endpoints/redirects.js +6 -8
  86. package/core/server/api/endpoints/stats.js +24 -0
  87. package/core/server/api/endpoints/utils/permissions.js +2 -16
  88. package/core/server/api/endpoints/utils/serializers/input/pages.js +5 -5
  89. package/core/server/api/endpoints/utils/serializers/input/posts.js +13 -8
  90. package/core/server/api/endpoints/utils/serializers/input/settings.js +1 -0
  91. package/core/server/api/endpoints/utils/serializers/output/mappers/activity-feed-events.js +51 -0
  92. package/core/server/api/endpoints/utils/serializers/output/mappers/comments.js +10 -1
  93. package/core/server/api/endpoints/utils/serializers/output/mappers/posts.js +1 -1
  94. package/core/server/api/endpoints/utils/validators/input/pages.js +24 -9
  95. package/core/server/api/endpoints/utils/validators/input/posts.js +24 -9
  96. package/core/server/data/exporter/table-lists.js +4 -1
  97. package/core/server/data/migrations/utils/settings.js +1 -3
  98. package/core/server/data/migrations/versions/5.15/2022-09-12-16-10-add-posts-lexical-column.js +8 -0
  99. package/core/server/data/migrations/versions/5.15/2022-09-14-12-46-add-email-track-clicks-setting.js +8 -0
  100. package/core/server/data/migrations/versions/5.15/2022-09-16-08-22-add-post-revisions-table.js +9 -0
  101. package/core/server/data/migrations/versions/5.16/2022-09-19-09-04-add-link-redirects-table.js +10 -0
  102. package/core/server/data/migrations/versions/5.16/2022-09-19-09-05-add-members-link-click-events-table.js +8 -0
  103. package/core/server/data/migrations/versions/5.16/2022-09-19-17-44-add-referrer-columns-to-member-events-table.js +21 -0
  104. package/core/server/data/migrations/versions/5.16/2022-09-19-17-44-add-referrer-columns-to-subscription-events-table.js +21 -0
  105. package/core/server/data/schema/default-settings/default-settings.json +8 -0
  106. package/core/server/data/schema/schema.js +29 -1
  107. package/core/server/lib/lexical.js +12 -0
  108. package/core/server/models/base/plugins/user-type.js +4 -6
  109. package/core/server/models/link-redirect.js +65 -0
  110. package/core/server/models/member-link-click-event.js +26 -0
  111. package/core/server/models/post-revision.js +35 -0
  112. package/core/server/models/post.js +90 -9
  113. package/core/server/services/bulk-email/bulk-email-processor.js +9 -10
  114. package/core/server/services/{redirects → custom-redirects}/api.js +0 -0
  115. package/core/server/services/{redirects → custom-redirects}/index.js +0 -0
  116. package/core/server/services/{redirects → custom-redirects}/utils.js +0 -0
  117. package/core/server/services/{redirects → custom-redirects}/validation.js +0 -0
  118. package/core/server/services/explore/service.js +5 -3
  119. package/core/server/services/link-redirection/LinkRedirectRepository.js +88 -0
  120. package/core/server/services/link-redirection/index.js +31 -0
  121. package/core/server/services/link-tracking/LinkClickRepository.js +69 -0
  122. package/core/server/services/link-tracking/PostLinkRepository.js +62 -0
  123. package/core/server/services/link-tracking/index.js +48 -0
  124. package/core/server/services/mega/email-preview.js +7 -0
  125. package/core/server/services/mega/mega.js +1 -1
  126. package/core/server/services/mega/post-email-serializer.js +101 -27
  127. package/core/server/services/member-attribution/index.js +12 -5
  128. package/core/server/services/members/api.js +1 -2
  129. package/core/server/services/permissions/index.js +1 -2
  130. package/core/server/services/posts/posts-service.js +7 -16
  131. package/core/server/services/posts/stats/post-stats.js +35 -0
  132. package/core/server/services/staff/index.js +10 -1
  133. package/core/server/services/url/config.js +2 -0
  134. package/core/server/web/admin/app.js +8 -2
  135. package/core/server/web/api/endpoints/admin/routes.js +5 -0
  136. package/core/shared/config/defaults.json +7 -7
  137. package/core/shared/config/overrides.json +3 -2
  138. package/core/shared/labs.js +4 -2
  139. package/package.json +115 -107
  140. package/yarn.lock +828 -414
  141. package/components/tryghost-adapter-manager-5.14.2.tgz +0 -0
  142. package/components/tryghost-api-framework-5.14.2.tgz +0 -0
  143. package/components/tryghost-api-version-compatibility-service-5.14.2.tgz +0 -0
  144. package/components/tryghost-bootstrap-socket-5.14.2.tgz +0 -0
  145. package/components/tryghost-constants-5.14.2.tgz +0 -0
  146. package/components/tryghost-custom-theme-settings-service-5.14.2.tgz +0 -0
  147. package/components/tryghost-domain-events-5.14.2.tgz +0 -0
  148. package/components/tryghost-email-analytics-provider-mailgun-5.14.2.tgz +0 -0
  149. package/components/tryghost-email-content-generator-5.14.2.tgz +0 -0
  150. package/components/tryghost-express-dynamic-redirects-5.14.2.tgz +0 -0
  151. package/components/tryghost-extract-api-key-5.14.2.tgz +0 -0
  152. package/components/tryghost-html-to-plaintext-5.14.2.tgz +0 -0
  153. package/components/tryghost-magic-link-5.14.2.tgz +0 -0
  154. package/components/tryghost-member-analytics-service-5.14.2.tgz +0 -0
  155. package/components/tryghost-member-attribution-5.14.2.tgz +0 -0
  156. package/components/tryghost-member-events-5.14.2.tgz +0 -0
  157. package/components/tryghost-members-analytics-ingress-5.14.2.tgz +0 -0
  158. package/components/tryghost-members-api-5.14.2.tgz +0 -0
  159. package/components/tryghost-members-csv-5.14.2.tgz +0 -0
  160. package/components/tryghost-members-events-service-5.14.2.tgz +0 -0
  161. package/components/tryghost-members-importer-5.14.2.tgz +0 -0
  162. package/components/tryghost-members-offers-5.14.2.tgz +0 -0
  163. package/components/tryghost-members-stripe-service-5.14.2.tgz +0 -0
  164. package/components/tryghost-minifier-5.14.2.tgz +0 -0
  165. package/components/tryghost-mw-api-version-mismatch-5.14.2.tgz +0 -0
  166. package/components/tryghost-mw-cache-control-5.14.2.tgz +0 -0
  167. package/components/tryghost-mw-session-from-token-5.14.2.tgz +0 -0
  168. package/components/tryghost-mw-update-user-last-seen-5.14.2.tgz +0 -0
  169. package/components/tryghost-mw-vhost-5.14.2.tgz +0 -0
  170. package/components/tryghost-session-service-5.14.2.tgz +0 -0
  171. package/components/tryghost-settings-path-manager-5.14.2.tgz +0 -0
  172. package/components/tryghost-staff-service-5.14.2.tgz +0 -0
  173. package/components/tryghost-update-check-service-5.14.2.tgz +0 -0
  174. package/core/built/admin/assets/ghost-40adc8310dcdd0be163cbf7b9d89c59a.css +0 -1
  175. package/core/built/admin/assets/ghost-dark-13b669d50f494edf24d832b32ece2177.css +0 -1
  176. package/core/server/services/permissions/public.js +0 -76
@@ -46,11 +46,13 @@ module.exports = class ExploreService {
46
46
  }
47
47
  };
48
48
 
49
- const mostRecentlyPublishedPost = await this.PostsService.getMostRecentlyPublishedPost();
50
- exploreProperties.most_recently_published_at = mostRecentlyPublishedPost?.get('published_at') || null;
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') || null;
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
- let _cheerio = cheerio.load(`<body>${post.html}</body>`);
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
- if (options.isBrowserPreview) {
329
- const previewUnsubscribeUrl = this.createUnsubscribeUrl(null);
330
- htmlTemplate = htmlTemplate.replace('%recipient.unsubscribe_url%', previewUnsubscribeUrl);
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
- if (labs.isSet('newsletterPaywall')) {
344
- data.post = post;
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
- /** Checks and hides content for newsletter behind paywall card
396
- * based on member's status and post access
397
- * Adds CTA in case content is hidden.
398
- */
399
- if (labs.isSet('newsletterPaywall')) {
400
- const paywallIndex = (result.html || '').indexOf('<!--members-only-->');
401
- if (paywallIndex !== -1 && memberSegment && result.post) {
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
- const memberHasAccess = membersService.contentGating.checkPostAccess(result.post, statusFilter);
476
+ memberHasAccess = membersService.contentGating.checkPostAccess(result.post, statusFilter);
477
+ }
409
478
 
410
- if (!memberHasAccess) {
411
- const postContentEndIdx = result.html.search(/[\s\n\r]+?<!-- POST CONTENT END -->/);
412
- result.html = result.html.slice(0, paywallIndex) + this.renderPaywallCTA(result.post) + result.html.slice(postContentEndIdx);
413
- result.plaintext = htmlToPlaintext.excerpt(result.html);
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 {MemberAttributionService, UrlTranslator, AttributionBuilder} = require('@tryghost/member-attribution');
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
- this.attributionBuilder = new AttributionBuilder({urlTranslator});
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
@@ -19,6 +19,5 @@ module.exports = {
19
19
  init: init,
20
20
  canThis: require('./can-this'),
21
21
  // @TODO: Make it so that we don't need to export these
22
- parseContext: require('./parse-context'),
23
- applyPublicRules: require('./public')
22
+ parseContext: require('./parse-context')
24
23
  };