ghost 5.4.0 → 5.6.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/PRIVACY.md +3 -2
- package/components/tryghost-adapter-manager-0.0.0.tgz +0 -0
- package/components/tryghost-api-version-compatibility-service-0.0.0.tgz +0 -0
- package/components/tryghost-bootstrap-socket-0.0.0.tgz +0 -0
- package/components/tryghost-constants-0.0.0.tgz +0 -0
- package/components/tryghost-custom-theme-settings-service-0.0.0.tgz +0 -0
- package/components/tryghost-domain-events-0.0.0.tgz +0 -0
- package/components/tryghost-email-analytics-provider-mailgun-0.0.0.tgz +0 -0
- package/components/tryghost-email-analytics-service-0.0.0.tgz +0 -0
- package/components/tryghost-email-content-generator-0.0.0.tgz +0 -0
- package/components/tryghost-express-dynamic-redirects-0.0.0.tgz +0 -0
- package/components/tryghost-extract-api-key-0.0.0.tgz +0 -0
- package/components/tryghost-job-manager-0.0.0.tgz +0 -0
- package/components/tryghost-magic-link-0.0.0.tgz +0 -0
- package/components/tryghost-member-analytics-service-0.0.0.tgz +0 -0
- package/components/tryghost-member-events-0.0.0.tgz +0 -0
- package/components/tryghost-members-analytics-ingress-0.0.0.tgz +0 -0
- package/components/tryghost-members-api-0.0.0.tgz +0 -0
- package/components/tryghost-members-csv-0.0.0.tgz +0 -0
- package/components/tryghost-members-events-service-0.0.0.tgz +0 -0
- package/components/tryghost-members-importer-0.0.0.tgz +0 -0
- package/components/tryghost-members-offers-0.0.0.tgz +0 -0
- package/components/tryghost-members-payments-0.0.0.tgz +0 -0
- package/components/tryghost-members-ssr-0.0.0.tgz +0 -0
- package/components/tryghost-members-stripe-service-0.0.0.tgz +0 -0
- package/components/tryghost-minifier-0.0.0.tgz +0 -0
- package/components/tryghost-mw-api-version-mismatch-0.0.0.tgz +0 -0
- package/components/tryghost-mw-error-handler-0.0.0.tgz +0 -0
- package/components/tryghost-mw-session-from-token-0.0.0.tgz +0 -0
- package/components/tryghost-mw-update-user-last-seen-0.0.0.tgz +0 -0
- package/components/tryghost-mw-vhost-0.0.0.tgz +0 -0
- package/components/tryghost-package-json-0.0.0.tgz +0 -0
- package/components/tryghost-security-0.0.0.tgz +0 -0
- package/components/tryghost-session-service-0.0.0.tgz +0 -0
- package/components/tryghost-settings-path-manager-0.0.0.tgz +0 -0
- package/components/tryghost-update-check-service-0.0.0.tgz +0 -0
- package/components/tryghost-verification-trigger-0.0.0.tgz +0 -0
- package/components/tryghost-version-notifications-data-service-0.0.0.tgz +0 -0
- package/content/themes/casper/assets/built/global.css +1 -1
- package/content/themes/casper/assets/built/global.css.map +1 -1
- 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 +9 -1
- package/content/themes/casper/gulpfile.js +1 -1
- package/content/themes/casper/package.json +9 -9
- package/content/themes/casper/yarn.lock +1154 -1249
- package/core/boot.js +6 -1
- package/core/built/assets/{chunk.3.dc389a0f93cb5fabd695.js → chunk.3.33097bb5eb150719bdd2.js} +19 -19
- package/core/built/assets/fonts/Inter.ttf +0 -0
- package/core/built/assets/ghost-dark-1bdd57aba1fa4a23388121740454dab2.css +1 -0
- package/core/built/assets/{ghost.min-36b64813b14c45075770658269d4b478.js → ghost.min-31bd482d1bcfe706448bc6f401481a28.js} +3001 -2874
- package/core/built/assets/ghost.min-b0576e0e36343533e70992f3e5bb02bb.css +1 -0
- package/core/built/assets/icons/event-comment.svg +3 -0
- package/core/built/assets/{vendor.min-be0129c9c6897c9f10425e2402881d77.js → vendor.min-3dd40d3052381526f38fd290d13baa47.js} +2394 -924
- package/core/frontend/helpers/comments.js +39 -14
- package/core/frontend/helpers/ghost_head.js +22 -4
- package/core/frontend/utils/frontend-apps.js +33 -0
- package/core/server/api/endpoints/{comments-comments.js → comments-members.js} +24 -43
- package/core/server/api/endpoints/index.js +2 -6
- package/core/server/api/endpoints/utils/serializers/output/config.js +2 -1
- package/core/server/api/endpoints/utils/serializers/output/mappers/activity-feed-events.js +17 -0
- package/core/server/api/endpoints/utils/serializers/output/mappers/comments.js +18 -0
- package/core/server/api/endpoints/utils/serializers/output/mappers/index.js +1 -0
- package/core/server/api/endpoints/utils/serializers/output/mappers/posts.js +11 -0
- package/core/server/api/endpoints/utils/serializers/output/members.js +12 -1
- package/core/server/api/endpoints/utils/serializers/output/utils/clean.js +4 -0
- package/core/server/data/exporter/table-lists.js +2 -1
- package/core/server/data/migrations/versions/5.3/2022-07-06-09-17-add-ghost-explore-integration.js +0 -1
- package/core/server/data/migrations/versions/5.3/2022-07-06-09-26-add-ghost-explore-integration-api-key.js +0 -1
- package/core/server/data/migrations/versions/5.5/2022-07-18-14-29-add-comment-reporting-permissions.js +10 -0
- package/core/server/data/migrations/versions/5.5/2022-07-18-14-31-drop-reports-reason.js +3 -0
- package/core/server/data/migrations/versions/5.5/2022-07-18-14-32-drop-nullable-member-id-from-likes.js +4 -0
- package/core/server/data/migrations/versions/5.5/2022-07-18-14-33-fix-comments-on-delete-foreign-keys.js +119 -0
- package/core/server/data/migrations/versions/5.5/2022-07-21-08-56-add-jobs-table.js +11 -0
- package/core/server/data/migrations/versions/5.6/2022-07-27-13-40-change-explore-type.js +24 -0
- package/core/server/data/schema/commands.js +7 -2
- package/core/server/data/schema/fixtures/fixtures.json +6 -1
- package/core/server/data/schema/schema.js +12 -4
- package/core/server/ghost-server.js +0 -22
- package/core/server/models/comment-report.js +34 -0
- package/core/server/models/comment.js +8 -7
- package/core/server/models/job.js +9 -0
- package/core/server/services/bulk-email/bulk-email-processor.js +6 -0
- package/core/server/services/comments/controller.js +82 -0
- package/core/server/services/comments/email-templates/new-comment-reply.hbs +2 -2
- package/core/server/services/comments/email-templates/new-comment-reply.txt.js +7 -8
- package/core/server/services/comments/email-templates/new-comment.hbs +2 -2
- package/core/server/services/comments/email-templates/new-comment.txt.js +7 -6
- package/core/server/services/comments/email-templates/report.hbs +199 -0
- package/core/server/services/comments/email-templates/report.txt.js +16 -0
- package/core/server/services/comments/emails.js +57 -1
- package/core/server/services/comments/index.js +6 -1
- package/core/server/services/comments/service.js +291 -9
- package/core/server/services/jobs/job-service.js +24 -1
- package/core/server/services/mail/GhostMailer.js +1 -0
- package/core/server/services/mega/email-preview.js +5 -1
- package/core/server/services/mega/mega.js +2 -4
- package/core/server/services/mega/post-email-serializer.js +97 -2
- package/core/server/services/mega/segment-parser.js +10 -1
- package/core/server/services/members/api.js +2 -1
- package/core/server/services/members/service.js +9 -4
- package/core/server/services/public-config/config.js +2 -1
- package/core/server/services/settings/settings-bread-service.js +1 -1
- package/core/server/services/stripe/service.js +9 -1
- 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/api/testmode/jobs/graceful-job.js +2 -2
- package/core/server/web/api/testmode/routes.js +14 -0
- package/core/server/web/comments/routes.js +10 -8
- package/core/shared/config/defaults.json +12 -7
- package/core/shared/config/env/config.testing.json +3 -2
- package/core/shared/labs.js +3 -1
- package/package.json +92 -59
- package/yarn.lock +1821 -2011
- package/core/built/assets/ghost-dark-739c1f5546bd048eeeb253965ef36712.css +0 -1
- package/core/built/assets/ghost.min-5211776b9497f36fac8c9e5f2584cbcc.css +0 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const {promises: fs} = require('fs');
|
|
2
2
|
const path = require('path');
|
|
3
3
|
const moment = require('moment');
|
|
4
|
+
const htmlToPlaintext = require('../../../shared/html-to-plaintext');
|
|
4
5
|
|
|
5
6
|
class CommentsServiceEmails {
|
|
6
7
|
constructor({config, logging, models, mailer, settingsCache, urlService, urlUtils}) {
|
|
@@ -48,7 +49,7 @@ class CommentsServiceEmails {
|
|
|
48
49
|
|
|
49
50
|
const {html, text} = await this.renderEmailTemplate('new-comment', templateData);
|
|
50
51
|
|
|
51
|
-
this.sendMail({
|
|
52
|
+
await this.sendMail({
|
|
52
53
|
to,
|
|
53
54
|
subject,
|
|
54
55
|
html,
|
|
@@ -65,6 +66,11 @@ class CommentsServiceEmails {
|
|
|
65
66
|
return;
|
|
66
67
|
}
|
|
67
68
|
|
|
69
|
+
// Don't send a notification if you reply to your own comment
|
|
70
|
+
if (parentMember.id === reply.get('member_id')) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
68
74
|
const to = parentMember.get('email');
|
|
69
75
|
const subject = '💬 You have a new reply on one of your comments';
|
|
70
76
|
|
|
@@ -100,6 +106,56 @@ class CommentsServiceEmails {
|
|
|
100
106
|
});
|
|
101
107
|
}
|
|
102
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Send an email to notify the owner of the site that a comment has been reported by a member
|
|
111
|
+
* @param {*} comment The comment model that has been reported
|
|
112
|
+
* @param {*} reporter The member object who reported this comment
|
|
113
|
+
*/
|
|
114
|
+
async notifiyReport(comment, reporter) {
|
|
115
|
+
const post = await this.models.Post.findOne({id: comment.get('post_id')}, {withRelated: ['authors']});
|
|
116
|
+
const member = await this.models.Member.findOne({id: comment.get('member_id')});
|
|
117
|
+
const owner = await this.models.User.getOwnerUser();
|
|
118
|
+
|
|
119
|
+
// For now we only send the report to the owner
|
|
120
|
+
const to = owner.get('email');
|
|
121
|
+
const subject = '🚩 A comment has been reported on your post';
|
|
122
|
+
|
|
123
|
+
const memberName = member.get('name') || 'Anonymous';
|
|
124
|
+
|
|
125
|
+
const templateData = {
|
|
126
|
+
siteTitle: this.settingsCache.get('title'),
|
|
127
|
+
siteUrl: this.urlUtils.getSiteUrl(),
|
|
128
|
+
siteDomain: this.siteDomain,
|
|
129
|
+
postTitle: post.get('title'),
|
|
130
|
+
postUrl: this.urlService.getUrlByResourceId(post.get('id'), {absolute: true}),
|
|
131
|
+
commentHtml: comment.get('html'),
|
|
132
|
+
commentText: htmlToPlaintext.email(comment.get('html')),
|
|
133
|
+
commentDate: moment(comment.get('created_at')).tz(this.settingsCache.get('timezone')).format('D MMM YYYY'),
|
|
134
|
+
|
|
135
|
+
reporterName: reporter.name,
|
|
136
|
+
reporterEmail: reporter.email,
|
|
137
|
+
reporter: reporter.name ? `${reporter.name} (${reporter.email})` : reporter.email,
|
|
138
|
+
|
|
139
|
+
memberName: memberName,
|
|
140
|
+
memberEmail: member.get('email'),
|
|
141
|
+
memberBio: member.get('bio'),
|
|
142
|
+
memberInitials: this.extractInitials(memberName),
|
|
143
|
+
accentColor: this.settingsCache.get('accent_color'),
|
|
144
|
+
fromEmail: this.notificationFromAddress,
|
|
145
|
+
toEmail: to,
|
|
146
|
+
staffUrl: `${this.urlUtils.getAdminUrl()}ghost/#/settings/staff/${owner.get('slug')}`
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const {html, text} = await this.renderEmailTemplate('report', templateData);
|
|
150
|
+
|
|
151
|
+
await this.sendMail({
|
|
152
|
+
to,
|
|
153
|
+
subject,
|
|
154
|
+
html,
|
|
155
|
+
text
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
103
159
|
// Utils
|
|
104
160
|
|
|
105
161
|
get siteDomain() {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
class CommentsServiceWrapper {
|
|
2
2
|
init() {
|
|
3
3
|
const CommentsService = require('./service');
|
|
4
|
+
const CommentsController = require('./controller');
|
|
4
5
|
|
|
5
6
|
const config = require('../../../shared/config');
|
|
6
7
|
const logging = require('@tryghost/logging');
|
|
@@ -10,6 +11,7 @@ class CommentsServiceWrapper {
|
|
|
10
11
|
const settingsCache = require('../../../shared/settings-cache');
|
|
11
12
|
const urlService = require('../url');
|
|
12
13
|
const urlUtils = require('../../../shared/url-utils');
|
|
14
|
+
const membersService = require('../members');
|
|
13
15
|
|
|
14
16
|
this.api = new CommentsService({
|
|
15
17
|
config,
|
|
@@ -18,8 +20,11 @@ class CommentsServiceWrapper {
|
|
|
18
20
|
mailer,
|
|
19
21
|
settingsCache,
|
|
20
22
|
urlService,
|
|
21
|
-
urlUtils
|
|
23
|
+
urlUtils,
|
|
24
|
+
contentGating: membersService.contentGating
|
|
22
25
|
});
|
|
26
|
+
|
|
27
|
+
this.controller = new CommentsController(this.api);
|
|
23
28
|
}
|
|
24
29
|
}
|
|
25
30
|
|
|
@@ -1,23 +1,305 @@
|
|
|
1
|
+
const tpl = require('@tryghost/tpl');
|
|
2
|
+
const errors = require('@tryghost/errors');
|
|
3
|
+
const {MemberCommentEvent} = require('@tryghost/member-events');
|
|
4
|
+
const DomainEvents = require('@tryghost/domain-events');
|
|
5
|
+
|
|
6
|
+
const messages = {
|
|
7
|
+
commentNotFound: 'Comment could not be found',
|
|
8
|
+
memberNotFound: 'Unable to find member',
|
|
9
|
+
likeNotFound: 'Unable to find like',
|
|
10
|
+
alreadyLiked: 'This comment was liked already',
|
|
11
|
+
replyToReply: 'Can not reply to a reply',
|
|
12
|
+
commentsNotEnabled: 'Comments are not enabled for this site.',
|
|
13
|
+
cannotCommentOnPost: 'You do not have permission to comment on this post.'
|
|
14
|
+
};
|
|
15
|
+
|
|
1
16
|
class CommentsService {
|
|
2
|
-
constructor({config, logging, models, mailer, settingsCache, urlService, urlUtils}) {
|
|
3
|
-
|
|
4
|
-
this.logging = logging;
|
|
17
|
+
constructor({config, logging, models, mailer, settingsCache, urlService, urlUtils, contentGating}) {
|
|
18
|
+
/** @private */
|
|
5
19
|
this.models = models;
|
|
6
|
-
|
|
20
|
+
|
|
21
|
+
/** @private */
|
|
7
22
|
this.settingsCache = settingsCache;
|
|
8
|
-
|
|
9
|
-
|
|
23
|
+
|
|
24
|
+
/** @private */
|
|
25
|
+
this.contentGating = contentGating;
|
|
10
26
|
|
|
11
27
|
const Emails = require('./emails');
|
|
12
|
-
|
|
28
|
+
/** @private */
|
|
29
|
+
this.emails = new Emails({
|
|
30
|
+
config,
|
|
31
|
+
logging,
|
|
32
|
+
models,
|
|
33
|
+
mailer,
|
|
34
|
+
settingsCache,
|
|
35
|
+
urlService,
|
|
36
|
+
urlUtils
|
|
37
|
+
});
|
|
13
38
|
}
|
|
14
39
|
|
|
40
|
+
/**
|
|
41
|
+
* @returns {'off'|'all'|'paid'}
|
|
42
|
+
*/
|
|
43
|
+
get enabled() {
|
|
44
|
+
const setting = this.settingsCache.get('comments_enabled');
|
|
45
|
+
if (setting === 'off' || setting === 'all' || setting === 'paid') {
|
|
46
|
+
return setting;
|
|
47
|
+
}
|
|
48
|
+
return 'off';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** @private */
|
|
52
|
+
checkEnabled() {
|
|
53
|
+
if (this.enabled === 'off') {
|
|
54
|
+
throw new errors.MethodNotAllowedError({
|
|
55
|
+
message: tpl(messages.commentsNotEnabled)
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** @private */
|
|
61
|
+
checkCommentAccess(memberModel) {
|
|
62
|
+
if (this.enabled === 'paid' && memberModel.get('status') === 'free') {
|
|
63
|
+
throw new errors.NoPermissionError({
|
|
64
|
+
message: tpl(messages.cannotCommentOnPost)
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** @private */
|
|
70
|
+
checkPostAccess(postModel, memberModel) {
|
|
71
|
+
const access = this.contentGating.checkPostAccess(postModel.toJSON(), memberModel.toJSON());
|
|
72
|
+
if (access === this.contentGating.BLOCK_ACCESS) {
|
|
73
|
+
throw new errors.NoPermissionError({
|
|
74
|
+
message: tpl(messages.cannotCommentOnPost)
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** @private */
|
|
15
80
|
async sendNewCommentNotifications(comment) {
|
|
16
|
-
this.emails.notifyPostAuthors(comment);
|
|
81
|
+
await this.emails.notifyPostAuthors(comment);
|
|
17
82
|
|
|
18
83
|
if (comment.get('parent_id')) {
|
|
19
|
-
this.emails.notifyParentCommentAuthor(comment);
|
|
84
|
+
await this.emails.notifyParentCommentAuthor(comment);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async reportComment(commentId, reporter) {
|
|
89
|
+
this.checkEnabled();
|
|
90
|
+
const comment = await this.models.Comment.findOne({id: commentId}, {require: true});
|
|
91
|
+
|
|
92
|
+
// Check if this reporter already reported this comment (then don't send an email)?
|
|
93
|
+
const existing = await this.models.CommentReport.findOne({
|
|
94
|
+
comment_id: comment.id,
|
|
95
|
+
member_id: reporter.id
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
if (existing) {
|
|
99
|
+
// Ignore silently for now
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Save report model
|
|
104
|
+
await this.models.CommentReport.add({
|
|
105
|
+
comment_id: comment.id,
|
|
106
|
+
member_id: reporter.id
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
await this.emails.notifiyReport(comment, reporter);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* @param {any} options
|
|
114
|
+
*/
|
|
115
|
+
async getComments(options) {
|
|
116
|
+
this.checkEnabled();
|
|
117
|
+
const page = await this.models.Comment.findPage(options);
|
|
118
|
+
|
|
119
|
+
return page;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* @param {string} id - The ID of the Comment to get
|
|
124
|
+
* @param {any} options
|
|
125
|
+
*/
|
|
126
|
+
async getCommentByID(id, options) {
|
|
127
|
+
this.checkEnabled();
|
|
128
|
+
const model = await this.models.Comment.findOne({id}, options);
|
|
129
|
+
|
|
130
|
+
if (!model) {
|
|
131
|
+
throw new errors.NotFoundError({
|
|
132
|
+
messages: tpl(messages.commentNotFound)
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return model;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* @param {string} post - The ID of the Post to comment on
|
|
141
|
+
* @param {string} member - The ID of the Member to comment as
|
|
142
|
+
* @param {string} comment - The HTML content of the Comment
|
|
143
|
+
* @param {any} options
|
|
144
|
+
*/
|
|
145
|
+
async commentOnPost(post, member, comment, options) {
|
|
146
|
+
this.checkEnabled();
|
|
147
|
+
const memberModel = await this.models.Member.findOne({
|
|
148
|
+
id: member
|
|
149
|
+
}, {
|
|
150
|
+
require: true,
|
|
151
|
+
...options
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
this.checkCommentAccess(memberModel);
|
|
155
|
+
|
|
156
|
+
const postModel = await this.models.Post.findOne({
|
|
157
|
+
id: post
|
|
158
|
+
}, {
|
|
159
|
+
require: true,
|
|
160
|
+
...options
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
this.checkPostAccess(postModel, memberModel);
|
|
164
|
+
|
|
165
|
+
const model = await this.models.Comment.add({
|
|
166
|
+
post_id: post,
|
|
167
|
+
member_id: member,
|
|
168
|
+
parent_id: null,
|
|
169
|
+
html: comment,
|
|
170
|
+
status: 'published'
|
|
171
|
+
}, options);
|
|
172
|
+
|
|
173
|
+
if (!options.context.internal) {
|
|
174
|
+
await this.sendNewCommentNotifications(model);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
DomainEvents.dispatch(MemberCommentEvent.create({
|
|
178
|
+
memberId: member,
|
|
179
|
+
postId: post,
|
|
180
|
+
commentId: model.id
|
|
181
|
+
}));
|
|
182
|
+
|
|
183
|
+
return model;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* @param {string} parent - The ID of the Comment to reply to
|
|
188
|
+
* @param {string} member - The ID of the Member to comment as
|
|
189
|
+
* @param {string} comment - The HTML content of the Comment
|
|
190
|
+
* @param {any} options
|
|
191
|
+
*/
|
|
192
|
+
async replyToComment(parent, member, comment, options) {
|
|
193
|
+
this.checkEnabled();
|
|
194
|
+
const memberModel = await this.models.Member.findOne({
|
|
195
|
+
id: member
|
|
196
|
+
}, {
|
|
197
|
+
require: true,
|
|
198
|
+
...options
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
this.checkCommentAccess(memberModel);
|
|
202
|
+
|
|
203
|
+
const parentComment = await this.getCommentByID(parent, options);
|
|
204
|
+
if (!parentComment) {
|
|
205
|
+
throw new errors.BadRequestError({
|
|
206
|
+
message: tpl(messages.commentNotFound)
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (parentComment.get('parent_id') !== null) {
|
|
211
|
+
throw new errors.BadRequestError({
|
|
212
|
+
message: tpl(messages.replyToReply)
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
const postModel = await this.models.Post.findOne({
|
|
216
|
+
id: parentComment.get('post_id')
|
|
217
|
+
}, {
|
|
218
|
+
require: true,
|
|
219
|
+
...options
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
this.checkPostAccess(postModel, memberModel);
|
|
223
|
+
|
|
224
|
+
const model = await this.models.Comment.add({
|
|
225
|
+
post_id: parentComment.get('post_id'),
|
|
226
|
+
member_id: member,
|
|
227
|
+
parent_id: parentComment.id,
|
|
228
|
+
html: comment,
|
|
229
|
+
status: 'published'
|
|
230
|
+
}, options);
|
|
231
|
+
|
|
232
|
+
if (!options.context.internal) {
|
|
233
|
+
await this.sendNewCommentNotifications(model);
|
|
20
234
|
}
|
|
235
|
+
|
|
236
|
+
DomainEvents.dispatch(MemberCommentEvent.create({
|
|
237
|
+
memberId: member,
|
|
238
|
+
postId: parentComment.get('post_id'),
|
|
239
|
+
commentId: model.id
|
|
240
|
+
}));
|
|
241
|
+
|
|
242
|
+
return model;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* @param {string} id - The ID of the Comment to delete
|
|
247
|
+
* @param {string} member - The ID of the Member to delete as
|
|
248
|
+
* @param {any} options
|
|
249
|
+
*/
|
|
250
|
+
async deleteComment(id, member, options) {
|
|
251
|
+
this.checkEnabled();
|
|
252
|
+
const existingComment = await this.getCommentByID(id, options);
|
|
253
|
+
|
|
254
|
+
if (existingComment.get('member_id') !== member) {
|
|
255
|
+
throw new errors.NoPermissionError({
|
|
256
|
+
// todo fix message
|
|
257
|
+
message: tpl(messages.memberNotFound)
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const model = await this.models.Comment.edit({
|
|
262
|
+
status: 'deleted'
|
|
263
|
+
}, {
|
|
264
|
+
id,
|
|
265
|
+
require: true,
|
|
266
|
+
...options
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
return model;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* @param {string} id - The ID of the Comment to edit
|
|
274
|
+
* @param {string} member - The ID of the Member to edit as
|
|
275
|
+
* @param {string} comment - The new HTML content of the Comment
|
|
276
|
+
* @param {any} options
|
|
277
|
+
*/
|
|
278
|
+
async editCommentContent(id, member, comment, options) {
|
|
279
|
+
this.checkEnabled();
|
|
280
|
+
const existingComment = await this.getCommentByID(id, options);
|
|
281
|
+
|
|
282
|
+
if (!comment) {
|
|
283
|
+
return existingComment;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (existingComment.get('member_id') !== member) {
|
|
287
|
+
throw new errors.NoPermissionError({
|
|
288
|
+
// todo fix message
|
|
289
|
+
message: tpl(messages.memberNotFound)
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const model = await this.models.Comment.edit({
|
|
294
|
+
html: comment,
|
|
295
|
+
edited_at: new Date()
|
|
296
|
+
}, {
|
|
297
|
+
id,
|
|
298
|
+
require: true,
|
|
299
|
+
...options
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
return model;
|
|
21
303
|
}
|
|
22
304
|
}
|
|
23
305
|
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
const JobManager = require('@tryghost/job-manager');
|
|
7
7
|
const logging = require('@tryghost/logging');
|
|
8
|
+
const models = require('../../models');
|
|
8
9
|
const sentry = require('../../../shared/sentry');
|
|
9
10
|
|
|
10
11
|
const errorHandler = (error, workerMeta) => {
|
|
@@ -17,6 +18,28 @@ const workerMessageHandler = ({name, message}) => {
|
|
|
17
18
|
logging.info(`Worker for job ${name} sent a message: ${message}`);
|
|
18
19
|
};
|
|
19
20
|
|
|
20
|
-
const
|
|
21
|
+
const initTestMode = () => {
|
|
22
|
+
// Output job queue length every 5 seconds
|
|
23
|
+
setInterval(() => {
|
|
24
|
+
logging.warn(`${jobManager.queue.length()} jobs in the queue. Idle: ${jobManager.queue.idle()}`);
|
|
25
|
+
|
|
26
|
+
const runningScheduledjobs = Object.keys(jobManager.bree.workers);
|
|
27
|
+
if (Object.keys(jobManager.bree.workers).length) {
|
|
28
|
+
logging.warn(`${Object.keys(jobManager.bree.workers).length} jobs running: ${runningScheduledjobs}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const scheduledJobs = Object.keys(jobManager.bree.intervals);
|
|
32
|
+
if (Object.keys(jobManager.bree.intervals).length) {
|
|
33
|
+
logging.warn(`${Object.keys(jobManager.bree.intervals).length} scheduled jobs: ${scheduledJobs}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (runningScheduledjobs.length === 0 && scheduledJobs.length === 0) {
|
|
37
|
+
logging.warn('No scheduled or running jobs');
|
|
38
|
+
}
|
|
39
|
+
}, 5000);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const jobManager = new JobManager({errorHandler, workerMessageHandler, JobModel: models.Job});
|
|
21
43
|
|
|
22
44
|
module.exports = jobManager;
|
|
45
|
+
module.exports.initTestMode = initTestMode;
|
|
@@ -95,6 +95,7 @@ module.exports = class GhostMailer {
|
|
|
95
95
|
* @param {string} message.html - email content
|
|
96
96
|
* @param {string} message.to - email recipient address
|
|
97
97
|
* @param {string} [message.from] - sender email address
|
|
98
|
+
* @param {string} [message.text] - text version of this message
|
|
98
99
|
* @param {boolean} [message.forceTextContent] - maps to generateTextFromHTML nodemailer option
|
|
99
100
|
* which is: "if set to true uses HTML to generate plain text body part from the HTML if the text is not defined"
|
|
100
101
|
* (ref: https://github.com/nodemailer/nodemailer/tree/da2f1d278f91b4262e940c0b37638e7027184b1d#e-mail-message-fields)
|
|
@@ -69,6 +69,7 @@ const getEmailData = async (postModel, options) => {
|
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
return {
|
|
72
|
+
post: postModel.toJSON(), // for content paywalling
|
|
72
73
|
subject,
|
|
73
74
|
html,
|
|
74
75
|
plaintext,
|
|
@@ -87,9 +88,6 @@ const sendTestEmail = async (postModel, toEmails, memberSegment) => {
|
|
|
87
88
|
let emailData = await getEmailData(postModel);
|
|
88
89
|
emailData.subject = `[Test] ${emailData.subject}`;
|
|
89
90
|
|
|
90
|
-
if (memberSegment) {
|
|
91
|
-
emailData = postEmailSerializer.renderEmailForSegment(emailData, memberSegment);
|
|
92
|
-
}
|
|
93
91
|
// fetch any matching members so that replacements use expected values
|
|
94
92
|
const recipients = await Promise.all(toEmails.map(async (email) => {
|
|
95
93
|
const member = await membersService.api.members.get({email});
|
|
@@ -109,7 +107,7 @@ const sendTestEmail = async (postModel, toEmails, memberSegment) => {
|
|
|
109
107
|
// enable tracking for previews to match real-world behaviour
|
|
110
108
|
emailData.track_opens = !!settingsCache.get('email_track_opens');
|
|
111
109
|
|
|
112
|
-
const response = await bulkEmailService.send(emailData, recipients);
|
|
110
|
+
const response = await bulkEmailService.send(emailData, recipients, memberSegment);
|
|
113
111
|
|
|
114
112
|
if (response instanceof bulkEmailService.FailedBatch) {
|
|
115
113
|
return Promise.reject(response.error);
|
|
@@ -2,15 +2,18 @@ const _ = require('lodash');
|
|
|
2
2
|
const template = require('./template');
|
|
3
3
|
const settingsCache = require('../../../shared/settings-cache');
|
|
4
4
|
const urlUtils = require('../../../shared/url-utils');
|
|
5
|
+
const labs = require('../../../shared/labs');
|
|
5
6
|
const moment = require('moment-timezone');
|
|
6
7
|
const api = require('../../api').endpoints;
|
|
7
8
|
const apiShared = require('../../api').shared;
|
|
8
9
|
const {URL} = require('url');
|
|
9
10
|
const mobiledocLib = require('../../lib/mobiledoc');
|
|
10
11
|
const htmlToPlaintext = require('../../../shared/html-to-plaintext');
|
|
12
|
+
const membersService = require('../members');
|
|
11
13
|
const {isUnsplashImage, isLocalContentImage} = require('@tryghost/kg-default-cards/lib/utils');
|
|
12
14
|
const {textColorForBackgroundColor, darkenToContrastThreshold} = require('@tryghost/color-utils');
|
|
13
15
|
const logging = require('@tryghost/logging');
|
|
16
|
+
const urlService = require('../../services/url');
|
|
14
17
|
|
|
15
18
|
const ALLOWED_REPLACEMENTS = ['first_name'];
|
|
16
19
|
|
|
@@ -74,6 +77,27 @@ const createUnsubscribeUrl = (uuid, newsletterUuid) => {
|
|
|
74
77
|
return unsubscribeUrl.href;
|
|
75
78
|
};
|
|
76
79
|
|
|
80
|
+
/**
|
|
81
|
+
* createPostSignupUrl
|
|
82
|
+
*
|
|
83
|
+
* Takes a post object. Returns the url that should be used to signup from newsletter
|
|
84
|
+
*
|
|
85
|
+
* @param {Object} post post object
|
|
86
|
+
*/
|
|
87
|
+
const createPostSignupUrl = (post) => {
|
|
88
|
+
let url = urlService.getUrlByResourceId(post.id, {absolute: true});
|
|
89
|
+
|
|
90
|
+
// For email-only posts, use site url as base
|
|
91
|
+
if (post.status !== 'published' && url.match(/\/404\//)) {
|
|
92
|
+
url = urlUtils.getSiteUrl();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const signupUrl = new URL(url);
|
|
96
|
+
signupUrl.hash = `/portal/signup`;
|
|
97
|
+
|
|
98
|
+
return signupUrl.href;
|
|
99
|
+
};
|
|
100
|
+
|
|
77
101
|
// NOTE: serialization is needed to make sure we do post transformations such as image URL transformation from relative to absolute
|
|
78
102
|
const serializePostModel = async (model) => {
|
|
79
103
|
// fetch mobiledoc rather than html and plaintext so we can render email-specific contents
|
|
@@ -297,18 +321,87 @@ const serialize = async (postModel, newsletter, options = {isBrowserPreview: fal
|
|
|
297
321
|
html: formatHtmlForEmail(htmlTemplate),
|
|
298
322
|
plaintext: post.plaintext
|
|
299
323
|
});
|
|
300
|
-
|
|
301
|
-
return {
|
|
324
|
+
const data = {
|
|
302
325
|
subject: post.email_subject || post.title,
|
|
303
326
|
html,
|
|
304
327
|
plaintext
|
|
305
328
|
};
|
|
329
|
+
if (labs.isSet('newsletterPaywall')) {
|
|
330
|
+
data.post = post;
|
|
331
|
+
}
|
|
332
|
+
return data;
|
|
306
333
|
};
|
|
307
334
|
|
|
335
|
+
/**
|
|
336
|
+
* renderPaywallCTA
|
|
337
|
+
*
|
|
338
|
+
* outputs html for rendering paywall CTA in newsletter
|
|
339
|
+
*
|
|
340
|
+
* @param {Object} post Post Object
|
|
341
|
+
*/
|
|
342
|
+
|
|
343
|
+
function renderPaywallCTA(post) {
|
|
344
|
+
const accentColor = settingsCache.get('accent_color');
|
|
345
|
+
const siteTitle = settingsCache.get('title') || 'Ghost';
|
|
346
|
+
const signupUrl = createPostSignupUrl(post);
|
|
347
|
+
|
|
348
|
+
return `<div class="align-center" style="text-align: center;">
|
|
349
|
+
<hr
|
|
350
|
+
style="position: relative; display: block; width: 100%; margin: 3em 0; padding: 0; height: 1px; border: 0; border-top: 1px solid #e5eff5;">
|
|
351
|
+
<h2
|
|
352
|
+
style="margin-top: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; line-height: 1.11em; font-weight: 700; text-rendering: optimizeLegibility; margin: 1.5em 0 0.5em 0; font-size: 26px;">
|
|
353
|
+
Subscribe to <span style="white-space: nowrap; font-size: 26px !important;">continue reading.</span></h2>
|
|
354
|
+
<p style="margin: 0 auto 1.5em auto; line-height: 1.6em; max-width: 440px;">Become a paid member of ${siteTitle} to get access to
|
|
355
|
+
<span style="white-space: nowrap;">all subscriber-only content.</span></p>
|
|
356
|
+
<div class="btn btn-accent" style="box-sizing: border-box; width: 100%; display: table;">
|
|
357
|
+
<table border="0" cellspacing="0" cellpadding="0" align="center"
|
|
358
|
+
style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: auto;">
|
|
359
|
+
<tbody>
|
|
360
|
+
<tr>
|
|
361
|
+
<td align="center"
|
|
362
|
+
style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; vertical-align: top; text-align: center; border-radius: 5px;"
|
|
363
|
+
valign="top" bgcolor="${accentColor}">
|
|
364
|
+
<a href="${signupUrl}"
|
|
365
|
+
style="overflow-wrap: anywhere; border: solid 1px #3498db; border-radius: 5px; box-sizing: border-box; cursor: pointer; display: inline-block; font-size: 14px; font-weight: bold; margin: 0; padding: 12px 25px; text-decoration: none; background-color: ${accentColor}; border-color: ${accentColor}; color: #FFFFFF;"
|
|
366
|
+
target="_blank">Subscribe
|
|
367
|
+
</a>
|
|
368
|
+
</td>
|
|
369
|
+
</tr>
|
|
370
|
+
</tbody>
|
|
371
|
+
</table>
|
|
372
|
+
</div>
|
|
373
|
+
<p style="margin: 0 0 1.5em 0; line-height: 1.6em;"></p>
|
|
374
|
+
</div>`;
|
|
375
|
+
}
|
|
376
|
+
|
|
308
377
|
function renderEmailForSegment(email, memberSegment) {
|
|
309
378
|
const cheerio = require('cheerio');
|
|
310
379
|
|
|
311
380
|
const result = {...email};
|
|
381
|
+
|
|
382
|
+
/** Checks and hides content for newsletter behind paywall card
|
|
383
|
+
* based on member's status and post access
|
|
384
|
+
* Adds CTA in case content is hidden.
|
|
385
|
+
*/
|
|
386
|
+
if (labs.isSet('newsletterPaywall')) {
|
|
387
|
+
const paywallIndex = (result.html || '').indexOf('<!--members-only-->');
|
|
388
|
+
if (paywallIndex !== -1 && memberSegment && result.post) {
|
|
389
|
+
let statusFilter = memberSegment === 'status:free' ? {status: 'free'} : {status: 'paid'};
|
|
390
|
+
const postVisiblity = result.post.visibility;
|
|
391
|
+
|
|
392
|
+
// For newsletter paywall, specific tiers visibility is considered on par to paid tiers
|
|
393
|
+
result.post.visibility = postVisiblity === 'tiers' ? 'paid' : postVisiblity;
|
|
394
|
+
|
|
395
|
+
const memberHasAccess = membersService.contentGating.checkPostAccess(result.post, statusFilter);
|
|
396
|
+
|
|
397
|
+
if (!memberHasAccess) {
|
|
398
|
+
const postContentEndIdx = result.html.search(/[\s\n\r]+?<!-- POST CONTENT END -->/);
|
|
399
|
+
result.html = result.html.slice(0, paywallIndex) + renderPaywallCTA(result.post) + result.html.slice(postContentEndIdx);
|
|
400
|
+
result.plaintext = htmlToPlaintext.excerpt(result.html);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
312
405
|
const $ = cheerio.load(result.html);
|
|
313
406
|
|
|
314
407
|
$('[data-gh-segment]').get().forEach((node) => {
|
|
@@ -322,6 +415,7 @@ function renderEmailForSegment(email, memberSegment) {
|
|
|
322
415
|
|
|
323
416
|
result.html = formatHtmlForEmail($.html());
|
|
324
417
|
result.plaintext = htmlToPlaintext.email(result.html);
|
|
418
|
+
delete result.post;
|
|
325
419
|
|
|
326
420
|
return result;
|
|
327
421
|
}
|
|
@@ -329,6 +423,7 @@ function renderEmailForSegment(email, memberSegment) {
|
|
|
329
423
|
module.exports = {
|
|
330
424
|
serialize,
|
|
331
425
|
createUnsubscribeUrl,
|
|
426
|
+
createPostSignupUrl,
|
|
332
427
|
renderEmailForSegment,
|
|
333
428
|
parseReplacements,
|
|
334
429
|
// Export for tests
|
|
@@ -1,11 +1,20 @@
|
|
|
1
|
+
const labs = require('../../../shared/labs');
|
|
2
|
+
|
|
1
3
|
const getSegmentsFromHtml = (html) => {
|
|
2
4
|
const cheerio = require('cheerio');
|
|
3
5
|
const $ = cheerio.load(html);
|
|
4
6
|
|
|
5
|
-
|
|
7
|
+
let allSegments = $('[data-gh-segment]')
|
|
6
8
|
.get()
|
|
7
9
|
.map(el => el.attribs['data-gh-segment']);
|
|
8
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Always add free and paid segments if email has paywall card
|
|
13
|
+
*/
|
|
14
|
+
if (labs.isSet('newsletterPaywall') && html.indexOf('<!--members-only-->') !== -1) {
|
|
15
|
+
allSegments = allSegments.concat(['status:free', 'status:-free']);
|
|
16
|
+
}
|
|
17
|
+
|
|
9
18
|
// only return unique elements
|
|
10
19
|
return [...new Set(allSegments)];
|
|
11
20
|
};
|