Skip to content

Commit

Permalink
Added support for gating access to blocks of post content
Browse files Browse the repository at this point in the history
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 nonMembers:true/false memberSegment:"status:free,status:-free"-->...gated content...<!--kg-gated-block:end-->`
  - 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
- bumped Koenig packages to add support for new `visibility` card property format when rendering
  • Loading branch information
kevinansfield committed Jan 29, 2025
1 parent 99037bb commit beaf57f
Show file tree
Hide file tree
Showing 8 changed files with 355 additions and 46 deletions.
2 changes: 1 addition & 1 deletion apps/admin-x-settings/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,56 @@
const membersService = require('../../../../../../services/members');
const htmlToPlaintext = require('@tryghost/html-to-plaintext');

const {PERMIT_ACCESS} = membersService.contentGating;

const HAS_GATED_BLOCKS_REGEX = /<!--\s*kg-gated-block:begin/;
const GATED_BLOCK_REGEX = /<!--\s*kg-gated-block:begin\s+([^\n]+?)\s*-->\s*([\s\S]*?)\s*<!--\s*kg-gated-block:end\s*-->/g;

const parseGatedBlockParams = function (paramsString) {
const params = {};
// Match key-value pairs with optional quotes around the value
const paramsRegex = /\b(?<key>\w+):["']?(?<value>[^"'\s]+)["']?/g;
let match;
while ((match = paramsRegex.exec(paramsString)) !== null) {
const key = match.groups.key;
const value = match.groups.value;
// Convert "true"/"false" strings to booleans for `nonMember`
params[key] = value === 'true' ? true : value === 'false' ? false : 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
Expand All @@ -15,11 +65,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) {
Expand All @@ -29,11 +76,22 @@ 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;
}

return attrs;
};

module.exports.forPost = forPost;
module.exports = {
parseGatedBlockParams,
stripGatedBlocks,
forPost
};
27 changes: 27 additions & 0 deletions ghost/core/core/server/services/members/content-gating.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
};
6 changes: 3 additions & 3 deletions ghost/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
16 changes: 16 additions & 0 deletions ghost/core/test/e2e-api/content/posts.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
});
});
Loading

0 comments on commit beaf57f

Please sign in to comment.