ghost 5.115.1 → 5.116.1

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 (243) hide show
  1. package/components/{tryghost-api-framework-5.115.1.tgz → tryghost-api-framework-5.116.1.tgz} +0 -0
  2. package/components/tryghost-constants-5.116.1.tgz +0 -0
  3. package/components/tryghost-custom-fonts-5.116.1.tgz +0 -0
  4. package/components/{tryghost-custom-theme-settings-service-5.115.1.tgz → tryghost-custom-theme-settings-service-5.116.1.tgz} +0 -0
  5. package/components/{tryghost-domain-events-5.115.1.tgz → tryghost-domain-events-5.116.1.tgz} +0 -0
  6. package/components/{tryghost-donations-5.115.1.tgz → tryghost-donations-5.116.1.tgz} +0 -0
  7. package/components/tryghost-email-addresses-5.116.1.tgz +0 -0
  8. package/components/tryghost-email-service-5.116.1.tgz +0 -0
  9. package/components/tryghost-email-suppression-list-5.116.1.tgz +0 -0
  10. package/components/tryghost-html-to-plaintext-5.116.1.tgz +0 -0
  11. package/components/tryghost-i18n-5.116.1.tgz +0 -0
  12. package/components/tryghost-job-manager-5.116.1.tgz +0 -0
  13. package/components/tryghost-link-replacer-5.116.1.tgz +0 -0
  14. package/components/tryghost-magic-link-5.116.1.tgz +0 -0
  15. package/components/{tryghost-member-attribution-5.115.1.tgz → tryghost-member-attribution-5.116.1.tgz} +0 -0
  16. package/components/tryghost-member-events-5.116.1.tgz +0 -0
  17. package/components/tryghost-members-api-5.116.1.tgz +0 -0
  18. package/components/tryghost-members-csv-5.116.1.tgz +0 -0
  19. package/components/{tryghost-members-offers-5.115.1.tgz → tryghost-members-offers-5.116.1.tgz} +0 -0
  20. package/components/{tryghost-milestones-5.115.1.tgz → tryghost-milestones-5.116.1.tgz} +0 -0
  21. package/components/{tryghost-mw-error-handler-5.115.1.tgz → tryghost-mw-error-handler-5.116.1.tgz} +0 -0
  22. package/components/tryghost-mw-vhost-5.116.1.tgz +0 -0
  23. package/components/{tryghost-post-events-5.115.1.tgz → tryghost-post-events-5.116.1.tgz} +0 -0
  24. package/components/{tryghost-post-revisions-5.115.1.tgz → tryghost-post-revisions-5.116.1.tgz} +0 -0
  25. package/components/tryghost-posts-service-5.116.1.tgz +0 -0
  26. package/components/{tryghost-prometheus-metrics-5.115.1.tgz → tryghost-prometheus-metrics-5.116.1.tgz} +0 -0
  27. package/components/tryghost-security-5.116.1.tgz +0 -0
  28. package/components/{tryghost-tiers-5.115.1.tgz → tryghost-tiers-5.116.1.tgz} +0 -0
  29. package/components/tryghost-webmentions-5.116.1.tgz +0 -0
  30. package/content/themes/casper/LICENSE +1 -1
  31. package/content/themes/casper/README.md +1 -1
  32. package/content/themes/source/LICENSE +1 -1
  33. package/content/themes/source/README.md +1 -1
  34. package/content/themes/source/assets/built/screen.css +1 -1
  35. package/content/themes/source/assets/built/screen.css.map +1 -1
  36. package/content/themes/source/assets/css/screen.css +6 -11
  37. package/content/themes/source/partials/feature-image.hbs +2 -2
  38. package/core/boot.js +0 -42
  39. package/core/built/admin/assets/admin-x-activitypub/admin-x-activitypub.js +24764 -24129
  40. package/core/built/admin/assets/admin-x-demo/admin-x-demo.js +1 -1
  41. package/core/built/admin/assets/admin-x-demo/{index-15df2af5.mjs → index-a9601514.mjs} +3 -3
  42. package/core/built/admin/assets/admin-x-demo/{modals-8ca61d78.mjs → modals-c1789d04.mjs} +2 -2
  43. package/core/built/admin/assets/admin-x-settings/{CodeEditorView-d2e6872f.mjs → CodeEditorView-e9c9deb8.mjs} +2 -2
  44. package/core/built/admin/assets/admin-x-settings/admin-x-settings.js +2 -2
  45. package/core/built/admin/assets/admin-x-settings/{index-8e8821e5.mjs → index-84580c3a.mjs} +2 -2
  46. package/core/built/admin/assets/admin-x-settings/{index-f5cb3db3.mjs → index-f744cab7.mjs} +49 -35
  47. package/core/built/admin/assets/admin-x-settings/{modals-e8ae4d46.mjs → modals-d9ca60c5.mjs} +1198 -1192
  48. package/core/built/admin/assets/chunk.524.cb72a86e19c9ffd6172e.js +35 -0
  49. package/core/built/admin/assets/chunk.582.4f4d38ffe79fbdbd26f7.js +37 -0
  50. package/core/built/admin/assets/{chunk.874.461cb3cf5b6b36915f8c.js → chunk.713.e9027c0cc3c56110f5da.js} +125 -98
  51. package/core/built/admin/assets/{ghost-df7b9558260aa27d18b195ee895b487d.js → ghost-03b64c086f3c60cabc85fe7a7e2b640a.js} +144 -145
  52. package/core/built/admin/assets/ghost-ba58e9822f7384461e926c7e23f04a75.css +1 -0
  53. package/core/built/admin/assets/ghost-dark-f1f29683b14ffa11615b3bba8b6ab92c.css +1 -0
  54. package/core/built/admin/assets/koenig-lexical/index.css +1 -1
  55. package/core/built/admin/assets/koenig-lexical/koenig-lexical.js +20563 -20891
  56. package/core/built/admin/assets/koenig-lexical/koenig-lexical.umd.js +139 -139
  57. package/core/built/admin/assets/posts/posts.js +5732 -5667
  58. package/core/built/admin/assets/stats/stats.js +71082 -7533
  59. package/core/built/admin/assets/{vendor-68a4aa424a179a90f5bbc2b750def576.js → vendor-72026232b36d97babc6320917c16c321.js} +36 -34
  60. package/core/built/admin/index.html +6 -6
  61. package/core/cli/generate-data.js +1 -1
  62. package/core/frontend/helpers/ghost_head.js +6 -1
  63. package/core/frontend/public/ghost-stats.js +55 -2
  64. package/core/frontend/services/assets-minification/AdminAuthAssets.js +2 -1
  65. package/core/frontend/services/assets-minification/CardAssets.js +1 -1
  66. package/core/frontend/services/assets-minification/CommentCountsAssets.js +1 -1
  67. package/core/frontend/services/assets-minification/MemberAttributionAssets.js +1 -1
  68. package/core/frontend/services/assets-minification/Minifier.js +191 -0
  69. package/core/frontend/services/routing/controllers/previews.js +2 -1
  70. package/core/server/adapters/cache/Redis.js +1 -1
  71. package/core/server/adapters/lib/redis/AdapterCacheRedis.js +287 -0
  72. package/core/server/adapters/lib/redis/redis-store-factory.js +22 -0
  73. package/core/server/api/endpoints/posts.js +9 -3
  74. package/core/server/api/endpoints/previews.js +35 -1
  75. package/core/server/api/endpoints/utils/serializers/output/utils/post-gating.js +6 -9
  76. package/core/server/api/endpoints/utils/validators/input/settings.js +1 -1
  77. package/core/server/data/db/connection.js +2 -0
  78. package/core/server/data/db/index.js +1 -0
  79. package/core/server/data/importer/handlers/ImporterContentFileHandler.js +90 -0
  80. package/core/server/data/importer/import-manager.js +1 -1
  81. package/core/server/data/seeders/DataGenerator.js +288 -0
  82. package/core/server/data/seeders/importers/BenefitsImporter.js +28 -0
  83. package/core/server/data/seeders/importers/CommentsImporter.js +73 -0
  84. package/core/server/data/seeders/importers/EmailBatchesImporter.js +38 -0
  85. package/core/server/data/seeders/importers/EmailRecipientFailuresImporter.js +67 -0
  86. package/core/server/data/seeders/importers/EmailRecipientsImporter.js +212 -0
  87. package/core/server/data/seeders/importers/EmailsImporter.js +99 -0
  88. package/core/server/data/seeders/importers/LabelsImporter.js +41 -0
  89. package/core/server/data/seeders/importers/MembersClickEventsImporter.js +69 -0
  90. package/core/server/data/seeders/importers/MembersCreatedEventsImporter.js +103 -0
  91. package/core/server/data/seeders/importers/MembersFeedbackImporter.js +45 -0
  92. package/core/server/data/seeders/importers/MembersImporter.js +111 -0
  93. package/core/server/data/seeders/importers/MembersLabelsImporter.js +39 -0
  94. package/core/server/data/seeders/importers/MembersLoginEventsImporter.js +69 -0
  95. package/core/server/data/seeders/importers/MembersNewslettersImporter.js +38 -0
  96. package/core/server/data/seeders/importers/MembersPaidSubscriptionEventsImporter.js +99 -0
  97. package/core/server/data/seeders/importers/MembersProductsImporter.js +42 -0
  98. package/core/server/data/seeders/importers/MembersStatusEventsImporter.js +58 -0
  99. package/core/server/data/seeders/importers/MembersStripeCustomersImporter.js +60 -0
  100. package/core/server/data/seeders/importers/MembersStripeCustomersSubscriptionsImporter.js +259 -0
  101. package/core/server/data/seeders/importers/MembersSubscribeEventsImporter.js +69 -0
  102. package/core/server/data/seeders/importers/MembersSubscriptionCreatedEventsImporter.js +95 -0
  103. package/core/server/data/seeders/importers/NewslettersImporter.js +40 -0
  104. package/core/server/data/seeders/importers/OffersImporter.js +70 -0
  105. package/core/server/data/seeders/importers/PostsAuthorsImporter.js +32 -0
  106. package/core/server/data/seeders/importers/PostsImporter.js +102 -0
  107. package/core/server/data/seeders/importers/PostsProductsImporter.js +35 -0
  108. package/core/server/data/seeders/importers/PostsTagsImporter.js +46 -0
  109. package/core/server/data/seeders/importers/ProductsBenefitsImporter.js +54 -0
  110. package/core/server/data/seeders/importers/ProductsImporter.js +90 -0
  111. package/core/server/data/seeders/importers/RecommendationClickEventsImporter.js +32 -0
  112. package/core/server/data/seeders/importers/RecommendationSubscribeEventsImporter.js +32 -0
  113. package/core/server/data/seeders/importers/RecommendationsImporter.js +34 -0
  114. package/core/server/data/seeders/importers/RedirectsImporter.js +49 -0
  115. package/core/server/data/seeders/importers/RolesUsersImporter.js +42 -0
  116. package/core/server/data/seeders/importers/StripePricesImporter.js +69 -0
  117. package/core/server/data/seeders/importers/StripeProductsImporter.js +34 -0
  118. package/core/server/data/seeders/importers/TableImporter.js +187 -0
  119. package/core/server/data/seeders/importers/TagsImporter.js +41 -0
  120. package/core/server/data/seeders/importers/UsersImporter.js +31 -0
  121. package/core/server/data/seeders/importers/WebMentionsImporter.js +42 -0
  122. package/core/server/data/seeders/importers/index.js +41 -0
  123. package/core/server/data/seeders/utils/JsonImporter.js +39 -0
  124. package/core/server/data/seeders/utils/blog-info.js +3 -0
  125. package/core/server/data/seeders/utils/database-date.js +7 -0
  126. package/core/server/data/seeders/utils/event-generator.js +48 -0
  127. package/core/server/data/seeders/utils/random.js +13 -0
  128. package/core/server/data/seeders/utils/topological-sort.js +33 -0
  129. package/core/server/services/adapter-manager/AdapterManager.js +161 -0
  130. package/core/server/services/adapter-manager/index.js +1 -1
  131. package/core/server/services/announcement-bar-service/AnnouncementBarSettings.js +54 -0
  132. package/core/server/services/announcement-bar-service/AnnouncementVisibilityValues.js +11 -0
  133. package/core/server/services/announcement-bar-service/index.js +1 -1
  134. package/core/server/services/api-version-compatibility/APIVersionCompatibilityService.js +1 -1
  135. package/core/server/services/auth/session/session-service.js +15 -5
  136. package/core/server/services/custom-redirects/index.js +1 -1
  137. package/core/server/services/email-analytics/EmailAnalyticsProviderMailgun.js +1 -1
  138. package/core/server/services/email-service/EmailServiceWrapper.js +4 -4
  139. package/core/server/services/email-suppression-list/MailgunEmailSuppressionList.js +1 -1
  140. package/core/server/services/email-suppression-list/service.js +1 -1
  141. package/core/server/services/lib/DynamicRedirectManager.js +156 -0
  142. package/core/server/services/lib/EmailContentGenerator.js +54 -0
  143. package/core/server/services/lib/InMemoryRepository.js +62 -0
  144. package/core/server/services/lib/InMemoryRepository.ts +80 -0
  145. package/core/server/services/lib/MailgunClient.js +364 -0
  146. package/core/server/services/link-redirection/LinkRedirect.js +26 -0
  147. package/core/server/services/link-redirection/LinkRedirectRepository.js +7 -7
  148. package/core/server/services/link-redirection/LinkRedirectsService.js +123 -0
  149. package/core/server/services/link-redirection/README.md +151 -0
  150. package/core/server/services/link-redirection/RedirectEvent.js +24 -0
  151. package/core/server/services/link-redirection/index.js +1 -1
  152. package/core/server/services/link-tracking/LinkClickTrackingService.js +1 -1
  153. package/core/server/services/mail/index.js +1 -1
  154. package/core/server/services/mail-events/InMemoryMailEventRepository.js +2 -2
  155. package/core/server/services/mail-events/InMemoryMailEventRepository.ts +1 -1
  156. package/core/server/services/members-events/LastSeenAtUpdater.js +1 -1
  157. package/core/server/services/offers/service.js +1 -1
  158. package/core/server/services/recommendations/RecommendationServiceWrapper.js +8 -8
  159. package/core/server/services/recommendations/service/BookshelfClickEventRepository.js +48 -0
  160. package/core/server/services/recommendations/service/BookshelfClickEventRepository.ts +49 -0
  161. package/core/server/services/recommendations/service/BookshelfRecommendationRepository.js +98 -0
  162. package/core/server/services/recommendations/service/BookshelfRecommendationRepository.ts +117 -0
  163. package/core/server/services/recommendations/service/BookshelfRepository.js +134 -0
  164. package/core/server/services/recommendations/service/BookshelfRepository.ts +196 -0
  165. package/core/server/services/recommendations/service/BookshelfSubscribeEventRepository.js +48 -0
  166. package/core/server/services/recommendations/service/BookshelfSubscribeEventRepository.ts +49 -0
  167. package/core/server/services/recommendations/service/ClickEvent.js +33 -0
  168. package/core/server/services/recommendations/service/ClickEvent.ts +32 -0
  169. package/core/server/services/recommendations/service/InMemoryRecommendationRepository.js +19 -0
  170. package/core/server/services/recommendations/service/InMemoryRecommendationRepository.ts +20 -0
  171. package/core/server/services/recommendations/service/IncomingRecommendationController.js +34 -0
  172. package/core/server/services/recommendations/service/IncomingRecommendationController.ts +51 -0
  173. package/core/server/services/recommendations/service/IncomingRecommendationEmailRenderer.js +25 -0
  174. package/core/server/services/recommendations/service/IncomingRecommendationEmailRenderer.ts +37 -0
  175. package/core/server/services/recommendations/service/IncomingRecommendationService.js +93 -0
  176. package/core/server/services/recommendations/service/IncomingRecommendationService.ts +160 -0
  177. package/core/server/services/recommendations/service/Recommendation.js +140 -0
  178. package/core/server/services/recommendations/service/Recommendation.ts +201 -0
  179. package/core/server/services/recommendations/service/RecommendationController.js +208 -0
  180. package/core/server/services/recommendations/service/RecommendationController.ts +258 -0
  181. package/core/server/services/recommendations/service/RecommendationMetadataService.js +86 -0
  182. package/core/server/services/recommendations/service/RecommendationMetadataService.ts +128 -0
  183. package/core/server/services/recommendations/service/RecommendationRepository.js +2 -0
  184. package/core/server/services/recommendations/service/RecommendationRepository.ts +13 -0
  185. package/core/server/services/recommendations/service/RecommendationService.js +228 -0
  186. package/core/server/services/recommendations/service/RecommendationService.ts +281 -0
  187. package/core/server/services/recommendations/service/SubscribeEvent.js +33 -0
  188. package/core/server/services/recommendations/service/SubscribeEvent.ts +32 -0
  189. package/core/server/services/recommendations/service/UnsafeData.js +183 -0
  190. package/core/server/services/recommendations/service/UnsafeData.ts +217 -0
  191. package/core/server/services/recommendations/service/WellknownService.js +36 -0
  192. package/core/server/services/recommendations/service/WellknownService.ts +47 -0
  193. package/core/server/services/recommendations/service/index.js +31 -0
  194. package/core/server/services/recommendations/service/index.ts +15 -0
  195. package/core/server/services/recommendations/service/libraries.d.ts +5 -0
  196. package/core/server/services/slack-notifications/SlackNotifications.js +211 -0
  197. package/core/server/services/slack-notifications/SlackNotificationsService.js +90 -0
  198. package/core/server/services/slack-notifications/service.js +4 -6
  199. package/core/server/web/api/endpoints/admin/app.js +1 -21
  200. package/core/server/web/api/middleware/version-match.js +41 -0
  201. package/core/shared/labs.js +2 -2
  202. package/package.json +87 -104
  203. package/tsconfig.tsbuildinfo +1 -1
  204. package/yarn.lock +1470 -1540
  205. package/components/tryghost-adapter-cache-redis-5.115.1.tgz +0 -0
  206. package/components/tryghost-adapter-manager-5.115.1.tgz +0 -0
  207. package/components/tryghost-announcement-bar-settings-5.115.1.tgz +0 -0
  208. package/components/tryghost-constants-5.115.1.tgz +0 -0
  209. package/components/tryghost-custom-fonts-5.115.1.tgz +0 -0
  210. package/components/tryghost-data-generator-5.115.1.tgz +0 -0
  211. package/components/tryghost-email-addresses-5.115.1.tgz +0 -0
  212. package/components/tryghost-email-content-generator-5.115.1.tgz +0 -0
  213. package/components/tryghost-email-events-5.115.1.tgz +0 -0
  214. package/components/tryghost-email-service-5.115.1.tgz +0 -0
  215. package/components/tryghost-email-suppression-list-5.115.1.tgz +0 -0
  216. package/components/tryghost-express-dynamic-redirects-5.115.1.tgz +0 -0
  217. package/components/tryghost-ghost-5.115.1.tgz +0 -0
  218. package/components/tryghost-html-to-plaintext-5.115.1.tgz +0 -0
  219. package/components/tryghost-i18n-5.115.1.tgz +0 -0
  220. package/components/tryghost-importer-handler-content-files-5.115.1.tgz +0 -0
  221. package/components/tryghost-in-memory-repository-5.115.1.tgz +0 -0
  222. package/components/tryghost-job-manager-5.115.1.tgz +0 -0
  223. package/components/tryghost-link-redirects-5.115.1.tgz +0 -0
  224. package/components/tryghost-link-replacer-5.115.1.tgz +0 -0
  225. package/components/tryghost-magic-link-5.115.1.tgz +0 -0
  226. package/components/tryghost-mailgun-client-5.115.1.tgz +0 -0
  227. package/components/tryghost-member-events-5.115.1.tgz +0 -0
  228. package/components/tryghost-members-api-5.115.1.tgz +0 -0
  229. package/components/tryghost-members-csv-5.115.1.tgz +0 -0
  230. package/components/tryghost-members-payments-5.115.1.tgz +0 -0
  231. package/components/tryghost-minifier-5.115.1.tgz +0 -0
  232. package/components/tryghost-mw-version-match-5.115.1.tgz +0 -0
  233. package/components/tryghost-mw-vhost-5.115.1.tgz +0 -0
  234. package/components/tryghost-posts-service-5.115.1.tgz +0 -0
  235. package/components/tryghost-recommendations-5.115.1.tgz +0 -0
  236. package/components/tryghost-security-5.115.1.tgz +0 -0
  237. package/components/tryghost-slack-notifications-5.115.1.tgz +0 -0
  238. package/components/tryghost-webmentions-5.115.1.tgz +0 -0
  239. package/core/built/admin/assets/chunk.524.2439684964c164c598ab.js +0 -35
  240. package/core/built/admin/assets/chunk.582.bf5a2bbb2c4eb69ef1e7.js +0 -37
  241. package/core/built/admin/assets/ghost-327b17ea23cb8c89bd7e6a51e18e8506.css +0 -1
  242. package/core/built/admin/assets/ghost-dark-f30a597ac19632a118939492591c531b.css +0 -1
  243. /package/core/built/admin/assets/{chunk.874.461cb3cf5b6b36915f8c.js.LICENSE.txt → chunk.713.e9027c0cc3c56110f5da.js.LICENSE.txt} +0 -0
@@ -0,0 +1,123 @@
1
+ const crypto = require('crypto');
2
+ const DomainEvents = require('@tryghost/domain-events');
3
+ const RedirectEvent = require('./RedirectEvent');
4
+ const LinkRedirect = require('./LinkRedirect');
5
+
6
+ /**
7
+ * @typedef {object} ILinkRedirectRepository
8
+ * @prop {(url: URL) => Promise<LinkRedirect|undefined>} getByURL
9
+ * @prop {({filter: string}) => Promise<LinkRedirect[]>} getAll
10
+ * @prop {({filter: string}) => Promise<String[]>} getFilteredIds
11
+ * @prop {(linkRedirect: LinkRedirect) => Promise<void>} save
12
+ */
13
+
14
+ class LinkRedirectsService {
15
+ /** @type ILinkRedirectRepository */
16
+ #linkRedirectRepository;
17
+ /** @type URL */
18
+ #baseURL;
19
+
20
+ /** @type String */
21
+ #redirectURLPrefix = 'r/';
22
+
23
+ /**
24
+ * @param {object} deps
25
+ * @param {ILinkRedirectRepository} deps.linkRedirectRepository
26
+ * @param {object} deps.config
27
+ * @param {URL} deps.config.baseURL
28
+ */
29
+ constructor(deps) {
30
+ this.#linkRedirectRepository = deps.linkRedirectRepository;
31
+ if (!deps.config.baseURL.pathname.endsWith('/')) {
32
+ this.#baseURL = new URL(deps.config.baseURL);
33
+ this.#baseURL.pathname += '/';
34
+ } else {
35
+ this.#baseURL = deps.config.baseURL;
36
+ }
37
+ this.handleRequest = this.handleRequest.bind(this);
38
+ }
39
+
40
+ /**
41
+ * Get a unique URL with slug for creating unique redirects
42
+ *
43
+ * @returns {Promise<URL>}
44
+ */
45
+ async getSlugUrl() {
46
+ let url;
47
+ while (!url || await this.#linkRedirectRepository.getByURL(url)) {
48
+ const slug = crypto.randomBytes(4).toString('hex');
49
+ url = new URL(`${this.#redirectURLPrefix}${slug}`, this.#baseURL);
50
+ }
51
+ return url;
52
+ }
53
+
54
+ /**
55
+ * @param {Object} options
56
+ *
57
+ * @returns {Promise<String[]>}
58
+ */
59
+ async getFilteredIds(options) {
60
+ return await this.#linkRedirectRepository.getFilteredIds(options);
61
+ }
62
+
63
+ /**
64
+ * @param {URL} from
65
+ * @param {URL} to
66
+ *
67
+ * @returns {Promise<LinkRedirect>}
68
+ */
69
+ async addRedirect(from, to) {
70
+ const link = new LinkRedirect({
71
+ from,
72
+ to
73
+ });
74
+
75
+ await this.#linkRedirectRepository.save(link);
76
+
77
+ return link;
78
+ }
79
+
80
+ /**
81
+ * @param {import('express').Request} req
82
+ * @param {import('express').Response} res
83
+ * @param {import('express').NextFunction} next
84
+ *
85
+ * @returns {Promise<void>}
86
+ */
87
+ async handleRequest(req, res, next) {
88
+ try {
89
+ // skip handling if original url doesn't match the prefix
90
+ const fullURLWithRedirectPrefix = `${this.#baseURL.pathname}${this.#redirectURLPrefix}`;
91
+ // @NOTE: below is equivalent to doing:
92
+ // router.get('/r/'), (req, res) ...
93
+ // To make it cleaner we should rework it to:
94
+ // linkRedirects.service.handleRequest(router);
95
+ // and mount routes on top like for example sitemapHandler does
96
+ // Cleanup issue: https://github.com/TryGhost/Toolbox/issues/516
97
+ if (!req.originalUrl.startsWith(fullURLWithRedirectPrefix)) {
98
+ return next();
99
+ }
100
+
101
+ const url = new URL(req.originalUrl, this.#baseURL);
102
+ const link = await this.#linkRedirectRepository.getByURL(url);
103
+
104
+ if (!link) {
105
+ return next();
106
+ }
107
+
108
+ const event = RedirectEvent.create({
109
+ url,
110
+ link
111
+ });
112
+
113
+ DomainEvents.dispatch(event);
114
+
115
+ res.setHeader('X-Robots-Tag', 'noindex, nofollow');
116
+ return res.redirect(link.to.href);
117
+ } catch (e) {
118
+ return next(e);
119
+ }
120
+ }
121
+ }
122
+
123
+ module.exports = LinkRedirectsService;
@@ -0,0 +1,151 @@
1
+ # Link Redirects
2
+
3
+
4
+ ## Usage
5
+
6
+
7
+ ## Develop
8
+
9
+ This is a monorepo package.
10
+
11
+ Follow the instructions for the top-level repo.
12
+ 1. `git clone` this repo & `cd` into it as usual
13
+ 2. Run `yarn` to install top-level dependencies.
14
+
15
+
16
+
17
+ ## Test
18
+
19
+ - `yarn lint` run just eslint
20
+ - `yarn test` run lint and tests
21
+
22
+ ## Overview of how Ghost handles LinkRedirects
23
+ ### Summary
24
+ When a publisher sends an email newsletter with email click analytics enabled, Ghost will replace all the links in the email's content with a link of the form `https://{site_url}/r/{redirect hash}?m={member UUID}`. When a member clicks on a link in their email, Ghost receives the request, redirects the user to the original link's URL, then updates some analytics data in the database.
25
+
26
+ ### The details
27
+ The following deep-dive covers the link redirect flow from when the member clicks on a link in an email.
28
+
29
+ First, we lookup the redirect by the `/r/{hash}` value in the URL:
30
+ ```
31
+ select `redirects`.* from `redirects` where `redirects`.`from` = ? limit ? undefined
32
+ ```
33
+
34
+ If the redirect exists, the `LinkRedirectsService` emits a `RedirectEvent`, and then responds to the HTTP request with a 302.
35
+
36
+ The `LinkClickTrackingService` subscribes to the `RedirectEvent` and kicks off the analytics inserts/updates. First we grab the `uuid` from the `?m={uuid}` parameter and lookup the member by `uuid`:
37
+ ```
38
+ select `members`.* from `members` where `members`.`uuid` = ? limit ? undefined
39
+ ```
40
+
41
+ Then we insert a row into the `members_click_events` table to record the click:
42
+ ```
43
+ insert into `members_click_events` (`created_at`, `id`, `member_id`, `redirect_id`) values (?, ?, ?, ?) undefined
44
+ ```
45
+
46
+ Then we query for the row we just inserted:
47
+ ```
48
+ select `members_click_events`.* from `members_click_events` where `members_click_events`.`id` = ? limit ? undefined
49
+ ```
50
+
51
+ At this point, we emit a `MemberLinkClickEvent` with the member ID and `lastSeenAt` timestamp.
52
+
53
+ The `LastSeenAtUpdater` subscribes to the `MemberLinkClickEvent`. First, it checks if the `lastSeenAt` value has already been updated in the current day.
54
+
55
+ If it has, we stop here.
56
+
57
+ If it hasn't, we continue to update the member. First, we select the member by ID:
58
+ ```
59
+ select `members`.* from `members` where `members`.`id` = ? limit ? undefined
60
+ ```
61
+
62
+ Then we start a transaction and get a lock on the member for updating:
63
+ ```
64
+ BEGIN; trx34
65
+ select `members`.* from `members` where `members`.`id` = ? limit ? for update trx34
66
+ ```
67
+
68
+ Since we're editing the member, we will eventually need to emit a `member.edited` webhook with the standard includes (labels and newsletters) so we also query them here:
69
+ ```
70
+ select `labels`.*, `members_labels`.`member_id` as `_pivot_member_id`, `members_labels`.`label_id` as `_pivot_label_id`, `members_labels`.`sort_order` as `_pivot_sort_order` from `labels` inner join `members_labels` on `members_labels`.`label_id` = `labels`.`id` where `members_labels`.`member_id` in (?) order by `sort_order` ASC for update trx34
71
+ ```
72
+
73
+ Then we query the member's newsletters:
74
+ ```
75
+ select `newsletters`.*, `members_newsletters`.`member_id` as `_pivot_member_id`, `members_newsletters`.`newsletter_id` as `_pivot_newsletter_id` from `newsletters` inner join `members_newsletters` on `members_newsletters`.`newsletter_id` = `newsletters`.`id` where `members_newsletters`.`member_id` in (?) order by `newsletters`.`sort_order` ASC for update trx34
76
+ ```
77
+
78
+ Then we update the member:
79
+ ```
80
+ update `members` set `uuid` = ?, `transient_id` = ?, `email` = ?, `status` = ?, `name` = ?, `expertise` = ?, `note` = ?, `geolocation` = ?, `enable_comment_notifications` = ?, `email_count` = ?, `email_opened_count` = ?, `email_open_rate` = ?, `email_disabled` = ?, `last_seen_at` = ?, `last_commented_at` = ?, `created_at` = ?, `created_by` = ?, `updated_at` = ?, `updated_by` = ? where `id` = ? trx34
81
+ ```
82
+
83
+ Then we select the member by ID again to get the freshly updated values from the DB:
84
+ ```
85
+ select `members`.* from `members` where `members`.`id` = ? limit ? trx34
86
+ ```
87
+
88
+ Then we commit the transaction:
89
+ ```
90
+ COMMIT; trx34
91
+ ```
92
+
93
+ Finally, we query for any member.edited webhooks and fire the `member.edited` event:
94
+ ```
95
+ select `webhooks`.* from `webhooks` where `event` = ? trx34
96
+ ```
97
+
98
+
99
+ ### Sequence Diagram
100
+ ```mermaid
101
+ sequenceDiagram
102
+ actor Member
103
+ participant Ghost
104
+ participant Ghost Async
105
+ participant DB
106
+ rect rgba(0,100,0, 0.1)
107
+ Member ->>Ghost: Clicks link in email
108
+ Ghost ->> DB: Query: lookup redirect
109
+ DB ->> Ghost: Redirect record
110
+ Note right of DB: Serve the redirect
111
+ Ghost -->> Ghost Async: Emit RedirectEvent
112
+ Ghost ->> Member: 302 Redirect
113
+ end
114
+ rect rgba(100,0,0,0.1)
115
+ Ghost Async ->> DB: Lookup Member by `uuid` from URL param
116
+ DB ->> Ghost Async: `member` record
117
+ Ghost Async ->> DB: Insert `member_click_event`
118
+ Note right of DB: Insert click event
119
+ DB ->> Ghost Async: 👌
120
+ Ghost Async ->> DB: Select `member_click_event`
121
+ DB ->> Ghost Async: `member_click_event` record
122
+ end
123
+ rect rgba(0,0,100, 0.1)
124
+ Ghost Async ->> DB: Select `member` by id
125
+ DB ->> Ghost Async: `member` record
126
+ Ghost Async ->> DB: Begin transaction
127
+ activate DB
128
+ DB ->> Ghost Async: 👌
129
+ Ghost Async ->> DB: Select `member` for update
130
+ DB ->> Ghost Async: `member` record
131
+ Ghost Async ->> DB: Select member labels for update
132
+ Note right of DB: Update member `lastSeenAt`
133
+ DB ->> Ghost Async: Member's labels
134
+ Ghost Async ->> DB: Select member newsletters for update
135
+ DB ->> Ghost Async: Member's newsletters
136
+ Ghost Async ->> DB: Update member's `lastSeenAt` timestamp
137
+ DB ->> Ghost Async: 👌
138
+ Ghost Async ->> DB: Select `member` by ID
139
+ DB ->> Ghost Async: `member` record
140
+ Ghost Async ->> DB: Commit transaction
141
+ DB ->> Ghost Async: 👌
142
+ deactivate DB
143
+ end
144
+ rect rgba(100,100,0,0.1)
145
+ Ghost Async ->> DB: Select `webhooks`
146
+ Note right of DB: Send `member.edited` webhook
147
+ DB ->> Ghost Async: `webhook` records
148
+ create participant Webhook Recipient
149
+ Ghost Async ->> Webhook Recipient: `member.edited` webhook
150
+ end
151
+ ```
@@ -0,0 +1,24 @@
1
+ /**
2
+ * @typedef {object} RedirectEventData
3
+ * @prop {URL} url
4
+ * @prop {import('./LinkRedirect')} link
5
+ */
6
+
7
+ module.exports = class RedirectEvent {
8
+ /**
9
+ * @param {RedirectEventData} data
10
+ * @param {Date} timestamp
11
+ */
12
+ constructor(data, timestamp) {
13
+ this.data = data;
14
+ this.timestamp = timestamp;
15
+ }
16
+
17
+ /**
18
+ * @param {RedirectEventData} data
19
+ * @param {Date} [timestamp]
20
+ */
21
+ static create(data, timestamp) {
22
+ return new RedirectEvent(data, timestamp ?? new Date);
23
+ }
24
+ };
@@ -14,7 +14,7 @@ class LinkRedirectsServiceWrapper {
14
14
  // Wire up all the dependencies
15
15
  const models = require('../../models');
16
16
 
17
- const {LinkRedirectsService} = require('@tryghost/link-redirects');
17
+ const LinkRedirectsService = require('./LinkRedirectsService');
18
18
 
19
19
  this.linkRedirectRepository = new LinkRedirectRepository({
20
20
  LinkRedirect: models.Redirect,
@@ -1,4 +1,4 @@
1
- const {RedirectEvent} = require('@tryghost/link-redirects');
1
+ const RedirectEvent = require('../link-redirection/RedirectEvent');
2
2
  const LinkClick = require('./ClickEvent');
3
3
  const PostLink = require('./PostLink');
4
4
  const ObjectID = require('bson-objectid').default;
@@ -1,7 +1,7 @@
1
1
  const path = require('path');
2
2
  const urlUtils = require('../../../shared/url-utils');
3
3
  const settingsCache = require('../../../shared/settings-cache');
4
- const EmailContentGenerator = require('@tryghost/email-content-generator');
4
+ const EmailContentGenerator = require('../lib/EmailContentGenerator');
5
5
 
6
6
  const emailContentGenerator = new EmailContentGenerator({
7
7
  getSiteUrl: () => urlUtils.urlFor('home', true),
@@ -1,8 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.InMemoryMailEventRepository = void 0;
4
- const in_memory_repository_1 = require("@tryghost/in-memory-repository");
5
- class InMemoryMailEventRepository extends in_memory_repository_1.InMemoryRepository {
4
+ const InMemoryRepository_1 = require("../lib/InMemoryRepository");
5
+ class InMemoryMailEventRepository extends InMemoryRepository_1.InMemoryRepository {
6
6
  toPrimitive() {
7
7
  return {};
8
8
  }
@@ -1,4 +1,4 @@
1
- import {InMemoryRepository} from '@tryghost/in-memory-repository';
1
+ import {InMemoryRepository} from '../lib/InMemoryRepository';
2
2
  import {MailEvent} from './MailEvent';
3
3
 
4
4
  export class InMemoryMailEventRepository extends InMemoryRepository<string, MailEvent> {
@@ -1,7 +1,7 @@
1
1
  const {MemberPageViewEvent, MemberCommentEvent, MemberLinkClickEvent} = require('@tryghost/member-events');
2
2
  const moment = require('moment-timezone');
3
3
  const {IncorrectUsageError} = require('@tryghost/errors');
4
- const {EmailOpenedEvent} = require('@tryghost/email-events');
4
+ const {EmailOpenedEvent} = require('@tryghost/email-service');
5
5
  const logging = require('@tryghost/logging');
6
6
  const LastSeenAtCache = require('./LastSeenAtCache');
7
7
 
@@ -1,6 +1,6 @@
1
- const DynamicRedirectManager = require('@tryghost/express-dynamic-redirects');
2
1
  const OffersModule = require('@tryghost/members-offers');
3
2
 
3
+ const DynamicRedirectManager = require('../lib/DynamicRedirectManager');
4
4
  const config = require('../../../shared/config');
5
5
  const urlUtils = require('../../../shared/url-utils');
6
6
  const models = require('../../models');
@@ -4,37 +4,37 @@ const logging = require('@tryghost/logging');
4
4
 
5
5
  class RecommendationServiceWrapper {
6
6
  /**
7
- * @type {import('@tryghost/recommendations').RecommendationRepository}
7
+ * @type {import('./service').RecommendationRepository}
8
8
  */
9
9
  repository;
10
10
 
11
11
  /**
12
- * @type {import('@tryghost/recommendations').BookshelfClickEventRepository}
12
+ * @type {import('./service').BookshelfClickEventRepository}
13
13
  */
14
14
  clickEventRepository;
15
15
 
16
16
  /**
17
- * @type {import('@tryghost/recommendations').BookshelfSubscribeEventRepository}
17
+ * @type {import('./service').BookshelfSubscribeEventRepository}
18
18
  */
19
19
  subscribeEventRepository;
20
20
 
21
21
  /**
22
- * @type {import('@tryghost/recommendations').RecommendationController}
22
+ * @type {import('./service').RecommendationController}
23
23
  */
24
24
  controller;
25
25
 
26
26
  /**
27
- * @type {import('@tryghost/recommendations').RecommendationService}
27
+ * @type {import('./service').RecommendationService}
28
28
  */
29
29
  service;
30
30
 
31
31
  /**
32
- * @type {import('@tryghost/recommendations').IncomingRecommendationController}
32
+ * @type {import('./service').IncomingRecommendationController}
33
33
  */
34
34
  incomingRecommendationController;
35
35
 
36
36
  /**
37
- * @type {import('@tryghost/recommendations').IncomingRecommendationService}
37
+ * @type {import('./service').IncomingRecommendationService}
38
38
  */
39
39
  incomingRecommendationService;
40
40
 
@@ -65,7 +65,7 @@ class RecommendationServiceWrapper {
65
65
  IncomingRecommendationService,
66
66
  IncomingRecommendationEmailRenderer,
67
67
  RecommendationMetadataService
68
- } = require('@tryghost/recommendations');
68
+ } = require('./service');
69
69
 
70
70
  const mentions = require('../mentions');
71
71
 
@@ -0,0 +1,48 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.BookshelfClickEventRepository = void 0;
7
+ const BookshelfRepository_1 = require("./BookshelfRepository");
8
+ const logging_1 = __importDefault(require("@tryghost/logging"));
9
+ const ClickEvent_1 = require("./ClickEvent");
10
+ class BookshelfClickEventRepository extends BookshelfRepository_1.BookshelfRepository {
11
+ sentry;
12
+ constructor(Model, deps = {}) {
13
+ super(Model);
14
+ this.sentry = deps.sentry;
15
+ }
16
+ toPrimitive(entity) {
17
+ return {
18
+ id: entity.id,
19
+ recommendation_id: entity.recommendationId,
20
+ member_id: entity.memberId,
21
+ created_at: entity.createdAt
22
+ };
23
+ }
24
+ modelToEntity(model) {
25
+ try {
26
+ return ClickEvent_1.ClickEvent.create({
27
+ id: model.id,
28
+ recommendationId: model.get('recommendation_id'),
29
+ memberId: model.get('member_id'),
30
+ createdAt: model.get('created_at')
31
+ });
32
+ }
33
+ catch (err) {
34
+ logging_1.default.error(err);
35
+ this.sentry?.captureException(err);
36
+ return null;
37
+ }
38
+ }
39
+ getFieldToColumnMap() {
40
+ return {
41
+ id: 'id',
42
+ recommendationId: 'recommendation_id',
43
+ memberId: 'member_id',
44
+ createdAt: 'created_at'
45
+ };
46
+ }
47
+ }
48
+ exports.BookshelfClickEventRepository = BookshelfClickEventRepository;
@@ -0,0 +1,49 @@
1
+ import {BookshelfRepository, ModelClass, ModelInstance} from './BookshelfRepository';
2
+ import logger from '@tryghost/logging';
3
+ import {ClickEvent} from './ClickEvent';
4
+
5
+ type Sentry = {
6
+ captureException(err: unknown): void;
7
+ }
8
+
9
+ export class BookshelfClickEventRepository extends BookshelfRepository<string, ClickEvent> {
10
+ sentry?: Sentry;
11
+
12
+ constructor(Model: ModelClass<string>, deps: {sentry?: Sentry} = {}) {
13
+ super(Model);
14
+ this.sentry = deps.sentry;
15
+ }
16
+
17
+ toPrimitive(entity: ClickEvent): object {
18
+ return {
19
+ id: entity.id,
20
+ recommendation_id: entity.recommendationId,
21
+ member_id: entity.memberId,
22
+ created_at: entity.createdAt
23
+ };
24
+ }
25
+
26
+ modelToEntity(model: ModelInstance<string>): ClickEvent | null {
27
+ try {
28
+ return ClickEvent.create({
29
+ id: model.id,
30
+ recommendationId: model.get('recommendation_id') as string,
31
+ memberId: model.get('member_id') as string | null,
32
+ createdAt: model.get('created_at') as Date
33
+ });
34
+ } catch (err) {
35
+ logger.error(err);
36
+ this.sentry?.captureException(err);
37
+ return null;
38
+ }
39
+ }
40
+
41
+ getFieldToColumnMap() {
42
+ return {
43
+ id: 'id',
44
+ recommendationId: 'recommendation_id',
45
+ memberId: 'member_id',
46
+ createdAt: 'created_at'
47
+ } as Record<keyof ClickEvent, string>;
48
+ }
49
+ }
@@ -0,0 +1,98 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.BookshelfRecommendationRepository = void 0;
7
+ const BookshelfRepository_1 = require("./BookshelfRepository");
8
+ const logging_1 = __importDefault(require("@tryghost/logging"));
9
+ const Recommendation_1 = require("./Recommendation");
10
+ class BookshelfRecommendationRepository extends BookshelfRepository_1.BookshelfRepository {
11
+ sentry;
12
+ constructor(Model, deps = {}) {
13
+ super(Model);
14
+ this.sentry = deps.sentry;
15
+ }
16
+ applyCustomQuery(query, options) {
17
+ query.select('recommendations.*');
18
+ if (options.include?.includes('clickCount') || options.order?.find(o => o.field === 'clickCount')) {
19
+ query.select((knex) => {
20
+ knex.count('*').from('recommendation_click_events').where('recommendation_click_events.recommendation_id', knex.client.raw('recommendations.id')).as('count__clicks');
21
+ });
22
+ }
23
+ if (options.include?.includes('subscriberCount') || options.order?.find(o => o.field === 'subscriberCount')) {
24
+ query.select((knex) => {
25
+ knex.count('*').from('recommendation_subscribe_events').where('recommendation_subscribe_events.recommendation_id', knex.client.raw('recommendations.id')).as('count__subscribers');
26
+ });
27
+ }
28
+ }
29
+ toPrimitive(entity) {
30
+ return {
31
+ id: entity.id,
32
+ title: entity.title,
33
+ description: entity.description,
34
+ excerpt: entity.excerpt,
35
+ featured_image: entity.featuredImage?.toString(),
36
+ favicon: entity.favicon?.toString(),
37
+ url: entity.url.toString(),
38
+ one_click_subscribe: entity.oneClickSubscribe,
39
+ created_at: entity.createdAt,
40
+ updated_at: entity.updatedAt
41
+ // Count relations are not saveable: so don't set them here
42
+ };
43
+ }
44
+ modelToEntity(model) {
45
+ try {
46
+ return Recommendation_1.Recommendation.create({
47
+ id: model.id,
48
+ title: model.get('title'),
49
+ description: model.get('description'),
50
+ excerpt: model.get('excerpt'),
51
+ featuredImage: model.get('featured_image'),
52
+ favicon: model.get('favicon'),
53
+ url: model.get('url'),
54
+ oneClickSubscribe: model.get('one_click_subscribe'),
55
+ createdAt: model.get('created_at'),
56
+ updatedAt: model.get('updated_at'),
57
+ clickCount: (model.get('count__clicks') ?? undefined),
58
+ subscriberCount: (model.get('count__subscribers') ?? undefined)
59
+ });
60
+ }
61
+ catch (err) {
62
+ logging_1.default.error(err);
63
+ this.sentry?.captureException(err);
64
+ return null;
65
+ }
66
+ }
67
+ getFieldToColumnMap() {
68
+ return {
69
+ id: 'id',
70
+ title: 'title',
71
+ description: 'description',
72
+ excerpt: 'excerpt',
73
+ featuredImage: 'featured_image',
74
+ favicon: 'favicon',
75
+ url: 'url',
76
+ oneClickSubscribe: 'one_click_subscribe',
77
+ createdAt: 'created_at',
78
+ updatedAt: 'updated_at',
79
+ clickCount: 'count__clicks',
80
+ subscriberCount: 'count__subscribers'
81
+ };
82
+ }
83
+ async getByUrl(url) {
84
+ const urlFilter = `url:~'${url.host.replace('www.', '')}${url.pathname.replace(/\/$/, '')}'`;
85
+ const recommendations = await this.getAll({ filter: urlFilter });
86
+ if (!recommendations || recommendations.length === 0) {
87
+ return null;
88
+ }
89
+ // Find URL based on the hostname and pathname.
90
+ // Query params, hash fragements, protocol and www are ignored.
91
+ const existing = recommendations.find((r) => {
92
+ return r.url.hostname.replace('www.', '') === url.hostname.replace('www.', '') &&
93
+ r.url.pathname.replace(/\/$/, '') === url.pathname.replace(/\/$/, '');
94
+ }) || null;
95
+ return existing;
96
+ }
97
+ }
98
+ exports.BookshelfRecommendationRepository = BookshelfRecommendationRepository;