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.
Files changed (112) hide show
  1. package/content/themes/casper/assets/built/screen.css +1 -1
  2. package/content/themes/casper/assets/built/screen.css.map +1 -1
  3. package/content/themes/casper/assets/css/screen.css +1 -1
  4. package/content/themes/casper/default.hbs +2 -2
  5. package/content/themes/casper/package.json +1 -1
  6. package/content/themes/casper/page.hbs +28 -26
  7. package/content/themes/casper/partials/post-card.hbs +2 -2
  8. package/content/themes/casper/post.hbs +67 -65
  9. package/content/themes/casper/tag.hbs +2 -2
  10. package/core/built/assets/{chunk.3.f80c7fbb7573ce508a05.js → chunk.3.4b1d9e20e57164ac9c29.js} +31 -29
  11. package/core/built/assets/ghost-dark-bb2831fc27fcb02893ed0a761207dc63.css +1 -0
  12. package/core/built/assets/{ghost.min-ba7f03a78d7d98444af386b8ae9347a7.js → ghost.min-d1d99f3ed6e0f427874b2a11e7078475.js} +1777 -1685
  13. package/core/built/assets/ghost.min-e7612edfa72b0fe2c201b387923e6fc7.css +1 -0
  14. package/core/built/assets/icons/check-2.svg +1 -0
  15. package/core/built/assets/icons/discount-bubble.svg +1 -0
  16. package/core/built/assets/icons/no-data-line-chart.svg +1 -0
  17. package/core/built/assets/icons/no-data-list.svg +9 -8
  18. package/core/built/assets/icons/no-data-subscription.svg +1 -0
  19. package/core/built/assets/{vendor.min-29784d514390cb5abc74ae660cb2fbc7.js → vendor.min-3660ec7864887f1496fe7a27fd23ab76.js} +1570 -1289
  20. package/core/frontend/helpers/ghost_head.js +7 -1
  21. package/core/frontend/helpers/match.js +19 -4
  22. package/core/frontend/helpers/products.js +68 -0
  23. package/core/frontend/helpers/tpl/content-cta.hbs +1 -1
  24. package/core/frontend/services/routing/controllers/email-post.js +3 -2
  25. package/core/frontend/services/settings/loader.js +2 -2
  26. package/core/frontend/services/sitemap/base-generator.js +12 -8
  27. package/core/frontend/services/sitemap/manager.js +1 -1
  28. package/core/frontend/services/theme-engine/handlebars/helpers.js +1 -0
  29. package/core/frontend/services/theme-engine/middleware.js +4 -1
  30. package/core/server/api/canary/email-preview.js +15 -33
  31. package/core/server/api/canary/integrations.js +7 -30
  32. package/core/server/api/canary/labels.js +8 -9
  33. package/core/server/api/canary/members.js +13 -9
  34. package/core/server/api/canary/schedules.js +9 -57
  35. package/core/server/api/canary/settings.js +20 -158
  36. package/core/server/api/canary/themes.js +5 -59
  37. package/core/server/api/canary/utils/serializers/output/members.js +2 -14
  38. package/core/server/api/canary/utils/validators/input/settings.js +23 -1
  39. package/core/server/api/canary/webhooks.js +6 -24
  40. package/core/server/api/v2/schedules.js +9 -57
  41. package/core/server/api/v3/email-preview.js +15 -28
  42. package/core/server/api/v3/integrations.js +7 -30
  43. package/core/server/api/v3/labels.js +8 -9
  44. package/core/server/api/v3/members.js +4 -1
  45. package/core/server/api/v3/schedules.js +9 -57
  46. package/core/server/api/v3/settings.js +13 -132
  47. package/core/server/api/v3/utils/validators/input/settings.js +23 -1
  48. package/core/server/api/v3/webhooks.js +6 -28
  49. package/core/server/data/exporter/table-lists.js +1 -0
  50. package/core/server/data/importer/import-manager.js +398 -0
  51. package/core/server/data/importer/importers/data/data-importer.js +162 -0
  52. package/core/server/data/importer/importers/data/index.js +1 -162
  53. package/core/server/data/importer/index.js +1 -379
  54. package/core/server/data/migrations/versions/4.14/01-fix-comped-member-statuses.js +70 -0
  55. package/core/server/data/migrations/versions/4.14/02-fix-free-members-status-events.js +60 -0
  56. package/core/server/data/migrations/versions/4.15/01-add-temp-members-analytic-events-table.js +12 -0
  57. package/core/server/data/migrations/versions/4.16/01-add-custom-theme-settings-table.js +9 -0
  58. package/core/server/data/schema/fixtures/utils.js +6 -1
  59. package/core/server/data/schema/schema.js +26 -0
  60. package/core/server/lib/request-external.js +3 -2
  61. package/core/server/models/action.js +1 -1
  62. package/core/server/models/api-key.js +1 -1
  63. package/core/server/models/base/bookshelf.js +0 -3
  64. package/core/server/models/base/index.js +2 -0
  65. package/core/server/models/base/plugins/events.js +2 -2
  66. package/core/server/models/base/plugins/raw-knex.js +10 -10
  67. package/core/server/models/custom-theme-setting.js +9 -0
  68. package/core/server/models/email.js +2 -2
  69. package/core/server/models/index.js +2 -0
  70. package/core/server/models/integration.js +1 -1
  71. package/core/server/models/label.js +2 -2
  72. package/core/server/models/member-analytic-event.js +9 -0
  73. package/core/server/models/member.js +2 -2
  74. package/core/server/models/post.js +2 -2
  75. package/core/server/models/settings.js +2 -2
  76. package/core/server/models/tag.js +2 -2
  77. package/core/server/models/user.js +2 -2
  78. package/core/server/models/webhook.js +2 -2
  79. package/core/server/services/bulk-email/bulk-email-processor.js +1 -4
  80. package/core/server/services/custom-theme-settings.js +8 -0
  81. package/core/server/services/integrations/integrations-service.js +61 -0
  82. package/core/server/services/mail/GhostMailer.js +29 -37
  83. package/core/server/services/mega/email-preview.js +41 -0
  84. package/core/server/services/mega/index.js +4 -0
  85. package/core/server/services/mega/mega.js +27 -23
  86. package/core/server/services/mega/post-email-serializer.js +28 -21
  87. package/core/server/services/mega/template.js +11 -0
  88. package/core/server/services/members/api.js +1 -0
  89. package/core/server/services/members/emails/signup.js +1 -1
  90. package/core/server/services/oembed.js +7 -2
  91. package/core/server/services/posts/post-scheduling-service.js +100 -0
  92. package/core/server/services/settings/index.js +13 -16
  93. package/core/server/services/settings/settings-bread-service.js +188 -0
  94. package/core/server/services/settings/settings-utils.js +32 -0
  95. package/core/server/services/themes/ThemeStorage.js +5 -4
  96. package/core/server/services/themes/activation-bridge.js +14 -0
  97. package/core/server/services/themes/index.js +2 -0
  98. package/core/server/services/themes/installer.js +72 -0
  99. package/core/server/services/themes/validate.js +5 -2
  100. package/core/server/services/webhooks/webhooks-service.js +55 -0
  101. package/core/server/web/admin/views/default-prod.html +4 -4
  102. package/core/server/web/admin/views/default.html +4 -4
  103. package/core/server/web/members/app.js +3 -0
  104. package/core/shared/config/defaults.json +2 -2
  105. package/core/shared/custom-theme-settings-cache.js +3 -0
  106. package/core/shared/i18n/translations/en.json +1 -6
  107. package/core/shared/labs.js +5 -7
  108. package/package.json +64 -62
  109. package/yarn.lock +1490 -1055
  110. package/core/built/assets/ghost-dark-98d56e4973a502750748090f9dbc8280.css +0 -1
  111. package/core/built/assets/ghost.min-6932a664a1cb92a8e4a15f540cae3ad8.css +0 -1
  112. 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 (labs.isSet('emailCardSegments') && memberSegment) {
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
- if (labs.isSet('emailCardSegments')) {
441
- const segments = getSegmentsFromHtml(emailModel.get('html'));
442
- const batchIds = [];
443
-
444
- if (segments.length) {
445
- const partitionedMembers = partitionMembersBySegment(memberRows, segments);
446
-
447
- for (const partition in partitionedMembers) {
448
- const emailBatchIds = await createEmailBatches({
449
- emailModel,
450
- memberRows: partitionedMembers[partition],
451
- memberSegment: partition === 'unsegmented' ? null : partition,
452
- options
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 = labs.isSet('emailCardSegments') ? labsTemplate : template;
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: juicedHtml,
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
- result.html = $.html();
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};">Activate my account</a> </td>
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
- const html = response.body;
105
- scraperResponse = await metascraper({html, url});
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
- // The string returned when a setting is set as write-only
10
- const obfuscatedSetting = '••••••••';
11
-
12
- // The function used to decide whether a setting is write-only
13
- function isSecretSetting(setting) {
14
- return /secret/.test(setting.key);
15
- }
16
-
17
- // The function that obfuscates a write-only setting
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 fileName
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
- * Rename a file / folder
74
+ * Remove a file / folder
75
75
  *
76
- * @param String backupName
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
  };