Skip to content

Commit 15d7bac

Browse files
committed
Added router-filter scalar columns to lazy URL field requirements
ref https://linear.app/ghost/issue/HKG-1875 A filtered collection router such as featured:true also needs its filter's own columns on the resource to choose the same router eager would. These were neither reported by getRequiredFields nor required on the forward path, so under ?fields=url a thin record let filterMatches see an undefined field and a featured post routed to /:slug/ instead of /featured/:slug/. The non-relation scalar fields a router filter references are now reported so the serializers force them, and required on the forward path so a thin resource is rejected rather than silently mis-routed. Field names are matched only at NQL expression boundaries, so colon-bearing values such as URLs or timestamps are not mistaken for fields; relation fields and the page/type discriminator stay excluded.
1 parent 5d0b553 commit 15d7bac

2 files changed

Lines changed: 94 additions & 2 deletions

File tree

ghost/core/core/server/services/url/lazy-url-service.ts

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,38 @@ function buildBaseFilters(): Map<string, BaseFilter> {
5757
return baseFilters;
5858
}
5959

60+
// Relation roots are loaded via getRequiredRelations (as withRelated), not as
61+
// scalar columns; `page`/`type` are the router-type discriminator, always set
62+
// on the resource. Everything else a router filter references is a scalar
63+
// own-column the resource must carry to be routed like eager.
64+
const FILTER_NON_SCALAR_FIELDS = new Set([
65+
'tag', 'tags', 'author', 'authors', 'primary_tag', 'primary_author', 'page', 'type'
66+
]);
67+
68+
// Scalar (own-column) fields a router filter reads, e.g. 'featured' from
69+
// 'featured:true'. Dotted clauses (e.g. tags.visibility) are relation
70+
// sub-fields and skipped — getRequiredRelations loads those relations.
71+
//
72+
// Only field names at an NQL expression boundary (start of the filter, or after
73+
// a `+`/`,`/`(` combinator) are matched, so colon-bearing values — URLs,
74+
// timestamps like 2020-01-01T00:00:00 — aren't mistaken for fields.
75+
function filterScalarFields(filter: string | null): string[] {
76+
if (!filter) {
77+
return [];
78+
}
79+
const fields = new Set<string>();
80+
const matcher = /(?:^|[+,(])\s*(\w+)(\.\w+)?:/g;
81+
let match;
82+
while ((match = matcher.exec(filter)) !== null) {
83+
const [, root, sub] = match;
84+
if (sub || FILTER_NON_SCALAR_FIELDS.has(root)) {
85+
continue;
86+
}
87+
fields.add(root);
88+
}
89+
return [...fields];
90+
}
91+
6092
interface LazyUrlServiceDeps {
6193
urlUtils?: typeof localUtils;
6294
findResource: FindResource;
@@ -143,8 +175,8 @@ export class LazyUrlService implements LazyUrlServiceBackend {
143175

144176
// Columns a resource of this type must carry for the lazy service to build
145177
// its URL: its base-filter columns plus the scalar columns its routers'
146-
// permalinks substitute. Permalink relations are covered separately by
147-
// getRequiredRelations; eager needs none of this (it looks URLs up by id).
178+
// permalinks substitute and filters read. Relations are covered separately
179+
// by getRequiredRelations; eager needs none of this (it looks URLs up by id).
148180
getRequiredFields(routerType: string): string[] {
149181
const fields = new Set<string>();
150182
const base = this.baseFilters.get(routerType);
@@ -164,6 +196,7 @@ export class LazyUrlService implements LazyUrlServiceBackend {
164196
if (/\b(year|month|day)\b/.test(config.permalink)) {
165197
fields.add('published_at');
166198
}
199+
filterScalarFields(config.filter).forEach(field => fields.add(field));
167200
}
168201
return [...fields];
169202
}
@@ -374,6 +407,11 @@ export class LazyUrlService implements LazyUrlServiceBackend {
374407
if (/\bprimary_author\b/.test(config.filter) && r.primary_author === undefined) {
375408
missing.push('primary_author');
376409
}
410+
for (const field of filterScalarFields(config.filter)) {
411+
if (r[field] === undefined) {
412+
missing.push(field);
413+
}
414+
}
377415
if (missing.length === 0) {
378416
return;
379417
}

ghost/core/test/unit/server/services/url/lazy-url-service.test.js

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,34 @@ describe('LazyUrlService', function () {
132132
);
133133
});
134134

135+
it('throws when a router filter references a scalar field the resource lacks (e.g. featured)', function () {
136+
const service = new LazyUrlService({urlUtils, findResource: noopFindResource});
137+
// featured:true needs the featured column; without it the filter would
138+
// see undefined and silently route to the wrong permalink.
139+
service.onRouterAddedType('featured', 'featured:true', 'posts', '/featured/:slug/');
140+
service.onRouterAddedType('default', null, 'posts', '/:slug/');
141+
142+
assert.throws(
143+
() => service.getUrlForResource({type: 'posts', id: 'p', slug: 'hot', status: 'published'}),
144+
/Thin resource passed to LazyUrlService/
145+
);
146+
});
147+
148+
it('routes via the filtered router when its scalar field is present', function () {
149+
const service = new LazyUrlService({urlUtils, findResource: noopFindResource});
150+
service.onRouterAddedType('featured', 'featured:true', 'posts', '/featured/:slug/');
151+
service.onRouterAddedType('default', null, 'posts', '/:slug/');
152+
153+
assert.equal(
154+
service.getUrlForResource({type: 'posts', id: 'p', slug: 'hot', status: 'published', featured: true}),
155+
'/featured/hot/'
156+
);
157+
assert.equal(
158+
service.getUrlForResource({type: 'posts', id: 'p', slug: 'meh', status: 'published', featured: false}),
159+
'/meh/'
160+
);
161+
});
162+
135163
it('expands shorthand tag/author filters via the EXPANSIONS table', function () {
136164
const service = new LazyUrlService({urlUtils, findResource: noopFindResource});
137165
service.onRouterAddedType('podcast', 'tag:podcast', 'posts', '/podcast/:slug/');
@@ -685,5 +713,31 @@ describe('LazyUrlService', function () {
685713
assert.deepEqual(service.getRequiredFields('posts').sort(), ['published_at', 'slug', 'status', 'type']);
686714
assert.deepEqual(service.getRequiredFields('pages').sort(), ['id', 'status', 'type']);
687715
});
716+
717+
it('includes scalar columns referenced by a router filter, but not relation or discriminator fields', function () {
718+
const service = new LazyUrlService({urlUtils, findResource: noopFindResource});
719+
service.onRouterAddedType('featured', 'featured:true', 'posts', '/featured/:slug/');
720+
service.onRouterAddedType('default', null, 'posts', '/:slug/');
721+
722+
// featured (scalar) is required; page/type (discriminator) and tag/
723+
// author relations are not — those go through getRequiredRelations.
724+
assert.deepEqual(service.getRequiredFields('posts').sort(), ['featured', 'slug', 'status', 'type']);
725+
});
726+
727+
it('does not treat relation or page/type filter clauses as scalar columns', function () {
728+
const service = new LazyUrlService({urlUtils, findResource: noopFindResource});
729+
service.onRouterAddedType('news', 'tag:news+page:false', 'posts', '/:slug/');
730+
731+
assert.deepEqual(service.getRequiredFields('posts').sort(), ['slug', 'status', 'type']);
732+
});
733+
734+
it('does not capture colon-bearing filter values (timestamps, URLs) as fields', function () {
735+
const service = new LazyUrlService({urlUtils, findResource: noopFindResource});
736+
// The timestamp value contains `00:00:00`; only `published_at` (at an
737+
// expression boundary) is a field, not `00`.
738+
service.onRouterAddedType('recent', "published_at:>'2020-01-01T00:00:00Z'", 'posts', '/:slug/');
739+
740+
assert.deepEqual(service.getRequiredFields('posts').sort(), ['published_at', 'slug', 'status', 'type']);
741+
});
688742
});
689743
});

0 commit comments

Comments
 (0)