ghost 5.110.4 → 5.112.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 (202) hide show
  1. package/components/tryghost-adapter-cache-memory-ttl-5.112.0.tgz +0 -0
  2. package/components/tryghost-adapter-cache-redis-5.112.0.tgz +0 -0
  3. package/components/{tryghost-adapter-manager-5.110.4.tgz → tryghost-adapter-manager-5.112.0.tgz} +0 -0
  4. package/components/tryghost-announcement-bar-settings-5.112.0.tgz +0 -0
  5. package/components/{tryghost-api-framework-5.110.4.tgz → tryghost-api-framework-5.112.0.tgz} +0 -0
  6. package/components/tryghost-api-version-compatibility-service-5.112.0.tgz +0 -0
  7. package/components/{tryghost-audience-feedback-5.110.4.tgz → tryghost-audience-feedback-5.112.0.tgz} +0 -0
  8. package/components/tryghost-bookshelf-repository-5.112.0.tgz +0 -0
  9. package/components/tryghost-bootstrap-socket-5.112.0.tgz +0 -0
  10. package/components/tryghost-captcha-service-5.112.0.tgz +0 -0
  11. package/components/tryghost-constants-5.112.0.tgz +0 -0
  12. package/components/tryghost-custom-fonts-5.112.0.tgz +0 -0
  13. package/components/{tryghost-custom-theme-settings-service-5.110.4.tgz → tryghost-custom-theme-settings-service-5.112.0.tgz} +0 -0
  14. package/components/{tryghost-data-generator-5.110.4.tgz → tryghost-data-generator-5.112.0.tgz} +0 -0
  15. package/components/{tryghost-domain-events-5.110.4.tgz → tryghost-domain-events-5.112.0.tgz} +0 -0
  16. package/components/tryghost-donations-5.112.0.tgz +0 -0
  17. package/components/tryghost-email-addresses-5.112.0.tgz +0 -0
  18. package/components/tryghost-email-analytics-provider-mailgun-5.112.0.tgz +0 -0
  19. package/components/{tryghost-email-analytics-service-5.110.4.tgz → tryghost-email-analytics-service-5.112.0.tgz} +0 -0
  20. package/components/tryghost-email-content-generator-5.112.0.tgz +0 -0
  21. package/components/tryghost-email-events-5.112.0.tgz +0 -0
  22. package/components/tryghost-email-service-5.112.0.tgz +0 -0
  23. package/components/tryghost-email-suppression-list-5.112.0.tgz +0 -0
  24. package/components/{tryghost-express-dynamic-redirects-5.110.4.tgz → tryghost-express-dynamic-redirects-5.112.0.tgz} +0 -0
  25. package/components/{tryghost-external-media-inliner-5.110.4.tgz → tryghost-external-media-inliner-5.112.0.tgz} +0 -0
  26. package/components/tryghost-extract-api-key-5.112.0.tgz +0 -0
  27. package/components/tryghost-ghost-5.112.0.tgz +0 -0
  28. package/components/{tryghost-html-to-plaintext-5.110.4.tgz → tryghost-html-to-plaintext-5.112.0.tgz} +0 -0
  29. package/components/tryghost-i18n-5.112.0.tgz +0 -0
  30. package/components/tryghost-identity-token-service-5.112.0.tgz +0 -0
  31. package/components/tryghost-importer-handler-content-files-5.112.0.tgz +0 -0
  32. package/components/{tryghost-importer-revue-5.110.4.tgz → tryghost-importer-revue-5.112.0.tgz} +0 -0
  33. package/components/tryghost-in-memory-repository-5.112.0.tgz +0 -0
  34. package/components/{tryghost-job-manager-5.110.4.tgz → tryghost-job-manager-5.112.0.tgz} +0 -0
  35. package/components/{tryghost-link-redirects-5.110.4.tgz → tryghost-link-redirects-5.112.0.tgz} +0 -0
  36. package/components/tryghost-link-replacer-5.112.0.tgz +0 -0
  37. package/components/{tryghost-magic-link-5.110.4.tgz → tryghost-magic-link-5.112.0.tgz} +0 -0
  38. package/components/tryghost-mail-events-5.112.0.tgz +0 -0
  39. package/components/tryghost-mailgun-client-5.112.0.tgz +0 -0
  40. package/components/{tryghost-member-attribution-5.110.4.tgz → tryghost-member-attribution-5.112.0.tgz} +0 -0
  41. package/components/{tryghost-member-events-5.110.4.tgz → tryghost-member-events-5.112.0.tgz} +0 -0
  42. package/components/{tryghost-members-api-5.110.4.tgz → tryghost-members-api-5.112.0.tgz} +0 -0
  43. package/components/{tryghost-members-csv-5.110.4.tgz → tryghost-members-csv-5.112.0.tgz} +0 -0
  44. package/components/{tryghost-members-importer-5.110.4.tgz → tryghost-members-importer-5.112.0.tgz} +0 -0
  45. package/components/{tryghost-members-offers-5.110.4.tgz → tryghost-members-offers-5.112.0.tgz} +0 -0
  46. package/components/{tryghost-members-payments-5.110.4.tgz → tryghost-members-payments-5.112.0.tgz} +0 -0
  47. package/components/{tryghost-members-ssr-5.110.4.tgz → tryghost-members-ssr-5.112.0.tgz} +0 -0
  48. package/components/{tryghost-members-stripe-service-5.110.4.tgz → tryghost-members-stripe-service-5.112.0.tgz} +0 -0
  49. package/components/{tryghost-milestones-5.110.4.tgz → tryghost-milestones-5.112.0.tgz} +0 -0
  50. package/components/tryghost-minifier-5.112.0.tgz +0 -0
  51. package/components/tryghost-mw-api-version-mismatch-5.112.0.tgz +0 -0
  52. package/components/{tryghost-mw-cache-control-5.110.4.tgz → tryghost-mw-cache-control-5.112.0.tgz} +0 -0
  53. package/components/tryghost-mw-error-handler-5.112.0.tgz +0 -0
  54. package/components/{tryghost-mw-session-from-token-5.110.4.tgz → tryghost-mw-session-from-token-5.112.0.tgz} +0 -0
  55. package/components/{tryghost-mw-update-user-last-seen-5.110.4.tgz → tryghost-mw-update-user-last-seen-5.112.0.tgz} +0 -0
  56. package/components/tryghost-mw-version-match-5.112.0.tgz +0 -0
  57. package/components/tryghost-mw-vhost-5.112.0.tgz +0 -0
  58. package/components/tryghost-package-json-5.112.0.tgz +0 -0
  59. package/components/{tryghost-post-events-5.110.4.tgz → tryghost-post-events-5.112.0.tgz} +0 -0
  60. package/components/{tryghost-post-revisions-5.110.4.tgz → tryghost-post-revisions-5.112.0.tgz} +0 -0
  61. package/components/{tryghost-posts-service-5.110.4.tgz → tryghost-posts-service-5.112.0.tgz} +0 -0
  62. package/components/tryghost-prometheus-metrics-5.112.0.tgz +0 -0
  63. package/components/tryghost-recommendations-5.112.0.tgz +0 -0
  64. package/components/tryghost-referrers-5.112.0.tgz +0 -0
  65. package/components/{tryghost-security-5.110.4.tgz → tryghost-security-5.112.0.tgz} +0 -0
  66. package/components/tryghost-session-service-5.112.0.tgz +0 -0
  67. package/components/tryghost-settings-path-manager-5.112.0.tgz +0 -0
  68. package/components/{tryghost-slack-notifications-5.110.4.tgz → tryghost-slack-notifications-5.112.0.tgz} +0 -0
  69. package/components/tryghost-tiers-5.112.0.tgz +0 -0
  70. package/components/tryghost-version-notifications-data-service-5.112.0.tgz +0 -0
  71. package/components/tryghost-webmentions-5.112.0.tgz +0 -0
  72. package/core/built/admin/assets/admin-x-activitypub/admin-x-activitypub.js +12799 -10270
  73. package/core/built/admin/assets/admin-x-demo/admin-x-demo.js +2 -2
  74. package/core/built/admin/assets/admin-x-demo/{index-82e381fb.mjs → index-0040480a.mjs} +3252 -2891
  75. package/core/built/admin/assets/admin-x-demo/{modals-b20a9ede.mjs → modals-fb35c86c.mjs} +2 -2
  76. package/core/built/admin/assets/admin-x-settings/{CodeEditorView-ea62c29b.mjs → CodeEditorView-ad8698fe.mjs} +624 -618
  77. package/core/built/admin/assets/admin-x-settings/admin-x-settings.js +3 -3
  78. package/core/built/admin/assets/admin-x-settings/{index-af8cf9cf.mjs → index-2713e469.mjs} +6892 -6469
  79. package/core/built/admin/assets/admin-x-settings/{index-4b25c788.mjs → index-463cec50.mjs} +2 -2
  80. package/core/built/admin/assets/admin-x-settings/{modals-cb2dc7b7.mjs → modals-033e8fc4.mjs} +7888 -7669
  81. package/core/built/admin/assets/{chunk.524.3096e68df5b51dacf872.js → chunk.524.db49da6fd8ae155205a4.js} +6 -6
  82. package/core/built/admin/assets/{chunk.582.e225422f90639ff30544.js → chunk.582.0bf715eb6807f7641706.js} +8 -8
  83. package/core/built/admin/assets/{ghost-98d002d50a5e01d2100b2c387a849249.js → ghost-62bd4d4c837d453e1038808dc1cd1e4c.js} +43 -42
  84. package/core/built/admin/assets/img/ap-nodes-01ee317529e6353a1c34a062c388f1e7.png +0 -0
  85. package/core/built/admin/assets/koenig-lexical/index.css +1 -1
  86. package/core/built/admin/assets/koenig-lexical/koenig-lexical.js +18314 -17680
  87. package/core/built/admin/assets/koenig-lexical/koenig-lexical.umd.js +229 -200
  88. package/core/built/admin/assets/posts/posts.js +24137 -24156
  89. package/core/built/admin/index.html +3 -3
  90. package/core/frontend/helpers/get.js +2 -3
  91. package/core/frontend/services/sitemap/SiteMapManager.js +1 -1
  92. package/core/frontend/src/cards/css/cta.css +40 -30
  93. package/core/frontend/src/cards/css/video.css +1 -0
  94. package/core/server/api/endpoints/settings-public.js +3 -2
  95. package/core/server/api/endpoints/utils/serializers/input/settings.js +3 -1
  96. package/core/server/api/endpoints/utils/serializers/input/utils/settings-key-group-mapper.js +2 -1
  97. package/core/server/api/endpoints/utils/serializers/input/utils/settings-key-type-mapper.js +2 -1
  98. package/core/server/data/migrations/versions/5.111/2025-03-05-16-36-39-add-captcha-setting.js +8 -0
  99. package/core/server/data/migrations/versions/5.112/2025-03-10-10-01-01-add-require-mfa-setting.js +8 -0
  100. package/core/server/data/schema/default-settings/default-settings.json +14 -0
  101. package/core/server/models/invite.js +4 -5
  102. package/core/server/models/post.js +3 -9
  103. package/core/server/models/relations/authors.js +2 -4
  104. package/core/server/models/role-utils.js +38 -0
  105. package/core/server/models/role.js +5 -3
  106. package/core/server/models/user.js +5 -3
  107. package/core/server/services/activitypub/ActivityPubService.js +116 -0
  108. package/core/server/services/activitypub/ActivityPubService.ts +139 -0
  109. package/core/server/services/activitypub/ActivityPubServiceWrapper.js +1 -1
  110. package/core/server/services/link-tracking/ClickEvent.js +25 -0
  111. package/core/server/services/link-tracking/FullPostLink.js +36 -0
  112. package/core/server/services/link-tracking/LinkClickRepository.js +1 -1
  113. package/core/server/services/link-tracking/LinkClickTrackingService.js +237 -0
  114. package/core/server/services/link-tracking/PostLink.js +29 -0
  115. package/core/server/services/link-tracking/PostLinkRepository.js +2 -2
  116. package/core/server/services/link-tracking/index.js +1 -1
  117. package/core/server/services/members-events/EventStorage.js +61 -0
  118. package/core/server/services/members-events/LastSeenAtCache.js +96 -0
  119. package/core/server/services/members-events/LastSeenAtUpdater.js +192 -0
  120. package/core/server/services/members-events/index.js +3 -1
  121. package/core/server/services/mentions-email-report/MentionEmailReportJob.js +117 -0
  122. package/core/server/services/mentions-email-report/service.js +3 -3
  123. package/core/server/services/staff/StaffService.js +179 -0
  124. package/core/server/services/staff/StaffServiceEmails.js +527 -0
  125. package/core/server/services/staff/email-templates/donation.hbs +119 -0
  126. package/core/server/services/staff/email-templates/donation.txt.js +15 -0
  127. package/core/server/services/staff/email-templates/mention-report.hbs +136 -0
  128. package/core/server/services/staff/email-templates/mention-report.txt.js +19 -0
  129. package/core/server/services/staff/email-templates/new-free-signup.hbs +118 -0
  130. package/core/server/services/staff/email-templates/new-free-signup.txt.js +13 -0
  131. package/core/server/services/staff/email-templates/new-milestone-received.hbs +142 -0
  132. package/core/server/services/staff/email-templates/new-milestone-received.txt.js +13 -0
  133. package/core/server/services/staff/email-templates/new-paid-cancellation.hbs +125 -0
  134. package/core/server/services/staff/email-templates/new-paid-cancellation.txt.js +13 -0
  135. package/core/server/services/staff/email-templates/new-paid-started.hbs +124 -0
  136. package/core/server/services/staff/email-templates/new-paid-started.txt.js +13 -0
  137. package/core/server/services/staff/email-templates/partials/preview.hbs +6 -0
  138. package/core/server/services/staff/email-templates/partials/styles.hbs +114 -0
  139. package/core/server/services/staff/email-templates/recommendation-received.hbs +154 -0
  140. package/core/server/services/staff/email-templates/recommendation-received.txt.js +13 -0
  141. package/core/server/services/staff/index.js +1 -1
  142. package/core/server/services/staff/milestone-email-config.js +207 -0
  143. package/core/server/services/stats/MembersStatsService.js +167 -0
  144. package/core/server/services/stats/MrrStatsService.js +161 -0
  145. package/core/server/services/stats/ReferrersStatsService.js +164 -0
  146. package/core/server/services/stats/StatsService.js +63 -0
  147. package/core/server/services/stats/SubscriptionStatsService.js +180 -0
  148. package/core/server/services/stats/service.js +1 -1
  149. package/core/server/services/url/Resources.js +2 -2
  150. package/core/shared/config/defaults.json +2 -1
  151. package/core/shared/events/URLResourceUpdatedEvent.js +33 -0
  152. package/core/shared/settings-cache/public.js +1 -0
  153. package/package.json +155 -158
  154. package/tsconfig.json +105 -0
  155. package/tsconfig.tsbuildinfo +1 -0
  156. package/yarn.lock +347 -136
  157. package/components/tryghost-activitypub-5.110.4.tgz +0 -0
  158. package/components/tryghost-adapter-cache-memory-ttl-5.110.4.tgz +0 -0
  159. package/components/tryghost-adapter-cache-redis-5.110.4.tgz +0 -0
  160. package/components/tryghost-announcement-bar-settings-5.110.4.tgz +0 -0
  161. package/components/tryghost-api-version-compatibility-service-5.110.4.tgz +0 -0
  162. package/components/tryghost-bookshelf-repository-5.110.4.tgz +0 -0
  163. package/components/tryghost-bootstrap-socket-5.110.4.tgz +0 -0
  164. package/components/tryghost-captcha-service-5.110.4.tgz +0 -0
  165. package/components/tryghost-constants-5.110.4.tgz +0 -0
  166. package/components/tryghost-custom-fonts-5.110.4.tgz +0 -0
  167. package/components/tryghost-donations-5.110.4.tgz +0 -0
  168. package/components/tryghost-dynamic-routing-events-5.110.4.tgz +0 -0
  169. package/components/tryghost-email-addresses-5.110.4.tgz +0 -0
  170. package/components/tryghost-email-analytics-provider-mailgun-5.110.4.tgz +0 -0
  171. package/components/tryghost-email-content-generator-5.110.4.tgz +0 -0
  172. package/components/tryghost-email-events-5.110.4.tgz +0 -0
  173. package/components/tryghost-email-service-5.110.4.tgz +0 -0
  174. package/components/tryghost-email-suppression-list-5.110.4.tgz +0 -0
  175. package/components/tryghost-extract-api-key-5.110.4.tgz +0 -0
  176. package/components/tryghost-ghost-5.110.4.tgz +0 -0
  177. package/components/tryghost-i18n-5.110.4.tgz +0 -0
  178. package/components/tryghost-identity-token-service-5.110.4.tgz +0 -0
  179. package/components/tryghost-importer-handler-content-files-5.110.4.tgz +0 -0
  180. package/components/tryghost-in-memory-repository-5.110.4.tgz +0 -0
  181. package/components/tryghost-link-replacer-5.110.4.tgz +0 -0
  182. package/components/tryghost-link-tracking-5.110.4.tgz +0 -0
  183. package/components/tryghost-mail-events-5.110.4.tgz +0 -0
  184. package/components/tryghost-mailgun-client-5.110.4.tgz +0 -0
  185. package/components/tryghost-members-events-service-5.110.4.tgz +0 -0
  186. package/components/tryghost-mentions-email-report-5.110.4.tgz +0 -0
  187. package/components/tryghost-minifier-5.110.4.tgz +0 -0
  188. package/components/tryghost-mw-api-version-mismatch-5.110.4.tgz +0 -0
  189. package/components/tryghost-mw-error-handler-5.110.4.tgz +0 -0
  190. package/components/tryghost-mw-version-match-5.110.4.tgz +0 -0
  191. package/components/tryghost-mw-vhost-5.110.4.tgz +0 -0
  192. package/components/tryghost-package-json-5.110.4.tgz +0 -0
  193. package/components/tryghost-prometheus-metrics-5.110.4.tgz +0 -0
  194. package/components/tryghost-recommendations-5.110.4.tgz +0 -0
  195. package/components/tryghost-referrers-5.110.4.tgz +0 -0
  196. package/components/tryghost-session-service-5.110.4.tgz +0 -0
  197. package/components/tryghost-settings-path-manager-5.110.4.tgz +0 -0
  198. package/components/tryghost-staff-service-5.110.4.tgz +0 -0
  199. package/components/tryghost-stats-service-5.110.4.tgz +0 -0
  200. package/components/tryghost-tiers-5.110.4.tgz +0 -0
  201. package/components/tryghost-version-notifications-data-service-5.110.4.tgz +0 -0
  202. package/components/tryghost-webmentions-5.110.4.tgz +0 -0
@@ -0,0 +1,139 @@
1
+ import ObjectID from 'bson-objectid';
2
+ import {Knex} from 'knex';
3
+ import {IdentityTokenService} from '@tryghost/identity-token-service';
4
+ import fetch from 'node-fetch';
5
+
6
+ type ExpectedWebhook = {
7
+ event: string;
8
+ target_url: URL;
9
+ api_version: string;
10
+ secret: string;
11
+ };
12
+
13
+ interface Logger {
14
+ info(message: string): void
15
+ warn(message: string): void
16
+ error(message: string): void
17
+ }
18
+
19
+ export class ActivityPubService {
20
+ constructor(
21
+ private knex: Knex,
22
+ private siteUrl: URL,
23
+ private logging: Logger,
24
+ private identityTokenService: IdentityTokenService
25
+ ) {}
26
+
27
+ getExpectedWebhooks(secret: string): ExpectedWebhook[] {
28
+ return [{
29
+ event: 'post.published',
30
+ target_url: new URL('.ghost/activitypub/webhooks/post/published', this.siteUrl),
31
+ api_version: 'v5.100.0',
32
+ secret
33
+ }, {
34
+ event: 'site.changed',
35
+ target_url: new URL('.ghost/activitypub/webhooks/site/changed', this.siteUrl),
36
+ api_version: 'v5.100.0',
37
+ secret
38
+ }];
39
+ }
40
+
41
+ async checkWebhookState(expectedWebhooks: ExpectedWebhook[], integration: {id: string}) {
42
+ this.logging.info(`Checking ActivityPub Webhook state`);
43
+
44
+ const webhooks = await this.knex
45
+ .select('*')
46
+ .from('webhooks')
47
+ .where('integration_id', '=', integration.id);
48
+
49
+ if (webhooks.length !== expectedWebhooks.length) {
50
+ this.logging.warn(`Expected ${expectedWebhooks.length} webhooks for ActivityPub`);
51
+ return false;
52
+ }
53
+
54
+ for (const expectedWebhook of expectedWebhooks) {
55
+ const foundWebhook = webhooks.find((webhook) => {
56
+ return webhook.event === expectedWebhook.event && webhook.target_url === expectedWebhook.target_url.href && webhook.secret === expectedWebhook.secret;
57
+ });
58
+ if (!foundWebhook) {
59
+ this.logging.error(`Could not find webhook for ${expectedWebhook.event} ${expectedWebhook.target_url}`);
60
+ return false;
61
+ }
62
+ }
63
+
64
+ return true;
65
+ }
66
+
67
+ async getWebhookSecret(): Promise<string | null> {
68
+ try {
69
+ const ownerUser = await this.knex.select('*').from('users').where('id', '=', '1').first();
70
+ const token = await this.identityTokenService.getTokenForUser(ownerUser.email, 'Owner');
71
+
72
+ const res = await fetch(new URL('.ghost/activitypub/site', this.siteUrl), {
73
+ headers: {
74
+ Authorization: `Bearer ${token}`
75
+ }
76
+ });
77
+
78
+ const body = await res.json();
79
+
80
+ return body.webhook_secret;
81
+ } catch (err: unknown) {
82
+ this.logging.error(`Could not get webhook secret for ActivityPub ${err}`);
83
+ return null;
84
+ }
85
+ }
86
+
87
+ async initialiseWebhooks() {
88
+ const integration = await this.knex
89
+ .select('*')
90
+ .from('integrations')
91
+ .where('slug', '=', 'ghost-activitypub')
92
+ .andWhere('type', '=', 'internal')
93
+ .first();
94
+
95
+ if (!integration) {
96
+ this.logging.error('No ActivityPub integration found - cannot initialise');
97
+ return;
98
+ }
99
+
100
+ const secret = await this.getWebhookSecret();
101
+
102
+ if (!secret) {
103
+ this.logging.error('No webhook secret found - cannot initialise');
104
+ return;
105
+ }
106
+
107
+ const expectedWebhooks = this.getExpectedWebhooks(secret);
108
+ const isInCorrectState = await this.checkWebhookState(expectedWebhooks, integration);
109
+
110
+ if (isInCorrectState) {
111
+ this.logging.info(`ActivityPub webhooks in correct state`);
112
+ return;
113
+ }
114
+
115
+ this.logging.info(`ActivityPub webhooks in incorrect state, deleting all of them and starting fresh`);
116
+ await this.knex
117
+ .del()
118
+ .from('webhooks')
119
+ .where('integration_id', '=', integration.id);
120
+
121
+ const webhooksToInsert = expectedWebhooks.map((expectedWebhook) => {
122
+ return {
123
+ id: (new ObjectID).toHexString(),
124
+ event: expectedWebhook.event,
125
+ target_url: expectedWebhook.target_url.href,
126
+ api_version: expectedWebhook.api_version,
127
+ name: `ActivityPub ${expectedWebhook.event} Webhook`,
128
+ secret: secret,
129
+ integration_id: integration.id,
130
+ created_at: this.knex.raw('current_timestamp'),
131
+ created_by: '1'
132
+ };
133
+ });
134
+
135
+ await this.knex
136
+ .insert(webhooksToInsert)
137
+ .into('webhooks');
138
+ }
139
+ }
@@ -1,4 +1,4 @@
1
- const {ActivityPubService} = require('@tryghost/activitypub');
1
+ const {ActivityPubService} = require('./ActivityPubService');
2
2
 
3
3
  module.exports = class ActivityPubServiceWrapper {
4
4
  /** @type ActivityPubService */
@@ -0,0 +1,25 @@
1
+ const ObjectID = require('bson-objectid').default;
2
+
3
+ module.exports = class ClickEvent {
4
+ /** @type {ObjectID} */
5
+ event_id;
6
+ /** @type {string} */
7
+ member_uuid;
8
+ /** @type {ObjectID} */
9
+ link_id;
10
+
11
+ constructor(data) {
12
+ if (!data.id) {
13
+ this.event_id = new ObjectID();
14
+ }
15
+
16
+ if (typeof data.id === 'string') {
17
+ this.event_id = ObjectID.createFromHexString(data.id);
18
+ } else {
19
+ this.event_id = data.id;
20
+ }
21
+
22
+ this.member_uuid = data.member_uuid;
23
+ this.link_id = data.link_id;
24
+ }
25
+ };
@@ -0,0 +1,36 @@
1
+ const ObjectID = require('bson-objectid').default;
2
+
3
+ /**
4
+ * @typedef {Object} FullPostLinkCount
5
+ * @property {number} clicks
6
+ */
7
+
8
+ /**
9
+ * Stores the connection between a LinkRedirect and a Post
10
+ */
11
+ module.exports = class FullPostLink {
12
+ /** @type {ObjectID} */
13
+ post_id;
14
+
15
+ /** @type {import('@tryghost/link-redirects/lib/LinkRedirect')} */
16
+ link;
17
+
18
+ /** @type {FullPostLinkCount} */
19
+ count;
20
+
21
+ /**
22
+ * @param {object} data
23
+ * @param {string|ObjectID} data.post_id
24
+ * @param {import('@tryghost/link-redirects/lib/LinkRedirect')} data.link
25
+ * @param {FullPostLinkCount} data.count
26
+ */
27
+ constructor(data) {
28
+ if (typeof data.post_id === 'string') {
29
+ this.post_id = ObjectID.createFromHexString(data.post_id);
30
+ } else {
31
+ this.post_id = data.post_id;
32
+ }
33
+ this.link = data.link;
34
+ this.count = data.count;
35
+ }
36
+ };
@@ -1,4 +1,4 @@
1
- const {LinkClick} = require('@tryghost/link-tracking');
1
+ const LinkClick = require('./ClickEvent');
2
2
  const ObjectID = require('bson-objectid').default;
3
3
  const sentry = require('../../../shared/sentry');
4
4
  const config = require('../../../shared/config');
@@ -0,0 +1,237 @@
1
+ const {RedirectEvent} = require('@tryghost/link-redirects');
2
+ const LinkClick = require('./ClickEvent');
3
+ const PostLink = require('./PostLink');
4
+ const ObjectID = require('bson-objectid').default;
5
+ const errors = require('@tryghost/errors');
6
+ const nql = require('@tryghost/nql');
7
+ const _ = require('lodash');
8
+ const tpl = require('@tryghost/tpl');
9
+ const moment = require('moment');
10
+
11
+ /**
12
+ * @typedef {object} ILinkClickRepository
13
+ * @prop {(event: LinkClick) => Promise<void>} save
14
+ * @prop {({filter: string}) => Promise<LinkClick[]>} getAll
15
+ */
16
+
17
+ /**
18
+ * @typedef {object} ILinkRedirect
19
+ * @prop {ObjectID} link_id
20
+ * @prop {URL} to
21
+ * @prop {URL} from
22
+ */
23
+
24
+ /**
25
+ * @typedef {import('./FullPostLink')} FullPostLink
26
+ */
27
+
28
+ /**
29
+ * @typedef {object} ILinkRedirectService
30
+ * @prop {(to: URL, slug: string) => Promise<ILinkRedirect>} addRedirect
31
+ * @prop {() => Promise<string>} getSlug
32
+ * @prop {({filter: string}) => Promise<ILinkRedirect[]>} getAll
33
+ * @prop {({filter: string}) => Promise<string[]>} getFilteredIds
34
+ */
35
+
36
+ /**
37
+ * @typedef {object} IPostLinkRepository
38
+ * @prop {(postLink: PostLink) => Promise<void>} save
39
+ * @prop {({filter: string}) => Promise<FullPostLink[]>} getAll
40
+ * @prop {(linkIds: array, data, options) => Promise<FullPostLink[]>} updateLinks
41
+ */
42
+
43
+ const messages = {
44
+ invalidFilter: 'Invalid filter value received',
45
+ unsupportedBulkAction: 'Unsupported bulk action',
46
+ invalidRedirectUrl: 'Invalid redirect URL value'
47
+ };
48
+
49
+ class LinkClickTrackingService {
50
+ #initialised = false;
51
+
52
+ /** @type ILinkClickRepository */
53
+ #linkClickRepository;
54
+ /** @type ILinkRedirectService */
55
+ #linkRedirectService;
56
+ /** @type IPostLinkRepository */
57
+ #postLinkRepository;
58
+ /** @type DomainEvents */
59
+ #DomainEvents;
60
+ /** @type {Object} */
61
+ #LinkRedirect;
62
+ /** @type {Object} */
63
+ #urlUtils;
64
+
65
+ /**
66
+ * @param {object} deps
67
+ * @param {ILinkClickRepository} deps.linkClickRepository
68
+ * @param {ILinkRedirectService} deps.linkRedirectService
69
+ * @param {IPostLinkRepository} deps.postLinkRepository
70
+ * @param {DomainEvents} deps.DomainEvents
71
+ * @param {urlUtils} deps.urlUtils
72
+ */
73
+ constructor(deps) {
74
+ this.#linkClickRepository = deps.linkClickRepository;
75
+ this.#linkRedirectService = deps.linkRedirectService;
76
+ this.#postLinkRepository = deps.postLinkRepository;
77
+ this.#DomainEvents = deps.DomainEvents;
78
+ this.#urlUtils = deps.urlUtils;
79
+ }
80
+
81
+ async init() {
82
+ if (this.#initialised) {
83
+ return;
84
+ }
85
+ this.subscribe();
86
+ this.#initialised = true;
87
+ }
88
+
89
+ /**
90
+ * @param {object} options
91
+ * @param {string} options.filter
92
+ * @return {Promise<FullPostLink[]>}
93
+ */
94
+ async getLinks(options) {
95
+ return await this.#postLinkRepository.getAll({
96
+ filter: options.filter
97
+ });
98
+ }
99
+
100
+ /**
101
+ * validate and manage the new redirect url in filter
102
+ * `to` url needs decoding and transformation to relative url for comparision
103
+ * @param {string} filter
104
+ * @returns {Object} parsed filter
105
+ * @throws {errors.BadRequestError}
106
+ */
107
+ #parseLinkFilter(filter) {
108
+ try {
109
+ const filterJson = nql(filter).parse();
110
+ const postId = filterJson?.$and?.[0]?.post_id;
111
+ const redirectUrl = new URL(filterJson?.$and?.[1]?.to);
112
+ if (!postId || !redirectUrl) {
113
+ throw new errors.BadRequestError({
114
+ message: tpl(messages.invalidFilter)
115
+ });
116
+ }
117
+ return {
118
+ postId,
119
+ redirectUrl
120
+ };
121
+ } catch (e) {
122
+ throw new errors.BadRequestError({
123
+ message: tpl(messages.invalidFilter),
124
+ context: e.message
125
+ });
126
+ }
127
+ }
128
+
129
+ #getRedirectLinkWithAttribution({newLink, oldLink, postId}) {
130
+ const newUrl = new URL(newLink);
131
+ const oldUrl = new URL(oldLink);
132
+ // append newsletter ref query param from oldUrl to newUrl
133
+ if (oldUrl.searchParams.has('ref')) {
134
+ newUrl.searchParams.set('ref', oldUrl.searchParams.get('ref'));
135
+ }
136
+
137
+ // append post attribution to site urls
138
+ const isSite = this.#urlUtils.isSiteUrl(newUrl);
139
+ if (isSite) {
140
+ newUrl.searchParams.set('attribution_type', 'post');
141
+ newUrl.searchParams.set('attribution_id', postId);
142
+ }
143
+ return newUrl;
144
+ }
145
+
146
+ async #updateLinks(data, options) {
147
+ const filterOptions = _.pick(options, ['transacting', 'context', 'filter']);
148
+
149
+ // decode and parse filter to manage new redirect url
150
+ const {postId, redirectUrl} = this.#parseLinkFilter(filterOptions.filter);
151
+
152
+ // manages transformation of current url to relative for comparision
153
+ const transformedOldUrl = this.#urlUtils.absoluteToTransformReady(redirectUrl.href);
154
+ const filterQuery = `post_id:'${postId}'+to:'${transformedOldUrl}'`;
155
+
156
+ const updatedFilterOptions = {
157
+ ...filterOptions,
158
+ filter: filterQuery
159
+ };
160
+
161
+ // get new redirect link with proper attribution
162
+ const newRedirectUrl = this.#getRedirectLinkWithAttribution({
163
+ newLink: data.meta?.link?.to,
164
+ oldLink: redirectUrl.href,
165
+ postId
166
+ });
167
+ const linkIds = await this.#linkRedirectService.getFilteredIds(updatedFilterOptions);
168
+
169
+ const bulkUpdateOptions = _.pick(options, ['transacting']);
170
+ const updateData = {
171
+ to: this.#urlUtils.absoluteToTransformReady(newRedirectUrl.href),
172
+ updated_at: moment().format('YYYY-MM-DD HH:mm:ss')
173
+ };
174
+
175
+ return await this.#postLinkRepository.updateLinks(linkIds, updateData, bulkUpdateOptions);
176
+ }
177
+
178
+ async bulkEdit(data, options) {
179
+ if (data.action === 'updateLink') {
180
+ return await this.#updateLinks(data, options);
181
+ }
182
+ throw new errors.IncorrectUsageError({
183
+ message: tpl(messages.unsupportedBulkAction)
184
+ });
185
+ }
186
+
187
+ /**
188
+ * @private (not using # to allow tests)
189
+ * Replace URL with a redirect that redirects to the original URL, and link that redirect with the given post
190
+ */
191
+ async addRedirectToUrl(url, post) {
192
+ // Generate a unique redirect slug
193
+ const slugUrl = await this.#linkRedirectService.getSlugUrl();
194
+
195
+ // Add redirect for link click tracking
196
+ const redirect = await this.#linkRedirectService.addRedirect(slugUrl, url);
197
+
198
+ // Store a reference of the link against the post
199
+ const postLink = new PostLink({
200
+ link_id: redirect.link_id,
201
+ post_id: ObjectID.createFromHexString(post.id)
202
+ });
203
+ await this.#postLinkRepository.save(postLink);
204
+
205
+ return redirect.from;
206
+ }
207
+
208
+ /**
209
+ * Add tracking to a URL and returns a new URL (if link click tracking is enabled)
210
+ * @param {URL} url
211
+ * @param {Post} post
212
+ * @param {string} memberUuid
213
+ * @return {Promise<URL>}
214
+ */
215
+ async addTrackingToUrl(url, post, memberUuid) {
216
+ url = await this.addRedirectToUrl(url, post);
217
+ url.searchParams.set('m', memberUuid);
218
+ return url;
219
+ }
220
+
221
+ subscribe() {
222
+ this.#DomainEvents.subscribe(RedirectEvent, async (event) => {
223
+ const uuid = event.data.url.searchParams.get('m');
224
+ if (!uuid) {
225
+ return;
226
+ }
227
+
228
+ const click = new LinkClick({
229
+ member_uuid: uuid,
230
+ link_id: event.data.link.link_id
231
+ });
232
+ await this.#linkClickRepository.save(click);
233
+ });
234
+ }
235
+ }
236
+
237
+ module.exports = LinkClickTrackingService;
@@ -0,0 +1,29 @@
1
+ const ObjectID = require('bson-objectid').default;
2
+
3
+ /**
4
+ * Stores the connection between a LinkRedirect and a Post
5
+ */
6
+ module.exports = class PostLink {
7
+ /** @type {ObjectID} */
8
+ post_id;
9
+ /** @type {ObjectID} */
10
+ link_id;
11
+
12
+ /**
13
+ * @param {object} data
14
+ * @param {string|ObjectID} data.post_id
15
+ * @param {string|ObjectID} data.link_id
16
+ */
17
+ constructor(data) {
18
+ if (typeof data.post_id === 'string') {
19
+ this.post_id = ObjectID.createFromHexString(data.post_id);
20
+ } else {
21
+ this.post_id = data.post_id;
22
+ }
23
+ if (typeof data.link_id === 'string') {
24
+ this.link_id = ObjectID.createFromHexString(data.link_id);
25
+ } else {
26
+ this.link_id = data.link_id;
27
+ }
28
+ }
29
+ };
@@ -1,9 +1,9 @@
1
- const {FullPostLink} = require('@tryghost/link-tracking');
1
+ const FullPostLink = require('./FullPostLink');
2
2
  const _ = require('lodash');
3
3
 
4
4
  /**
5
5
  * @typedef {import('bson-objectid').default} ObjectID
6
- * @typedef {import('@tryghost/link-tracking/lib/PostLink')} PostLink
6
+ * @typedef {import('./PostLink')} PostLink
7
7
  */
8
8
 
9
9
  module.exports = class PostLinkRepository {
@@ -20,7 +20,7 @@ class LinkTrackingServiceWrapper {
20
20
  const {MemberLinkClickEvent} = require('@tryghost/member-events');
21
21
  const DomainEvents = require('@tryghost/domain-events');
22
22
 
23
- const {LinkClickTrackingService} = require('@tryghost/link-tracking');
23
+ const LinkClickTrackingService = require('./LinkClickTrackingService');
24
24
 
25
25
  const postLinkRepository = new PostLinkRepository({
26
26
  LinkRedirect: models.Redirect,
@@ -0,0 +1,61 @@
1
+ const {MemberCreatedEvent, SubscriptionCreatedEvent} = require('@tryghost/member-events');
2
+
3
+ /**
4
+ * Store events in the database
5
+ */
6
+ class EventStorage {
7
+ /**
8
+ *
9
+ * @param {Object} deps
10
+ * @param {Object} deps.labsService
11
+ * @param {Object} deps.models
12
+ * @param {Object} deps.models.MemberCreatedEvent
13
+ * @param {Object} deps.models.SubscriptionCreatedEvent
14
+ */
15
+ constructor({labsService, models}) {
16
+ this.models = models;
17
+ this.labsService = labsService;
18
+ }
19
+
20
+ /**
21
+ * Subscribe to events of this domainEvents service
22
+ * @param {Object} domainEvents The DomainEvents service
23
+ */
24
+ subscribe(domainEvents) {
25
+ domainEvents.subscribe(MemberCreatedEvent, async (event) => {
26
+ let attribution = event.data.attribution;
27
+
28
+ await this.models.MemberCreatedEvent.add({
29
+ member_id: event.data.memberId,
30
+ created_at: event.timestamp,
31
+ attribution_id: attribution?.id ?? null,
32
+ attribution_url: attribution?.url ?? null,
33
+ attribution_type: attribution?.type ?? null,
34
+ source: event.data.source,
35
+ referrer_source: attribution?.referrerSource ?? null,
36
+ referrer_medium: attribution?.referrerMedium ?? null,
37
+ referrer_url: attribution?.referrerUrl ?? null,
38
+ batch_id: event.data.batchId ?? null
39
+ });
40
+ });
41
+
42
+ domainEvents.subscribe(SubscriptionCreatedEvent, async (event) => {
43
+ let attribution = event.data.attribution;
44
+
45
+ await this.models.SubscriptionCreatedEvent.add({
46
+ member_id: event.data.memberId,
47
+ subscription_id: event.data.subscriptionId,
48
+ created_at: event.timestamp,
49
+ attribution_id: attribution?.id ?? null,
50
+ attribution_url: attribution?.url ?? null,
51
+ attribution_type: attribution?.type ?? null,
52
+ referrer_source: attribution?.referrerSource ?? null,
53
+ referrer_medium: attribution?.referrerMedium ?? null,
54
+ referrer_url: attribution?.referrerUrl ?? null,
55
+ batch_id: event.data.batchId ?? null
56
+ });
57
+ });
58
+ }
59
+ }
60
+
61
+ module.exports = EventStorage;
@@ -0,0 +1,96 @@
1
+ const moment = require('moment-timezone');
2
+
3
+ /**
4
+ * A cache that stores the member ids that have been seen today. This cache is used to avoid having to query the database for the last_seen_at timestamp of a member multiple times in the same day.
5
+ *
6
+ * @constructor
7
+ * @param {Object} settingsCache - An instance of the settings cache
8
+ * @property {Set} _cache - A set that stores all the member ids that have been seen today
9
+ * @property {Object} _settingsCache - An instance of the settings cache
10
+ * @property {string} _startOfDay - The start of the current day in the site timezone, formatted in ISO 8601
11
+ */
12
+ class LastSeenAtCache {
13
+ /**
14
+ *
15
+ * @param {Object} deps - Dependencies
16
+ * @param {Object} deps.services - The list of service dependencies
17
+ * @param {Object} deps.services.settingsCache - The settings service
18
+ */
19
+ constructor({services: {settingsCache}}) {
20
+ this._cache = new Set();
21
+ this._settingsCache = settingsCache;
22
+ this._startOfDay = this._getStartOfCurrentDay();
23
+ }
24
+
25
+ /**
26
+ * @method add - Adds a member id to the cache
27
+ * @param {string} memberId
28
+ */
29
+ add(memberId) {
30
+ this._cache.add(memberId);
31
+ }
32
+
33
+ /**
34
+ * @method remove - Removes a member id from the cache
35
+ * @param {string} memberId
36
+ */
37
+ remove(memberId) {
38
+ this._cache.delete(memberId);
39
+ }
40
+
41
+ /**
42
+ * @method shouldUpdateMember - Checks if a member should be updated
43
+ * @param {string} memberId
44
+ * @returns {boolean} - Returns true if the member should be updated
45
+ */
46
+ shouldUpdateMember(memberId) {
47
+ return !this._has(memberId);
48
+ }
49
+
50
+ /**
51
+ * @method clear - Clears the cache
52
+ */
53
+ clear() {
54
+ this._cache.clear();
55
+ }
56
+
57
+ /**
58
+ * @method _has - Refreshes the cache and checks if a member id is in the cache
59
+ * @param {string} memberId
60
+ * @returns {boolean}
61
+ */
62
+ _has(memberId) {
63
+ this._refresh();
64
+ return this._cache.has(memberId);
65
+ }
66
+
67
+ /**
68
+ * @method _shouldClear - Checks if the cache should be cleared, based on the current day
69
+ * @returns {boolean} - Returns true if the cache should be cleared
70
+ */
71
+ _shouldClear() {
72
+ return this._startOfDay !== this._getStartOfCurrentDay();
73
+ }
74
+
75
+ /**
76
+ * @method _refresh - Clears the cache if the day has changed
77
+ */
78
+ _refresh() {
79
+ if (this._shouldClear()) {
80
+ this.clear();
81
+ this._startOfDay = this._getStartOfCurrentDay();
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Returns the start of the current day in the site timezone
87
+ * @returns {string} The start of the current day in the site timezone, formatted as a ISO 8601 string
88
+ */
89
+ _getStartOfCurrentDay() {
90
+ const timezone = this._settingsCache.get('timezone') || 'Etc/UTC';
91
+ const startOfDay = moment().tz(timezone).startOf('day').utc().toISOString();
92
+ return startOfDay;
93
+ }
94
+ }
95
+
96
+ module.exports = LastSeenAtCache;