Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/renovate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ permissions:

jobs:
renovate:
# Never run on forks: both `schedule` and `workflow_dispatch` can fire on a
# fork that has Actions enabled, and this job mints the Renovate GitHub App
# token. Matches the fork guard used across the rest of the repo's
# privileged workflows.
if: github.repository == 'TryGhost/Ghost'
runs-on: ubuntu-latest
timeout-minutes: 45

Expand Down
13 changes: 10 additions & 3 deletions ghost/admin/app/models/post.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {on} from '@ember/object/evented';
import {inject as service} from '@ember/service';

const BLANK_LEXICAL = '{"root":{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1}],"direction":null,"format":"","indent":0,"type":"root","version":1}}';
const SEARCH_INDEXED_FIELDS = ['title', 'slug', 'status', 'visibility', 'publishedAtUTC'];

// ember-cli-shims doesn't export these so we must get them manually
const {Comparable} = Ember;
Expand Down Expand Up @@ -440,12 +441,18 @@ export default Model.extend(Comparable, ValidationEngine, {
this.set('publishedAtUTC', publishedAtUTC);
},

// when a published post is updated, unpublished, or deleted we expire the search content cache
// when indexed post fields are updated or deleted we expire the search content cache
save() {
const [oldStatus] = this.changedAttributes().status || [];
const changedAttributes = this.changedAttributes();
const previousUrl = this.url;
const previousPublishedAtUTC = this.publishedAtUTC?.valueOf();
const searchIndexedFieldChanged = SEARCH_INDEXED_FIELDS.some(field => changedAttributes[field]);

return this._super(...arguments).then((res) => {
if (this.status === 'published' || oldStatus === 'published') {
const urlChanged = previousUrl !== this.url;
const publishedAtUTCChanged = previousPublishedAtUTC !== this.publishedAtUTC?.valueOf();

if (this.isDeleted || searchIndexedFieldChanged || urlChanged || publishedAtUTCChanged) {
this.search.expireContent();
}

Expand Down
2 changes: 1 addition & 1 deletion ghost/admin/app/services/search.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export default class SearchService extends Service {
}

// start loading immediately in the background
this.refreshContentTask.perform();
this.refreshContentTask.unlinked().perform();

// debounce searches to 200ms to avoid thrashing CPU
yield timeout(200);
Expand Down
3 changes: 2 additions & 1 deletion ghost/admin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
"broccoli-terser-sourcemap": "4.1.1",
"chai": "catalog:",
"chai-dom": "1.12.1",
"chalk": "catalog:",
"codemirror": "5.65.21",
"cssnano": "4.1.10",
"element-resize-detector": "1.2.4",
Expand Down Expand Up @@ -120,7 +121,7 @@
"ember-truth-helpers": "3.1.1",
"eslint": "catalog:",
"eslint-plugin-babel": "5.3.1",
"flexsearch": "0.8.212",
"flexsearch": "0.7.43",
"fs-extra": "catalog:",
"ghost": "workspace:*",
"google-caja-bower": "https://github.com/acburdine/google-caja-bower#ghost",
Expand Down
25 changes: 23 additions & 2 deletions ghost/admin/tests/integration/models/post-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,21 @@ describe('Integration: Model: post', function () {
search.isContentStale = false;
});

it('expires on published save', async function () {
it('expires when published title changes', async function () {
const serverPost = this.server.create('post', {status: 'published'});

const postModel = await store.find('post', serverPost.id);
postModel.title = 'New title';
await postModel.save();

expect(search.isContentStale, 'stale flag after save').to.be.true;
});

it('expires when draft title changes', async function () {
const serverPost = this.server.create('post', {status: 'draft'});

const postModel = await store.find('post', serverPost.id);
postModel.title = 'New title';
await postModel.save();

expect(search.isContentStale, 'stale flag after save').to.be.true;
Expand Down Expand Up @@ -68,12 +79,22 @@ describe('Integration: Model: post', function () {
expect(search.isContentStale, 'stale flag after save').to.be.false;
});

it('does not expire on draft delete', async function () {
it('expires on draft delete', async function () {
const serverPost = this.server.create('post', {status: 'draft'});

const postModel = await store.find('post', serverPost.id);
await postModel.destroyRecord();

expect(search.isContentStale, 'stale flag after save').to.be.true;
});

it('does not expire when non-search content changes', async function () {
const serverPost = this.server.create('post', {status: 'published'});

const postModel = await store.find('post', serverPost.id);
postModel.html = '<p>Updated content</p>';
await postModel.save();

expect(search.isContentStale, 'stale flag after save').to.be.false;
});
});
Expand Down
69 changes: 69 additions & 0 deletions ghost/admin/tests/integration/services/search-test.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import sinon from 'sinon';
import {authenticateSession} from 'ember-simple-auth/test-support';
import {describe, it} from 'mocha';
import {expect} from 'chai';
Expand Down Expand Up @@ -29,6 +30,16 @@ const suites = [{
}
}];

function searchIndexRequests(server) {
return server.pretender.handledRequests.filter(request => request.url.includes('/search-index/'));
}

function wait(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}

suites.forEach((suite) => {
describe(suite.name, function () {
const hooks = setupTest();
Expand Down Expand Up @@ -57,6 +68,10 @@ suites.forEach((suite) => {
firstUser = this.server.create('user', {name: 'First user', slug: 'first-user'});
});

afterEach(function () {
sinon.restore();
});

it('is using correct provider', async function () {
suite.confirmProvider.bind(this)();
});
Expand All @@ -76,5 +91,59 @@ suites.forEach((suite) => {
expect(results[0].options[0].visibility).to.equal('members');
expect(results[0].options[0].publishedAt).to.equal('2024-05-08T16:21:07.000Z');
});

it('does not refresh cached content for subsequent searches', async function () {
await search.searchTask.perform('first');

expect(searchIndexRequests(this.server), 'initial search index requests').to.have.length(4);

await search.searchTask.perform('post');

expect(searchIndexRequests(this.server), 'later search index requests').to.have.length(4);
});

it('keeps the content cache fresh after a restarted search waits for an in-flight refresh', async function () {
this.server.timing = 500;

search.searchTask.perform('fir');
await search.searchTask.perform('first');

expect(searchIndexRequests(this.server), 'initial search index requests').to.have.length(4);

await search.searchTask.perform('post');

expect(searchIndexRequests(this.server), 'later search index requests').to.have.length(4);
});

it('keeps one provider refresh alive across restarted searches', async function () {
let resolveRefresh;
const provider = search.provider;
const refreshPromise = new Promise((resolve) => {
resolveRefresh = resolve;
});

sinon.stub(provider.refreshContentTask, 'perform').returns(refreshPromise);
sinon.stub(provider.searchTask, 'perform').returns([]);

search.searchTask.perform('t');
await wait(250);

search.searchTask.perform('te');
await wait(250);

const finalSearch = search.searchTask.perform('tes');
await wait(250);

expect(provider.refreshContentTask.perform, 'provider refresh starts').to.have.been.calledOnce;

resolveRefresh();
await finalSearch;

expect(search.isContentStale, 'stale flag after shared refresh').to.be.false;

await search.searchTask.perform('post');

expect(provider.refreshContentTask.perform, 'provider refresh starts after cached search').to.have.been.calledOnce;
});
});
});
47 changes: 37 additions & 10 deletions ghost/core/core/frontend/services/llms/service.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,16 @@ const {

const DEFAULT_BUDGET = 5 * 1024 * 1024;
const TRUNCATION_FOOTER = '\n_Truncated after 5 MiB. Use `/llms.txt` for the complete index of older public content._\n';
const RECENT_POSTS_FOOTER = '\n_Includes the latest 500 public posts. Use `/llms.txt` for the complete index of older public content._\n';
const FULL_PAGE_SIZE = 100;
const FULL_POST_LIMIT = 500;

function createLlmsService({settingsCache, labs, config, urlServiceFacade, urlUtils, models, routing, api, fullTxtBudget}) {
const BUDGET = (fullTxtBudget || DEFAULT_BUDGET) - Buffer.byteLength(TRUNCATION_FOOTER, 'utf8');
const footerBudget = Math.max(
Buffer.byteLength(TRUNCATION_FOOTER, 'utf8'),
Buffer.byteLength(RECENT_POSTS_FOOTER, 'utf8')
);
const BUDGET = (fullTxtBudget || DEFAULT_BUDGET) - footerBudget;
function isEnabled() {
return labs.isSet('llmsTxt') && !settingsCache.get('is_private') && settingsCache.get('llms_enabled') !== false;
}
Expand Down Expand Up @@ -67,46 +73,55 @@ function createLlmsService({settingsCache, labs, config, urlServiceFacade, urlUt

let output = header;
let wasTruncated = false;
let wasLimited = false;

const pageResult = await appendBoundedSectionPaginated(output, 'Pages', 'page');
output = pageResult.output;
wasTruncated = pageResult.wasTruncated;

if (!wasTruncated) {
const postResult = await appendBoundedSectionPaginated(output, 'Posts', 'post');
const postResult = await appendBoundedSectionPaginated(output, 'Posts', 'post', {maxEntries: FULL_POST_LIMIT});
output = postResult.output;
wasTruncated = postResult.wasTruncated;
wasLimited = postResult.wasLimited;
}

if (wasTruncated) {
output += TRUNCATION_FOOTER;
} else if (wasLimited) {
output += RECENT_POSTS_FOOTER;
}

return output.trimEnd() + '\n';
}

async function appendBoundedSectionPaginated(prefix, heading, type) {
async function appendBoundedSectionPaginated(prefix, heading, type, {maxEntries = null} = {}) {
const headingBlock = `${prefix}## ${heading}\n`;

if (Buffer.byteLength(headingBlock, 'utf8') > BUDGET) {
return {output: prefix, wasTruncated: true};
return {output: prefix, wasTruncated: true, wasLimited: false};
}

let output = headingBlock;
let outputBytes = Buffer.byteLength(output, 'utf8');
let wasTruncated = false;
let wasLimited = false;
let page = 1;
let hasMore = true;
let entriesRendered = 0;

while (hasMore && !wasTruncated) {
while (hasMore && !wasTruncated && !wasLimited) {
const result = await fetchFullEntries(type, page);
const entries = result.entries;
hasMore = result.hasMore;

if (!entries.length && page === 1) {
const emptySection = `${output}_No public content available._\n`;
const emptySectionBytes = Buffer.byteLength(emptySection, 'utf8');

if (Buffer.byteLength(emptySection, 'utf8') <= BUDGET) {
if (emptySectionBytes <= BUDGET) {
output = emptySection;
outputBytes = emptySectionBytes;
} else {
wasTruncated = true;
}
Expand All @@ -115,21 +130,33 @@ function createLlmsService({settingsCache, labs, config, urlServiceFacade, urlUt
}

for (const entry of entries) {
if (maxEntries && entriesRendered >= maxEntries) {
wasLimited = true;
break;
}

const formattedEntry = buildFullEntry(entry);
const candidate = `${output}${formattedEntry}\n`;
const entryBlock = `${formattedEntry}\n`;
const entryBytes = Buffer.byteLength(entryBlock, 'utf8');

if (Buffer.byteLength(candidate, 'utf8') > BUDGET) {
if (outputBytes + entryBytes > BUDGET) {
wasTruncated = true;
break;
}

output = candidate;
output = `${output}${entryBlock}`;
outputBytes += entryBytes;
entriesRendered += 1;
}

if (maxEntries && entriesRendered >= maxEntries && hasMore) {
wasLimited = true;
}

page += 1;
}

return {output, wasTruncated};
return {output, wasTruncated, wasLimited};
}

function buildHeader() {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
module.exports.QUERY = {
export const QUERY = {
tag: {
controller: 'tagsPublic',
type: 'read',
Expand Down Expand Up @@ -43,9 +43,9 @@ module.exports.QUERY = {
slug: '%s'
}
}
};
} as const;

module.exports.TAXONOMIES = {
export const TAXONOMIES = {
tag: {
filter: 'tags:\'%s\'+tags.visibility:public',
editRedirect: '#/tags/:slug/',
Expand All @@ -56,4 +56,4 @@ module.exports.TAXONOMIES = {
editRedirect: '#/settings/staff/:slug/',
resource: 'authors'
}
};
} as const;
3 changes: 1 addition & 2 deletions ghost/core/core/server/api/endpoints/comment-replies.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,7 @@ const controller = {
},
permissions: true,
query(frame) {
frame.options.isAdmin = true;
return commentsService.controller.read(frame);
return commentsService.controller.adminRead(frame);
}
}
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,18 @@
*
* const data = toPlain(post);
* urlService.facade.getUrlForResource({...data, type: 'posts'}, ...)
*
* @template T
* @param {T | {toJSON(): T}} modelOrObj
* @returns {T}
*/
module.exports = function toPlain(modelOrObj) {
if (modelOrObj && typeof modelOrObj.toJSON === 'function') {
return modelOrObj.toJSON();
}
return modelOrObj;
type JsonSerializable<T> = {
toJSON(): T;
};

const isJsonSerializable = <T>(modelOrObj: T | JsonSerializable<T>): modelOrObj is JsonSerializable<T> => !!(
modelOrObj
&& typeof modelOrObj === 'object'
&& 'toJSON' in modelOrObj
&& typeof modelOrObj.toJSON === 'function'
);

export const toPlain = <T>(modelOrObj: T | JsonSerializable<T>): T => (
isJsonSerializable(modelOrObj) ? modelOrObj.toJSON() : modelOrObj
);
Loading
Loading