ghost 6.54.0 → 6.55.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.
- package/components/ghost-storage-base-3.0.0.tgz +0 -0
- package/components/tryghost-adapter-base-cache-0.2.0.tgz +0 -0
- package/components/tryghost-adapter-base-scheduling-0.2.0.tgz +0 -0
- package/components/tryghost-adapter-base-sso-0.1.1.tgz +0 -0
- package/components/tryghost-custom-field-types-0.0.0.tgz +0 -0
- package/components/tryghost-kg-default-cards-10.3.3.tgz +0 -0
- package/core/boot.js +3 -6
- package/core/built/admin/assets/{account-migration-DLvVA5yq.js → account-migration-sjHLx5dy.js} +1 -1
- package/core/built/admin/assets/activitypub/{account-migration-DHbZcxGM.js → account-migration-DoYXtnZ8.js} +4 -4
- package/core/built/admin/assets/activitypub/activitypub.js +2 -2
- package/core/built/admin/assets/activitypub/{bluesky-sharing-zfxUey_L.js → bluesky-sharing-Bxeh6MKW.js} +4 -4
- package/core/built/admin/assets/activitypub/{dist-BqkDUQxJ2.js → dist-ByFmDSNP2.js} +64 -63
- package/core/built/admin/assets/activitypub/{dist-Ba_QxHHl.js → dist-D8sFfdpC.js} +2 -2
- package/core/built/admin/assets/activitypub/{domain-CmwSZLHK.js → domain-CWueFW6q.js} +5 -5
- package/core/built/admin/assets/activitypub/{edit-profile-CXdwNmVG.js → edit-profile-BGxPU7N5.js} +3 -3
- package/core/built/admin/assets/activitypub/{explore-DfbjzGM1.js → explore-DBK-u0Ch.js} +3 -3
- package/core/built/admin/assets/activitypub/{feed-DSEBJOqf.js → feed-Bcir5RQZ.js} +3 -3
- package/core/built/admin/assets/activitypub/{field-CY7XVcx6.js → field-BV-CIT3d.js} +2 -2
- package/core/built/admin/assets/activitypub/{inbox-DtQ0kytT.js → inbox-yC0nfvzl.js} +4 -4
- package/core/built/admin/assets/activitypub/{label-Dw96GQ7u.js → label-DTRS9TDk.js} +2 -2
- package/core/built/admin/assets/activitypub/layout-qx-bUL1k.js +1 -0
- package/core/built/admin/assets/activitypub/{moderation-Dg_-I-8C.js → moderation-Dwl9t0pb.js} +4 -4
- package/core/built/admin/assets/activitypub/{note-Cy1-uKMY.js → note-B-zKsZ7r.js} +3 -3
- package/core/built/admin/assets/activitypub/{notifications-B_fvoLIM.js → notifications-DjJZD54t.js} +3 -3
- package/core/built/admin/assets/activitypub/{preferences-ByJseZp6.js → preferences-CoU8vs6d.js} +6 -6
- package/core/built/admin/assets/activitypub/{profile-BFtbnIjz.js → profile-BAsaJekx.js} +6 -6
- package/core/built/admin/assets/activitypub/{routes-Dh3xXfce.js → routes-CLH3OVTP.js} +28 -25
- package/core/built/admin/assets/activitypub/{settings-DTgqJPxM.js → settings-DA6pNJ-Q.js} +3 -3
- package/core/built/admin/assets/activitypub/{site-CdL9ZLqm.js → site-CQD42Pw5.js} +2 -2
- package/core/built/admin/assets/activitypub/{tabs-ogqwoBlb.js → tabs-Cg0saow1.js} +7 -7
- package/core/built/admin/assets/activitypub/{use-reply-chain-data-CxonteFD.js → use-reply-chain-data-Br4aOWOh.js} +2 -2
- package/core/built/admin/assets/audience-DjEWNhp3.js +1 -0
- package/core/built/admin/assets/{automated-emails-Cn87L5Md.js → automated-emails-DRy7qY-J.js} +2 -2
- package/core/built/admin/assets/{automations-BP_aHamJ.js → automations-Bx3VcFIL.js} +1 -1
- package/core/built/admin/assets/{automations-B7-y3dXt.js → automations-DhKeS_U5.js} +1 -1
- package/core/built/admin/assets/{bluesky-sharing-Bh36XPh4.js → bluesky-sharing-ClMd0-Ft.js} +1 -1
- package/core/built/admin/assets/{breadcrumb-D34YWnaw.js → breadcrumb-BD0U2ygg.js} +1 -1
- package/core/built/admin/assets/{chart-helpers-DbklaJw9.js → chart-helpers-fjQKRVyY.js} +1 -1
- package/core/built/admin/assets/checkbox-CWa28lgO.js +1 -0
- package/core/built/admin/assets/{chunk.962.b73442b07be930bb5d85.js → chunk.211.6f7328d63c41536451b3.js} +737 -559
- package/core/built/admin/assets/{chunk.962.b73442b07be930bb5d85.js.LICENSE.txt → chunk.211.6f7328d63c41536451b3.js.LICENSE.txt} +2 -0
- package/core/built/admin/assets/{chunk.589.096f4d0bb775e5229aa9.js → chunk.481.6c7395a49484bb4583aa.js} +41 -38
- package/core/built/admin/assets/chunk.524.cffd399783a519e404a9.js +25 -0
- package/core/built/admin/assets/chunk.582.eebf0d0b2d202bb746a5.js +27 -0
- package/core/built/admin/assets/code-editor-view-CCr0rBWS.js +1 -0
- package/core/built/admin/assets/comments-DyUSMUdj.js +1 -0
- package/core/built/admin/assets/{date-range-select-B5vSsrpx.js → date-range-select-BFp58EMk.js} +1 -1
- package/core/built/admin/assets/{disabled-sources-indicator-NkOIoMue.js → disabled-sources-indicator-CUxMfiPa.js} +1 -1
- package/core/built/admin/assets/dist-2Tnz2Yfq.js +1 -0
- package/core/built/admin/assets/dist-CjY-f5Z1.js +1 -0
- package/core/built/admin/assets/dist-DIC8yz5C.js +1 -0
- package/core/built/admin/assets/domain-C0bV2UV8.js +1 -0
- package/core/built/admin/assets/dropzone-DlK-K2DQ.js +5 -0
- package/core/built/admin/assets/{edit-profile-rvDGrS8J.js → edit-profile-BKT-REJw.js} +1 -1
- package/core/built/admin/assets/editor-Dp865O-4.js +8 -0
- package/core/built/admin/assets/{explore-BZAZOg5X.js → explore-ClUpJZOp.js} +1 -1
- package/core/built/admin/assets/{feed-CFV8xDw9.js → feed-DF92dUMP.js} +1 -1
- package/core/built/admin/assets/field-Do7lieBA.js +1 -0
- package/core/built/admin/assets/{filter-sources-Fx8wiYYw.js → filter-sources-BV2rnpfA.js} +1 -1
- package/core/built/admin/assets/filters-CUXJ2Nm9.js +1 -0
- package/core/built/admin/assets/{filters-CGX_pf2N.js → filters-DfbIWc_l.js} +1 -1
- package/core/built/admin/assets/{ghost-3063eae29e6c9cc6d6e7131928a27da0.css → ghost-a9c788f6f0b1ea6cf0f14ca4b13f4496.css} +1 -1
- package/core/built/admin/assets/{ghost-b1dce91ad3232cc259ee5fbbf9526243.js → ghost-c4057474943effc568c5659ccc4909d7.js} +407 -406
- package/core/built/admin/assets/{ghost-dark-c8d0ab7f515b2010c173e23e9b32ea8a.css → ghost-dark-db36d795d21c61426837df40af270966.css} +1 -1
- package/core/built/admin/assets/gift-link-modal-f9VIMNDv.js +1 -0
- package/core/built/admin/assets/{gift-links-nBxJjZiQ.js → gift-links-CsUhefWG.js} +1 -1
- package/core/built/admin/assets/growth-B3I6gNLm.js +1 -0
- package/core/built/admin/assets/{Growth-CSaHoSVD.js → growth-CVw1My4S.js} +2 -2
- package/core/built/admin/assets/{hooks-BApwwBE0.js → hooks-CixO01lL.js} +1 -1
- package/core/built/admin/assets/{inbox-BRp1_aaC.js → inbox-CFBSu4Ed.js} +12 -12
- package/core/built/admin/assets/index-9Cw8z3rA.css +1 -0
- package/core/built/admin/assets/index-BGG7NckO.js +67 -0
- package/core/built/admin/assets/input-group-BSgG_lVw.js +1 -0
- package/core/built/admin/assets/input-surface-BpAjZXY5.js +1 -0
- package/core/built/admin/assets/koenig-lexical/koenig-lexical.js +974 -937
- package/core/built/admin/assets/koenig-lexical/koenig-lexical.umd.js +28 -28
- package/core/built/admin/assets/koenig-lexical/style.css +1 -1
- package/core/built/admin/assets/{koenig-lexical-CM7UFNdL.js → koenig-lexical-f8vHRYvz.js} +28 -28
- package/core/built/admin/assets/{kpi-card-BrGH6FsT.js → kpi-card-DSap9rAy.js} +1 -1
- package/core/built/admin/assets/{kpi-card-CIGyijjx.js → kpi-card-DU0rn9Cj.js} +1 -1
- package/core/built/admin/assets/{kpi-helpers-_HRRsklE.js → kpi-helpers-Dz33AV6h.js} +1 -1
- package/core/built/admin/assets/{kpi-tabs-BSvh9eoq.js → kpi-tabs-BtTTrKvJ.js} +1 -1
- package/core/built/admin/assets/{label-BOR9XdKl.js → label-DS8Mi6G5.js} +1 -1
- package/core/built/admin/assets/label-picker-1DvSuLA1.js +1 -0
- package/core/built/admin/assets/labels-CDSUt4J_.js +1 -0
- package/core/built/admin/assets/layout-yQEi2qDM.js +1 -0
- package/core/built/admin/assets/{link-helpers-tOlxmuH5.js → link-helpers-7EYqvvHs.js} +1 -1
- package/core/built/admin/assets/member-custom-fields-yOMHyAg9.js +1 -0
- package/core/built/admin/assets/member-detail-BAq7gDaa.js +1 -0
- package/core/built/admin/assets/members-CQpVFJu4.js +10 -0
- package/core/built/admin/assets/modals-ReORvWnu.js +31 -0
- package/core/built/admin/assets/moderation-DySbgyUS.js +1 -0
- package/core/built/admin/assets/newsletter-CJQvtHqN.js +1 -0
- package/core/built/admin/assets/{Newsletters-C5cs6FGo.js → newsletters-B3QQXfGT.js} +1 -1
- package/core/built/admin/assets/{newsletters-D1jbtV-h.js → newsletters-BXJbJgA-.js} +1 -1
- package/core/built/admin/assets/note-C8kxrz6w.js +1 -0
- package/core/built/admin/assets/notifications-CgtSfX7t.js +1 -0
- package/core/built/admin/assets/{offers-CrY6nSRR.js → offers-DEniVRD5.js} +1 -1
- package/core/built/admin/assets/onboarding-route-4qtziA9d.js +1 -0
- package/core/built/admin/assets/overview-Bf2sbznq.js +1 -0
- package/core/built/admin/assets/overview-CBfePxcr.js +1 -0
- package/core/built/admin/assets/{page-header-C0jVxtS8.js → page-header-B3Wb9Jqv.js} +1 -1
- package/core/built/admin/assets/{pages-BbNWFcGj.js → pages-B4goRPUn.js} +1 -1
- package/core/built/admin/assets/{post-analytics-Iw7dk1mT.js → post-analytics-We-ko5Cb.js} +1 -1
- package/core/built/admin/assets/post-analytics-header-Bmc9su6q.js +1 -0
- package/core/built/admin/assets/post-analytics-provider-B6UynTGb.js +1 -0
- package/core/built/admin/assets/{post-share-modal-DBfKl2GZ.js → post-share-modal-DFfO93_0.js} +1 -1
- package/core/built/admin/assets/{posts-EWpZIjkr.js → posts-DhXE7b6r.js} +1 -1
- package/core/built/admin/assets/{preferences-D4EANGoc.js → preferences-DJPfHEGu.js} +2 -2
- package/core/built/admin/assets/profile-DqBQp-gT.js +1 -0
- package/core/built/admin/assets/{referrers-BCfFYSHc.js → referrers-pmJT4k3-.js} +1 -1
- package/core/built/admin/assets/select-C5_xEt5b.js +1 -0
- package/core/built/admin/assets/settings-BwiVIXau.js +4 -0
- package/core/built/admin/assets/{settings-Bz3RZRmE.js → settings-WXNeWyU1.js} +1 -1
- package/core/built/admin/assets/shade-app-B3heOAfz-CGXQwWeO.js +3 -0
- package/core/built/admin/assets/{share-modal-CkdTTMJd.js → share-modal-DsgW3wbg.js} +1 -1
- package/core/built/admin/assets/social-urls-BQPFEk1_.js +8 -0
- package/core/built/admin/assets/{sort-button-DZp9Y2r1.js → sort-button-Bb_FCl7e.js} +1 -1
- package/core/built/admin/assets/{source-icon-CHr9qJ2Y.js → source-icon-BeaRxUn9.js} +1 -1
- package/core/built/admin/assets/{stats-DGoXZxWE.js → stats-Bjj_kmJ1.js} +1 -1
- package/core/built/admin/assets/{stats-view-D2-1L0ms.js → stats-view-BxPbW6K1.js} +1 -1
- package/core/built/admin/assets/{step-1-RdahbwyG.js → step-1-B1e3-1F8.js} +1 -1
- package/core/built/admin/assets/{step-2-D83PeRwd.js → step-2-C5WaeBJo.js} +1 -1
- package/core/built/admin/assets/{step-3-p_Op4wv4.js → step-3-B46iVPwu.js} +2 -2
- package/core/built/admin/assets/switch-z5Y6Uzt6.js +1 -0
- package/core/built/admin/assets/{table-B8LgTQJZ.js → table-CB-4_HBJ.js} +1 -1
- package/core/built/admin/assets/tabs-CCEgKYzq.js +1 -0
- package/core/built/admin/assets/{tags-B4l891z2.js → tags-Cg2VJGmG.js} +1 -1
- package/core/built/admin/assets/textarea-Bjccvh_E.js +1 -0
- package/core/built/admin/assets/{tiers-BgYLWRJv.js → tiers-DYsa4DCo.js} +1 -1
- package/core/built/admin/assets/{toggle-group-DNXDkAwf.js → toggle-group-b3IlxvhV.js} +1 -1
- package/core/built/admin/assets/{topic-filter-DZGjEcz3.js → topic-filter-DAFT87tQ.js} +1 -1
- package/core/built/admin/assets/{use-analytics-data-CaT8RirX.js → use-analytics-data-S0FTd43s.js} +1 -1
- package/core/built/admin/assets/use-filter-params-Gpoq2E1L.js +1 -0
- package/core/built/admin/assets/{use-growth-stats-9Db2CuJe.js → use-growth-stats-CIaiHOVb.js} +1 -1
- package/core/built/admin/assets/{use-post-referrers-BV54YcQS.js → use-post-referrers-Cq2vBPrA.js} +1 -1
- package/core/built/admin/assets/{use-reply-chain-data-DuYZGDan.js → use-reply-chain-data-DBDRtGu-.js} +1 -1
- package/core/built/admin/assets/{use-simple-pagination-8fTua9Nt.js → use-simple-pagination-CjYNxdwe.js} +1 -1
- package/core/built/admin/assets/{use-tinybird-query-Dz156HvZ.js → use-tinybird-query-falHAtuK.js} +1 -1
- package/core/built/admin/assets/{use-tinybird-token-C9RUdL_n.js → use-tinybird-token-D7-8ND1y.js} +1 -1
- package/core/built/admin/assets/{users-CcSCQFcQ.js → users-Dkv8NQZe.js} +1 -1
- package/core/built/admin/assets/{virtual-list-BCvP0YF-.js → virtual-list-CvnC9zF2.js} +1 -1
- package/core/built/admin/assets/web-B_5GZRvH.js +1 -0
- package/core/built/admin/assets/web-jFYWCKDn.js +2 -0
- package/core/built/admin/index.html +14 -10
- package/core/frontend/services/proxy.js +10 -1
- package/core/frontend/services/routing/router-manager.js +14 -14
- package/core/frontend/services/routing/static-routes-router.js +8 -10
- package/core/server/adapters/storage/LocalFilesStorage.js +13 -11
- package/core/server/adapters/storage/LocalFilesStorage.ts +24 -0
- package/core/server/adapters/storage/LocalImagesStorage.js +14 -12
- package/core/server/adapters/storage/LocalImagesStorage.ts +24 -0
- package/core/server/adapters/storage/LocalMediaStorage.js +13 -11
- package/core/server/adapters/storage/LocalMediaStorage.ts +24 -0
- package/core/server/adapters/storage/LocalStorageBase.js +107 -167
- package/core/server/adapters/storage/LocalStorageBase.ts +342 -0
- package/core/server/adapters/storage/utils.js +36 -50
- package/core/server/adapters/storage/utils.ts +60 -0
- package/core/server/api/endpoints/automation-action-links.js +68 -0
- package/core/server/api/endpoints/automation-action-links.ts +46 -0
- package/core/server/api/endpoints/db.js +6 -0
- package/core/server/api/endpoints/index.js +4 -0
- package/core/server/api/endpoints/members.js +25 -28
- package/core/server/api/endpoints/pages.js +6 -1
- package/core/server/api/endpoints/posts.js +11 -2
- package/core/server/api/endpoints/settings.js +0 -2
- package/core/server/api/endpoints/utils/serializers/output/mappers/comments.js +4 -1
- package/core/server/api/endpoints/utils/serializers/output/members.js +18 -87
- package/core/server/api/endpoints/utils/serializers/output/utils/url.js +11 -1
- package/core/server/data/exporter/export-filename.js +2 -1
- package/core/server/data/importer/importers/data/revue-subscriber-importer.js +6 -10
- package/core/server/data/importer/importers/json-to-html.js +24 -4
- package/core/server/data/migrations/versions/6.55/2026-07-27-11-25-44-add-to-hash-to-redirects.js +51 -0
- package/core/server/data/migrations/versions/6.55/2026-07-28-12-08-00-remove-routes-hash-from-settings.js +5 -0
- package/core/server/data/schema/commands.js +5 -0
- package/core/server/data/schema/default-settings/default-settings.json +0 -4
- package/core/server/data/schema/schema.js +5 -1
- package/core/server/lib/image/image-size.js +3 -17
- package/core/server/lib/image/image-utils.js +15 -1
- package/core/server/lib/request-external.js +1 -1
- package/core/server/models/user.js +46 -70
- package/core/server/services/auth/session/session-service.js +28 -1
- package/core/server/services/automations/automations-api.js +21 -6
- package/core/server/services/automations/automations-api.ts +25 -6
- package/core/server/services/automations/automations-repository.ts +19 -0
- package/core/server/services/automations/database-automations-repository.js +116 -28
- package/core/server/services/automations/database-automations-repository.ts +118 -9
- package/core/server/services/automations/database-date.js +10 -0
- package/core/server/services/automations/database-date.ts +5 -0
- package/core/server/services/automations/poll.js +5 -2
- package/core/server/services/automations/poll.ts +7 -2
- package/core/server/services/automations/scheduler-poll-time.js +40 -0
- package/core/server/services/automations/scheduler-poll-time.ts +38 -0
- package/core/server/services/automations/service.js +15 -3
- package/core/server/services/automations/service.ts +16 -3
- package/core/server/services/comments/comments-service-emails.js +14 -8
- package/core/server/services/comments/comments-service.js +9 -4
- package/core/server/services/email-rendering/partials/email-wrapper.hbs +3 -1
- package/core/server/services/email-service/email-service-wrapper.js +6 -1
- package/core/server/services/email-service/email-templates/partials/styles.hbs +8 -0
- package/core/server/services/email-service/newsletter-email-event-storage.js +41 -10
- package/core/server/services/lib/dynamic-redirect-manager.js +12 -3
- package/core/server/services/link-redirection/link-redirect-repository.js +41 -4
- package/core/server/services/link-redirection/link-redirect.js +2 -0
- package/core/server/services/link-redirection/link-redirect.ts +3 -1
- package/core/server/services/link-redirection/link-redirects-service.js +38 -2
- package/core/server/services/link-tracking/click-event.js +3 -0
- package/core/server/services/link-tracking/index.js +38 -6
- package/core/server/services/link-tracking/link-click-repository.js +30 -9
- package/core/server/services/link-tracking/link-click-tracking-service.js +44 -3
- package/core/server/services/member-attribution/outbound-link-tagger.js +3 -1
- package/core/server/services/member-welcome-emails/member-welcome-email-renderer.js +19 -4
- package/core/server/services/member-welcome-emails/service.js +11 -3
- package/core/server/services/members/{importer → import-export}/config.js +4 -9
- package/core/server/services/members/{importer → import-export}/config.ts +4 -9
- package/core/server/services/members/import-export/csv/formula.js +16 -0
- package/core/server/services/members/import-export/csv/formula.ts +14 -0
- package/core/server/services/members/import-export/csv/index.js +12 -0
- package/core/server/services/members/import-export/csv/index.ts +3 -0
- package/core/server/services/members/import-export/csv/parse.js +64 -0
- package/core/server/services/members/import-export/csv/parse.ts +69 -0
- package/core/server/services/members/import-export/csv/serialize.js +13 -0
- package/core/server/services/members/import-export/csv/serialize.ts +18 -0
- package/core/server/services/members/import-export/export/exporter.js +202 -0
- package/core/server/services/members/import-export/export/exporter.ts +325 -0
- package/core/server/services/members/import-export/import/completion-email.js +84 -0
- package/core/server/services/members/import-export/import/completion-email.ts +141 -0
- package/core/server/services/members/import-export/import/importer.js +297 -0
- package/core/server/services/members/import-export/import/importer.ts +468 -0
- package/core/server/services/members/import-export/import/reader.js +32 -0
- package/core/server/services/members/import-export/import/reader.ts +33 -0
- package/core/server/services/members/import-export/import/row.js +33 -0
- package/core/server/services/members/import-export/import/row.ts +52 -0
- package/core/server/services/members/import-export/import/spool.js +30 -0
- package/core/server/services/members/import-export/import/spool.ts +38 -0
- package/core/server/services/members/import-export/index.js +99 -0
- package/core/server/services/members/import-export/index.ts +137 -0
- package/core/server/services/members/members-api/controllers/router-controller.js +7 -0
- package/core/server/services/members/service.js +25 -20
- package/core/server/services/members-custom-fields/values-service.js +19 -7
- package/core/server/services/members-custom-fields/values-service.ts +19 -7
- package/core/server/services/mentions/service.js +5 -1
- package/core/server/services/posts/posts-exporter.js +6 -4
- package/core/server/services/posts/posts-service.js +2 -2
- package/core/server/services/route-settings/activation-bridge.js +59 -49
- package/core/server/services/route-settings/activation-bridge.ts +91 -51
- package/core/server/services/route-settings/dynamic-routing-service.js +22 -51
- package/core/server/services/route-settings/index.js +1 -16
- package/core/server/services/settings/settings-service.js +0 -18
- package/core/server/services/themes/storage.js +9 -0
- package/core/server/services/themes/theme-storage.js +1 -1
- package/core/server/services/url/url-service-facade.js +1 -1
- package/core/server/services/url/url-service-facade.ts +8 -1
- package/core/server/web/api/endpoints/admin/routes.js +1 -0
- package/core/shared/config/defaults.json +2 -1
- package/core/shared/express.js +6 -1
- package/core/shared/labs.js +2 -1
- package/package.json +11 -11
- package/pnpm-lock.yaml +18 -18
- package/pnpm-workspace.yaml +8 -7
- package/components/tryghost-adapter-base-scheduling-0.1.1.tgz +0 -0
- package/core/built/admin/assets/Overview-j4NaeUNm.js +0 -1
- package/core/built/admin/assets/Web-_fD935OX.js +0 -2
- package/core/built/admin/assets/activitypub/layout-IdVq3lRy.js +0 -1
- package/core/built/admin/assets/audience-BKRdsFmy.js +0 -1
- package/core/built/admin/assets/checkbox-DA4-1UiI.js +0 -1
- package/core/built/admin/assets/chunk.524.45e0875f41b71e46e849.js +0 -25
- package/core/built/admin/assets/chunk.582.51defd4b009f0de8d4d5.js +0 -27
- package/core/built/admin/assets/code-editor-view-BKBrkffZ.js +0 -1
- package/core/built/admin/assets/comments-CzVNIT8b.js +0 -1
- package/core/built/admin/assets/constants-c-JNF9c2.js +0 -1
- package/core/built/admin/assets/dist-CT46_f_J.js +0 -1
- package/core/built/admin/assets/domain-B7_n5nfx.js +0 -1
- package/core/built/admin/assets/dropzone-DcuJEEUf.js +0 -5
- package/core/built/admin/assets/editor-BCiIyFjm.js +0 -8
- package/core/built/admin/assets/field-BW-M2Zwp.js +0 -1
- package/core/built/admin/assets/filters-DtcXMZDU.js +0 -1
- package/core/built/admin/assets/gift-link-modal-Bk8qpnMw.js +0 -1
- package/core/built/admin/assets/growth-Ck2s3JjR.js +0 -1
- package/core/built/admin/assets/index-BBf502Wg.js +0 -69
- package/core/built/admin/assets/index-BCCZKxP2.css +0 -1
- package/core/built/admin/assets/input-group-DjuYnNCd.js +0 -1
- package/core/built/admin/assets/label-picker-BdNAWuUX.js +0 -1
- package/core/built/admin/assets/labels-Cyayfd-e.js +0 -1
- package/core/built/admin/assets/layout-Bw4GnxXp.js +0 -1
- package/core/built/admin/assets/member-custom-fields-DvFyamLu.js +0 -1
- package/core/built/admin/assets/member-detail-Cg2vTqvJ.js +0 -1
- package/core/built/admin/assets/members-DsPxYAVm.js +0 -10
- package/core/built/admin/assets/modals-Bf_fyvHZ.js +0 -31
- package/core/built/admin/assets/moderation-Dlp7M5sa.js +0 -1
- package/core/built/admin/assets/multi-select-combobox-CH1o8XXQ.js +0 -1
- package/core/built/admin/assets/newsletter-CQ5CAYUI.js +0 -1
- package/core/built/admin/assets/note-BrAZ7Z0o.js +0 -1
- package/core/built/admin/assets/notifications-BnbHLOPC.js +0 -1
- package/core/built/admin/assets/onboarding-route-eU146kuA.js +0 -1
- package/core/built/admin/assets/overview-BpbaK-xu.js +0 -1
- package/core/built/admin/assets/post-analytics-header-D9pJ4iNm.js +0 -1
- package/core/built/admin/assets/post-analytics-provider-BDqcfBor.js +0 -1
- package/core/built/admin/assets/profile-dJ44Bn_W.js +0 -1
- package/core/built/admin/assets/select--2uVN9_-.js +0 -1
- package/core/built/admin/assets/settings-SD9bxIpt.js +0 -4
- package/core/built/admin/assets/social-urls-BvFZwYcE.js +0 -8
- package/core/built/admin/assets/switch-qp518gFQ.js +0 -1
- package/core/built/admin/assets/tabs-D3ZOYkM7.js +0 -1
- package/core/built/admin/assets/textarea-CTlb3Uu6.js +0 -1
- package/core/built/admin/assets/use-filter-params-DeQ8sX4N.js +0 -1
- package/core/built/admin/assets/web-DTZnnUGr.js +0 -1
- package/core/server/services/mail/templates/newsletter.html +0 -876
- package/core/server/services/members/csv/index.js +0 -10
- package/core/server/services/members/csv/index.ts +0 -3
- package/core/server/services/members/csv/parse.js +0 -94
- package/core/server/services/members/csv/parse.ts +0 -111
- package/core/server/services/members/csv/types.js +0 -2
- package/core/server/services/members/csv/types.ts +0 -39
- package/core/server/services/members/csv/unparse.js +0 -69
- package/core/server/services/members/csv/unparse.ts +0 -79
- package/core/server/services/members/exporter/query.js +0 -233
- package/core/server/services/members/importer/index.js +0 -30
- package/core/server/services/members/importer/labels.js +0 -125
- package/core/server/services/members/importer/members-csv-importer.js +0 -520
- package/core/server/services/route-settings/default-settings-manager.js +0 -63
- package/core/server/services/route-settings/route-settings.js +0 -170
- package/core/server/services/route-settings/settings-loader.js +0 -60
- package/core/server/services/route-settings/settings-path-manager.js +0 -47
- package/core/server/services/route-settings/validate.js +0 -440
- /package/core/server/services/members/{importer → import-export/import}/email-template.js +0 -0
- /package/core/server/services/members/{importer/members-csv-importer-stripe-utils.js → import-export/import/stripe-utils.js} +0 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
const SCHEDULER_POLL_INTERVAL_MS = 15 * 60 * 1000;
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* An implementation of the [FNV-1a hash][0].
|
|
5
|
+
*
|
|
6
|
+
* We need something that generates per-site jitter within a 15-minute
|
|
7
|
+
* interval. We want a 32-bit number which we can modulo at the end.
|
|
8
|
+
*
|
|
9
|
+
* FNV-1a is good for this purpose because:
|
|
10
|
+
*
|
|
11
|
+
* - it is simple
|
|
12
|
+
* - it generates a 32-bit number
|
|
13
|
+
* - it is [more efficient than CRC32, MD5, and SHA][1], using less CPU and RAM
|
|
14
|
+
*
|
|
15
|
+
* This implementation assumes ASCII strings, which we expect callers to always
|
|
16
|
+
* use. If that assumption is ever violated, we'll compute incorrect jitter,
|
|
17
|
+
* which is acceptable.
|
|
18
|
+
*
|
|
19
|
+
* [0]: https://en.wikipedia.org/wiki/Fowler%E2%80%93Noll%E2%80%93Vo_hash_function#FNV-1a_hash
|
|
20
|
+
* [1]: https://gist.github.com/EvanHahn/20d845c8157691d24977831159487950
|
|
21
|
+
*/
|
|
22
|
+
const fnv1a32 = (asciiStr: string): number => {
|
|
23
|
+
let hash = 0x811c9dc5;
|
|
24
|
+
for (let i = 0; i < asciiStr.length; i++) {
|
|
25
|
+
hash ^= asciiStr.charCodeAt(i);
|
|
26
|
+
hash = Math.imul(hash, 0x01000193);
|
|
27
|
+
}
|
|
28
|
+
return hash >>> 0;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Round the time up to a 15-minute interval with site-specific jitter.
|
|
33
|
+
*/
|
|
34
|
+
export const getSchedulerPollTime = (date: Readonly<Date>, siteIdentifier: string): Date => {
|
|
35
|
+
const siteOffset = fnv1a32(siteIdentifier) % SCHEDULER_POLL_INTERVAL_MS;
|
|
36
|
+
const interval = Math.ceil((date.getTime() - siteOffset) / SCHEDULER_POLL_INTERVAL_MS);
|
|
37
|
+
return new Date(interval * SCHEDULER_POLL_INTERVAL_MS + siteOffset);
|
|
38
|
+
}
|
|
@@ -43,6 +43,7 @@ const poll_1 = require("./poll");
|
|
|
43
43
|
const automationsApi = __importStar(require("./automations-api"));
|
|
44
44
|
const promises_1 = require("node:timers/promises");
|
|
45
45
|
const soonest_timer_1 = require("../../lib/soonest-timer");
|
|
46
|
+
const scheduler_poll_time_1 = require("./scheduler-poll-time");
|
|
46
47
|
// @ts-expect-error This module currently lacks type definitions.
|
|
47
48
|
const jobs_1 = __importDefault(require("../email-analytics/jobs"));
|
|
48
49
|
const urlUtils = require('../../../shared/url-utils').default;
|
|
@@ -54,11 +55,13 @@ const memberWelcomeEmailService = require('../member-welcome-emails/service');
|
|
|
54
55
|
const scheduleAutomationEmailAnalyticsJob = () => (jobs_1.default.scheduleRecurringAutomationsJob(true));
|
|
55
56
|
class AutomationsService {
|
|
56
57
|
#enqueuePollAt;
|
|
57
|
-
init({ domainEvents, apiUrl, schedulerAdapter, internalKeys }) {
|
|
58
|
+
init({ domainEvents, apiUrl, schedulerAdapter, internalKeys, siteUuid }) {
|
|
58
59
|
const isInitialized = Boolean(this.#enqueuePollAt);
|
|
59
60
|
if (isInitialized) {
|
|
60
61
|
return;
|
|
61
62
|
}
|
|
63
|
+
// If we don't get a valid site UUID for some reason, compute jitter with the API URL.
|
|
64
|
+
const siteIdentifier = typeof siteUuid === 'string' && siteUuid.length ? siteUuid : apiUrl;
|
|
62
65
|
const enqueuePollNow = () => domainEvents.dispatch(StartAutomationsPollEvent.create());
|
|
63
66
|
const soonestTimer = new soonest_timer_1.SoonestTimer(enqueuePollNow);
|
|
64
67
|
/**
|
|
@@ -86,11 +89,20 @@ class AutomationsService {
|
|
|
86
89
|
}
|
|
87
90
|
soonestTimer.scheduleAt(date);
|
|
88
91
|
try {
|
|
92
|
+
const schedulerPollTime = (0, scheduler_poll_time_1.getSchedulerPollTime)(date, siteIdentifier);
|
|
89
93
|
const key = await internalKeys.get('ghost-scheduler');
|
|
90
|
-
const signedAdminToken = getSignedAdminToken({
|
|
94
|
+
const signedAdminToken = getSignedAdminToken({
|
|
95
|
+
publishedAt: schedulerPollTime.toISOString(),
|
|
96
|
+
apiUrl,
|
|
97
|
+
key
|
|
98
|
+
});
|
|
91
99
|
const url = new URL(urlUtils.urlJoin(apiUrl, 'automations', 'poll'));
|
|
92
100
|
url.searchParams.set('token', signedAdminToken);
|
|
93
|
-
schedulerAdapter.schedule({
|
|
101
|
+
schedulerAdapter.schedule({
|
|
102
|
+
time: schedulerPollTime.getTime(),
|
|
103
|
+
url: url.toString(),
|
|
104
|
+
extra: { httpMethod: 'PUT' }
|
|
105
|
+
});
|
|
94
106
|
}
|
|
95
107
|
catch (err) {
|
|
96
108
|
logging.error({ event: { name: 'automations.enqueue-poll.error' }, err, at: date.toISOString() }, 'Failed to enqueue automations poll');
|
|
@@ -8,6 +8,7 @@ import {poll} from './poll';
|
|
|
8
8
|
import * as automationsApi from './automations-api';
|
|
9
9
|
import {setImmediate as flushEventLoop} from 'node:timers/promises';
|
|
10
10
|
import {SoonestTimer} from '../../lib/soonest-timer';
|
|
11
|
+
import {getSchedulerPollTime} from './scheduler-poll-time';
|
|
11
12
|
// @ts-expect-error This module currently lacks type definitions.
|
|
12
13
|
import emailAnalyticsJobs from '../email-analytics/jobs';
|
|
13
14
|
|
|
@@ -23,6 +24,7 @@ type AutomationsServiceOptions = {
|
|
|
23
24
|
domainEvents: Pick<DomainEvents, 'dispatch' | 'subscribe'>;
|
|
24
25
|
internalKeys: InternalKeys;
|
|
25
26
|
schedulerAdapter: Pick<SchedulerAdapter, 'schedule' | 'register'>;
|
|
27
|
+
siteUuid: unknown;
|
|
26
28
|
};
|
|
27
29
|
|
|
28
30
|
const scheduleAutomationEmailAnalyticsJob = () => (
|
|
@@ -32,12 +34,14 @@ const scheduleAutomationEmailAnalyticsJob = () => (
|
|
|
32
34
|
export class AutomationsService {
|
|
33
35
|
#enqueuePollAt: undefined | ((date: Readonly<Date>) => Promise<void>);
|
|
34
36
|
|
|
35
|
-
init({domainEvents, apiUrl, schedulerAdapter, internalKeys}: AutomationsServiceOptions): void {
|
|
37
|
+
init({domainEvents, apiUrl, schedulerAdapter, internalKeys, siteUuid}: AutomationsServiceOptions): void {
|
|
36
38
|
const isInitialized = Boolean(this.#enqueuePollAt);
|
|
37
39
|
if (isInitialized) {
|
|
38
40
|
return;
|
|
39
41
|
}
|
|
40
42
|
|
|
43
|
+
// If we don't get a valid site UUID for some reason, compute jitter with the API URL.
|
|
44
|
+
const siteIdentifier = typeof siteUuid === 'string' && siteUuid.length ? siteUuid : apiUrl;
|
|
41
45
|
const enqueuePollNow = () => domainEvents.dispatch(StartAutomationsPollEvent.create());
|
|
42
46
|
|
|
43
47
|
const soonestTimer = new SoonestTimer(enqueuePollNow);
|
|
@@ -69,11 +73,20 @@ export class AutomationsService {
|
|
|
69
73
|
soonestTimer.scheduleAt(date);
|
|
70
74
|
|
|
71
75
|
try {
|
|
76
|
+
const schedulerPollTime = getSchedulerPollTime(date, siteIdentifier);
|
|
72
77
|
const key = await internalKeys.get('ghost-scheduler');
|
|
73
|
-
const signedAdminToken = getSignedAdminToken({
|
|
78
|
+
const signedAdminToken = getSignedAdminToken({
|
|
79
|
+
publishedAt: schedulerPollTime.toISOString(),
|
|
80
|
+
apiUrl,
|
|
81
|
+
key
|
|
82
|
+
});
|
|
74
83
|
const url = new URL(urlUtils.urlJoin(apiUrl, 'automations', 'poll'));
|
|
75
84
|
url.searchParams.set('token', signedAdminToken);
|
|
76
|
-
schedulerAdapter.schedule({
|
|
85
|
+
schedulerAdapter.schedule({
|
|
86
|
+
time: schedulerPollTime.getTime(),
|
|
87
|
+
url: url.toString(),
|
|
88
|
+
extra: {httpMethod: 'PUT'}
|
|
89
|
+
});
|
|
77
90
|
} catch (err) {
|
|
78
91
|
logging.error({event: {name: 'automations.enqueue-poll.error'}, err, at: date.toISOString()}, 'Failed to enqueue automations poll');
|
|
79
92
|
}
|
|
@@ -26,7 +26,10 @@ class CommentsServiceEmails {
|
|
|
26
26
|
*/
|
|
27
27
|
getPostUrl(post, commentId) {
|
|
28
28
|
const postData = toPlain(post);
|
|
29
|
-
|
|
29
|
+
// Comments live on pages as well as posts; the URL service routes by
|
|
30
|
+
// the passed type, so a page typed 'posts' resolves to /404/.
|
|
31
|
+
const routerType = postData.type === 'page' ? 'pages' : 'posts';
|
|
32
|
+
const baseUrl = this.urlService.facade.getUrlForResource({...postData, type: routerType}, {absolute: true});
|
|
30
33
|
return `${baseUrl}#ghost-comments-${commentId}`;
|
|
31
34
|
}
|
|
32
35
|
|
|
@@ -84,12 +87,13 @@ class CommentsServiceEmails {
|
|
|
84
87
|
}
|
|
85
88
|
}
|
|
86
89
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
90
|
+
/**
|
|
91
|
+
* @returns {Promise<string|undefined>} The id of the member that was notified, or undefined if no email was sent
|
|
92
|
+
*/
|
|
93
|
+
async notifyParentCommentAuthor(reply, {type = 'parent', parent = null} = {}) {
|
|
94
|
+
if (!parent) {
|
|
95
|
+
const parentId = type === 'in_reply_to' ? reply.get('in_reply_to_id') : reply.get('parent_id');
|
|
96
|
+
parent = await this.models.Comment.findOne({id: parentId});
|
|
93
97
|
}
|
|
94
98
|
const parentMember = parent.related('member');
|
|
95
99
|
|
|
@@ -137,12 +141,14 @@ class CommentsServiceEmails {
|
|
|
137
141
|
text
|
|
138
142
|
} = await this.commentsServiceEmailRenderer.renderEmailTemplate('new-comment-reply', templateData);
|
|
139
143
|
|
|
140
|
-
|
|
144
|
+
await this.sendMail({
|
|
141
145
|
to: parentMemberEmail,
|
|
142
146
|
subject,
|
|
143
147
|
html,
|
|
144
148
|
text
|
|
145
149
|
});
|
|
150
|
+
|
|
151
|
+
return parentMember.id;
|
|
146
152
|
}
|
|
147
153
|
|
|
148
154
|
/**
|
|
@@ -378,11 +378,16 @@ class CommentsService {
|
|
|
378
378
|
async sendNewCommentNotifications(comment) {
|
|
379
379
|
await this.emails.notifyPostAuthors(comment);
|
|
380
380
|
|
|
381
|
-
|
|
382
|
-
await this.emails.notifyParentCommentAuthor(comment, {type: 'parent'})
|
|
383
|
-
|
|
381
|
+
const notifiedMemberId = comment.get('parent_id')
|
|
382
|
+
? await this.emails.notifyParentCommentAuthor(comment, {type: 'parent'})
|
|
383
|
+
: null;
|
|
384
|
+
|
|
384
385
|
if (comment.get('in_reply_to_id')) {
|
|
385
|
-
await this.
|
|
386
|
+
const inReplyTo = await this.models.Comment.findOne({id: comment.get('in_reply_to_id')});
|
|
387
|
+
|
|
388
|
+
if (inReplyTo && (!notifiedMemberId || inReplyTo.get('member_id') !== notifiedMemberId)) {
|
|
389
|
+
await this.emails.notifyParentCommentAuthor(comment, {type: 'in_reply_to', parent: inReplyTo});
|
|
390
|
+
}
|
|
386
391
|
}
|
|
387
392
|
}
|
|
388
393
|
|
|
@@ -84,7 +84,9 @@
|
|
|
84
84
|
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
|
|
85
85
|
<tr>
|
|
86
86
|
<td>
|
|
87
|
-
<
|
|
87
|
+
<h1 class="post-title-heading">
|
|
88
|
+
<a href="{{post.url}}" class="{{classes.titleLink}}">{{post.title}}</a>
|
|
89
|
+
</h1>
|
|
88
90
|
</td>
|
|
89
91
|
</tr>
|
|
90
92
|
</table>
|
|
@@ -6,7 +6,12 @@ const events = require('../../lib/common/events');
|
|
|
6
6
|
class EmailServiceWrapper {
|
|
7
7
|
getPostUrl(post) {
|
|
8
8
|
const jsonModel = post.toJSON();
|
|
9
|
-
|
|
9
|
+
// The URL service routes by resource type. Pages and posts share the
|
|
10
|
+
// Post model, so the page's own type must reach forPost — otherwise it
|
|
11
|
+
// defaults to 'posts', matches no post collection, and 404s under the
|
|
12
|
+
// lazy service.
|
|
13
|
+
const type = jsonModel.type === 'page' ? 'pages' : 'posts';
|
|
14
|
+
url.forPost(post.id, jsonModel, {options: {}}, type);
|
|
10
15
|
return jsonModel.url;
|
|
11
16
|
}
|
|
12
17
|
|
|
@@ -161,6 +161,14 @@ img.is-dark-background {
|
|
|
161
161
|
color: {{postTitleColor}};
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
+
/* Outlook's Word engine ignores `inherit`, so repeat the `.post-title > table td` values */
|
|
165
|
+
.post-title-heading {
|
|
166
|
+
margin: 0;
|
|
167
|
+
font-size: 36px;
|
|
168
|
+
line-height: 1.1;
|
|
169
|
+
font-weight: {{titleWeight}};
|
|
170
|
+
}
|
|
171
|
+
|
|
164
172
|
.post-title a {
|
|
165
173
|
color: {{postTitleColor}} !important;
|
|
166
174
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const moment = require('moment-timezone');
|
|
2
|
+
const errors = require('@tryghost/errors');
|
|
2
3
|
const logging = require('@tryghost/logging');
|
|
3
4
|
const config = require('../../../shared/config');
|
|
4
5
|
|
|
@@ -177,11 +178,20 @@ class NewsletterEmailEventStorage {
|
|
|
177
178
|
|
|
178
179
|
async handleUnsubscribed(event) {
|
|
179
180
|
try {
|
|
180
|
-
|
|
181
|
-
const newsletters = await this.findNewslettersToKeep(event);
|
|
182
|
-
await this.#membersRepository.update({newsletters}, {id: event.memberId});
|
|
181
|
+
const result = await this.findNewslettersToKeep(event);
|
|
183
182
|
|
|
184
|
-
|
|
183
|
+
if (result.status === 'failed') {
|
|
184
|
+
// Leave Mailgun's suppression in place: these events are fetched
|
|
185
|
+
// once and never retried, so it is the only remaining protection.
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (result.status === 'ok') {
|
|
190
|
+
await this.#membersRepository.update({newsletters: result.newsletters}, {id: event.memberId});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Remove member from Mailgun's suppression list, only once the local
|
|
194
|
+
// record reflects the unsubscribe or there is no member left to protect
|
|
185
195
|
await this.#emailSuppressionList.removeUnsubscribe(event.email);
|
|
186
196
|
} catch (err) {
|
|
187
197
|
logging.error(err);
|
|
@@ -205,22 +215,43 @@ class NewsletterEmailEventStorage {
|
|
|
205
215
|
}
|
|
206
216
|
}
|
|
207
217
|
|
|
218
|
+
/**
|
|
219
|
+
* @typedef {{status: 'ok', newsletters: {id: string}[]}
|
|
220
|
+
* | {status: 'no-member'}
|
|
221
|
+
* | {status: 'failed'}} FindNewslettersToKeepResult
|
|
222
|
+
*/
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* @param {import('./events/email-unsubscribed-event')} event
|
|
226
|
+
* @returns {Promise<FindNewslettersToKeepResult>}
|
|
227
|
+
*/
|
|
208
228
|
async findNewslettersToKeep(event) {
|
|
209
229
|
try {
|
|
210
|
-
const member = await this.#membersRepository.get({
|
|
230
|
+
const member = await this.#membersRepository.get({id: event.memberId}, {
|
|
211
231
|
withRelated: ['newsletters']
|
|
212
232
|
});
|
|
233
|
+
|
|
234
|
+
if (!member) {
|
|
235
|
+
return {status: 'no-member'};
|
|
236
|
+
}
|
|
237
|
+
|
|
213
238
|
const existingNewsletters = member.related('newsletters');
|
|
214
239
|
|
|
215
240
|
const email = await this.#models.Email.findOne({id: event.emailId});
|
|
216
241
|
const newsletterToRemove = email.get('newsletter_id');
|
|
217
242
|
|
|
218
|
-
return
|
|
219
|
-
|
|
220
|
-
|
|
243
|
+
return {
|
|
244
|
+
status: 'ok',
|
|
245
|
+
newsletters: existingNewsletters.models.filter(newsletter => newsletter.id !== newsletterToRemove).map((n) => {
|
|
246
|
+
return {id: n.id};
|
|
247
|
+
})
|
|
248
|
+
};
|
|
221
249
|
} catch (err) {
|
|
222
|
-
logging.error(
|
|
223
|
-
|
|
250
|
+
logging.error(new errors.InternalServerError({
|
|
251
|
+
message: `Could not resolve newsletters to keep for unsubscribe event (member ${event.memberId}, email ${event.emailId})`,
|
|
252
|
+
err
|
|
253
|
+
}));
|
|
254
|
+
return {status: 'failed'};
|
|
224
255
|
}
|
|
225
256
|
}
|
|
226
257
|
|
|
@@ -56,14 +56,23 @@ class DynamicRedirectManager {
|
|
|
56
56
|
|
|
57
57
|
this.router.get(fromRegex, (req, res) => {
|
|
58
58
|
const maxAge = permanent ? this.permanentMaxAge : 0;
|
|
59
|
-
const toURL = parseURL(to);
|
|
60
|
-
const toURLParams = parseQuerystring(toURL.query);
|
|
61
59
|
const currentURL = parseURL(req.url);
|
|
62
60
|
const currentURLParams = parseQuerystring(currentURL.query);
|
|
61
|
+
|
|
62
|
+
const toURL = parseURL(to);
|
|
63
|
+
toURL.pathname = currentURL.pathname.replace(fromRegex, toURL.pathname);
|
|
64
|
+
|
|
65
|
+
// Captures are percent-encoded path segments (Express rejects any it can't decode),
|
|
66
|
+
// so decode then re-encode for the query — reserved chars stay literal, no double-encoding
|
|
67
|
+
const captures = currentURL.pathname.match(fromRegex) || [];
|
|
68
|
+
const substitutedQuery = (toURL.query || '').replace(/\$(\d+)/g, (match, n) => (
|
|
69
|
+
captures[n] !== undefined ? encodeURIComponent(decodeURIComponent(captures[n])) : match
|
|
70
|
+
));
|
|
71
|
+
const toURLParams = parseQuerystring(substitutedQuery);
|
|
72
|
+
|
|
63
73
|
const params = Object.assign({}, currentURLParams, toURLParams);
|
|
64
74
|
const search = formatQuerystring(params);
|
|
65
75
|
|
|
66
|
-
toURL.pathname = currentURL.pathname.replace(fromRegex, toURL.pathname);
|
|
67
76
|
toURL.search = search !== '' ? `?${search}` : null;
|
|
68
77
|
|
|
69
78
|
/**
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
const crypto = require('crypto');
|
|
1
2
|
const {LinkRedirect} = require('./link-redirect');
|
|
2
3
|
const ObjectID = require('bson-objectid').default;
|
|
3
4
|
const debug = require('@tryghost/debug')('LinkRedirectRepository');
|
|
@@ -49,7 +50,11 @@ module.exports = class LinkRedirectRepository {
|
|
|
49
50
|
const model = await this.#LinkRedirect.add({
|
|
50
51
|
// Only store the pathname (no support for variable query strings)
|
|
51
52
|
from: this.stripSubdirectoryFromPath(linkRedirect.from.pathname),
|
|
52
|
-
to: linkRedirect.to.href
|
|
53
|
+
to: linkRedirect.to.href,
|
|
54
|
+
...(linkRedirect.automationActionRevisionId ? {
|
|
55
|
+
automation_action_revision_id: linkRedirect.automationActionRevisionId,
|
|
56
|
+
to_hash: this.#getToHash(linkRedirect.to)
|
|
57
|
+
} : {})
|
|
53
58
|
}, {});
|
|
54
59
|
|
|
55
60
|
linkRedirect.link_id = ObjectID.createFromHexString(model.id);
|
|
@@ -82,7 +87,8 @@ module.exports = class LinkRedirectRepository {
|
|
|
82
87
|
id: model.id,
|
|
83
88
|
from: new URL(this.#trimLeadingSlash(model.get('from')), this.#urlUtils.urlFor('home', true)),
|
|
84
89
|
to: new URL(model.get('to')),
|
|
85
|
-
edited
|
|
90
|
+
edited,
|
|
91
|
+
automationActionRevisionId: model.get('automation_action_revision_id')
|
|
86
92
|
});
|
|
87
93
|
}
|
|
88
94
|
|
|
@@ -93,6 +99,7 @@ module.exports = class LinkRedirectRepository {
|
|
|
93
99
|
* @param {string} serialized.from - path of the URL
|
|
94
100
|
* @param {string} serialized.to - URL to redirect to
|
|
95
101
|
* @param {boolean} serialized.edited - whether the link has been edited
|
|
102
|
+
* @param {string} [serialized.automationActionRevisionId] - owning automation action revision
|
|
96
103
|
* @returns {InstanceType<LinkRedirect>} LinkRedirect
|
|
97
104
|
*/
|
|
98
105
|
#fromSerialized(serialized) {
|
|
@@ -100,7 +107,8 @@ module.exports = class LinkRedirectRepository {
|
|
|
100
107
|
id: serialized.link_id,
|
|
101
108
|
from: new URL(this.#trimLeadingSlash(serialized.from), this.#urlUtils.urlFor('home', true)),
|
|
102
109
|
to: new URL(serialized.to),
|
|
103
|
-
edited: serialized.edited
|
|
110
|
+
edited: serialized.edited,
|
|
111
|
+
automationActionRevisionId: serialized.automationActionRevisionId
|
|
104
112
|
});
|
|
105
113
|
}
|
|
106
114
|
|
|
@@ -114,7 +122,10 @@ module.exports = class LinkRedirectRepository {
|
|
|
114
122
|
link_id: linkRedirect.link_id.toHexString(),
|
|
115
123
|
from: linkRedirect.from.pathname,
|
|
116
124
|
to: linkRedirect.to.href,
|
|
117
|
-
edited: linkRedirect.edited
|
|
125
|
+
edited: linkRedirect.edited,
|
|
126
|
+
...(linkRedirect.automationActionRevisionId ? {
|
|
127
|
+
automationActionRevisionId: linkRedirect.automationActionRevisionId
|
|
128
|
+
} : {})
|
|
118
129
|
};
|
|
119
130
|
}
|
|
120
131
|
|
|
@@ -180,6 +191,32 @@ module.exports = class LinkRedirectRepository {
|
|
|
180
191
|
}
|
|
181
192
|
}
|
|
182
193
|
|
|
194
|
+
/**
|
|
195
|
+
* Get an automation LinkRedirect by action revision and destination URL
|
|
196
|
+
* @param {string} automationActionRevisionId
|
|
197
|
+
* @param {Readonly<URL>} url
|
|
198
|
+
* @returns {Promise<InstanceType<LinkRedirect>|undefined>} LinkRedirect
|
|
199
|
+
*/
|
|
200
|
+
async getByAutomationActionRevisionAndURL(automationActionRevisionId, url) {
|
|
201
|
+
const linkRedirectModel = await this.#LinkRedirect.findOne({
|
|
202
|
+
automation_action_revision_id: automationActionRevisionId,
|
|
203
|
+
to_hash: this.#getToHash(url)
|
|
204
|
+
}, {});
|
|
205
|
+
|
|
206
|
+
if (linkRedirectModel) {
|
|
207
|
+
return this.fromModel(linkRedirectModel);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* The destination column is too long to index, so unique lookups use its SHA-256 digest
|
|
213
|
+
* @param {Readonly<URL>} url
|
|
214
|
+
* @returns {Buffer}
|
|
215
|
+
*/
|
|
216
|
+
#getToHash(url) {
|
|
217
|
+
return crypto.createHash('sha256').update(url.href).digest();
|
|
218
|
+
}
|
|
219
|
+
|
|
183
220
|
/**
|
|
184
221
|
* Convert root relative URLs to subdirectory relative URLs
|
|
185
222
|
* @param {string} path
|
|
@@ -10,6 +10,7 @@ class LinkRedirect {
|
|
|
10
10
|
from;
|
|
11
11
|
to;
|
|
12
12
|
edited;
|
|
13
|
+
automationActionRevisionId;
|
|
13
14
|
constructor(data) {
|
|
14
15
|
if (!data.id) {
|
|
15
16
|
this.link_id = new bson_objectid_1.default();
|
|
@@ -20,6 +21,7 @@ class LinkRedirect {
|
|
|
20
21
|
this.from = data.from;
|
|
21
22
|
this.to = data.to;
|
|
22
23
|
this.edited = !!data.edited;
|
|
24
|
+
this.automationActionRevisionId = data.automationActionRevisionId ?? undefined;
|
|
23
25
|
}
|
|
24
26
|
}
|
|
25
27
|
exports.LinkRedirect = LinkRedirect;
|
|
@@ -5,8 +5,9 @@ export class LinkRedirect {
|
|
|
5
5
|
from: URL;
|
|
6
6
|
to: URL;
|
|
7
7
|
edited: boolean;
|
|
8
|
+
automationActionRevisionId: string | undefined;
|
|
8
9
|
|
|
9
|
-
constructor(data: {id?: string; from: URL; to: URL; edited?: boolean}) {
|
|
10
|
+
constructor(data: {id?: string; from: URL; to: URL; edited?: boolean; automationActionRevisionId?: string | null}) {
|
|
10
11
|
if (!data.id) {
|
|
11
12
|
this.link_id = new ObjectID();
|
|
12
13
|
}
|
|
@@ -18,5 +19,6 @@ export class LinkRedirect {
|
|
|
18
19
|
this.from = data.from;
|
|
19
20
|
this.to = data.to;
|
|
20
21
|
this.edited = !!data.edited;
|
|
22
|
+
this.automationActionRevisionId = data.automationActionRevisionId ?? undefined;
|
|
21
23
|
}
|
|
22
24
|
}
|
|
@@ -6,6 +6,7 @@ const {LinkRedirect} = require('./link-redirect');
|
|
|
6
6
|
/**
|
|
7
7
|
* @typedef {object} ILinkRedirectRepository
|
|
8
8
|
* @prop {(url: URL) => Promise<LinkRedirect|undefined>} getByURL
|
|
9
|
+
* @prop {(automationActionRevisionId: string, url: URL) => Promise<LinkRedirect|undefined>} getByAutomationActionRevisionAndURL
|
|
9
10
|
* @prop {({filter: string}) => Promise<LinkRedirect[]>} getAll
|
|
10
11
|
* @prop {({filter: string}) => Promise<String[]>} getFilteredIds
|
|
11
12
|
* @prop {(linkRedirect: LinkRedirect) => Promise<void>} save
|
|
@@ -19,6 +20,8 @@ const MEMBER_UUID_PLACEHOLDER = '%%{uuid}%%';
|
|
|
19
20
|
// Ghost uses UUID v4; this regex is not that strict
|
|
20
21
|
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
21
22
|
|
|
23
|
+
const isUniqueConstraintError = err => err?.code === 'ER_DUP_ENTRY' || err?.code?.startsWith?.('SQLITE_CONSTRAINT');
|
|
24
|
+
|
|
22
25
|
class LinkRedirectsService {
|
|
23
26
|
/** @type ILinkRedirectRepository */
|
|
24
27
|
#linkRedirectRepository;
|
|
@@ -71,13 +74,15 @@ class LinkRedirectsService {
|
|
|
71
74
|
/**
|
|
72
75
|
* @param {URL} from
|
|
73
76
|
* @param {URL} to
|
|
77
|
+
* @param {string} [automationActionRevisionId]
|
|
74
78
|
*
|
|
75
79
|
* @returns {Promise<LinkRedirect>}
|
|
76
80
|
*/
|
|
77
|
-
async addRedirect(from, to) {
|
|
81
|
+
async addRedirect(from, to, automationActionRevisionId) {
|
|
78
82
|
const link = new LinkRedirect({
|
|
79
83
|
from,
|
|
80
|
-
to
|
|
84
|
+
to,
|
|
85
|
+
automationActionRevisionId
|
|
81
86
|
});
|
|
82
87
|
|
|
83
88
|
await this.#linkRedirectRepository.save(link);
|
|
@@ -85,6 +90,37 @@ class LinkRedirectsService {
|
|
|
85
90
|
return link;
|
|
86
91
|
}
|
|
87
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Get the shared redirect for a destination within an automation action revision,
|
|
95
|
+
* creating it on first use. A concurrent winning insert is returned after a
|
|
96
|
+
* duplicate-key failure.
|
|
97
|
+
* @param {string} automationActionRevisionId
|
|
98
|
+
* @param {Readonly<URL>} to
|
|
99
|
+
* @returns {Promise<LinkRedirect>}
|
|
100
|
+
*/
|
|
101
|
+
async getOrAddAutomationRedirect(automationActionRevisionId, to) {
|
|
102
|
+
const existing = await this.#linkRedirectRepository.getByAutomationActionRevisionAndURL(automationActionRevisionId, to);
|
|
103
|
+
if (existing) {
|
|
104
|
+
return existing;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const from = await this.getSlugUrl();
|
|
108
|
+
try {
|
|
109
|
+
return await this.addRedirect(from, to, automationActionRevisionId);
|
|
110
|
+
} catch (err) {
|
|
111
|
+
if (!isUniqueConstraintError(err)) {
|
|
112
|
+
throw err;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const winner = await this.#linkRedirectRepository.getByAutomationActionRevisionAndURL(automationActionRevisionId, to);
|
|
116
|
+
if (winner) {
|
|
117
|
+
return winner;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
throw err;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
88
124
|
/**
|
|
89
125
|
* Returns the relative path prefix without subdirectory (e.g., /r/)
|
|
90
126
|
* Used for Express route matching where subdirectory is already stripped
|
|
@@ -7,6 +7,8 @@ module.exports = class ClickEvent {
|
|
|
7
7
|
member_uuid;
|
|
8
8
|
/** @type {ObjectID} */
|
|
9
9
|
link_id;
|
|
10
|
+
/** @type {Date | undefined} */
|
|
11
|
+
timestamp;
|
|
10
12
|
|
|
11
13
|
constructor(data) {
|
|
12
14
|
if (!data.id) {
|
|
@@ -21,5 +23,6 @@ module.exports = class ClickEvent {
|
|
|
21
23
|
|
|
22
24
|
this.member_uuid = data.member_uuid;
|
|
23
25
|
this.link_id = data.link_id;
|
|
26
|
+
this.timestamp = data.timestamp;
|
|
24
27
|
}
|
|
25
28
|
};
|
|
@@ -4,12 +4,37 @@ const errors = require('@tryghost/errors');
|
|
|
4
4
|
const urlUtils = require('../../../shared/url-utils').default;
|
|
5
5
|
|
|
6
6
|
class LinkTrackingServiceWrapper {
|
|
7
|
+
#initPromise;
|
|
8
|
+
#automationsApi;
|
|
9
|
+
|
|
10
|
+
constructor({
|
|
11
|
+
automationsApi = require('../automations/automations-api')
|
|
12
|
+
} = {}) {
|
|
13
|
+
this.#automationsApi = automationsApi;
|
|
14
|
+
}
|
|
15
|
+
|
|
7
16
|
async init() {
|
|
8
17
|
if (this.service) {
|
|
9
18
|
// Already done
|
|
10
19
|
return;
|
|
11
20
|
}
|
|
12
21
|
|
|
22
|
+
if (!this.#initPromise) {
|
|
23
|
+
this.#initPromise = this.#initialise();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const initPromise = this.#initPromise;
|
|
27
|
+
try {
|
|
28
|
+
await initPromise;
|
|
29
|
+
} catch (error) {
|
|
30
|
+
if (this.#initPromise === initPromise) {
|
|
31
|
+
this.#initPromise = undefined;
|
|
32
|
+
}
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async #initialise() {
|
|
13
38
|
const linkRedirection = require('../link-redirection');
|
|
14
39
|
if (!linkRedirection.service) {
|
|
15
40
|
throw new errors.InternalServerError({message: 'LinkRedirectionService should be initialised before LinkTrackingService'});
|
|
@@ -27,24 +52,31 @@ class LinkTrackingServiceWrapper {
|
|
|
27
52
|
linkRedirectRepository: linkRedirection.linkRedirectRepository
|
|
28
53
|
});
|
|
29
54
|
|
|
30
|
-
|
|
55
|
+
const linkClickRepository = new LinkClickRepository({
|
|
31
56
|
MemberLinkClickEventModel: models.MemberClickEvent,
|
|
32
57
|
Member: models.Member,
|
|
33
58
|
MemberLinkClickEvent: MemberLinkClickEvent,
|
|
34
59
|
DomainEvents
|
|
35
60
|
});
|
|
36
61
|
|
|
37
|
-
|
|
38
|
-
this.service = new LinkClickTrackingService({
|
|
62
|
+
const service = new LinkClickTrackingService({
|
|
39
63
|
linkRedirectService: linkRedirection.service,
|
|
40
|
-
linkClickRepository
|
|
64
|
+
linkClickRepository,
|
|
41
65
|
postLinkRepository,
|
|
42
66
|
DomainEvents,
|
|
43
|
-
urlUtils
|
|
67
|
+
urlUtils,
|
|
68
|
+
automationsApi: this.#automationsApi,
|
|
69
|
+
runInTransaction: callback => models.Base.transaction(callback)
|
|
44
70
|
});
|
|
45
71
|
|
|
46
|
-
await
|
|
72
|
+
await service.init();
|
|
73
|
+
|
|
74
|
+
// Expose the service only after it has finished initialising
|
|
75
|
+
this.linkClickRepository = linkClickRepository;
|
|
76
|
+
this.service = service;
|
|
47
77
|
}
|
|
48
78
|
}
|
|
49
79
|
|
|
50
80
|
module.exports = new LinkTrackingServiceWrapper();
|
|
81
|
+
// Exposed for testing purposes only
|
|
82
|
+
module.exports.LinkTrackingServiceWrapper = LinkTrackingServiceWrapper;
|