ghost 5.115.1 → 5.116.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (243) hide show
  1. package/components/{tryghost-api-framework-5.115.1.tgz → tryghost-api-framework-5.116.1.tgz} +0 -0
  2. package/components/tryghost-constants-5.116.1.tgz +0 -0
  3. package/components/tryghost-custom-fonts-5.116.1.tgz +0 -0
  4. package/components/{tryghost-custom-theme-settings-service-5.115.1.tgz → tryghost-custom-theme-settings-service-5.116.1.tgz} +0 -0
  5. package/components/{tryghost-domain-events-5.115.1.tgz → tryghost-domain-events-5.116.1.tgz} +0 -0
  6. package/components/{tryghost-donations-5.115.1.tgz → tryghost-donations-5.116.1.tgz} +0 -0
  7. package/components/tryghost-email-addresses-5.116.1.tgz +0 -0
  8. package/components/tryghost-email-service-5.116.1.tgz +0 -0
  9. package/components/tryghost-email-suppression-list-5.116.1.tgz +0 -0
  10. package/components/tryghost-html-to-plaintext-5.116.1.tgz +0 -0
  11. package/components/tryghost-i18n-5.116.1.tgz +0 -0
  12. package/components/tryghost-job-manager-5.116.1.tgz +0 -0
  13. package/components/tryghost-link-replacer-5.116.1.tgz +0 -0
  14. package/components/tryghost-magic-link-5.116.1.tgz +0 -0
  15. package/components/{tryghost-member-attribution-5.115.1.tgz → tryghost-member-attribution-5.116.1.tgz} +0 -0
  16. package/components/tryghost-member-events-5.116.1.tgz +0 -0
  17. package/components/tryghost-members-api-5.116.1.tgz +0 -0
  18. package/components/tryghost-members-csv-5.116.1.tgz +0 -0
  19. package/components/{tryghost-members-offers-5.115.1.tgz → tryghost-members-offers-5.116.1.tgz} +0 -0
  20. package/components/{tryghost-milestones-5.115.1.tgz → tryghost-milestones-5.116.1.tgz} +0 -0
  21. package/components/{tryghost-mw-error-handler-5.115.1.tgz → tryghost-mw-error-handler-5.116.1.tgz} +0 -0
  22. package/components/tryghost-mw-vhost-5.116.1.tgz +0 -0
  23. package/components/{tryghost-post-events-5.115.1.tgz → tryghost-post-events-5.116.1.tgz} +0 -0
  24. package/components/{tryghost-post-revisions-5.115.1.tgz → tryghost-post-revisions-5.116.1.tgz} +0 -0
  25. package/components/tryghost-posts-service-5.116.1.tgz +0 -0
  26. package/components/{tryghost-prometheus-metrics-5.115.1.tgz → tryghost-prometheus-metrics-5.116.1.tgz} +0 -0
  27. package/components/tryghost-security-5.116.1.tgz +0 -0
  28. package/components/{tryghost-tiers-5.115.1.tgz → tryghost-tiers-5.116.1.tgz} +0 -0
  29. package/components/tryghost-webmentions-5.116.1.tgz +0 -0
  30. package/content/themes/casper/LICENSE +1 -1
  31. package/content/themes/casper/README.md +1 -1
  32. package/content/themes/source/LICENSE +1 -1
  33. package/content/themes/source/README.md +1 -1
  34. package/content/themes/source/assets/built/screen.css +1 -1
  35. package/content/themes/source/assets/built/screen.css.map +1 -1
  36. package/content/themes/source/assets/css/screen.css +6 -11
  37. package/content/themes/source/partials/feature-image.hbs +2 -2
  38. package/core/boot.js +0 -42
  39. package/core/built/admin/assets/admin-x-activitypub/admin-x-activitypub.js +24764 -24129
  40. package/core/built/admin/assets/admin-x-demo/admin-x-demo.js +1 -1
  41. package/core/built/admin/assets/admin-x-demo/{index-15df2af5.mjs → index-a9601514.mjs} +3 -3
  42. package/core/built/admin/assets/admin-x-demo/{modals-8ca61d78.mjs → modals-c1789d04.mjs} +2 -2
  43. package/core/built/admin/assets/admin-x-settings/{CodeEditorView-d2e6872f.mjs → CodeEditorView-e9c9deb8.mjs} +2 -2
  44. package/core/built/admin/assets/admin-x-settings/admin-x-settings.js +2 -2
  45. package/core/built/admin/assets/admin-x-settings/{index-8e8821e5.mjs → index-84580c3a.mjs} +2 -2
  46. package/core/built/admin/assets/admin-x-settings/{index-f5cb3db3.mjs → index-f744cab7.mjs} +49 -35
  47. package/core/built/admin/assets/admin-x-settings/{modals-e8ae4d46.mjs → modals-d9ca60c5.mjs} +1198 -1192
  48. package/core/built/admin/assets/chunk.524.cb72a86e19c9ffd6172e.js +35 -0
  49. package/core/built/admin/assets/chunk.582.4f4d38ffe79fbdbd26f7.js +37 -0
  50. package/core/built/admin/assets/{chunk.874.461cb3cf5b6b36915f8c.js → chunk.713.e9027c0cc3c56110f5da.js} +125 -98
  51. package/core/built/admin/assets/{ghost-df7b9558260aa27d18b195ee895b487d.js → ghost-03b64c086f3c60cabc85fe7a7e2b640a.js} +144 -145
  52. package/core/built/admin/assets/ghost-ba58e9822f7384461e926c7e23f04a75.css +1 -0
  53. package/core/built/admin/assets/ghost-dark-f1f29683b14ffa11615b3bba8b6ab92c.css +1 -0
  54. package/core/built/admin/assets/koenig-lexical/index.css +1 -1
  55. package/core/built/admin/assets/koenig-lexical/koenig-lexical.js +20563 -20891
  56. package/core/built/admin/assets/koenig-lexical/koenig-lexical.umd.js +139 -139
  57. package/core/built/admin/assets/posts/posts.js +5732 -5667
  58. package/core/built/admin/assets/stats/stats.js +71082 -7533
  59. package/core/built/admin/assets/{vendor-68a4aa424a179a90f5bbc2b750def576.js → vendor-72026232b36d97babc6320917c16c321.js} +36 -34
  60. package/core/built/admin/index.html +6 -6
  61. package/core/cli/generate-data.js +1 -1
  62. package/core/frontend/helpers/ghost_head.js +6 -1
  63. package/core/frontend/public/ghost-stats.js +55 -2
  64. package/core/frontend/services/assets-minification/AdminAuthAssets.js +2 -1
  65. package/core/frontend/services/assets-minification/CardAssets.js +1 -1
  66. package/core/frontend/services/assets-minification/CommentCountsAssets.js +1 -1
  67. package/core/frontend/services/assets-minification/MemberAttributionAssets.js +1 -1
  68. package/core/frontend/services/assets-minification/Minifier.js +191 -0
  69. package/core/frontend/services/routing/controllers/previews.js +2 -1
  70. package/core/server/adapters/cache/Redis.js +1 -1
  71. package/core/server/adapters/lib/redis/AdapterCacheRedis.js +287 -0
  72. package/core/server/adapters/lib/redis/redis-store-factory.js +22 -0
  73. package/core/server/api/endpoints/posts.js +9 -3
  74. package/core/server/api/endpoints/previews.js +35 -1
  75. package/core/server/api/endpoints/utils/serializers/output/utils/post-gating.js +6 -9
  76. package/core/server/api/endpoints/utils/validators/input/settings.js +1 -1
  77. package/core/server/data/db/connection.js +2 -0
  78. package/core/server/data/db/index.js +1 -0
  79. package/core/server/data/importer/handlers/ImporterContentFileHandler.js +90 -0
  80. package/core/server/data/importer/import-manager.js +1 -1
  81. package/core/server/data/seeders/DataGenerator.js +288 -0
  82. package/core/server/data/seeders/importers/BenefitsImporter.js +28 -0
  83. package/core/server/data/seeders/importers/CommentsImporter.js +73 -0
  84. package/core/server/data/seeders/importers/EmailBatchesImporter.js +38 -0
  85. package/core/server/data/seeders/importers/EmailRecipientFailuresImporter.js +67 -0
  86. package/core/server/data/seeders/importers/EmailRecipientsImporter.js +212 -0
  87. package/core/server/data/seeders/importers/EmailsImporter.js +99 -0
  88. package/core/server/data/seeders/importers/LabelsImporter.js +41 -0
  89. package/core/server/data/seeders/importers/MembersClickEventsImporter.js +69 -0
  90. package/core/server/data/seeders/importers/MembersCreatedEventsImporter.js +103 -0
  91. package/core/server/data/seeders/importers/MembersFeedbackImporter.js +45 -0
  92. package/core/server/data/seeders/importers/MembersImporter.js +111 -0
  93. package/core/server/data/seeders/importers/MembersLabelsImporter.js +39 -0
  94. package/core/server/data/seeders/importers/MembersLoginEventsImporter.js +69 -0
  95. package/core/server/data/seeders/importers/MembersNewslettersImporter.js +38 -0
  96. package/core/server/data/seeders/importers/MembersPaidSubscriptionEventsImporter.js +99 -0
  97. package/core/server/data/seeders/importers/MembersProductsImporter.js +42 -0
  98. package/core/server/data/seeders/importers/MembersStatusEventsImporter.js +58 -0
  99. package/core/server/data/seeders/importers/MembersStripeCustomersImporter.js +60 -0
  100. package/core/server/data/seeders/importers/MembersStripeCustomersSubscriptionsImporter.js +259 -0
  101. package/core/server/data/seeders/importers/MembersSubscribeEventsImporter.js +69 -0
  102. package/core/server/data/seeders/importers/MembersSubscriptionCreatedEventsImporter.js +95 -0
  103. package/core/server/data/seeders/importers/NewslettersImporter.js +40 -0
  104. package/core/server/data/seeders/importers/OffersImporter.js +70 -0
  105. package/core/server/data/seeders/importers/PostsAuthorsImporter.js +32 -0
  106. package/core/server/data/seeders/importers/PostsImporter.js +102 -0
  107. package/core/server/data/seeders/importers/PostsProductsImporter.js +35 -0
  108. package/core/server/data/seeders/importers/PostsTagsImporter.js +46 -0
  109. package/core/server/data/seeders/importers/ProductsBenefitsImporter.js +54 -0
  110. package/core/server/data/seeders/importers/ProductsImporter.js +90 -0
  111. package/core/server/data/seeders/importers/RecommendationClickEventsImporter.js +32 -0
  112. package/core/server/data/seeders/importers/RecommendationSubscribeEventsImporter.js +32 -0
  113. package/core/server/data/seeders/importers/RecommendationsImporter.js +34 -0
  114. package/core/server/data/seeders/importers/RedirectsImporter.js +49 -0
  115. package/core/server/data/seeders/importers/RolesUsersImporter.js +42 -0
  116. package/core/server/data/seeders/importers/StripePricesImporter.js +69 -0
  117. package/core/server/data/seeders/importers/StripeProductsImporter.js +34 -0
  118. package/core/server/data/seeders/importers/TableImporter.js +187 -0
  119. package/core/server/data/seeders/importers/TagsImporter.js +41 -0
  120. package/core/server/data/seeders/importers/UsersImporter.js +31 -0
  121. package/core/server/data/seeders/importers/WebMentionsImporter.js +42 -0
  122. package/core/server/data/seeders/importers/index.js +41 -0
  123. package/core/server/data/seeders/utils/JsonImporter.js +39 -0
  124. package/core/server/data/seeders/utils/blog-info.js +3 -0
  125. package/core/server/data/seeders/utils/database-date.js +7 -0
  126. package/core/server/data/seeders/utils/event-generator.js +48 -0
  127. package/core/server/data/seeders/utils/random.js +13 -0
  128. package/core/server/data/seeders/utils/topological-sort.js +33 -0
  129. package/core/server/services/adapter-manager/AdapterManager.js +161 -0
  130. package/core/server/services/adapter-manager/index.js +1 -1
  131. package/core/server/services/announcement-bar-service/AnnouncementBarSettings.js +54 -0
  132. package/core/server/services/announcement-bar-service/AnnouncementVisibilityValues.js +11 -0
  133. package/core/server/services/announcement-bar-service/index.js +1 -1
  134. package/core/server/services/api-version-compatibility/APIVersionCompatibilityService.js +1 -1
  135. package/core/server/services/auth/session/session-service.js +15 -5
  136. package/core/server/services/custom-redirects/index.js +1 -1
  137. package/core/server/services/email-analytics/EmailAnalyticsProviderMailgun.js +1 -1
  138. package/core/server/services/email-service/EmailServiceWrapper.js +4 -4
  139. package/core/server/services/email-suppression-list/MailgunEmailSuppressionList.js +1 -1
  140. package/core/server/services/email-suppression-list/service.js +1 -1
  141. package/core/server/services/lib/DynamicRedirectManager.js +156 -0
  142. package/core/server/services/lib/EmailContentGenerator.js +54 -0
  143. package/core/server/services/lib/InMemoryRepository.js +62 -0
  144. package/core/server/services/lib/InMemoryRepository.ts +80 -0
  145. package/core/server/services/lib/MailgunClient.js +364 -0
  146. package/core/server/services/link-redirection/LinkRedirect.js +26 -0
  147. package/core/server/services/link-redirection/LinkRedirectRepository.js +7 -7
  148. package/core/server/services/link-redirection/LinkRedirectsService.js +123 -0
  149. package/core/server/services/link-redirection/README.md +151 -0
  150. package/core/server/services/link-redirection/RedirectEvent.js +24 -0
  151. package/core/server/services/link-redirection/index.js +1 -1
  152. package/core/server/services/link-tracking/LinkClickTrackingService.js +1 -1
  153. package/core/server/services/mail/index.js +1 -1
  154. package/core/server/services/mail-events/InMemoryMailEventRepository.js +2 -2
  155. package/core/server/services/mail-events/InMemoryMailEventRepository.ts +1 -1
  156. package/core/server/services/members-events/LastSeenAtUpdater.js +1 -1
  157. package/core/server/services/offers/service.js +1 -1
  158. package/core/server/services/recommendations/RecommendationServiceWrapper.js +8 -8
  159. package/core/server/services/recommendations/service/BookshelfClickEventRepository.js +48 -0
  160. package/core/server/services/recommendations/service/BookshelfClickEventRepository.ts +49 -0
  161. package/core/server/services/recommendations/service/BookshelfRecommendationRepository.js +98 -0
  162. package/core/server/services/recommendations/service/BookshelfRecommendationRepository.ts +117 -0
  163. package/core/server/services/recommendations/service/BookshelfRepository.js +134 -0
  164. package/core/server/services/recommendations/service/BookshelfRepository.ts +196 -0
  165. package/core/server/services/recommendations/service/BookshelfSubscribeEventRepository.js +48 -0
  166. package/core/server/services/recommendations/service/BookshelfSubscribeEventRepository.ts +49 -0
  167. package/core/server/services/recommendations/service/ClickEvent.js +33 -0
  168. package/core/server/services/recommendations/service/ClickEvent.ts +32 -0
  169. package/core/server/services/recommendations/service/InMemoryRecommendationRepository.js +19 -0
  170. package/core/server/services/recommendations/service/InMemoryRecommendationRepository.ts +20 -0
  171. package/core/server/services/recommendations/service/IncomingRecommendationController.js +34 -0
  172. package/core/server/services/recommendations/service/IncomingRecommendationController.ts +51 -0
  173. package/core/server/services/recommendations/service/IncomingRecommendationEmailRenderer.js +25 -0
  174. package/core/server/services/recommendations/service/IncomingRecommendationEmailRenderer.ts +37 -0
  175. package/core/server/services/recommendations/service/IncomingRecommendationService.js +93 -0
  176. package/core/server/services/recommendations/service/IncomingRecommendationService.ts +160 -0
  177. package/core/server/services/recommendations/service/Recommendation.js +140 -0
  178. package/core/server/services/recommendations/service/Recommendation.ts +201 -0
  179. package/core/server/services/recommendations/service/RecommendationController.js +208 -0
  180. package/core/server/services/recommendations/service/RecommendationController.ts +258 -0
  181. package/core/server/services/recommendations/service/RecommendationMetadataService.js +86 -0
  182. package/core/server/services/recommendations/service/RecommendationMetadataService.ts +128 -0
  183. package/core/server/services/recommendations/service/RecommendationRepository.js +2 -0
  184. package/core/server/services/recommendations/service/RecommendationRepository.ts +13 -0
  185. package/core/server/services/recommendations/service/RecommendationService.js +228 -0
  186. package/core/server/services/recommendations/service/RecommendationService.ts +281 -0
  187. package/core/server/services/recommendations/service/SubscribeEvent.js +33 -0
  188. package/core/server/services/recommendations/service/SubscribeEvent.ts +32 -0
  189. package/core/server/services/recommendations/service/UnsafeData.js +183 -0
  190. package/core/server/services/recommendations/service/UnsafeData.ts +217 -0
  191. package/core/server/services/recommendations/service/WellknownService.js +36 -0
  192. package/core/server/services/recommendations/service/WellknownService.ts +47 -0
  193. package/core/server/services/recommendations/service/index.js +31 -0
  194. package/core/server/services/recommendations/service/index.ts +15 -0
  195. package/core/server/services/recommendations/service/libraries.d.ts +5 -0
  196. package/core/server/services/slack-notifications/SlackNotifications.js +211 -0
  197. package/core/server/services/slack-notifications/SlackNotificationsService.js +90 -0
  198. package/core/server/services/slack-notifications/service.js +4 -6
  199. package/core/server/web/api/endpoints/admin/app.js +1 -21
  200. package/core/server/web/api/middleware/version-match.js +41 -0
  201. package/core/shared/labs.js +2 -2
  202. package/package.json +87 -104
  203. package/tsconfig.tsbuildinfo +1 -1
  204. package/yarn.lock +1470 -1540
  205. package/components/tryghost-adapter-cache-redis-5.115.1.tgz +0 -0
  206. package/components/tryghost-adapter-manager-5.115.1.tgz +0 -0
  207. package/components/tryghost-announcement-bar-settings-5.115.1.tgz +0 -0
  208. package/components/tryghost-constants-5.115.1.tgz +0 -0
  209. package/components/tryghost-custom-fonts-5.115.1.tgz +0 -0
  210. package/components/tryghost-data-generator-5.115.1.tgz +0 -0
  211. package/components/tryghost-email-addresses-5.115.1.tgz +0 -0
  212. package/components/tryghost-email-content-generator-5.115.1.tgz +0 -0
  213. package/components/tryghost-email-events-5.115.1.tgz +0 -0
  214. package/components/tryghost-email-service-5.115.1.tgz +0 -0
  215. package/components/tryghost-email-suppression-list-5.115.1.tgz +0 -0
  216. package/components/tryghost-express-dynamic-redirects-5.115.1.tgz +0 -0
  217. package/components/tryghost-ghost-5.115.1.tgz +0 -0
  218. package/components/tryghost-html-to-plaintext-5.115.1.tgz +0 -0
  219. package/components/tryghost-i18n-5.115.1.tgz +0 -0
  220. package/components/tryghost-importer-handler-content-files-5.115.1.tgz +0 -0
  221. package/components/tryghost-in-memory-repository-5.115.1.tgz +0 -0
  222. package/components/tryghost-job-manager-5.115.1.tgz +0 -0
  223. package/components/tryghost-link-redirects-5.115.1.tgz +0 -0
  224. package/components/tryghost-link-replacer-5.115.1.tgz +0 -0
  225. package/components/tryghost-magic-link-5.115.1.tgz +0 -0
  226. package/components/tryghost-mailgun-client-5.115.1.tgz +0 -0
  227. package/components/tryghost-member-events-5.115.1.tgz +0 -0
  228. package/components/tryghost-members-api-5.115.1.tgz +0 -0
  229. package/components/tryghost-members-csv-5.115.1.tgz +0 -0
  230. package/components/tryghost-members-payments-5.115.1.tgz +0 -0
  231. package/components/tryghost-minifier-5.115.1.tgz +0 -0
  232. package/components/tryghost-mw-version-match-5.115.1.tgz +0 -0
  233. package/components/tryghost-mw-vhost-5.115.1.tgz +0 -0
  234. package/components/tryghost-posts-service-5.115.1.tgz +0 -0
  235. package/components/tryghost-recommendations-5.115.1.tgz +0 -0
  236. package/components/tryghost-security-5.115.1.tgz +0 -0
  237. package/components/tryghost-slack-notifications-5.115.1.tgz +0 -0
  238. package/components/tryghost-webmentions-5.115.1.tgz +0 -0
  239. package/core/built/admin/assets/chunk.524.2439684964c164c598ab.js +0 -35
  240. package/core/built/admin/assets/chunk.582.bf5a2bbb2c4eb69ef1e7.js +0 -37
  241. package/core/built/admin/assets/ghost-327b17ea23cb8c89bd7e6a51e18e8506.css +0 -1
  242. package/core/built/admin/assets/ghost-dark-f30a597ac19632a118939492591c531b.css +0 -1
  243. /package/core/built/admin/assets/{chunk.874.461cb3cf5b6b36915f8c.js.LICENSE.txt → chunk.713.e9027c0cc3c56110f5da.js.LICENSE.txt} +0 -0
@@ -0,0 +1,201 @@
1
+ /* eslint-disable ghost/filenames/match-exported-class */
2
+
3
+ import ObjectId from 'bson-objectid';
4
+ import errors from '@tryghost/errors';
5
+ import {UnsafeData} from './UnsafeData';
6
+
7
+ /**
8
+ * We never expose Entities outside of services. Because we should never expose the bussiness logic methods. The plain objects are used for that
9
+ */
10
+ export type RecommendationPlain = {
11
+ id: string,
12
+ title: string
13
+ description: string|null
14
+ excerpt: string|null // Fetched from the site meta data
15
+ featuredImage: URL|null // Fetched from the site meta data
16
+ favicon: URL|null // Fetched from the site meta data
17
+ url: URL
18
+ oneClickSubscribe: boolean,
19
+ createdAt: Date,
20
+ updatedAt: Date|null,
21
+
22
+ /**
23
+ * These are read only, you cannot change them
24
+ */
25
+ clickCount?: number
26
+ subscriberCount?: number
27
+ }
28
+
29
+ export type RecommendationCreateData = {
30
+ id?: string
31
+ title: string
32
+ description: string|null
33
+ excerpt: string|null // Fetched from the site meta data
34
+ featuredImage: URL|string|null // Fetched from the site meta data
35
+ favicon: URL|string|null // Fetched from the site meta data
36
+ url: URL|string
37
+ oneClickSubscribe: boolean
38
+ createdAt?: Date
39
+ updatedAt?: Date|null,
40
+
41
+ /**
42
+ * These are read only, you cannot change them
43
+ */
44
+ clickCount?: number
45
+ subscriberCount?: number
46
+ }
47
+
48
+ export type AddRecommendation = Omit<RecommendationCreateData, 'id'|'createdAt'|'updatedAt'>
49
+ export type EditRecommendation = Partial<AddRecommendation>
50
+
51
+ export class Recommendation {
52
+ id: string;
53
+ title: string;
54
+ description: string|null;
55
+ excerpt: string|null; // Fetched from the site meta data
56
+ featuredImage: URL|null; // Fetched from the site meta data
57
+ favicon: URL|null; // Fetched from the site meta data
58
+ url: URL;
59
+ oneClickSubscribe: boolean;
60
+ createdAt: Date;
61
+ updatedAt: Date|null;
62
+ #clickCount: number|undefined;
63
+ #subscriberCount: number|undefined;
64
+
65
+ #deleted: boolean;
66
+
67
+ get deleted() {
68
+ return this.#deleted;
69
+ }
70
+
71
+ get clickCount() {
72
+ return this.#clickCount;
73
+ }
74
+
75
+ get subscriberCount() {
76
+ return this.#subscriberCount;
77
+ }
78
+
79
+ private constructor(data: RecommendationPlain) {
80
+ this.id = data.id;
81
+ this.title = data.title;
82
+ this.description = data.description;
83
+ this.excerpt = data.excerpt;
84
+ this.featuredImage = data.featuredImage;
85
+ this.favicon = data.favicon;
86
+ this.url = data.url;
87
+ this.oneClickSubscribe = data.oneClickSubscribe;
88
+ this.createdAt = data.createdAt;
89
+ this.updatedAt = data.updatedAt;
90
+ this.#clickCount = data.clickCount;
91
+ this.#subscriberCount = data.subscriberCount;
92
+ this.#deleted = false;
93
+ }
94
+
95
+ static validate(properties: AddRecommendation) {
96
+ if (properties.title.length === 0) {
97
+ throw new errors.ValidationError({
98
+ message: 'Title must not be empty'
99
+ });
100
+ }
101
+
102
+ if (properties.title.length > 2000) {
103
+ throw new errors.ValidationError({
104
+ message: 'Title must be less than 2000 characters'
105
+ });
106
+ }
107
+
108
+ if (properties.description && properties.description.length > 200) {
109
+ throw new errors.ValidationError({
110
+ message: 'Description must be less than 200 characters'
111
+ });
112
+ }
113
+ }
114
+
115
+ clean() {
116
+ if (this.description !== null && this.description.length === 0) {
117
+ this.description = null;
118
+ }
119
+
120
+ if (this.excerpt !== null && this.excerpt.length === 0) {
121
+ this.excerpt = null;
122
+ }
123
+
124
+ if (this.excerpt !== null && this.excerpt.length > 2000) {
125
+ this.excerpt = this.excerpt.slice(0, 1997) + '...';
126
+ }
127
+
128
+ this.createdAt.setMilliseconds(0);
129
+ this.updatedAt?.setMilliseconds(0);
130
+ }
131
+
132
+ static create(data: RecommendationCreateData) {
133
+ const id = data.id ?? ObjectId().toString();
134
+
135
+ const d = {
136
+ id,
137
+ title: data.title,
138
+ description: data.description,
139
+ excerpt: data.excerpt,
140
+ featuredImage: new UnsafeData(data.featuredImage, {field: ['featuredImage']}).nullable.url,
141
+ favicon: new UnsafeData(data.favicon, {field: ['favicon']}).nullable.url,
142
+ url: new UnsafeData(data.url, {field: ['url']}).url,
143
+ oneClickSubscribe: data.oneClickSubscribe,
144
+ createdAt: data.createdAt ?? new Date(),
145
+ updatedAt: data.updatedAt ?? null,
146
+ clickCount: data.clickCount,
147
+ subscriberCount: data.subscriberCount
148
+ };
149
+
150
+ this.validate(d);
151
+ const recommendation = new Recommendation(d);
152
+ recommendation.clean();
153
+
154
+ return recommendation;
155
+ }
156
+
157
+ get plain(): RecommendationPlain {
158
+ return {
159
+ id: this.id,
160
+ title: this.title,
161
+ description: this.description,
162
+ excerpt: this.excerpt,
163
+ featuredImage: this.featuredImage,
164
+ favicon: this.favicon,
165
+ url: this.url,
166
+ oneClickSubscribe: this.oneClickSubscribe,
167
+ createdAt: this.createdAt,
168
+ updatedAt: this.updatedAt,
169
+ clickCount: this.clickCount,
170
+ subscriberCount: this.subscriberCount
171
+ };
172
+ }
173
+
174
+ /**
175
+ * Change the specified properties. Properties that are set to undefined will not be changed
176
+ */
177
+ edit(properties: EditRecommendation) {
178
+ // Delete undefined properties
179
+ const newProperties = this.plain;
180
+ let didChange = false;
181
+
182
+ for (const key of Object.keys(properties) as (keyof EditRecommendation)[]) {
183
+ if (Object.prototype.hasOwnProperty.call(properties, key) && properties[key] !== undefined && properties[key] !== newProperties[key]) {
184
+ (newProperties as Record<string, unknown>)[key] = properties[key] as unknown;
185
+ didChange = true;
186
+ }
187
+ }
188
+
189
+ if (!didChange) {
190
+ return;
191
+ }
192
+ newProperties.updatedAt = new Date();
193
+
194
+ const created = Recommendation.create(newProperties);
195
+ Object.assign(this, created);
196
+ }
197
+
198
+ delete() {
199
+ this.#deleted = true;
200
+ }
201
+ }
@@ -0,0 +1,208 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.RecommendationController = void 0;
7
+ /* eslint-disable @typescript-eslint/no-explicit-any */
8
+ const errors_1 = __importDefault(require("@tryghost/errors"));
9
+ const UnsafeData_1 = require("./UnsafeData");
10
+ const RecommendationIncludesMap = {
11
+ 'count.clicks': 'clickCount',
12
+ 'count.subscribers': 'subscriberCount'
13
+ };
14
+ const RecommendationOrderMap = {
15
+ title: 'title',
16
+ description: 'description',
17
+ excerpt: 'excerpt',
18
+ one_click_subscribe: 'oneClickSubscribe',
19
+ created_at: 'createdAt',
20
+ updated_at: 'updatedAt',
21
+ 'count.clicks': 'clickCount',
22
+ 'count.subscribers': 'subscriberCount'
23
+ };
24
+ class RecommendationController {
25
+ service;
26
+ constructor(deps) {
27
+ this.service = deps.service;
28
+ }
29
+ async read(frame) {
30
+ const options = new UnsafeData_1.UnsafeData(frame.options);
31
+ const id = options.key('id').string;
32
+ const recommendation = await this.service.readRecommendation(id);
33
+ return this.#serialize([recommendation]);
34
+ }
35
+ async add(frame) {
36
+ const data = new UnsafeData_1.UnsafeData(frame.data);
37
+ const recommendation = data.key('recommendations').index(0);
38
+ const plain = {
39
+ title: recommendation.key('title').string,
40
+ url: recommendation.key('url').url,
41
+ // Optional fields
42
+ oneClickSubscribe: recommendation.optionalKey('one_click_subscribe')?.boolean ?? false,
43
+ description: recommendation.optionalKey('description')?.nullable.string ?? null,
44
+ excerpt: recommendation.optionalKey('excerpt')?.nullable.string ?? null,
45
+ featuredImage: recommendation.optionalKey('featured_image')?.nullable.url ?? null,
46
+ favicon: recommendation.optionalKey('favicon')?.nullable.url ?? null
47
+ };
48
+ return this.#serialize([await this.service.addRecommendation(plain)]);
49
+ }
50
+ /**
51
+ * Given a recommendation URL, returns either an existing recommendation with that url and updated metadata,
52
+ * or the metadata from that URL as if it would create a new one (without creating a new one)
53
+ *
54
+ * This can be used in the frontend when creating a new recommendation (duplication checking + showing a preview before saving)
55
+ */
56
+ async check(frame) {
57
+ const data = new UnsafeData_1.UnsafeData(frame.data);
58
+ const recommendation = data.key('recommendations').index(0);
59
+ const url = recommendation.key('url').url;
60
+ return this.#serialize([await this.service.checkRecommendation(url)]);
61
+ }
62
+ async edit(frame) {
63
+ const options = new UnsafeData_1.UnsafeData(frame.options);
64
+ const data = new UnsafeData_1.UnsafeData(frame.data);
65
+ const recommendation = data.key('recommendations').index(0);
66
+ const id = options.key('id').string;
67
+ const plain = {
68
+ title: recommendation.optionalKey('title')?.string,
69
+ url: recommendation.optionalKey('url')?.url,
70
+ oneClickSubscribe: recommendation.optionalKey('one_click_subscribe')?.boolean,
71
+ description: recommendation.optionalKey('description')?.nullable.string,
72
+ excerpt: recommendation.optionalKey('excerpt')?.nullable.string,
73
+ featuredImage: recommendation.optionalKey('featured_image')?.nullable.url,
74
+ favicon: recommendation.optionalKey('favicon')?.nullable.url
75
+ };
76
+ return this.#serialize([await this.service.editRecommendation(id, plain)]);
77
+ }
78
+ async destroy(frame) {
79
+ const options = new UnsafeData_1.UnsafeData(frame.options);
80
+ const id = options.key('id').string;
81
+ await this.service.deleteRecommendation(id);
82
+ }
83
+ #stringToOrder(str) {
84
+ if (!str) {
85
+ // Default order
86
+ return [
87
+ {
88
+ field: 'createdAt',
89
+ direction: 'desc'
90
+ }
91
+ ];
92
+ }
93
+ const parts = str.split(',');
94
+ const order = [];
95
+ for (const [index, part] of parts.entries()) {
96
+ const trimmed = part.trim();
97
+ const fieldData = new UnsafeData_1.UnsafeData(trimmed.split(' ')[0].trim(), { field: ['order', index.toString(), 'field'] });
98
+ const directionData = new UnsafeData_1.UnsafeData(trimmed.split(' ')[1]?.trim() ?? 'desc', { field: ['order', index.toString(), 'direction'] });
99
+ const validatedField = fieldData.enum(Object.keys(RecommendationOrderMap));
100
+ const direction = directionData.enum(['asc', 'desc']);
101
+ // Convert 'count.' and camelCase to snake_case
102
+ const field = RecommendationOrderMap[validatedField];
103
+ order.push({
104
+ field,
105
+ direction
106
+ });
107
+ }
108
+ return order;
109
+ }
110
+ async browse(frame) {
111
+ const options = new UnsafeData_1.UnsafeData(frame.options);
112
+ const page = options.optionalKey('page')?.integer ?? 1;
113
+ const limit = options.optionalKey('limit')?.integer ?? 5;
114
+ const include = options.optionalKey('withRelated')?.array.map((item) => {
115
+ return RecommendationIncludesMap[item.enum(Object.keys(RecommendationIncludesMap))];
116
+ }) ?? [];
117
+ const filter = options.optionalKey('filter')?.string;
118
+ const orderOption = options.optionalKey('order')?.string;
119
+ const order = this.#stringToOrder(orderOption);
120
+ const count = await this.service.countRecommendations({});
121
+ const recommendations = (await this.service.listRecommendations({ page, limit, filter, include, order }));
122
+ return this.#serialize(recommendations, {
123
+ pagination: this.#serializePagination({ page, limit, count })
124
+ });
125
+ }
126
+ async trackClicked(frame) {
127
+ const member = this.#optionalAuthMember(frame);
128
+ const options = new UnsafeData_1.UnsafeData(frame.options);
129
+ const id = options.key('id').string;
130
+ await this.service.trackClicked({
131
+ id,
132
+ memberId: member?.id
133
+ });
134
+ }
135
+ async trackSubscribed(frame) {
136
+ const member = this.#authMember(frame);
137
+ const options = new UnsafeData_1.UnsafeData(frame.options);
138
+ const id = options.key('id').string;
139
+ await this.service.trackSubscribed({
140
+ id,
141
+ memberId: member.id
142
+ });
143
+ }
144
+ #authMember(frame) {
145
+ const options = new UnsafeData_1.UnsafeData(frame.options);
146
+ const memberId = options.key('context').optionalKey('member')?.nullable.key('id').string;
147
+ if (!memberId) {
148
+ // This is an internal server error because authentication should happen outside this service.
149
+ throw new errors_1.default.UnauthorizedError({
150
+ message: 'Member not found'
151
+ });
152
+ }
153
+ return {
154
+ id: memberId
155
+ };
156
+ }
157
+ #optionalAuthMember(frame) {
158
+ try {
159
+ const member = this.#authMember(frame);
160
+ return member;
161
+ }
162
+ catch (e) {
163
+ if (e instanceof errors_1.default.UnauthorizedError) {
164
+ // This is fine, this is not required
165
+ }
166
+ else {
167
+ throw e;
168
+ }
169
+ }
170
+ return null;
171
+ }
172
+ #serialize(recommendations, meta) {
173
+ return {
174
+ data: recommendations.map((entity) => {
175
+ const d = {
176
+ id: entity.id ?? null,
177
+ title: entity.title ?? null,
178
+ description: entity.description ?? null,
179
+ excerpt: entity.excerpt ?? null,
180
+ featured_image: entity.featuredImage?.toString() ?? null,
181
+ favicon: entity.favicon?.toString() ?? null,
182
+ url: entity.url?.toString() ?? null,
183
+ one_click_subscribe: entity.oneClickSubscribe ?? null,
184
+ created_at: entity.createdAt?.toISOString() ?? null,
185
+ updated_at: entity.updatedAt?.toISOString() ?? null,
186
+ count: entity.clickCount !== undefined || entity.subscriberCount !== undefined ? {
187
+ clicks: entity.clickCount,
188
+ subscribers: entity.subscriberCount
189
+ } : undefined
190
+ };
191
+ return d;
192
+ }),
193
+ meta
194
+ };
195
+ }
196
+ #serializePagination({ page, limit, count }) {
197
+ const pages = Math.ceil(count / limit);
198
+ return {
199
+ page,
200
+ limit,
201
+ total: count,
202
+ pages,
203
+ prev: page > 1 ? page - 1 : null,
204
+ next: page < pages ? page + 1 : null
205
+ };
206
+ }
207
+ }
208
+ exports.RecommendationController = RecommendationController;
@@ -0,0 +1,258 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import errors from '@tryghost/errors';
3
+ import {AddRecommendation, Recommendation, RecommendationPlain} from './Recommendation';
4
+ import {RecommendationService} from './RecommendationService';
5
+ import {UnsafeData} from './UnsafeData';
6
+ import {OrderOption} from './BookshelfRepository';
7
+
8
+ type Frame = {
9
+ data: unknown,
10
+ options: unknown,
11
+ user: unknown,
12
+ };
13
+
14
+ const RecommendationIncludesMap = {
15
+ 'count.clicks': 'clickCount' as const,
16
+ 'count.subscribers': 'subscriberCount' as const
17
+ };
18
+
19
+ const RecommendationOrderMap = {
20
+ title: 'title' as const,
21
+ description: 'description' as const,
22
+ excerpt: 'excerpt' as const,
23
+ one_click_subscribe: 'oneClickSubscribe' as const,
24
+ created_at: 'createdAt' as const,
25
+ updated_at: 'updatedAt' as const,
26
+ 'count.clicks': 'clickCount' as const,
27
+ 'count.subscribers': 'subscriberCount' as const
28
+ };
29
+
30
+ export class RecommendationController {
31
+ service: RecommendationService;
32
+
33
+ constructor(deps: {service: RecommendationService}) {
34
+ this.service = deps.service;
35
+ }
36
+
37
+ async read(frame: Frame) {
38
+ const options = new UnsafeData(frame.options);
39
+ const id = options.key('id').string;
40
+
41
+ const recommendation = await this.service.readRecommendation(id);
42
+
43
+ return this.#serialize(
44
+ [recommendation]
45
+ );
46
+ }
47
+
48
+ async add(frame: Frame) {
49
+ const data = new UnsafeData(frame.data);
50
+ const recommendation = data.key('recommendations').index(0);
51
+ const plain: AddRecommendation = {
52
+ title: recommendation.key('title').string,
53
+ url: recommendation.key('url').url,
54
+
55
+ // Optional fields
56
+ oneClickSubscribe: recommendation.optionalKey('one_click_subscribe')?.boolean ?? false,
57
+ description: recommendation.optionalKey('description')?.nullable.string ?? null,
58
+ excerpt: recommendation.optionalKey('excerpt')?.nullable.string ?? null,
59
+ featuredImage: recommendation.optionalKey('featured_image')?.nullable.url ?? null,
60
+ favicon: recommendation.optionalKey('favicon')?.nullable.url ?? null
61
+ };
62
+
63
+ return this.#serialize(
64
+ [await this.service.addRecommendation(plain)]
65
+ );
66
+ }
67
+
68
+ /**
69
+ * Given a recommendation URL, returns either an existing recommendation with that url and updated metadata,
70
+ * or the metadata from that URL as if it would create a new one (without creating a new one)
71
+ *
72
+ * This can be used in the frontend when creating a new recommendation (duplication checking + showing a preview before saving)
73
+ */
74
+ async check(frame: Frame) {
75
+ const data = new UnsafeData(frame.data);
76
+ const recommendation = data.key('recommendations').index(0);
77
+ const url = recommendation.key('url').url;
78
+
79
+ return this.#serialize(
80
+ [await this.service.checkRecommendation(url)]
81
+ );
82
+ }
83
+
84
+ async edit(frame: Frame) {
85
+ const options = new UnsafeData(frame.options);
86
+ const data = new UnsafeData(frame.data);
87
+ const recommendation = data.key('recommendations').index(0);
88
+
89
+ const id = options.key('id').string;
90
+ const plain: Partial<RecommendationPlain> = {
91
+ title: recommendation.optionalKey('title')?.string,
92
+ url: recommendation.optionalKey('url')?.url,
93
+ oneClickSubscribe: recommendation.optionalKey('one_click_subscribe')?.boolean,
94
+ description: recommendation.optionalKey('description')?.nullable.string,
95
+ excerpt: recommendation.optionalKey('excerpt')?.nullable.string,
96
+ featuredImage: recommendation.optionalKey('featured_image')?.nullable.url,
97
+ favicon: recommendation.optionalKey('favicon')?.nullable.url
98
+ };
99
+
100
+ return this.#serialize(
101
+ [await this.service.editRecommendation(id, plain)]
102
+ );
103
+ }
104
+
105
+ async destroy(frame: Frame) {
106
+ const options = new UnsafeData(frame.options);
107
+ const id = options.key('id').string;
108
+ await this.service.deleteRecommendation(id);
109
+ }
110
+
111
+ #stringToOrder(str?: string) {
112
+ if (!str) {
113
+ // Default order
114
+ return [
115
+ {
116
+ field: 'createdAt' as const,
117
+ direction: 'desc' as const
118
+ }
119
+ ];
120
+ }
121
+
122
+ const parts = str.split(',');
123
+ const order: OrderOption<Recommendation> = [];
124
+ for (const [index, part] of parts.entries()) {
125
+ const trimmed = part.trim();
126
+ const fieldData = new UnsafeData(trimmed.split(' ')[0].trim(), {field: ['order', index.toString(), 'field']});
127
+ const directionData = new UnsafeData(trimmed.split(' ')[1]?.trim() ?? 'desc', {field: ['order', index.toString(), 'direction']});
128
+
129
+ const validatedField = fieldData.enum(
130
+ Object.keys(RecommendationOrderMap) as (keyof typeof RecommendationOrderMap)[]
131
+ );
132
+ const direction = directionData.enum(['asc' as const, 'desc' as const]);
133
+
134
+ // Convert 'count.' and camelCase to snake_case
135
+ const field = RecommendationOrderMap[validatedField];
136
+ order.push({
137
+ field,
138
+ direction
139
+ });
140
+ }
141
+
142
+ return order;
143
+ }
144
+
145
+ async browse(frame: Frame) {
146
+ const options = new UnsafeData(frame.options);
147
+
148
+ const page = options.optionalKey('page')?.integer ?? 1;
149
+ const limit = options.optionalKey('limit')?.integer ?? 5;
150
+ const include = options.optionalKey('withRelated')?.array.map((item) => {
151
+ return RecommendationIncludesMap[item.enum(
152
+ Object.keys(RecommendationIncludesMap) as (keyof typeof RecommendationIncludesMap)[]
153
+ )];
154
+ }) ?? [];
155
+ const filter = options.optionalKey('filter')?.string;
156
+
157
+ const orderOption = options.optionalKey('order')?.string;
158
+ const order = this.#stringToOrder(orderOption);
159
+
160
+ const count = await this.service.countRecommendations({});
161
+ const recommendations = (await this.service.listRecommendations({page, limit, filter, include, order}));
162
+
163
+ return this.#serialize(
164
+ recommendations,
165
+ {
166
+ pagination: this.#serializePagination({page, limit, count})
167
+ }
168
+ );
169
+ }
170
+
171
+ async trackClicked(frame: Frame) {
172
+ const member = this.#optionalAuthMember(frame);
173
+ const options = new UnsafeData(frame.options);
174
+ const id = options.key('id').string;
175
+
176
+ await this.service.trackClicked({
177
+ id,
178
+ memberId: member?.id
179
+ });
180
+ }
181
+ async trackSubscribed(frame: Frame) {
182
+ const member = this.#authMember(frame);
183
+ const options = new UnsafeData(frame.options);
184
+ const id = options.key('id').string;
185
+
186
+ await this.service.trackSubscribed({
187
+ id,
188
+ memberId: member.id
189
+ });
190
+ }
191
+
192
+ #authMember(frame: Frame): {id: string} {
193
+ const options = new UnsafeData(frame.options);
194
+ const memberId = options.key('context').optionalKey('member')?.nullable.key('id').string;
195
+ if (!memberId) {
196
+ // This is an internal server error because authentication should happen outside this service.
197
+ throw new errors.UnauthorizedError({
198
+ message: 'Member not found'
199
+ });
200
+ }
201
+ return {
202
+ id: memberId
203
+ };
204
+ }
205
+
206
+ #optionalAuthMember(frame: Frame): {id: string}|null {
207
+ try {
208
+ const member = this.#authMember(frame);
209
+ return member;
210
+ } catch (e) {
211
+ if (e instanceof errors.UnauthorizedError) {
212
+ // This is fine, this is not required
213
+ } else {
214
+ throw e;
215
+ }
216
+ }
217
+ return null;
218
+ }
219
+
220
+ #serialize(recommendations: Partial<RecommendationPlain>[], meta?: any) {
221
+ return {
222
+ data: recommendations.map((entity) => {
223
+ const d = {
224
+ id: entity.id ?? null,
225
+ title: entity.title ?? null,
226
+ description: entity.description ?? null,
227
+ excerpt: entity.excerpt ?? null,
228
+ featured_image: entity.featuredImage?.toString() ?? null,
229
+ favicon: entity.favicon?.toString() ?? null,
230
+ url: entity.url?.toString() ?? null,
231
+ one_click_subscribe: entity.oneClickSubscribe ?? null,
232
+ created_at: entity.createdAt?.toISOString() ?? null,
233
+ updated_at: entity.updatedAt?.toISOString() ?? null,
234
+ count: entity.clickCount !== undefined || entity.subscriberCount !== undefined ? {
235
+ clicks: entity.clickCount,
236
+ subscribers: entity.subscriberCount
237
+ } : undefined
238
+ };
239
+
240
+ return d;
241
+ }),
242
+ meta
243
+ };
244
+ }
245
+
246
+ #serializePagination({page, limit, count}: {page: number, limit: number, count: number}) {
247
+ const pages = Math.ceil(count / limit);
248
+
249
+ return {
250
+ page,
251
+ limit,
252
+ total: count,
253
+ pages,
254
+ prev: page > 1 ? page - 1 : null,
255
+ next: page < pages ? page + 1 : null
256
+ };
257
+ }
258
+ }