0
Fork 0
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:
Kevin Ansfield 2025-01-30 12:47:42 +00:00 committed by GitHub
parent 0cdec925ae
commit 523b9d47a0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 433 additions and 96 deletions

View file

@ -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",

View file

@ -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 @@
}
}
}
}
}

View file

@ -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
};

View file

@ -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
};

View file

@ -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",

View file

@ -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/);
});
});

View file

@ -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, &#39;Segoe UI&#39;, Roboto, Helvetica, Arial, sans-serif, &#39;Apple Color Emoji&#39;, &#39;Segoe UI Emoji&#39;, &#39;Segoe UI Symbol&#39;; 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, &#39;Segoe UI&#39;, Roboto, Helvetica, Arial, sans-serif, &#39;Apple Color Emoji&#39;, &#39;Segoe UI Emoji&#39;, &#39;Segoe UI Symbol&#39;; line-height: 1.11em; font-weight: 700; text-rendering: optimizeLegibility; margin: 1.5em 0 0.5em 0; font-size: 26px;\\">Here&#39;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, &#39;Segoe UI&#39;, Roboto, Helvetica, Arial, sans-serif, &#39;Apple Color Emoji&#39;, &#39;Segoe UI Emoji&#39;, &#39;Segoe UI Symbol&#39;; 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, &#39;Segoe UI&#39;, Roboto, Helvetica, Arial, sans-serif, &#39;Apple Color Emoji&#39;, &#39;Segoe UI Emoji&#39;, &#39;Segoe UI Symbol&#39;; 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&amp;w=2070&amp;auto=format&amp;fit=crop&amp;ixlib=rb-4.0.3&amp;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, &#39;Segoe UI&#39;, Roboto, Helvetica, Arial, sans-serif, &#39;Apple Color Emoji&#39;, &#39;Segoe UI Emoji&#39;, &#39;Segoe UI Symbol&#39;; 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&amp;w=2070&amp;auto=format&amp;fit=crop&amp;ixlib=rb-4.0.3&amp;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, &#39;Segoe UI&#39;, Roboto, Helvetica, Arial, sans-serif, &#39;Apple Color Emoji&#39;, &#39;Segoe UI Emoji&#39;, &#39;Segoe UI Symbol&#39;; 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, &#39;Segoe UI&#39;, Roboto, Oxygen, Ubuntu, Cantarell, &#39;Open Sans&#39;, &#39;Helvetica Neue&#39;, sans-serif; border-radius: 3px; border: 1px solid #e5eff5; overflow-wrap: anywhere; color: #FF1A75; text-decoration: none;\\" target=\\"_blank\\">

View file

@ -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',

View file

@ -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 () {

View file

@ -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());
});
});
});

View file

@ -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});
});
});
});

View file

@ -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"