ghost 4.13.0 → 4.16.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/assets/built/screen.css +1 -1
- package/content/themes/casper/assets/built/screen.css.map +1 -1
- package/content/themes/casper/assets/css/screen.css +1 -1
- package/content/themes/casper/default.hbs +2 -2
- package/content/themes/casper/package.json +1 -1
- package/content/themes/casper/page.hbs +28 -26
- package/content/themes/casper/partials/post-card.hbs +2 -2
- package/content/themes/casper/post.hbs +67 -65
- package/content/themes/casper/tag.hbs +2 -2
- package/core/built/assets/{chunk.3.f80c7fbb7573ce508a05.js → chunk.3.4b1d9e20e57164ac9c29.js} +31 -29
- package/core/built/assets/ghost-dark-bb2831fc27fcb02893ed0a761207dc63.css +1 -0
- package/core/built/assets/{ghost.min-ba7f03a78d7d98444af386b8ae9347a7.js → ghost.min-d1d99f3ed6e0f427874b2a11e7078475.js} +1777 -1685
- package/core/built/assets/ghost.min-e7612edfa72b0fe2c201b387923e6fc7.css +1 -0
- package/core/built/assets/icons/check-2.svg +1 -0
- package/core/built/assets/icons/discount-bubble.svg +1 -0
- package/core/built/assets/icons/no-data-line-chart.svg +1 -0
- package/core/built/assets/icons/no-data-list.svg +9 -8
- package/core/built/assets/icons/no-data-subscription.svg +1 -0
- package/core/built/assets/{vendor.min-29784d514390cb5abc74ae660cb2fbc7.js → vendor.min-3660ec7864887f1496fe7a27fd23ab76.js} +1570 -1289
- package/core/frontend/helpers/ghost_head.js +7 -1
- package/core/frontend/helpers/match.js +19 -4
- package/core/frontend/helpers/products.js +68 -0
- package/core/frontend/helpers/tpl/content-cta.hbs +1 -1
- package/core/frontend/services/routing/controllers/email-post.js +3 -2
- package/core/frontend/services/settings/loader.js +2 -2
- package/core/frontend/services/sitemap/base-generator.js +12 -8
- package/core/frontend/services/sitemap/manager.js +1 -1
- package/core/frontend/services/theme-engine/handlebars/helpers.js +1 -0
- package/core/frontend/services/theme-engine/middleware.js +4 -1
- package/core/server/api/canary/email-preview.js +15 -33
- package/core/server/api/canary/integrations.js +7 -30
- package/core/server/api/canary/labels.js +8 -9
- package/core/server/api/canary/members.js +13 -9
- package/core/server/api/canary/schedules.js +9 -57
- package/core/server/api/canary/settings.js +20 -158
- package/core/server/api/canary/themes.js +5 -59
- package/core/server/api/canary/utils/serializers/output/members.js +2 -14
- package/core/server/api/canary/utils/validators/input/settings.js +23 -1
- package/core/server/api/canary/webhooks.js +6 -24
- package/core/server/api/v2/schedules.js +9 -57
- package/core/server/api/v3/email-preview.js +15 -28
- package/core/server/api/v3/integrations.js +7 -30
- package/core/server/api/v3/labels.js +8 -9
- package/core/server/api/v3/members.js +4 -1
- package/core/server/api/v3/schedules.js +9 -57
- package/core/server/api/v3/settings.js +13 -132
- package/core/server/api/v3/utils/validators/input/settings.js +23 -1
- package/core/server/api/v3/webhooks.js +6 -28
- package/core/server/data/exporter/table-lists.js +1 -0
- package/core/server/data/importer/import-manager.js +398 -0
- package/core/server/data/importer/importers/data/data-importer.js +162 -0
- package/core/server/data/importer/importers/data/index.js +1 -162
- package/core/server/data/importer/index.js +1 -379
- package/core/server/data/migrations/versions/4.14/01-fix-comped-member-statuses.js +70 -0
- package/core/server/data/migrations/versions/4.14/02-fix-free-members-status-events.js +60 -0
- package/core/server/data/migrations/versions/4.15/01-add-temp-members-analytic-events-table.js +12 -0
- package/core/server/data/migrations/versions/4.16/01-add-custom-theme-settings-table.js +9 -0
- package/core/server/data/schema/fixtures/utils.js +6 -1
- package/core/server/data/schema/schema.js +26 -0
- package/core/server/lib/request-external.js +3 -2
- package/core/server/models/action.js +1 -1
- package/core/server/models/api-key.js +1 -1
- package/core/server/models/base/bookshelf.js +0 -3
- package/core/server/models/base/index.js +2 -0
- package/core/server/models/base/plugins/events.js +2 -2
- package/core/server/models/base/plugins/raw-knex.js +10 -10
- package/core/server/models/custom-theme-setting.js +9 -0
- package/core/server/models/email.js +2 -2
- package/core/server/models/index.js +2 -0
- package/core/server/models/integration.js +1 -1
- package/core/server/models/label.js +2 -2
- package/core/server/models/member-analytic-event.js +9 -0
- package/core/server/models/member.js +2 -2
- package/core/server/models/post.js +2 -2
- package/core/server/models/settings.js +2 -2
- package/core/server/models/tag.js +2 -2
- package/core/server/models/user.js +2 -2
- package/core/server/models/webhook.js +2 -2
- package/core/server/services/bulk-email/bulk-email-processor.js +1 -4
- package/core/server/services/custom-theme-settings.js +8 -0
- package/core/server/services/integrations/integrations-service.js +61 -0
- package/core/server/services/mail/GhostMailer.js +29 -37
- package/core/server/services/mega/email-preview.js +41 -0
- package/core/server/services/mega/index.js +4 -0
- package/core/server/services/mega/mega.js +27 -23
- package/core/server/services/mega/post-email-serializer.js +28 -21
- package/core/server/services/mega/template.js +11 -0
- package/core/server/services/members/api.js +1 -0
- package/core/server/services/members/emails/signup.js +1 -1
- package/core/server/services/oembed.js +7 -2
- package/core/server/services/posts/post-scheduling-service.js +100 -0
- package/core/server/services/settings/index.js +13 -16
- package/core/server/services/settings/settings-bread-service.js +188 -0
- package/core/server/services/settings/settings-utils.js +32 -0
- package/core/server/services/themes/ThemeStorage.js +5 -4
- package/core/server/services/themes/activation-bridge.js +14 -0
- package/core/server/services/themes/index.js +2 -0
- package/core/server/services/themes/installer.js +72 -0
- package/core/server/services/themes/validate.js +5 -2
- package/core/server/services/webhooks/webhooks-service.js +55 -0
- package/core/server/web/admin/views/default-prod.html +4 -4
- package/core/server/web/admin/views/default.html +4 -4
- package/core/server/web/members/app.js +3 -0
- package/core/shared/config/defaults.json +2 -2
- package/core/shared/custom-theme-settings-cache.js +3 -0
- package/core/shared/i18n/translations/en.json +1 -6
- package/core/shared/labs.js +5 -7
- package/package.json +64 -62
- package/yarn.lock +1490 -1055
- package/core/built/assets/ghost-dark-98d56e4973a502750748090f9dbc8280.css +0 -1
- package/core/built/assets/ghost.min-6932a664a1cb92a8e4a15f540cae3ad8.css +0 -1
- package/core/server/services/mega/template-labs.js +0 -1024
|
@@ -17,7 +17,6 @@ const db = require('../../data/db');
|
|
|
17
17
|
const models = require('../../models');
|
|
18
18
|
const postEmailSerializer = require('./post-email-serializer');
|
|
19
19
|
const {getSegmentsFromHtml} = require('./segment-parser');
|
|
20
|
-
const labs = require('../../../shared/labs');
|
|
21
20
|
|
|
22
21
|
// Used to listen to email.added and email.edited model events originally, I think to offload this - ideally would just use jobs now if possible
|
|
23
22
|
const events = require('../../lib/common/events');
|
|
@@ -74,16 +73,15 @@ const getEmailData = async (postModel, options) => {
|
|
|
74
73
|
* @param {Object} postModel - post model instance
|
|
75
74
|
* @param {[string]} toEmails - member email addresses to send email to
|
|
76
75
|
* @param {ValidAPIVersion} apiVersion - api version to be used when serializing email data
|
|
77
|
-
* @param {ValidMemberSegment} memberSegment
|
|
76
|
+
* @param {ValidMemberSegment} [memberSegment]
|
|
78
77
|
*/
|
|
79
78
|
const sendTestEmail = async (postModel, toEmails, apiVersion, memberSegment) => {
|
|
80
79
|
let emailData = await getEmailData(postModel, {apiVersion});
|
|
81
80
|
emailData.subject = `[Test] ${emailData.subject}`;
|
|
82
81
|
|
|
83
|
-
if (
|
|
82
|
+
if (memberSegment) {
|
|
84
83
|
emailData = postEmailSerializer.renderEmailForSegment(emailData, memberSegment);
|
|
85
84
|
}
|
|
86
|
-
|
|
87
85
|
// fetch any matching members so that replacements use expected values
|
|
88
86
|
const recipients = await Promise.all(toEmails.map(async (email) => {
|
|
89
87
|
const member = await membersService.api.members.get({email});
|
|
@@ -109,6 +107,14 @@ const sendTestEmail = async (postModel, toEmails, apiVersion, memberSegment) =>
|
|
|
109
107
|
return Promise.reject(response.error);
|
|
110
108
|
}
|
|
111
109
|
|
|
110
|
+
if (response && response[0] && response[0].error) {
|
|
111
|
+
return Promise.reject(new errors.EmailError({
|
|
112
|
+
statusCode: response[0].error.statusCode,
|
|
113
|
+
message: response[0].error.message,
|
|
114
|
+
context: response[0].error.originalMessage
|
|
115
|
+
}));
|
|
116
|
+
}
|
|
117
|
+
|
|
112
118
|
return response;
|
|
113
119
|
};
|
|
114
120
|
|
|
@@ -437,28 +443,26 @@ async function createSegmentedEmailBatches({emailModel, options}) {
|
|
|
437
443
|
return [];
|
|
438
444
|
}
|
|
439
445
|
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
batchIds.push(emailBatchIds);
|
|
455
|
-
}
|
|
456
|
-
return batchIds;
|
|
446
|
+
const segments = getSegmentsFromHtml(emailModel.get('html'));
|
|
447
|
+
const batchIds = [];
|
|
448
|
+
|
|
449
|
+
if (segments.length) {
|
|
450
|
+
const partitionedMembers = partitionMembersBySegment(memberRows, segments);
|
|
451
|
+
|
|
452
|
+
for (const partition in partitionedMembers) {
|
|
453
|
+
const emailBatchIds = await createEmailBatches({
|
|
454
|
+
emailModel,
|
|
455
|
+
memberRows: partitionedMembers[partition],
|
|
456
|
+
memberSegment: partition === 'unsegmented' ? null : partition,
|
|
457
|
+
options
|
|
458
|
+
});
|
|
459
|
+
batchIds.push(emailBatchIds);
|
|
457
460
|
}
|
|
461
|
+
} else {
|
|
462
|
+
const emailBatchIds = await createEmailBatches({emailModel, memberRows, options});
|
|
463
|
+
batchIds.push(emailBatchIds);
|
|
458
464
|
}
|
|
459
465
|
|
|
460
|
-
const emailBatchIds = await createEmailBatches({emailModel, memberRows, options});
|
|
461
|
-
const batchIds = [emailBatchIds];
|
|
462
466
|
return batchIds;
|
|
463
467
|
}
|
|
464
468
|
|
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
const _ = require('lodash');
|
|
2
2
|
const juice = require('juice');
|
|
3
3
|
const template = require('./template');
|
|
4
|
-
const labsTemplate = require('./template-labs');
|
|
5
4
|
const settingsCache = require('../../../shared/settings-cache');
|
|
6
5
|
const urlUtils = require('../../../shared/url-utils');
|
|
7
|
-
const labs = require('../../../shared/labs');
|
|
8
6
|
const moment = require('moment-timezone');
|
|
9
7
|
const cheerio = require('cheerio');
|
|
10
8
|
const api = require('../../api');
|
|
@@ -17,6 +15,30 @@ const logging = require('@tryghost/logging');
|
|
|
17
15
|
|
|
18
16
|
const ALLOWED_REPLACEMENTS = ['first_name'];
|
|
19
17
|
|
|
18
|
+
// Format a full html document ready for email by inlining CSS, adjusting links,
|
|
19
|
+
// and performing any client-specific fixes
|
|
20
|
+
const formatHtmlForEmail = function formatHtmlForEmail(html) {
|
|
21
|
+
const juiceOptions = {inlinePseudoElements: true};
|
|
22
|
+
|
|
23
|
+
let juicedHtml = juice(html, juiceOptions);
|
|
24
|
+
|
|
25
|
+
// convert juiced HTML to a DOM-like interface for further manipulation
|
|
26
|
+
// happens after inlining of CSS so we can change element types without worrying about styling
|
|
27
|
+
const _cheerio = cheerio.load(juicedHtml);
|
|
28
|
+
|
|
29
|
+
// force all links to open in new tab
|
|
30
|
+
_cheerio('a').attr('target', '_blank');
|
|
31
|
+
// convert figure and figcaption to div so that Outlook applies margins
|
|
32
|
+
_cheerio('figure, figcaption').each((i, elem) => !!(elem.tagName = 'div'));
|
|
33
|
+
|
|
34
|
+
juicedHtml = _cheerio.html();
|
|
35
|
+
|
|
36
|
+
// Fix any unsupported chars in Outlook
|
|
37
|
+
juicedHtml = juicedHtml.replace(/'/g, ''');
|
|
38
|
+
|
|
39
|
+
return juicedHtml;
|
|
40
|
+
};
|
|
41
|
+
|
|
20
42
|
const getSite = () => {
|
|
21
43
|
const publicSettings = settingsCache.getPublic();
|
|
22
44
|
return Object.assign({}, publicSettings, {
|
|
@@ -266,7 +288,7 @@ const serialize = async (postModel, options = {isBrowserPreview: false, apiVersi
|
|
|
266
288
|
|
|
267
289
|
const templateSettings = await getTemplateSettings();
|
|
268
290
|
|
|
269
|
-
const render =
|
|
291
|
+
const render = template;
|
|
270
292
|
|
|
271
293
|
let htmlTemplate = render({post, site: getSite(), templateSettings});
|
|
272
294
|
|
|
@@ -275,25 +297,9 @@ const serialize = async (postModel, options = {isBrowserPreview: false, apiVersi
|
|
|
275
297
|
htmlTemplate = htmlTemplate.replace('%recipient.unsubscribe_url%', previewUnsubscribeUrl);
|
|
276
298
|
}
|
|
277
299
|
|
|
278
|
-
// Inline css to style attributes, turn on support for pseudo classes.
|
|
279
|
-
const juiceOptions = {inlinePseudoElements: true};
|
|
280
|
-
let juicedHtml = juice(htmlTemplate, juiceOptions);
|
|
281
|
-
|
|
282
|
-
// convert juiced HTML to a DOM-like interface for further manipulation
|
|
283
|
-
// happens after inlining of CSS so we can change element types without worrying about styling
|
|
284
|
-
_cheerio = cheerio.load(juicedHtml);
|
|
285
|
-
// force all links to open in new tab
|
|
286
|
-
_cheerio('a').attr('target','_blank');
|
|
287
|
-
// convert figure and figcaption to div so that Outlook applies margins
|
|
288
|
-
_cheerio('figure, figcaption').each((i, elem) => !!(elem.tagName = 'div'));
|
|
289
|
-
juicedHtml = _cheerio.html();
|
|
290
|
-
|
|
291
|
-
// Fix any unsupported chars in Outlook
|
|
292
|
-
juicedHtml = juicedHtml.replace(/'/g, ''');
|
|
293
|
-
|
|
294
300
|
// Clean up any unknown replacements strings to get our final content
|
|
295
301
|
const {html, plaintext} = normalizeReplacementStrings({
|
|
296
|
-
html:
|
|
302
|
+
html: formatHtmlForEmail(htmlTemplate),
|
|
297
303
|
plaintext: post.plaintext
|
|
298
304
|
});
|
|
299
305
|
|
|
@@ -316,7 +322,8 @@ function renderEmailForSegment(email, memberSegment) {
|
|
|
316
322
|
$(node).removeAttr('data-gh-segment');
|
|
317
323
|
}
|
|
318
324
|
});
|
|
319
|
-
|
|
325
|
+
|
|
326
|
+
result.html = formatHtmlForEmail($.html());
|
|
320
327
|
result.plaintext = htmlToPlaintext(result.html);
|
|
321
328
|
|
|
322
329
|
return result;
|
|
@@ -576,6 +576,7 @@ figure blockquote p {
|
|
|
576
576
|
.btn {
|
|
577
577
|
box-sizing: border-box;
|
|
578
578
|
width: 100%;
|
|
579
|
+
display: table;
|
|
579
580
|
}
|
|
580
581
|
|
|
581
582
|
.btn>tbody>tr>td {
|
|
@@ -618,6 +619,16 @@ figure blockquote p {
|
|
|
618
619
|
color: #ffffff;
|
|
619
620
|
}
|
|
620
621
|
|
|
622
|
+
.btn-accent table td {
|
|
623
|
+
background-color: ${templateSettings.adjustedAccentColor || '#3498db'};
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
.btn-accent a {
|
|
627
|
+
background-color: ${templateSettings.adjustedAccentColor || '#3498db'};
|
|
628
|
+
border-color: ${templateSettings.adjustedAccentColor || '#3498db'};
|
|
629
|
+
color: ${templateSettings.adjustedAccentContrastColor || '#ffffff'};
|
|
630
|
+
}
|
|
631
|
+
|
|
621
632
|
/* -------------------------------------
|
|
622
633
|
OTHER STYLES THAT MIGHT BE USEFUL
|
|
623
634
|
------------------------------------- */
|
|
@@ -176,6 +176,7 @@ function createApiInstance(config) {
|
|
|
176
176
|
MemberPaymentEvent: models.MemberPaymentEvent,
|
|
177
177
|
MemberStatusEvent: models.MemberStatusEvent,
|
|
178
178
|
MemberProductEvent: models.MemberProductEvent,
|
|
179
|
+
MemberAnalyticEvent: models.MemberAnalyticEvent,
|
|
179
180
|
StripeProduct: models.StripeProduct,
|
|
180
181
|
StripePrice: models.StripePrice,
|
|
181
182
|
Product: models.Product,
|
|
@@ -125,7 +125,7 @@ module.exports = ({siteTitle, email, url, accentColor = '#15212A', siteDomain, s
|
|
|
125
125
|
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: auto;">
|
|
126
126
|
<tbody>
|
|
127
127
|
<tr>
|
|
128
|
-
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; vertical-align: top; background-color: ${accentColor}; border-radius: 5px; text-align: center;"> <a href="${url}" target="_blank" style="display: inline-block; color: #ffffff; background-color: ${accentColor}; border: solid 1px ${accentColor}; border-radius: 5px; box-sizing: border-box; cursor: pointer; text-decoration: none; font-size: 16px; font-weight: normal; margin: 0; padding: 9px 22px 10px; border-color: ${accentColor};">
|
|
128
|
+
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; vertical-align: top; background-color: ${accentColor}; border-radius: 5px; text-align: center;"> <a href="${url}" target="_blank" style="display: inline-block; color: #ffffff; background-color: ${accentColor}; border: solid 1px ${accentColor}; border-radius: 5px; box-sizing: border-box; cursor: pointer; text-decoration: none; font-size: 16px; font-weight: normal; margin: 0; padding: 9px 22px 10px; border-color: ${accentColor};">Confirm signup</a> </td>
|
|
129
129
|
</tr>
|
|
130
130
|
</tbody>
|
|
131
131
|
</table>
|
|
@@ -101,8 +101,13 @@ class OEmbed {
|
|
|
101
101
|
try {
|
|
102
102
|
const cookieJar = new CookieJar();
|
|
103
103
|
const response = await this.externalRequest(url, {cookieJar});
|
|
104
|
-
|
|
105
|
-
|
|
104
|
+
|
|
105
|
+
if (this.isIpOrLocalhost(response.url)) {
|
|
106
|
+
scraperResponse = {};
|
|
107
|
+
} else {
|
|
108
|
+
const html = response.body;
|
|
109
|
+
scraperResponse = await metascraper({html, url});
|
|
110
|
+
}
|
|
106
111
|
} catch (err) {
|
|
107
112
|
return Promise.reject(err);
|
|
108
113
|
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
const _ = require('lodash');
|
|
2
|
+
const errors = require('@tryghost/errors');
|
|
3
|
+
const moment = require('moment');
|
|
4
|
+
const config = require('../../../shared/config');
|
|
5
|
+
const urlUtils = require('../../../shared/url-utils');
|
|
6
|
+
const api = require('../../api');
|
|
7
|
+
|
|
8
|
+
const messages = {
|
|
9
|
+
jobNotFound: 'Job not found.',
|
|
10
|
+
jobPublishInThePast: 'Use the force flag to publish a post in the past.'
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
class PostSchedulingService {
|
|
14
|
+
/**
|
|
15
|
+
*
|
|
16
|
+
* @param {Object} options
|
|
17
|
+
* @param {String} options.apiVersion - api version
|
|
18
|
+
*/
|
|
19
|
+
constructor({apiVersion}) {
|
|
20
|
+
this.api = api[apiVersion];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Publishes scheduled resource (a post or a page at the moment of writing)
|
|
25
|
+
*
|
|
26
|
+
* @param {String} resourceType one of 'post' or 'page' resources
|
|
27
|
+
* @param {String} id resource id
|
|
28
|
+
* @param {Boolean} force force publish flag
|
|
29
|
+
* @param {Object} options api query options
|
|
30
|
+
* @returns {Promise<Object, Object>}
|
|
31
|
+
*/
|
|
32
|
+
async publish(resourceType, id, force, options) {
|
|
33
|
+
const publishAPostBySchedulerToleranceInMinutes = config.get('times').publishAPostBySchedulerToleranceInMinutes;
|
|
34
|
+
|
|
35
|
+
const result = await this.api[resourceType].read({id}, options);
|
|
36
|
+
const preScheduledResource = result[resourceType][0];
|
|
37
|
+
|
|
38
|
+
const publishedAtMoment = moment(preScheduledResource.published_at);
|
|
39
|
+
|
|
40
|
+
if (publishedAtMoment.diff(moment(), 'minutes') > publishAPostBySchedulerToleranceInMinutes) {
|
|
41
|
+
return Promise.reject(new errors.NotFoundError({message: messages.jobNotFound}));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (publishedAtMoment.diff(moment(), 'minutes') < publishAPostBySchedulerToleranceInMinutes * -1 && force !== true) {
|
|
45
|
+
return Promise.reject(new errors.NotFoundError({message: messages.jobPublishInThePast}));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const editedResource = {};
|
|
49
|
+
editedResource[resourceType] = [{
|
|
50
|
+
status: 'published',
|
|
51
|
+
updated_at: moment(preScheduledResource.updated_at).toISOString(true)
|
|
52
|
+
}];
|
|
53
|
+
|
|
54
|
+
const editResult = await this.api[resourceType].edit(
|
|
55
|
+
editedResource,
|
|
56
|
+
_.pick(options, ['context', 'id', 'transacting', 'forUpdate'])
|
|
57
|
+
);
|
|
58
|
+
const scheduledResource = editResult[resourceType][0];
|
|
59
|
+
|
|
60
|
+
return {scheduledResource, preScheduledResource};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
*
|
|
65
|
+
* @param {Object} scheduledResource post or page resource object
|
|
66
|
+
* @param {Object} preScheduledResource post or page resource object in state before publishing
|
|
67
|
+
* @returns {Boolean|Object}
|
|
68
|
+
*/
|
|
69
|
+
handleCacheInvalidation(scheduledResource, preScheduledResource) {
|
|
70
|
+
if (
|
|
71
|
+
(scheduledResource.status === 'published' && preScheduledResource.status !== 'published') ||
|
|
72
|
+
(scheduledResource.status === 'draft' && preScheduledResource.status === 'published')
|
|
73
|
+
) {
|
|
74
|
+
return true;
|
|
75
|
+
} else if (
|
|
76
|
+
(scheduledResource.status === 'draft' && preScheduledResource.status !== 'published') ||
|
|
77
|
+
(scheduledResource.status === 'scheduled' && preScheduledResource.status !== 'scheduled')
|
|
78
|
+
) {
|
|
79
|
+
return {
|
|
80
|
+
value: urlUtils.urlFor({
|
|
81
|
+
relativeUrl: urlUtils.urlJoin('/p', scheduledResource.uuid, '/')
|
|
82
|
+
})
|
|
83
|
+
};
|
|
84
|
+
} else {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* @param {string} apiVersion - API version to use within the service
|
|
92
|
+
* @returns {PostSchedulingService} instance of the PostsService
|
|
93
|
+
*/
|
|
94
|
+
const getPostSchedulingServiceInstance = (apiVersion) => {
|
|
95
|
+
return new PostSchedulingService({
|
|
96
|
+
apiVersion: apiVersion
|
|
97
|
+
});
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
module.exports = getPostSchedulingServiceInstance;
|
|
@@ -5,22 +5,18 @@
|
|
|
5
5
|
const events = require('../../lib/common/events');
|
|
6
6
|
const models = require('../../models');
|
|
7
7
|
const SettingsCache = require('../../../shared/settings-cache');
|
|
8
|
+
const SettingsBREADService = require('./settings-bread-service');
|
|
9
|
+
const {obfuscatedSetting, isSecretSetting, hideValueIfSecret} = require('./settings-utils');
|
|
8
10
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
function hideValueIfSecret(setting) {
|
|
19
|
-
if (setting.value && isSecretSetting(setting)) {
|
|
20
|
-
return {...setting, value: obfuscatedSetting};
|
|
21
|
-
}
|
|
22
|
-
return setting;
|
|
23
|
-
}
|
|
11
|
+
/**
|
|
12
|
+
* @returns {SettingsBREADService} instance of the PostsService
|
|
13
|
+
*/
|
|
14
|
+
const getSettingsBREADServiceInstance = () => {
|
|
15
|
+
return new SettingsBREADService({
|
|
16
|
+
SettingsModel: models.Settings,
|
|
17
|
+
settingsCache: SettingsCache
|
|
18
|
+
});
|
|
19
|
+
};
|
|
24
20
|
|
|
25
21
|
module.exports = {
|
|
26
22
|
/**
|
|
@@ -74,5 +70,6 @@ module.exports = {
|
|
|
74
70
|
|
|
75
71
|
obfuscatedSetting,
|
|
76
72
|
isSecretSetting,
|
|
77
|
-
hideValueIfSecret
|
|
73
|
+
hideValueIfSecret,
|
|
74
|
+
getSettingsBREADServiceInstance
|
|
78
75
|
};
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
const _ = require('lodash');
|
|
2
|
+
const tpl = require('@tryghost/tpl');
|
|
3
|
+
const {NotFoundError, NoPermissionError, BadRequestError} = require('@tryghost/errors');
|
|
4
|
+
const {obfuscatedSetting, isSecretSetting, hideValueIfSecret} = require('./settings-utils');
|
|
5
|
+
|
|
6
|
+
const messages = {
|
|
7
|
+
problemFindingSetting: 'Problem finding setting: {key}',
|
|
8
|
+
accessCoreSettingFromExtReq: 'Attempted to access core setting from external request'
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
class SettingsBREADService {
|
|
12
|
+
/**
|
|
13
|
+
*
|
|
14
|
+
* @param {Object} options
|
|
15
|
+
* @param {Object} options.SettingsModel
|
|
16
|
+
* @param {Object} options.settingsCache - SettingsCache instance
|
|
17
|
+
*/
|
|
18
|
+
constructor({SettingsModel, settingsCache}) {
|
|
19
|
+
this.SettingsModel = SettingsModel;
|
|
20
|
+
this.settingsCache = settingsCache;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
*
|
|
25
|
+
* @param {Object} context ghost API context instance
|
|
26
|
+
* @returns
|
|
27
|
+
*/
|
|
28
|
+
browse(context) {
|
|
29
|
+
let settings = this.settingsCache.getAll();
|
|
30
|
+
|
|
31
|
+
// CASE: no context passed (functional call)
|
|
32
|
+
if (!context) {
|
|
33
|
+
return Promise.resolve(settings.filter((setting) => {
|
|
34
|
+
return setting.group === 'site';
|
|
35
|
+
}));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (!context.internal) {
|
|
39
|
+
// CASE: omit core settings unless internal request
|
|
40
|
+
settings = _.filter(settings, (setting) => {
|
|
41
|
+
const isCore = setting.group === 'core';
|
|
42
|
+
return !isCore;
|
|
43
|
+
});
|
|
44
|
+
// CASE: omit secret settings unless internal request
|
|
45
|
+
settings = settings.map(hideValueIfSecret);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return settings;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
*
|
|
53
|
+
* @param {String} key setting key
|
|
54
|
+
* @param {Object} [context] API context instance
|
|
55
|
+
* @returns {Object} an object with a filled out key that comes in a parameter
|
|
56
|
+
*/
|
|
57
|
+
read(key, context) {
|
|
58
|
+
let setting;
|
|
59
|
+
|
|
60
|
+
if (key === 'slack') {
|
|
61
|
+
const slackURL = this.settingsCache.get('slack_url', {resolve: false});
|
|
62
|
+
const slackUsername = this.settingsCache.get('slack_username', {resolve: false});
|
|
63
|
+
|
|
64
|
+
setting = slackURL || slackUsername;
|
|
65
|
+
setting.key = 'slack';
|
|
66
|
+
setting.value = [{
|
|
67
|
+
url: slackURL && slackURL.value,
|
|
68
|
+
username: slackUsername && slackUsername.value
|
|
69
|
+
}];
|
|
70
|
+
} else {
|
|
71
|
+
setting = this.settingsCache.get(key, {resolve: false});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (!setting) {
|
|
75
|
+
return Promise.reject(new NotFoundError({
|
|
76
|
+
message: tpl(messages.problemFindingSetting, {
|
|
77
|
+
key: key
|
|
78
|
+
})
|
|
79
|
+
}));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// @TODO: handle in settings model permissible fn
|
|
83
|
+
if (setting.group === 'core' && !(context && context.internal)) {
|
|
84
|
+
return Promise.reject(new NoPermissionError({
|
|
85
|
+
message: tpl(messages.accessCoreSettingFromExtReq)
|
|
86
|
+
}));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
setting = hideValueIfSecret(setting);
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
[key]: setting
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
*
|
|
98
|
+
* @param {Object[]} settings
|
|
99
|
+
* @param {Object} options
|
|
100
|
+
* @param {Object} [options.context]
|
|
101
|
+
* @param {Object} [stripeConnectData]
|
|
102
|
+
* @returns
|
|
103
|
+
*/
|
|
104
|
+
async edit(settings, options, stripeConnectData) {
|
|
105
|
+
const filteredSettings = settings.filter((setting) => {
|
|
106
|
+
// The `stripe_connect_integration_token` "setting" is only used to set the `stripe_connect_*` settings.
|
|
107
|
+
return ![
|
|
108
|
+
'stripe_connect_integration_token',
|
|
109
|
+
'stripe_connect_publishable_key',
|
|
110
|
+
'stripe_connect_secret_key',
|
|
111
|
+
'stripe_connect_livemode',
|
|
112
|
+
'stripe_connect_account_id',
|
|
113
|
+
'stripe_connect_display_name'
|
|
114
|
+
].includes(setting.key)
|
|
115
|
+
// Remove obfuscated settings
|
|
116
|
+
&& !(setting.value === obfuscatedSetting && isSecretSetting(setting));
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const getSetting = setting => this.settingsCache.get(setting.key, {resolve: false});
|
|
120
|
+
|
|
121
|
+
const firstUnknownSetting = filteredSettings.find(setting => !getSetting(setting));
|
|
122
|
+
|
|
123
|
+
if (firstUnknownSetting) {
|
|
124
|
+
throw new NotFoundError({
|
|
125
|
+
message: tpl(messages.problemFindingSetting, {
|
|
126
|
+
key: firstUnknownSetting.key
|
|
127
|
+
})
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (!(options.context && options.context.internal)) {
|
|
132
|
+
const firstCoreSetting = filteredSettings.find(setting => getSetting(setting).group === 'core');
|
|
133
|
+
|
|
134
|
+
if (firstCoreSetting) {
|
|
135
|
+
throw new NoPermissionError({
|
|
136
|
+
message: tpl(messages.accessCoreSettingFromExtReq)
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (stripeConnectData) {
|
|
142
|
+
filteredSettings.push({
|
|
143
|
+
key: 'stripe_connect_publishable_key',
|
|
144
|
+
value: stripeConnectData.public_key
|
|
145
|
+
});
|
|
146
|
+
filteredSettings.push({
|
|
147
|
+
key: 'stripe_connect_secret_key',
|
|
148
|
+
value: stripeConnectData.secret_key
|
|
149
|
+
});
|
|
150
|
+
filteredSettings.push({
|
|
151
|
+
key: 'stripe_connect_livemode',
|
|
152
|
+
value: stripeConnectData.livemode
|
|
153
|
+
});
|
|
154
|
+
filteredSettings.push({
|
|
155
|
+
key: 'stripe_connect_display_name',
|
|
156
|
+
value: stripeConnectData.display_name
|
|
157
|
+
});
|
|
158
|
+
filteredSettings.push({
|
|
159
|
+
key: 'stripe_connect_account_id',
|
|
160
|
+
value: stripeConnectData.account_id
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return this.SettingsModel.edit(filteredSettings, options);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
*
|
|
169
|
+
* @param {Object} stripeConnectIntegrationToken
|
|
170
|
+
* @param {Function} getSessionProp sync function fetching property from session store
|
|
171
|
+
* @param {Function} getStripeConnectTokenData async function retreiving Stripe Connect data for settings
|
|
172
|
+
* @returns {Promise<Object>} resolves with an object with following keys: public_key, secret_key, livemode, display_name, account_id
|
|
173
|
+
*/
|
|
174
|
+
async getStripeConnectData(stripeConnectIntegrationToken, getSessionProp, getStripeConnectTokenData) {
|
|
175
|
+
if (stripeConnectIntegrationToken && stripeConnectIntegrationToken.value) {
|
|
176
|
+
try {
|
|
177
|
+
return await getStripeConnectTokenData(stripeConnectIntegrationToken.value, getSessionProp);
|
|
178
|
+
} catch (err) {
|
|
179
|
+
throw new BadRequestError({
|
|
180
|
+
err,
|
|
181
|
+
message: 'The Stripe Connect token could not be parsed.'
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
module.exports = SettingsBREADService;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// The string returned when a setting is set as write-only
|
|
2
|
+
const obfuscatedSetting = '••••••••';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @description // The function used to decide whether a setting is write-only
|
|
6
|
+
* @param {Object} setting setting record
|
|
7
|
+
* @param {String} setting.key
|
|
8
|
+
* @returns {Boolean}
|
|
9
|
+
*/
|
|
10
|
+
function isSecretSetting(setting) {
|
|
11
|
+
return /secret/.test(setting.key);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @description The function that obfuscates a write-only setting
|
|
16
|
+
* @param {Object} setting setting record
|
|
17
|
+
* @param {String} setting.value
|
|
18
|
+
* @param {String} setting.key
|
|
19
|
+
* @returns {Object} settings record with obfuscated value if it's a secret
|
|
20
|
+
*/
|
|
21
|
+
function hideValueIfSecret(setting) {
|
|
22
|
+
if (setting.value && isSecretSetting(setting)) {
|
|
23
|
+
return {...setting, value: obfuscatedSetting};
|
|
24
|
+
}
|
|
25
|
+
return setting;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
module.exports = {
|
|
29
|
+
obfuscatedSetting,
|
|
30
|
+
isSecretSetting,
|
|
31
|
+
hideValueIfSecret
|
|
32
|
+
};
|
|
@@ -60,8 +60,8 @@ class ThemeStorage extends LocalFileStorage {
|
|
|
60
60
|
/**
|
|
61
61
|
* Rename a file / folder
|
|
62
62
|
*
|
|
63
|
-
*
|
|
64
|
-
* @param String
|
|
63
|
+
* @param {String} srcName
|
|
64
|
+
* @param {String} destName
|
|
65
65
|
*/
|
|
66
66
|
rename(srcName, destName) {
|
|
67
67
|
let src = path.join(this.getTargetDir(), srcName);
|
|
@@ -71,9 +71,10 @@ class ThemeStorage extends LocalFileStorage {
|
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
/**
|
|
74
|
-
*
|
|
74
|
+
* Remove a file / folder
|
|
75
75
|
*
|
|
76
|
-
* @param String
|
|
76
|
+
* @param {String} fileName
|
|
77
|
+
* @returns {Promise<void>}
|
|
77
78
|
*/
|
|
78
79
|
delete(fileName) {
|
|
79
80
|
return fs.remove(path.join(this.getTargetDir(), fileName));
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
const debug = require('@tryghost/debug')('themes');
|
|
2
2
|
const bridge = require('../../../bridge');
|
|
3
|
+
const labs = require('../../../shared/labs');
|
|
4
|
+
const customThemeSettings = require('../custom-theme-settings');
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
7
|
* These helper methods mean that the bridge is only required in one place
|
|
@@ -8,14 +10,26 @@ const bridge = require('../../../bridge');
|
|
|
8
10
|
module.exports = {
|
|
9
11
|
activateFromBoot: (themeName, theme, checkedTheme) => {
|
|
10
12
|
debug('Activating theme (method A on boot)', themeName);
|
|
13
|
+
// TODO: probably a better place for this to happen - after successful activation / when reloading site?
|
|
14
|
+
if (labs.isSet('customThemeSettings')) {
|
|
15
|
+
customThemeSettings.activateTheme(checkedTheme);
|
|
16
|
+
}
|
|
11
17
|
bridge.activateTheme(theme, checkedTheme);
|
|
12
18
|
},
|
|
13
19
|
activateFromAPI: (themeName, theme, checkedTheme) => {
|
|
14
20
|
debug('Activating theme (method B on API "activate")', themeName);
|
|
21
|
+
// TODO: probably a better place for this to happen - after successful activation / when reloading site?
|
|
22
|
+
if (labs.isSet('customThemeSettings')) {
|
|
23
|
+
customThemeSettings.activateTheme(checkedTheme);
|
|
24
|
+
}
|
|
15
25
|
bridge.activateTheme(theme, checkedTheme);
|
|
16
26
|
},
|
|
17
27
|
activateFromAPIOverride: (themeName, theme, checkedTheme) => {
|
|
18
28
|
debug('Activating theme (method C on API "override")', themeName);
|
|
29
|
+
// TODO: probably a better place for this to happen - after successful activation / when reloading site?
|
|
30
|
+
if (labs.isSet('customThemeSettings')) {
|
|
31
|
+
customThemeSettings.activateTheme(checkedTheme);
|
|
32
|
+
}
|
|
19
33
|
bridge.activateTheme(theme, checkedTheme);
|
|
20
34
|
}
|
|
21
35
|
};
|
|
@@ -2,6 +2,7 @@ const activate = require('./activate');
|
|
|
2
2
|
const themeLoader = require('./loader');
|
|
3
3
|
const storage = require('./storage');
|
|
4
4
|
const getJSON = require('./to-json');
|
|
5
|
+
const installer = require('./installer');
|
|
5
6
|
|
|
6
7
|
const settingsCache = require('../../../shared/settings-cache');
|
|
7
8
|
|
|
@@ -26,6 +27,7 @@ module.exports = {
|
|
|
26
27
|
activate: activate.activate,
|
|
27
28
|
getZip: storage.getZip,
|
|
28
29
|
setFromZip: storage.setFromZip,
|
|
30
|
+
installFromGithub: installer.installFromGithub,
|
|
29
31
|
destroy: storage.destroy
|
|
30
32
|
}
|
|
31
33
|
};
|