ghost 5.30.1 → 5.32.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 (138) hide show
  1. package/components/{tryghost-adapter-manager-5.30.1.tgz → tryghost-adapter-manager-5.32.0.tgz} +0 -0
  2. package/components/{tryghost-api-framework-5.30.1.tgz → tryghost-api-framework-5.32.0.tgz} +0 -0
  3. package/components/{tryghost-api-version-compatibility-service-5.30.1.tgz → tryghost-api-version-compatibility-service-5.32.0.tgz} +0 -0
  4. package/components/{tryghost-audience-feedback-5.30.1.tgz → tryghost-audience-feedback-5.32.0.tgz} +0 -0
  5. package/components/{tryghost-bootstrap-socket-5.30.1.tgz → tryghost-bootstrap-socket-5.32.0.tgz} +0 -0
  6. package/components/tryghost-constants-5.32.0.tgz +0 -0
  7. package/components/{tryghost-custom-theme-settings-service-5.30.1.tgz → tryghost-custom-theme-settings-service-5.32.0.tgz} +0 -0
  8. package/components/tryghost-data-generator-5.32.0.tgz +0 -0
  9. package/components/tryghost-domain-events-5.32.0.tgz +0 -0
  10. package/components/tryghost-email-analytics-provider-mailgun-5.32.0.tgz +0 -0
  11. package/components/{tryghost-email-analytics-service-5.30.1.tgz → tryghost-email-analytics-service-5.32.0.tgz} +0 -0
  12. package/components/{tryghost-email-content-generator-5.30.1.tgz → tryghost-email-content-generator-5.32.0.tgz} +0 -0
  13. package/components/{tryghost-email-events-5.30.1.tgz → tryghost-email-events-5.32.0.tgz} +0 -0
  14. package/components/tryghost-email-service-5.32.0.tgz +0 -0
  15. package/components/tryghost-email-suppression-list-5.32.0.tgz +0 -0
  16. package/components/{tryghost-express-dynamic-redirects-5.30.1.tgz → tryghost-express-dynamic-redirects-5.32.0.tgz} +0 -0
  17. package/components/{tryghost-extract-api-key-5.30.1.tgz → tryghost-extract-api-key-5.32.0.tgz} +0 -0
  18. package/components/{tryghost-html-to-plaintext-5.30.1.tgz → tryghost-html-to-plaintext-5.32.0.tgz} +0 -0
  19. package/components/tryghost-i18n-5.32.0.tgz +0 -0
  20. package/components/{tryghost-importer-revue-5.30.1.tgz → tryghost-importer-revue-5.32.0.tgz} +0 -0
  21. package/components/{tryghost-job-manager-5.30.1.tgz → tryghost-job-manager-5.32.0.tgz} +0 -0
  22. package/components/tryghost-link-redirects-5.32.0.tgz +0 -0
  23. package/components/tryghost-link-replacer-5.32.0.tgz +0 -0
  24. package/components/{tryghost-link-tracking-5.30.1.tgz → tryghost-link-tracking-5.32.0.tgz} +0 -0
  25. package/components/tryghost-magic-link-5.32.0.tgz +0 -0
  26. package/components/{tryghost-mailgun-client-5.30.1.tgz → tryghost-mailgun-client-5.32.0.tgz} +0 -0
  27. package/components/tryghost-member-attribution-5.32.0.tgz +0 -0
  28. package/components/{tryghost-member-events-5.30.1.tgz → tryghost-member-events-5.32.0.tgz} +0 -0
  29. package/components/{tryghost-members-api-5.30.1.tgz → tryghost-members-api-5.32.0.tgz} +0 -0
  30. package/components/{tryghost-members-csv-5.30.1.tgz → tryghost-members-csv-5.32.0.tgz} +0 -0
  31. package/components/{tryghost-members-events-service-5.30.1.tgz → tryghost-members-events-service-5.32.0.tgz} +0 -0
  32. package/components/{tryghost-members-importer-5.30.1.tgz → tryghost-members-importer-5.32.0.tgz} +0 -0
  33. package/components/tryghost-members-offers-5.32.0.tgz +0 -0
  34. package/components/tryghost-members-payments-5.32.0.tgz +0 -0
  35. package/components/{tryghost-members-ssr-5.30.1.tgz → tryghost-members-ssr-5.32.0.tgz} +0 -0
  36. package/components/{tryghost-members-stripe-service-5.30.1.tgz → tryghost-members-stripe-service-5.32.0.tgz} +0 -0
  37. package/components/{tryghost-minifier-5.30.1.tgz → tryghost-minifier-5.32.0.tgz} +0 -0
  38. package/components/tryghost-mw-api-version-mismatch-5.32.0.tgz +0 -0
  39. package/components/tryghost-mw-cache-control-5.32.0.tgz +0 -0
  40. package/components/{tryghost-mw-error-handler-5.30.1.tgz → tryghost-mw-error-handler-5.32.0.tgz} +0 -0
  41. package/components/tryghost-mw-session-from-token-5.32.0.tgz +0 -0
  42. package/components/tryghost-mw-update-user-last-seen-5.32.0.tgz +0 -0
  43. package/components/tryghost-mw-vhost-5.32.0.tgz +0 -0
  44. package/components/tryghost-oembed-service-5.32.0.tgz +0 -0
  45. package/components/tryghost-package-json-5.32.0.tgz +0 -0
  46. package/components/tryghost-referrers-5.32.0.tgz +0 -0
  47. package/components/{tryghost-security-5.30.1.tgz → tryghost-security-5.32.0.tgz} +0 -0
  48. package/components/{tryghost-session-service-5.30.1.tgz → tryghost-session-service-5.32.0.tgz} +0 -0
  49. package/components/{tryghost-settings-path-manager-5.30.1.tgz → tryghost-settings-path-manager-5.32.0.tgz} +0 -0
  50. package/components/{tryghost-staff-service-5.30.1.tgz → tryghost-staff-service-5.32.0.tgz} +0 -0
  51. package/components/{tryghost-stats-service-5.30.1.tgz → tryghost-stats-service-5.32.0.tgz} +0 -0
  52. package/components/{tryghost-tiers-5.30.1.tgz → tryghost-tiers-5.32.0.tgz} +0 -0
  53. package/components/tryghost-update-check-service-5.32.0.tgz +0 -0
  54. package/components/tryghost-verification-trigger-5.32.0.tgz +0 -0
  55. package/components/{tryghost-version-notifications-data-service-5.30.1.tgz → tryghost-version-notifications-data-service-5.32.0.tgz} +0 -0
  56. package/components/tryghost-webmentions-5.32.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.ab7136d5c88fd7e056e4.js → chunk.143.ad05239cc363254caed7.js} +5 -5
  61. package/core/built/admin/assets/{chunk.178.663428ba9c9d39ba275c.js → chunk.178.5260f900f09f859bf8ed.js} +4 -4
  62. package/core/built/admin/assets/{chunk.47.f29250a4560868c21293.js → chunk.47.f231a64fe3fbaba23b84.js} +4 -4
  63. package/core/built/admin/assets/{chunk.47.f29250a4560868c21293.js.LICENSE.txt → chunk.47.f231a64fe3fbaba23b84.js.LICENSE.txt} +0 -0
  64. package/core/built/admin/assets/{ghost-23dc524374e35a582886c36f7dacdb05.js → ghost-4571ad33bd158ced8a9f5c13598fdb7f.js} +142 -124
  65. package/core/built/admin/assets/{ghost-d3e45940f0f1601232d464d0b429d45f.css → ghost-721a7adc4ca642c88e4ac85e1cb8b385.css} +1 -1
  66. package/core/built/admin/assets/{ghost-dark-c8dc36895dfcbc03f0edd94311c65bfd.css → ghost-dark-fb05eb50e216469c5626356731afa42f.css} +1 -1
  67. package/core/built/admin/assets/{vendor-fadbf85ad92c591dc4bd3755312b6ddf.js → vendor-0441964c34d58f2aacd5a04bbe240243.js} +34 -37
  68. package/core/built/admin/index.html +6 -6
  69. package/core/frontend/helpers/ghost_head.js +1 -1
  70. package/core/server/api/endpoints/index.js +4 -0
  71. package/core/server/api/endpoints/mentions.js +33 -0
  72. package/core/server/api/endpoints/oembed.js +1 -22
  73. package/core/server/api/endpoints/utils/serializers/input/settings.js +2 -1
  74. package/core/server/api/endpoints/utils/serializers/output/mappers/emails.js +8 -0
  75. package/core/server/api/endpoints/utils/serializers/output/mappers/index.js +2 -1
  76. package/core/server/api/endpoints/utils/serializers/output/mappers/mentions.js +18 -0
  77. package/core/server/data/exporter/table-lists.js +1 -0
  78. package/core/server/data/importer/import-manager.js +21 -5
  79. package/core/server/data/migrations/versions/5.31/2022-12-05-09-56-update-newsletter-subscriptions.js +23 -0
  80. package/core/server/data/migrations/versions/5.31/2023-01-17-14-59-add-outbound-link-tagging-setting.js +8 -0
  81. package/core/server/data/migrations/versions/5.31/2023-01-19-07-46-add-mentions-table.js +17 -0
  82. package/core/server/data/migrations/versions/5.32/2023-01-24-08-00-fix-invalid-tier-expiry-for-paid-members.js +35 -0
  83. package/core/server/data/migrations/versions/5.32/2023-01-24-08-09-restore-incorrect-expired-tiers-for-members.js +46 -0
  84. package/core/server/data/schema/default-settings/default-settings.json +10 -0
  85. package/core/server/data/schema/schema.js +15 -0
  86. package/core/server/models/base/plugins/actions.js +1 -1
  87. package/core/server/models/base/plugins/sanitize.js +2 -0
  88. package/core/server/models/member.js +2 -2
  89. package/core/server/models/mention.js +9 -0
  90. package/core/server/models/redirect.js +2 -1
  91. package/core/server/services/api-version-compatibility/index.js +5 -8
  92. package/core/server/services/bulk-email/bulk-email-processor.js +7 -4
  93. package/core/server/services/email-service/wrapper.js +2 -1
  94. package/core/server/services/email-suppression-list/MailgunEmailSuppressionList.js +1 -1
  95. package/core/server/services/link-redirection/LinkRedirectRepository.js +2 -1
  96. package/core/server/services/link-tracking/PostLinkRepository.js +2 -1
  97. package/core/server/services/mega/post-email-serializer.js +2 -2
  98. package/core/server/services/member-attribution/index.js +2 -0
  99. package/core/server/services/mentions/BookshelfMentionRepository.js +117 -0
  100. package/core/server/services/mentions/MentionController.js +66 -0
  101. package/core/server/services/mentions/WebmentionMetadata.js +20 -0
  102. package/core/server/services/mentions/index.js +1 -0
  103. package/core/server/services/mentions/service.js +77 -0
  104. package/core/server/services/oembed/index.js +1 -0
  105. package/core/server/services/{nft-oembed.js → oembed/nft-oembed.js} +0 -0
  106. package/core/server/services/oembed/service.js +24 -0
  107. package/core/server/services/{twitter-embed.js → oembed/twitter-embed.js} +0 -0
  108. package/core/server/services/url/Resources.js +3 -17
  109. package/core/server/services/url/UrlService.js +3 -3
  110. package/core/server/web/api/endpoints/admin/routes.js +3 -0
  111. package/core/server/web/parent/frontend.js +1 -0
  112. package/core/server/web/webmentions/index.js +1 -0
  113. package/core/server/web/webmentions/routes.js +18 -0
  114. package/core/shared/labs.js +6 -3
  115. package/package.json +114 -110
  116. package/yarn.lock +616 -76
  117. package/components/tryghost-constants-5.30.1.tgz +0 -0
  118. package/components/tryghost-data-generator-5.30.1.tgz +0 -0
  119. package/components/tryghost-domain-events-5.30.1.tgz +0 -0
  120. package/components/tryghost-email-analytics-provider-mailgun-5.30.1.tgz +0 -0
  121. package/components/tryghost-email-service-5.30.1.tgz +0 -0
  122. package/components/tryghost-email-suppression-list-5.30.1.tgz +0 -0
  123. package/components/tryghost-link-redirects-5.30.1.tgz +0 -0
  124. package/components/tryghost-link-replacer-5.30.1.tgz +0 -0
  125. package/components/tryghost-magic-link-5.30.1.tgz +0 -0
  126. package/components/tryghost-member-attribution-5.30.1.tgz +0 -0
  127. package/components/tryghost-members-offers-5.30.1.tgz +0 -0
  128. package/components/tryghost-members-payments-5.30.1.tgz +0 -0
  129. package/components/tryghost-mw-api-version-mismatch-5.30.1.tgz +0 -0
  130. package/components/tryghost-mw-cache-control-5.30.1.tgz +0 -0
  131. package/components/tryghost-mw-session-from-token-5.30.1.tgz +0 -0
  132. package/components/tryghost-mw-update-user-last-seen-5.30.1.tgz +0 -0
  133. package/components/tryghost-mw-vhost-5.30.1.tgz +0 -0
  134. package/components/tryghost-oembed-service-5.30.1.tgz +0 -0
  135. package/components/tryghost-package-json-5.30.1.tgz +0 -0
  136. package/components/tryghost-referrers-5.30.1.tgz +0 -0
  137. package/components/tryghost-update-check-service-5.30.1.tgz +0 -0
  138. package/components/tryghost-verification-trigger-5.30.1.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
+ });
@@ -0,0 +1,35 @@
1
+ const logging = require('@tryghost/logging');
2
+ const {createTransactionalMigration} = require('../../utils');
3
+
4
+ module.exports = createTransactionalMigration(
5
+ async function up(knex) {
6
+ logging.info('Removing expiry dates for paid members');
7
+ try {
8
+ // Fetch all members with a paid status that have an expiry date
9
+ // Paid members should not have an expiry date
10
+ const invalidExpiryIds = await knex('members_products')
11
+ .select('members_products.id')
12
+ .leftJoin('members', 'members_products.member_id', 'members.id')
13
+ .where('members.status', '=', 'paid')
14
+ .whereNotNull('members_products.expiry_at').pluck('members_products.id');
15
+
16
+ logging.info(`Found ${invalidExpiryIds.length} paid members with expiry dates`);
17
+
18
+ if (invalidExpiryIds.length === 0) {
19
+ return;
20
+ }
21
+
22
+ logging.info(`Removing expiry dates for ${invalidExpiryIds.length} paid members`);
23
+
24
+ await knex('members_products')
25
+ .update('expiry_at', null)
26
+ .whereIn('id', invalidExpiryIds);
27
+ } catch (err) {
28
+ logging.warn('Failed to remove expiry dates for paid members');
29
+ logging.warn(err);
30
+ }
31
+ },
32
+ async function down() {
33
+ // no-op: we don't want to reintroduce the incorrect expiry dates for member tiers
34
+ }
35
+ );
@@ -0,0 +1,46 @@
1
+ const logging = require('@tryghost/logging');
2
+ const ObjectId = require('bson-objectid').default;
3
+ const {createTransactionalMigration} = require('../../utils');
4
+
5
+ module.exports = createTransactionalMigration(
6
+ async function up(knex) {
7
+ logging.info('Restoring member<>tier mapping for members with paid status');
8
+ try {
9
+ // fetch all members with a paid status that don't have a members_products record
10
+ // and have a members_product_events record with an action of "added"
11
+ // and fetch the product_id from the most recent record for that member
12
+ const memberWithTiers = await knex.select('m.id as member_id', 'mpe.product_id as product_id')
13
+ .from('members as m')
14
+ .leftJoin('members_products as mp', 'm.id', 'mp.member_id')
15
+ .leftJoin('members_product_events as mpe', function () {
16
+ this.on('m.id', 'mpe.member_id')
17
+ .andOn(knex.raw('mpe.created_at = (SELECT max(created_at) FROM members_product_events WHERE member_id = mpe.member_id and action = "added")'));
18
+ })
19
+ .where({'m.status': 'paid', 'mp.member_id': null, 'mpe.action': 'added'});
20
+
21
+ // create a new members_products record for each member with id, member_id and product_id
22
+ const toInsert = memberWithTiers.map((memberTier) => {
23
+ return {
24
+ ...memberTier,
25
+ id: ObjectId().toHexString()
26
+ };
27
+ }).filter((memberTier) => {
28
+ // filter out any members that don't have a product_id for some reason
29
+ if (!memberTier.product_id) {
30
+ logging.warn(`Invalid record found - member_id: ${memberTier.member_id} is without product_id`);
31
+ return false;
32
+ }
33
+ return true;
34
+ });
35
+
36
+ logging.info(`Inserting ${toInsert.length} records into members_products`);
37
+ await knex.batchInsert('members_products', toInsert);
38
+ } catch (err) {
39
+ logging.warn('Failed to restore member<>tier mapping for members with paid status');
40
+ logging.warn(err);
41
+ }
42
+ },
43
+ async function down() {
44
+ // np-op: we don't want to delete the missing records we've just inserted
45
+ }
46
+ );
@@ -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':
@@ -219,8 +219,8 @@ const Member = ghostBookshelf.Model.extend({
219
219
 
220
220
  async updateTierExpiry(products = [], options = {}) {
221
221
  for (const product of products) {
222
- if (product?.expiry_at) {
223
- const expiry = new Date(product.expiry_at);
222
+ if (product?.id) {
223
+ const expiry = product.expiry_at ? new Date(product.expiry_at) : null;
224
224
  const queryOptions = _.extend({}, options, {
225
225
  query: {where: {product_id: product.id}}
226
226
  });
@@ -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
 
@@ -93,7 +93,7 @@ module.exports = {
93
93
  } catch (error) {
94
94
  return new FailedBatch(emailBatchId, error);
95
95
  }
96
- }, {concurrency: 10});
96
+ }, {concurrency: 2});
97
97
 
98
98
  const successes = batchResults.filter(response => (response instanceof SuccessfulBatch));
99
99
  const failures = batchResults.filter(response => (response instanceof FailedBatch));
@@ -196,8 +196,11 @@ module.exports = {
196
196
 
197
197
  // log any error that didn't come from the provider which would have already logged it
198
198
  if (!error.code || error.code !== 'BULK_EMAIL_SEND_FAILED') {
199
- let ghostError = new errors.InternalServerError({
200
- err: error
199
+ let ghostError = new errors.EmailError({
200
+ err: error,
201
+ code: 'BULK_EMAIL_SEND_FAILED',
202
+ message: `Error sending email batch ${emailBatchId}`,
203
+ context: error.message
201
204
  });
202
205
  sentry.captureException(ghostError);
203
206
  logging.error(ghostError);
@@ -274,7 +277,7 @@ module.exports = {
274
277
  });
275
278
 
276
279
  sentry.captureException(ghostError);
277
- logging.warn(ghostError);
280
+ logging.error(ghostError);
278
281
 
279
282
  debug(`failed to send message (${Date.now() - startTime}ms)`);
280
283
  throw ghostError;
@@ -88,7 +88,8 @@ class EmailServiceWrapper {
88
88
  jobsService,
89
89
  emailSegmenter,
90
90
  emailRenderer,
91
- db
91
+ db,
92
+ sentry
92
93
  });
93
94
 
94
95
  this.service = new EmailService({
@@ -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;