ghost 5.30.0 → 5.31.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 (126) hide show
  1. package/components/{tryghost-adapter-manager-5.30.0.tgz → tryghost-adapter-manager-5.31.0.tgz} +0 -0
  2. package/components/{tryghost-api-framework-5.30.0.tgz → tryghost-api-framework-5.31.0.tgz} +0 -0
  3. package/components/{tryghost-api-version-compatibility-service-5.30.0.tgz → tryghost-api-version-compatibility-service-5.31.0.tgz} +0 -0
  4. package/components/{tryghost-audience-feedback-5.30.0.tgz → tryghost-audience-feedback-5.31.0.tgz} +0 -0
  5. package/components/{tryghost-bootstrap-socket-5.30.0.tgz → tryghost-bootstrap-socket-5.31.0.tgz} +0 -0
  6. package/components/{tryghost-constants-5.30.0.tgz → tryghost-constants-5.31.0.tgz} +0 -0
  7. package/components/{tryghost-custom-theme-settings-service-5.30.0.tgz → tryghost-custom-theme-settings-service-5.31.0.tgz} +0 -0
  8. package/components/{tryghost-data-generator-5.30.0.tgz → tryghost-data-generator-5.31.0.tgz} +0 -0
  9. package/components/tryghost-domain-events-5.31.0.tgz +0 -0
  10. package/components/tryghost-email-analytics-provider-mailgun-5.31.0.tgz +0 -0
  11. package/components/{tryghost-email-analytics-service-5.30.0.tgz → tryghost-email-analytics-service-5.31.0.tgz} +0 -0
  12. package/components/{tryghost-email-content-generator-5.30.0.tgz → tryghost-email-content-generator-5.31.0.tgz} +0 -0
  13. package/components/{tryghost-email-events-5.30.0.tgz → tryghost-email-events-5.31.0.tgz} +0 -0
  14. package/components/tryghost-email-service-5.31.0.tgz +0 -0
  15. package/components/{tryghost-email-suppression-list-5.30.0.tgz → tryghost-email-suppression-list-5.31.0.tgz} +0 -0
  16. package/components/{tryghost-express-dynamic-redirects-5.30.0.tgz → tryghost-express-dynamic-redirects-5.31.0.tgz} +0 -0
  17. package/components/tryghost-extract-api-key-5.31.0.tgz +0 -0
  18. package/components/{tryghost-html-to-plaintext-5.30.0.tgz → tryghost-html-to-plaintext-5.31.0.tgz} +0 -0
  19. package/components/tryghost-i18n-5.31.0.tgz +0 -0
  20. package/components/tryghost-importer-revue-5.31.0.tgz +0 -0
  21. package/components/{tryghost-job-manager-5.30.0.tgz → tryghost-job-manager-5.31.0.tgz} +0 -0
  22. package/components/{tryghost-link-redirects-5.30.0.tgz → tryghost-link-redirects-5.31.0.tgz} +0 -0
  23. package/components/tryghost-link-replacer-5.31.0.tgz +0 -0
  24. package/components/{tryghost-link-tracking-5.30.0.tgz → tryghost-link-tracking-5.31.0.tgz} +0 -0
  25. package/components/{tryghost-magic-link-5.30.0.tgz → tryghost-magic-link-5.31.0.tgz} +0 -0
  26. package/components/{tryghost-mailgun-client-5.30.0.tgz → tryghost-mailgun-client-5.31.0.tgz} +0 -0
  27. package/components/tryghost-member-attribution-5.31.0.tgz +0 -0
  28. package/components/tryghost-member-events-5.31.0.tgz +0 -0
  29. package/components/{tryghost-members-api-5.30.0.tgz → tryghost-members-api-5.31.0.tgz} +0 -0
  30. package/components/{tryghost-members-csv-5.30.0.tgz → tryghost-members-csv-5.31.0.tgz} +0 -0
  31. package/components/{tryghost-members-events-service-5.30.0.tgz → tryghost-members-events-service-5.31.0.tgz} +0 -0
  32. package/components/{tryghost-members-importer-5.30.0.tgz → tryghost-members-importer-5.31.0.tgz} +0 -0
  33. package/components/tryghost-members-offers-5.31.0.tgz +0 -0
  34. package/components/tryghost-members-payments-5.31.0.tgz +0 -0
  35. package/components/{tryghost-members-ssr-5.30.0.tgz → tryghost-members-ssr-5.31.0.tgz} +0 -0
  36. package/components/{tryghost-members-stripe-service-5.30.0.tgz → tryghost-members-stripe-service-5.31.0.tgz} +0 -0
  37. package/components/{tryghost-minifier-5.30.0.tgz → tryghost-minifier-5.31.0.tgz} +0 -0
  38. package/components/{tryghost-mw-api-version-mismatch-5.30.0.tgz → tryghost-mw-api-version-mismatch-5.31.0.tgz} +0 -0
  39. package/components/{tryghost-mw-cache-control-5.30.0.tgz → tryghost-mw-cache-control-5.31.0.tgz} +0 -0
  40. package/components/{tryghost-mw-error-handler-5.30.0.tgz → tryghost-mw-error-handler-5.31.0.tgz} +0 -0
  41. package/components/tryghost-mw-session-from-token-5.31.0.tgz +0 -0
  42. package/components/tryghost-mw-update-user-last-seen-5.31.0.tgz +0 -0
  43. package/components/{tryghost-mw-vhost-5.30.0.tgz → tryghost-mw-vhost-5.31.0.tgz} +0 -0
  44. package/components/tryghost-oembed-service-5.31.0.tgz +0 -0
  45. package/components/tryghost-package-json-5.31.0.tgz +0 -0
  46. package/components/tryghost-referrers-5.31.0.tgz +0 -0
  47. package/components/{tryghost-security-5.30.0.tgz → tryghost-security-5.31.0.tgz} +0 -0
  48. package/components/{tryghost-session-service-5.30.0.tgz → tryghost-session-service-5.31.0.tgz} +0 -0
  49. package/components/tryghost-settings-path-manager-5.31.0.tgz +0 -0
  50. package/components/{tryghost-staff-service-5.30.0.tgz → tryghost-staff-service-5.31.0.tgz} +0 -0
  51. package/components/tryghost-stats-service-5.31.0.tgz +0 -0
  52. package/components/{tryghost-tiers-5.30.0.tgz → tryghost-tiers-5.31.0.tgz} +0 -0
  53. package/components/{tryghost-update-check-service-5.30.0.tgz → tryghost-update-check-service-5.31.0.tgz} +0 -0
  54. package/components/tryghost-verification-trigger-5.31.0.tgz +0 -0
  55. package/components/{tryghost-version-notifications-data-service-5.30.0.tgz → tryghost-version-notifications-data-service-5.31.0.tgz} +0 -0
  56. package/components/tryghost-webmentions-5.31.0.tgz +0 -0
  57. package/content/themes/casper/author.hbs +6 -6
  58. package/content/themes/casper/package.json +1 -1
  59. package/core/boot.js +2 -0
  60. package/core/built/admin/assets/{chunk.143.f9aa3f7f1c45d1d921cd.js → chunk.143.7ef8d39b50a9ef3d6a6b.js} +5 -5
  61. package/core/built/admin/assets/{chunk.178.55d68318431345983298.js → chunk.178.8a8e070a8c2682df548a.js} +4 -4
  62. package/core/built/admin/assets/{ghost-d3e45940f0f1601232d464d0b429d45f.css → ghost-721a7adc4ca642c88e4ac85e1cb8b385.css} +1 -1
  63. package/core/built/admin/assets/{ghost-dark-c8dc36895dfcbc03f0edd94311c65bfd.css → ghost-dark-fb05eb50e216469c5626356731afa42f.css} +1 -1
  64. package/core/built/admin/assets/{ghost-23dc524374e35a582886c36f7dacdb05.js → ghost-fc0450a45ea5be2e5a10c4d897d5b430.js} +142 -124
  65. package/core/built/admin/assets/{vendor-fadbf85ad92c591dc4bd3755312b6ddf.js → vendor-0441964c34d58f2aacd5a04bbe240243.js} +34 -37
  66. package/core/built/admin/index.html +5 -5
  67. package/core/frontend/helpers/ghost_head.js +16 -0
  68. package/core/server/api/endpoints/index.js +4 -0
  69. package/core/server/api/endpoints/mentions.js +33 -0
  70. package/core/server/api/endpoints/oembed.js +1 -22
  71. package/core/server/api/endpoints/utils/serializers/input/settings.js +2 -1
  72. package/core/server/api/endpoints/utils/serializers/output/mappers/index.js +2 -1
  73. package/core/server/api/endpoints/utils/serializers/output/mappers/mentions.js +18 -0
  74. package/core/server/data/exporter/table-lists.js +1 -0
  75. package/core/server/data/importer/import-manager.js +21 -5
  76. package/core/server/data/migrations/versions/5.31/2022-12-05-09-56-update-newsletter-subscriptions.js +23 -0
  77. package/core/server/data/migrations/versions/5.31/2023-01-17-14-59-add-outbound-link-tagging-setting.js +8 -0
  78. package/core/server/data/migrations/versions/5.31/2023-01-19-07-46-add-mentions-table.js +17 -0
  79. package/core/server/data/schema/default-settings/default-settings.json +10 -0
  80. package/core/server/data/schema/schema.js +15 -0
  81. package/core/server/models/base/plugins/actions.js +1 -1
  82. package/core/server/models/base/plugins/sanitize.js +2 -0
  83. package/core/server/models/mention.js +9 -0
  84. package/core/server/models/redirect.js +2 -1
  85. package/core/server/services/api-version-compatibility/index.js +5 -8
  86. package/core/server/services/email-suppression-list/MailgunEmailSuppressionList.js +1 -1
  87. package/core/server/services/link-redirection/LinkRedirectRepository.js +2 -1
  88. package/core/server/services/link-tracking/PostLinkRepository.js +2 -1
  89. package/core/server/services/mega/post-email-serializer.js +2 -2
  90. package/core/server/services/member-attribution/index.js +2 -0
  91. package/core/server/services/mentions/BookshelfMentionRepository.js +117 -0
  92. package/core/server/services/mentions/MentionController.js +66 -0
  93. package/core/server/services/mentions/WebmentionMetadata.js +20 -0
  94. package/core/server/services/mentions/index.js +1 -0
  95. package/core/server/services/mentions/service.js +77 -0
  96. package/core/server/services/oembed/index.js +1 -0
  97. package/core/server/services/{nft-oembed.js → oembed/nft-oembed.js} +0 -0
  98. package/core/server/services/oembed/service.js +24 -0
  99. package/core/server/services/{twitter-embed.js → oembed/twitter-embed.js} +0 -0
  100. package/core/server/services/url/Resources.js +3 -17
  101. package/core/server/services/url/UrlService.js +3 -3
  102. package/core/server/web/api/endpoints/admin/routes.js +3 -0
  103. package/core/server/web/parent/frontend.js +1 -0
  104. package/core/server/web/webmentions/index.js +1 -0
  105. package/core/server/web/webmentions/routes.js +18 -0
  106. package/core/shared/labs.js +6 -4
  107. package/package.json +115 -111
  108. package/yarn.lock +620 -80
  109. package/components/tryghost-domain-events-5.30.0.tgz +0 -0
  110. package/components/tryghost-email-analytics-provider-mailgun-5.30.0.tgz +0 -0
  111. package/components/tryghost-email-service-5.30.0.tgz +0 -0
  112. package/components/tryghost-extract-api-key-5.30.0.tgz +0 -0
  113. package/components/tryghost-importer-revue-5.30.0.tgz +0 -0
  114. package/components/tryghost-link-replacer-5.30.0.tgz +0 -0
  115. package/components/tryghost-member-attribution-5.30.0.tgz +0 -0
  116. package/components/tryghost-member-events-5.30.0.tgz +0 -0
  117. package/components/tryghost-members-offers-5.30.0.tgz +0 -0
  118. package/components/tryghost-members-payments-5.30.0.tgz +0 -0
  119. package/components/tryghost-mw-session-from-token-5.30.0.tgz +0 -0
  120. package/components/tryghost-mw-update-user-last-seen-5.30.0.tgz +0 -0
  121. package/components/tryghost-oembed-service-5.30.0.tgz +0 -0
  122. package/components/tryghost-package-json-5.30.0.tgz +0 -0
  123. package/components/tryghost-referrers-5.30.0.tgz +0 -0
  124. package/components/tryghost-settings-path-manager-5.30.0.tgz +0 -0
  125. package/components/tryghost-stats-service-5.30.0.tgz +0 -0
  126. package/components/tryghost-verification-trigger-5.30.0.tgz +0 -0
@@ -0,0 +1,8 @@
1
+ const {addSetting} = require('../../utils');
2
+
3
+ module.exports = addSetting({
4
+ key: 'outbound_link_tagging',
5
+ value: 'true',
6
+ type: 'boolean',
7
+ group: 'analytics'
8
+ });
@@ -0,0 +1,17 @@
1
+ const {addTable} = require('../../utils');
2
+
3
+ module.exports = addTable('mentions', {
4
+ id: {type: 'string', maxlength: 24, nullable: false, primary: true},
5
+ source: {type: 'string', maxlength: 2000, nullable: false},
6
+ source_title: {type: 'string', maxlength: 2000, nullable: true},
7
+ source_site_title: {type: 'string', maxlength: 2000, nullable: true},
8
+ source_excerpt: {type: 'string', maxlength: 2000, nullable: true},
9
+ source_author: {type: 'string', maxlength: 2000, nullable: true},
10
+ source_featured_image: {type: 'string', maxlength: 2000, nullable: true},
11
+ source_favicon: {type: 'string', maxlength: 2000, nullable: true},
12
+ target: {type: 'string', maxlength: 2000, nullable: false},
13
+ resource_id: {type: 'string', maxlength: 24, nullable: true},
14
+ resource_type: {type: 'string', maxlength: 50, nullable: true},
15
+ created_at: {type: 'dateTime', nullable: false},
16
+ payload: {type: 'text', maxlength: 65535, nullable: true}
17
+ });
@@ -480,5 +480,15 @@
480
480
  ]]
481
481
  }
482
482
  }
483
+ },
484
+ "analytics": {
485
+ "outbound_link_tagging": {
486
+ "defaultValue": "true",
487
+ "validations": {
488
+ "isEmpty": false,
489
+ "isIn": [["true", "false"]]
490
+ },
491
+ "type": "boolean"
492
+ }
483
493
  }
484
494
  }
@@ -979,5 +979,20 @@ module.exports = {
979
979
  '@@UNIQUE_CONSTRAINTS@@': [
980
980
  ['email_id', 'member_id']
981
981
  ]
982
+ },
983
+ mentions: {
984
+ id: {type: 'string', maxlength: 24, nullable: false, primary: true},
985
+ source: {type: 'string', maxlength: 2000, nullable: false},
986
+ source_title: {type: 'string', maxlength: 2000, nullable: true},
987
+ source_site_title: {type: 'string', maxlength: 2000, nullable: true},
988
+ source_excerpt: {type: 'string', maxlength: 2000, nullable: true},
989
+ source_author: {type: 'string', maxlength: 2000, nullable: true},
990
+ source_featured_image: {type: 'string', maxlength: 2000, nullable: true},
991
+ source_favicon: {type: 'string', maxlength: 2000, nullable: true},
992
+ target: {type: 'string', maxlength: 2000, nullable: false},
993
+ resource_id: {type: 'string', maxlength: 24, nullable: true},
994
+ resource_type: {type: 'string', maxlength: 50, nullable: true},
995
+ created_at: {type: 'dateTime', nullable: false},
996
+ payload: {type: 'text', maxlength: 65535, nullable: true}
982
997
  }
983
998
  };
@@ -93,7 +93,7 @@ module.exports = function (Bookshelf) {
93
93
 
94
94
  const insert = (action) => {
95
95
  Bookshelf.model('Action')
96
- .add(action)
96
+ .add(action, {autoRefresh: false})
97
97
  .catch((err) => {
98
98
  if (_.isArray(err)) {
99
99
  err = err[0];
@@ -38,6 +38,8 @@ module.exports = function (Bookshelf) {
38
38
  return baseOptions.concat('shallow', 'columns', 'previous');
39
39
  case 'destroy':
40
40
  return baseOptions.concat(extraOptions, ['id', 'destroyBy', 'require']);
41
+ case 'add':
42
+ return baseOptions.concat(extraOptions, ['autoRefresh']);
41
43
  case 'edit':
42
44
  return baseOptions.concat(extraOptions, ['id', 'require']);
43
45
  case 'findOne':
@@ -0,0 +1,9 @@
1
+ const ghostBookshelf = require('./base');
2
+
3
+ const Mention = ghostBookshelf.Model.extend({
4
+ tableName: 'mentions'
5
+ });
6
+
7
+ module.exports = {
8
+ Mention: ghostBookshelf.model('Mention', Mention)
9
+ };
@@ -36,7 +36,8 @@ const Redirect = ghostBookshelf.Model.extend({
36
36
  permittedOptions(methodName) {
37
37
  let options = ghostBookshelf.Model.permittedOptions.call(this, methodName);
38
38
  const validOptions = {
39
- findAll: ['filter', 'columns', 'withRelated']
39
+ findAll: ['filter', 'columns', 'withRelated'],
40
+ edit: ['importing']
40
41
  };
41
42
 
42
43
  if (validOptions[methodName]) {
@@ -30,21 +30,18 @@ module.exports.errorHandler = (err, req, res, next) => {
30
30
  };
31
31
 
32
32
  /**
33
- * If Accept-Version is set on the request set Content-Version on the response
34
- * Also, add 'Accept-Version' to VARY as it effects response caching
33
+ * Set Content-Version on the response, and add 'Accept-Version' to VARY as
34
+ * it effects response caching
35
35
  * TODO: move the method to mw once back-compatibility with 4.x is sorted
36
- *
36
+ *
37
37
  * @param {import('express').Request} req
38
38
  * @param {import('express').Response} res
39
39
  * @param {import('express').NextFunction} next
40
40
  */
41
41
  module.exports.contentVersion = (req, res, next) => {
42
- if (req.header('accept-version')) {
43
- res.header('Content-Version', `v${ghostVersion.safe}`);
44
- }
45
-
42
+ res.header('Content-Version', `v${ghostVersion.safe}`);
46
43
  res.vary('Accept-Version');
47
-
44
+
48
45
  next();
49
46
  };
50
47
 
@@ -73,7 +73,7 @@ class MailgunEmailSuppressionList extends AbstractEmailSuppressionList {
73
73
 
74
74
  try {
75
75
  const collection = await this.Suppression.findAll({
76
- filter: `email:[${emails.join(',')}]`
76
+ filter: `email:[${emails.map(email => `'${email}'`).join(',')}]`
77
77
  });
78
78
 
79
79
  return emails.map((email) => {
@@ -37,7 +37,8 @@ module.exports = class LinkRedirectRepository {
37
37
 
38
38
  fromModel(model) {
39
39
  // Store if link has been edited
40
- const edited = model.get('created_at')?.getTime() !== model.get('updated_at')?.getTime();
40
+ // Note: in some edge cases updated_at is set directly after created_at, sometimes with a second difference, so we need to check for that
41
+ const edited = model.get('updated_at')?.getTime() > (model.get('created_at')?.getTime() + 1000);
41
42
 
42
43
  return new LinkRedirect({
43
44
  id: model.id,
@@ -80,7 +80,8 @@ module.exports = class PostLinkRepository {
80
80
  await this.#LinkRedirect.edit({
81
81
  post_id: postLink.post_id.toHexString()
82
82
  }, {
83
- id: postLink.link_id.toHexString()
83
+ id: postLink.link_id.toHexString(),
84
+ importing: true // skip setting updated_at when linking a post to a link
84
85
  });
85
86
  }
86
87
  };
@@ -400,13 +400,13 @@ const PostEmailSerializer = {
400
400
 
401
401
  if (isSite) {
402
402
  // Add newsletter name as ref to the URL
403
- url = memberAttribution.service.addEmailSourceAttributionTracking(url, newsletter);
403
+ url = memberAttribution.service.addOutboundLinkTagging(url, newsletter);
404
404
 
405
405
  // Only add post attribution to our own site (because external sites could/should not process this information)
406
406
  url = memberAttribution.service.addPostAttributionTracking(url, post);
407
407
  } else {
408
408
  // Add email source attribution without the newsletter name
409
- url = memberAttribution.service.addEmailSourceAttributionTracking(url);
409
+ url = memberAttribution.service.addOutboundLinkTagging(url);
410
410
  }
411
411
 
412
412
  // Add link click tracking
@@ -1,6 +1,7 @@
1
1
  const urlService = require('../url');
2
2
  const urlUtils = require('../../../shared/url-utils');
3
3
  const settingsCache = require('../../../shared/settings-cache');
4
+ const labs = require('../../../shared/labs');
4
5
 
5
6
  class MemberAttributionServiceWrapper {
6
7
  init() {
@@ -41,6 +42,7 @@ class MemberAttributionServiceWrapper {
41
42
  },
42
43
  attributionBuilder: this.attributionBuilder,
43
44
  getTrackingEnabled: () => !!settingsCache.get('members_track_sources'),
45
+ getOutboundLinkTaggingEnabled: () => !labs.isSet('outboundLinkTagging') || !!settingsCache.get('outbound_link_tagging'),
44
46
  getSiteTitle: () => settingsCache.get('title')
45
47
  });
46
48
  }
@@ -0,0 +1,117 @@
1
+ const {Mention} = require('@tryghost/webmentions');
2
+ const logging = require('@tryghost/logging');
3
+
4
+ /**
5
+ * @typedef {import('@tryghost/webmentions/lib/MentionsAPI').IMentionRepository} IMentionRepository
6
+ */
7
+
8
+ /**
9
+ * @template Model
10
+ * @typedef {import('@tryghost/webmentions/lib/MentionsAPI').Page<Model>} Page
11
+ */
12
+
13
+ /**
14
+ * @typedef {import('@tryghost/webmentions/lib/MentionsAPI').GetPageOptions} GetPageOptions
15
+ */
16
+
17
+ /**
18
+ * @implements {IMentionRepository}
19
+ */
20
+ module.exports = class BookshelfMentionRepository {
21
+ /** @type {Object} */
22
+ #MentionModel;
23
+
24
+ /**
25
+ * @param {object} deps
26
+ * @param {object} deps.MentionModel Bookshelf Model
27
+ */
28
+ constructor(deps) {
29
+ this.#MentionModel = deps.MentionModel;
30
+ }
31
+
32
+ #modelToMention(model) {
33
+ let payload;
34
+ try {
35
+ payload = JSON.parse(model.get('payload'));
36
+ } catch (err) {
37
+ logging.error(err);
38
+ payload = {};
39
+ }
40
+ return Mention.create({
41
+ id: model.get('id'),
42
+ source: model.get('source'),
43
+ target: model.get('target'),
44
+ timestamp: model.get('created_at'),
45
+ payload,
46
+ resourceId: model.get('resource_id'),
47
+ sourceTitle: model.get('source_title'),
48
+ sourceSiteTitle: model.get('source_site_title'),
49
+ sourceAuthor: model.get('source_author'),
50
+ sourceExcerpt: model.get('source_excerpt'),
51
+ sourceFavicon: model.get('source_favicon'),
52
+ sourceFeaturedImaged: model.get('source_featured_image')
53
+ });
54
+ }
55
+
56
+ /**
57
+ * @param {GetPageOptions} options
58
+ * @returns {Promise<Page<import('@tryghost/webmentions/lib/Mention')>>}
59
+ */
60
+ async getPage(options) {
61
+ const page = await this.#MentionModel.findPage(options);
62
+
63
+ return {
64
+ data: await Promise.all(page.data.map(model => this.#modelToMention(model))),
65
+ meta: page.meta
66
+ };
67
+ }
68
+
69
+ /**
70
+ * @param {URL} source
71
+ * @param {URL} target
72
+ * @returns {Promise<import('@tryghost/webmentions/lib/Mention')|null>}
73
+ */
74
+ async getBySourceAndTarget(source, target) {
75
+ const model = await this.#MentionModel.findOne({
76
+ source: source.href,
77
+ target: target.href
78
+ }, {require: false});
79
+
80
+ if (!model) {
81
+ return null;
82
+ }
83
+
84
+ return this.#modelToMention(model);
85
+ }
86
+
87
+ /**
88
+ * @param {import('@tryghost/webmentions/lib/Mention')} mention
89
+ * @returns {Promise<void>}
90
+ */
91
+ async save(mention) {
92
+ const data = {
93
+ id: mention.id.toHexString(),
94
+ source: mention.source.href,
95
+ source_title: mention.sourceTitle,
96
+ source_site_title: mention.sourceSiteTitle,
97
+ source_excerpt: mention.sourceExcerpt,
98
+ source_author: mention.sourceAuthor,
99
+ source_featured_image: mention.sourceFeaturedImage?.href,
100
+ source_favicon: mention.sourceFavicon?.href,
101
+ target: mention.target.href,
102
+ resource_id: mention.resourceId?.toHexString(),
103
+ resource_type: mention.resourceId ? 'post' : null,
104
+ payload: mention.payload ? JSON.stringify(mention.payload) : null
105
+ };
106
+
107
+ const existing = await this.#MentionModel.findOne({id: data.id}, {require: false});
108
+
109
+ if (!existing) {
110
+ await this.#MentionModel.add(data);
111
+ } else {
112
+ await this.#MentionModel.edit(data, {
113
+ id: data.id
114
+ });
115
+ }
116
+ }
117
+ };
@@ -0,0 +1,66 @@
1
+ const logging = require('@tryghost/logging');
2
+
3
+ /**
4
+ * @typedef {import('@tryghost/webmentions/lib/webmentions').MentionsAPI} MentionsAPI
5
+ * @typedef {import('@tryghost/webmentions/lib/webmentions').Mention} Mention
6
+ */
7
+
8
+ /**
9
+ * @template Model
10
+ * @typedef {import('@tryghost/webmentions/lib/MentionsAPI').Page} Page<Model>
11
+ */
12
+
13
+ module.exports = class MentionController {
14
+ /** @type {import('@tryghost/webmentions/lib/MentionsAPI')} */
15
+ #api;
16
+
17
+ async init(deps) {
18
+ this.#api = deps.api;
19
+ }
20
+
21
+ /**
22
+ * @param {import('@tryghost/api-framework').Frame} frame
23
+ * @returns {Promise<Page<Mention>>}
24
+ */
25
+ async browse(frame) {
26
+ let limit;
27
+ if (!frame.options.limit || frame.options.limit === 'all') {
28
+ limit = 'all';
29
+ } else {
30
+ limit = parseInt(frame.options.limit);
31
+ }
32
+
33
+ let page;
34
+ if (frame.options.page) {
35
+ page = parseInt(frame.options.page);
36
+ } else {
37
+ page = 1;
38
+ }
39
+
40
+ const results = await this.#api.listMentions({
41
+ filter: frame.options.filter,
42
+ limit,
43
+ page
44
+ });
45
+
46
+ return results;
47
+ }
48
+
49
+ /**
50
+ * @param {import('@tryghost/api-framework').Frame} frame
51
+ * @returns {Promise<void>}
52
+ */
53
+ async receive(frame) {
54
+ logging.info('[Webmention] ' + JSON.stringify(frame.data));
55
+ const {source, target, ...payload} = frame.data;
56
+ const result = this.#api.processWebmention({
57
+ source: new URL(source),
58
+ target: new URL(target),
59
+ payload
60
+ });
61
+
62
+ result.catch(function rejected(err) {
63
+ logging.error(err);
64
+ });
65
+ }
66
+ };
@@ -0,0 +1,20 @@
1
+ const oembedService = require('../oembed');
2
+
3
+ module.exports = class WebmentionMetadata {
4
+ /**
5
+ * @param {URL} url
6
+ * @returns {Promise<import('@tryghost/webmentions/lib/MentionsAPI').WebmentionMetadata>}
7
+ */
8
+ async fetch(url) {
9
+ const data = await oembedService.fetchOembedDataFromUrl(url.href, 'bookmark');
10
+ const result = {
11
+ siteTitle: data.metadata.publisher,
12
+ title: data.metadata.title,
13
+ excerpt: data.metadata.description,
14
+ author: data.metadata.author,
15
+ image: data.metadata.thumbnail ? new URL(data.metadata.thumbnail) : null,
16
+ favicon: data.metadata.icon ? new URL(data.metadata.icon) : null
17
+ };
18
+ return result;
19
+ }
20
+ };
@@ -0,0 +1 @@
1
+ module.exports = require('./service');
@@ -0,0 +1,77 @@
1
+ const ObjectID = require('bson-objectid').default;
2
+ const MentionController = require('./MentionController');
3
+ const WebmentionMetadata = require('./WebmentionMetadata');
4
+ const {
5
+ MentionsAPI,
6
+ MentionSendingService,
7
+ MentionDiscoveryService
8
+ } = require('@tryghost/webmentions');
9
+ const BookshelfMentionRepository = require('./BookshelfMentionRepository');
10
+ const models = require('../../models');
11
+ const events = require('../../lib/common/events');
12
+ const externalRequest = require('../../../server/lib/request-external.js');
13
+ const urlUtils = require('../../../shared/url-utils');
14
+ const outputSerializerUrlUtil = require('../../../server/api/endpoints/utils/serializers/output/utils/url');
15
+ const labs = require('../../../shared/labs');
16
+ const urlService = require('../url');
17
+
18
+ function getPostUrl(post) {
19
+ const jsonModel = {};
20
+ outputSerializerUrlUtil.forPost(post.id, jsonModel, {options: {}});
21
+ return jsonModel.url;
22
+ }
23
+ module.exports = {
24
+ controller: new MentionController(),
25
+ async init() {
26
+ const repository = new BookshelfMentionRepository({
27
+ MentionModel: models.Mention
28
+ });
29
+ const webmentionMetadata = new WebmentionMetadata();
30
+ const discoveryService = new MentionDiscoveryService({externalRequest});
31
+ const api = new MentionsAPI({
32
+ repository,
33
+ webmentionMetadata,
34
+ resourceService: {
35
+ async getByURL(url) {
36
+ const path = urlUtils.absoluteToRelative(url.href, {withoutSubdirectory: true});
37
+ const resource = urlService.getResource(path);
38
+ if (resource?.config?.type === 'posts') {
39
+ return {
40
+ type: 'post',
41
+ id: ObjectID.createFromHexString(resource.data.id)
42
+ };
43
+ }
44
+ return {
45
+ type: null,
46
+ id: null
47
+ };
48
+ }
49
+ },
50
+ routingService: {
51
+ async pageExists(url) {
52
+ const siteUrl = new URL(urlUtils.getSiteUrl());
53
+ if (siteUrl.origin !== url.origin) {
54
+ return false;
55
+ }
56
+ const subdir = urlUtils.getSubdir();
57
+ if (subdir && !url.pathname.startsWith(subdir)) {
58
+ return false;
59
+ }
60
+
61
+ return true;
62
+ }
63
+ }
64
+ });
65
+
66
+ this.controller.init({api});
67
+
68
+ const sendingService = new MentionSendingService({
69
+ discoveryService,
70
+ externalRequest,
71
+ getSiteUrl: () => urlUtils.urlFor('home', true),
72
+ getPostUrl: post => getPostUrl(post),
73
+ isEnabled: () => labs.isSet('webmentions')
74
+ });
75
+ sendingService.listen(events);
76
+ }
77
+ };
@@ -0,0 +1 @@
1
+ module.exports = require('./service');
@@ -0,0 +1,24 @@
1
+ const config = require('../../../shared/config');
2
+ const externalRequest = require('../../lib/request-external');
3
+
4
+ const OEmbed = require('@tryghost/oembed-service');
5
+ const oembed = new OEmbed({config, externalRequest});
6
+
7
+ const NFT = require('./nft-oembed');
8
+ const nft = new NFT({
9
+ config: {
10
+ apiKey: config.get('opensea').privateReadOnlyApiKey
11
+ }
12
+ });
13
+
14
+ const Twitter = require('./twitter-embed');
15
+ const twitter = new Twitter({
16
+ config: {
17
+ bearerToken: config.get('twitter').privateReadOnlyToken
18
+ }
19
+ });
20
+
21
+ oembed.registerProvider(nft);
22
+ oembed.registerProvider(twitter);
23
+
24
+ module.exports = oembed;
@@ -21,10 +21,11 @@ class Resources {
21
21
  * @param {Object} options
22
22
  * @param {Object} [options.resources] - resources to initialize with instead of fetching them from the database
23
23
  * @param {Object} [options.queue] - instance of the Queue class
24
+ * @param {Object[]} [options.resourcesConfig] - resource config used when handling resource events and fetching
24
25
  */
25
- constructor({resources = {}, queue} = {}) {
26
+ constructor({resources = {}, queue, resourcesConfig = []} = {}) {
26
27
  this.queue = queue;
27
- this.resourcesConfig = [];
28
+ this.resourcesConfig = resourcesConfig;
28
29
  this.data = resources;
29
30
 
30
31
  this.listeners = [];
@@ -47,20 +48,6 @@ class Resources {
47
48
  events.on(eventName, listener);
48
49
  }
49
50
 
50
- /**
51
- * @description Initialize the resource config. We currently fetch the data straight via the the model layer,
52
- * but because Ghost supports multiple API versions, we have to ensure we load the correct data.
53
- *
54
- * @TODO: https://github.com/TryGhost/Ghost/issues/10360
55
- */
56
- initResourceConfig() {
57
- if (!_.isEmpty(this.resourcesConfig)) {
58
- return;
59
- }
60
-
61
- this.resourcesConfig = require('./config');
62
- }
63
-
64
51
  /**
65
52
  * @description Helper function to initialize data fetching.
66
53
  */
@@ -440,7 +427,6 @@ class Resources {
440
427
 
441
428
  this.listeners = [];
442
429
  this.data = {};
443
- this.resourcesConfig = null;
444
430
  }
445
431
 
446
432
  /**
@@ -8,6 +8,7 @@ const Queue = require('./Queue');
8
8
  const Urls = require('./Urls');
9
9
  const Resources = require('./Resources');
10
10
  const urlUtils = require('../../../shared/url-utils');
11
+ const resourcesConfig = require('./config');
11
12
 
12
13
  /**
13
14
  * The url service class holds all instances in a centralized place.
@@ -17,7 +18,7 @@ const urlUtils = require('../../../shared/url-utils');
17
18
  class UrlService {
18
19
  /**
19
20
  *
20
- * @param {Object} options
21
+ * @param {Object} [options]
21
22
  * @param {Object} [options.cache] - cache handler instance
22
23
  * @param {Function} [options.cache.read] - read cache by type
23
24
  * @param {Function} [options.cache.write] - write into cache by type
@@ -35,6 +36,7 @@ class UrlService {
35
36
  // Way too many tests fail if the initialization is removed so leaving it as is for time being
36
37
  this.urls = new Urls();
37
38
  this.resources = new Resources({
39
+ resourcesConfig: resourcesConfig,
38
40
  queue: this.queue
39
41
  });
40
42
 
@@ -321,12 +323,10 @@ class UrlService {
321
323
  if (persistedUrls && persistedResources) {
322
324
  this.urls.urls = persistedUrls;
323
325
  this.resources.data = persistedResources;
324
- this.resources.initResourceConfig();
325
326
  this.resources.initEventListeners();
326
327
 
327
328
  this._onQueueEnded('init');
328
329
  } else {
329
- this.resources.initResourceConfig();
330
330
  this.resources.initEventListeners();
331
331
  await this.resources.fetchResources();
332
332
  // CASE: all resources are fetched, start the queue
@@ -5,6 +5,7 @@ const apiMw = require('../../middleware');
5
5
  const mw = require('./middleware');
6
6
 
7
7
  const shared = require('../../../shared');
8
+ const labs = require('../../../../../shared/labs');
8
9
 
9
10
  module.exports = function apiRoutes() {
10
11
  const router = express.Router('admin api');
@@ -31,6 +32,8 @@ module.exports = function apiRoutes() {
31
32
  router.put('/posts/:id', mw.authAdminApi, http(api.posts.edit));
32
33
  router.del('/posts/:id', mw.authAdminApi, http(api.posts.destroy));
33
34
 
35
+ router.get('/mentions', labs.enabledMiddleware('webmentions'), mw.authAdminApi, http(api.mentions.browse));
36
+
34
37
  router.put('/comments/:id', mw.authAdminApi, http(api.comments.edit));
35
38
 
36
39
  // ## Pages
@@ -18,6 +18,7 @@ module.exports = (routerConfig) => {
18
18
  frontendApp.use(shared.middleware.urlRedirects.frontendSSLRedirect);
19
19
 
20
20
  frontendApp.lazyUse('/members', require('../members'));
21
+ frontendApp.lazyUse('/webmentions', require('../webmentions'));
21
22
  frontendApp.use('/', require('../../../frontend/web')(routerConfig));
22
23
 
23
24
  return frontendApp;
@@ -0,0 +1 @@
1
+ module.exports = require('./routes');
@@ -0,0 +1,18 @@
1
+ const express = require('../../../shared/express');
2
+ const api = require('../../api').endpoints;
3
+ const {http} = require('@tryghost/api-framework');
4
+ const shared = require('../shared');
5
+
6
+ const bodyParser = require('body-parser');
7
+
8
+ module.exports = function apiRoutes() {
9
+ const router = express.Router('webmentions');
10
+
11
+ // shouldn't be cached
12
+ router.use(shared.middleware.cacheControl('private'));
13
+
14
+ // Webmentions
15
+ router.post('/receive', bodyParser.urlencoded({extended: true, limit: '5mb'}), http(api.mentions.receive));
16
+
17
+ return router;
18
+ };