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.
- package/components/{tryghost-api-framework-5.115.1.tgz → tryghost-api-framework-5.116.0.tgz} +0 -0
- package/components/tryghost-constants-5.116.0.tgz +0 -0
- package/components/tryghost-custom-fonts-5.116.0.tgz +0 -0
- package/components/{tryghost-custom-theme-settings-service-5.115.1.tgz → tryghost-custom-theme-settings-service-5.116.0.tgz} +0 -0
- package/components/tryghost-domain-events-5.116.0.tgz +0 -0
- package/components/tryghost-donations-5.116.0.tgz +0 -0
- package/components/tryghost-email-addresses-5.116.0.tgz +0 -0
- package/components/tryghost-email-service-5.116.0.tgz +0 -0
- package/components/tryghost-email-suppression-list-5.116.0.tgz +0 -0
- package/components/tryghost-html-to-plaintext-5.116.0.tgz +0 -0
- package/components/tryghost-i18n-5.116.0.tgz +0 -0
- package/components/tryghost-job-manager-5.116.0.tgz +0 -0
- package/components/tryghost-link-replacer-5.116.0.tgz +0 -0
- package/components/tryghost-magic-link-5.116.0.tgz +0 -0
- package/components/{tryghost-member-attribution-5.115.1.tgz → tryghost-member-attribution-5.116.0.tgz} +0 -0
- package/components/tryghost-member-events-5.116.0.tgz +0 -0
- package/components/tryghost-members-api-5.116.0.tgz +0 -0
- package/components/tryghost-members-csv-5.116.0.tgz +0 -0
- package/components/{tryghost-members-offers-5.115.1.tgz → tryghost-members-offers-5.116.0.tgz} +0 -0
- package/components/{tryghost-milestones-5.115.1.tgz → tryghost-milestones-5.116.0.tgz} +0 -0
- package/components/{tryghost-mw-error-handler-5.115.1.tgz → tryghost-mw-error-handler-5.116.0.tgz} +0 -0
- package/components/tryghost-mw-vhost-5.116.0.tgz +0 -0
- package/components/tryghost-post-events-5.116.0.tgz +0 -0
- package/components/{tryghost-post-revisions-5.115.1.tgz → tryghost-post-revisions-5.116.0.tgz} +0 -0
- package/components/tryghost-posts-service-5.116.0.tgz +0 -0
- package/components/{tryghost-prometheus-metrics-5.115.1.tgz → tryghost-prometheus-metrics-5.116.0.tgz} +0 -0
- package/components/tryghost-security-5.116.0.tgz +0 -0
- package/components/{tryghost-tiers-5.115.1.tgz → tryghost-tiers-5.116.0.tgz} +0 -0
- package/components/tryghost-webmentions-5.116.0.tgz +0 -0
- package/core/boot.js +0 -42
- package/core/built/admin/assets/admin-x-activitypub/admin-x-activitypub.js +24764 -24129
- package/core/built/admin/assets/admin-x-demo/admin-x-demo.js +1 -1
- package/core/built/admin/assets/admin-x-demo/{index-15df2af5.mjs → index-a9601514.mjs} +3 -3
- package/core/built/admin/assets/admin-x-demo/{modals-8ca61d78.mjs → modals-c1789d04.mjs} +2 -2
- package/core/built/admin/assets/admin-x-settings/{CodeEditorView-d2e6872f.mjs → CodeEditorView-e9c9deb8.mjs} +2 -2
- package/core/built/admin/assets/admin-x-settings/admin-x-settings.js +2 -2
- package/core/built/admin/assets/admin-x-settings/{index-8e8821e5.mjs → index-84580c3a.mjs} +2 -2
- package/core/built/admin/assets/admin-x-settings/{index-f5cb3db3.mjs → index-f744cab7.mjs} +49 -35
- package/core/built/admin/assets/admin-x-settings/{modals-e8ae4d46.mjs → modals-d9ca60c5.mjs} +1198 -1192
- package/core/built/admin/assets/chunk.524.8371443ef8f60db429d0.js +35 -0
- package/core/built/admin/assets/chunk.582.f90151775f2e53dd21d9.js +37 -0
- package/core/built/admin/assets/{chunk.874.461cb3cf5b6b36915f8c.js → chunk.713.e9027c0cc3c56110f5da.js} +125 -98
- package/core/built/admin/assets/{ghost-df7b9558260aa27d18b195ee895b487d.js → ghost-03b64c086f3c60cabc85fe7a7e2b640a.js} +144 -145
- package/core/built/admin/assets/ghost-ba58e9822f7384461e926c7e23f04a75.css +1 -0
- package/core/built/admin/assets/ghost-dark-f1f29683b14ffa11615b3bba8b6ab92c.css +1 -0
- package/core/built/admin/assets/koenig-lexical/index.css +1 -1
- package/core/built/admin/assets/koenig-lexical/koenig-lexical.js +20563 -20891
- package/core/built/admin/assets/koenig-lexical/koenig-lexical.umd.js +139 -139
- package/core/built/admin/assets/posts/posts.js +5732 -5667
- package/core/built/admin/assets/stats/stats.js +71082 -7533
- package/core/built/admin/assets/{vendor-68a4aa424a179a90f5bbc2b750def576.js → vendor-72026232b36d97babc6320917c16c321.js} +36 -34
- package/core/built/admin/index.html +6 -6
- package/core/cli/generate-data.js +1 -1
- package/core/frontend/helpers/ghost_head.js +6 -1
- package/core/frontend/public/ghost-stats.js +55 -2
- package/core/frontend/services/assets-minification/AdminAuthAssets.js +2 -1
- package/core/frontend/services/assets-minification/CardAssets.js +1 -1
- package/core/frontend/services/assets-minification/CommentCountsAssets.js +1 -1
- package/core/frontend/services/assets-minification/MemberAttributionAssets.js +1 -1
- package/core/frontend/services/assets-minification/Minifier.js +191 -0
- package/core/frontend/services/routing/controllers/previews.js +2 -1
- package/core/server/adapters/cache/Redis.js +1 -1
- package/core/server/adapters/lib/redis/AdapterCacheRedis.js +287 -0
- package/core/server/adapters/lib/redis/redis-store-factory.js +22 -0
- package/core/server/api/endpoints/posts.js +9 -3
- package/core/server/api/endpoints/previews.js +35 -1
- package/core/server/api/endpoints/utils/serializers/output/utils/post-gating.js +6 -9
- package/core/server/api/endpoints/utils/validators/input/settings.js +1 -1
- package/core/server/data/db/connection.js +2 -0
- package/core/server/data/db/index.js +1 -0
- package/core/server/data/importer/handlers/ImporterContentFileHandler.js +90 -0
- package/core/server/data/importer/import-manager.js +1 -1
- package/core/server/data/seeders/DataGenerator.js +288 -0
- package/core/server/data/seeders/importers/BenefitsImporter.js +28 -0
- package/core/server/data/seeders/importers/CommentsImporter.js +73 -0
- package/core/server/data/seeders/importers/EmailBatchesImporter.js +38 -0
- package/core/server/data/seeders/importers/EmailRecipientFailuresImporter.js +67 -0
- package/core/server/data/seeders/importers/EmailRecipientsImporter.js +212 -0
- package/core/server/data/seeders/importers/EmailsImporter.js +99 -0
- package/core/server/data/seeders/importers/LabelsImporter.js +41 -0
- package/core/server/data/seeders/importers/MembersClickEventsImporter.js +69 -0
- package/core/server/data/seeders/importers/MembersCreatedEventsImporter.js +103 -0
- package/core/server/data/seeders/importers/MembersFeedbackImporter.js +45 -0
- package/core/server/data/seeders/importers/MembersImporter.js +111 -0
- package/core/server/data/seeders/importers/MembersLabelsImporter.js +39 -0
- package/core/server/data/seeders/importers/MembersLoginEventsImporter.js +69 -0
- package/core/server/data/seeders/importers/MembersNewslettersImporter.js +38 -0
- package/core/server/data/seeders/importers/MembersPaidSubscriptionEventsImporter.js +99 -0
- package/core/server/data/seeders/importers/MembersProductsImporter.js +42 -0
- package/core/server/data/seeders/importers/MembersStatusEventsImporter.js +58 -0
- package/core/server/data/seeders/importers/MembersStripeCustomersImporter.js +60 -0
- package/core/server/data/seeders/importers/MembersStripeCustomersSubscriptionsImporter.js +259 -0
- package/core/server/data/seeders/importers/MembersSubscribeEventsImporter.js +69 -0
- package/core/server/data/seeders/importers/MembersSubscriptionCreatedEventsImporter.js +95 -0
- package/core/server/data/seeders/importers/NewslettersImporter.js +40 -0
- package/core/server/data/seeders/importers/OffersImporter.js +70 -0
- package/core/server/data/seeders/importers/PostsAuthorsImporter.js +32 -0
- package/core/server/data/seeders/importers/PostsImporter.js +102 -0
- package/core/server/data/seeders/importers/PostsProductsImporter.js +35 -0
- package/core/server/data/seeders/importers/PostsTagsImporter.js +46 -0
- package/core/server/data/seeders/importers/ProductsBenefitsImporter.js +54 -0
- package/core/server/data/seeders/importers/ProductsImporter.js +90 -0
- package/core/server/data/seeders/importers/RecommendationClickEventsImporter.js +32 -0
- package/core/server/data/seeders/importers/RecommendationSubscribeEventsImporter.js +32 -0
- package/core/server/data/seeders/importers/RecommendationsImporter.js +34 -0
- package/core/server/data/seeders/importers/RedirectsImporter.js +49 -0
- package/core/server/data/seeders/importers/RolesUsersImporter.js +42 -0
- package/core/server/data/seeders/importers/StripePricesImporter.js +69 -0
- package/core/server/data/seeders/importers/StripeProductsImporter.js +34 -0
- package/core/server/data/seeders/importers/TableImporter.js +187 -0
- package/core/server/data/seeders/importers/TagsImporter.js +41 -0
- package/core/server/data/seeders/importers/UsersImporter.js +31 -0
- package/core/server/data/seeders/importers/WebMentionsImporter.js +42 -0
- package/core/server/data/seeders/importers/index.js +41 -0
- package/core/server/data/seeders/utils/JsonImporter.js +39 -0
- package/core/server/data/seeders/utils/blog-info.js +3 -0
- package/core/server/data/seeders/utils/database-date.js +7 -0
- package/core/server/data/seeders/utils/event-generator.js +48 -0
- package/core/server/data/seeders/utils/random.js +13 -0
- package/core/server/data/seeders/utils/topological-sort.js +33 -0
- package/core/server/services/adapter-manager/AdapterManager.js +161 -0
- package/core/server/services/adapter-manager/index.js +1 -1
- package/core/server/services/announcement-bar-service/AnnouncementBarSettings.js +54 -0
- package/core/server/services/announcement-bar-service/AnnouncementVisibilityValues.js +11 -0
- package/core/server/services/announcement-bar-service/index.js +1 -1
- package/core/server/services/api-version-compatibility/APIVersionCompatibilityService.js +1 -1
- package/core/server/services/auth/session/session-service.js +15 -5
- package/core/server/services/custom-redirects/index.js +1 -1
- package/core/server/services/email-analytics/EmailAnalyticsProviderMailgun.js +1 -1
- package/core/server/services/email-service/EmailServiceWrapper.js +4 -4
- package/core/server/services/email-suppression-list/MailgunEmailSuppressionList.js +1 -1
- package/core/server/services/email-suppression-list/service.js +1 -1
- package/core/server/services/lib/DynamicRedirectManager.js +156 -0
- package/core/server/services/lib/EmailContentGenerator.js +54 -0
- package/core/server/services/lib/InMemoryRepository.js +62 -0
- package/core/server/services/lib/InMemoryRepository.ts +80 -0
- package/core/server/services/lib/MailgunClient.js +364 -0
- package/core/server/services/link-redirection/LinkRedirect.js +26 -0
- package/core/server/services/link-redirection/LinkRedirectRepository.js +7 -7
- package/core/server/services/link-redirection/LinkRedirectsService.js +123 -0
- package/core/server/services/link-redirection/README.md +151 -0
- package/core/server/services/link-redirection/RedirectEvent.js +24 -0
- package/core/server/services/link-redirection/index.js +1 -1
- package/core/server/services/link-tracking/LinkClickTrackingService.js +1 -1
- package/core/server/services/mail/index.js +1 -1
- package/core/server/services/mail-events/InMemoryMailEventRepository.js +2 -2
- package/core/server/services/mail-events/InMemoryMailEventRepository.ts +1 -1
- package/core/server/services/members-events/LastSeenAtUpdater.js +1 -1
- package/core/server/services/offers/service.js +1 -1
- package/core/server/services/recommendations/RecommendationServiceWrapper.js +8 -8
- package/core/server/services/recommendations/service/BookshelfClickEventRepository.js +48 -0
- package/core/server/services/recommendations/service/BookshelfClickEventRepository.ts +49 -0
- package/core/server/services/recommendations/service/BookshelfRecommendationRepository.js +98 -0
- package/core/server/services/recommendations/service/BookshelfRecommendationRepository.ts +117 -0
- package/core/server/services/recommendations/service/BookshelfRepository.js +134 -0
- package/core/server/services/recommendations/service/BookshelfRepository.ts +196 -0
- package/core/server/services/recommendations/service/BookshelfSubscribeEventRepository.js +48 -0
- package/core/server/services/recommendations/service/BookshelfSubscribeEventRepository.ts +49 -0
- package/core/server/services/recommendations/service/ClickEvent.js +33 -0
- package/core/server/services/recommendations/service/ClickEvent.ts +32 -0
- package/core/server/services/recommendations/service/InMemoryRecommendationRepository.js +19 -0
- package/core/server/services/recommendations/service/InMemoryRecommendationRepository.ts +20 -0
- package/core/server/services/recommendations/service/IncomingRecommendationController.js +34 -0
- package/core/server/services/recommendations/service/IncomingRecommendationController.ts +51 -0
- package/core/server/services/recommendations/service/IncomingRecommendationEmailRenderer.js +25 -0
- package/core/server/services/recommendations/service/IncomingRecommendationEmailRenderer.ts +37 -0
- package/core/server/services/recommendations/service/IncomingRecommendationService.js +93 -0
- package/core/server/services/recommendations/service/IncomingRecommendationService.ts +160 -0
- package/core/server/services/recommendations/service/Recommendation.js +140 -0
- package/core/server/services/recommendations/service/Recommendation.ts +201 -0
- package/core/server/services/recommendations/service/RecommendationController.js +208 -0
- package/core/server/services/recommendations/service/RecommendationController.ts +258 -0
- package/core/server/services/recommendations/service/RecommendationMetadataService.js +86 -0
- package/core/server/services/recommendations/service/RecommendationMetadataService.ts +128 -0
- package/core/server/services/recommendations/service/RecommendationRepository.js +2 -0
- package/core/server/services/recommendations/service/RecommendationRepository.ts +13 -0
- package/core/server/services/recommendations/service/RecommendationService.js +228 -0
- package/core/server/services/recommendations/service/RecommendationService.ts +281 -0
- package/core/server/services/recommendations/service/SubscribeEvent.js +33 -0
- package/core/server/services/recommendations/service/SubscribeEvent.ts +32 -0
- package/core/server/services/recommendations/service/UnsafeData.js +183 -0
- package/core/server/services/recommendations/service/UnsafeData.ts +217 -0
- package/core/server/services/recommendations/service/WellknownService.js +36 -0
- package/core/server/services/recommendations/service/WellknownService.ts +47 -0
- package/core/server/services/recommendations/service/index.js +31 -0
- package/core/server/services/recommendations/service/index.ts +15 -0
- package/core/server/services/recommendations/service/libraries.d.ts +5 -0
- package/core/server/services/slack-notifications/SlackNotifications.js +211 -0
- package/core/server/services/slack-notifications/SlackNotificationsService.js +90 -0
- package/core/server/services/slack-notifications/service.js +4 -6
- package/core/server/web/api/endpoints/admin/app.js +1 -21
- package/core/server/web/api/middleware/version-match.js +41 -0
- package/core/shared/labs.js +2 -2
- package/package.json +87 -104
- package/tsconfig.tsbuildinfo +1 -1
- package/yarn.lock +1470 -1540
- package/components/tryghost-adapter-cache-redis-5.115.1.tgz +0 -0
- package/components/tryghost-adapter-manager-5.115.1.tgz +0 -0
- package/components/tryghost-announcement-bar-settings-5.115.1.tgz +0 -0
- package/components/tryghost-constants-5.115.1.tgz +0 -0
- package/components/tryghost-custom-fonts-5.115.1.tgz +0 -0
- package/components/tryghost-data-generator-5.115.1.tgz +0 -0
- package/components/tryghost-domain-events-5.115.1.tgz +0 -0
- package/components/tryghost-donations-5.115.1.tgz +0 -0
- package/components/tryghost-email-addresses-5.115.1.tgz +0 -0
- package/components/tryghost-email-content-generator-5.115.1.tgz +0 -0
- package/components/tryghost-email-events-5.115.1.tgz +0 -0
- package/components/tryghost-email-service-5.115.1.tgz +0 -0
- package/components/tryghost-email-suppression-list-5.115.1.tgz +0 -0
- package/components/tryghost-express-dynamic-redirects-5.115.1.tgz +0 -0
- package/components/tryghost-ghost-5.115.1.tgz +0 -0
- package/components/tryghost-html-to-plaintext-5.115.1.tgz +0 -0
- package/components/tryghost-i18n-5.115.1.tgz +0 -0
- package/components/tryghost-importer-handler-content-files-5.115.1.tgz +0 -0
- package/components/tryghost-in-memory-repository-5.115.1.tgz +0 -0
- package/components/tryghost-job-manager-5.115.1.tgz +0 -0
- package/components/tryghost-link-redirects-5.115.1.tgz +0 -0
- package/components/tryghost-link-replacer-5.115.1.tgz +0 -0
- package/components/tryghost-magic-link-5.115.1.tgz +0 -0
- package/components/tryghost-mailgun-client-5.115.1.tgz +0 -0
- package/components/tryghost-member-events-5.115.1.tgz +0 -0
- package/components/tryghost-members-api-5.115.1.tgz +0 -0
- package/components/tryghost-members-csv-5.115.1.tgz +0 -0
- package/components/tryghost-members-payments-5.115.1.tgz +0 -0
- package/components/tryghost-minifier-5.115.1.tgz +0 -0
- package/components/tryghost-mw-version-match-5.115.1.tgz +0 -0
- package/components/tryghost-mw-vhost-5.115.1.tgz +0 -0
- package/components/tryghost-post-events-5.115.1.tgz +0 -0
- package/components/tryghost-posts-service-5.115.1.tgz +0 -0
- package/components/tryghost-recommendations-5.115.1.tgz +0 -0
- package/components/tryghost-security-5.115.1.tgz +0 -0
- package/components/tryghost-slack-notifications-5.115.1.tgz +0 -0
- package/components/tryghost-webmentions-5.115.1.tgz +0 -0
- package/core/built/admin/assets/chunk.524.2439684964c164c598ab.js +0 -35
- package/core/built/admin/assets/chunk.582.bf5a2bbb2c4eb69ef1e7.js +0 -37
- package/core/built/admin/assets/ghost-327b17ea23cb8c89bd7e6a51e18e8506.css +0 -1
- package/core/built/admin/assets/ghost-dark-f30a597ac19632a118939492591c531b.css +0 -1
- /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,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;
|