ghost 5.89.6 → 5.90.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 (183) hide show
  1. package/components/tryghost-adapter-cache-memory-ttl-5.90.0.tgz +0 -0
  2. package/components/{tryghost-adapter-cache-redis-5.89.6.tgz → tryghost-adapter-cache-redis-5.90.0.tgz} +0 -0
  3. package/components/{tryghost-adapter-manager-5.89.6.tgz → tryghost-adapter-manager-5.90.0.tgz} +0 -0
  4. package/components/tryghost-announcement-bar-settings-5.90.0.tgz +0 -0
  5. package/components/{tryghost-api-framework-5.89.6.tgz → tryghost-api-framework-5.90.0.tgz} +0 -0
  6. package/components/tryghost-api-version-compatibility-service-5.90.0.tgz +0 -0
  7. package/components/tryghost-audience-feedback-5.90.0.tgz +0 -0
  8. package/components/tryghost-bookshelf-repository-5.90.0.tgz +0 -0
  9. package/components/tryghost-bootstrap-socket-5.90.0.tgz +0 -0
  10. package/components/tryghost-collections-5.90.0.tgz +0 -0
  11. package/components/tryghost-constants-5.90.0.tgz +0 -0
  12. package/components/tryghost-custom-theme-settings-service-5.90.0.tgz +0 -0
  13. package/components/{tryghost-data-generator-5.89.6.tgz → tryghost-data-generator-5.90.0.tgz} +0 -0
  14. package/components/tryghost-domain-events-5.90.0.tgz +0 -0
  15. package/components/tryghost-donations-5.90.0.tgz +0 -0
  16. package/components/tryghost-dynamic-routing-events-5.90.0.tgz +0 -0
  17. package/components/tryghost-email-addresses-5.90.0.tgz +0 -0
  18. package/components/tryghost-email-analytics-provider-mailgun-5.90.0.tgz +0 -0
  19. package/components/tryghost-email-analytics-service-5.90.0.tgz +0 -0
  20. package/components/{tryghost-email-content-generator-5.89.6.tgz → tryghost-email-content-generator-5.90.0.tgz} +0 -0
  21. package/components/tryghost-email-events-5.90.0.tgz +0 -0
  22. package/components/{tryghost-email-service-5.89.6.tgz → tryghost-email-service-5.90.0.tgz} +0 -0
  23. package/components/tryghost-email-suppression-list-5.90.0.tgz +0 -0
  24. package/components/tryghost-express-dynamic-redirects-5.90.0.tgz +0 -0
  25. package/components/tryghost-external-media-inliner-5.90.0.tgz +0 -0
  26. package/components/tryghost-extract-api-key-5.90.0.tgz +0 -0
  27. package/components/tryghost-ghost-5.90.0.tgz +0 -0
  28. package/components/tryghost-html-to-plaintext-5.90.0.tgz +0 -0
  29. package/components/tryghost-i18n-5.90.0.tgz +0 -0
  30. package/components/tryghost-importer-handler-content-files-5.90.0.tgz +0 -0
  31. package/components/{tryghost-importer-revue-5.89.6.tgz → tryghost-importer-revue-5.90.0.tgz} +0 -0
  32. package/components/tryghost-in-memory-repository-5.90.0.tgz +0 -0
  33. package/components/{tryghost-job-manager-5.89.6.tgz → tryghost-job-manager-5.90.0.tgz} +0 -0
  34. package/components/tryghost-link-redirects-5.90.0.tgz +0 -0
  35. package/components/{tryghost-link-replacer-5.89.6.tgz → tryghost-link-replacer-5.90.0.tgz} +0 -0
  36. package/components/{tryghost-link-tracking-5.89.6.tgz → tryghost-link-tracking-5.90.0.tgz} +0 -0
  37. package/components/tryghost-magic-link-5.90.0.tgz +0 -0
  38. package/components/tryghost-mail-events-5.90.0.tgz +0 -0
  39. package/components/{tryghost-mailgun-client-5.89.6.tgz → tryghost-mailgun-client-5.90.0.tgz} +0 -0
  40. package/components/{tryghost-member-attribution-5.89.6.tgz → tryghost-member-attribution-5.90.0.tgz} +0 -0
  41. package/components/tryghost-member-events-5.90.0.tgz +0 -0
  42. package/components/tryghost-members-api-5.90.0.tgz +0 -0
  43. package/components/tryghost-members-csv-5.90.0.tgz +0 -0
  44. package/components/tryghost-members-events-service-5.90.0.tgz +0 -0
  45. package/components/tryghost-members-importer-5.90.0.tgz +0 -0
  46. package/components/tryghost-members-offers-5.90.0.tgz +0 -0
  47. package/components/tryghost-members-payments-5.90.0.tgz +0 -0
  48. package/components/tryghost-members-ssr-5.90.0.tgz +0 -0
  49. package/components/tryghost-members-stripe-service-5.90.0.tgz +0 -0
  50. package/components/tryghost-mentions-email-report-5.90.0.tgz +0 -0
  51. package/components/tryghost-milestones-5.90.0.tgz +0 -0
  52. package/components/tryghost-minifier-5.90.0.tgz +0 -0
  53. package/components/tryghost-model-to-domain-event-interceptor-5.90.0.tgz +0 -0
  54. package/components/tryghost-mw-api-version-mismatch-5.90.0.tgz +0 -0
  55. package/components/tryghost-mw-cache-control-5.90.0.tgz +0 -0
  56. package/components/tryghost-mw-error-handler-5.90.0.tgz +0 -0
  57. package/components/tryghost-mw-session-from-token-5.90.0.tgz +0 -0
  58. package/components/tryghost-mw-update-user-last-seen-5.90.0.tgz +0 -0
  59. package/components/tryghost-mw-version-match-5.90.0.tgz +0 -0
  60. package/components/tryghost-mw-vhost-5.90.0.tgz +0 -0
  61. package/components/tryghost-nql-filter-expansions-5.90.0.tgz +0 -0
  62. package/components/{tryghost-oembed-service-5.89.6.tgz → tryghost-oembed-service-5.90.0.tgz} +0 -0
  63. package/components/tryghost-package-json-5.90.0.tgz +0 -0
  64. package/components/{tryghost-post-events-5.89.6.tgz → tryghost-post-events-5.90.0.tgz} +0 -0
  65. package/components/tryghost-post-revisions-5.90.0.tgz +0 -0
  66. package/components/tryghost-posts-service-5.90.0.tgz +0 -0
  67. package/components/tryghost-recommendations-5.90.0.tgz +0 -0
  68. package/components/tryghost-referrers-5.90.0.tgz +0 -0
  69. package/components/tryghost-security-5.90.0.tgz +0 -0
  70. package/components/{tryghost-session-service-5.89.6.tgz → tryghost-session-service-5.90.0.tgz} +0 -0
  71. package/components/tryghost-settings-path-manager-5.90.0.tgz +0 -0
  72. package/components/tryghost-slack-notifications-5.90.0.tgz +0 -0
  73. package/components/{tryghost-staff-service-5.89.6.tgz → tryghost-staff-service-5.90.0.tgz} +0 -0
  74. package/components/{tryghost-stats-service-5.89.6.tgz → tryghost-stats-service-5.90.0.tgz} +0 -0
  75. package/components/{tryghost-tiers-5.89.6.tgz → tryghost-tiers-5.90.0.tgz} +0 -0
  76. package/components/tryghost-update-check-service-5.90.0.tgz +0 -0
  77. package/components/tryghost-verification-trigger-5.90.0.tgz +0 -0
  78. package/components/tryghost-version-notifications-data-service-5.90.0.tgz +0 -0
  79. package/components/tryghost-webmentions-5.90.0.tgz +0 -0
  80. package/core/boot.js +8 -5
  81. package/core/built/admin/assets/admin-x-activitypub/admin-x-activitypub.js +2 -2
  82. package/core/built/admin/assets/admin-x-activitypub/{index-e318fbcd.mjs → index-79b91eda.mjs} +4643 -4540
  83. package/core/built/admin/assets/admin-x-activitypub/modals-25d834a0.mjs +1017 -0
  84. package/core/built/admin/assets/admin-x-demo/admin-x-demo.js +2 -2
  85. package/core/built/admin/assets/admin-x-demo/index-9379b3eb.mjs +15889 -0
  86. package/core/built/admin/assets/admin-x-demo/{modals-c17f9071.mjs → modals-88dc270f.mjs} +3 -3
  87. package/core/built/admin/assets/admin-x-settings/{CodeEditorView-511c0703.mjs → CodeEditorView-821d5b2c.mjs} +2 -2
  88. package/core/built/admin/assets/admin-x-settings/admin-x-settings.js +3 -3
  89. package/core/built/admin/assets/admin-x-settings/{index-5941f1da.mjs → index-1c47835f.mjs} +16355 -12569
  90. package/core/built/admin/assets/admin-x-settings/{index-a33217a0.mjs → index-29d842f9.mjs} +2 -2
  91. package/core/built/admin/assets/admin-x-settings/{modals-434ac8fd.mjs → modals-7c030639.mjs} +6330 -5814
  92. package/core/built/admin/assets/{chunk.42.90f279da5ca389af68b9.js → chunk.42.eae83e03ed76c8e8c57a.js} +4 -4
  93. package/core/built/admin/assets/{chunk.524.c121fa5c220befde8234.js → chunk.524.4551f9a844deaa462046.js} +5 -5
  94. package/core/built/admin/assets/{chunk.582.78f1ec44751f5313fcc9.js → chunk.582.1b3f6a148cd1896c12aa.js} +6 -6
  95. package/core/built/admin/assets/ghost-dark-cedf65ab1ef5082419d66cc99a151071.css +1 -0
  96. package/core/built/admin/assets/ghost-e452bcc2710cf5a8372a7f7202661191.css +1 -0
  97. package/core/built/admin/assets/{ghost-532c38e4c3ecd17d68cdcc9fb58b9098.js → ghost-e733775ba236c045dbcdaaa39231c6d8.js} +61 -53
  98. package/core/built/admin/assets/{vendor-eb806b4d193377b9b4bbf885840bfdc2.js → vendor-bff75d127fcf4a75c8a83b473bdb308f.js} +2 -2
  99. package/core/built/admin/index.html +6 -6
  100. package/core/frontend/services/routing/RouterManager.js +7 -7
  101. package/core/frontend/src/cards/css/audio.css +3 -0
  102. package/core/frontend/src/cards/css/file.css +4 -1
  103. package/core/frontend/src/cards/css/product.css +3 -3
  104. package/core/frontend/src/cards/css/toggle.css +1 -1
  105. package/core/server/data/migrations/versions/5.90/2024-08-20-09-40-24-update-default-donations-suggested-amount.js +42 -0
  106. package/core/server/data/schema/default-settings/default-settings.json +1 -1
  107. package/core/server/models/base/plugins/crud.js +14 -0
  108. package/core/server/models/member-click-event.js +5 -2
  109. package/core/server/services/email-analytics/EmailAnalyticsServiceWrapper.js +37 -8
  110. package/core/server/services/email-analytics/lib/queries.js +117 -21
  111. package/core/server/services/members/RequestIntegrityTokenProvider.js +62 -0
  112. package/core/server/services/members/middleware.js +42 -2
  113. package/core/server/services/members/service.js +6 -0
  114. package/core/server/web/members/app.js +3 -0
  115. package/core/shared/instrumentation.js +39 -27
  116. package/package.json +150 -153
  117. package/yarn.lock +466 -801
  118. package/components/tryghost-adapter-cache-memory-ttl-5.89.6.tgz +0 -0
  119. package/components/tryghost-announcement-bar-settings-5.89.6.tgz +0 -0
  120. package/components/tryghost-api-version-compatibility-service-5.89.6.tgz +0 -0
  121. package/components/tryghost-audience-feedback-5.89.6.tgz +0 -0
  122. package/components/tryghost-bookshelf-repository-5.89.6.tgz +0 -0
  123. package/components/tryghost-bootstrap-socket-5.89.6.tgz +0 -0
  124. package/components/tryghost-collections-5.89.6.tgz +0 -0
  125. package/components/tryghost-constants-5.89.6.tgz +0 -0
  126. package/components/tryghost-custom-theme-settings-service-5.89.6.tgz +0 -0
  127. package/components/tryghost-domain-events-5.89.6.tgz +0 -0
  128. package/components/tryghost-donations-5.89.6.tgz +0 -0
  129. package/components/tryghost-dynamic-routing-events-5.89.6.tgz +0 -0
  130. package/components/tryghost-email-addresses-5.89.6.tgz +0 -0
  131. package/components/tryghost-email-analytics-provider-mailgun-5.89.6.tgz +0 -0
  132. package/components/tryghost-email-analytics-service-5.89.6.tgz +0 -0
  133. package/components/tryghost-email-events-5.89.6.tgz +0 -0
  134. package/components/tryghost-email-suppression-list-5.89.6.tgz +0 -0
  135. package/components/tryghost-express-dynamic-redirects-5.89.6.tgz +0 -0
  136. package/components/tryghost-external-media-inliner-5.89.6.tgz +0 -0
  137. package/components/tryghost-extract-api-key-5.89.6.tgz +0 -0
  138. package/components/tryghost-ghost-5.89.6.tgz +0 -0
  139. package/components/tryghost-html-to-plaintext-5.89.6.tgz +0 -0
  140. package/components/tryghost-i18n-5.89.6.tgz +0 -0
  141. package/components/tryghost-importer-handler-content-files-5.89.6.tgz +0 -0
  142. package/components/tryghost-in-memory-repository-5.89.6.tgz +0 -0
  143. package/components/tryghost-link-redirects-5.89.6.tgz +0 -0
  144. package/components/tryghost-magic-link-5.89.6.tgz +0 -0
  145. package/components/tryghost-mail-events-5.89.6.tgz +0 -0
  146. package/components/tryghost-member-events-5.89.6.tgz +0 -0
  147. package/components/tryghost-members-api-5.89.6.tgz +0 -0
  148. package/components/tryghost-members-csv-5.89.6.tgz +0 -0
  149. package/components/tryghost-members-events-service-5.89.6.tgz +0 -0
  150. package/components/tryghost-members-importer-5.89.6.tgz +0 -0
  151. package/components/tryghost-members-offers-5.89.6.tgz +0 -0
  152. package/components/tryghost-members-payments-5.89.6.tgz +0 -0
  153. package/components/tryghost-members-ssr-5.89.6.tgz +0 -0
  154. package/components/tryghost-members-stripe-service-5.89.6.tgz +0 -0
  155. package/components/tryghost-mentions-email-report-5.89.6.tgz +0 -0
  156. package/components/tryghost-milestones-5.89.6.tgz +0 -0
  157. package/components/tryghost-minifier-5.89.6.tgz +0 -0
  158. package/components/tryghost-model-to-domain-event-interceptor-5.89.6.tgz +0 -0
  159. package/components/tryghost-mw-api-version-mismatch-5.89.6.tgz +0 -0
  160. package/components/tryghost-mw-cache-control-5.89.6.tgz +0 -0
  161. package/components/tryghost-mw-error-handler-5.89.6.tgz +0 -0
  162. package/components/tryghost-mw-session-from-token-5.89.6.tgz +0 -0
  163. package/components/tryghost-mw-update-user-last-seen-5.89.6.tgz +0 -0
  164. package/components/tryghost-mw-version-match-5.89.6.tgz +0 -0
  165. package/components/tryghost-mw-vhost-5.89.6.tgz +0 -0
  166. package/components/tryghost-nql-filter-expansions-5.89.6.tgz +0 -0
  167. package/components/tryghost-package-json-5.89.6.tgz +0 -0
  168. package/components/tryghost-post-revisions-5.89.6.tgz +0 -0
  169. package/components/tryghost-posts-service-5.89.6.tgz +0 -0
  170. package/components/tryghost-recommendations-5.89.6.tgz +0 -0
  171. package/components/tryghost-referrers-5.89.6.tgz +0 -0
  172. package/components/tryghost-security-5.89.6.tgz +0 -0
  173. package/components/tryghost-settings-path-manager-5.89.6.tgz +0 -0
  174. package/components/tryghost-slack-notifications-5.89.6.tgz +0 -0
  175. package/components/tryghost-update-check-service-5.89.6.tgz +0 -0
  176. package/components/tryghost-verification-trigger-5.89.6.tgz +0 -0
  177. package/components/tryghost-version-notifications-data-service-5.89.6.tgz +0 -0
  178. package/components/tryghost-webmentions-5.89.6.tgz +0 -0
  179. package/core/built/admin/assets/admin-x-activitypub/modals-a6e383d4.mjs +0 -422
  180. package/core/built/admin/assets/admin-x-demo/index-116afae1.mjs +0 -12365
  181. package/core/built/admin/assets/ghost-dark-f9c5f2aae7d01ae36bd6932c0abf683a.css +0 -1
  182. package/core/built/admin/assets/ghost-f627cc6acd7574a7bdbf2f2988365c78.css +0 -1
  183. /package/core/built/admin/assets/{chunk.42.90f279da5ca389af68b9.js.LICENSE.txt → chunk.42.eae83e03ed76c8e8c57a.js.LICENSE.txt} +0 -0
@@ -8782,7 +8782,7 @@ e.default={content:'<path stroke="currentColor" stroke-linecap="round" stroke-li
8782
8782
  Object.defineProperty(e,"__esModule",{value:!0}),e.default=void 0
8783
8783
  e.default={content:'<defs><style>.labs_svg__a{fill:none;stroke:currentColor;stroke-linecap:round;stroke-linejoin:round;stroke-width:1.5px}</style></defs><path class="labs_svg__a" d="M6.726.75h10.5m-1.5 7.5V.75h-7.5v7.5L1.489 18.615A3 3 0 004 23.25h15.948a3 3 0 002.515-4.635zm-10.425 4.5h13.35m-4.425 4.5h3m-1.5-1.5v3"/><path class="labs_svg__a" d="M6.726 19.125a.375.375 0 01.374.375m-.749 0a.375.375 0 01.375-.375m0 .75a.375.375 0 01-.375-.375"/><path class="labs_svg__a" d="M7.1 19.5a.375.375 0 01-.375.375m3.001-3.75a.375.375 0 01.375.375m-.75 0a.375.375 0 01.375-.375m0 .75a.375.375 0 01-.375-.375"/><path class="labs_svg__a" d="M10.1 16.5a.375.375 0 01-.375.375M15.726 3.75h-3m3 3h-3"/>',attrs:{viewBox:"0 0 24 24"}}})),define("ember-svg-jar/inlined/link",["exports"],(function(e){"use strict"
8784
8784
  Object.defineProperty(e,"__esModule",{value:!0}),e.default=void 0
8785
- e.default={content:'<path stroke="currentColor" d="M14.5 12.5l.086.086a2 2 0 002.828 0l3.965-3.964a3.01 3.01 0 000-4.243l-1.758-1.757a3.008 3.008 0 00-4.242 0l-3.965 3.964a2 2 0 000 2.829l.086.085m-2 2l-.086-.085a2 2 0 00-2.828 0l-3.965 3.964a3.01 3.01 0 000 4.243l1.758 1.757a3.008 3.008 0 004.242 0l3.965-3.964a2 2 0 000-2.829L12.5 14.5m-4.389 1.389l7.778-7.778" fill="none"/>',attrs:{viewBox:"0 0 24 24"}}})),define("ember-svg-jar/inlined/lock-filled",["exports"],(function(e){"use strict"
8785
+ e.default={content:'<desc>Hyperlink 3 Streamline Icon: https://streamlinehq.com</desc><path d="M9.364 18.5l-.932.932a4.5 4.5 0 01-6.364-6.364l4.773-4.774a4.5 4.5 0 016.825 5.825" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/><path d="M14.818 5.567l.75-.75a4.5 4.5 0 016.364 6.364l-4.773 4.773a4.5 4.5 0 01-6.824-5.826" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>',attrs:{viewBox:"0 0 24 24"}}})),define("ember-svg-jar/inlined/lock-filled",["exports"],(function(e){"use strict"
8786
8786
  Object.defineProperty(e,"__esModule",{value:!0}),e.default=void 0
8787
8787
  e.default={content:'<path d="M19.5 9.5h-.75V6.75a6.75 6.75 0 00-13.5 0V9.5H4.5a2 2 0 00-2 2V22a2 2 0 002 2h15a2 2 0 002-2V11.5a2 2 0 00-2-2zm-7.5 9a2 2 0 112-2 2 2 0 01-2 2zM16.25 9a.5.5 0 01-.5.5h-7.5a.5.5 0 01-.5-.5V6.75a4.25 4.25 0 018.5 0z" fill="currentColor"/>',attrs:{viewBox:"0 0 24 24"}}})),define("ember-svg-jar/inlined/lock",["exports"],(function(e){"use strict"
8788
8788
  Object.defineProperty(e,"__esModule",{value:!0}),e.default=void 0
@@ -9518,4 +9518,4 @@ e.default=class{constructor(e){if(this._data=new t.default,e)for(let t=0;t<e.len
9518
9518
  return this}get(e){let t=this._data[e]
9519
9519
  return t===r.UNDEFINED_KEY?void 0:t}set(e,t){return this._data[e]=t,this}delete(e){return this._data[e]=r.UNDEFINED_KEY,!0}}}))
9520
9520
 
9521
- //# sourceMappingURL=vendor-a5396535c9254637f330cdc37337a070.map
9521
+ //# sourceMappingURL=vendor-a3242d3bb7bb2f2ad87cc8ba3d607097.map
@@ -8,7 +8,7 @@
8
8
  <title>Ghost Admin</title>
9
9
 
10
10
 
11
- <meta name="ghost-admin/config/environment" content="%7B%22modulePrefix%22%3A%22ghost-admin%22%2C%22environment%22%3A%22production%22%2C%22cdnUrl%22%3A%22%22%2C%22editorUrl%22%3A%22%22%2C%22rootURL%22%3A%22%22%2C%22locationType%22%3A%22trailing-hash%22%2C%22EmberENV%22%3A%7B%22FEATURES%22%3A%7B%7D%2C%22EXTEND_PROTOTYPES%22%3A%7B%22Date%22%3Afalse%2C%22Array%22%3Atrue%2C%22String%22%3Atrue%2C%22Function%22%3Afalse%7D%2C%22_APPLICATION_TEMPLATE_WRAPPER%22%3Afalse%2C%22_JQUERY_INTEGRATION%22%3Atrue%2C%22_TEMPLATE_ONLY_GLIMMER_COMPONENTS%22%3Atrue%7D%2C%22APP%22%3A%7B%22version%22%3A%225.89%22%2C%22name%22%3A%22ghost-admin%22%7D%2C%22ember-simple-auth%22%3A%7B%7D%2C%22%40sentry%2Fember%22%3A%7B%22disablePerformance%22%3Atrue%2C%22sentry%22%3A%7B%7D%7D%2C%22ember-cli-mirage%22%3A%7B%22usingProxy%22%3Afalse%2C%22useDefaultPassthroughs%22%3Atrue%7D%2C%22exportApplicationGlobal%22%3Afalse%2C%22ember-load%22%3A%7B%22loadingIndicatorClass%22%3A%22ember-load-indicator%22%7D%2C%22editorFilename%22%3A%22koenig-lexical.umd.js%22%2C%22editorHash%22%3A%224e5636b008%22%2C%22adminXDemoFilename%22%3A%22admin-x-demo.js%22%2C%22adminXDemoHash%22%3A%2238e9a4d76a%22%2C%22adminXSettingsFilename%22%3A%22admin-x-settings.js%22%2C%22adminXSettingsHash%22%3A%2226cabffbea%22%2C%22adminXActivitypubFilename%22%3A%22admin-x-activitypub.js%22%2C%22adminXActivitypubHash%22%3A%224e8596d2e2%22%7D" />
11
+ <meta name="ghost-admin/config/environment" content="%7B%22modulePrefix%22%3A%22ghost-admin%22%2C%22environment%22%3A%22production%22%2C%22cdnUrl%22%3A%22%22%2C%22editorUrl%22%3A%22%22%2C%22rootURL%22%3A%22%22%2C%22locationType%22%3A%22trailing-hash%22%2C%22EmberENV%22%3A%7B%22FEATURES%22%3A%7B%7D%2C%22EXTEND_PROTOTYPES%22%3A%7B%22Date%22%3Afalse%2C%22Array%22%3Atrue%2C%22String%22%3Atrue%2C%22Function%22%3Afalse%7D%2C%22_APPLICATION_TEMPLATE_WRAPPER%22%3Afalse%2C%22_JQUERY_INTEGRATION%22%3Atrue%2C%22_TEMPLATE_ONLY_GLIMMER_COMPONENTS%22%3Atrue%7D%2C%22APP%22%3A%7B%22version%22%3A%225.90%22%2C%22name%22%3A%22ghost-admin%22%7D%2C%22ember-simple-auth%22%3A%7B%7D%2C%22%40sentry%2Fember%22%3A%7B%22disablePerformance%22%3Atrue%2C%22sentry%22%3A%7B%7D%7D%2C%22ember-cli-mirage%22%3A%7B%22usingProxy%22%3Afalse%2C%22useDefaultPassthroughs%22%3Atrue%7D%2C%22exportApplicationGlobal%22%3Afalse%2C%22ember-load%22%3A%7B%22loadingIndicatorClass%22%3A%22ember-load-indicator%22%7D%2C%22editorFilename%22%3A%22koenig-lexical.umd.js%22%2C%22editorHash%22%3A%224e5636b008%22%2C%22adminXDemoFilename%22%3A%22admin-x-demo.js%22%2C%22adminXDemoHash%22%3A%22b027d76bc5%22%2C%22adminXSettingsFilename%22%3A%22admin-x-settings.js%22%2C%22adminXSettingsHash%22%3A%2206c6b7a1d5%22%2C%22adminXActivitypubFilename%22%3A%22admin-x-activitypub.js%22%2C%22adminXActivitypubHash%22%3A%2261ea0f97da%22%7D" />
12
12
 
13
13
  <meta name="HandheldFriendly" content="True" />
14
14
  <meta name="MobileOptimized" content="320" />
@@ -37,7 +37,7 @@
37
37
  </style>
38
38
 
39
39
  <link integrity="" rel="stylesheet" href="assets/vendor-0ede59da8efb5e28fa929557f7ff7154.css">
40
- <link integrity="" rel="stylesheet" href="assets/ghost-f627cc6acd7574a7bdbf2f2988365c78.css" title="light">
40
+ <link integrity="" rel="stylesheet" href="assets/ghost-e452bcc2710cf5a8372a7f7202661191.css" title="light">
41
41
 
42
42
 
43
43
  </head>
@@ -56,9 +56,9 @@
56
56
 
57
57
  <div id="ember-basic-dropdown-wormhole"></div>
58
58
 
59
- <script src="assets/vendor-eb806b4d193377b9b4bbf885840bfdc2.js"></script>
60
- <script src="assets/chunk.42.90f279da5ca389af68b9.js"></script>
61
- <script src="assets/chunk.524.c121fa5c220befde8234.js"></script>
62
- <script src="assets/ghost-532c38e4c3ecd17d68cdcc9fb58b9098.js"></script>
59
+ <script src="assets/vendor-bff75d127fcf4a75c8a83b473bdb308f.js"></script>
60
+ <script src="assets/chunk.42.eae83e03ed76c8e8c57a.js"></script>
61
+ <script src="assets/chunk.524.4551f9a844deaa462046.js"></script>
62
+ <script src="assets/ghost-e733775ba236c045dbcdaaa39231c6d8.js"></script>
63
63
  </body>
64
64
  </html>
@@ -126,6 +126,13 @@ class RouterManager {
126
126
  this.registry.setRouter(staticRoutesRouter.identifier, staticRoutesRouter);
127
127
  });
128
128
 
129
+ _.each(routerSettings.taxonomies, (value, key) => {
130
+ const taxonomyRouter = new TaxonomyRouter(key, value, RESOURCE_CONFIG, this.routerCreated.bind(this));
131
+ this.siteRouter.mountRouter(taxonomyRouter.router());
132
+
133
+ this.registry.setRouter(taxonomyRouter.identifier, taxonomyRouter);
134
+ });
135
+
129
136
  _.each(routerSettings.collections, (value, key) => {
130
137
  const collectionRouter = new CollectionRouter(key, value, RESOURCE_CONFIG, this.routerCreated.bind(this));
131
138
  this.siteRouter.mountRouter(collectionRouter.router());
@@ -137,13 +144,6 @@ class RouterManager {
137
144
 
138
145
  this.registry.setRouter('staticPagesRouter', staticPagesRouter);
139
146
 
140
- _.each(routerSettings.taxonomies, (value, key) => {
141
- const taxonomyRouter = new TaxonomyRouter(key, value, RESOURCE_CONFIG, this.routerCreated.bind(this));
142
- this.siteRouter.mountRouter(taxonomyRouter.router());
143
-
144
- this.registry.setRouter(taxonomyRouter.identifier, taxonomyRouter);
145
- });
146
-
147
147
  const appRouter = new ParentRouter('AppsRouter');
148
148
  this.siteRouter.mountRouter(appRouter.router());
149
149
 
@@ -9,7 +9,10 @@
9
9
  min-height: 96px;
10
10
  border-radius: 6px;
11
11
  padding: 4px;
12
+ background: #fff;
13
+ color: #222;
12
14
  box-shadow: inset 0 0 0 1px rgba(124, 139, 154, 0.25);
15
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
13
16
  }
14
17
 
15
18
  .kg-audio-card+.kg-audio-card {
@@ -14,11 +14,14 @@
14
14
  color: inherit;
15
15
  padding: 12px;
16
16
  min-height: 92px;
17
+ background: #fff;
18
+ color: #222;
17
19
  border: 1px solid rgb(124 139 154 / 25%);
18
20
  border-radius: 5px;
19
21
  transition: all ease-in-out 0.35s;
20
22
  text-decoration: none;
21
23
  width: 100%;
24
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
22
25
  }
23
26
 
24
27
  .kg-file-card a.kg-file-card-container:hover {
@@ -137,4 +140,4 @@
137
140
 
138
141
  .kg-file-card + .kg-file-card {
139
142
  margin-top: 1em;
140
- }
143
+ }
@@ -19,8 +19,11 @@
19
19
  max-width: 550px;
20
20
  padding: 20px;
21
21
  width: 100%;
22
+ background: #fff;
23
+ color: #222;
22
24
  border-radius: 5px;
23
25
  box-shadow: inset 0 0 0 1px rgb(124 139 154 / 25%);
26
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
24
27
  }
25
28
 
26
29
  .kg-product-card-image {
@@ -34,7 +37,6 @@
34
37
  }
35
38
 
36
39
  .kg-product-card h4.kg-product-card-title {
37
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
38
40
  text-decoration: none;
39
41
  font-weight: 600;
40
42
  font-size: 21px;
@@ -50,7 +52,6 @@
50
52
  .kg-product-card .kg-product-card-description p,
51
53
  .kg-product-card .kg-product-card-description ol,
52
54
  .kg-product-card .kg-product-card-description ul {
53
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
54
55
  font-size: 14px;
55
56
  line-height: 1.5em;
56
57
  opacity: .7;
@@ -115,7 +116,6 @@
115
116
  display: flex;
116
117
  position: static;
117
118
  align-items: center;
118
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
119
119
  font-size: 14px;
120
120
  font-weight: 600;
121
121
  line-height: 1em;
@@ -94,4 +94,4 @@
94
94
 
95
95
  .kg-toggle-card + .kg-toggle-card {
96
96
  margin-top: 1em;
97
- }
97
+ }
@@ -0,0 +1,42 @@
1
+ // For information on writing migrations, see https://www.notion.so/ghost/Database-migrations-eb5b78c435d741d2b34a582d57c24253
2
+
3
+ const logging = require('@tryghost/logging');
4
+
5
+ // For DDL - schema changes
6
+ // const {createNonTransactionalMigration} = require('../../utils');
7
+
8
+ // For DML - data changes
9
+ const {createTransactionalMigration} = require('../../utils');
10
+
11
+ // Or use a specific helper
12
+ // const {addTable, createAddColumnMigration} = require('../../utils');
13
+
14
+ module.exports = createTransactionalMigration(
15
+ async function up(knex) {
16
+ try {
17
+ // find the existing donations_suggested_amount setting
18
+ const existingSuggestedAmount = await knex('settings')
19
+ .where({key: 'donations_suggested_amount'})
20
+ .first();
21
+
22
+ // previous default is '0', if it's been set to something else we don't want to change it
23
+ if (existingSuggestedAmount.value !== '0') {
24
+ logging.info('donations_suggested_amount setting does not have previous default of 0, skipping migration');
25
+ return;
26
+ }
27
+
28
+ // new default is '500', update the setting
29
+ logging.info('Updating donations_suggested_amount default setting to 500');
30
+ await knex('settings')
31
+ .where({key: 'donations_suggested_amount'})
32
+ .update({value: '500'});
33
+ } catch (error) {
34
+ logging.error(`Error updating donations_suggested_amount setting: ${error.message}`);
35
+ }
36
+ },
37
+ async function down() {
38
+ // no-op
39
+ // we can't guarantee that a suggested amount of 500 now isn't
40
+ // something that was set explicitly
41
+ }
42
+ );
@@ -567,7 +567,7 @@
567
567
  "type": "string"
568
568
  },
569
569
  "donations_suggested_amount": {
570
- "defaultValue": 0,
570
+ "defaultValue": 500,
571
571
  "validations": {
572
572
  "isEmpty": false
573
573
  },
@@ -120,6 +120,20 @@ module.exports = function (Bookshelf) {
120
120
  });
121
121
  }
122
122
 
123
+ if (Array.isArray(options.cte)) {
124
+ options.cte.forEach((cte) => {
125
+ itemCollection.query((qb) => {
126
+ qb.with(cte.name, qb.client.raw(cte.query));
127
+ });
128
+ });
129
+ }
130
+
131
+ if (options.from) {
132
+ itemCollection.query((qb) => {
133
+ qb.from(options.from);
134
+ });
135
+ }
136
+
123
137
  //option param to skip distinct from count query, distinct adds a lot of latency and in this case the result set will always be unique.
124
138
  if (unfilteredOptions.useBasicCount) {
125
139
  options.useBasicCount = unfilteredOptions.useBasicCount;
@@ -21,7 +21,10 @@ const MemberClickEvent = ghostBookshelf.Model.extend({
21
21
  return expansions;
22
22
  },
23
23
 
24
- filterRelations() {
24
+ filterRelations(options) {
25
+ if (options && options.filterRelations === false) {
26
+ return {};
27
+ }
25
28
  return {
26
29
  link: {
27
30
  // Mongo-knex doesn't support belongsTo relations
@@ -47,7 +50,7 @@ const MemberClickEvent = ghostBookshelf.Model.extend({
47
50
  permittedOptions(methodName) {
48
51
  let options = ghostBookshelf.Model.permittedOptions.call(this, methodName);
49
52
  const validOptions = {
50
- findPage: ['selectRaw', 'whereRaw']
53
+ findPage: ['selectRaw', 'whereRaw', 'cte', 'from', 'useCTE', 'filterRelations']
51
54
  };
52
55
 
53
56
  if (validOptions[methodName]) {
@@ -57,11 +57,22 @@ class EmailAnalyticsServiceWrapper {
57
57
  });
58
58
  }
59
59
 
60
- async fetchLatest({maxEvents} = {maxEvents: Infinity}) {
61
- logging.info('[EmailAnalytics] Fetch latest started');
60
+ async fetchLatestOpenedEvents({maxEvents} = {maxEvents: Infinity}) {
61
+ logging.info('[EmailAnalytics] Fetch latest opened events started');
62
62
 
63
63
  const fetchStartDate = new Date();
64
- const totalEvents = await this.service.fetchLatest({maxEvents});
64
+ const totalEvents = await this.service.fetchLatestOpenedEvents({maxEvents});
65
+ const fetchEndDate = new Date();
66
+
67
+ logging.info(`[EmailAnalytics] Fetched ${totalEvents} events and aggregated stats in ${fetchEndDate.getTime() - fetchStartDate.getTime()}ms (latest opens)`);
68
+ return totalEvents;
69
+ }
70
+
71
+ async fetchLatestNonOpenedEvents({maxEvents} = {maxEvents: Infinity}) {
72
+ logging.info('[EmailAnalytics] Fetch latest non-opened events started');
73
+
74
+ const fetchStartDate = new Date();
75
+ const totalEvents = await this.service.fetchLatestNonOpenedEvents({maxEvents});
65
76
  const fetchEndDate = new Date();
66
77
 
67
78
  logging.info(`[EmailAnalytics] Fetched ${totalEvents} events and aggregated stats in ${fetchEndDate.getTime() - fetchStartDate.getTime()}ms (latest)`);
@@ -69,7 +80,7 @@ class EmailAnalyticsServiceWrapper {
69
80
  }
70
81
 
71
82
  async fetchMissing({maxEvents} = {maxEvents: Infinity}) {
72
- logging.info('[EmailAnalytics] Fetch missing started');
83
+ logging.info('[EmailAnalytics] Fetch missing events started');
73
84
 
74
85
  const fetchStartDate = new Date();
75
86
  const totalEvents = await this.service.fetchMissing({maxEvents});
@@ -83,7 +94,7 @@ class EmailAnalyticsServiceWrapper {
83
94
  if (maxEvents < 300) {
84
95
  return 0;
85
96
  }
86
- logging.info('[EmailAnalytics] Fetch scheduled started');
97
+ logging.info('[EmailAnalytics] Fetch scheduled events started');
87
98
 
88
99
  const fetchStartDate = new Date();
89
100
  const totalEvents = await this.service.fetchScheduled({maxEvents});
@@ -100,13 +111,31 @@ class EmailAnalyticsServiceWrapper {
100
111
  }
101
112
  this.fetching = true;
102
113
 
114
+ // NOTE: Data shows we can process ~7500 events per minute on Pro; this can vary locally
103
115
  try {
104
- const c1 = await this.fetchLatest({maxEvents: Infinity});
105
- const c2 = await this.fetchMissing({maxEvents: Infinity});
116
+ // Prioritize opens since they are the most important (only data directly displayed to users)
117
+ await this.fetchLatestOpenedEvents({maxEvents: Infinity});
118
+
119
+ // Set limits on how much we fetch without checkings for opened events. During surge events (following newsletter send)
120
+ // we want to make sure we don't spend too much time collecting delivery data.
121
+ const c1 = await this.fetchLatestNonOpenedEvents({maxEvents: 20000});
122
+ if (c1 > 15000) {
123
+ this.fetching = false;
124
+ logging.info('[EmailAnalytics] Restarting fetch due to high event count');
125
+ this.startFetch();
126
+ return;
127
+ }
128
+ const c2 = await this.fetchMissing({maxEvents: 20000});
129
+ if ((c1 + c2) > 15000) {
130
+ this.fetching = false;
131
+ logging.info('[EmailAnalytics] Restarting fetch due to high event count');
132
+ this.startFetch();
133
+ return;
134
+ }
106
135
 
107
136
  // Only fetch scheduled if we didn't fetch a lot of normal events
108
137
  await this.fetchScheduled({maxEvents: 20000 - c1 - c2});
109
-
138
+
110
139
  this.fetching = false;
111
140
  } catch (e) {
112
141
  logging.error(e, 'Error while fetching email analytics');
@@ -1,9 +1,14 @@
1
1
  const _ = require('lodash');
2
2
  const debug = require('@tryghost/debug')('services:email-analytics');
3
3
  const db = require('../../../data/db');
4
+ const logging = require('@tryghost/logging');
5
+ const {default: ObjectID} = require('bson-objectid');
4
6
 
5
7
  const MIN_EMAIL_COUNT_FOR_OPEN_RATE = 5;
6
8
 
9
+ /** @typedef {'email-analytics-latest-opened'|'email-analytics-latest-others'|'email-analytics-missing'|'email-analytics-scheduled'} EmailAnalyticsJobName */
10
+ /** @typedef {'delivered'|'opened'|'failed'} EmailAnalyticsEvent */
11
+
7
12
  module.exports = {
8
13
  async shouldFetchStats() {
9
14
  // don't fetch stats from Mailgun if we haven't sent any emails
@@ -11,35 +16,126 @@ module.exports = {
11
16
  return emailCount && emailCount.count > 0;
12
17
  },
13
18
 
14
- async getLastSeenEventTimestamp() {
19
+ /**
20
+ * Retrieves the timestamp of the last seen event for the specified email analytics events.
21
+ * @param {EmailAnalyticsJobName} jobName - The name of the job to update.
22
+ * @param {EmailAnalyticsEvent[]} [events=['delivered', 'opened', 'failed']] - The email analytics events to consider.
23
+ * @returns {Promise<Date|null>} The timestamp of the last seen event, or null if no events are found.
24
+ */
25
+ async getLastEventTimestamp(jobName, events = ['delivered', 'opened', 'failed']) {
15
26
  const startDate = new Date();
16
-
17
- // three separate queries is much faster than using max/greatest (with coalesce to handle nulls) across columns
18
- let {maxDeliveredAt} = await db.knex('email_recipients').select(db.knex.raw('MAX(delivered_at) as maxDeliveredAt')).first() || {};
19
- let {maxOpenedAt} = await db.knex('email_recipients').select(db.knex.raw('MAX(opened_at) as maxOpenedAt')).first() || {};
20
- let {maxFailedAt} = await db.knex('email_recipients').select(db.knex.raw('MAX(failed_at) as maxFailedAt')).first() || {};
21
-
22
- if (maxDeliveredAt && !(maxDeliveredAt instanceof Date)) {
23
- // SQLite returns a string instead of a Date
24
- maxDeliveredAt = new Date(maxDeliveredAt);
27
+
28
+ let maxOpenedAt;
29
+ let maxDeliveredAt;
30
+ let maxFailedAt;
31
+
32
+ const jobData = await db.knex('jobs').select('finished_at', 'started_at').where('name', jobName).first();
33
+
34
+ if (jobData) {
35
+ debug(`Using job data for ${jobName}`);
36
+ const lastJobTimestamp = jobData.finished_at || jobData.started_at;
37
+ maxOpenedAt = events.includes('opened') ? lastJobTimestamp : null;
38
+ maxDeliveredAt = events.includes('delivered') ? lastJobTimestamp : null;
39
+ maxFailedAt = events.includes('failed') ? lastJobTimestamp : null;
40
+ } else {
41
+ debug(`Job data not found for ${jobName}, using email_recipients data`);
42
+ logging.info(`Job data not found for ${jobName}, using email_recipients data`);
43
+ if (events.includes('opened')) {
44
+ maxOpenedAt = (await db.knex('email_recipients').select(db.knex.raw('MAX(opened_at) as maxOpenedAt')).first()).maxOpenedAt;
45
+ }
46
+ if (events.includes('delivered')) {
47
+ maxDeliveredAt = (await db.knex('email_recipients').select(db.knex.raw('MAX(delivered_at) as maxDeliveredAt')).first()).maxDeliveredAt;
48
+ }
49
+ if (events.includes('failed')) {
50
+ maxFailedAt = (await db.knex('email_recipients').select(db.knex.raw('MAX(failed_at) as maxFailedAt')).first()).maxFailedAt;
51
+ }
52
+
53
+ // Insert a new job row if it doesn't exist
54
+ await db.knex('jobs').insert({
55
+ id: new ObjectID().toHexString(),
56
+ name: jobName,
57
+ started_at: new Date(),
58
+ created_at: new Date(),
59
+ status: 'started'
60
+ }).onConflict('name').ignore();
25
61
  }
26
62
 
27
- if (maxOpenedAt && !(maxOpenedAt instanceof Date)) {
28
- // SQLite returns a string instead of a Date
29
- maxOpenedAt = new Date(maxOpenedAt);
30
- }
31
-
32
- if (maxFailedAt && !(maxFailedAt instanceof Date)) {
33
- // SQLite returns a string instead of a Date
34
- maxFailedAt = new Date(maxFailedAt);
35
- }
63
+ // Convert string dates to Date objects for SQLite compatibility
64
+ [maxOpenedAt, maxDeliveredAt, maxFailedAt] = [maxOpenedAt, maxDeliveredAt, maxFailedAt].map(date => (
65
+ date && !(date instanceof Date) ? new Date(date) : date
66
+ ));
36
67
 
37
- const lastSeenEventTimestamp = _.max([maxDeliveredAt, maxOpenedAt, maxFailedAt]);
68
+ const lastSeenEventTimestamp = _.max([maxOpenedAt, maxDeliveredAt, maxFailedAt]);
38
69
  debug(`getLastSeenEventTimestamp: finished in ${Date.now() - startDate}ms`);
39
70
 
40
71
  return lastSeenEventTimestamp;
41
72
  },
42
73
 
74
+ /**
75
+ * Sets the timestamp of the last seen event for the specified email analytics events.
76
+ * @param {EmailAnalyticsJobName} jobName - The name of the job to update.
77
+ * @param {'completed'|'started'} field - The field to update.
78
+ * @param {Date} date - The timestamp of the last seen event.
79
+ * @returns {Promise<void>}
80
+ * @description
81
+ * Updates the `finished_at` or `started_at` column of the specified job in the `jobs` table with the provided timestamp.
82
+ * This is used to keep track of the last time the job was run to avoid expensive queries following reboot.
83
+ */
84
+ async setJobTimestamp(jobName, field, date) {
85
+ // Convert string dates to Date objects for SQLite compatibility
86
+ try {
87
+ debug(`Setting ${field} timestamp for job ${jobName} to ${date}`);
88
+ const updateField = field === 'completed' ? 'finished_at' : 'started_at';
89
+ const status = field === 'completed' ? 'finished' : 'started';
90
+ const result = await db.knex('jobs').update({[updateField]: date, updated_at: new Date(), status: status}).where('name', jobName);
91
+ if (result === 0) {
92
+ await db.knex('jobs').insert({
93
+ id: new ObjectID().toHexString(),
94
+ name: jobName,
95
+ [updateField]: date,
96
+ updated_at: date,
97
+ status: status
98
+ });
99
+ }
100
+ } catch (err) {
101
+ debug(`Error setting ${field} timestamp for job ${jobName}: ${err.message}`);
102
+ }
103
+ },
104
+
105
+ /**
106
+ * Sets the status of the specified email analytics job.
107
+ * @param {EmailAnalyticsJobName} jobName - The name of the job to update.
108
+ * @param {'started'|'finished'|'failed'} status - The new status of the job.
109
+ * @returns {Promise<void>}
110
+ * @description
111
+ * Updates the `status` column of the specified job in the `jobs` table with the provided status.
112
+ * This is used to keep track of the current state of the job.
113
+ */
114
+ async setJobStatus(jobName, status) {
115
+ debug(`Setting status for job ${jobName} to ${status}`);
116
+ try {
117
+ const result = await db.knex('jobs')
118
+ .update({
119
+ status: status,
120
+ updated_at: new Date()
121
+ })
122
+ .where('name', jobName);
123
+
124
+ if (result === 0) {
125
+ await db.knex('jobs').insert({
126
+ id: new ObjectID().toHexString(),
127
+ name: jobName,
128
+ status: status,
129
+ created_at: new Date(),
130
+ updated_at: new Date()
131
+ });
132
+ }
133
+ } catch (err) {
134
+ debug(`Error setting status for job ${jobName}: ${err.message}`);
135
+ throw err;
136
+ }
137
+ },
138
+
43
139
  async aggregateEmailStats(emailId) {
44
140
  const {totalCount} = await db.knex('emails').select(db.knex.raw('email_count as totalCount')).where('id', emailId).first() || {totalCount: 0};
45
141
  // use IS NULL here because that will typically match far fewer rows than IS NOT NULL making the query faster
@@ -78,4 +174,4 @@ module.exports = {
78
174
  .update(updateQuery)
79
175
  .where('id', memberId);
80
176
  }
81
- };
177
+ };
@@ -0,0 +1,62 @@
1
+ const crypto = require('crypto');
2
+
3
+ class RequestIntegrityTokenProvider {
4
+ #themeSecret;
5
+ #tokenDuration;
6
+
7
+ /**
8
+ * @param {object} options
9
+ * @param {string} options.themeSecret
10
+ * @param {number} options.tokenDuration - in milliseconds
11
+ */
12
+ constructor(options) {
13
+ this.#themeSecret = options.themeSecret;
14
+ this.#tokenDuration = options.tokenDuration;
15
+ }
16
+
17
+ /**
18
+ * @returns {string}
19
+ */
20
+ create() {
21
+ const currentTime = Date.now();
22
+ const expiryTime = currentTime + this.#tokenDuration;
23
+ const nonce = crypto.randomBytes(16).toString('hex');
24
+ const hmac = crypto.createHmac('sha256', this.#themeSecret);
25
+ hmac.update(`${expiryTime.toString()}:${nonce}`);
26
+ return `${expiryTime.toString()}:${nonce}:${hmac.digest('hex')}`;
27
+ }
28
+
29
+ /**
30
+ * @param {string} token
31
+ * @returns {boolean}
32
+ */
33
+ validate(token) {
34
+ const parts = token.split(':');
35
+ if (parts.length !== 3) {
36
+ // Invalid token string
37
+ return false;
38
+ }
39
+
40
+ const timestamp = parseInt(parts[0], 10);
41
+ const nonce = parts[1];
42
+ const hmacDigest = parts[2];
43
+
44
+ const hmac = crypto.createHmac('sha256', this.#themeSecret);
45
+ hmac.update(`${timestamp.toString()}:${nonce}`);
46
+ const expectedHmac = hmac.digest('hex');
47
+
48
+ if (expectedHmac !== hmacDigest) {
49
+ // HMAC mismatch
50
+ return false;
51
+ }
52
+
53
+ if (Date.now() > timestamp) {
54
+ // Token expired
55
+ return false;
56
+ }
57
+
58
+ return true;
59
+ }
60
+ }
61
+
62
+ module.exports = RequestIntegrityTokenProvider;
@@ -35,7 +35,7 @@ const getFreeTier = async function getFreeTier() {
35
35
  * @param {import('express').Request} req - The member object
36
36
  * @param {import('express').Response} res - The express response object to set the cookies on
37
37
  * @param {Object} freeTier - The free tier object
38
- * @returns
38
+ * @returns
39
39
  */
40
40
  const setAccessCookies = function setAccessCookies(member, req, res, freeTier) {
41
41
  if (!member) {
@@ -157,6 +157,44 @@ const getIdentityToken = async function getIdentityToken(req, res) {
157
157
  }
158
158
  };
159
159
 
160
+ const createIntegrityToken = async function createIntegrityToken(req, res) {
161
+ try {
162
+ const token = membersService.requestIntegrityTokenProvider.create();
163
+ res.writeHead(200);
164
+ res.end(token);
165
+ } catch (err) {
166
+ res.writeHead(204);
167
+ res.end();
168
+ }
169
+ };
170
+
171
+ const verifyIntegrityToken = async function verifyIntegrityToken(req, res, next) {
172
+ const shouldThrowForInvalidToken = config.get('verifyRequestIntegrity');
173
+ try {
174
+ const token = req.body.integrityToken;
175
+ if (!token) {
176
+ logging.warn('Request with missing integrity token.');
177
+ if (shouldThrowForInvalidToken) {
178
+ throw new errors.BadRequestError();
179
+ } else {
180
+ return next();
181
+ }
182
+ }
183
+ if (membersService.requestIntegrityTokenProvider.validate(token)) {
184
+ return next();
185
+ } else {
186
+ logging.warn('Request with invalid integrity token.');
187
+ if (shouldThrowForInvalidToken) {
188
+ throw new errors.BadRequestError();
189
+ } else {
190
+ return next();
191
+ }
192
+ }
193
+ } catch (err) {
194
+ next(err);
195
+ }
196
+ };
197
+
160
198
  const deleteSession = async function deleteSession(req, res) {
161
199
  try {
162
200
  await membersService.ssr.deleteSession(req, res);
@@ -397,5 +435,7 @@ module.exports = {
397
435
  updateMemberNewsletters,
398
436
  deleteSession,
399
437
  accessInfoSession,
400
- deleteSuppression
438
+ deleteSuppression,
439
+ createIntegrityToken,
440
+ verifyIntegrityToken
401
441
  };
@@ -19,6 +19,7 @@ const tiersService = require('../tiers');
19
19
  const VerificationTrigger = require('@tryghost/verification-trigger');
20
20
  const DatabaseInfo = require('@tryghost/database-info');
21
21
  const settingsHelpers = require('../settings-helpers');
22
+ const RequestIntegrityTokenProvider = require('./RequestIntegrityTokenProvider');
22
23
 
23
24
  const messages = {
24
25
  noLiveKeysInDevelopment: 'Cannot use live stripe keys in development. Please restart in production mode.',
@@ -193,6 +194,11 @@ module.exports = {
193
194
  ssr: null,
194
195
  verificationTrigger: null,
195
196
 
197
+ requestIntegrityTokenProvider: new RequestIntegrityTokenProvider({
198
+ themeSecret: settingsCache.get('theme_session_secret'),
199
+ tokenDuration: 1000 * 60 * 5
200
+ }),
201
+
196
202
  stripeConnect: require('./stripe-connect'),
197
203
 
198
204
  processImport: null,