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.
- package/components/tryghost-adapter-cache-memory-ttl-5.112.0.tgz +0 -0
- package/components/{tryghost-adapter-cache-redis-5.111.0.tgz → tryghost-adapter-cache-redis-5.112.0.tgz} +0 -0
- package/components/{tryghost-adapter-manager-5.111.0.tgz → tryghost-adapter-manager-5.112.0.tgz} +0 -0
- package/components/tryghost-announcement-bar-settings-5.112.0.tgz +0 -0
- package/components/{tryghost-api-framework-5.111.0.tgz → tryghost-api-framework-5.112.0.tgz} +0 -0
- package/components/{tryghost-api-version-compatibility-service-5.111.0.tgz → tryghost-api-version-compatibility-service-5.112.0.tgz} +0 -0
- package/components/{tryghost-audience-feedback-5.111.0.tgz → tryghost-audience-feedback-5.112.0.tgz} +0 -0
- package/components/tryghost-bookshelf-repository-5.112.0.tgz +0 -0
- package/components/tryghost-bootstrap-socket-5.112.0.tgz +0 -0
- package/components/{tryghost-captcha-service-5.111.0.tgz → tryghost-captcha-service-5.112.0.tgz} +0 -0
- package/components/tryghost-constants-5.112.0.tgz +0 -0
- package/components/tryghost-custom-fonts-5.112.0.tgz +0 -0
- package/components/{tryghost-custom-theme-settings-service-5.111.0.tgz → tryghost-custom-theme-settings-service-5.112.0.tgz} +0 -0
- package/components/{tryghost-data-generator-5.111.0.tgz → tryghost-data-generator-5.112.0.tgz} +0 -0
- package/components/tryghost-domain-events-5.112.0.tgz +0 -0
- package/components/{tryghost-donations-5.111.0.tgz → tryghost-donations-5.112.0.tgz} +0 -0
- package/components/{tryghost-email-addresses-5.111.0.tgz → tryghost-email-addresses-5.112.0.tgz} +0 -0
- package/components/{tryghost-email-analytics-provider-mailgun-5.111.0.tgz → tryghost-email-analytics-provider-mailgun-5.112.0.tgz} +0 -0
- package/components/{tryghost-email-analytics-service-5.111.0.tgz → tryghost-email-analytics-service-5.112.0.tgz} +0 -0
- package/components/{tryghost-email-content-generator-5.111.0.tgz → tryghost-email-content-generator-5.112.0.tgz} +0 -0
- package/components/{tryghost-email-events-5.111.0.tgz → tryghost-email-events-5.112.0.tgz} +0 -0
- package/components/tryghost-email-service-5.112.0.tgz +0 -0
- package/components/{tryghost-email-suppression-list-5.111.0.tgz → tryghost-email-suppression-list-5.112.0.tgz} +0 -0
- package/components/{tryghost-express-dynamic-redirects-5.111.0.tgz → tryghost-express-dynamic-redirects-5.112.0.tgz} +0 -0
- package/components/{tryghost-external-media-inliner-5.111.0.tgz → tryghost-external-media-inliner-5.112.0.tgz} +0 -0
- package/components/tryghost-extract-api-key-5.112.0.tgz +0 -0
- package/components/tryghost-ghost-5.112.0.tgz +0 -0
- package/components/{tryghost-html-to-plaintext-5.111.0.tgz → tryghost-html-to-plaintext-5.112.0.tgz} +0 -0
- package/components/tryghost-i18n-5.112.0.tgz +0 -0
- package/components/{tryghost-identity-token-service-5.111.0.tgz → tryghost-identity-token-service-5.112.0.tgz} +0 -0
- package/components/{tryghost-importer-handler-content-files-5.111.0.tgz → tryghost-importer-handler-content-files-5.112.0.tgz} +0 -0
- package/components/{tryghost-importer-revue-5.111.0.tgz → tryghost-importer-revue-5.112.0.tgz} +0 -0
- package/components/tryghost-in-memory-repository-5.112.0.tgz +0 -0
- package/components/{tryghost-job-manager-5.111.0.tgz → tryghost-job-manager-5.112.0.tgz} +0 -0
- package/components/tryghost-link-redirects-5.112.0.tgz +0 -0
- package/components/tryghost-link-replacer-5.112.0.tgz +0 -0
- package/components/{tryghost-magic-link-5.111.0.tgz → tryghost-magic-link-5.112.0.tgz} +0 -0
- package/components/{tryghost-mail-events-5.111.0.tgz → tryghost-mail-events-5.112.0.tgz} +0 -0
- package/components/{tryghost-mailgun-client-5.111.0.tgz → tryghost-mailgun-client-5.112.0.tgz} +0 -0
- package/components/{tryghost-member-attribution-5.111.0.tgz → tryghost-member-attribution-5.112.0.tgz} +0 -0
- package/components/tryghost-member-events-5.112.0.tgz +0 -0
- package/components/{tryghost-members-api-5.111.0.tgz → tryghost-members-api-5.112.0.tgz} +0 -0
- package/components/tryghost-members-csv-5.112.0.tgz +0 -0
- package/components/{tryghost-members-importer-5.111.0.tgz → tryghost-members-importer-5.112.0.tgz} +0 -0
- package/components/{tryghost-members-offers-5.111.0.tgz → tryghost-members-offers-5.112.0.tgz} +0 -0
- package/components/tryghost-members-payments-5.112.0.tgz +0 -0
- package/components/{tryghost-members-ssr-5.111.0.tgz → tryghost-members-ssr-5.112.0.tgz} +0 -0
- package/components/{tryghost-members-stripe-service-5.111.0.tgz → tryghost-members-stripe-service-5.112.0.tgz} +0 -0
- package/components/{tryghost-milestones-5.111.0.tgz → tryghost-milestones-5.112.0.tgz} +0 -0
- package/components/{tryghost-minifier-5.111.0.tgz → tryghost-minifier-5.112.0.tgz} +0 -0
- package/components/{tryghost-mw-api-version-mismatch-5.111.0.tgz → tryghost-mw-api-version-mismatch-5.112.0.tgz} +0 -0
- package/components/tryghost-mw-cache-control-5.112.0.tgz +0 -0
- package/components/{tryghost-mw-error-handler-5.111.0.tgz → tryghost-mw-error-handler-5.112.0.tgz} +0 -0
- package/components/{tryghost-mw-session-from-token-5.111.0.tgz → tryghost-mw-session-from-token-5.112.0.tgz} +0 -0
- package/components/{tryghost-mw-update-user-last-seen-5.111.0.tgz → tryghost-mw-update-user-last-seen-5.112.0.tgz} +0 -0
- package/components/tryghost-mw-version-match-5.112.0.tgz +0 -0
- package/components/tryghost-mw-vhost-5.112.0.tgz +0 -0
- package/components/{tryghost-package-json-5.111.0.tgz → tryghost-package-json-5.112.0.tgz} +0 -0
- package/components/tryghost-post-events-5.112.0.tgz +0 -0
- package/components/tryghost-post-revisions-5.112.0.tgz +0 -0
- package/components/{tryghost-posts-service-5.111.0.tgz → tryghost-posts-service-5.112.0.tgz} +0 -0
- package/components/{tryghost-prometheus-metrics-5.111.0.tgz → tryghost-prometheus-metrics-5.112.0.tgz} +0 -0
- package/components/tryghost-recommendations-5.112.0.tgz +0 -0
- package/components/{tryghost-referrers-5.111.0.tgz → tryghost-referrers-5.112.0.tgz} +0 -0
- package/components/{tryghost-security-5.111.0.tgz → tryghost-security-5.112.0.tgz} +0 -0
- package/components/tryghost-session-service-5.112.0.tgz +0 -0
- package/components/{tryghost-settings-path-manager-5.111.0.tgz → tryghost-settings-path-manager-5.112.0.tgz} +0 -0
- package/components/{tryghost-slack-notifications-5.111.0.tgz → tryghost-slack-notifications-5.112.0.tgz} +0 -0
- package/components/tryghost-tiers-5.112.0.tgz +0 -0
- package/components/tryghost-version-notifications-data-service-5.112.0.tgz +0 -0
- package/components/{tryghost-webmentions-5.111.0.tgz → tryghost-webmentions-5.112.0.tgz} +0 -0
- package/core/boot.js +0 -3
- package/core/built/admin/assets/admin-x-activitypub/admin-x-activitypub.js +10216 -9646
- package/core/built/admin/assets/admin-x-settings/{CodeEditorView-1298238e.mjs → CodeEditorView-ad8698fe.mjs} +2 -2
- package/core/built/admin/assets/admin-x-settings/admin-x-settings.js +1 -1
- package/core/built/admin/assets/admin-x-settings/{index-2707471f.mjs → index-2713e469.mjs} +2750 -2714
- package/core/built/admin/assets/admin-x-settings/{index-0f51ccb5.mjs → index-463cec50.mjs} +2 -2
- package/core/built/admin/assets/admin-x-settings/{modals-f5983704.mjs → modals-033e8fc4.mjs} +4 -4
- package/core/built/admin/assets/{chunk.524.405c43b2cb20553b51d9.js → chunk.524.db49da6fd8ae155205a4.js} +5 -5
- package/core/built/admin/assets/{chunk.582.eb4b096f29c97c9d6a64.js → chunk.582.0bf715eb6807f7641706.js} +8 -8
- package/core/built/admin/assets/{ghost-87cffc153ec73d217c1ae9f9207ea5e1.js → ghost-62bd4d4c837d453e1038808dc1cd1e4c.js} +9 -7
- package/core/built/admin/assets/img/ap-nodes-01ee317529e6353a1c34a062c388f1e7.png +0 -0
- package/core/built/admin/assets/koenig-lexical/koenig-lexical.js +12944 -12924
- package/core/built/admin/assets/koenig-lexical/koenig-lexical.umd.js +136 -134
- package/core/built/admin/assets/posts/posts.js +2 -2
- package/core/built/admin/index.html +3 -3
- package/core/frontend/helpers/get.js +2 -3
- package/core/frontend/src/cards/css/cta.css +38 -36
- package/core/server/api/endpoints/utils/serializers/input/settings.js +2 -1
- package/core/server/api/endpoints/utils/serializers/input/utils/settings-key-group-mapper.js +2 -1
- package/core/server/api/endpoints/utils/serializers/input/utils/settings-key-type-mapper.js +2 -1
- package/core/server/data/migrations/versions/5.112/2025-03-10-10-01-01-add-require-mfa-setting.js +8 -0
- package/core/server/data/schema/default-settings/default-settings.json +6 -0
- package/core/server/models/invite.js +4 -5
- package/core/server/models/post.js +3 -9
- package/core/server/models/relations/authors.js +2 -4
- package/core/server/models/role-utils.js +38 -0
- package/core/server/models/role.js +5 -3
- package/core/server/models/user.js +5 -3
- package/core/server/services/activitypub/ActivityPubService.js +116 -0
- package/core/server/services/activitypub/ActivityPubService.ts +139 -0
- package/core/server/services/activitypub/ActivityPubServiceWrapper.js +1 -1
- package/core/server/services/stats/MembersStatsService.js +167 -0
- package/core/server/services/stats/MrrStatsService.js +161 -0
- package/core/server/services/stats/ReferrersStatsService.js +164 -0
- package/core/server/services/stats/StatsService.js +63 -0
- package/core/server/services/stats/SubscriptionStatsService.js +180 -0
- package/core/server/services/stats/service.js +1 -1
- package/core/server/services/url/Resources.js +1 -1
- package/package.json +138 -141
- package/tsconfig.tsbuildinfo +1 -1
- package/yarn.lock +30 -72
- package/components/tryghost-activitypub-5.111.0.tgz +0 -0
- package/components/tryghost-adapter-cache-memory-ttl-5.111.0.tgz +0 -0
- package/components/tryghost-announcement-bar-settings-5.111.0.tgz +0 -0
- package/components/tryghost-bookshelf-repository-5.111.0.tgz +0 -0
- package/components/tryghost-bootstrap-socket-5.111.0.tgz +0 -0
- package/components/tryghost-constants-5.111.0.tgz +0 -0
- package/components/tryghost-custom-fonts-5.111.0.tgz +0 -0
- package/components/tryghost-domain-events-5.111.0.tgz +0 -0
- package/components/tryghost-email-service-5.111.0.tgz +0 -0
- package/components/tryghost-extract-api-key-5.111.0.tgz +0 -0
- package/components/tryghost-ghost-5.111.0.tgz +0 -0
- package/components/tryghost-i18n-5.111.0.tgz +0 -0
- package/components/tryghost-in-memory-repository-5.111.0.tgz +0 -0
- package/components/tryghost-link-redirects-5.111.0.tgz +0 -0
- package/components/tryghost-link-replacer-5.111.0.tgz +0 -0
- package/components/tryghost-member-events-5.111.0.tgz +0 -0
- package/components/tryghost-members-csv-5.111.0.tgz +0 -0
- package/components/tryghost-members-payments-5.111.0.tgz +0 -0
- package/components/tryghost-mw-cache-control-5.111.0.tgz +0 -0
- package/components/tryghost-mw-version-match-5.111.0.tgz +0 -0
- package/components/tryghost-mw-vhost-5.111.0.tgz +0 -0
- package/components/tryghost-post-events-5.111.0.tgz +0 -0
- package/components/tryghost-post-revisions-5.111.0.tgz +0 -0
- package/components/tryghost-recommendations-5.111.0.tgz +0 -0
- package/components/tryghost-session-service-5.111.0.tgz +0 -0
- package/components/tryghost-stats-service-5.111.0.tgz +0 -0
- package/components/tryghost-tiers-5.111.0.tgz +0 -0
- package/components/tryghost-version-notifications-data-service-5.111.0.tgz +0 -0
- package/core/demo.js +0 -6
- 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 (
|
|
84
|
+
if (isOwner) {
|
|
83
85
|
checkAgainst = ['Owner', 'Administrator', 'Editor', 'Author', 'Contributor'];
|
|
84
|
-
} else if (
|
|
86
|
+
} else if (isAdmin) {
|
|
85
87
|
checkAgainst = ['Administrator', 'Editor', 'Author', 'Contributor'];
|
|
86
|
-
} else if (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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
|
+
}
|
|
@@ -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
|
+
*/
|