ghost 4.41.3 → 4.43.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/content/themes/casper/package.json +2 -3
- package/content/themes/casper/partials/post-card.hbs +1 -1
- package/core/built/assets/ghost-dark-1933079797e24ccb8839657020830be5.css +1 -0
- package/core/built/assets/{ghost.min-1abf114ca26a71e8e1f09054f3592614.js → ghost.min-2a278873d60d6a13a4c05a396e5bed5e.js} +533 -398
- package/core/built/assets/ghost.min-38f3c38c0c6a1864f57079b068a0b0ce.css +1 -0
- package/core/built/assets/{vendor.min-9094db77ba3190cb10876f8e42e1d90d.js → vendor.min-21f79c68a284acb1b70039f3f63e5507.js} +68 -68
- package/core/built/assets/{vendor.min-2c8ad32b7960bb605ebc20097fee5ebd.css → vendor.min-ba66b98f7c24fa40e061c7ffc94f4e23.css} +214 -0
- package/core/frontend/apps/amp/lib/helpers/amp_analytics.js +1 -1
- package/core/frontend/apps/amp/lib/helpers/amp_components.js +1 -1
- package/core/frontend/apps/amp/lib/helpers/amp_content.js +1 -1
- package/core/frontend/apps/amp/lib/helpers/amp_style.js +1 -1
- package/core/frontend/apps/amp/lib/router.js +6 -5
- package/core/frontend/apps/private-blogging/lib/helpers/input_password.js +1 -1
- package/core/frontend/apps/private-blogging/lib/router.js +2 -2
- package/core/frontend/helpers/asset.js +1 -1
- package/core/frontend/helpers/author.js +1 -1
- package/core/frontend/helpers/authors.js +1 -1
- package/core/frontend/helpers/body_class.js +1 -1
- package/core/frontend/helpers/cancel_link.js +1 -1
- package/core/frontend/helpers/concat.js +1 -1
- package/core/frontend/helpers/content.js +1 -1
- package/core/frontend/helpers/date.js +1 -1
- package/core/frontend/helpers/encode.js +1 -1
- package/core/frontend/helpers/excerpt.js +1 -1
- package/core/frontend/helpers/facebook_url.js +1 -1
- package/core/frontend/helpers/foreach.js +2 -2
- package/core/frontend/helpers/get.js +1 -1
- package/core/frontend/helpers/ghost_foot.js +1 -1
- package/core/frontend/helpers/ghost_head.js +1 -1
- package/core/frontend/helpers/lang.js +1 -1
- package/core/frontend/helpers/link.js +1 -1
- package/core/frontend/helpers/link_class.js +1 -1
- package/core/frontend/helpers/match.js +1 -1
- package/core/frontend/helpers/navigation.js +1 -1
- package/core/frontend/helpers/pagination.js +1 -1
- package/core/frontend/helpers/plural.js +1 -1
- package/core/frontend/helpers/post_class.js +1 -1
- package/core/frontend/helpers/prev_post.js +6 -5
- package/core/frontend/helpers/price.js +1 -0
- package/core/frontend/helpers/products.js +1 -1
- package/core/frontend/helpers/reading_time.js +2 -2
- package/core/frontend/helpers/t.js +1 -1
- package/core/frontend/helpers/tags.js +1 -1
- package/core/frontend/helpers/tiers.js +1 -1
- package/core/frontend/helpers/title.js +1 -1
- package/core/frontend/helpers/twitter_url.js +1 -1
- package/core/frontend/helpers/url.js +1 -1
- package/core/frontend/meta/url.js +4 -4
- package/core/{server/data/schema → frontend/services/data}/checks.js +4 -4
- package/core/frontend/services/{routing/helpers → data}/entry-lookup.js +3 -3
- package/core/frontend/services/{routing/helpers → data}/fetch-data.js +3 -3
- package/core/frontend/services/data/index.js +5 -0
- package/core/frontend/services/{rendering.js → handlebars.js} +2 -1
- package/core/frontend/services/helpers/handlebars.js +1 -1
- package/core/frontend/services/proxy.js +2 -4
- package/core/frontend/services/{routing/helpers → rendering}/context.js +0 -0
- package/core/frontend/services/{routing/helpers → rendering}/error.js +0 -0
- package/core/frontend/services/{routing/helpers → rendering}/format-response.js +1 -1
- package/core/frontend/services/{routing/helpers → rendering}/index.js +0 -8
- package/core/frontend/services/{routing/helpers → rendering}/render-entries.js +1 -1
- package/core/frontend/services/{routing/helpers → rendering}/render-entry.js +1 -1
- package/core/frontend/services/{routing/helpers → rendering}/renderer.js +1 -1
- package/core/frontend/services/{routing/helpers → rendering}/secure.js +0 -0
- package/core/frontend/services/{routing/helpers → rendering}/templates.js +2 -2
- package/core/frontend/services/routing/CollectionRouter.js +1 -1
- package/core/frontend/services/routing/controllers/channel.js +9 -9
- package/core/frontend/services/routing/controllers/collection.js +9 -9
- package/core/frontend/services/routing/controllers/email-post.js +5 -6
- package/core/frontend/services/routing/controllers/entry.js +6 -6
- package/core/frontend/services/routing/controllers/preview.js +5 -6
- package/core/frontend/services/routing/controllers/rss.js +4 -3
- package/core/frontend/services/routing/controllers/static.js +5 -5
- package/core/frontend/services/routing/controllers/unsubscribe.js +2 -2
- package/core/frontend/services/routing/index.js +0 -4
- package/core/frontend/web/middleware/error-handler.js +2 -2
- package/core/server/api/canary/email-preview.js +2 -1
- package/core/server/api/canary/{email.js → emails.js} +0 -0
- package/core/server/api/canary/index.js +9 -1
- package/core/server/api/canary/members.js +0 -45
- package/core/server/api/canary/newsletters.js +45 -0
- package/core/server/api/canary/stats.js +23 -0
- package/core/server/api/canary/utils/serializers/output/email-previews.js +7 -0
- package/core/server/api/canary/utils/serializers/output/index.js +2 -22
- package/core/server/api/canary/utils/serializers/output/mappers/index.js +1 -0
- package/core/server/api/canary/utils/serializers/output/mappers/snippets.js +36 -0
- package/core/server/api/canary/utils/serializers/output/members.js +5 -2
- package/core/server/api/canary/utils/serializers/output/oembed.js +2 -2
- package/core/server/api/canary/utils/serializers/output/redirects.js +2 -2
- package/core/server/api/canary/utils/serializers/output/schedules.js +2 -2
- package/core/server/api/canary/utils/serializers/output/slack.js +2 -2
- package/core/server/api/canary/utils/serializers/output/themes.js +2 -2
- package/core/server/api/canary/utils/serializers/output/users.js +0 -23
- package/core/server/api/canary/utils/validators/input/index.js +6 -0
- package/core/server/api/shared/http.js +52 -51
- package/core/server/api/shared/serializers/handle.js +25 -26
- package/core/server/data/exporter/table-lists.js +2 -0
- package/core/server/data/migrations/utils.js +34 -2
- package/core/server/data/migrations/versions/4.42/2022-03-21-17-17-add.js +25 -0
- package/core/server/data/migrations/versions/4.42/2022-03-30-15-44-add-newsletter-permissions.js +28 -0
- package/core/server/data/migrations/versions/4.43/2022-03-28-19-26-recreate-newsletter-table.js +29 -0
- package/core/server/data/migrations/versions/4.43/2022-03-29-14-45-add-members-newsletters-table.js +7 -0
- package/core/server/data/migrations/versions/4.43/2022-04-01-10-13-add-post-newsletter-relation.js +108 -0
- package/core/server/data/migrations/versions/4.43/2022-04-06-09-47-add-type-column-to-paid-subscription-events.js +7 -0
- package/core/server/data/migrations/versions/4.43/2022-04-06-14-56-add-email-newsletter-relation.js +8 -0
- package/core/server/data/migrations/versions/4.43/2022-04-08-10-45-add-subscription-id-to-mrr-events.js +7 -0
- package/core/server/data/schema/commands.js +19 -14
- package/core/server/data/schema/index.js +0 -1
- package/core/server/data/schema/schema.js +36 -0
- package/core/server/models/base/bookshelf.js +1 -1
- package/core/server/models/base/plugins/crud.js +8 -0
- package/core/server/models/member.js +18 -1
- package/core/server/models/newsletter.js +43 -0
- package/core/server/models/post.js +4 -1
- package/core/server/services/auth/setup.js +4 -1
- package/core/server/services/mega/template.js +25 -13
- package/core/server/services/members/api.js +3 -1
- package/core/server/services/members/middleware.js +13 -3
- package/core/server/services/members/service.js +2 -1
- package/core/server/services/members/utils.js +13 -1
- package/core/server/services/newsletters/index.js +10 -0
- package/core/server/services/newsletters/service.js +24 -0
- package/core/server/services/slack.js +11 -3
- package/core/server/services/stats/index.js +1 -0
- package/core/server/services/stats/lib/members-stats-service.js +161 -0
- package/core/server/services/stats/lib/mrr-stats-service.js +154 -0
- package/core/server/services/stats/service.js +8 -0
- package/core/server/services/stripe/service.js +1 -0
- package/core/server/services/webhooks/webhooks-service.js +3 -1
- package/core/server/web/admin/views/default-prod.html +5 -5
- package/core/server/web/admin/views/default.html +5 -5
- package/core/server/web/api/canary/admin/routes.js +9 -2
- package/core/shared/config/defaults.json +2 -2
- package/core/shared/config/env/config.development.json +26 -0
- package/core/shared/config/env/config.production.json +21 -0
- package/core/shared/config/env/config.testing-mysql.json +59 -0
- package/core/shared/config/env/config.testing.json +58 -0
- package/package.json +50 -50
- package/yarn.lock +700 -769
- package/content/themes/casper/assets/css/csscomb.json +0 -240
- package/core/built/assets/ghost-dark-146c4c688b47d45c4aa018ee0f79cebc.css +0 -1
- package/core/built/assets/ghost.min-a73b150c7eecc4641d377cc73fb5eecd.css +0 -1
- package/core/server/api/canary/utils/serializers/output/email-preview.js +0 -10
- package/core/server/api/canary/utils/serializers/output/emails.js +0 -22
- package/core/server/api/canary/utils/serializers/output/identities.js +0 -7
- package/core/server/api/canary/utils/serializers/output/member-signin-urls.js +0 -7
- package/core/server/api/canary/utils/serializers/output/snippets.js +0 -107
- package/core/server/api/canary/utils/serializers/output/webhooks.js +0 -15
|
@@ -70,12 +70,12 @@ const getOfferData = async function (req, res) {
|
|
|
70
70
|
|
|
71
71
|
const updateMemberData = async function (req, res) {
|
|
72
72
|
try {
|
|
73
|
-
const data = _.pick(req.body, 'name', 'subscribed');
|
|
73
|
+
const data = _.pick(req.body, 'name', 'subscribed', 'newsletters');
|
|
74
74
|
const member = await membersService.ssr.getMemberDataFromSession(req, res);
|
|
75
75
|
if (member) {
|
|
76
76
|
const options = {
|
|
77
77
|
id: member.id,
|
|
78
|
-
withRelated: ['stripeSubscriptions', 'stripeSubscriptions.customer', 'stripeSubscriptions.stripePrice']
|
|
78
|
+
withRelated: ['stripeSubscriptions', 'stripeSubscriptions.customer', 'stripeSubscriptions.stripePrice', 'newsletters']
|
|
79
79
|
};
|
|
80
80
|
const updatedMember = await membersService.api.members.update(data, options);
|
|
81
81
|
|
|
@@ -133,6 +133,11 @@ const getPortalProductPrices = async function () {
|
|
|
133
133
|
};
|
|
134
134
|
};
|
|
135
135
|
|
|
136
|
+
const getSiteNewsletters = async function () {
|
|
137
|
+
const newsletters = await models.Newsletter.findAll();
|
|
138
|
+
return newsletters.toJSON();
|
|
139
|
+
};
|
|
140
|
+
|
|
136
141
|
const getMemberSiteData = async function (req, res) {
|
|
137
142
|
const isStripeConfigured = membersService.config.isStripeConnected();
|
|
138
143
|
const domain = urlUtils.urlFor('home', true).match(new RegExp('^https?://([^/:?#]+)(?:[/:?#]|$)', 'i'));
|
|
@@ -144,7 +149,7 @@ const getMemberSiteData = async function (req, res) {
|
|
|
144
149
|
}
|
|
145
150
|
const {products = [], prices = []} = await getPortalProductPrices() || {};
|
|
146
151
|
const portalVersion = config.get('portal:version');
|
|
147
|
-
|
|
152
|
+
const newsletters = await getSiteNewsletters();
|
|
148
153
|
const response = {
|
|
149
154
|
title: settingsCache.get('title'),
|
|
150
155
|
description: settingsCache.get('description'),
|
|
@@ -170,6 +175,11 @@ const getMemberSiteData = async function (req, res) {
|
|
|
170
175
|
prices,
|
|
171
176
|
products
|
|
172
177
|
};
|
|
178
|
+
|
|
179
|
+
if (labsService.isSet('multipleNewsletters')) {
|
|
180
|
+
response.newsletters = newsletters;
|
|
181
|
+
}
|
|
182
|
+
|
|
173
183
|
if (labsService.isSet('multipleProducts')) {
|
|
174
184
|
response.portal_products = settingsCache.get('portal_products');
|
|
175
185
|
}
|
|
@@ -19,6 +19,7 @@ const VerificationTrigger = require('@tryghost/verification-trigger');
|
|
|
19
19
|
const DomainEvents = require('@tryghost/domain-events');
|
|
20
20
|
const {LastSeenAtUpdater} = require('@tryghost/members-events-service');
|
|
21
21
|
const events = require('../../lib/common/events');
|
|
22
|
+
const DatabaseInfo = require('@tryghost/database-info');
|
|
22
23
|
|
|
23
24
|
const messages = {
|
|
24
25
|
noLiveKeysInDevelopment: 'Cannot use live stripe keys in development. Please restart in production mode.',
|
|
@@ -37,7 +38,7 @@ const membersConfig = new MembersConfigProvider({
|
|
|
37
38
|
const membersStats = new MembersStats({
|
|
38
39
|
db: db,
|
|
39
40
|
settingsCache: settingsCache,
|
|
40
|
-
isSQLite:
|
|
41
|
+
isSQLite: DatabaseInfo.isSQLite(db.knex)
|
|
41
42
|
});
|
|
42
43
|
|
|
43
44
|
let membersApi;
|
|
@@ -1,8 +1,16 @@
|
|
|
1
|
+
const labsService = require('../../../shared/labs');
|
|
2
|
+
|
|
3
|
+
function formatNewsletterResponse(newsletters) {
|
|
4
|
+
return newsletters.map(({id, name, description, sort_order: sortOrder}) => {
|
|
5
|
+
return {id, name, description, sort_order: sortOrder};
|
|
6
|
+
});
|
|
7
|
+
}
|
|
8
|
+
|
|
1
9
|
module.exports.formattedMemberResponse = function formattedMemberResponse(member) {
|
|
2
10
|
if (!member) {
|
|
3
11
|
return null;
|
|
4
12
|
}
|
|
5
|
-
|
|
13
|
+
const data = {
|
|
6
14
|
uuid: member.uuid,
|
|
7
15
|
email: member.email,
|
|
8
16
|
name: member.name,
|
|
@@ -12,4 +20,8 @@ module.exports.formattedMemberResponse = function formattedMemberResponse(member
|
|
|
12
20
|
subscriptions: member.subscriptions || [],
|
|
13
21
|
paid: member.status !== 'free'
|
|
14
22
|
};
|
|
23
|
+
if (member.newsletters && labsService.isSet('multipleNewsletters')) {
|
|
24
|
+
data.newsletters = formatNewsletterResponse(member.newsletters);
|
|
25
|
+
}
|
|
26
|
+
return data;
|
|
15
27
|
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
const NewslettersService = require('./service.js');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @returns {NewslettersService} instance of the NewslettersService
|
|
5
|
+
*/
|
|
6
|
+
const getNewslettersServiceInstance = ({NewsletterModel}) => {
|
|
7
|
+
return new NewslettersService({NewsletterModel});
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
module.exports = getNewslettersServiceInstance;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
class NewslettersService {
|
|
2
|
+
/**
|
|
3
|
+
*
|
|
4
|
+
* @param {Object} options
|
|
5
|
+
* @param {Object} options.NewsletterModel
|
|
6
|
+
*/
|
|
7
|
+
constructor({NewsletterModel}) {
|
|
8
|
+
this.NewsletterModel = NewsletterModel;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
*
|
|
13
|
+
* @param {Object} options browse options
|
|
14
|
+
* @returns
|
|
15
|
+
*/
|
|
16
|
+
async browse(options) {
|
|
17
|
+
let newsletters = await this.NewsletterModel.findAll(options);
|
|
18
|
+
|
|
19
|
+
return newsletters.toJSON();
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
module.exports = NewslettersService;
|
|
24
|
+
|
|
@@ -6,7 +6,6 @@ const {blogIcon} = require('../lib/image');
|
|
|
6
6
|
const urlUtils = require('../../shared/url-utils');
|
|
7
7
|
const urlService = require('./url');
|
|
8
8
|
const settingsCache = require('../../shared/settings-cache');
|
|
9
|
-
const schema = require('../data/schema').checks;
|
|
10
9
|
const moment = require('moment');
|
|
11
10
|
|
|
12
11
|
// Used to receive post.published model event, but also the slack.test event from the API which iirc this was done to avoid circular deps a long time ago
|
|
@@ -37,6 +36,15 @@ function getSlackSettings() {
|
|
|
37
36
|
};
|
|
38
37
|
}
|
|
39
38
|
|
|
39
|
+
/**
|
|
40
|
+
* @TODO: change this function to check for the properties we depend on
|
|
41
|
+
* @param {Object} data
|
|
42
|
+
* @returns {boolean}
|
|
43
|
+
*/
|
|
44
|
+
function hasPostProperties(data) {
|
|
45
|
+
return Object.prototype.hasOwnProperty.call(data, 'html') && Object.prototype.hasOwnProperty.call(data, 'title') && Object.prototype.hasOwnProperty.call(data, 'slug');
|
|
46
|
+
}
|
|
47
|
+
|
|
40
48
|
function ping(post) {
|
|
41
49
|
let message;
|
|
42
50
|
let title;
|
|
@@ -47,7 +55,7 @@ function ping(post) {
|
|
|
47
55
|
let blogTitle = settingsCache.get('title');
|
|
48
56
|
|
|
49
57
|
// If this is a post, we want to send the link of the post
|
|
50
|
-
if (
|
|
58
|
+
if (hasPostProperties(post)) {
|
|
51
59
|
message = urlService.getUrlByResourceId(post.id, {absolute: true});
|
|
52
60
|
title = post.title ? post.title : null;
|
|
53
61
|
author = post.authors ? post.authors[0] : null;
|
|
@@ -79,7 +87,7 @@ function ping(post) {
|
|
|
79
87
|
return;
|
|
80
88
|
}
|
|
81
89
|
|
|
82
|
-
if (
|
|
90
|
+
if (hasPostProperties(post)) {
|
|
83
91
|
slackData = {
|
|
84
92
|
// We are handling the case of test notification here by checking
|
|
85
93
|
// if it is a post or a test message to check webhook working.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
module.exports = require('./service');
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
const moment = require('moment');
|
|
2
|
+
|
|
3
|
+
class MembersStatsService {
|
|
4
|
+
constructor({db}) {
|
|
5
|
+
this.db = db;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Get the current total members grouped by status
|
|
10
|
+
* @returns {Promise<TotalMembersByStatus>}
|
|
11
|
+
*/
|
|
12
|
+
async getCount() {
|
|
13
|
+
const knex = this.db.knex;
|
|
14
|
+
const rows = await knex('members')
|
|
15
|
+
.select('status')
|
|
16
|
+
.select(knex.raw('COUNT(id) AS total'))
|
|
17
|
+
.groupBy('status');
|
|
18
|
+
|
|
19
|
+
const paidEvent = rows.find(c => c.status === 'paid');
|
|
20
|
+
const freeEvent = rows.find(c => c.status === 'free');
|
|
21
|
+
const compedEvent = rows.find(c => c.status === 'comped');
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
paid: paidEvent ? paidEvent.total : 0,
|
|
25
|
+
free: freeEvent ? freeEvent.total : 0,
|
|
26
|
+
comped: compedEvent ? compedEvent.total : 0
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Get the member deltas by status for all days, sorted ascending
|
|
32
|
+
* @returns {Promise<MemberStatusDelta[]>} The deltas of paid, free and comped users per day, sorted ascending
|
|
33
|
+
*/
|
|
34
|
+
async fetchAllStatusDeltas() {
|
|
35
|
+
const knex = this.db.knex;
|
|
36
|
+
const rows = await knex('members_status_events')
|
|
37
|
+
.select(knex.raw('DATE(created_at) as date'))
|
|
38
|
+
.select(knex.raw(`SUM(
|
|
39
|
+
CASE WHEN to_status='paid' THEN 1
|
|
40
|
+
ELSE 0 END
|
|
41
|
+
) as paid_subscribed`))
|
|
42
|
+
.select(knex.raw(`SUM(
|
|
43
|
+
CASE WHEN from_status='paid' THEN 1
|
|
44
|
+
ELSE 0 END
|
|
45
|
+
) as paid_canceled`))
|
|
46
|
+
.select(knex.raw(`SUM(
|
|
47
|
+
CASE WHEN to_status='comped' THEN 1
|
|
48
|
+
WHEN from_status='comped' THEN -1
|
|
49
|
+
ELSE 0 END
|
|
50
|
+
) as comped_delta`))
|
|
51
|
+
.select(knex.raw(`SUM(
|
|
52
|
+
CASE WHEN to_status='free' THEN 1
|
|
53
|
+
WHEN from_status='free' THEN -1
|
|
54
|
+
ELSE 0 END
|
|
55
|
+
) as free_delta`))
|
|
56
|
+
.groupByRaw('DATE(created_at)')
|
|
57
|
+
.orderByRaw('DATE(created_at)');
|
|
58
|
+
return rows;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Returns a list of the total members by status for each day, including the paid deltas paid_subscribed and paid_canceled
|
|
63
|
+
* @returns {Promise<CountHistory>}
|
|
64
|
+
*/
|
|
65
|
+
async getCountHistory() {
|
|
66
|
+
const rows = await this.fetchAllStatusDeltas();
|
|
67
|
+
|
|
68
|
+
// Fetch current total amounts and start counting from there
|
|
69
|
+
const totals = await this.getCount();
|
|
70
|
+
let {paid, free, comped} = totals;
|
|
71
|
+
|
|
72
|
+
// Get today in UTC (default timezone)
|
|
73
|
+
const today = moment().format('YYYY-MM-DD');
|
|
74
|
+
|
|
75
|
+
const cumulativeResults = [];
|
|
76
|
+
|
|
77
|
+
// Loop in reverse order (needed to have correct sorted result)
|
|
78
|
+
for (let i = rows.length - 1; i >= 0; i -= 1) {
|
|
79
|
+
const row = rows[i];
|
|
80
|
+
|
|
81
|
+
// Convert JSDates to YYYY-MM-DD (in UTC)
|
|
82
|
+
const date = moment(row.date).format('YYYY-MM-DD');
|
|
83
|
+
if (date > today) {
|
|
84
|
+
// Skip results that are in the future (fix for invalid events)
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
cumulativeResults.unshift({
|
|
88
|
+
date,
|
|
89
|
+
paid: Math.max(0, paid),
|
|
90
|
+
free: Math.max(0, free),
|
|
91
|
+
comped: Math.max(0, comped),
|
|
92
|
+
|
|
93
|
+
// Deltas
|
|
94
|
+
paid_subscribed: row.paid_subscribed,
|
|
95
|
+
paid_canceled: row.paid_canceled
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Update current counts
|
|
99
|
+
paid -= row.paid_subscribed - row.paid_canceled;
|
|
100
|
+
free -= row.free_delta;
|
|
101
|
+
comped -= row.comped_delta;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 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)
|
|
105
|
+
const oldestDate = rows.length > 0 ? moment(rows[0].date).add(-1, 'days').format('YYYY-MM-DD') : today;
|
|
106
|
+
|
|
107
|
+
cumulativeResults.unshift({
|
|
108
|
+
date: oldestDate,
|
|
109
|
+
paid: Math.max(0, paid),
|
|
110
|
+
free: Math.max(0, free),
|
|
111
|
+
comped: Math.max(0, comped),
|
|
112
|
+
|
|
113
|
+
// Deltas
|
|
114
|
+
paid_subscribed: 0,
|
|
115
|
+
paid_canceled: 0
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
data: cumulativeResults,
|
|
120
|
+
meta: {
|
|
121
|
+
totals
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
module.exports = MembersStatsService;
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* @typedef MemberStatusDelta
|
|
131
|
+
* @type {Object}
|
|
132
|
+
* @property {Date} date
|
|
133
|
+
* @property {number} paid_subscribed Paid members that subscribed on this day
|
|
134
|
+
* @property {number} paid_canceled Paid members that canceled on this day
|
|
135
|
+
* @property {number} comped_delta Total net comped members on this day
|
|
136
|
+
* @property {number} free_delta Total net members on this day
|
|
137
|
+
*/
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* @typedef TotalMembersByStatus
|
|
141
|
+
* @type {Object}
|
|
142
|
+
* @property {number} paid Total paid members
|
|
143
|
+
* @property {number} free Total free members
|
|
144
|
+
* @property {number} comped Total comped members
|
|
145
|
+
*/
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* @typedef {Object} TotalMembersByStatusItem
|
|
149
|
+
* @property {string} date In YYYY-MM-DD format
|
|
150
|
+
* @property {number} paid Total paid members
|
|
151
|
+
* @property {number} free Total free members
|
|
152
|
+
* @property {number} comped Total comped members
|
|
153
|
+
* @property {number} paid_subscribed Paid members that subscribed on this day
|
|
154
|
+
* @property {number} paid_canceled Paid members that canceled on this day
|
|
155
|
+
*/
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* @typedef {Object} CountHistory
|
|
159
|
+
* @property {TotalMembersByStatusItem[]} data List of the total members by status for each day, including the paid deltas paid_subscribed and paid_canceled
|
|
160
|
+
* @property {Object} meta
|
|
161
|
+
*/
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
const moment = require('moment');
|
|
2
|
+
|
|
3
|
+
class MrrStatsService {
|
|
4
|
+
constructor({db}) {
|
|
5
|
+
this.db = db;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Get the current total MRR, grouped by currency (ascending order)
|
|
10
|
+
* @returns {Promise<MrrByCurrency[]>}
|
|
11
|
+
*/
|
|
12
|
+
async getCurrentMrr() {
|
|
13
|
+
const knex = this.db.knex;
|
|
14
|
+
const rows = await knex('members_stripe_customers_subscriptions')
|
|
15
|
+
.select(knex.raw(`plan_currency as currency`))
|
|
16
|
+
.select(knex.raw(`SUM(
|
|
17
|
+
CASE WHEN plan_interval = 'year' THEN
|
|
18
|
+
FLOOR(plan_amount / 12)
|
|
19
|
+
ELSE
|
|
20
|
+
plan_amount
|
|
21
|
+
END
|
|
22
|
+
) AS mrr`))
|
|
23
|
+
.whereIn('status', ['active', 'unpaid', 'past_due'])
|
|
24
|
+
.where('cancel_at_period_end', 0)
|
|
25
|
+
.groupBy('plan_currency')
|
|
26
|
+
.orderBy('currency');
|
|
27
|
+
|
|
28
|
+
if (rows.length === 0) {
|
|
29
|
+
// Add a USD placeholder to always have at least one currency
|
|
30
|
+
rows.push({
|
|
31
|
+
currency: 'usd',
|
|
32
|
+
mrr: 0
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return rows;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Get the MRR deltas for all days (from old to new), grouped by currency (ascending alphabetically)
|
|
41
|
+
* @returns {Promise<MrrDelta[]>} The deltas sorted from new to old
|
|
42
|
+
*/
|
|
43
|
+
async fetchAllDeltas() {
|
|
44
|
+
const knex = this.db.knex;
|
|
45
|
+
const rows = await knex('members_paid_subscription_events')
|
|
46
|
+
.select('currency')
|
|
47
|
+
// In SQLite, DATE(created_at) would map to a string value, while DATE(created_at) would map to a JSDate object in MySQL
|
|
48
|
+
// That is why we need the cast here (to have some consistency)
|
|
49
|
+
.select(knex.raw('CAST(DATE(created_at) as CHAR) as date'))
|
|
50
|
+
.select(knex.raw(`SUM(mrr_delta) as delta`))
|
|
51
|
+
.groupByRaw('CAST(DATE(created_at) as CHAR), currency')
|
|
52
|
+
.orderByRaw('CAST(DATE(created_at) as CHAR), currency');
|
|
53
|
+
return rows;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Returns a list of the MRR history for each day and currency, including the current MRR per currency as meta data.
|
|
58
|
+
* The respons is in ascending date order, and currencies for the same date are always in ascending order.
|
|
59
|
+
* @returns {Promise<MrrHistory>}
|
|
60
|
+
*/
|
|
61
|
+
async getHistory() {
|
|
62
|
+
// Fetch current total amounts and start counting from there
|
|
63
|
+
const totals = await this.getCurrentMrr();
|
|
64
|
+
|
|
65
|
+
const rows = await this.fetchAllDeltas();
|
|
66
|
+
|
|
67
|
+
// Get today in UTC (default timezone)
|
|
68
|
+
const today = moment().format('YYYY-MM-DD');
|
|
69
|
+
|
|
70
|
+
const results = [];
|
|
71
|
+
|
|
72
|
+
// Create a map of the totals by currency for fast lookup and editing
|
|
73
|
+
const currentTotals = {};
|
|
74
|
+
for (const total of totals) {
|
|
75
|
+
currentTotals[total.currency] = total.mrr;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Loop in reverse order (needed to have correct sorted result)
|
|
79
|
+
for (let i = rows.length - 1; i >= 0; i -= 1) {
|
|
80
|
+
const row = rows[i];
|
|
81
|
+
|
|
82
|
+
if (currentTotals[row.currency] === undefined) {
|
|
83
|
+
// Skip unexpected currencies that are not in the totals
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Convert JSDates to YYYY-MM-DD (in UTC)
|
|
88
|
+
const date = moment(row.date).format('YYYY-MM-DD');
|
|
89
|
+
|
|
90
|
+
if (date > today) {
|
|
91
|
+
// Skip results that are in the future for some reason
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
results.unshift({
|
|
96
|
+
date,
|
|
97
|
+
mrr: Math.max(0, currentTotals[row.currency]),
|
|
98
|
+
currency: row.currency
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
currentTotals[row.currency] -= row.delta;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Now also add the oldest days we have left over and do not have deltas
|
|
105
|
+
const oldestDate = rows.length > 0 ? moment(rows[0].date).add(-1, 'days').format('YYYY-MM-DD') : today;
|
|
106
|
+
|
|
107
|
+
// Note that we also need to loop the totals in reverse order because we need to unshift
|
|
108
|
+
for (let i = totals.length - 1; i >= 0; i -= 1) {
|
|
109
|
+
const total = totals[i];
|
|
110
|
+
results.unshift({
|
|
111
|
+
date: oldestDate,
|
|
112
|
+
mrr: Math.max(0, currentTotals[total.currency]),
|
|
113
|
+
currency: total.currency
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
data: results,
|
|
119
|
+
meta: {
|
|
120
|
+
totals
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
module.exports = MrrStatsService;
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* @typedef MrrByCurrency
|
|
130
|
+
* @type {Object}
|
|
131
|
+
* @property {number} mrr
|
|
132
|
+
* @property {string} currency
|
|
133
|
+
*/
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* @typedef MrrDelta
|
|
137
|
+
* @type {Object}
|
|
138
|
+
* @property {Date} date
|
|
139
|
+
* @property {string} currency
|
|
140
|
+
* @property {number} delta MRR change on this day
|
|
141
|
+
*/
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* @typedef {Object} MrrRecord
|
|
145
|
+
* @property {string} date In YYYY-MM-DD format
|
|
146
|
+
* @property {string} currency
|
|
147
|
+
* @property {number} mrr MRR on this day
|
|
148
|
+
*/
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* @typedef {Object} MrrHistory
|
|
152
|
+
* @property {MrrRecord[]} data List of the total members by status for each day, including the paid deltas paid_subscribed and paid_canceled
|
|
153
|
+
* @property {Object} meta
|
|
154
|
+
*/
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
const db = require('../../data/db');
|
|
2
|
+
const MemberStatsService = require('./lib/members-stats-service');
|
|
3
|
+
const MrrStatsService = require('./lib/mrr-stats-service');
|
|
4
|
+
|
|
5
|
+
module.exports = {
|
|
6
|
+
members: new MemberStatsService({db}),
|
|
7
|
+
mrr: new MrrStatsService({db})
|
|
8
|
+
};
|
|
@@ -12,6 +12,7 @@ const {getConfig} = require('./config');
|
|
|
12
12
|
async function configureApi() {
|
|
13
13
|
const cfg = getConfig(settings, config, urlUtils);
|
|
14
14
|
if (cfg) {
|
|
15
|
+
cfg.testEnv = process.env.NODE_ENV.startsWith('test');
|
|
15
16
|
await module.exports.configure(cfg);
|
|
16
17
|
return true;
|
|
17
18
|
}
|
|
@@ -32,7 +32,9 @@ class WebhooksService {
|
|
|
32
32
|
const newWebhook = await this.WebhookModel.add(data.webhooks[0], options);
|
|
33
33
|
return newWebhook;
|
|
34
34
|
} catch (error) {
|
|
35
|
-
if (error.errno === 1452
|
|
35
|
+
if (error.errno === 1452
|
|
36
|
+
|| (error.code === 'SQLITE_CONSTRAINT' && /SQLITE_CONSTRAINT: FOREIGN KEY constraint failed/.test(error.message))
|
|
37
|
+
|| (error.code === 'SQLITE_CONSTRAINT_FOREIGNKEY')) {
|
|
36
38
|
throw new ValidationError({
|
|
37
39
|
message: tpl(messages.nonExistingIntegrationIdProvided.message, {
|
|
38
40
|
key: 'integration_id'
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
<title>Ghost Admin</title>
|
|
9
9
|
|
|
10
10
|
|
|
11
|
-
<meta name="ghost-admin/config/environment" content="%7B%22modulePrefix%22%3A%22ghost-admin%22%2C%22environment%22%3A%22production%22%2C%22rootURL%22%3A%22%2F%22%2C%22locationType%22%3A%22trailing-hash%22%2C%22EmberENV%22%3A%7B%22FEATURES%22%3A%7B%7D%2C%22EXTEND_PROTOTYPES%22%3A%7B%22Date%22%3Afalse%2C%22Array%22%3Atrue%2C%22String%22%3Atrue%2C%22Function%22%3Afalse%7D%2C%22_APPLICATION_TEMPLATE_WRAPPER%22%3Afalse%2C%22_JQUERY_INTEGRATION%22%3Atrue%2C%22_TEMPLATE_ONLY_GLIMMER_COMPONENTS%22%3Atrue%7D%2C%22APP%22%3A%7B%22version%22%3A%224.
|
|
11
|
+
<meta name="ghost-admin/config/environment" content="%7B%22modulePrefix%22%3A%22ghost-admin%22%2C%22environment%22%3A%22production%22%2C%22rootURL%22%3A%22%2F%22%2C%22locationType%22%3A%22trailing-hash%22%2C%22EmberENV%22%3A%7B%22FEATURES%22%3A%7B%7D%2C%22EXTEND_PROTOTYPES%22%3A%7B%22Date%22%3Afalse%2C%22Array%22%3Atrue%2C%22String%22%3Atrue%2C%22Function%22%3Afalse%7D%2C%22_APPLICATION_TEMPLATE_WRAPPER%22%3Afalse%2C%22_JQUERY_INTEGRATION%22%3Atrue%2C%22_TEMPLATE_ONLY_GLIMMER_COMPONENTS%22%3Atrue%7D%2C%22APP%22%3A%7B%22version%22%3A%224.43%22%2C%22name%22%3A%22ghost-admin%22%7D%2C%22ember-simple-auth%22%3A%7B%7D%2C%22moment%22%3A%7B%22includeTimezone%22%3A%22all%22%7D%2C%22%40sentry%2Fember%22%3A%7B%22disablePerformance%22%3Atrue%2C%22sentry%22%3A%7B%7D%7D%2C%22ember-cli-mirage%22%3A%7B%22usingProxy%22%3Afalse%2C%22useDefaultPassthroughs%22%3Atrue%7D%2C%22exportApplicationGlobal%22%3Afalse%2C%22ember-load%22%3A%7B%22loadingIndicatorClass%22%3A%22ember-load-indicator%22%7D%7D" />
|
|
12
12
|
|
|
13
13
|
<meta name="HandheldFriendly" content="True" />
|
|
14
14
|
<meta name="MobileOptimized" content="320" />
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
</style>
|
|
38
38
|
|
|
39
39
|
|
|
40
|
-
<link rel="stylesheet" href="assets/vendor.min-
|
|
41
|
-
<link rel="stylesheet" href="assets/ghost.min-
|
|
40
|
+
<link rel="stylesheet" href="assets/vendor.min-ba66b98f7c24fa40e061c7ffc94f4e23.css">
|
|
41
|
+
<link rel="stylesheet" href="assets/ghost.min-38f3c38c0c6a1864f57079b068a0b0ce.css" title="light">
|
|
42
42
|
|
|
43
43
|
|
|
44
44
|
|
|
@@ -56,8 +56,8 @@
|
|
|
56
56
|
<div id="ember-basic-dropdown-wormhole"></div>
|
|
57
57
|
|
|
58
58
|
|
|
59
|
-
<script src="assets/vendor.min-
|
|
60
|
-
<script src="assets/ghost.min-
|
|
59
|
+
<script src="assets/vendor.min-21f79c68a284acb1b70039f3f63e5507.js"></script>
|
|
60
|
+
<script src="assets/ghost.min-2a278873d60d6a13a4c05a396e5bed5e.js"></script>
|
|
61
61
|
|
|
62
62
|
</body>
|
|
63
63
|
</html>
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
<title>Ghost Admin</title>
|
|
9
9
|
|
|
10
10
|
|
|
11
|
-
<meta name="ghost-admin/config/environment" content="%7B%22modulePrefix%22%3A%22ghost-admin%22%2C%22environment%22%3A%22production%22%2C%22rootURL%22%3A%22%2F%22%2C%22locationType%22%3A%22trailing-hash%22%2C%22EmberENV%22%3A%7B%22FEATURES%22%3A%7B%7D%2C%22EXTEND_PROTOTYPES%22%3A%7B%22Date%22%3Afalse%2C%22Array%22%3Atrue%2C%22String%22%3Atrue%2C%22Function%22%3Afalse%7D%2C%22_APPLICATION_TEMPLATE_WRAPPER%22%3Afalse%2C%22_JQUERY_INTEGRATION%22%3Atrue%2C%22_TEMPLATE_ONLY_GLIMMER_COMPONENTS%22%3Atrue%7D%2C%22APP%22%3A%7B%22version%22%3A%224.
|
|
11
|
+
<meta name="ghost-admin/config/environment" content="%7B%22modulePrefix%22%3A%22ghost-admin%22%2C%22environment%22%3A%22production%22%2C%22rootURL%22%3A%22%2F%22%2C%22locationType%22%3A%22trailing-hash%22%2C%22EmberENV%22%3A%7B%22FEATURES%22%3A%7B%7D%2C%22EXTEND_PROTOTYPES%22%3A%7B%22Date%22%3Afalse%2C%22Array%22%3Atrue%2C%22String%22%3Atrue%2C%22Function%22%3Afalse%7D%2C%22_APPLICATION_TEMPLATE_WRAPPER%22%3Afalse%2C%22_JQUERY_INTEGRATION%22%3Atrue%2C%22_TEMPLATE_ONLY_GLIMMER_COMPONENTS%22%3Atrue%7D%2C%22APP%22%3A%7B%22version%22%3A%224.43%22%2C%22name%22%3A%22ghost-admin%22%7D%2C%22ember-simple-auth%22%3A%7B%7D%2C%22moment%22%3A%7B%22includeTimezone%22%3A%22all%22%7D%2C%22%40sentry%2Fember%22%3A%7B%22disablePerformance%22%3Atrue%2C%22sentry%22%3A%7B%7D%7D%2C%22ember-cli-mirage%22%3A%7B%22usingProxy%22%3Afalse%2C%22useDefaultPassthroughs%22%3Atrue%7D%2C%22exportApplicationGlobal%22%3Afalse%2C%22ember-load%22%3A%7B%22loadingIndicatorClass%22%3A%22ember-load-indicator%22%7D%7D" />
|
|
12
12
|
|
|
13
13
|
<meta name="HandheldFriendly" content="True" />
|
|
14
14
|
<meta name="MobileOptimized" content="320" />
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
</style>
|
|
38
38
|
|
|
39
39
|
|
|
40
|
-
<link rel="stylesheet" href="assets/vendor.min-
|
|
41
|
-
<link rel="stylesheet" href="assets/ghost.min-
|
|
40
|
+
<link rel="stylesheet" href="assets/vendor.min-ba66b98f7c24fa40e061c7ffc94f4e23.css">
|
|
41
|
+
<link rel="stylesheet" href="assets/ghost.min-38f3c38c0c6a1864f57079b068a0b0ce.css" title="light">
|
|
42
42
|
|
|
43
43
|
|
|
44
44
|
|
|
@@ -56,8 +56,8 @@
|
|
|
56
56
|
<div id="ember-basic-dropdown-wormhole"></div>
|
|
57
57
|
|
|
58
58
|
|
|
59
|
-
<script src="assets/vendor.min-
|
|
60
|
-
<script src="assets/ghost.min-
|
|
59
|
+
<script src="assets/vendor.min-21f79c68a284acb1b70039f3f63e5507.js"></script>
|
|
60
|
+
<script src="assets/ghost.min-2a278873d60d6a13a4c05a396e5bed5e.js"></script>
|
|
61
61
|
|
|
62
62
|
</body>
|
|
63
63
|
</html>
|
|
@@ -113,8 +113,6 @@ module.exports = function apiRoutes() {
|
|
|
113
113
|
|
|
114
114
|
router.get('/members/stats/count', mw.authAdminApi, http(api.members.memberStats));
|
|
115
115
|
router.get('/members/stats/mrr', mw.authAdminApi, http(api.members.mrrStats));
|
|
116
|
-
router.get('/members/stats/subscribers', mw.authAdminApi, http(api.members.subscriberStats));
|
|
117
|
-
router.get('/members/stats/gross_volume', mw.authAdminApi, http(api.members.grossVolumeStats));
|
|
118
116
|
|
|
119
117
|
router.get('/members/events', mw.authAdminApi, http(api.members.activityFeed));
|
|
120
118
|
|
|
@@ -139,6 +137,10 @@ module.exports = function apiRoutes() {
|
|
|
139
137
|
|
|
140
138
|
router.get('/members/:id/signin_urls', mw.authAdminApi, http(api.memberSigninUrls.read));
|
|
141
139
|
|
|
140
|
+
// ## Stats
|
|
141
|
+
router.get('/stats/member_count', mw.authAdminApi, http(api.stats.memberCountHistory));
|
|
142
|
+
router.get('/stats/mrr', mw.authAdminApi, http(api.stats.mrr));
|
|
143
|
+
|
|
142
144
|
// ## Labels
|
|
143
145
|
router.get('/labels', mw.authAdminApi, http(api.labels.browse));
|
|
144
146
|
router.get('/labels/:id', mw.authAdminApi, http(api.labels.read));
|
|
@@ -290,6 +292,7 @@ module.exports = function apiRoutes() {
|
|
|
290
292
|
router.get('/actions', mw.authAdminApi, http(api.actions.browse));
|
|
291
293
|
|
|
292
294
|
// ## Email Preview
|
|
295
|
+
// @TODO: rename to email_previews in 5.0
|
|
293
296
|
router.get('/email_preview/posts/:id', mw.authAdminApi, http(api.email_preview.read));
|
|
294
297
|
router.post('/email_preview/posts/:id', mw.authAdminApi, http(api.email_preview.sendTestEmail));
|
|
295
298
|
|
|
@@ -309,5 +312,9 @@ module.exports = function apiRoutes() {
|
|
|
309
312
|
router.get('/custom_theme_settings', mw.authAdminApi, http(api.customThemeSettings.browse));
|
|
310
313
|
router.put('/custom_theme_settings', mw.authAdminApi, http(api.customThemeSettings.edit));
|
|
311
314
|
|
|
315
|
+
router.get('/newsletters', mw.authAdminApi, http(api.newsletters.browse));
|
|
316
|
+
router.post('/newsletters', mw.authAdminApi, http(api.newsletters.add));
|
|
317
|
+
router.put('/newsletters/:id', mw.authAdminApi, http(api.newsletters.edit));
|
|
318
|
+
|
|
312
319
|
return router;
|
|
313
320
|
};
|
|
@@ -128,8 +128,8 @@
|
|
|
128
128
|
"emailAnalytics": true
|
|
129
129
|
},
|
|
130
130
|
"portal": {
|
|
131
|
-
"url": "https://unpkg.com/@tryghost/portal@~1.
|
|
132
|
-
"version": "1.
|
|
131
|
+
"url": "https://unpkg.com/@tryghost/portal@~1.19.0/umd/portal.min.js",
|
|
132
|
+
"version": "1.19"
|
|
133
133
|
},
|
|
134
134
|
"tenor": {
|
|
135
135
|
"publicReadOnlyApiKey": null,
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"url": "http://localhost:2368",
|
|
3
|
+
"database": {
|
|
4
|
+
"client": "sqlite3",
|
|
5
|
+
"connection": {
|
|
6
|
+
"filename": "content/data/ghost-dev.db"
|
|
7
|
+
},
|
|
8
|
+
"debug": false
|
|
9
|
+
},
|
|
10
|
+
"paths": {
|
|
11
|
+
"contentPath": "content/"
|
|
12
|
+
},
|
|
13
|
+
"privacy": {
|
|
14
|
+
"useRpcPing": false,
|
|
15
|
+
"useUpdateCheck": true
|
|
16
|
+
},
|
|
17
|
+
"useMinFiles": false,
|
|
18
|
+
"caching": {
|
|
19
|
+
"theme": {
|
|
20
|
+
"maxAge": 0
|
|
21
|
+
},
|
|
22
|
+
"admin": {
|
|
23
|
+
"maxAge": 0
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|