ghost 5.111.0 → 5.112.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 (142) hide show
  1. package/components/tryghost-adapter-cache-memory-ttl-5.112.0.tgz +0 -0
  2. package/components/{tryghost-adapter-cache-redis-5.111.0.tgz → tryghost-adapter-cache-redis-5.112.0.tgz} +0 -0
  3. package/components/{tryghost-adapter-manager-5.111.0.tgz → tryghost-adapter-manager-5.112.0.tgz} +0 -0
  4. package/components/tryghost-announcement-bar-settings-5.112.0.tgz +0 -0
  5. package/components/{tryghost-api-framework-5.111.0.tgz → tryghost-api-framework-5.112.0.tgz} +0 -0
  6. package/components/{tryghost-api-version-compatibility-service-5.111.0.tgz → tryghost-api-version-compatibility-service-5.112.0.tgz} +0 -0
  7. package/components/{tryghost-audience-feedback-5.111.0.tgz → tryghost-audience-feedback-5.112.0.tgz} +0 -0
  8. package/components/tryghost-bookshelf-repository-5.112.0.tgz +0 -0
  9. package/components/tryghost-bootstrap-socket-5.112.0.tgz +0 -0
  10. package/components/{tryghost-captcha-service-5.111.0.tgz → tryghost-captcha-service-5.112.0.tgz} +0 -0
  11. package/components/tryghost-constants-5.112.0.tgz +0 -0
  12. package/components/tryghost-custom-fonts-5.112.0.tgz +0 -0
  13. package/components/{tryghost-custom-theme-settings-service-5.111.0.tgz → tryghost-custom-theme-settings-service-5.112.0.tgz} +0 -0
  14. package/components/{tryghost-data-generator-5.111.0.tgz → tryghost-data-generator-5.112.0.tgz} +0 -0
  15. package/components/tryghost-domain-events-5.112.0.tgz +0 -0
  16. package/components/{tryghost-donations-5.111.0.tgz → tryghost-donations-5.112.0.tgz} +0 -0
  17. package/components/{tryghost-email-addresses-5.111.0.tgz → tryghost-email-addresses-5.112.0.tgz} +0 -0
  18. package/components/{tryghost-email-analytics-provider-mailgun-5.111.0.tgz → tryghost-email-analytics-provider-mailgun-5.112.0.tgz} +0 -0
  19. package/components/{tryghost-email-analytics-service-5.111.0.tgz → tryghost-email-analytics-service-5.112.0.tgz} +0 -0
  20. package/components/{tryghost-email-content-generator-5.111.0.tgz → tryghost-email-content-generator-5.112.0.tgz} +0 -0
  21. package/components/{tryghost-email-events-5.111.0.tgz → tryghost-email-events-5.112.0.tgz} +0 -0
  22. package/components/tryghost-email-service-5.112.0.tgz +0 -0
  23. package/components/{tryghost-email-suppression-list-5.111.0.tgz → tryghost-email-suppression-list-5.112.0.tgz} +0 -0
  24. package/components/{tryghost-express-dynamic-redirects-5.111.0.tgz → tryghost-express-dynamic-redirects-5.112.0.tgz} +0 -0
  25. package/components/{tryghost-external-media-inliner-5.111.0.tgz → tryghost-external-media-inliner-5.112.0.tgz} +0 -0
  26. package/components/tryghost-extract-api-key-5.112.0.tgz +0 -0
  27. package/components/tryghost-ghost-5.112.0.tgz +0 -0
  28. package/components/{tryghost-html-to-plaintext-5.111.0.tgz → tryghost-html-to-plaintext-5.112.0.tgz} +0 -0
  29. package/components/tryghost-i18n-5.112.0.tgz +0 -0
  30. package/components/{tryghost-identity-token-service-5.111.0.tgz → tryghost-identity-token-service-5.112.0.tgz} +0 -0
  31. package/components/{tryghost-importer-handler-content-files-5.111.0.tgz → tryghost-importer-handler-content-files-5.112.0.tgz} +0 -0
  32. package/components/{tryghost-importer-revue-5.111.0.tgz → tryghost-importer-revue-5.112.0.tgz} +0 -0
  33. package/components/tryghost-in-memory-repository-5.112.0.tgz +0 -0
  34. package/components/{tryghost-job-manager-5.111.0.tgz → tryghost-job-manager-5.112.0.tgz} +0 -0
  35. package/components/tryghost-link-redirects-5.112.0.tgz +0 -0
  36. package/components/tryghost-link-replacer-5.112.0.tgz +0 -0
  37. package/components/{tryghost-magic-link-5.111.0.tgz → tryghost-magic-link-5.112.0.tgz} +0 -0
  38. package/components/{tryghost-mail-events-5.111.0.tgz → tryghost-mail-events-5.112.0.tgz} +0 -0
  39. package/components/{tryghost-mailgun-client-5.111.0.tgz → tryghost-mailgun-client-5.112.0.tgz} +0 -0
  40. package/components/{tryghost-member-attribution-5.111.0.tgz → tryghost-member-attribution-5.112.0.tgz} +0 -0
  41. package/components/tryghost-member-events-5.112.0.tgz +0 -0
  42. package/components/{tryghost-members-api-5.111.0.tgz → tryghost-members-api-5.112.0.tgz} +0 -0
  43. package/components/tryghost-members-csv-5.112.0.tgz +0 -0
  44. package/components/{tryghost-members-importer-5.111.0.tgz → tryghost-members-importer-5.112.0.tgz} +0 -0
  45. package/components/{tryghost-members-offers-5.111.0.tgz → tryghost-members-offers-5.112.0.tgz} +0 -0
  46. package/components/tryghost-members-payments-5.112.0.tgz +0 -0
  47. package/components/{tryghost-members-ssr-5.111.0.tgz → tryghost-members-ssr-5.112.0.tgz} +0 -0
  48. package/components/{tryghost-members-stripe-service-5.111.0.tgz → tryghost-members-stripe-service-5.112.0.tgz} +0 -0
  49. package/components/{tryghost-milestones-5.111.0.tgz → tryghost-milestones-5.112.0.tgz} +0 -0
  50. package/components/{tryghost-minifier-5.111.0.tgz → tryghost-minifier-5.112.0.tgz} +0 -0
  51. package/components/{tryghost-mw-api-version-mismatch-5.111.0.tgz → tryghost-mw-api-version-mismatch-5.112.0.tgz} +0 -0
  52. package/components/tryghost-mw-cache-control-5.112.0.tgz +0 -0
  53. package/components/{tryghost-mw-error-handler-5.111.0.tgz → tryghost-mw-error-handler-5.112.0.tgz} +0 -0
  54. package/components/{tryghost-mw-session-from-token-5.111.0.tgz → tryghost-mw-session-from-token-5.112.0.tgz} +0 -0
  55. package/components/{tryghost-mw-update-user-last-seen-5.111.0.tgz → tryghost-mw-update-user-last-seen-5.112.0.tgz} +0 -0
  56. package/components/tryghost-mw-version-match-5.112.0.tgz +0 -0
  57. package/components/tryghost-mw-vhost-5.112.0.tgz +0 -0
  58. package/components/{tryghost-package-json-5.111.0.tgz → tryghost-package-json-5.112.0.tgz} +0 -0
  59. package/components/tryghost-post-events-5.112.0.tgz +0 -0
  60. package/components/tryghost-post-revisions-5.112.0.tgz +0 -0
  61. package/components/{tryghost-posts-service-5.111.0.tgz → tryghost-posts-service-5.112.0.tgz} +0 -0
  62. package/components/{tryghost-prometheus-metrics-5.111.0.tgz → tryghost-prometheus-metrics-5.112.0.tgz} +0 -0
  63. package/components/tryghost-recommendations-5.112.0.tgz +0 -0
  64. package/components/{tryghost-referrers-5.111.0.tgz → tryghost-referrers-5.112.0.tgz} +0 -0
  65. package/components/{tryghost-security-5.111.0.tgz → tryghost-security-5.112.0.tgz} +0 -0
  66. package/components/tryghost-session-service-5.112.0.tgz +0 -0
  67. package/components/{tryghost-settings-path-manager-5.111.0.tgz → tryghost-settings-path-manager-5.112.0.tgz} +0 -0
  68. package/components/{tryghost-slack-notifications-5.111.0.tgz → tryghost-slack-notifications-5.112.0.tgz} +0 -0
  69. package/components/tryghost-tiers-5.112.0.tgz +0 -0
  70. package/components/tryghost-version-notifications-data-service-5.112.0.tgz +0 -0
  71. package/components/{tryghost-webmentions-5.111.0.tgz → tryghost-webmentions-5.112.0.tgz} +0 -0
  72. package/core/boot.js +0 -3
  73. package/core/built/admin/assets/admin-x-activitypub/admin-x-activitypub.js +10216 -9646
  74. package/core/built/admin/assets/admin-x-settings/{CodeEditorView-1298238e.mjs → CodeEditorView-ad8698fe.mjs} +2 -2
  75. package/core/built/admin/assets/admin-x-settings/admin-x-settings.js +1 -1
  76. package/core/built/admin/assets/admin-x-settings/{index-2707471f.mjs → index-2713e469.mjs} +2750 -2714
  77. package/core/built/admin/assets/admin-x-settings/{index-0f51ccb5.mjs → index-463cec50.mjs} +2 -2
  78. package/core/built/admin/assets/admin-x-settings/{modals-f5983704.mjs → modals-033e8fc4.mjs} +4 -4
  79. package/core/built/admin/assets/{chunk.524.405c43b2cb20553b51d9.js → chunk.524.db49da6fd8ae155205a4.js} +5 -5
  80. package/core/built/admin/assets/{chunk.582.eb4b096f29c97c9d6a64.js → chunk.582.0bf715eb6807f7641706.js} +8 -8
  81. package/core/built/admin/assets/{ghost-87cffc153ec73d217c1ae9f9207ea5e1.js → ghost-62bd4d4c837d453e1038808dc1cd1e4c.js} +9 -7
  82. package/core/built/admin/assets/img/ap-nodes-01ee317529e6353a1c34a062c388f1e7.png +0 -0
  83. package/core/built/admin/assets/koenig-lexical/koenig-lexical.js +12944 -12924
  84. package/core/built/admin/assets/koenig-lexical/koenig-lexical.umd.js +136 -134
  85. package/core/built/admin/assets/posts/posts.js +2 -2
  86. package/core/built/admin/index.html +3 -3
  87. package/core/frontend/helpers/get.js +2 -3
  88. package/core/frontend/src/cards/css/cta.css +38 -36
  89. package/core/server/api/endpoints/utils/serializers/input/settings.js +2 -1
  90. package/core/server/api/endpoints/utils/serializers/input/utils/settings-key-group-mapper.js +2 -1
  91. package/core/server/api/endpoints/utils/serializers/input/utils/settings-key-type-mapper.js +2 -1
  92. package/core/server/data/migrations/versions/5.112/2025-03-10-10-01-01-add-require-mfa-setting.js +8 -0
  93. package/core/server/data/schema/default-settings/default-settings.json +6 -0
  94. package/core/server/models/invite.js +4 -5
  95. package/core/server/models/post.js +3 -9
  96. package/core/server/models/relations/authors.js +2 -4
  97. package/core/server/models/role-utils.js +38 -0
  98. package/core/server/models/role.js +5 -3
  99. package/core/server/models/user.js +5 -3
  100. package/core/server/services/activitypub/ActivityPubService.js +116 -0
  101. package/core/server/services/activitypub/ActivityPubService.ts +139 -0
  102. package/core/server/services/activitypub/ActivityPubServiceWrapper.js +1 -1
  103. package/core/server/services/stats/MembersStatsService.js +167 -0
  104. package/core/server/services/stats/MrrStatsService.js +161 -0
  105. package/core/server/services/stats/ReferrersStatsService.js +164 -0
  106. package/core/server/services/stats/StatsService.js +63 -0
  107. package/core/server/services/stats/SubscriptionStatsService.js +180 -0
  108. package/core/server/services/stats/service.js +1 -1
  109. package/core/server/services/url/Resources.js +1 -1
  110. package/package.json +138 -141
  111. package/tsconfig.tsbuildinfo +1 -1
  112. package/yarn.lock +30 -72
  113. package/components/tryghost-activitypub-5.111.0.tgz +0 -0
  114. package/components/tryghost-adapter-cache-memory-ttl-5.111.0.tgz +0 -0
  115. package/components/tryghost-announcement-bar-settings-5.111.0.tgz +0 -0
  116. package/components/tryghost-bookshelf-repository-5.111.0.tgz +0 -0
  117. package/components/tryghost-bootstrap-socket-5.111.0.tgz +0 -0
  118. package/components/tryghost-constants-5.111.0.tgz +0 -0
  119. package/components/tryghost-custom-fonts-5.111.0.tgz +0 -0
  120. package/components/tryghost-domain-events-5.111.0.tgz +0 -0
  121. package/components/tryghost-email-service-5.111.0.tgz +0 -0
  122. package/components/tryghost-extract-api-key-5.111.0.tgz +0 -0
  123. package/components/tryghost-ghost-5.111.0.tgz +0 -0
  124. package/components/tryghost-i18n-5.111.0.tgz +0 -0
  125. package/components/tryghost-in-memory-repository-5.111.0.tgz +0 -0
  126. package/components/tryghost-link-redirects-5.111.0.tgz +0 -0
  127. package/components/tryghost-link-replacer-5.111.0.tgz +0 -0
  128. package/components/tryghost-member-events-5.111.0.tgz +0 -0
  129. package/components/tryghost-members-csv-5.111.0.tgz +0 -0
  130. package/components/tryghost-members-payments-5.111.0.tgz +0 -0
  131. package/components/tryghost-mw-cache-control-5.111.0.tgz +0 -0
  132. package/components/tryghost-mw-version-match-5.111.0.tgz +0 -0
  133. package/components/tryghost-mw-vhost-5.111.0.tgz +0 -0
  134. package/components/tryghost-post-events-5.111.0.tgz +0 -0
  135. package/components/tryghost-post-revisions-5.111.0.tgz +0 -0
  136. package/components/tryghost-recommendations-5.111.0.tgz +0 -0
  137. package/components/tryghost-session-service-5.111.0.tgz +0 -0
  138. package/components/tryghost-stats-service-5.111.0.tgz +0 -0
  139. package/components/tryghost-tiers-5.111.0.tgz +0 -0
  140. package/components/tryghost-version-notifications-data-service-5.111.0.tgz +0 -0
  141. package/core/demo.js +0 -6
  142. package/core/demo.ts +0 -3
@@ -0,0 +1,38 @@
1
+ // check if the user has an assigned role
2
+ // so that we can stop writing this everywhere:
3
+ //_.some(loadedPermissions.user.roles, {name: 'Administrator'})
4
+
5
+ function checkUserPermissionsForRole(loadedPermissions, roleName) {
6
+ if (!loadedPermissions?.user?.roles) {
7
+ return false;
8
+ }
9
+
10
+ return loadedPermissions.user.roles.some(role => role.name === roleName);
11
+ }
12
+
13
+ function setIsRoles(loadedPermissions) {
14
+ // utility function to parse the permissions object and set up all the "is" variables.
15
+ let resultsObject = {
16
+ isOwner: false,
17
+ isAdmin: false,
18
+ isEditor: false,
19
+ isAuthor: false,
20
+ isContributor: false,
21
+ isSuperEditor: false,
22
+ isEitherEditor: false
23
+ };
24
+ if (!loadedPermissions?.user?.roles) {
25
+ return resultsObject;
26
+ }
27
+ resultsObject.isOwner = checkUserPermissionsForRole(loadedPermissions, 'Owner');
28
+ resultsObject.isAdmin = checkUserPermissionsForRole(loadedPermissions, 'Administrator');
29
+ resultsObject.isEditor = checkUserPermissionsForRole(loadedPermissions, 'Editor');
30
+ resultsObject.isAuthor = checkUserPermissionsForRole(loadedPermissions, 'Author');
31
+ resultsObject.isContributor = checkUserPermissionsForRole(loadedPermissions, 'Contributor');
32
+ resultsObject.isSuperEditor = checkUserPermissionsForRole(loadedPermissions, 'Super Editor');
33
+ resultsObject.isEitherEditor = resultsObject.isEditor || resultsObject.isSuperEditor;
34
+ return resultsObject;
35
+ }
36
+
37
+ exports.setIsRoles = setIsRoles;
38
+ exports.checkUserPermissionsForRole = checkUserPermissionsForRole;
@@ -2,6 +2,7 @@ const _ = require('lodash');
2
2
  const ghostBookshelf = require('./base');
3
3
  const tpl = require('@tryghost/tpl');
4
4
  const errors = require('@tryghost/errors');
5
+ const {setIsRoles} = require('./role-utils');
5
6
 
6
7
  const messages = {
7
8
  roleNotFound: 'Role not found',
@@ -78,12 +79,13 @@ Role = ghostBookshelf.Model.extend({
78
79
  const roleModel = roleModelOrId;
79
80
 
80
81
  if (action === 'assign' && loadedPermissions.user) {
82
+ const {isOwner, isAdmin, isEitherEditor} = setIsRoles(loadedPermissions);
81
83
  let checkAgainst;
82
- if (_.some(loadedPermissions.user.roles, {name: 'Owner'})) {
84
+ if (isOwner) {
83
85
  checkAgainst = ['Owner', 'Administrator', 'Editor', 'Author', 'Contributor'];
84
- } else if (_.some(loadedPermissions.user.roles, {name: 'Administrator'})) {
86
+ } else if (isAdmin) {
85
87
  checkAgainst = ['Administrator', 'Editor', 'Author', 'Contributor'];
86
- } else if (_.some(loadedPermissions.user.roles, {name: 'Editor'})) {
88
+ } else if (isEitherEditor) {
87
89
  checkAgainst = ['Author', 'Contributor'];
88
90
  }
89
91
 
@@ -13,6 +13,7 @@ const permissions = require('../services/permissions');
13
13
  const urlUtils = require('../../shared/url-utils');
14
14
  const activeStates = ['active', 'warn-1', 'warn-2', 'warn-3', 'warn-4'];
15
15
  const ASSIGNABLE_ROLES = ['Administrator', 'Editor', 'Author', 'Contributor'];
16
+ const {setIsRoles} = require('./role-utils');
16
17
 
17
18
  const messages = {
18
19
  valueCannotBeBlank: 'Value in [{tableName}.{columnKey}] cannot be blank.',
@@ -784,6 +785,7 @@ User = ghostBookshelf.Model.extend({
784
785
  const self = this;
785
786
  const userModel = userModelOrId;
786
787
  let origArgs;
788
+ const {isOwner, isEitherEditor} = setIsRoles(loadedPermissions);
787
789
 
788
790
  // If we passed in a model without its related roles, we need to fetch it again
789
791
  if (_.isObject(userModelOrId) && !_.isObject(userModelOrId.related('roles'))) {
@@ -826,10 +828,10 @@ User = ghostBookshelf.Model.extend({
826
828
  if (context.user === userModel.get('id')) {
827
829
  // If this is the same user that requests the operation allow it.
828
830
  hasUserPermission = true;
829
- } else if (loadedPermissions.user && userModel.hasRole('Owner')) {
831
+ } else if (isOwner) {
830
832
  // Owner can only be edited by owner
831
833
  hasUserPermission = loadedPermissions.user && _.some(loadedPermissions.user.roles, {name: 'Owner'});
832
- } else if (loadedPermissions.user && _.some(loadedPermissions.user.roles, {name: 'Editor'})) {
834
+ } else if (isEitherEditor) {
833
835
  // If the user we are trying to edit is an Author or Contributor, allow it
834
836
  hasUserPermission = userModel.hasRole('Author') || userModel.hasRole('Contributor');
835
837
  }
@@ -844,7 +846,7 @@ User = ghostBookshelf.Model.extend({
844
846
  }
845
847
 
846
848
  // Users with the role 'Editor' have complex permissions when the action === 'destroy'
847
- if (loadedPermissions.user && _.some(loadedPermissions.user.roles, {name: 'Editor'})) {
849
+ if (isEitherEditor) {
848
850
  // Alternatively, if the user we are trying to edit is an Author, allow it
849
851
  hasUserPermission = context.user === userModel.get('id') || userModel.hasRole('Author') || userModel.hasRole('Contributor');
850
852
  }
@@ -0,0 +1,116 @@
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.ActivityPubService = void 0;
7
+ const bson_objectid_1 = __importDefault(require("bson-objectid"));
8
+ const node_fetch_1 = __importDefault(require("node-fetch"));
9
+ class ActivityPubService {
10
+ knex;
11
+ siteUrl;
12
+ logging;
13
+ identityTokenService;
14
+ constructor(knex, siteUrl, logging, identityTokenService) {
15
+ this.knex = knex;
16
+ this.siteUrl = siteUrl;
17
+ this.logging = logging;
18
+ this.identityTokenService = identityTokenService;
19
+ }
20
+ getExpectedWebhooks(secret) {
21
+ return [{
22
+ event: 'post.published',
23
+ target_url: new URL('.ghost/activitypub/webhooks/post/published', this.siteUrl),
24
+ api_version: 'v5.100.0',
25
+ secret
26
+ }, {
27
+ event: 'site.changed',
28
+ target_url: new URL('.ghost/activitypub/webhooks/site/changed', this.siteUrl),
29
+ api_version: 'v5.100.0',
30
+ secret
31
+ }];
32
+ }
33
+ async checkWebhookState(expectedWebhooks, integration) {
34
+ this.logging.info(`Checking ActivityPub Webhook state`);
35
+ const webhooks = await this.knex
36
+ .select('*')
37
+ .from('webhooks')
38
+ .where('integration_id', '=', integration.id);
39
+ if (webhooks.length !== expectedWebhooks.length) {
40
+ this.logging.warn(`Expected ${expectedWebhooks.length} webhooks for ActivityPub`);
41
+ return false;
42
+ }
43
+ for (const expectedWebhook of expectedWebhooks) {
44
+ const foundWebhook = webhooks.find((webhook) => {
45
+ return webhook.event === expectedWebhook.event && webhook.target_url === expectedWebhook.target_url.href && webhook.secret === expectedWebhook.secret;
46
+ });
47
+ if (!foundWebhook) {
48
+ this.logging.error(`Could not find webhook for ${expectedWebhook.event} ${expectedWebhook.target_url}`);
49
+ return false;
50
+ }
51
+ }
52
+ return true;
53
+ }
54
+ async getWebhookSecret() {
55
+ try {
56
+ const ownerUser = await this.knex.select('*').from('users').where('id', '=', '1').first();
57
+ const token = await this.identityTokenService.getTokenForUser(ownerUser.email, 'Owner');
58
+ const res = await (0, node_fetch_1.default)(new URL('.ghost/activitypub/site', this.siteUrl), {
59
+ headers: {
60
+ Authorization: `Bearer ${token}`
61
+ }
62
+ });
63
+ const body = await res.json();
64
+ return body.webhook_secret;
65
+ }
66
+ catch (err) {
67
+ this.logging.error(`Could not get webhook secret for ActivityPub ${err}`);
68
+ return null;
69
+ }
70
+ }
71
+ async initialiseWebhooks() {
72
+ const integration = await this.knex
73
+ .select('*')
74
+ .from('integrations')
75
+ .where('slug', '=', 'ghost-activitypub')
76
+ .andWhere('type', '=', 'internal')
77
+ .first();
78
+ if (!integration) {
79
+ this.logging.error('No ActivityPub integration found - cannot initialise');
80
+ return;
81
+ }
82
+ const secret = await this.getWebhookSecret();
83
+ if (!secret) {
84
+ this.logging.error('No webhook secret found - cannot initialise');
85
+ return;
86
+ }
87
+ const expectedWebhooks = this.getExpectedWebhooks(secret);
88
+ const isInCorrectState = await this.checkWebhookState(expectedWebhooks, integration);
89
+ if (isInCorrectState) {
90
+ this.logging.info(`ActivityPub webhooks in correct state`);
91
+ return;
92
+ }
93
+ this.logging.info(`ActivityPub webhooks in incorrect state, deleting all of them and starting fresh`);
94
+ await this.knex
95
+ .del()
96
+ .from('webhooks')
97
+ .where('integration_id', '=', integration.id);
98
+ const webhooksToInsert = expectedWebhooks.map((expectedWebhook) => {
99
+ return {
100
+ id: (new bson_objectid_1.default).toHexString(),
101
+ event: expectedWebhook.event,
102
+ target_url: expectedWebhook.target_url.href,
103
+ api_version: expectedWebhook.api_version,
104
+ name: `ActivityPub ${expectedWebhook.event} Webhook`,
105
+ secret: secret,
106
+ integration_id: integration.id,
107
+ created_at: this.knex.raw('current_timestamp'),
108
+ created_by: '1'
109
+ };
110
+ });
111
+ await this.knex
112
+ .insert(webhooksToInsert)
113
+ .into('webhooks');
114
+ }
115
+ }
116
+ exports.ActivityPubService = ActivityPubService;
@@ -0,0 +1,139 @@
1
+ import ObjectID from 'bson-objectid';
2
+ import {Knex} from 'knex';
3
+ import {IdentityTokenService} from '@tryghost/identity-token-service';
4
+ import fetch from 'node-fetch';
5
+
6
+ type ExpectedWebhook = {
7
+ event: string;
8
+ target_url: URL;
9
+ api_version: string;
10
+ secret: string;
11
+ };
12
+
13
+ interface Logger {
14
+ info(message: string): void
15
+ warn(message: string): void
16
+ error(message: string): void
17
+ }
18
+
19
+ export class ActivityPubService {
20
+ constructor(
21
+ private knex: Knex,
22
+ private siteUrl: URL,
23
+ private logging: Logger,
24
+ private identityTokenService: IdentityTokenService
25
+ ) {}
26
+
27
+ getExpectedWebhooks(secret: string): ExpectedWebhook[] {
28
+ return [{
29
+ event: 'post.published',
30
+ target_url: new URL('.ghost/activitypub/webhooks/post/published', this.siteUrl),
31
+ api_version: 'v5.100.0',
32
+ secret
33
+ }, {
34
+ event: 'site.changed',
35
+ target_url: new URL('.ghost/activitypub/webhooks/site/changed', this.siteUrl),
36
+ api_version: 'v5.100.0',
37
+ secret
38
+ }];
39
+ }
40
+
41
+ async checkWebhookState(expectedWebhooks: ExpectedWebhook[], integration: {id: string}) {
42
+ this.logging.info(`Checking ActivityPub Webhook state`);
43
+
44
+ const webhooks = await this.knex
45
+ .select('*')
46
+ .from('webhooks')
47
+ .where('integration_id', '=', integration.id);
48
+
49
+ if (webhooks.length !== expectedWebhooks.length) {
50
+ this.logging.warn(`Expected ${expectedWebhooks.length} webhooks for ActivityPub`);
51
+ return false;
52
+ }
53
+
54
+ for (const expectedWebhook of expectedWebhooks) {
55
+ const foundWebhook = webhooks.find((webhook) => {
56
+ return webhook.event === expectedWebhook.event && webhook.target_url === expectedWebhook.target_url.href && webhook.secret === expectedWebhook.secret;
57
+ });
58
+ if (!foundWebhook) {
59
+ this.logging.error(`Could not find webhook for ${expectedWebhook.event} ${expectedWebhook.target_url}`);
60
+ return false;
61
+ }
62
+ }
63
+
64
+ return true;
65
+ }
66
+
67
+ async getWebhookSecret(): Promise<string | null> {
68
+ try {
69
+ const ownerUser = await this.knex.select('*').from('users').where('id', '=', '1').first();
70
+ const token = await this.identityTokenService.getTokenForUser(ownerUser.email, 'Owner');
71
+
72
+ const res = await fetch(new URL('.ghost/activitypub/site', this.siteUrl), {
73
+ headers: {
74
+ Authorization: `Bearer ${token}`
75
+ }
76
+ });
77
+
78
+ const body = await res.json();
79
+
80
+ return body.webhook_secret;
81
+ } catch (err: unknown) {
82
+ this.logging.error(`Could not get webhook secret for ActivityPub ${err}`);
83
+ return null;
84
+ }
85
+ }
86
+
87
+ async initialiseWebhooks() {
88
+ const integration = await this.knex
89
+ .select('*')
90
+ .from('integrations')
91
+ .where('slug', '=', 'ghost-activitypub')
92
+ .andWhere('type', '=', 'internal')
93
+ .first();
94
+
95
+ if (!integration) {
96
+ this.logging.error('No ActivityPub integration found - cannot initialise');
97
+ return;
98
+ }
99
+
100
+ const secret = await this.getWebhookSecret();
101
+
102
+ if (!secret) {
103
+ this.logging.error('No webhook secret found - cannot initialise');
104
+ return;
105
+ }
106
+
107
+ const expectedWebhooks = this.getExpectedWebhooks(secret);
108
+ const isInCorrectState = await this.checkWebhookState(expectedWebhooks, integration);
109
+
110
+ if (isInCorrectState) {
111
+ this.logging.info(`ActivityPub webhooks in correct state`);
112
+ return;
113
+ }
114
+
115
+ this.logging.info(`ActivityPub webhooks in incorrect state, deleting all of them and starting fresh`);
116
+ await this.knex
117
+ .del()
118
+ .from('webhooks')
119
+ .where('integration_id', '=', integration.id);
120
+
121
+ const webhooksToInsert = expectedWebhooks.map((expectedWebhook) => {
122
+ return {
123
+ id: (new ObjectID).toHexString(),
124
+ event: expectedWebhook.event,
125
+ target_url: expectedWebhook.target_url.href,
126
+ api_version: expectedWebhook.api_version,
127
+ name: `ActivityPub ${expectedWebhook.event} Webhook`,
128
+ secret: secret,
129
+ integration_id: integration.id,
130
+ created_at: this.knex.raw('current_timestamp'),
131
+ created_by: '1'
132
+ };
133
+ });
134
+
135
+ await this.knex
136
+ .insert(webhooksToInsert)
137
+ .into('webhooks');
138
+ }
139
+ }
@@ -1,4 +1,4 @@
1
- const {ActivityPubService} = require('@tryghost/activitypub');
1
+ const {ActivityPubService} = require('./ActivityPubService');
2
2
 
3
3
  module.exports = class ActivityPubServiceWrapper {
4
4
  /** @type ActivityPubService */
@@ -0,0 +1,167 @@
1
+ const moment = require('moment');
2
+
3
+ class MembersStatsService {
4
+ /**
5
+ * @param {object} deps
6
+ * @param {import('knex').Knex} deps.knex*/
7
+ constructor({knex}) {
8
+ this.knex = knex;
9
+ }
10
+
11
+ /**
12
+ * Get the current total members grouped by status
13
+ * @returns {Promise<TotalMembersByStatus>}
14
+ */
15
+ async getCount() {
16
+ const knex = this.knex;
17
+ const rows = await knex('members')
18
+ .select('status')
19
+ .select(knex.raw('COUNT(id) AS total'))
20
+ .groupBy('status');
21
+
22
+ const paidEvent = rows.find(c => c.status === 'paid');
23
+ const freeEvent = rows.find(c => c.status === 'free');
24
+ const compedEvent = rows.find(c => c.status === 'comped');
25
+
26
+ return {
27
+ paid: paidEvent ? paidEvent.total : 0,
28
+ free: freeEvent ? freeEvent.total : 0,
29
+ comped: compedEvent ? compedEvent.total : 0
30
+ };
31
+ }
32
+
33
+ /**
34
+ * Get the member deltas by status for all days, sorted ascending
35
+ * @returns {Promise<MemberStatusDelta[]>} The deltas of paid, free and comped users per day, sorted ascending
36
+ */
37
+ async fetchAllStatusDeltas() {
38
+ const knex = this.knex;
39
+ const ninetyDaysAgo = moment.utc().subtract(91, 'days').startOf('day').utc().format('YYYY-MM-DD HH:mm:ss');
40
+ const rows = await knex('members_status_events')
41
+ .select(knex.raw('DATE(created_at) as date'))
42
+ .select(knex.raw(`SUM(
43
+ CASE WHEN to_status='paid' THEN 1
44
+ ELSE 0 END
45
+ ) as paid_subscribed`))
46
+ .select(knex.raw(`SUM(
47
+ CASE WHEN from_status='paid' THEN 1
48
+ ELSE 0 END
49
+ ) as paid_canceled`))
50
+ .select(knex.raw(`SUM(
51
+ CASE WHEN to_status='comped' THEN 1
52
+ WHEN from_status='comped' THEN -1
53
+ ELSE 0 END
54
+ ) as comped_delta`))
55
+ .select(knex.raw(`SUM(
56
+ CASE WHEN to_status='free' THEN 1
57
+ WHEN from_status='free' THEN -1
58
+ ELSE 0 END
59
+ ) as free_delta`))
60
+ .where('created_at', '>=', ninetyDaysAgo)
61
+ .groupByRaw('DATE(created_at)');
62
+ return rows;
63
+ }
64
+
65
+ /**
66
+ * Returns a list of the total members by status for each day, including the paid deltas paid_subscribed and paid_canceled
67
+ * @returns {Promise<CountHistory>}
68
+ */
69
+ async getCountHistory() {
70
+ const rows = await this.fetchAllStatusDeltas();
71
+
72
+ // Fetch current total amounts and start counting from there
73
+ const totals = await this.getCount();
74
+ let {paid, free, comped} = totals;
75
+
76
+ // Get today in UTC (default timezone)
77
+ const today = moment().format('YYYY-MM-DD');
78
+
79
+ const cumulativeResults = [];
80
+
81
+ rows.sort((a, b) => new Date(a.date) - new Date(b.date));
82
+ // Loop in reverse order (needed to have correct sorted result)
83
+ for (let i = rows.length - 1; i >= 0; i -= 1) {
84
+ const row = rows[i];
85
+
86
+ // Convert JSDates to YYYY-MM-DD (in UTC)
87
+ const date = moment(row.date).format('YYYY-MM-DD');
88
+ if (date > today) {
89
+ // Skip results that are in the future (fix for invalid events)
90
+ continue;
91
+ }
92
+ cumulativeResults.unshift({
93
+ date,
94
+ paid: Math.max(0, paid),
95
+ free: Math.max(0, free),
96
+ comped: Math.max(0, comped),
97
+
98
+ // Deltas
99
+ paid_subscribed: row.paid_subscribed,
100
+ paid_canceled: row.paid_canceled
101
+ });
102
+
103
+ // Update current counts
104
+ paid -= row.paid_subscribed - row.paid_canceled;
105
+ free -= row.free_delta;
106
+ comped -= row.comped_delta;
107
+ }
108
+
109
+ // Now also add the oldest day we have left over (this one will be zero, which is also needed as a data point for graphs)
110
+ const oldestDate = rows.length > 0 ? moment(rows[0].date).add(-1, 'days').format('YYYY-MM-DD') : today;
111
+
112
+ cumulativeResults.unshift({
113
+ date: oldestDate,
114
+ paid: Math.max(0, paid),
115
+ free: Math.max(0, free),
116
+ comped: Math.max(0, comped),
117
+
118
+ // Deltas
119
+ paid_subscribed: 0,
120
+ paid_canceled: 0
121
+ });
122
+
123
+ return {
124
+ data: cumulativeResults,
125
+ meta: {
126
+ totals
127
+ }
128
+ };
129
+ }
130
+ }
131
+
132
+ module.exports = MembersStatsService;
133
+
134
+ /**
135
+ * @typedef MemberStatusDelta
136
+ * @type {Object}
137
+ * @property {Date} date
138
+ * @property {number} paid_subscribed Paid members that subscribed on this day
139
+ * @property {number} paid_canceled Paid members that canceled on this day
140
+ * @property {number} comped_delta Total net comped members on this day
141
+ * @property {number} free_delta Total net members on this day
142
+ */
143
+
144
+ /**
145
+ * @typedef TotalMembersByStatus
146
+ * @type {Object}
147
+ * @property {number} paid Total paid members
148
+ * @property {number} free Total free members
149
+ * @property {number} comped Total comped members
150
+ */
151
+
152
+ /**
153
+ * @typedef {Object} TotalMembersByStatusItem
154
+ * @property {string} date In YYYY-MM-DD format
155
+ * @property {number} paid Total paid members
156
+ * @property {number} free Total free members
157
+ * @property {number} comped Total comped members
158
+ * @property {number} paid_subscribed Paid members that subscribed on this day
159
+ * @property {number} paid_canceled Paid members that canceled on this day
160
+ */
161
+
162
+ /**
163
+ * @typedef {Object} CountHistory
164
+ * @property {TotalMembersByStatusItem[]} data List of the total members by status for each day, including the paid deltas paid_subscribed and paid_canceled
165
+ * @property {Object} meta
166
+ * @property {TotalMembersByStatus} meta.totals
167
+ */