ghost 5.115.1 → 5.116.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (238) hide show
  1. package/components/{tryghost-api-framework-5.115.1.tgz → tryghost-api-framework-5.116.0.tgz} +0 -0
  2. package/components/tryghost-constants-5.116.0.tgz +0 -0
  3. package/components/tryghost-custom-fonts-5.116.0.tgz +0 -0
  4. package/components/{tryghost-custom-theme-settings-service-5.115.1.tgz → tryghost-custom-theme-settings-service-5.116.0.tgz} +0 -0
  5. package/components/tryghost-domain-events-5.116.0.tgz +0 -0
  6. package/components/tryghost-donations-5.116.0.tgz +0 -0
  7. package/components/tryghost-email-addresses-5.116.0.tgz +0 -0
  8. package/components/tryghost-email-service-5.116.0.tgz +0 -0
  9. package/components/tryghost-email-suppression-list-5.116.0.tgz +0 -0
  10. package/components/tryghost-html-to-plaintext-5.116.0.tgz +0 -0
  11. package/components/tryghost-i18n-5.116.0.tgz +0 -0
  12. package/components/tryghost-job-manager-5.116.0.tgz +0 -0
  13. package/components/tryghost-link-replacer-5.116.0.tgz +0 -0
  14. package/components/tryghost-magic-link-5.116.0.tgz +0 -0
  15. package/components/{tryghost-member-attribution-5.115.1.tgz → tryghost-member-attribution-5.116.0.tgz} +0 -0
  16. package/components/tryghost-member-events-5.116.0.tgz +0 -0
  17. package/components/tryghost-members-api-5.116.0.tgz +0 -0
  18. package/components/tryghost-members-csv-5.116.0.tgz +0 -0
  19. package/components/{tryghost-members-offers-5.115.1.tgz → tryghost-members-offers-5.116.0.tgz} +0 -0
  20. package/components/{tryghost-milestones-5.115.1.tgz → tryghost-milestones-5.116.0.tgz} +0 -0
  21. package/components/{tryghost-mw-error-handler-5.115.1.tgz → tryghost-mw-error-handler-5.116.0.tgz} +0 -0
  22. package/components/tryghost-mw-vhost-5.116.0.tgz +0 -0
  23. package/components/tryghost-post-events-5.116.0.tgz +0 -0
  24. package/components/{tryghost-post-revisions-5.115.1.tgz → tryghost-post-revisions-5.116.0.tgz} +0 -0
  25. package/components/tryghost-posts-service-5.116.0.tgz +0 -0
  26. package/components/{tryghost-prometheus-metrics-5.115.1.tgz → tryghost-prometheus-metrics-5.116.0.tgz} +0 -0
  27. package/components/tryghost-security-5.116.0.tgz +0 -0
  28. package/components/{tryghost-tiers-5.115.1.tgz → tryghost-tiers-5.116.0.tgz} +0 -0
  29. package/components/tryghost-webmentions-5.116.0.tgz +0 -0
  30. package/core/boot.js +0 -42
  31. package/core/built/admin/assets/admin-x-activitypub/admin-x-activitypub.js +24764 -24129
  32. package/core/built/admin/assets/admin-x-demo/admin-x-demo.js +1 -1
  33. package/core/built/admin/assets/admin-x-demo/{index-15df2af5.mjs → index-a9601514.mjs} +3 -3
  34. package/core/built/admin/assets/admin-x-demo/{modals-8ca61d78.mjs → modals-c1789d04.mjs} +2 -2
  35. package/core/built/admin/assets/admin-x-settings/{CodeEditorView-d2e6872f.mjs → CodeEditorView-e9c9deb8.mjs} +2 -2
  36. package/core/built/admin/assets/admin-x-settings/admin-x-settings.js +2 -2
  37. package/core/built/admin/assets/admin-x-settings/{index-8e8821e5.mjs → index-84580c3a.mjs} +2 -2
  38. package/core/built/admin/assets/admin-x-settings/{index-f5cb3db3.mjs → index-f744cab7.mjs} +49 -35
  39. package/core/built/admin/assets/admin-x-settings/{modals-e8ae4d46.mjs → modals-d9ca60c5.mjs} +1198 -1192
  40. package/core/built/admin/assets/chunk.524.8371443ef8f60db429d0.js +35 -0
  41. package/core/built/admin/assets/chunk.582.f90151775f2e53dd21d9.js +37 -0
  42. package/core/built/admin/assets/{chunk.874.461cb3cf5b6b36915f8c.js → chunk.713.e9027c0cc3c56110f5da.js} +125 -98
  43. package/core/built/admin/assets/{ghost-df7b9558260aa27d18b195ee895b487d.js → ghost-03b64c086f3c60cabc85fe7a7e2b640a.js} +144 -145
  44. package/core/built/admin/assets/ghost-ba58e9822f7384461e926c7e23f04a75.css +1 -0
  45. package/core/built/admin/assets/ghost-dark-f1f29683b14ffa11615b3bba8b6ab92c.css +1 -0
  46. package/core/built/admin/assets/koenig-lexical/index.css +1 -1
  47. package/core/built/admin/assets/koenig-lexical/koenig-lexical.js +20563 -20891
  48. package/core/built/admin/assets/koenig-lexical/koenig-lexical.umd.js +139 -139
  49. package/core/built/admin/assets/posts/posts.js +5732 -5667
  50. package/core/built/admin/assets/stats/stats.js +71082 -7533
  51. package/core/built/admin/assets/{vendor-68a4aa424a179a90f5bbc2b750def576.js → vendor-72026232b36d97babc6320917c16c321.js} +36 -34
  52. package/core/built/admin/index.html +6 -6
  53. package/core/cli/generate-data.js +1 -1
  54. package/core/frontend/helpers/ghost_head.js +6 -1
  55. package/core/frontend/public/ghost-stats.js +55 -2
  56. package/core/frontend/services/assets-minification/AdminAuthAssets.js +2 -1
  57. package/core/frontend/services/assets-minification/CardAssets.js +1 -1
  58. package/core/frontend/services/assets-minification/CommentCountsAssets.js +1 -1
  59. package/core/frontend/services/assets-minification/MemberAttributionAssets.js +1 -1
  60. package/core/frontend/services/assets-minification/Minifier.js +191 -0
  61. package/core/frontend/services/routing/controllers/previews.js +2 -1
  62. package/core/server/adapters/cache/Redis.js +1 -1
  63. package/core/server/adapters/lib/redis/AdapterCacheRedis.js +287 -0
  64. package/core/server/adapters/lib/redis/redis-store-factory.js +22 -0
  65. package/core/server/api/endpoints/posts.js +9 -3
  66. package/core/server/api/endpoints/previews.js +35 -1
  67. package/core/server/api/endpoints/utils/serializers/output/utils/post-gating.js +6 -9
  68. package/core/server/api/endpoints/utils/validators/input/settings.js +1 -1
  69. package/core/server/data/db/connection.js +2 -0
  70. package/core/server/data/db/index.js +1 -0
  71. package/core/server/data/importer/handlers/ImporterContentFileHandler.js +90 -0
  72. package/core/server/data/importer/import-manager.js +1 -1
  73. package/core/server/data/seeders/DataGenerator.js +288 -0
  74. package/core/server/data/seeders/importers/BenefitsImporter.js +28 -0
  75. package/core/server/data/seeders/importers/CommentsImporter.js +73 -0
  76. package/core/server/data/seeders/importers/EmailBatchesImporter.js +38 -0
  77. package/core/server/data/seeders/importers/EmailRecipientFailuresImporter.js +67 -0
  78. package/core/server/data/seeders/importers/EmailRecipientsImporter.js +212 -0
  79. package/core/server/data/seeders/importers/EmailsImporter.js +99 -0
  80. package/core/server/data/seeders/importers/LabelsImporter.js +41 -0
  81. package/core/server/data/seeders/importers/MembersClickEventsImporter.js +69 -0
  82. package/core/server/data/seeders/importers/MembersCreatedEventsImporter.js +103 -0
  83. package/core/server/data/seeders/importers/MembersFeedbackImporter.js +45 -0
  84. package/core/server/data/seeders/importers/MembersImporter.js +111 -0
  85. package/core/server/data/seeders/importers/MembersLabelsImporter.js +39 -0
  86. package/core/server/data/seeders/importers/MembersLoginEventsImporter.js +69 -0
  87. package/core/server/data/seeders/importers/MembersNewslettersImporter.js +38 -0
  88. package/core/server/data/seeders/importers/MembersPaidSubscriptionEventsImporter.js +99 -0
  89. package/core/server/data/seeders/importers/MembersProductsImporter.js +42 -0
  90. package/core/server/data/seeders/importers/MembersStatusEventsImporter.js +58 -0
  91. package/core/server/data/seeders/importers/MembersStripeCustomersImporter.js +60 -0
  92. package/core/server/data/seeders/importers/MembersStripeCustomersSubscriptionsImporter.js +259 -0
  93. package/core/server/data/seeders/importers/MembersSubscribeEventsImporter.js +69 -0
  94. package/core/server/data/seeders/importers/MembersSubscriptionCreatedEventsImporter.js +95 -0
  95. package/core/server/data/seeders/importers/NewslettersImporter.js +40 -0
  96. package/core/server/data/seeders/importers/OffersImporter.js +70 -0
  97. package/core/server/data/seeders/importers/PostsAuthorsImporter.js +32 -0
  98. package/core/server/data/seeders/importers/PostsImporter.js +102 -0
  99. package/core/server/data/seeders/importers/PostsProductsImporter.js +35 -0
  100. package/core/server/data/seeders/importers/PostsTagsImporter.js +46 -0
  101. package/core/server/data/seeders/importers/ProductsBenefitsImporter.js +54 -0
  102. package/core/server/data/seeders/importers/ProductsImporter.js +90 -0
  103. package/core/server/data/seeders/importers/RecommendationClickEventsImporter.js +32 -0
  104. package/core/server/data/seeders/importers/RecommendationSubscribeEventsImporter.js +32 -0
  105. package/core/server/data/seeders/importers/RecommendationsImporter.js +34 -0
  106. package/core/server/data/seeders/importers/RedirectsImporter.js +49 -0
  107. package/core/server/data/seeders/importers/RolesUsersImporter.js +42 -0
  108. package/core/server/data/seeders/importers/StripePricesImporter.js +69 -0
  109. package/core/server/data/seeders/importers/StripeProductsImporter.js +34 -0
  110. package/core/server/data/seeders/importers/TableImporter.js +187 -0
  111. package/core/server/data/seeders/importers/TagsImporter.js +41 -0
  112. package/core/server/data/seeders/importers/UsersImporter.js +31 -0
  113. package/core/server/data/seeders/importers/WebMentionsImporter.js +42 -0
  114. package/core/server/data/seeders/importers/index.js +41 -0
  115. package/core/server/data/seeders/utils/JsonImporter.js +39 -0
  116. package/core/server/data/seeders/utils/blog-info.js +3 -0
  117. package/core/server/data/seeders/utils/database-date.js +7 -0
  118. package/core/server/data/seeders/utils/event-generator.js +48 -0
  119. package/core/server/data/seeders/utils/random.js +13 -0
  120. package/core/server/data/seeders/utils/topological-sort.js +33 -0
  121. package/core/server/services/adapter-manager/AdapterManager.js +161 -0
  122. package/core/server/services/adapter-manager/index.js +1 -1
  123. package/core/server/services/announcement-bar-service/AnnouncementBarSettings.js +54 -0
  124. package/core/server/services/announcement-bar-service/AnnouncementVisibilityValues.js +11 -0
  125. package/core/server/services/announcement-bar-service/index.js +1 -1
  126. package/core/server/services/api-version-compatibility/APIVersionCompatibilityService.js +1 -1
  127. package/core/server/services/auth/session/session-service.js +15 -5
  128. package/core/server/services/custom-redirects/index.js +1 -1
  129. package/core/server/services/email-analytics/EmailAnalyticsProviderMailgun.js +1 -1
  130. package/core/server/services/email-service/EmailServiceWrapper.js +4 -4
  131. package/core/server/services/email-suppression-list/MailgunEmailSuppressionList.js +1 -1
  132. package/core/server/services/email-suppression-list/service.js +1 -1
  133. package/core/server/services/lib/DynamicRedirectManager.js +156 -0
  134. package/core/server/services/lib/EmailContentGenerator.js +54 -0
  135. package/core/server/services/lib/InMemoryRepository.js +62 -0
  136. package/core/server/services/lib/InMemoryRepository.ts +80 -0
  137. package/core/server/services/lib/MailgunClient.js +364 -0
  138. package/core/server/services/link-redirection/LinkRedirect.js +26 -0
  139. package/core/server/services/link-redirection/LinkRedirectRepository.js +7 -7
  140. package/core/server/services/link-redirection/LinkRedirectsService.js +123 -0
  141. package/core/server/services/link-redirection/README.md +151 -0
  142. package/core/server/services/link-redirection/RedirectEvent.js +24 -0
  143. package/core/server/services/link-redirection/index.js +1 -1
  144. package/core/server/services/link-tracking/LinkClickTrackingService.js +1 -1
  145. package/core/server/services/mail/index.js +1 -1
  146. package/core/server/services/mail-events/InMemoryMailEventRepository.js +2 -2
  147. package/core/server/services/mail-events/InMemoryMailEventRepository.ts +1 -1
  148. package/core/server/services/members-events/LastSeenAtUpdater.js +1 -1
  149. package/core/server/services/offers/service.js +1 -1
  150. package/core/server/services/recommendations/RecommendationServiceWrapper.js +8 -8
  151. package/core/server/services/recommendations/service/BookshelfClickEventRepository.js +48 -0
  152. package/core/server/services/recommendations/service/BookshelfClickEventRepository.ts +49 -0
  153. package/core/server/services/recommendations/service/BookshelfRecommendationRepository.js +98 -0
  154. package/core/server/services/recommendations/service/BookshelfRecommendationRepository.ts +117 -0
  155. package/core/server/services/recommendations/service/BookshelfRepository.js +134 -0
  156. package/core/server/services/recommendations/service/BookshelfRepository.ts +196 -0
  157. package/core/server/services/recommendations/service/BookshelfSubscribeEventRepository.js +48 -0
  158. package/core/server/services/recommendations/service/BookshelfSubscribeEventRepository.ts +49 -0
  159. package/core/server/services/recommendations/service/ClickEvent.js +33 -0
  160. package/core/server/services/recommendations/service/ClickEvent.ts +32 -0
  161. package/core/server/services/recommendations/service/InMemoryRecommendationRepository.js +19 -0
  162. package/core/server/services/recommendations/service/InMemoryRecommendationRepository.ts +20 -0
  163. package/core/server/services/recommendations/service/IncomingRecommendationController.js +34 -0
  164. package/core/server/services/recommendations/service/IncomingRecommendationController.ts +51 -0
  165. package/core/server/services/recommendations/service/IncomingRecommendationEmailRenderer.js +25 -0
  166. package/core/server/services/recommendations/service/IncomingRecommendationEmailRenderer.ts +37 -0
  167. package/core/server/services/recommendations/service/IncomingRecommendationService.js +93 -0
  168. package/core/server/services/recommendations/service/IncomingRecommendationService.ts +160 -0
  169. package/core/server/services/recommendations/service/Recommendation.js +140 -0
  170. package/core/server/services/recommendations/service/Recommendation.ts +201 -0
  171. package/core/server/services/recommendations/service/RecommendationController.js +208 -0
  172. package/core/server/services/recommendations/service/RecommendationController.ts +258 -0
  173. package/core/server/services/recommendations/service/RecommendationMetadataService.js +86 -0
  174. package/core/server/services/recommendations/service/RecommendationMetadataService.ts +128 -0
  175. package/core/server/services/recommendations/service/RecommendationRepository.js +2 -0
  176. package/core/server/services/recommendations/service/RecommendationRepository.ts +13 -0
  177. package/core/server/services/recommendations/service/RecommendationService.js +228 -0
  178. package/core/server/services/recommendations/service/RecommendationService.ts +281 -0
  179. package/core/server/services/recommendations/service/SubscribeEvent.js +33 -0
  180. package/core/server/services/recommendations/service/SubscribeEvent.ts +32 -0
  181. package/core/server/services/recommendations/service/UnsafeData.js +183 -0
  182. package/core/server/services/recommendations/service/UnsafeData.ts +217 -0
  183. package/core/server/services/recommendations/service/WellknownService.js +36 -0
  184. package/core/server/services/recommendations/service/WellknownService.ts +47 -0
  185. package/core/server/services/recommendations/service/index.js +31 -0
  186. package/core/server/services/recommendations/service/index.ts +15 -0
  187. package/core/server/services/recommendations/service/libraries.d.ts +5 -0
  188. package/core/server/services/slack-notifications/SlackNotifications.js +211 -0
  189. package/core/server/services/slack-notifications/SlackNotificationsService.js +90 -0
  190. package/core/server/services/slack-notifications/service.js +4 -6
  191. package/core/server/web/api/endpoints/admin/app.js +1 -21
  192. package/core/server/web/api/middleware/version-match.js +41 -0
  193. package/core/shared/labs.js +2 -2
  194. package/package.json +87 -104
  195. package/tsconfig.tsbuildinfo +1 -1
  196. package/yarn.lock +1470 -1540
  197. package/components/tryghost-adapter-cache-redis-5.115.1.tgz +0 -0
  198. package/components/tryghost-adapter-manager-5.115.1.tgz +0 -0
  199. package/components/tryghost-announcement-bar-settings-5.115.1.tgz +0 -0
  200. package/components/tryghost-constants-5.115.1.tgz +0 -0
  201. package/components/tryghost-custom-fonts-5.115.1.tgz +0 -0
  202. package/components/tryghost-data-generator-5.115.1.tgz +0 -0
  203. package/components/tryghost-domain-events-5.115.1.tgz +0 -0
  204. package/components/tryghost-donations-5.115.1.tgz +0 -0
  205. package/components/tryghost-email-addresses-5.115.1.tgz +0 -0
  206. package/components/tryghost-email-content-generator-5.115.1.tgz +0 -0
  207. package/components/tryghost-email-events-5.115.1.tgz +0 -0
  208. package/components/tryghost-email-service-5.115.1.tgz +0 -0
  209. package/components/tryghost-email-suppression-list-5.115.1.tgz +0 -0
  210. package/components/tryghost-express-dynamic-redirects-5.115.1.tgz +0 -0
  211. package/components/tryghost-ghost-5.115.1.tgz +0 -0
  212. package/components/tryghost-html-to-plaintext-5.115.1.tgz +0 -0
  213. package/components/tryghost-i18n-5.115.1.tgz +0 -0
  214. package/components/tryghost-importer-handler-content-files-5.115.1.tgz +0 -0
  215. package/components/tryghost-in-memory-repository-5.115.1.tgz +0 -0
  216. package/components/tryghost-job-manager-5.115.1.tgz +0 -0
  217. package/components/tryghost-link-redirects-5.115.1.tgz +0 -0
  218. package/components/tryghost-link-replacer-5.115.1.tgz +0 -0
  219. package/components/tryghost-magic-link-5.115.1.tgz +0 -0
  220. package/components/tryghost-mailgun-client-5.115.1.tgz +0 -0
  221. package/components/tryghost-member-events-5.115.1.tgz +0 -0
  222. package/components/tryghost-members-api-5.115.1.tgz +0 -0
  223. package/components/tryghost-members-csv-5.115.1.tgz +0 -0
  224. package/components/tryghost-members-payments-5.115.1.tgz +0 -0
  225. package/components/tryghost-minifier-5.115.1.tgz +0 -0
  226. package/components/tryghost-mw-version-match-5.115.1.tgz +0 -0
  227. package/components/tryghost-mw-vhost-5.115.1.tgz +0 -0
  228. package/components/tryghost-post-events-5.115.1.tgz +0 -0
  229. package/components/tryghost-posts-service-5.115.1.tgz +0 -0
  230. package/components/tryghost-recommendations-5.115.1.tgz +0 -0
  231. package/components/tryghost-security-5.115.1.tgz +0 -0
  232. package/components/tryghost-slack-notifications-5.115.1.tgz +0 -0
  233. package/components/tryghost-webmentions-5.115.1.tgz +0 -0
  234. package/core/built/admin/assets/chunk.524.2439684964c164c598ab.js +0 -35
  235. package/core/built/admin/assets/chunk.582.bf5a2bbb2c4eb69ef1e7.js +0 -37
  236. package/core/built/admin/assets/ghost-327b17ea23cb8c89bd7e6a51e18e8506.css +0 -1
  237. package/core/built/admin/assets/ghost-dark-f30a597ac19632a118939492591c531b.css +0 -1
  238. /package/core/built/admin/assets/{chunk.874.461cb3cf5b6b36915f8c.js.LICENSE.txt → chunk.713.e9027c0cc3c56110f5da.js.LICENSE.txt} +0 -0
@@ -0,0 +1,86 @@
1
+ "use strict";
2
+ /* eslint-disable ghost/filenames/match-exported-class */
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.RecommendationMetadataService = void 0;
5
+ class RecommendationMetadataService {
6
+ #oembedService;
7
+ #externalRequest;
8
+ constructor(dependencies) {
9
+ this.#oembedService = dependencies.oembedService;
10
+ this.#externalRequest = dependencies.externalRequest;
11
+ }
12
+ async #fetchJSON(url, options) {
13
+ // Even though we have throwHttpErrors: false, we still need to catch DNS errors
14
+ // that can arise from externalRequest, otherwise we'll return a HTTP 500 to the user
15
+ try {
16
+ // default content type is application/x-www-form-encoded which is what we need for the webmentions spec
17
+ const response = await this.#externalRequest.get(url.toString(), {
18
+ throwHttpErrors: false,
19
+ maxRedirects: 10,
20
+ followRedirect: true,
21
+ timeout: 15000,
22
+ retry: {
23
+ // Only retry on network issues, or specific HTTP status codes
24
+ limit: 3
25
+ },
26
+ ...options
27
+ });
28
+ if (response.statusCode >= 200 && response.statusCode < 300) {
29
+ try {
30
+ return JSON.parse(response.body);
31
+ }
32
+ catch (e) {
33
+ return undefined;
34
+ }
35
+ }
36
+ }
37
+ catch (e) {
38
+ return undefined;
39
+ }
40
+ }
41
+ #castUrl(url) {
42
+ if (!url) {
43
+ return null;
44
+ }
45
+ try {
46
+ return new URL(url);
47
+ }
48
+ catch (e) {
49
+ return null;
50
+ }
51
+ }
52
+ async fetch(url, options = { timeout: 5000 }) {
53
+ // Make sure url path ends with a slash (urls should be resolved relative to the path)
54
+ if (!url.pathname.endsWith('/')) {
55
+ url.pathname += '/';
56
+ }
57
+ // 1. Check if it is a Ghost site
58
+ let ghostSiteData = await this.#fetchJSON(new URL('members/api/site', url), options);
59
+ if (!ghostSiteData && url.pathname !== '' && url.pathname !== '/') {
60
+ // Try root relative URL
61
+ ghostSiteData = await this.#fetchJSON(new URL('members/api/site', url.origin), options);
62
+ }
63
+ if (ghostSiteData && typeof ghostSiteData === 'object' && ghostSiteData.site && typeof ghostSiteData.site === 'object') {
64
+ // Check if the Ghost site returns allow_external_signup, otherwise it is an old Ghost version that returns unreliable data
65
+ if (typeof ghostSiteData.site.allow_external_signup === 'boolean') {
66
+ return {
67
+ title: ghostSiteData.site.title || null,
68
+ excerpt: ghostSiteData.site.description || null,
69
+ featuredImage: this.#castUrl(ghostSiteData.site.cover_image),
70
+ favicon: this.#castUrl(ghostSiteData.site.icon || ghostSiteData.site.logo),
71
+ oneClickSubscribe: !!ghostSiteData.site.allow_external_signup
72
+ };
73
+ }
74
+ }
75
+ // Use the oembed service to fetch metadata
76
+ const oembed = await this.#oembedService.fetchOembedDataFromUrl(url.toString(), 'mention');
77
+ return {
78
+ title: oembed?.metadata?.title || null,
79
+ excerpt: oembed?.metadata?.description || null,
80
+ featuredImage: this.#castUrl(oembed?.metadata?.thumbnail),
81
+ favicon: this.#castUrl(oembed?.metadata?.icon),
82
+ oneClickSubscribe: false
83
+ };
84
+ }
85
+ }
86
+ exports.RecommendationMetadataService = RecommendationMetadataService;
@@ -0,0 +1,128 @@
1
+ /* eslint-disable ghost/filenames/match-exported-class */
2
+
3
+ type OembedMetadata<Type extends string> = {
4
+ version: '1.0',
5
+ type: Type,
6
+ url: string,
7
+ metadata: {
8
+ title: string|null,
9
+ description: string|null,
10
+ publisher: string|null,
11
+ author: string|null,
12
+ thumbnail: string|null,
13
+ icon: string|null
14
+ },
15
+ body?: Type extends 'mention' ? string : unknown,
16
+ contentType?: Type extends 'mention' ? string : unknown
17
+ }
18
+
19
+ type OEmbedService = {
20
+ fetchOembedDataFromUrl<Type extends string>(url: string, type: Type, options?: {timeout?: number}): Promise<OembedMetadata<Type>>
21
+ }
22
+
23
+ type ExternalRequest = {
24
+ get(url: string, options: object): Promise<{statusCode: number, body: string}>
25
+ }
26
+
27
+ export type RecommendationMetadata = {
28
+ title: string|null,
29
+ excerpt: string|null,
30
+ featuredImage: URL|null,
31
+ favicon: URL|null,
32
+ oneClickSubscribe: boolean
33
+ }
34
+
35
+ export class RecommendationMetadataService {
36
+ #oembedService: OEmbedService;
37
+ #externalRequest: ExternalRequest;
38
+
39
+ constructor(dependencies: {oembedService: OEmbedService, externalRequest: ExternalRequest}) {
40
+ this.#oembedService = dependencies.oembedService;
41
+ this.#externalRequest = dependencies.externalRequest;
42
+ }
43
+
44
+ async #fetchJSON(url: URL, options?: {timeout?: number}) {
45
+ // Even though we have throwHttpErrors: false, we still need to catch DNS errors
46
+ // that can arise from externalRequest, otherwise we'll return a HTTP 500 to the user
47
+ try {
48
+ // default content type is application/x-www-form-encoded which is what we need for the webmentions spec
49
+ const response = await this.#externalRequest.get(url.toString(), {
50
+ throwHttpErrors: false,
51
+ maxRedirects: 10,
52
+ followRedirect: true,
53
+ timeout: 15000,
54
+ retry: {
55
+ // Only retry on network issues, or specific HTTP status codes
56
+ limit: 3
57
+ },
58
+ ...options
59
+ });
60
+
61
+ if (response.statusCode >= 200 && response.statusCode < 300) {
62
+ try {
63
+ return JSON.parse(response.body);
64
+ } catch (e) {
65
+ return undefined;
66
+ }
67
+ }
68
+ } catch (e) {
69
+ return undefined;
70
+ }
71
+ }
72
+
73
+ #castUrl(url: string|null|undefined): URL|null {
74
+ if (!url) {
75
+ return null;
76
+ }
77
+ try {
78
+ return new URL(url);
79
+ } catch (e) {
80
+ return null;
81
+ }
82
+ }
83
+
84
+ async fetch(url: URL, options: {timeout: number} = {timeout: 5000}): Promise<RecommendationMetadata> {
85
+ // Make sure url path ends with a slash (urls should be resolved relative to the path)
86
+ if (!url.pathname.endsWith('/')) {
87
+ url.pathname += '/';
88
+ }
89
+
90
+ // 1. Check if it is a Ghost site
91
+ let ghostSiteData = await this.#fetchJSON(
92
+ new URL('members/api/site', url),
93
+ options
94
+ );
95
+
96
+ if (!ghostSiteData && url.pathname !== '' && url.pathname !== '/') {
97
+ // Try root relative URL
98
+ ghostSiteData = await this.#fetchJSON(
99
+ new URL('members/api/site', url.origin),
100
+ options
101
+ );
102
+ }
103
+
104
+ if (ghostSiteData && typeof ghostSiteData === 'object' && ghostSiteData.site && typeof ghostSiteData.site === 'object') {
105
+ // Check if the Ghost site returns allow_external_signup, otherwise it is an old Ghost version that returns unreliable data
106
+ if (typeof ghostSiteData.site.allow_external_signup === 'boolean') {
107
+ return {
108
+ title: ghostSiteData.site.title || null,
109
+ excerpt: ghostSiteData.site.description || null,
110
+ featuredImage: this.#castUrl(ghostSiteData.site.cover_image),
111
+ favicon: this.#castUrl(ghostSiteData.site.icon || ghostSiteData.site.logo),
112
+ oneClickSubscribe: !!ghostSiteData.site.allow_external_signup
113
+ };
114
+ }
115
+ }
116
+
117
+ // Use the oembed service to fetch metadata
118
+ const oembed = await this.#oembedService.fetchOembedDataFromUrl(url.toString(), 'mention');
119
+
120
+ return {
121
+ title: oembed?.metadata?.title || null,
122
+ excerpt: oembed?.metadata?.description || null,
123
+ featuredImage: this.#castUrl(oembed?.metadata?.thumbnail),
124
+ favicon: this.#castUrl(oembed?.metadata?.icon),
125
+ oneClickSubscribe: false
126
+ };
127
+ }
128
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,13 @@
1
+ import {AllOptions} from './BookshelfRepository';
2
+ import {Recommendation} from './Recommendation';
3
+
4
+ export interface RecommendationRepository {
5
+ save(entity: Recommendation): Promise<void>;
6
+ getById(id: string): Promise<Recommendation | null>;
7
+ getByUrl(url: URL): Promise<Recommendation|null>;
8
+ getAll(options: Omit<AllOptions<Recommendation>, 'page'|'limit'>): Promise<Recommendation[]>;
9
+ getPage(options: AllOptions<Recommendation> & Required<Pick<AllOptions<Recommendation>, 'page'|'limit'>>): Promise<Recommendation[]>;
10
+ getCount(options: {
11
+ filter?: string;
12
+ }): Promise<number>;
13
+ }
@@ -0,0 +1,228 @@
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.RecommendationService = void 0;
7
+ const errors_1 = __importDefault(require("@tryghost/errors"));
8
+ const logging_1 = __importDefault(require("@tryghost/logging"));
9
+ const tpl_1 = __importDefault(require("@tryghost/tpl"));
10
+ const ClickEvent_1 = require("./ClickEvent");
11
+ const Recommendation_1 = require("./Recommendation");
12
+ const SubscribeEvent_1 = require("./SubscribeEvent");
13
+ const messages = {
14
+ notFound: 'Recommendation with id {id} not found'
15
+ };
16
+ class RecommendationService {
17
+ repository;
18
+ clickEventRepository;
19
+ subscribeEventRepository;
20
+ wellknownService;
21
+ mentionSendingService;
22
+ recommendationEnablerService;
23
+ recommendationMetadataService;
24
+ constructor(deps) {
25
+ this.repository = deps.repository;
26
+ this.wellknownService = deps.wellknownService;
27
+ this.mentionSendingService = deps.mentionSendingService;
28
+ this.recommendationEnablerService = deps.recommendationEnablerService;
29
+ this.clickEventRepository = deps.clickEventRepository;
30
+ this.subscribeEventRepository = deps.subscribeEventRepository;
31
+ this.recommendationMetadataService = deps.recommendationMetadataService;
32
+ }
33
+ async init() {
34
+ const recommendations = await this.#listRecommendations();
35
+ await this.updateWellknown(recommendations);
36
+ // Do a slow update of all the recommendation metadata (keeping logo up to date, one-click-subscribe, etc.)
37
+ // We better move this to a job in the future
38
+ if (!process.env.NODE_ENV?.startsWith('test')) {
39
+ setTimeout(async () => {
40
+ try {
41
+ await this.updateAllRecommendationsMetadata();
42
+ }
43
+ catch (e) {
44
+ logging_1.default.error('[Recommendations] Failed to update all recommendations metadata on boot', e);
45
+ }
46
+ }, 2 * 60 * 1000 + Math.random() * 5 * 60 * 1000);
47
+ }
48
+ }
49
+ async updateAllRecommendationsMetadata() {
50
+ const recommendations = await this.#listRecommendations();
51
+ logging_1.default.info('[Recommendations] Updating recommendations metadata');
52
+ for (const recommendation of recommendations) {
53
+ try {
54
+ await this._updateRecommendationMetadata(recommendation);
55
+ await this.repository.save(recommendation);
56
+ }
57
+ catch (e) {
58
+ logging_1.default.error('[Recommendations] Failed to save updated metadata for recommendation ' + recommendation.url.toString(), e);
59
+ }
60
+ }
61
+ }
62
+ async updateWellknown(recommendations) {
63
+ await this.wellknownService.set(recommendations);
64
+ }
65
+ async updateRecommendationsEnabledSetting(recommendations) {
66
+ const expectedSetting = (recommendations.length > 0).toString();
67
+ const currentSetting = this.recommendationEnablerService.getSetting();
68
+ if (currentSetting && currentSetting === expectedSetting) {
69
+ return;
70
+ }
71
+ await this.recommendationEnablerService.setSetting(expectedSetting);
72
+ }
73
+ sendMentionToRecommendation(recommendation) {
74
+ this.mentionSendingService.sendAll({
75
+ url: this.wellknownService.getURL(),
76
+ links: [
77
+ recommendation.url
78
+ ]
79
+ }).catch((err) => {
80
+ logging_1.default.error('Failed to send mention to recommendation', err);
81
+ });
82
+ }
83
+ async readRecommendation(id) {
84
+ const recommendation = await this.repository.getById(id);
85
+ if (!recommendation) {
86
+ throw new errors_1.default.NotFoundError({
87
+ message: (0, tpl_1.default)(messages.notFound, { id })
88
+ });
89
+ }
90
+ return recommendation.plain;
91
+ }
92
+ async addRecommendation(addRecommendation) {
93
+ const recommendation = Recommendation_1.Recommendation.create(addRecommendation);
94
+ // If a recommendation with this URL already exists, throw an error
95
+ const existing = await this.repository.getByUrl(recommendation.url);
96
+ if (existing) {
97
+ throw new errors_1.default.ValidationError({
98
+ message: 'A recommendation with this URL already exists.'
99
+ });
100
+ }
101
+ await this.repository.save(recommendation);
102
+ const recommendations = await this.#listRecommendations();
103
+ await this.updateWellknown(recommendations);
104
+ await this.updateRecommendationsEnabledSetting(recommendations);
105
+ // Only send an update for the mentioned URL
106
+ this.sendMentionToRecommendation(recommendation);
107
+ return recommendation.plain;
108
+ }
109
+ async checkRecommendation(url) {
110
+ // If a recommendation with this URL already exists, return it, but with updated metadata
111
+ const existing = await this.repository.getByUrl(url);
112
+ if (existing) {
113
+ this._updateRecommendationMetadata(existing);
114
+ await this.repository.save(existing);
115
+ return existing.plain;
116
+ }
117
+ let metadata;
118
+ try {
119
+ metadata = await this.recommendationMetadataService.fetch(url);
120
+ }
121
+ catch (e) {
122
+ logging_1.default.error('[Recommendations] Failed to fetch metadata for url ' + url, e);
123
+ return {
124
+ url: url,
125
+ title: undefined,
126
+ excerpt: undefined,
127
+ featuredImage: undefined,
128
+ favicon: undefined,
129
+ oneClickSubscribe: false
130
+ };
131
+ }
132
+ return {
133
+ url: url,
134
+ title: metadata.title ?? undefined,
135
+ excerpt: metadata.excerpt ?? undefined,
136
+ featuredImage: metadata.featuredImage ?? undefined,
137
+ favicon: metadata.favicon ?? undefined,
138
+ oneClickSubscribe: !!metadata.oneClickSubscribe
139
+ };
140
+ }
141
+ async _updateRecommendationMetadata(recommendation) {
142
+ // Fetch data
143
+ try {
144
+ const metadata = await this.recommendationMetadataService.fetch(recommendation.url);
145
+ // Set null values to undefined so we don't trigger an update
146
+ recommendation.edit({
147
+ // Don't set title if it's already set on the recommendation
148
+ title: recommendation.title ? undefined : (metadata.title ?? undefined),
149
+ excerpt: metadata.excerpt ?? undefined,
150
+ featuredImage: metadata.featuredImage ?? undefined,
151
+ favicon: metadata.favicon ?? undefined,
152
+ oneClickSubscribe: !!metadata.oneClickSubscribe
153
+ });
154
+ }
155
+ catch (e) {
156
+ logging_1.default.error('[Recommendations] Failed to update metadata for recommendation ' + recommendation.url.toString(), e);
157
+ }
158
+ }
159
+ async editRecommendation(id, recommendationEdit) {
160
+ // Check if it exists
161
+ const existing = await this.repository.getById(id);
162
+ if (!existing) {
163
+ throw new errors_1.default.NotFoundError({
164
+ message: (0, tpl_1.default)(messages.notFound, { id })
165
+ });
166
+ }
167
+ existing.edit(recommendationEdit);
168
+ await this._updateRecommendationMetadata(existing);
169
+ await this.repository.save(existing);
170
+ const recommendations = await this.#listRecommendations();
171
+ await this.updateWellknown(recommendations);
172
+ this.sendMentionToRecommendation(existing);
173
+ return existing.plain;
174
+ }
175
+ async deleteRecommendation(id) {
176
+ const existing = await this.repository.getById(id);
177
+ if (!existing) {
178
+ throw new errors_1.default.NotFoundError({
179
+ message: (0, tpl_1.default)(messages.notFound, { id })
180
+ });
181
+ }
182
+ existing.delete();
183
+ await this.repository.save(existing);
184
+ const recommendations = await this.#listRecommendations();
185
+ await this.updateWellknown(recommendations);
186
+ await this.updateRecommendationsEnabledSetting(recommendations);
187
+ // Send a mention (because it was deleted, according to the webmentions spec)
188
+ this.sendMentionToRecommendation(existing);
189
+ }
190
+ /**
191
+ * Sames as listRecommendations, but returns Entities instead of plain objects (Entities are only used internally)
192
+ */
193
+ async #listRecommendations(options = { page: 1, limit: 'all' }) {
194
+ if (options.limit === 'all') {
195
+ return await this.repository.getAll({
196
+ ...options
197
+ });
198
+ }
199
+ return await this.repository.getPage({
200
+ ...options,
201
+ page: options.page || 1,
202
+ limit: options.limit || 15
203
+ });
204
+ }
205
+ async listRecommendations(options = { page: 1, limit: 'all', include: [] }) {
206
+ const list = await this.#listRecommendations(options);
207
+ return list.map(e => e.plain);
208
+ }
209
+ async countRecommendations({ filter }) {
210
+ return await this.repository.getCount({ filter });
211
+ }
212
+ async trackClicked({ id, memberId }) {
213
+ const clickEvent = ClickEvent_1.ClickEvent.create({ recommendationId: id, memberId });
214
+ await this.clickEventRepository.save(clickEvent);
215
+ }
216
+ async trackSubscribed({ id, memberId }) {
217
+ const subscribeEvent = SubscribeEvent_1.SubscribeEvent.create({ recommendationId: id, memberId });
218
+ await this.subscribeEventRepository.save(subscribeEvent);
219
+ }
220
+ async readRecommendationByUrl(url) {
221
+ const recommendation = await this.repository.getByUrl(url);
222
+ if (!recommendation) {
223
+ return null;
224
+ }
225
+ return recommendation.plain;
226
+ }
227
+ }
228
+ exports.RecommendationService = RecommendationService;