ghost 6.54.1 → 6.56.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 (356) hide show
  1. package/components/tryghost-adapter-base-scheduling-0.2.0.tgz +0 -0
  2. package/components/tryghost-custom-field-types-0.0.0.tgz +0 -0
  3. package/components/tryghost-kg-default-nodes-2.1.4.tgz +0 -0
  4. package/components/tryghost-kg-default-transforms-1.3.3.tgz +0 -0
  5. package/components/tryghost-kg-html-to-lexical-1.3.3.tgz +0 -0
  6. package/components/tryghost-kg-lexical-html-renderer-1.4.3.tgz +0 -0
  7. package/components/tryghost-nql-string-0.0.0.tgz +0 -0
  8. package/core/boot.js +3 -6
  9. package/core/built/admin/assets/{Combination-CJG7Flp-.js → Combination-BCm9FL4o.js} +1 -1
  10. package/core/built/admin/assets/{PolarAngleAxis-BGjf7sBu.js → PolarAngleAxis-C4qtHy1B.js} +1 -1
  11. package/core/built/admin/assets/{RadialBarChart-zeFf9mBz.js → RadialBarChart-Bh09Stsj.js} +1 -1
  12. package/core/built/admin/assets/{account-migration-BklF2I_I.js → account-migration-D3O_yVck.js} +1 -1
  13. package/core/built/admin/assets/activitypub/{account-migration-CNovr6W-.js → account-migration-D8FbP-Z-.js} +7 -7
  14. package/core/built/admin/assets/activitypub/activitypub.js +6 -6
  15. package/core/built/admin/assets/activitypub/{avatar-B3DEDB1l.js → avatar-11EA1VSs.js} +2 -2
  16. package/core/built/admin/assets/activitypub/{avatar-flipboard-Cqrg0XMx.js → avatar-flipboard-jfbLG-xe.js} +2 -2
  17. package/core/built/admin/assets/activitypub/{bluesky-sharing-Ceslb1_M.js → bluesky-sharing-f1jTQe2P.js} +16 -16
  18. package/core/built/admin/assets/activitypub/{content-formatters-BpQARh28.js → content-formatters-nmTOPY9M.js} +3 -3
  19. package/core/built/admin/assets/activitypub/{copy-D9UWIAan.js → copy-BCwo9ep3.js} +2 -2
  20. package/core/built/admin/assets/activitypub/{dist-DHAjBcV3.js → dist-BEfX-0aW.js} +12 -12
  21. package/core/built/admin/assets/activitypub/{domain-C1qQvJ8C.js → domain-AaDlZlrA.js} +9 -9
  22. package/core/built/admin/assets/activitypub/{edit-profile-BcH-jEfM.js → edit-profile-DPi8Tl5M.js} +945 -945
  23. package/core/built/admin/assets/activitypub/{explore-AxwBh9dI.js → explore-CxTzjILQ.js} +8 -8
  24. package/core/built/admin/assets/activitypub/{feed-BaOlbh7a.js → feed-DPv5x2MX.js} +33 -33
  25. package/core/built/admin/assets/activitypub/{field-B7mYYQpN.js → field-DbgA8lat.js} +3 -3
  26. package/core/built/admin/assets/activitypub/{hash-BC9Q0N4c.js → hash-BWuZoYkA.js} +2 -2
  27. package/core/built/admin/assets/activitypub/{inbox-DQ4ZnC1m.js → inbox-35VDQ2K2.js} +2 -2
  28. package/core/built/admin/assets/activitypub/{inbox-vXez-5uJ.js → inbox-DSrF_i1O.js} +85 -85
  29. package/core/built/admin/assets/activitypub/{label-Chv5T8bw.js → label-CPjFBbo7.js} +3 -3
  30. package/core/built/admin/assets/activitypub/layout-CTgrezN7.js +1 -0
  31. package/core/built/admin/assets/activitypub/{moderation-DtSM2i1I.js → moderation-BkDC1XVZ.js} +11 -11
  32. package/core/built/admin/assets/activitypub/{note-Dckp4S-2.js → note-xEyH4xT-.js} +7 -7
  33. package/core/built/admin/assets/activitypub/{notifications-CG5T22vg.js → notifications-DClaL3ui.js} +9 -9
  34. package/core/built/admin/assets/activitypub/{preferences-D_799jdl.js → preferences-DpnOa-4Z.js} +20 -20
  35. package/core/built/admin/assets/activitypub/{profile-D2JfXBQl.js → profile-Dvi9mz-6.js} +237 -237
  36. package/core/built/admin/assets/activitypub/{reply-CpdMeAO5.js → reply-Cs9ySd6Z.js} +2 -2
  37. package/core/built/admin/assets/activitypub/{routes-CCHe2dQp.js → routes-DFBeTnfn.js} +1139 -1150
  38. package/core/built/admin/assets/activitypub/{separator-BI1FLsYM.js → separator-C5u_VjzY.js} +3 -3
  39. package/core/built/admin/assets/activitypub/{settings-kx-9Enkz.js → settings-CV2fFu6D.js} +13 -13
  40. package/core/built/admin/assets/activitypub/{site-x4_IjVJ0.js → site-_TgDd_EM.js} +81 -81
  41. package/core/built/admin/assets/activitypub/{step-1-DIDg4aHb.js → step-1-BfnNL2oF.js} +5 -5
  42. package/core/built/admin/assets/activitypub/{step-2-BfGe8BYt.js → step-2-DLndZkO-.js} +6 -6
  43. package/core/built/admin/assets/activitypub/{step-3-mE9spAqw.js → step-3-A-PZTtRE.js} +10 -10
  44. package/core/built/admin/assets/activitypub/{tabs-CyPRQQ1N.js → tabs-BUbz7tea.js} +321 -319
  45. package/core/built/admin/assets/activitypub/{topic-filter-DLHBZT93.js → topic-filter-BdX_xYYT.js} +3 -3
  46. package/core/built/admin/assets/activitypub/{use-activity-pub-queries-VzbN99u_.js → use-activity-pub-queries-EwuRemHh.js} +2 -2
  47. package/core/built/admin/assets/activitypub/{use-navigate-with-base-path-CAwXTmqH.js → use-navigate-with-base-path-cy4ACBc4.js} +92 -90
  48. package/core/built/admin/assets/activitypub/{use-reply-chain-data-BAB6fNGt.js → use-reply-chain-data-CyFcGfan.js} +5 -5
  49. package/core/built/admin/assets/activitypub/{x-C8tAnXly.js → x-7zk8mV2p.js} +2 -2
  50. package/core/built/admin/assets/audience-CkBb2LlO.js +1 -0
  51. package/core/built/admin/assets/{automated-emails-of1MvVsv.js → automated-emails-6t2bIRWf.js} +2 -2
  52. package/core/built/admin/assets/{automations-B7-y3dXt.js → automations-DhKeS_U5.js} +1 -1
  53. package/core/built/admin/assets/{automations-DRFjW5fw.js → automations-cMllU545.js} +1 -1
  54. package/core/built/admin/assets/{bluesky-sharing-BbbjWdzs.js → bluesky-sharing-DPGpyD-G.js} +1 -1
  55. package/core/built/admin/assets/{box-BWbhXh21.js → box-By_EgGtm.js} +1 -1
  56. package/core/built/admin/assets/{breadcrumb-B-M9Ip_2.js → breadcrumb-DIfcF5ow.js} +1 -1
  57. package/core/built/admin/assets/build-CsiH26uS.js +1 -0
  58. package/core/built/admin/assets/{card-DGoDJzrn.js → card-aUHX0hyR.js} +1 -1
  59. package/core/built/admin/assets/{chart-BuRT2Y6p.js → chart-BnXyfH11.js} +1 -1
  60. package/core/built/admin/assets/{chart-helpers-xiSsW8r0.js → chart-helpers-DV0FkdcD.js} +1 -1
  61. package/core/built/admin/assets/{checkbox-EhJKHFdZ.js → checkbox-BIl077a0.js} +1 -1
  62. package/core/built/admin/assets/{chunk.361.3cdb584b973959e52ccc.js → chunk.332.05991a101d67f732dec3.js} +16 -12
  63. package/core/built/admin/assets/chunk.524.87b52f4fada529df2c4c.js +25 -0
  64. package/core/built/admin/assets/chunk.582.340bcca873e806cbd4bb.js +27 -0
  65. package/core/built/admin/assets/{code-editor-view-CZtJFGhR.js → code-editor-view-C0aWNwdl.js} +1 -1
  66. package/core/built/admin/assets/comments-qwhIjH3c.js +1 -0
  67. package/core/built/admin/assets/{container-BYz2Ed39.js → container-LiZf3XdY.js} +1 -1
  68. package/core/built/admin/assets/{data-list-16rkJYYi.js → data-list-CupF7bjM.js} +1 -1
  69. package/core/built/admin/assets/{date-range-select-CTlyo8zW.js → date-range-select-dXc-GsVe.js} +1 -1
  70. package/core/built/admin/assets/{disabled-sources-indicator-qs2GfLvl.js → disabled-sources-indicator-DYAXW-6V.js} +1 -1
  71. package/core/built/admin/assets/dist-BUKmD51u.js +1 -0
  72. package/core/built/admin/assets/{dist-2Tnz2Yfq.js → dist-CNlMj7ML.js} +1 -1
  73. package/core/built/admin/assets/{dist-DGzPJ_bO.js → dist-Cb5RSHSg.js} +1 -1
  74. package/core/built/admin/assets/{dist-P_ohUlTi.js → dist-CqO2lWS0.js} +1 -1
  75. package/core/built/admin/assets/{domain-CPNXKVOV.js → domain-B5ogXUBa.js} +1 -1
  76. package/core/built/admin/assets/{dropzone-BmCXnw6J.js → dropzone-BnFG7dDP.js} +1 -1
  77. package/core/built/admin/assets/{edit-profile-SGrJve5M.js → edit-profile-B07jCZy9.js} +1 -1
  78. package/core/built/admin/assets/editor-DDYSOXGN.js +8 -0
  79. package/core/built/admin/assets/{empty-indicator-oX1u1Pkn.js → empty-indicator-WB1WgT9h.js} +1 -1
  80. package/core/built/admin/assets/{explore-CUYLv9G9.js → explore-Ca1y8HW_.js} +1 -1
  81. package/core/built/admin/assets/{feed-CPdJMttL.js → feed-x4iFMBP4.js} +1 -1
  82. package/core/built/admin/assets/field-ChCAabZg.js +1 -0
  83. package/core/built/admin/assets/filter-sources-LdtH1e-T.js +1 -0
  84. package/core/built/admin/assets/{filters-bXkkWro8.js → filters-CLehPo_D.js} +1 -1
  85. package/core/built/admin/assets/filters-CjVKRP8U.js +1 -0
  86. package/core/built/admin/assets/{ghost-05f9a7182ed56903dba05878a5b8422b.js → ghost-7f01131115186d1af84ead76e79646dd.js} +275 -255
  87. package/core/built/admin/assets/{ghost-3063eae29e6c9cc6d6e7131928a27da0.css → ghost-d38c30b866e59f2e06a357b5eb844fe5.css} +1 -1
  88. package/core/built/admin/assets/{ghost-dark-c8d0ab7f515b2010c173e23e9b32ea8a.css → ghost-dark-1bffd5099ba7a749303133efacf8480b.css} +1 -1
  89. package/core/built/admin/assets/gift-link-modal-Buqh2vXh.js +1 -0
  90. package/core/built/admin/assets/{gift-links-nBxJjZiQ.js → gift-links-CsUhefWG.js} +1 -1
  91. package/core/built/admin/assets/growth-DajOBdN0.js +1 -0
  92. package/core/built/admin/assets/growth-Dy2UOSay.js +3 -0
  93. package/core/built/admin/assets/{hooks-BApwwBE0.js → hooks-CixO01lL.js} +1 -1
  94. package/core/built/admin/assets/{inbox-Xx1CdnfO.js → inbox-BUOLDoji.js} +13 -13
  95. package/core/built/admin/assets/index-08n05-rc.js +67 -0
  96. package/core/built/admin/assets/index-DgUzgFvS.css +1 -0
  97. package/core/built/admin/assets/{inline-EMAm3okk.js → inline-Cw-gigDW.js} +1 -1
  98. package/core/built/admin/assets/input-group-uSK3XT7b.js +1 -0
  99. package/core/built/admin/assets/{input-surface-BpAjZXY5.js → input-surface-BdOe4KzO.js} +1 -1
  100. package/core/built/admin/assets/koenig-lexical/koenig-lexical.js +9132 -9087
  101. package/core/built/admin/assets/koenig-lexical/koenig-lexical.umd.js +121 -121
  102. package/core/built/admin/assets/koenig-lexical/style.css +1 -1
  103. package/core/built/admin/assets/{koenig-lexical-B61IALf5.js → koenig-lexical-BFc4yFed.js} +82 -82
  104. package/core/built/admin/assets/{kpi-card-DCdlyaSS.js → kpi-card-BN-E6WYX.js} +1 -1
  105. package/core/built/admin/assets/{kpi-card-BlZaG1Lo.js → kpi-card-CCt-qeWD.js} +1 -1
  106. package/core/built/admin/assets/{kpi-helpers-DpAuS1Nb.js → kpi-helpers-DlxTbTs4.js} +1 -1
  107. package/core/built/admin/assets/{kpi-tabs-Dme44bQS.js → kpi-tabs-BlcUttpn.js} +1 -1
  108. package/core/built/admin/assets/{label-dSs5aepE.js → label-AVrxwmsz.js} +1 -1
  109. package/core/built/admin/assets/label-picker-DVnpu_IF.js +1 -0
  110. package/core/built/admin/assets/labels-DAIqKed7.js +1 -0
  111. package/core/built/admin/assets/layout-B0YLh7R9.js +1 -0
  112. package/core/built/admin/assets/link-helpers-D9LsDOkG.js +1 -0
  113. package/core/built/admin/assets/{list-page-B6ANtzkd.js → list-page-CdsnQR9N.js} +1 -1
  114. package/core/built/admin/assets/member-custom-fields-C8NpZYza.js +1 -0
  115. package/core/built/admin/assets/member-detail-CIVAdKXf.js +1 -0
  116. package/core/built/admin/assets/members-oLPiSkXD.js +10 -0
  117. package/core/built/admin/assets/modals-CB7v-k3T.js +31 -0
  118. package/core/built/admin/assets/moderation-CQ3urTFE.js +1 -0
  119. package/core/built/admin/assets/newsletter-rbeDX9Wk.js +1 -0
  120. package/core/built/admin/assets/{newsletters-D1jbtV-h.js → newsletters-BXJbJgA-.js} +1 -1
  121. package/core/built/admin/assets/newsletters-vt3Ni1Ov.js +1 -0
  122. package/core/built/admin/assets/note-Dk877BTv.js +1 -0
  123. package/core/built/admin/assets/notifications-CPlLjPxO.js +1 -0
  124. package/core/built/admin/assets/{offers-CrY6nSRR.js → offers-DEniVRD5.js} +1 -1
  125. package/core/built/admin/assets/onboarding-route-DdStOtjy.js +1 -0
  126. package/core/built/admin/assets/overview--4V-kx9c.js +1 -0
  127. package/core/built/admin/assets/overview-DSLv30oz.js +1 -0
  128. package/core/built/admin/assets/{page-header-op0eN36I.js → page-header-pp4dWmOS.js} +1 -1
  129. package/core/built/admin/assets/{pages-BbNWFcGj.js → pages-B4goRPUn.js} +1 -1
  130. package/core/built/admin/assets/{post-analytics-WwRDEnrV.js → post-analytics-BMJrI81R.js} +1 -1
  131. package/core/built/admin/assets/post-analytics-header-pU2m9ZuV.js +1 -0
  132. package/core/built/admin/assets/post-analytics-provider-Btml5vJI.js +1 -0
  133. package/core/built/admin/assets/{post-share-modal-Dx-MEjhI.js → post-share-modal-AcUZkbfL.js} +1 -1
  134. package/core/built/admin/assets/{posts-EWpZIjkr.js → posts-DhXE7b6r.js} +1 -1
  135. package/core/built/admin/assets/preferences-Ru4CRPHC.js +5 -0
  136. package/core/built/admin/assets/profile-BNb1oj7D.js +1 -0
  137. package/core/built/admin/assets/{referrers-BCfFYSHc.js → referrers-pmJT4k3-.js} +1 -1
  138. package/core/built/admin/assets/{router-provider-C1R4AZ2o.js → router-provider-BmEZf6c6.js} +1 -1
  139. package/core/built/admin/assets/select-B0HteHrp.js +1 -0
  140. package/core/built/admin/assets/settings-B1C9Q3wo.js +4 -0
  141. package/core/built/admin/assets/{settings-H6HxFgfI.js → settings-BGgk7oCI.js} +1 -1
  142. package/core/built/admin/assets/{shade-app-B3heOAfz-CGXQwWeO.js → shade-app-B3heOAfz-BhQXxNkT.js} +1 -1
  143. package/core/built/admin/assets/{share-modal-BJCadJyp.js → share-modal-BeuydaBf.js} +1 -1
  144. package/core/built/admin/assets/social-urls-DA2lXrhW.js +8 -0
  145. package/core/built/admin/assets/{sort-button-DyYlfYp7.js → sort-button-Df4t2AL8.js} +1 -1
  146. package/core/built/admin/assets/{source-icon-_ubRDbg_.js → source-icon-ajoaKb41.js} +1 -1
  147. package/core/built/admin/assets/{stack-xiN0eQ6Z.js → stack-CIfOAAVf.js} +1 -1
  148. package/core/built/admin/assets/{stats-BQZMGMvn.js → stats-C_lfxZAw.js} +1 -1
  149. package/core/built/admin/assets/{stats-view-Doei-qCX.js → stats-view-Cxot1iD8.js} +1 -1
  150. package/core/built/admin/assets/{step-1-IKiLBPoC.js → step-1-ogAA4lDr.js} +1 -1
  151. package/core/built/admin/assets/{step-2-jJ41PsDN.js → step-2-C5Xini-7.js} +1 -1
  152. package/core/built/admin/assets/{step-3-CaqFAEqf.js → step-3-CZv1FV81.js} +2 -2
  153. package/core/built/admin/assets/{switch-t-WyENU2.js → switch-CyQcwMAv.js} +1 -1
  154. package/core/built/admin/assets/{table-DxUL78jU.js → table-DFj1wiYv.js} +1 -1
  155. package/core/built/admin/assets/tabs-D4y1gqUd.js +1 -0
  156. package/core/built/admin/assets/{tags-CNujGwkT.js → tags-Dmft-Y4O.js} +1 -1
  157. package/core/built/admin/assets/{text-DwtjwGkh.js → text-DU9Hv33_.js} +1 -1
  158. package/core/built/admin/assets/textarea-d9qEkMOP.js +1 -0
  159. package/core/built/admin/assets/{tiers-BgYLWRJv.js → tiers-DYsa4DCo.js} +1 -1
  160. package/core/built/admin/assets/{toggle-group-BeEpQ69p.js → toggle-group-R2QMFvXW.js} +1 -1
  161. package/core/built/admin/assets/{topic-filter-Bb5MOD-1.js → topic-filter-DGDxDalo.js} +1 -1
  162. package/core/built/admin/assets/{use-analytics-data-Cds6XDjg.js → use-analytics-data-C76v4LIp.js} +1 -1
  163. package/core/built/admin/assets/use-filter-params-WrdhAISv.js +1 -0
  164. package/core/built/admin/assets/{use-growth-stats-D8uf98-G.js → use-growth-stats-aNXJOmQr.js} +1 -1
  165. package/core/built/admin/assets/{use-post-referrers-CRtgyuXB.js → use-post-referrers-CG0SZioj.js} +1 -1
  166. package/core/built/admin/assets/{use-reply-chain-data-DbKYX50v.js → use-reply-chain-data-RLdPKRmS.js} +1 -1
  167. package/core/built/admin/assets/{use-simple-pagination-DC01kpo0.js → use-simple-pagination-DgcF69KI.js} +1 -1
  168. package/core/built/admin/assets/{use-tinybird-query-C7bQRJWw.js → use-tinybird-query-C1R_Kbmy.js} +1 -1
  169. package/core/built/admin/assets/{use-tinybird-token-Az4tam7_.js → use-tinybird-token-C9fPjVM_.js} +1 -1
  170. package/core/built/admin/assets/{users-CcSCQFcQ.js → users-Dkv8NQZe.js} +1 -1
  171. package/core/built/admin/assets/{virtual-list-Dhuh9fGx.js → virtual-list-BmTUzeoF.js} +1 -1
  172. package/core/built/admin/assets/web-BqW0b0_C.js +1 -0
  173. package/core/built/admin/assets/web-Cy5GKMWM.js +2 -0
  174. package/core/built/admin/index.html +18 -18
  175. package/core/frontend/services/data/fetch-data.js +10 -5
  176. package/core/frontend/services/proxy.js +7 -8
  177. package/core/frontend/services/routing/api-adapter.js +115 -0
  178. package/core/frontend/services/routing/api-adapter.ts +157 -0
  179. package/core/frontend/services/routing/collection-router.js +5 -3
  180. package/core/frontend/services/routing/controllers/index.js +1 -1
  181. package/core/frontend/services/routing/controllers/rss.js +26 -31
  182. package/core/frontend/services/routing/controllers/rss.ts +66 -0
  183. package/core/frontend/services/routing/controllers/static.js +8 -7
  184. package/core/frontend/services/routing/parent-router.js +17 -4
  185. package/core/frontend/services/routing/permalink-adapter.js +26 -0
  186. package/core/frontend/services/routing/permalink-adapter.ts +23 -0
  187. package/core/frontend/services/routing/registry.js +59 -33
  188. package/core/frontend/services/routing/router-manager.js +14 -18
  189. package/core/frontend/services/routing/static-routes-router.js +11 -13
  190. package/core/frontend/services/routing/taxonomy-router.js +6 -2
  191. package/core/server/api/endpoints/automation-action-links.js +68 -0
  192. package/core/server/api/endpoints/automation-action-links.ts +46 -0
  193. package/core/server/api/endpoints/index.js +4 -0
  194. package/core/server/api/endpoints/settings.js +0 -2
  195. package/core/server/api/endpoints/utils/serializers/output/utils/url.js +11 -1
  196. package/core/server/data/migrations/versions/6.55/2026-07-27-11-25-44-add-to-hash-to-redirects.js +51 -0
  197. package/core/server/data/migrations/versions/6.55/2026-07-28-12-08-00-remove-routes-hash-from-settings.js +5 -0
  198. package/core/server/data/migrations/versions/6.56/2026-08-03-19-27-51-add-automation-run-step-id-to-automated-email-recipients.js +8 -0
  199. package/core/server/data/migrations/versions/6.56/2026-08-04-14-42-39-restrict-notification-add-permission-to-administrators.js +12 -0
  200. package/core/server/data/schema/commands.js +5 -0
  201. package/core/server/data/schema/default-settings/default-settings.json +0 -4
  202. package/core/server/data/schema/fixtures/fixtures.json +2 -2
  203. package/core/server/data/schema/schema.js +6 -1
  204. package/core/server/models/post.js +10 -1
  205. package/core/server/services/automations/automations-api.js +15 -0
  206. package/core/server/services/automations/automations-api.ts +19 -0
  207. package/core/server/services/automations/automations-repository.ts +20 -0
  208. package/core/server/services/automations/database-automations-repository.js +132 -30
  209. package/core/server/services/automations/database-automations-repository.ts +141 -12
  210. package/core/server/services/automations/database-date.js +10 -0
  211. package/core/server/services/automations/database-date.ts +5 -0
  212. package/core/server/services/automations/get-scheduler-idempotency-key.js +13 -0
  213. package/core/server/services/automations/get-scheduler-idempotency-key.ts +8 -0
  214. package/core/server/services/automations/poll.js +7 -2
  215. package/core/server/services/automations/poll.ts +10 -2
  216. package/core/server/services/automations/scheduler-poll-time.js +40 -0
  217. package/core/server/services/automations/scheduler-poll-time.ts +38 -0
  218. package/core/server/services/automations/service.js +19 -3
  219. package/core/server/services/automations/service.ts +20 -3
  220. package/core/server/services/comments/comments-service-emails.js +10 -7
  221. package/core/server/services/comments/comments-service.js +9 -4
  222. package/core/server/services/email-rendering/partials/email-wrapper.hbs +3 -1
  223. package/core/server/services/email-service/email-service-wrapper.js +6 -1
  224. package/core/server/services/email-service/email-templates/partials/styles.hbs +8 -0
  225. package/core/server/services/email-service/newsletter-email-event-storage.js +41 -10
  226. package/core/server/services/gift-links/CONTEXT.md +9 -0
  227. package/core/server/services/gifts/CONTEXT.md +55 -0
  228. package/core/server/services/gifts/README.md +28 -0
  229. package/core/server/services/gifts/gift-bookshelf-repository.js +65 -45
  230. package/core/server/services/gifts/gift-bookshelf-repository.ts +203 -74
  231. package/core/server/services/gifts/gift-checkout-adapter.js +59 -0
  232. package/core/server/services/gifts/gift-checkout-offer.js +74 -0
  233. package/core/server/services/gifts/gift-checkout-offer.ts +127 -0
  234. package/core/server/services/gifts/gift-codec.js +63 -0
  235. package/core/server/services/gifts/gift-codec.ts +62 -0
  236. package/core/server/services/gifts/gift-controller.js +6 -31
  237. package/core/server/services/gifts/gift-controller.ts +6 -69
  238. package/core/server/services/gifts/gift-email-service.ts +3 -2
  239. package/core/server/services/gifts/gift-schema.js +34 -0
  240. package/core/server/services/gifts/gift-schema.ts +39 -0
  241. package/core/server/services/gifts/gift-service-wrapper.js +13 -11
  242. package/core/server/services/gifts/gift-service.js +265 -52
  243. package/core/server/services/gifts/gift-service.ts +431 -97
  244. package/core/server/services/gifts/gift.ts +16 -39
  245. package/core/server/services/lib/dynamic-redirect-manager.js +12 -3
  246. package/core/server/services/link-redirection/link-redirect-repository.js +41 -4
  247. package/core/server/services/link-redirection/link-redirect.js +2 -0
  248. package/core/server/services/link-redirection/link-redirect.ts +3 -1
  249. package/core/server/services/link-redirection/link-redirects-service.js +38 -2
  250. package/core/server/services/link-tracking/click-event.js +3 -0
  251. package/core/server/services/link-tracking/index.js +38 -6
  252. package/core/server/services/link-tracking/link-click-repository.js +35 -9
  253. package/core/server/services/link-tracking/link-click-tracking-service.js +54 -4
  254. package/core/server/services/member-attribution/outbound-link-tagger.js +3 -1
  255. package/core/server/services/member-welcome-emails/member-welcome-email-renderer.js +20 -4
  256. package/core/server/services/member-welcome-emails/service.js +15 -3
  257. package/core/server/services/members/api.js +1 -2
  258. package/core/server/services/members/import-export/csv/formula.js +16 -0
  259. package/core/server/services/members/import-export/csv/formula.ts +14 -0
  260. package/core/server/services/members/import-export/csv/index.js +3 -1
  261. package/core/server/services/members/import-export/csv/index.ts +1 -0
  262. package/core/server/services/members/import-export/import/completion-email.js +22 -3
  263. package/core/server/services/members/import-export/import/completion-email.ts +29 -7
  264. package/core/server/services/members/import-export/import/importer.js +25 -4
  265. package/core/server/services/members/import-export/import/importer.ts +44 -5
  266. package/core/server/services/members/import-export/index.js +18 -1
  267. package/core/server/services/members/import-export/index.ts +32 -3
  268. package/core/server/services/members/members-api/controllers/router-controller.js +38 -36
  269. package/core/server/services/members/members-api/members-api.js +13 -7
  270. package/core/server/services/members/members-api/repositories/event-repository.js +4 -94
  271. package/core/server/services/members/members-api/services/member-bread-service.js +3 -3
  272. package/core/server/services/members/members-api/services/payments-service.js +6 -68
  273. package/core/server/services/members/service.js +8 -1
  274. package/core/server/services/members-custom-fields/schema.js +8 -1
  275. package/core/server/services/members-custom-fields/schema.ts +8 -0
  276. package/core/server/services/members-custom-fields/values-service.js +136 -8
  277. package/core/server/services/members-custom-fields/values-service.ts +152 -10
  278. package/core/server/services/mentions/service.js +5 -1
  279. package/core/server/services/oembed/oembed-service.js +44 -3
  280. package/core/server/services/posts/posts-service.js +11 -0
  281. package/core/server/services/route-settings/dynamic-routing-service.js +21 -52
  282. package/core/server/services/route-settings/index.js +1 -16
  283. package/core/server/services/route-settings/route-settings-parser.js +25 -25
  284. package/core/server/services/route-settings/route-settings-parser.ts +27 -27
  285. package/core/server/services/route-settings/validation-errors.js +68 -4
  286. package/core/server/services/route-settings/validation-errors.ts +79 -4
  287. package/core/server/services/settings/settings-service.js +0 -18
  288. package/core/server/services/stats/posts-stats-service.js +27 -23
  289. package/core/server/services/stripe/services/webhook/charge-refunded-event-service.js +1 -1
  290. package/core/server/services/stripe/services/webhook/checkout-session-event-service.js +16 -4
  291. package/core/server/services/stripe/stripe-api.js +10 -0
  292. package/core/server/services/url/url-service-facade.js +1 -1
  293. package/core/server/services/url/url-service-facade.ts +8 -1
  294. package/core/server/web/api/endpoints/admin/routes.js +1 -0
  295. package/core/server/web/gift-preview/controller.js +10 -25
  296. package/core/shared/labs.js +2 -2
  297. package/package.json +8 -7
  298. package/pnpm-lock.yaml +28 -18
  299. package/pnpm-workspace.yaml +6 -4
  300. package/components/tryghost-kg-default-nodes-2.1.3.tgz +0 -0
  301. package/core/built/admin/assets/Growth-BTP1LthE.js +0 -3
  302. package/core/built/admin/assets/Newsletters-CSEX0myc.js +0 -1
  303. package/core/built/admin/assets/Overview-BjACEsJF.js +0 -1
  304. package/core/built/admin/assets/Web-CxxxjCcv.js +0 -2
  305. package/core/built/admin/assets/activitypub/layout-CziktJXo.js +0 -1
  306. package/core/built/admin/assets/audience-X_XsZivI.js +0 -1
  307. package/core/built/admin/assets/chunk.524.8365cd9280405c0f6216.js +0 -25
  308. package/core/built/admin/assets/chunk.582.61d078937e6102769f2c.js +0 -27
  309. package/core/built/admin/assets/comments-CvyXxOyN.js +0 -1
  310. package/core/built/admin/assets/constants-c-JNF9c2.js +0 -1
  311. package/core/built/admin/assets/dist-CjY-f5Z1.js +0 -1
  312. package/core/built/admin/assets/editor-BOG2YPUm.js +0 -8
  313. package/core/built/admin/assets/field-D0XISCK2.js +0 -1
  314. package/core/built/admin/assets/filter-sources-Bieps75g.js +0 -1
  315. package/core/built/admin/assets/filters-CuB-A7ma.js +0 -1
  316. package/core/built/admin/assets/gift-link-modal-su8MsRdg.js +0 -1
  317. package/core/built/admin/assets/growth-Ba6S5FQX.js +0 -1
  318. package/core/built/admin/assets/index-D8PbzuT9.css +0 -1
  319. package/core/built/admin/assets/index-ukW_0Uie.js +0 -67
  320. package/core/built/admin/assets/input-group-CGOXcy4l.js +0 -1
  321. package/core/built/admin/assets/label-picker-Uq91atex.js +0 -1
  322. package/core/built/admin/assets/labels-Cyayfd-e.js +0 -1
  323. package/core/built/admin/assets/layout-WUcz6tu1.js +0 -1
  324. package/core/built/admin/assets/link-helpers-tOlxmuH5.js +0 -1
  325. package/core/built/admin/assets/member-custom-fields-NCzrMuba.js +0 -1
  326. package/core/built/admin/assets/member-detail-DPWDtYnN.js +0 -1
  327. package/core/built/admin/assets/members-B9rO7qr4.js +0 -10
  328. package/core/built/admin/assets/modals-CxRaan1J.js +0 -31
  329. package/core/built/admin/assets/moderation-D8zon60E.js +0 -1
  330. package/core/built/admin/assets/multi-select-combobox-1wYm3nle.js +0 -1
  331. package/core/built/admin/assets/newsletter-DbYMRxRS.js +0 -1
  332. package/core/built/admin/assets/note-PiWPnnrh.js +0 -1
  333. package/core/built/admin/assets/notifications-DEgqqB9G.js +0 -1
  334. package/core/built/admin/assets/nql-BsgFMd93.js +0 -1
  335. package/core/built/admin/assets/onboarding-route-BzF6HeVn.js +0 -1
  336. package/core/built/admin/assets/overview-DixLeIqY.js +0 -1
  337. package/core/built/admin/assets/post-analytics-header-DsMoLx0R.js +0 -1
  338. package/core/built/admin/assets/post-analytics-provider-BJCPLMIb.js +0 -1
  339. package/core/built/admin/assets/preferences-D7-j6A4C.js +0 -5
  340. package/core/built/admin/assets/profile-Dl69QPp0.js +0 -1
  341. package/core/built/admin/assets/select-C1qDZnW4.js +0 -1
  342. package/core/built/admin/assets/settings-BbmC_Iqq.js +0 -4
  343. package/core/built/admin/assets/social-urls-BPL1nADp.js +0 -8
  344. package/core/built/admin/assets/tabs-BQcJQ1ke.js +0 -1
  345. package/core/built/admin/assets/textarea-Bjccvh_E.js +0 -1
  346. package/core/built/admin/assets/use-filter-params-DjXkhO54.js +0 -1
  347. package/core/built/admin/assets/web-LtHNiNBV.js +0 -1
  348. package/core/server/services/gifts/gift-repository.js +0 -2
  349. package/core/server/services/gifts/gift-repository.ts +0 -29
  350. package/core/server/services/route-settings/activation-bridge.js +0 -164
  351. package/core/server/services/route-settings/activation-bridge.ts +0 -210
  352. package/core/server/services/route-settings/default-settings-manager.js +0 -63
  353. package/core/server/services/route-settings/route-settings.js +0 -170
  354. package/core/server/services/route-settings/settings-loader.js +0 -60
  355. package/core/server/services/route-settings/settings-path-manager.js +0 -47
  356. package/core/server/services/route-settings/validate.js +0 -440
@@ -21,6 +21,21 @@ const MAX_KEY_LENGTH = 191;
21
21
  // validated by its own field type's schema, which isn't known until the key is
22
22
  // resolved to a definition.
23
23
  const ValuesInput = zod_1.z.record(zod_1.z.string().max(MAX_KEY_LENGTH), zod_1.z.unknown());
24
+ // JSON.parse as a total function. Anything unreadable — unparseable text, or the null
25
+ // a nullable column can always return — reads as `undefined`, which no value schema
26
+ // accepts. That way a corrupt blob is turned away by the same check that turns away a
27
+ // well-formed one of the wrong shape, instead of needing a branch of its own.
28
+ function decodeJson(raw) {
29
+ if (raw === null) {
30
+ return undefined;
31
+ }
32
+ try {
33
+ return JSON.parse(raw);
34
+ }
35
+ catch {
36
+ return undefined;
37
+ }
38
+ }
24
39
  /**
25
40
  * The value half of member custom fields: reading and writing what a member holds
26
41
  * for each defined field. It belongs to the member aggregate — a value is data
@@ -137,8 +152,9 @@ class CustomFieldValuesService {
137
152
  /**
138
153
  * Resolve input into the writes it implies, rejecting anything invalid, and
139
154
  * writing nothing. Returned so a caller can validate before it commits to a
140
- * change it would have to unwind (the member edit validates up front), then
141
- * apply the same plan without re-resolving or re-validating.
155
+ * change it would have to unwind (the member edit and the importer both validate
156
+ * up front, before opening a transaction), then apply the same plan without
157
+ * re-resolving or re-validating.
142
158
  */
143
159
  async planWrite(input) {
144
160
  const values = this.parseValues(input);
@@ -187,25 +203,129 @@ class CustomFieldValuesService {
187
203
  }
188
204
  return writes;
189
205
  }
206
+ /**
207
+ * @private
208
+ * The composite values this member already holds for the fields in a plan,
209
+ * keyed by field id. Composites only: a scalar has nothing to merge into.
210
+ *
211
+ * Read through the caller's executor, never the base connection. On a
212
+ * single-connection pool a read off `this.knex` inside an open transaction
213
+ * waits for the connection that transaction is holding, and hangs.
214
+ *
215
+ * A blob that won't decode is left out, so the write replaces it rather than
216
+ * merging into something unreadable.
217
+ */
218
+ async storedComposites(trx, memberId, writes) {
219
+ const composites = new Map();
220
+ // Merging is only meaningful for a value with parts, which is a fact about the
221
+ // type's shape rather than the column it lands in. Asking the storage type
222
+ // instead would sweep in the first json-backed type that isn't a record — a
223
+ // multi-select held as a list, say — and log a decode failure for every row of
224
+ // an import, over a value that reads back perfectly well.
225
+ const fieldIds = writes
226
+ .filter(({ field, value }) => value !== undefined && (0, custom_field_types_1.subFieldsOf)(field.type) !== null)
227
+ .map(({ field }) => field.id);
228
+ if (fieldIds.length === 0) {
229
+ return composites;
230
+ }
231
+ const rows = await trx(VALUES_TABLE)
232
+ .where('member_id', memberId)
233
+ .whereIn('custom_field_id', fieldIds)
234
+ .whereNotNull('value_json')
235
+ .select('custom_field_id', 'value_json')
236
+ // Merging reads a value and writes it back, so the row has to stay still in
237
+ // between: without the lock a write committing after this read is overwritten
238
+ // by the sub-fields it just replaced. knex omits this on SQLite, which needs
239
+ // no lock — its pool is a single connection, so writes never overlap.
240
+ .forUpdate();
241
+ for (const row of rows) {
242
+ // The blob is the edge here: the columns come from a query written just
243
+ // above, but whatever an earlier write left in value_json is unknown until
244
+ // it is read back. Parsing it is what narrows the type, and it rejects the
245
+ // array, null or bare number the column could equally be holding.
246
+ const stored = schema_1.StoredCompositeValue.safeParse(decodeJson(row.value_json));
247
+ if (stored.success) {
248
+ composites.set(row.custom_field_id, stored.data);
249
+ continue;
250
+ }
251
+ logging_1.default.warn(`Replacing an unreadable custom field value rather than merging into it (member '${memberId}', field '${row.custom_field_id}').`);
252
+ }
253
+ return composites;
254
+ }
255
+ /**
256
+ * @private
257
+ * The stored sub-fields with the incoming ones written over them, re-checked
258
+ * against the field type before it is handed back.
259
+ *
260
+ * The re-check is what makes this safe to write without a second thought. Both
261
+ * halves were valid when they were written, but "valid" is a moving target: the
262
+ * stored half may predate a change to the type, so it can carry a sub-field the
263
+ * type has since dropped or a bound it has since tightened. Parsing the result
264
+ * strips what no longer belongs instead of faithfully persisting it on every
265
+ * import from here on.
266
+ *
267
+ * A merged value that won't parse falls back to the incoming value alone rather
268
+ * than failing. The row is trying to write something valid, and refusing it over
269
+ * data that was already in the database punishes the wrong write.
270
+ */
271
+ mergeComposite(field, existing, incoming) {
272
+ const merged = custom_field_types_1.FIELD_TYPES[field.type].value.safeParse({ ...existing, ...incoming });
273
+ if (merged.success) {
274
+ return merged.data;
275
+ }
276
+ logging_1.default.warn(`Replacing a custom field value that no longer validates rather than merging into it (field '${field.key}').`);
277
+ return incoming;
278
+ }
190
279
  /**
191
280
  * Apply a plan from `planWrite`.
192
281
  *
193
282
  * Merge, not replace: only the fields in the plan are touched, so a caller
194
- * that doesn't know about a field can't erase it. The whole plan is applied in
195
- * one transaction, so a mid-batch failure rolls the batch back.
283
+ * that doesn't know about a field can't erase it.
284
+ *
285
+ * `mergeComposites` extends that same rule from a field to a composite's
286
+ * sub-fields, because a sub-field is a field: a row writes the sub-fields it
287
+ * fills and leaves the rest alone, exactly as a blank `name` column leaves a
288
+ * member's name alone. The CSV import needs it — without it a file naming one
289
+ * address column would replace the whole address, clearing five sub-fields it
290
+ * never mentioned. The API does not merge: a PUT sends a whole address, so
291
+ * what it sends is what should be stored.
292
+ *
293
+ * The merge is asked for here rather than at plan time, where the caller's intent
294
+ * is otherwise expressed, because it cannot happen before a transaction is open:
295
+ * it reads the stored value and writes it back, and the row lock that keeps those
296
+ * two steps together only exists inside the transaction. `planWrite` runs before
297
+ * one is opened -- the importer plans a row, then opens a transaction per member --
298
+ * so a merge decided there would have to read outside the lock it depends on.
299
+ *
300
+ * Always transactional, so a mid-batch failure rolls the batch back. Passed an
301
+ * executor it joins that transaction -- the importer passes its per-member one, so a
302
+ * failed value write takes the member with it; passed nothing it opens its own.
196
303
  */
197
- async applyWrite(memberId, writes) {
304
+ async applyWrite(memberId, writes, executor = this.knex, { mergeComposites = false } = {}) {
198
305
  if (writes.length === 0) {
199
306
  return;
200
307
  }
201
- await this.knex.transaction(async (trx) => {
308
+ const apply = async (trx) => {
309
+ const stored = mergeComposites
310
+ ? await this.storedComposites(trx, memberId, writes)
311
+ : new Map();
202
312
  for (const { field, value } of writes) {
203
313
  const target = { member_id: memberId, custom_field_id: field.id };
204
314
  if (value === undefined) {
205
315
  await trx(VALUES_TABLE).where(target).del();
206
316
  continue;
207
317
  }
208
- const valueColumns = (0, storage_1.storageColumnsFor)(field.type, value);
318
+ // Both sides of the merge go through the same shape check, so it joins
319
+ // two objects the type system knows about rather than two assertions.
320
+ // The plan already validated `value` against its field type, so for a
321
+ // composite this parse cannot fail; a scalar simply doesn't match, and
322
+ // falls through to being written as it stands.
323
+ const existing = stored.get(field.id);
324
+ const incoming = schema_1.StoredCompositeValue.safeParse(value);
325
+ const merged = existing && incoming.success
326
+ ? this.mergeComposite(field, existing, incoming.data)
327
+ : value;
328
+ const valueColumns = (0, storage_1.storageColumnsFor)(field.type, merged);
209
329
  await trx(VALUES_TABLE)
210
330
  .insert({ id: new bson_objectid_1.default().toHexString(), ...target, ...valueColumns, created_at: new Date() })
211
331
  // The unique index on (member_id, custom_field_id) is
@@ -213,7 +333,15 @@ class CustomFieldValuesService {
213
333
  .onConflict(['member_id', 'custom_field_id'])
214
334
  .merge({ ...valueColumns, updated_at: new Date() });
215
335
  }
216
- });
336
+ };
337
+ // isTransaction is knex's marker for a transactor: join an existing transaction
338
+ // rather than nesting a savepoint under it, otherwise open one.
339
+ if (executor.isTransaction) {
340
+ await apply(executor);
341
+ }
342
+ else {
343
+ await executor.transaction(apply);
344
+ }
217
345
  }
218
346
  }
219
347
  exports.CustomFieldValuesService = CustomFieldValuesService;
@@ -3,8 +3,8 @@ import errors from '@tryghost/errors';
3
3
  import logging from '@tryghost/logging';
4
4
  import type {Knex} from 'knex';
5
5
  import {z} from 'zod';
6
- import {FIELD_TYPES, type FieldType} from '@tryghost/custom-field-types';
7
- import {DbCustomFieldValueWithField, FIELD_STATUS} from './schema';
6
+ import {FIELD_TYPES, subFieldsOf, type FieldType} from '@tryghost/custom-field-types';
7
+ import {DbCustomFieldValueWithField, FIELD_STATUS, StoredCompositeValue} from './schema';
8
8
  import {activeFields} from './queries';
9
9
  import {storageCodecFor, storageColumnsFor} from './storage';
10
10
 
@@ -20,6 +20,21 @@ const MAX_KEY_LENGTH = 191;
20
20
  // resolved to a definition.
21
21
  const ValuesInput = z.record(z.string().max(MAX_KEY_LENGTH), z.unknown());
22
22
 
23
+ // JSON.parse as a total function. Anything unreadable — unparseable text, or the null
24
+ // a nullable column can always return — reads as `undefined`, which no value schema
25
+ // accepts. That way a corrupt blob is turned away by the same check that turns away a
26
+ // well-formed one of the wrong shape, instead of needing a branch of its own.
27
+ function decodeJson(raw: string | null): unknown {
28
+ if (raw === null) {
29
+ return undefined;
30
+ }
31
+ try {
32
+ return JSON.parse(raw);
33
+ } catch {
34
+ return undefined;
35
+ }
36
+ }
37
+
23
38
  // The field facts the value path needs: the id to write the FK, the key to match
24
39
  // input against, the name for error messages, and the type to pick the validator
25
40
  // and storage column. All columns on members_custom_fields, so a value operation
@@ -164,8 +179,9 @@ export class CustomFieldValuesService {
164
179
  /**
165
180
  * Resolve input into the writes it implies, rejecting anything invalid, and
166
181
  * writing nothing. Returned so a caller can validate before it commits to a
167
- * change it would have to unwind (the member edit validates up front), then
168
- * apply the same plan without re-resolving or re-validating.
182
+ * change it would have to unwind (the member edit and the importer both validate
183
+ * up front, before opening a transaction), then apply the same plan without
184
+ * re-resolving or re-validating.
169
185
  */
170
186
  async planWrite(input: unknown): Promise<PlannedWrite[]> {
171
187
  const values = this.parseValues(input);
@@ -221,19 +237,126 @@ export class CustomFieldValuesService {
221
237
  return writes;
222
238
  }
223
239
 
240
+ /**
241
+ * @private
242
+ * The composite values this member already holds for the fields in a plan,
243
+ * keyed by field id. Composites only: a scalar has nothing to merge into.
244
+ *
245
+ * Read through the caller's executor, never the base connection. On a
246
+ * single-connection pool a read off `this.knex` inside an open transaction
247
+ * waits for the connection that transaction is holding, and hangs.
248
+ *
249
+ * A blob that won't decode is left out, so the write replaces it rather than
250
+ * merging into something unreadable.
251
+ */
252
+ private async storedComposites(trx: Knex, memberId: string, writes: PlannedWrite[]): Promise<Map<string, Record<string, unknown>>> {
253
+ const composites = new Map<string, Record<string, unknown>>();
254
+ // Merging is only meaningful for a value with parts, which is a fact about the
255
+ // type's shape rather than the column it lands in. Asking the storage type
256
+ // instead would sweep in the first json-backed type that isn't a record — a
257
+ // multi-select held as a list, say — and log a decode failure for every row of
258
+ // an import, over a value that reads back perfectly well.
259
+ const fieldIds = writes
260
+ .filter(({field, value}) => value !== undefined && subFieldsOf(field.type) !== null)
261
+ .map(({field}) => field.id);
262
+
263
+ if (fieldIds.length === 0) {
264
+ return composites;
265
+ }
266
+
267
+ const rows = await trx(VALUES_TABLE)
268
+ .where('member_id', memberId)
269
+ .whereIn('custom_field_id', fieldIds)
270
+ .whereNotNull('value_json')
271
+ .select('custom_field_id', 'value_json')
272
+ // Merging reads a value and writes it back, so the row has to stay still in
273
+ // between: without the lock a write committing after this read is overwritten
274
+ // by the sub-fields it just replaced. knex omits this on SQLite, which needs
275
+ // no lock — its pool is a single connection, so writes never overlap.
276
+ .forUpdate();
277
+
278
+ for (const row of rows) {
279
+ // The blob is the edge here: the columns come from a query written just
280
+ // above, but whatever an earlier write left in value_json is unknown until
281
+ // it is read back. Parsing it is what narrows the type, and it rejects the
282
+ // array, null or bare number the column could equally be holding.
283
+ const stored = StoredCompositeValue.safeParse(decodeJson(row.value_json));
284
+ if (stored.success) {
285
+ composites.set(row.custom_field_id, stored.data);
286
+ continue;
287
+ }
288
+ logging.warn(`Replacing an unreadable custom field value rather than merging into it (member '${memberId}', field '${row.custom_field_id}').`);
289
+ }
290
+
291
+ return composites;
292
+ }
293
+
294
+ /**
295
+ * @private
296
+ * The stored sub-fields with the incoming ones written over them, re-checked
297
+ * against the field type before it is handed back.
298
+ *
299
+ * The re-check is what makes this safe to write without a second thought. Both
300
+ * halves were valid when they were written, but "valid" is a moving target: the
301
+ * stored half may predate a change to the type, so it can carry a sub-field the
302
+ * type has since dropped or a bound it has since tightened. Parsing the result
303
+ * strips what no longer belongs instead of faithfully persisting it on every
304
+ * import from here on.
305
+ *
306
+ * A merged value that won't parse falls back to the incoming value alone rather
307
+ * than failing. The row is trying to write something valid, and refusing it over
308
+ * data that was already in the database punishes the wrong write.
309
+ */
310
+ private mergeComposite(field: ActiveField, existing: Record<string, unknown>, incoming: Record<string, unknown>): unknown {
311
+ const merged = FIELD_TYPES[field.type].value.safeParse({...existing, ...incoming});
312
+ if (merged.success) {
313
+ return merged.data;
314
+ }
315
+
316
+ logging.warn(`Replacing a custom field value that no longer validates rather than merging into it (field '${field.key}').`);
317
+ return incoming;
318
+ }
319
+
224
320
  /**
225
321
  * Apply a plan from `planWrite`.
226
322
  *
227
323
  * Merge, not replace: only the fields in the plan are touched, so a caller
228
- * that doesn't know about a field can't erase it. The whole plan is applied in
229
- * one transaction, so a mid-batch failure rolls the batch back.
324
+ * that doesn't know about a field can't erase it.
325
+ *
326
+ * `mergeComposites` extends that same rule from a field to a composite's
327
+ * sub-fields, because a sub-field is a field: a row writes the sub-fields it
328
+ * fills and leaves the rest alone, exactly as a blank `name` column leaves a
329
+ * member's name alone. The CSV import needs it — without it a file naming one
330
+ * address column would replace the whole address, clearing five sub-fields it
331
+ * never mentioned. The API does not merge: a PUT sends a whole address, so
332
+ * what it sends is what should be stored.
333
+ *
334
+ * The merge is asked for here rather than at plan time, where the caller's intent
335
+ * is otherwise expressed, because it cannot happen before a transaction is open:
336
+ * it reads the stored value and writes it back, and the row lock that keeps those
337
+ * two steps together only exists inside the transaction. `planWrite` runs before
338
+ * one is opened -- the importer plans a row, then opens a transaction per member --
339
+ * so a merge decided there would have to read outside the lock it depends on.
340
+ *
341
+ * Always transactional, so a mid-batch failure rolls the batch back. Passed an
342
+ * executor it joins that transaction -- the importer passes its per-member one, so a
343
+ * failed value write takes the member with it; passed nothing it opens its own.
230
344
  */
231
- async applyWrite(memberId: string, writes: PlannedWrite[]): Promise<void> {
345
+ async applyWrite(
346
+ memberId: string,
347
+ writes: PlannedWrite[],
348
+ executor: Knex = this.knex,
349
+ {mergeComposites = false}: {mergeComposites?: boolean} = {}
350
+ ): Promise<void> {
232
351
  if (writes.length === 0) {
233
352
  return;
234
353
  }
235
354
 
236
- await this.knex.transaction(async (trx) => {
355
+ const apply = async (trx: Knex) => {
356
+ const stored = mergeComposites
357
+ ? await this.storedComposites(trx, memberId, writes)
358
+ : new Map<string, Record<string, unknown>>();
359
+
237
360
  for (const {field, value} of writes) {
238
361
  const target = {member_id: memberId, custom_field_id: field.id};
239
362
 
@@ -242,7 +365,18 @@ export class CustomFieldValuesService {
242
365
  continue;
243
366
  }
244
367
 
245
- const valueColumns = storageColumnsFor(field.type, value);
368
+ // Both sides of the merge go through the same shape check, so it joins
369
+ // two objects the type system knows about rather than two assertions.
370
+ // The plan already validated `value` against its field type, so for a
371
+ // composite this parse cannot fail; a scalar simply doesn't match, and
372
+ // falls through to being written as it stands.
373
+ const existing = stored.get(field.id);
374
+ const incoming = StoredCompositeValue.safeParse(value);
375
+ const merged = existing && incoming.success
376
+ ? this.mergeComposite(field, existing, incoming.data)
377
+ : value;
378
+
379
+ const valueColumns = storageColumnsFor(field.type, merged);
246
380
  await trx(VALUES_TABLE)
247
381
  .insert({id: new ObjectID().toHexString(), ...target, ...valueColumns, created_at: new Date()})
248
382
  // The unique index on (member_id, custom_field_id) is
@@ -250,6 +384,14 @@ export class CustomFieldValuesService {
250
384
  .onConflict(['member_id', 'custom_field_id'])
251
385
  .merge({...valueColumns, updated_at: new Date()});
252
386
  }
253
- });
387
+ };
388
+
389
+ // isTransaction is knex's marker for a transactor: join an existing transaction
390
+ // rather than nesting a savepoint under it, otherwise open one.
391
+ if (executor.isTransaction) {
392
+ await apply(executor);
393
+ } else {
394
+ await executor.transaction(apply);
395
+ }
254
396
  }
255
397
  }
@@ -29,7 +29,11 @@ async function getPostData(post) {
29
29
 
30
30
  function getPostUrl(id, postData) {
31
31
  const jsonModel = {...postData};
32
- outputSerializerUrlUtil.forPost(id, jsonModel, {options: {}});
32
+ // The URL service routes by resource type. Pages and posts share the Post
33
+ // model, so the page's own type must reach forPost — otherwise it defaults
34
+ // to 'posts', matches no post collection, and 404s under the lazy service.
35
+ const type = postData.type === 'page' ? 'pages' : 'posts';
36
+ outputSerializerUrlUtil.forPost(id, jsonModel, {options: {}}, type);
33
37
  return jsonModel.url;
34
38
  }
35
39
 
@@ -9,6 +9,7 @@ const charset = require('charset');
9
9
  const iconv = require('iconv-lite');
10
10
  const path = require('path');
11
11
  const crypto = require('crypto');
12
+ const imageTransform = require('@tryghost/image-transform');
12
13
 
13
14
  // Some sites block non-standard user agents so we need to mimic a typical browser
14
15
  // Note: the Ghost/5.0 string _may_ be in use by 3rd parties so use caution when updating across majors
@@ -39,7 +40,26 @@ const messages = {
39
40
  insufficientMetadata: 'URL contains insufficient metadata.',
40
41
  unknownProvider: 'No provider found for supplied URL.',
41
42
  unableToFetchOembed: 'Unable to fetch requested embed.',
42
- unauthorized: 'URL contains a private resource.'
43
+ unauthorized: 'URL contains a private resource.',
44
+ unconvertibleSvg: 'SVG image is too large or compressed to convert.'
45
+ };
46
+
47
+ const SVG_RASTER_SIZE = 256;
48
+ const SVG_RASTER_TIMEOUT_SECONDS = 10;
49
+ const MAX_SVG_BYTES = 32 * 1024;
50
+ const GZIP_MAGIC = [0x1f, 0x8b];
51
+
52
+ const SVG_EXTENSIONS = new Set(['.svg', '.svgz']);
53
+ const SVG_SNIFF_BYTES = 1024;
54
+
55
+ const shouldRasterize = (buffer, ext) => {
56
+ if (SVG_EXTENSIONS.has(ext.toLowerCase())) {
57
+ return true;
58
+ }
59
+
60
+ const head = buffer.subarray(0, SVG_SNIFF_BYTES).toString('utf8').trimStart();
61
+
62
+ return head.startsWith('<') && /<svg[\s:>]/i.test(head);
43
63
  };
44
64
 
45
65
  /**
@@ -175,14 +195,35 @@ class OEmbedService {
175
195
  }
176
196
 
177
197
  // Fetch image buffer from the URL
178
- const imageBuffer = await this.fetchImageBuffer(imageUrl);
198
+ let imageBuffer = await this.fetchImageBuffer(imageUrl);
179
199
 
180
200
  // Extract file name from URL
181
201
  const fileName = path.basename(new URL(imageUrl).pathname);
182
- const ext = path.extname(fileName);
202
+ let ext = path.extname(fileName);
183
203
  const baseName = ext ? path.basename(fileName, ext) : fileName;
184
204
  const name = this.imageStore.getSanitizedFileName(baseName);
185
205
 
206
+ if (shouldRasterize(imageBuffer, ext)) {
207
+ if (imageBuffer.length > MAX_SVG_BYTES || GZIP_MAGIC.every((byte, index) => imageBuffer[index] === byte)) {
208
+ throw new errors.ValidationError({
209
+ message: tpl(messages.unconvertibleSvg),
210
+ context: imageUrl
211
+ });
212
+ }
213
+
214
+ imageBuffer = await imageTransform.resizeFromBuffer(imageBuffer, {
215
+ // without `format` this returns the original bytes whenever they
216
+ // are smaller, storing the SVG under a .png name
217
+ format: 'png',
218
+ width: SVG_RASTER_SIZE,
219
+ height: SVG_RASTER_SIZE,
220
+ withoutEnlargement: false,
221
+ timeout: SVG_RASTER_TIMEOUT_SECONDS
222
+ });
223
+
224
+ ext = '.png';
225
+ }
226
+
186
227
  const uniqueFileName = `${name}-${crypto.randomUUID()}${ext}`;
187
228
  const targetPath = path.join(imageType, uniqueFileName);
188
229
 
@@ -312,6 +312,17 @@ class PostsService {
312
312
  });
313
313
  }
314
314
 
315
+ // The `comments.in_reply_to_id` references form chains between a post's
316
+ // comments, which MySQL cannot resolve while cascade-deleting them
317
+ // alongside `comments.parent_id`. Clear the references first so the
318
+ // `comments.post_id` cascade delete can do its job
319
+ await this.models.Post.bulkEdit(deleteIds, 'comments', {
320
+ data: {in_reply_to_id: null},
321
+ column: 'post_id',
322
+ transacting: options.transacting,
323
+ throwErrors: true
324
+ });
325
+
315
326
  // Posts and emails
316
327
  await this.models.Post.bulkDestroy(deleteEmailIds, 'emails', {transacting: options.transacting, throwErrors: true});
317
328
  const result = await this.models.Post.bulkDestroy(deleteIds, 'posts', {...options, throwErrors: true});
@@ -1,5 +1,3 @@
1
- const crypto = require('crypto');
2
- const fs = require('fs-extra');
3
1
  const debug = require('@tryghost/debug')('services:route-settings:service');
4
2
  const logging = require('@tryghost/logging');
5
3
  const errors = require('@tryghost/errors');
@@ -9,12 +7,6 @@ const messages = {
9
7
  loadError: 'Could not load routes.yaml file.'
10
8
  };
11
9
 
12
- /**
13
- * md5 of the expanded default route settings — the routes_hash value for a
14
- * site that has never customised its routes.
15
- */
16
- const DEFAULT_ROUTES_SETTING_HASH = '3d180d52c663d173a6be791ef411ed01';
17
-
18
10
  function isStoredContentError(err) {
19
11
  return err.errorType === 'ValidationError' || err.errorType === 'IncorrectUsageError';
20
12
  }
@@ -27,26 +19,21 @@ class DynamicRoutingService {
27
19
  constructor() {
28
20
  /** @type {RouteSettingsStore} */
29
21
  this.store = null;
30
- this.settingsLoader = null;
31
22
  this.routerManager = null;
32
23
  this.urlService = null;
33
24
  }
34
25
 
35
26
  /**
36
- * Wire the storage-layer dependencies so the API surface (upload, download,
37
- * getCurrentHash) works immediately after boot — even when the frontend is
38
- * disabled and `start()` is never called.
27
+ * Wire the storage-layer dependency so the API surface (upload, download)
28
+ * works immediately after boot — even when the frontend is disabled and
29
+ * `start()` is never called.
39
30
  *
40
31
  * @param {object} deps
41
32
  * @param {RouteSettingsStore} deps.store - adapter-manager provided store
42
- * @param {object} [deps.settingsLoader] - legacy SettingsLoader used as a
43
- * read-path fallback when the stricter store parser rejects a file
44
- * (see loadRouteSettings)
45
33
  */
46
- configure({store, settingsLoader}) {
34
+ configure({store}) {
47
35
  debug('configure');
48
36
  this.store = store;
49
- this.settingsLoader = settingsLoader;
50
37
  }
51
38
 
52
39
  /**
@@ -67,49 +54,31 @@ class DynamicRoutingService {
67
54
  }
68
55
 
69
56
  async loadRouteSettings() {
70
- const {expandRouteSettings} = require('./activation-bridge');
71
-
72
57
  try {
73
- return expandRouteSettings(await this.store.get());
58
+ return await this.store.get();
74
59
  } catch (err) {
75
- if (!this.settingsLoader || err.errorType !== 'ValidationError') {
76
- throw err;
60
+ // A stored-content error means the site's routes.yaml is invalid —
61
+ // either it fails validation or it isn't parseable YAML. Log a
62
+ // targeted error so the failure is easy to spot in the logs, then
63
+ // rethrow so the caller surfaces the genuine error rather than
64
+ // silently degrading.
65
+ if (isStoredContentError(err)) {
66
+ logging.error(new errors.InternalServerError({
67
+ message: 'Route settings could not be loaded because the routes.yaml file is invalid. Please fix the file.',
68
+ code: 'ROUTE_SETTINGS_VALIDATION_ERROR',
69
+ err,
70
+ errorDetails: {reason: err.message}
71
+ }));
77
72
  }
78
- logging.error(new errors.InternalServerError({
79
- message: 'Route settings failed the stricter validation and were loaded with the legacy validator instead. Please review the routes.yaml file.',
80
- code: 'ROUTE_SETTINGS_VALIDATION_FALLBACK',
81
- err,
82
- errorDetails: {reason: err.message}
83
- }));
84
-
85
- return this.settingsLoader.loadSettings();
86
- }
87
- }
88
-
89
- getDefaultHash() {
90
- return DEFAULT_ROUTES_SETTING_HASH;
91
- }
92
73
 
93
- async getCurrentHash() {
94
- const expanded = await this.loadRouteSettings();
95
-
96
- return crypto.createHash('md5')
97
- .update(JSON.stringify(expanded), 'binary')
98
- .digest('hex');
74
+ throw err;
75
+ }
99
76
  }
100
77
 
101
78
  async download() {
102
- try {
103
- const settings = await this.store.get();
104
-
105
- return settings.yamlSource;
106
- } catch (err) {
107
- if (!this.settingsLoader || !isStoredContentError(err)) {
108
- throw err;
109
- }
79
+ const settings = await this.store.get();
110
80
 
111
- return fs.readFile(this.settingsLoader.settingFilePath, 'utf8');
112
- }
81
+ return settings.yamlSource;
113
82
  }
114
83
 
115
84
  async upload(yamlContent) {
@@ -1,5 +1,3 @@
1
- const config = require('../../../shared/config');
2
- const parseYaml = require('./yaml-parser');
3
1
  const DynamicRoutingService = require('./dynamic-routing-service');
4
2
 
5
3
  const service = new DynamicRoutingService();
@@ -7,15 +5,9 @@ const service = new DynamicRoutingService();
7
5
  module.exports = {
8
6
  init: async () => {
9
7
  const adapterManager = require('../adapter-manager').default;
10
- const SettingsLoader = require('./settings-loader');
11
- const SettingsPathManager = require('./settings-path-manager');
12
-
13
- const settingsPathManager = new SettingsPathManager({type: 'routes', paths: [config.getContentPath('settings')]});
14
- const settingsLoader = new SettingsLoader({parseYaml, settingFilePath: settingsPathManager.getDefaultFilePath()});
15
8
 
16
9
  service.configure({
17
- store: adapterManager.getAdapter('route-settings'),
18
- settingsLoader
10
+ store: adapterManager.getAdapter('route-settings')
19
11
  });
20
12
  },
21
13
 
@@ -27,10 +19,6 @@ module.exports = {
27
19
  return service.loadRouteSettings.bind(service);
28
20
  },
29
21
 
30
- get getDefaultHash() {
31
- return service.getDefaultHash.bind(service);
32
- },
33
-
34
22
  /**
35
23
  * Methods backing the Admin API settings endpoint — delegate to the
36
24
  * service instance so the endpoint stays decoupled from service wiring.
@@ -41,9 +29,6 @@ module.exports = {
41
29
  },
42
30
  get download() {
43
31
  return service.download.bind(service);
44
- },
45
- get getCurrentHash() {
46
- return service.getCurrentHash.bind(service);
47
32
  }
48
33
  }
49
34
  };