diff --git a/apps/admin-x-settings/package.json b/apps/admin-x-settings/package.json index ce0283e3a96..b5c05e11576 100644 --- a/apps/admin-x-settings/package.json +++ b/apps/admin-x-settings/package.json @@ -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", diff --git a/ghost/core/core/server/api/endpoints/utils/serializers/output/utils/post-gating.js b/ghost/core/core/server/api/endpoints/utils/serializers/output/utils/post-gating.js index 07655f311a5..657842ad0ce 100644 --- a/ghost/core/core/server/api/endpoints/utils/serializers/output/utils/post-gating.js +++ b/ghost/core/core/server/api/endpoints/utils/serializers/output/utils/post-gating.js @@ -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*([\s\S]*?)\s*/g; + +const parseGatedBlockParams = function (paramsString) { + const params = {}; + // Match key-value pairs with optional quotes around the value + const paramsRegex = /\b(?\w+):["']?(?[^"'\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 @@ -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) { @@ -29,6 +76,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 +90,8 @@ const forPost = (attrs, frame) => { return attrs; }; -module.exports.forPost = forPost; +module.exports = { + parseGatedBlockParams, + stripGatedBlocks, + forPost +}; diff --git a/ghost/core/core/server/services/members/content-gating.js b/ghost/core/core/server/services/members/content-gating.js index 206a71fc196..de980951b43 100644 --- a/ghost/core/core/server/services/members/content-gating.js +++ b/ghost/core/core/server/services/members/content-gating.js @@ -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 }; diff --git a/ghost/core/package.json b/ghost/core/package.json index ce75556c25e..ac68ee8c90f 100644 --- a/ghost/core/package.json +++ b/ghost/core/package.json @@ -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", diff --git a/ghost/core/test/e2e-api/content/posts.test.js b/ghost/core/test/e2e-api/content/posts.test.js index 51647c17b1a..a301469f8c3 100644 --- a/ghost/core/test/e2e-api/content/posts.test.js +++ b/ghost/core/test/e2e-api/content/posts.test.js @@ -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: '

Visible to free/paid members

',visibility: {web: {nonMember: false,memberSegment: 'status:free,status:-free'},email: {memberSegment: ''}}},{type: 'html',version: 1,html: '

Visible to anonymous viewers

',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/); + }); }); diff --git a/ghost/core/test/unit/api/canary/utils/serializers/output/utils/post-gating.test.js b/ghost/core/test/unit/api/canary/utils/serializers/output/utils/post-gating.test.js index f9b1e9d1c87..25af83ecb70 100644 --- a/ghost/core/test/unit/api/canary/utils/serializers/output/utils/post-gating.test.js +++ b/ghost/core/test/unit/api/canary/utils/serializers/output/utils/post-gating.test.js @@ -1,7 +1,13 @@ -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 () { it('should NOT hide content attributes when visibility is public', function () { const attrs = { @@ -19,7 +25,7 @@ describe('Unit: endpoints/utils/serializers/output/utils/post-gating', function 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 () { @@ -38,8 +44,8 @@ describe('Unit: endpoints/utils/serializers/output/utils/post-gating', function 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 () { @@ -60,8 +66,8 @@ describe('Unit: endpoints/utils/serializers/output/utils/post-gating', function gating.forPost(attrs, frame); - attrs.plaintext.should.eql('I see dead people'); - attrs.html.should.eql('

What\'s the matter?

'); + assert.equal(attrs.plaintext, 'I see dead people'); + assert.equal(attrs.html, '

What\'s the matter?

'); }); it('should hide content attributes when visibility is "paid" and member has status of "free"', function () { @@ -84,8 +90,8 @@ describe('Unit: endpoints/utils/serializers/output/utils/post-gating', function 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 () { @@ -108,8 +114,175 @@ describe('Unit: endpoints/utils/serializers/output/utils/post-gating', function gating.forPost(attrs, frame); - attrs.plaintext.should.eql('Secret paid content'); - attrs.html.should.eql('

Can read this

'); + assert.equal(attrs.plaintext, 'Secret paid content'); + assert.equal(attrs.html, '

Can read this

'); + }); + + it('does not call stripGatedBlocks when a post has no gated blocks', function () { + const attrs = { + visibility: 'public', + html: '

no gated blocks

' + }; + + const frame = { + options: {}, + original: { + context: {} + } + }; + + 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: '

gated block

' + }; + + const frame = { + options: {}, + original: { + context: {} + } + }; + + 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: ` +

Members only.

+

Everyone can see this.

+

Anonymous only.

+ `, + plaintext: 'Members only. Everyone can see this. Anonymous only.', + excerpt: 'Members only. Everyone can see this. Anonymous only.' + }; + + const frame = { + options: {}, + original: { + context: {} + } + }; + + gating.forPost(attrs, frame); + + assert.match(attrs.html, /

Everyone can see this\.<\/p>\n\s+

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 testCases = [{ + 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'} + }]; + + testCases.forEach(function (testCase) { + it(`should parse ${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 = '

no gated blocks

'; + 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 = '

gated blocks

'; + 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 = '

gated blocks

'; + const result = gating.stripGatedBlocks(html, {}); + sinon.assert.calledWith(checkGatedBlockAccessStub, {nonMember: true}, {}); + assert.equal(result, '

gated blocks

'); + }); + + it('handles content with multiple permitted blocks', function () { + const checkGatedBlockAccessStub = stubCheckGatedBlockAccess(true); + const html = ` +

gated block 1

+

Non-gated block

+

gated block 2

+ `; + const result = gating.stripGatedBlocks(html, {}); + sinon.assert.calledTwice(checkGatedBlockAccessStub); + assert.equal(result, ` +

gated block 1

+

Non-gated block

+

gated block 2

+ `); + }); + + it('handles mix of permitted and denied blocks', function () { + const checkGatedBlockAccessStub = sinon.stub(membersContentGating, 'checkGatedBlockAccess') + .onFirstCall().returns(false) + .onSecondCall().returns(true); + const html = ` +

gated block 1

+

Non-gated block

+

gated block 2

+ `; + const result = gating.stripGatedBlocks(html, null); + sinon.assert.calledTwice(checkGatedBlockAccessStub); + assert.equal(result.trim(), ` +

Non-gated block

+

gated block 2

+ `.trim()); }); }); }); diff --git a/ghost/core/test/unit/server/services/members/content-gating.test.js b/ghost/core/test/unit/server/services/members/content-gating.test.js index fdd9228dfc8..0142cec74e5 100644 --- a/ghost/core/test/unit/server/services/members/content-gating.test.js +++ b/ghost/core/test/unit/server/services/members/content-gating.test.js @@ -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}); + }); + }); }); diff --git a/yarn.lock b/yarn.lock index 1fcff823eb6..547fde3a9c9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7793,10 +7793,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" @@ -7810,21 +7810,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" @@ -7832,15 +7832,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" @@ -7848,8 +7848,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" @@ -7884,10 +7884,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"