mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-03-11 02:12:21 -05:00
Added support for gating access to blocks of post content (#22069)
ref https://linear.app/ghost/issue/PLG-327 - updated post output serializer's gating functions to add gating of specific content blocks - uses regex to look for specific strings in the HTML for speed compared to fully parsing the HTML - content gating blocks look like `<!--kg-gated-block:begin nonMember:true/false memberSegment:"status:free,status:-free"-->...gated content...<!--kg-gated-block:end-->` - parsing of params is limited to `nonMember` with a true/false value and `memberSegment` with a string value containing a limited set of supported filters - occurs at the API level so that content is correctly gated in Content API output and front-end website - added `checkGatedBlockAccess()` to members-service content-gating methods to keep the underlying member checks co-located
This commit is contained in:
parent
0cdec925ae
commit
523b9d47a0
12 changed files with 433 additions and 96 deletions
|
@ -36,7 +36,7 @@
|
|||
"dependencies": {
|
||||
"@codemirror/lang-html": "6.4.9",
|
||||
"@tryghost/color-utils": "0.2.2",
|
||||
"@tryghost/kg-unsplash-selector": "0.2.7",
|
||||
"@tryghost/kg-unsplash-selector": "0.2.8",
|
||||
"@tryghost/limit-service": "1.2.14",
|
||||
"@tryghost/nql": "0.12.7",
|
||||
"@tryghost/timezone-data": "0.4.4",
|
||||
|
|
|
@ -50,7 +50,7 @@
|
|||
"@tryghost/helpers": "1.1.90",
|
||||
"@tryghost/kg-clean-basic-html": "4.1.5",
|
||||
"@tryghost/kg-converters": "1.0.8",
|
||||
"@tryghost/koenig-lexical": "1.4.0",
|
||||
"@tryghost/koenig-lexical": "1.5.1",
|
||||
"@tryghost/limit-service": "1.2.14",
|
||||
"@tryghost/members-csv": "0.0.0",
|
||||
"@tryghost/nql": "0.12.7",
|
||||
|
@ -217,4 +217,4 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,86 @@
|
|||
const membersService = require('../../../../../../services/members');
|
||||
const htmlToPlaintext = require('@tryghost/html-to-plaintext');
|
||||
|
||||
const {PERMIT_ACCESS} = membersService.contentGating;
|
||||
|
||||
// Match the start of a gated block - fast regex as a pre-check before doing full regex+loop
|
||||
const HAS_GATED_BLOCKS_REGEX = /<!--\s*kg-gated-block:begin/;
|
||||
// Match gated block comments
|
||||
// e.g. <!--kg-gated-block:begin nonMember:true memberSegment:"status:free"-->...gated content<!--kg-gated-block:end-->
|
||||
const GATED_BLOCK_REGEX = /<!--\s*kg-gated-block:begin\s+([^\n]+?)\s*-->\s*([\s\S]*?)\s*<!--\s*kg-gated-block:end\s*-->/g;
|
||||
// Match the key-value pairs (with optional quotes around the value) in the gated-block:begin comment
|
||||
const GATED_BLOCK_PARAM_REGEX = /\b(?<key>\w+):["']?(?<value>[^"'\s]+)["']?/g;
|
||||
|
||||
const ALLOWED_GATED_BLOCK_PARAMS = {
|
||||
nonMember: {type: 'boolean'},
|
||||
memberSegment: {type: 'string', allowedValues: ['', 'status:free,status:-free', 'status:free', 'status:-free']}
|
||||
};
|
||||
const ALLOWED_GATED_BLOCK_KEYS = Object.keys(ALLOWED_GATED_BLOCK_PARAMS);
|
||||
|
||||
const parseGatedBlockParams = function (paramsString) {
|
||||
const params = {};
|
||||
|
||||
const matches = paramsString.matchAll(GATED_BLOCK_PARAM_REGEX);
|
||||
for (const match of matches) {
|
||||
const key = match.groups.key;
|
||||
let value = match.groups.value;
|
||||
|
||||
if (!ALLOWED_GATED_BLOCK_KEYS.includes(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Convert "true"/"false" strings to booleans, otherwise keep as string
|
||||
if (value === 'true') {
|
||||
value = true;
|
||||
} else if (value === 'false') {
|
||||
value = false;
|
||||
}
|
||||
|
||||
if (typeof value !== ALLOWED_GATED_BLOCK_PARAMS[key].type) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ALLOWED_GATED_BLOCK_PARAMS[key].allowedValues && !ALLOWED_GATED_BLOCK_PARAMS[key].allowedValues.includes(value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
params[key] = value;
|
||||
}
|
||||
|
||||
return params;
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {string} html - The HTML to strip gated blocks from
|
||||
* @param {object} member - The member who's access should be checked
|
||||
* @returns {string} HTML with gated blocks stripped
|
||||
*/
|
||||
const stripGatedBlocks = function (html, member) {
|
||||
return html.replace(GATED_BLOCK_REGEX, (match, params, content) => {
|
||||
const gatedBlockParams = module.exports.parseGatedBlockParams(params);
|
||||
const checkResult = membersService.contentGating.checkGatedBlockAccess(gatedBlockParams, member);
|
||||
|
||||
if (checkResult === PERMIT_ACCESS) {
|
||||
// return content rather than match to avoid rendering gated block wrapping comments
|
||||
return content;
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
function _updatePlaintext(attrs) {
|
||||
if (attrs.html) {
|
||||
attrs.plaintext = htmlToPlaintext.excerpt(attrs.html);
|
||||
}
|
||||
}
|
||||
|
||||
function _updateExcerpt(attrs) {
|
||||
if (!attrs.custom_excerpt && attrs.excerpt) {
|
||||
attrs.excerpt = htmlToPlaintext.excerpt(attrs.html).substring(0, 500);
|
||||
}
|
||||
}
|
||||
|
||||
// @TODO: reconsider the location of this - it's part of members and adds a property to the API
|
||||
const forPost = (attrs, frame) => {
|
||||
// CASE: Access always defaults to true, unless members is enabled and the member does not have access
|
||||
|
@ -15,11 +95,8 @@ const forPost = (attrs, frame) => {
|
|||
|
||||
if (paywallIndex !== -1) {
|
||||
attrs.html = attrs.html.slice(0, paywallIndex);
|
||||
attrs.plaintext = htmlToPlaintext.excerpt(attrs.html);
|
||||
|
||||
if (!attrs.custom_excerpt && attrs.excerpt) {
|
||||
attrs.excerpt = attrs.plaintext.substring(0, 500);
|
||||
}
|
||||
_updatePlaintext(attrs);
|
||||
_updateExcerpt(attrs);
|
||||
} else {
|
||||
['plaintext', 'html', 'excerpt'].forEach((field) => {
|
||||
if (attrs[field] !== undefined) {
|
||||
|
@ -29,6 +106,13 @@ const forPost = (attrs, frame) => {
|
|||
}
|
||||
}
|
||||
|
||||
const hasGatedBlocks = HAS_GATED_BLOCKS_REGEX.test(attrs.html);
|
||||
if (hasGatedBlocks) {
|
||||
attrs.html = module.exports.stripGatedBlocks(attrs.html, frame.original.context.member);
|
||||
_updatePlaintext(attrs);
|
||||
_updateExcerpt(attrs);
|
||||
}
|
||||
|
||||
if (!Object.prototype.hasOwnProperty.call(frame.options, 'columns') || (frame.options.columns.includes('access'))) {
|
||||
attrs.access = memberHasAccess;
|
||||
}
|
||||
|
@ -36,4 +120,8 @@ const forPost = (attrs, frame) => {
|
|||
return attrs;
|
||||
};
|
||||
|
||||
module.exports.forPost = forPost;
|
||||
module.exports = {
|
||||
parseGatedBlockParams,
|
||||
stripGatedBlocks,
|
||||
forPost
|
||||
};
|
||||
|
|
|
@ -61,8 +61,35 @@ function checkPostAccess(post, member) {
|
|||
return BLOCK_ACCESS;
|
||||
}
|
||||
|
||||
function checkGatedBlockAccess(gatedBlockParams, member) {
|
||||
const {nonMember, memberSegment} = gatedBlockParams;
|
||||
const isLoggedIn = !!member;
|
||||
|
||||
if (nonMember && !isLoggedIn) {
|
||||
return PERMIT_ACCESS;
|
||||
}
|
||||
|
||||
if (!memberSegment && isLoggedIn) {
|
||||
return BLOCK_ACCESS;
|
||||
}
|
||||
|
||||
if (memberSegment && member) {
|
||||
const nqlQuery = nql(memberSegment, {expansions: MEMBER_NQL_EXPANSIONS, transformer: rejectUnknownKeys});
|
||||
|
||||
// if we only have unknown keys the NQL query will be empty and "pass" for all members
|
||||
// we should block access in this case to match the memberSegment:"" behaviour
|
||||
const parsedQuery = nqlQuery.parse();
|
||||
if (Object.keys(parsedQuery).length > 0) {
|
||||
return nqlQuery.queryJSON(member) ? PERMIT_ACCESS : BLOCK_ACCESS;
|
||||
}
|
||||
}
|
||||
|
||||
return BLOCK_ACCESS;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
checkPostAccess,
|
||||
checkGatedBlockAccess,
|
||||
PERMIT_ACCESS,
|
||||
BLOCK_ACCESS
|
||||
};
|
||||
|
|
|
@ -106,9 +106,9 @@
|
|||
"@tryghost/kg-converters": "1.0.8",
|
||||
"@tryghost/kg-default-atoms": "5.0.4",
|
||||
"@tryghost/kg-default-cards": "10.0.10",
|
||||
"@tryghost/kg-default-nodes": "1.2.3",
|
||||
"@tryghost/kg-html-to-lexical": "1.1.23",
|
||||
"@tryghost/kg-lexical-html-renderer": "1.1.25",
|
||||
"@tryghost/kg-default-nodes": "1.3.0",
|
||||
"@tryghost/kg-html-to-lexical": "1.1.24",
|
||||
"@tryghost/kg-lexical-html-renderer": "1.2.0",
|
||||
"@tryghost/kg-mobiledoc-html-renderer": "7.0.7",
|
||||
"@tryghost/limit-service": "1.2.14",
|
||||
"@tryghost/link-redirects": "0.0.0",
|
||||
|
|
|
@ -430,4 +430,20 @@ describe('Posts Content API', function () {
|
|||
assert(!query.sql.includes('*'), 'Query should not select *');
|
||||
}
|
||||
});
|
||||
|
||||
it('Strips out gated blocks not viewable by anonymous viewers ', async function () {
|
||||
const post = await models.Post.add({
|
||||
title: 'title',
|
||||
status: 'published',
|
||||
slug: 'gated-blocks',
|
||||
lexical: JSON.stringify({root: {children: [{type: 'html',version: 1,html: '<p>Visible to free/paid members</p>',visibility: {web: {nonMember: false,memberSegment: 'status:free,status:-free'},email: {memberSegment: ''}}},{type: 'html',version: 1,html: '<p>Visible to anonymous viewers</p>',visibility: {web: {nonMember: true,memberSegment: ''},email: {memberSegment: ''}}},{children: [],direction: null,format: '',indent: 0,type: 'paragraph',version: 1}],direction: null,format: '',indent: 0,type: 'root',version: 1}})
|
||||
}, {context: {internal: true}});
|
||||
|
||||
const response = await agent
|
||||
.get(`posts/${post.id}/`)
|
||||
.expectStatus(200);
|
||||
|
||||
assert.doesNotMatch(response.body.posts[0].html, /Visible to free\/paid members/);
|
||||
assert.match(response.body.posts[0].html, /Visible to anonymous viewers/);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1834,12 +1834,12 @@ Ghost: Independent technology for modern publishingBeautiful, modern publishing
|
|||
<!-- POST CONTENT START -->
|
||||
<p style=\\"margin: 0 0 1.5em 0; line-height: 1.6em;\\">This is just a simple paragraph, no frills.</p><blockquote style=\\"margin: 0; padding: 0; border-left: #FF1A75 2px solid; font-size: 17px; font-weight: 500; line-height: 1.6em; letter-spacing: -0.2px;\\"><p style=\\"line-height: 1.6em; margin: 2em 25px; font-size: 1em; padding: 0;\\">This is block quote</p></blockquote><blockquote class=\\"kg-blockquote-alt\\" style=\\"margin: 0; padding: 0; font-weight: 500; line-height: 1.6em; letter-spacing: -0.2px; border-left: 0 none; text-align: center; font-size: 1.2em; font-style: italic; color: #738a94;\\"><p style=\\"line-height: 1.6em; margin: 2em 25px; font-size: 1em; margin-right: 50px; margin-left: 50px; padding: 0;\\">This is a...different block quote</p></blockquote><h2 id=\\"this-is-a-heading\\" 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: 32px;\\">This is a heading!</h2><h3 id=\\"heres-a-smaller-heading\\" 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;\\">Here's a smaller heading.</h3><div class=\\"kg-card kg-image-card kg-card-hascaption\\" style=\\"margin: 0 0 1.5em; padding: 0;\\"><img src=\\"https://upload.wikimedia.org/wikipedia/commons/thumb/8/8c/Cow_%28Fleckvieh_breed%29_Oeschinensee_Slaunger_2009-07-07.jpg/1920px-Cow_%28Fleckvieh_breed%29_Oeschinensee_Slaunger_2009-07-07.jpg\\" class=\\"kg-image\\" alt=\\"Cows eat grass.\\" loading=\\"lazy\\" style=\\"border: none; -ms-interpolation-mode: bicubic; max-width: 100%; display: block; margin: 0 auto; height: auto; width: auto;\\" width=\\"auto\\" height=\\"auto\\"><div class=\\"kg-card-figcaption\\" style=\\"text-align: center; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; padding-top: 10px; padding-bottom: 10px; line-height: 1.5em; color: #738a94; font-size: 13px;\\"><span style=\\"text-align: center; white-space: pre-wrap;\\">A lovely cow</span></div></div><h1 id=\\"a-heading\\" 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; text-rendering: optimizeLegibility; margin: 1.5em 0 0.5em 0; font-size: 42px; font-weight: 700;\\">A heading</h1>
|
||||
<p style=\\"margin: 0 0 1.5em 0; line-height: 1.6em;\\">and a paragraph (in markdown!)</p>
|
||||
<div>
|
||||
|
||||
<!--kg-card-begin: html-->
|
||||
<p style=\\"margin: 0 0 1.5em 0; line-height: 1.6em;\\">A paragraph inside an HTML card.</p>
|
||||
<p style=\\"margin: 0 0 1.5em 0; line-height: 1.6em;\\">And another one, with some <b>bold</b> text.</p>
|
||||
<!--kg-card-end: html-->
|
||||
</div><hr style=\\"position: relative; display: block; width: 100%; margin: 3em 0; padding: 0; height: 1px; border: 0; border-top: 1px solid #e5eff5;\\"><div class=\\"kg-card kg-gallery-card kg-width-wide kg-card-hascaption\\" style=\\"margin: 0 0 1.5em; padding: 0;\\"><div class=\\"kg-gallery-container\\" style=\\"margin-top: -20px;\\"><div class=\\"kg-gallery-row\\"><div class=\\"kg-gallery-image\\"><img src=\\"https://plus.unsplash.com/premium_photo-1700558685152-81f821a40724?q=80&w=2070&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D\\" width=\\"600\\" height=\\"400\\" loading=\\"lazy\\" alt style=\\"border: none; -ms-interpolation-mode: bicubic; max-width: 100%; padding-top: 20px; width: 100%; height: auto;\\"></div></div></div><div class=\\"kg-card-figcaption\\" style=\\"text-align: center; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; padding-top: 10px; padding-bottom: 10px; line-height: 1.5em; color: #738a94; font-size: 13px;\\"><p dir=\\"ltr\\" style=\\"margin: 0 0 1.5em 0; line-height: 1.6em;\\"><span style=\\"white-space: pre-wrap;\\">A gallery.</span></p></div></div><div>
|
||||
<hr style=\\"position: relative; display: block; width: 100%; margin: 3em 0; padding: 0; height: 1px; border: 0; border-top: 1px solid #e5eff5;\\"><div class=\\"kg-card kg-gallery-card kg-width-wide kg-card-hascaption\\" style=\\"margin: 0 0 1.5em; padding: 0;\\"><div class=\\"kg-gallery-container\\" style=\\"margin-top: -20px;\\"><div class=\\"kg-gallery-row\\"><div class=\\"kg-gallery-image\\"><img src=\\"https://plus.unsplash.com/premium_photo-1700558685152-81f821a40724?q=80&w=2070&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D\\" width=\\"600\\" height=\\"400\\" loading=\\"lazy\\" alt style=\\"border: none; -ms-interpolation-mode: bicubic; max-width: 100%; padding-top: 20px; width: 100%; height: auto;\\"></div></div></div><div class=\\"kg-card-figcaption\\" style=\\"text-align: center; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; padding-top: 10px; padding-bottom: 10px; line-height: 1.5em; color: #738a94; font-size: 13px;\\"><p dir=\\"ltr\\" style=\\"margin: 0 0 1.5em 0; line-height: 1.6em;\\"><span style=\\"white-space: pre-wrap;\\">A gallery.</span></p></div></div><div>
|
||||
<!--[if !mso !vml]-->
|
||||
<div class=\\"kg-card kg-bookmark-card kg-card-hascaption\\" style=\\"margin: 0 0 1.5em; padding: 0; width: 100%; background: #ffffff;\\">
|
||||
<a class=\\"kg-bookmark-container\\" href=\\"http://127.0.0.1:2369/r/xxxxxx?m=member-uuid\\" style=\\"display: flex; min-height: 148px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; border-radius: 3px; border: 1px solid #e5eff5; overflow-wrap: anywhere; color: #FF1A75; text-decoration: none;\\" target=\\"_blank\\">
|
||||
|
|
|
@ -191,6 +191,7 @@ describe('Can send cards via email', function () {
|
|||
'extended-text', // not a card
|
||||
'extended-quote', // not a card
|
||||
'extended-heading', // not a card
|
||||
'call-to-action', // behind the contentVisibilityAlpha labs flag
|
||||
// not a card and shouldn't be present in published posts / emails
|
||||
'tk',
|
||||
'at-link',
|
||||
|
|
|
@ -353,7 +353,7 @@ describe('Unit: endpoints/utils/serializers/input/posts', function () {
|
|||
serializers.input.posts.edit(apiConfig, frame);
|
||||
|
||||
let postData = frame.data.posts[0];
|
||||
postData.lexical.should.equal('{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"this is great feature","type":"extended-text","version":1}],"direction":null,"format":"","indent":0,"type":"paragraph","version":1},{"type":"html","version":1,"html":"<div class=\\"custom\\">My Custom HTML</div>","visibility":{"showOnEmail":true,"showOnWeb":true,"segment":""}},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"custom html preserved!","type":"extended-text","version":1}],"direction":null,"format":"","indent":0,"type":"paragraph","version":1}],"direction":null,"format":"","indent":0,"type":"root","version":1}}');
|
||||
postData.lexical.should.equal('{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"this is great feature","type":"extended-text","version":1}],"direction":null,"format":"","indent":0,"type":"paragraph","version":1},{"type":"html","version":1,"html":"<div class=\\"custom\\">My Custom HTML</div>","visibility":{"web":{"nonMember":true,"memberSegment":"status:free,status:-free"},"email":{"memberSegment":"status:free,status:-free"}}},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"custom html preserved!","type":"extended-text","version":1}],"direction":null,"format":"","indent":0,"type":"paragraph","version":1}],"direction":null,"format":"","indent":0,"type":"root","version":1}}');
|
||||
});
|
||||
|
||||
it('throws error when HTML conversion fails', function () {
|
||||
|
|
|
@ -1,8 +1,25 @@
|
|||
const should = require('should');
|
||||
const assert = require('assert/strict');
|
||||
const sinon = require('sinon');
|
||||
const gating = require('../../../../../../../../core/server/api/endpoints/utils/serializers/output/utils/post-gating');
|
||||
const membersContentGating = require('../../../../../../../../core/server/services/members/content-gating');
|
||||
|
||||
describe('Unit: endpoints/utils/serializers/output/utils/post-gating', function () {
|
||||
afterEach(function () {
|
||||
sinon.restore();
|
||||
});
|
||||
|
||||
describe('for post', function () {
|
||||
let frame;
|
||||
|
||||
beforeEach(function () {
|
||||
frame = {
|
||||
options: {},
|
||||
original: {
|
||||
context: {}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
it('should NOT hide content attributes when visibility is public', function () {
|
||||
const attrs = {
|
||||
visibility: 'public',
|
||||
|
@ -10,16 +27,9 @@ describe('Unit: endpoints/utils/serializers/output/utils/post-gating', function
|
|||
html: '<p>I am here to stay</p>'
|
||||
};
|
||||
|
||||
const frame = {
|
||||
options: {},
|
||||
original: {
|
||||
context: {}
|
||||
}
|
||||
};
|
||||
|
||||
gating.forPost(attrs, frame);
|
||||
|
||||
attrs.plaintext.should.eql('no touching');
|
||||
assert.equal(attrs.plaintext, 'no touching');
|
||||
});
|
||||
|
||||
it('should hide content attributes when visibility is "members"', function () {
|
||||
|
@ -29,17 +39,10 @@ describe('Unit: endpoints/utils/serializers/output/utils/post-gating', function
|
|||
html: '<p>I am here to stay</p>'
|
||||
};
|
||||
|
||||
const frame = {
|
||||
options: {},
|
||||
original: {
|
||||
context: {}
|
||||
}
|
||||
};
|
||||
|
||||
gating.forPost(attrs, frame);
|
||||
|
||||
attrs.plaintext.should.eql('');
|
||||
attrs.html.should.eql('');
|
||||
assert.equal(attrs.plaintext, '');
|
||||
assert.equal(attrs.html, '');
|
||||
});
|
||||
|
||||
it('should NOT hide content attributes when visibility is "members" and member is present', function () {
|
||||
|
@ -49,19 +52,12 @@ describe('Unit: endpoints/utils/serializers/output/utils/post-gating', function
|
|||
html: '<p>What\'s the matter?</p>'
|
||||
};
|
||||
|
||||
const frame = {
|
||||
options: {},
|
||||
original: {
|
||||
context: {
|
||||
member: {}
|
||||
}
|
||||
}
|
||||
};
|
||||
frame.original.context.member = {};
|
||||
|
||||
gating.forPost(attrs, frame);
|
||||
|
||||
attrs.plaintext.should.eql('I see dead people');
|
||||
attrs.html.should.eql('<p>What\'s the matter?</p>');
|
||||
assert.equal(attrs.plaintext, 'I see dead people');
|
||||
assert.equal(attrs.html, '<p>What\'s the matter?</p>');
|
||||
});
|
||||
|
||||
it('should hide content attributes when visibility is "paid" and member has status of "free"', function () {
|
||||
|
@ -71,21 +67,12 @@ describe('Unit: endpoints/utils/serializers/output/utils/post-gating', function
|
|||
html: '<p>What\'s the matter?</p>'
|
||||
};
|
||||
|
||||
const frame = {
|
||||
options: {},
|
||||
original: {
|
||||
context: {
|
||||
member: {
|
||||
status: 'free'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
frame.original.context.member = {status: 'free'};
|
||||
|
||||
gating.forPost(attrs, frame);
|
||||
|
||||
attrs.plaintext.should.eql('');
|
||||
attrs.html.should.eql('');
|
||||
assert.equal(attrs.plaintext, '');
|
||||
assert.equal(attrs.html, '');
|
||||
});
|
||||
|
||||
it('should NOT hide content attributes when visibility is "paid" and member has status of "paid"', function () {
|
||||
|
@ -95,21 +82,204 @@ describe('Unit: endpoints/utils/serializers/output/utils/post-gating', function
|
|||
html: '<p>Can read this</p>'
|
||||
};
|
||||
|
||||
const frame = {
|
||||
options: {},
|
||||
original: {
|
||||
context: {
|
||||
member: {
|
||||
status: 'paid'
|
||||
}
|
||||
}
|
||||
}
|
||||
frame.original.context.member = {status: 'paid'};
|
||||
|
||||
gating.forPost(attrs, frame);
|
||||
|
||||
assert.equal(attrs.plaintext, 'Secret paid content');
|
||||
assert.equal(attrs.html, '<p>Can read this</p>');
|
||||
});
|
||||
|
||||
it('does not call stripGatedBlocks when a post has no gated blocks', function () {
|
||||
const attrs = {
|
||||
visibility: 'public',
|
||||
html: '<p>no gated blocks</p>'
|
||||
};
|
||||
|
||||
const stripGatedBlocksStub = sinon.stub(gating, 'stripGatedBlocks');
|
||||
gating.forPost(attrs, frame);
|
||||
sinon.assert.notCalled(stripGatedBlocksStub);
|
||||
});
|
||||
|
||||
it('calls stripGatedBlocks when a post has gated blocks', function () {
|
||||
const attrs = {
|
||||
visibility: 'public',
|
||||
html: '<!--kg-gated-block:begin nonMember:true--><p>gated block</p><!--kg-gated-block:end-->'
|
||||
};
|
||||
|
||||
const stripGatedBlocksStub = sinon.stub(gating, 'stripGatedBlocks');
|
||||
gating.forPost(attrs, frame);
|
||||
sinon.assert.calledOnce(stripGatedBlocksStub);
|
||||
});
|
||||
|
||||
it('updates html, plaintext, and excerpt when a post has gated blocks', function () {
|
||||
const attrs = {
|
||||
visibility: 'public',
|
||||
html: `
|
||||
<!--kg-gated-block:begin nonMember:false memberSegment:"status:free,status:-free"--><p>Members only.</p><!--kg-gated-block:end-->
|
||||
<p>Everyone can see this.</p>
|
||||
<!--kg-gated-block:begin nonMember:true--><p>Anonymous only.</p><!--kg-gated-block:end-->
|
||||
`,
|
||||
plaintext: 'Members only. Everyone can see this. Anonymous only.',
|
||||
excerpt: 'Members only. Everyone can see this. Anonymous only.'
|
||||
};
|
||||
|
||||
gating.forPost(attrs, frame);
|
||||
|
||||
attrs.plaintext.should.eql('Secret paid content');
|
||||
attrs.html.should.eql('<p>Can read this</p>');
|
||||
assert.match(attrs.html, /<p>Everyone can see this\.<\/p>\n\s+<p>Anonymous only.<\/p>/);
|
||||
assert.match(attrs.plaintext, /^\n+Everyone can see this.\n+Anonymous only.\n$/);
|
||||
assert.match(attrs.excerpt, /^\n+Everyone can see this.\n+Anonymous only.\n$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseGatedBlockParams', function () {
|
||||
function testFn(input, expected) {
|
||||
const params = gating.parseGatedBlockParams(input);
|
||||
assert.deepEqual(params, expected);
|
||||
}
|
||||
|
||||
const validTestCases = [{
|
||||
input: 'nonMember:true',
|
||||
output: {nonMember: true}
|
||||
}, {
|
||||
input: 'nonMember:false',
|
||||
output: {nonMember: false}
|
||||
}, {
|
||||
input: 'nonMember:\'true\'',
|
||||
output: {nonMember: true}
|
||||
}, {
|
||||
input: 'nonMember:\'false\'',
|
||||
output: {nonMember: false}
|
||||
}, {
|
||||
input: 'nonMember:"true"',
|
||||
output: {nonMember: true}
|
||||
}, {
|
||||
input: 'memberSegment:\'\'',
|
||||
output: {}
|
||||
}, {
|
||||
input: 'memberSegment:"status:free"',
|
||||
output: {memberSegment: 'status:free'}
|
||||
}, {
|
||||
input: 'nonMember:true memberSegment:"status:free"',
|
||||
output: {nonMember: true, memberSegment: 'status:free'}
|
||||
}, {
|
||||
input: 'memberSegment:"status:free" nonMember:true',
|
||||
output: {nonMember: true, memberSegment: 'status:free'}
|
||||
}];
|
||||
|
||||
validTestCases.forEach(function (testCase) {
|
||||
it(`should parse ${testCase.input} correctly`, function () {
|
||||
testFn(testCase.input, testCase.output);
|
||||
});
|
||||
});
|
||||
|
||||
// we only support known keys and values with the correct types and allowed values
|
||||
// we should also handle malformed input gracefully
|
||||
const invalidTestCases = [{
|
||||
input: 'unknownKey:true nonMember:false',
|
||||
output: {nonMember: false}
|
||||
}, {
|
||||
input: 'nonMember:invalid',
|
||||
output: {}
|
||||
}, {
|
||||
input: 'nonMember: memberSegment:"status:free"',
|
||||
output: {memberSegment: 'status:free'}
|
||||
}, {
|
||||
input: 'memberSegment:"status:paid"',
|
||||
output: {}
|
||||
}, {
|
||||
input: 'nonMember:memberSegment:"status:free"',
|
||||
output: {}
|
||||
}, {
|
||||
input: 'memberSegment',
|
||||
output: {}
|
||||
}];
|
||||
|
||||
invalidTestCases.forEach(function (testCase) {
|
||||
it(`should handle unexpected input ${testCase.input} correctly`, function () {
|
||||
testFn(testCase.input, testCase.output);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripGatedBlocks', function () {
|
||||
function stubCheckGatedBlockAccess(permitAccess) {
|
||||
return sinon.stub(membersContentGating, 'checkGatedBlockAccess').returns(permitAccess);
|
||||
}
|
||||
|
||||
it('handles content with no gated blocks', function () {
|
||||
const checkGatedBlockAccessStub = stubCheckGatedBlockAccess(true);
|
||||
const html = '<p>no gated blocks</p>';
|
||||
const result = gating.stripGatedBlocks(html, {});
|
||||
assert.equal(result, html);
|
||||
sinon.assert.notCalled(checkGatedBlockAccessStub);
|
||||
});
|
||||
|
||||
it('handles content with only a denied gated block', function () {
|
||||
const checkGatedBlockAccessStub = stubCheckGatedBlockAccess(false);
|
||||
const html = '<!--kg-gated-block:begin nonMember:false--><p>gated blocks</p><!--kg-gated-block:end-->';
|
||||
const result = gating.stripGatedBlocks(html, {});
|
||||
sinon.assert.calledWith(checkGatedBlockAccessStub, {nonMember: false}, {});
|
||||
assert.equal(result, '');
|
||||
});
|
||||
|
||||
it('handles content with only a permitted gated block', function () {
|
||||
const checkGatedBlockAccessStub = stubCheckGatedBlockAccess(true);
|
||||
const html = '<!--kg-gated-block:begin nonMember:true--><p>gated blocks</p><!--kg-gated-block:end-->';
|
||||
const result = gating.stripGatedBlocks(html, {});
|
||||
sinon.assert.calledWith(checkGatedBlockAccessStub, {nonMember: true}, {});
|
||||
assert.equal(result, '<p>gated blocks</p>');
|
||||
});
|
||||
|
||||
it('handles content with multiple permitted blocks', function () {
|
||||
const checkGatedBlockAccessStub = stubCheckGatedBlockAccess(true);
|
||||
const html = `
|
||||
<!--kg-gated-block:begin nonMember:true--><p>gated block 1</p><!--kg-gated-block:end-->
|
||||
<p>Non-gated block</p>
|
||||
<!--kg-gated-block:begin nonMember:true--><p>gated block 2</p><!--kg-gated-block:end-->
|
||||
`;
|
||||
const result = gating.stripGatedBlocks(html, {});
|
||||
sinon.assert.calledTwice(checkGatedBlockAccessStub);
|
||||
assert.equal(result, `
|
||||
<p>gated block 1</p>
|
||||
<p>Non-gated block</p>
|
||||
<p>gated block 2</p>
|
||||
`);
|
||||
});
|
||||
|
||||
it('handles mix of permitted and denied blocks', function () {
|
||||
const checkGatedBlockAccessStub = sinon.stub(membersContentGating, 'checkGatedBlockAccess')
|
||||
.onFirstCall().returns(false)
|
||||
.onSecondCall().returns(true);
|
||||
const html = `
|
||||
<!--kg-gated-block:begin nonMember:true--><p>gated block 1</p><!--kg-gated-block:end-->
|
||||
<p>Non-gated block</p>
|
||||
<!--kg-gated-block:begin nonMember:false--><p>gated block 2</p><!--kg-gated-block:end-->
|
||||
`;
|
||||
const result = gating.stripGatedBlocks(html, null);
|
||||
sinon.assert.calledTwice(checkGatedBlockAccessStub);
|
||||
assert.equal(result.trim(), `
|
||||
<p>Non-gated block</p>
|
||||
<p>gated block 2</p>
|
||||
`.trim());
|
||||
});
|
||||
|
||||
it('handles malformed gated block comments', function () {
|
||||
const checkGatedBlockAccessStub = stubCheckGatedBlockAccess(true);
|
||||
const html = `
|
||||
<!--kg-gated-block:begin-><p>malformed gated block 1</p><!--kg-gated-block:end-->
|
||||
<p>Non-gated block</p>
|
||||
<!--kg-gated-block:begin <p>malformed gated block 2</p>
|
||||
<!--kg-gated-block:begin nonMember:true--><p>valid gated block</p><!--kg-gated-block:end-->
|
||||
`;
|
||||
const result = gating.stripGatedBlocks(html, null);
|
||||
sinon.assert.calledOnce(checkGatedBlockAccessStub);
|
||||
assert.equal(result.trim(), `
|
||||
<!--kg-gated-block:begin-><p>malformed gated block 1</p><!--kg-gated-block:end-->
|
||||
<p>Non-gated block</p>
|
||||
<!--kg-gated-block:begin <p>malformed gated block 2</p>
|
||||
<p>valid gated block</p>
|
||||
`.trim());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
const should = require('should');
|
||||
const {checkPostAccess} = require('../../../../../core/server/services/members/content-gating');
|
||||
const {checkPostAccess, checkGatedBlockAccess} = require('../../../../../core/server/services/members/content-gating');
|
||||
|
||||
describe('Members Service - Content gating', function () {
|
||||
describe('checkPostAccess', function () {
|
||||
|
@ -90,4 +90,39 @@ describe('Members Service - Content gating', function () {
|
|||
should(access).be.false();
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkGatedBlockAccess', function () {
|
||||
function testCheckGatedBlockAccess({params, member, expectedAccess}) {
|
||||
const access = checkGatedBlockAccess(params, member);
|
||||
should(access).be.exactly(expectedAccess);
|
||||
}
|
||||
|
||||
it('nonMember:true permits access when not logged in', function () {
|
||||
testCheckGatedBlockAccess({params: {nonMember: true}, member: null, expectedAccess: true});
|
||||
});
|
||||
|
||||
it('nonMember:false blocks access when not logged in', function () {
|
||||
testCheckGatedBlockAccess({params: {nonMember: false}, member: null, expectedAccess: false});
|
||||
});
|
||||
|
||||
it('memberSegment:"" blocks access when logged in', function () {
|
||||
testCheckGatedBlockAccess({params: {memberSegment: ''}, member: {}, expectedAccess: false});
|
||||
});
|
||||
|
||||
it('memberSegment:undefined blocks access when logged in', function () {
|
||||
testCheckGatedBlockAccess({params: {memberSegment: undefined}, member: {}, expectedAccess: false});
|
||||
});
|
||||
|
||||
it('memberSegment:"status:free" permits access when logged in as free member', function () {
|
||||
testCheckGatedBlockAccess({params: {memberSegment: 'status:free'}, member: {status: 'free'}, expectedAccess: true});
|
||||
});
|
||||
|
||||
it('memberSegment:"status:free" blocks access when logged in as paid member', function () {
|
||||
testCheckGatedBlockAccess({params: {memberSegment: 'status:free'}, member: {status: 'paid'}, expectedAccess: false});
|
||||
});
|
||||
|
||||
it('handles unknown segment keys', function () {
|
||||
testCheckGatedBlockAccess({params: {memberSegment: 'unknown:free'}, member: {status: 'free'}, expectedAccess: false});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
58
yarn.lock
58
yarn.lock
|
@ -7786,10 +7786,10 @@
|
|||
lodash "^4.17.21"
|
||||
luxon "^3.5.0"
|
||||
|
||||
"@tryghost/kg-default-nodes@1.2.3":
|
||||
version "1.2.3"
|
||||
resolved "https://registry.yarnpkg.com/@tryghost/kg-default-nodes/-/kg-default-nodes-1.2.3.tgz#598563ac26d50fdd4d1a763850fd1127a15e09c9"
|
||||
integrity sha512-kEXvWL/gYDA4E32yfIBocgXd4r1049DRt2jG/zbQLA5THRi0F9gcbiV7119U+QjL2QKV3sM1fS+FBJYFsiInPA==
|
||||
"@tryghost/kg-default-nodes@1.3.0":
|
||||
version "1.3.0"
|
||||
resolved "https://registry.yarnpkg.com/@tryghost/kg-default-nodes/-/kg-default-nodes-1.3.0.tgz#76975dac11e2c7861e47c55fe2fc915f8f333686"
|
||||
integrity sha512-Ut/UZ0IIlPgbQxmNdUSKekBwP4ZI6w036EQhqboafSSs8fSVX5L7VwpeB8Zc22+WcZiozAC8f+w4NnS5eQ7x2Q==
|
||||
dependencies:
|
||||
"@lexical/clipboard" "0.13.1"
|
||||
"@lexical/rich-text" "0.13.1"
|
||||
|
@ -7803,21 +7803,21 @@
|
|||
lodash "^4.17.21"
|
||||
luxon "^3.5.0"
|
||||
|
||||
"@tryghost/kg-default-transforms@1.1.23":
|
||||
version "1.1.23"
|
||||
resolved "https://registry.yarnpkg.com/@tryghost/kg-default-transforms/-/kg-default-transforms-1.1.23.tgz#b17de41b49d8eaf001d356b7212936bf6ffe81c2"
|
||||
integrity sha512-AXsanvFOag81ESfEw4GX0rPn5hBR89QnKncRAkw+MYQWJCTykCadsHwG7owWkGqLOq5RTg3gPPO/kRB5UQAwLQ==
|
||||
"@tryghost/kg-default-transforms@1.1.24":
|
||||
version "1.1.24"
|
||||
resolved "https://registry.yarnpkg.com/@tryghost/kg-default-transforms/-/kg-default-transforms-1.1.24.tgz#4ff857b0cbcbc42bec0187cc2497154302a26640"
|
||||
integrity sha512-vArLAELM3xrbtSChLyiFKKP2dvkbJRWWmyOEKfIC0Uo/akgPpauEEL30HjO0xnvBfh0xfv/iak9NMW1Kv8Eeew==
|
||||
dependencies:
|
||||
"@lexical/list" "0.13.1"
|
||||
"@lexical/rich-text" "0.13.1"
|
||||
"@lexical/utils" "0.13.1"
|
||||
"@tryghost/kg-default-nodes" "1.2.3"
|
||||
"@tryghost/kg-default-nodes" "1.3.0"
|
||||
lexical "0.13.1"
|
||||
|
||||
"@tryghost/kg-html-to-lexical@1.1.23":
|
||||
version "1.1.23"
|
||||
resolved "https://registry.yarnpkg.com/@tryghost/kg-html-to-lexical/-/kg-html-to-lexical-1.1.23.tgz#5af60af2f5c6e46b76e239ae2ed9e1d9d6320c1f"
|
||||
integrity sha512-XJg7b/faqS7rF5G7gnj3q4UHOZYJHDLXTvSLUbrSV53Dx67mXcUJmhQFogI2Ywqwz9kcUdzbh1NPmh8pBGLH1A==
|
||||
"@tryghost/kg-html-to-lexical@1.1.24":
|
||||
version "1.1.24"
|
||||
resolved "https://registry.yarnpkg.com/@tryghost/kg-html-to-lexical/-/kg-html-to-lexical-1.1.24.tgz#31e4fe84c09f1d2406f886c7ab583ce92e4373ab"
|
||||
integrity sha512-9OZJ0UPh5EArEeCafX0rJg2yq9XLmzfSZ09yx2ba/smt3Naw2SOMrbwXGXM7rX5AA2pWDZI5LtIqpR1NfnUF+g==
|
||||
dependencies:
|
||||
"@lexical/clipboard" "0.13.1"
|
||||
"@lexical/headless" "0.13.1"
|
||||
|
@ -7825,15 +7825,15 @@
|
|||
"@lexical/link" "0.13.1"
|
||||
"@lexical/list" "0.13.1"
|
||||
"@lexical/rich-text" "0.13.1"
|
||||
"@tryghost/kg-default-nodes" "1.2.3"
|
||||
"@tryghost/kg-default-transforms" "1.1.23"
|
||||
"@tryghost/kg-default-nodes" "1.3.0"
|
||||
"@tryghost/kg-default-transforms" "1.1.24"
|
||||
jsdom "^24.1.0"
|
||||
lexical "0.13.1"
|
||||
|
||||
"@tryghost/kg-lexical-html-renderer@1.1.25":
|
||||
version "1.1.25"
|
||||
resolved "https://registry.yarnpkg.com/@tryghost/kg-lexical-html-renderer/-/kg-lexical-html-renderer-1.1.25.tgz#d4b21e8b6d2af50a3da34896fb48933f65e4b7d0"
|
||||
integrity sha512-4pSGVJM3q8CnKJdBFkMJ/F08O9vi1gLMs64TisTvyNKo+3uGw4cqhw0s3SVqtvAOkmkXrU9RUSOACuzDl7e8mw==
|
||||
"@tryghost/kg-lexical-html-renderer@1.2.0":
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/@tryghost/kg-lexical-html-renderer/-/kg-lexical-html-renderer-1.2.0.tgz#4fdc8be0fc2a16551f3dc180aa8ba1387c7e1eef"
|
||||
integrity sha512-prQbMN+B9G66RZ6apJLbMt/Y0ZcLuotaHMjkycutnkB4Znqi2fA7Gfys7Y9H2dVppBXIi//4+VYMrvkaqd52yg==
|
||||
dependencies:
|
||||
"@lexical/clipboard" "0.13.1"
|
||||
"@lexical/code" "0.13.1"
|
||||
|
@ -7841,8 +7841,8 @@
|
|||
"@lexical/link" "0.13.1"
|
||||
"@lexical/list" "0.13.1"
|
||||
"@lexical/rich-text" "0.13.1"
|
||||
"@tryghost/kg-default-nodes" "1.2.3"
|
||||
"@tryghost/kg-default-transforms" "1.1.23"
|
||||
"@tryghost/kg-default-nodes" "1.3.0"
|
||||
"@tryghost/kg-default-transforms" "1.1.24"
|
||||
jsdom "^24.1.0"
|
||||
lexical "0.13.1"
|
||||
|
||||
|
@ -7877,10 +7877,10 @@
|
|||
dependencies:
|
||||
"@tryghost/kg-clean-basic-html" "4.1.1"
|
||||
|
||||
"@tryghost/kg-unsplash-selector@0.2.7":
|
||||
version "0.2.7"
|
||||
resolved "https://registry.yarnpkg.com/@tryghost/kg-unsplash-selector/-/kg-unsplash-selector-0.2.7.tgz#31eb215fa571c108af8691e224e1010b96d68ca6"
|
||||
integrity sha512-fgM6uS9AdcET3s7L7kQ1DbvNf5D7axxyHPU6Dw+FxAGbIQkIgXHJMSFTYrO2mtGTPeYSOKt/h9DLe/2T0JscWQ==
|
||||
"@tryghost/kg-unsplash-selector@0.2.8":
|
||||
version "0.2.8"
|
||||
resolved "https://registry.yarnpkg.com/@tryghost/kg-unsplash-selector/-/kg-unsplash-selector-0.2.8.tgz#ce2486421049f7ee4fd6bd84217a977a94284be5"
|
||||
integrity sha512-T2sk3GZ5k0/5lub9pF9eZ3UqhkFBywUPTnLDDDsC9/Rmpgbf1+xQHSZQTnikbvLBj5SKbyX2++bSnyShnXiwrw==
|
||||
|
||||
"@tryghost/kg-utils@1.0.29":
|
||||
version "1.0.29"
|
||||
|
@ -7889,10 +7889,10 @@
|
|||
dependencies:
|
||||
semver "^7.6.2"
|
||||
|
||||
"@tryghost/koenig-lexical@1.4.0":
|
||||
version "1.4.0"
|
||||
resolved "https://registry.yarnpkg.com/@tryghost/koenig-lexical/-/koenig-lexical-1.4.0.tgz#b0ce10deea253b1c5d55d04aee8bc77baea2192d"
|
||||
integrity sha512-JhkWdmvr+eznZC8ckdjZ7Oetx/VXt8Osu0vuCvfp1GwfCCkuOqqSp+T28hwLqS3T94hOWri/hwadq2EBWyMBnQ==
|
||||
"@tryghost/koenig-lexical@1.5.1":
|
||||
version "1.5.1"
|
||||
resolved "https://registry.yarnpkg.com/@tryghost/koenig-lexical/-/koenig-lexical-1.5.1.tgz#1bded5bff93c4bbd5c24afd5f310756d3f26802f"
|
||||
integrity sha512-znziI8FcUQMJ6vQ22VxrILm6p/ZiE8C4x3aOVwspvsNRGrVdSPQH3YsROcW0XOmuJlfLISlr8Jwocgpfwrp2lA==
|
||||
|
||||
"@tryghost/limit-service@1.2.14":
|
||||
version "1.2.14"
|
||||
|
|
Loading…
Add table
Reference in a new issue