Skip to content

fix(content-type): fix ensure parameter causing duplicate entries and skipped items in paginated results#35300

Merged
nollymar merged 11 commits intomainfrom
issue-35213-bug-content-drive-dotassetfileasset-duplicate-in-content-type-filter-after-reload-and-become-unselectable
Apr 17, 2026
Merged

fix(content-type): fix ensure parameter causing duplicate entries and skipped items in paginated results#35300
nollymar merged 11 commits intomainfrom
issue-35213-bug-content-drive-dotassetfileasset-duplicate-in-content-type-filter-after-reload-and-become-unselectable

Conversation

@rjvelazco
Copy link
Copy Markdown
Member

@rjvelazco rjvelazco commented Apr 13, 2026

What's the problem?

The /api/v1/contenttype endpoint supports an ensure parameter — a way to guarantee that a specific content type appears in the first page of results, even if it wouldn't land there in normal alphabetical order. Content Drive uses this so that when you reload the page with a content type already selected in the URL (e.g. ?filters=contentType:dotAsset), the multiselect dropdown can immediately show your pre-selected item.

The problem: the pagination math didn't account for the slot that the ensure item "borrowed" from page 1. This caused two bugs at once:

Bug 1 — The ensured item appears twice

Consider 7 content types sorted alphabetically and a request for 3 per page with ensure=FileAsset (which naturally lives at position 5):

Page Expected Actual (before fix)
1 (offset=0) [Blog, DotAsset, FileAsset] [Blog, DotAsset, FileAsset]
2 (offset=3) [Generic, News, Product] [Generic, News, FileAsset] ❌ FileAsset again!
3 (offset=6) [Widget] [Widget]

Bug 2 — A content type goes missing forever

Because FileAsset was "borrowed" to page 1, it displaced the item that should have been in the 3rd slot. That item — Generic in the example — gets pushed to page 2. But the offset for page 2 was never adjusted, so the database query skips right over it:

Content type Natural position Where it ended up
Blog 0 Page 1 ✅
DotAsset 1 Page 1 ✅
FileAsset 5 Page 1 ✅ (via ensure)
Generic 2 Nowhere — permanently skipped
News 3 Page 2 ✅
Product 4 Page 2 ✅
Widget 6 Page 3 ✅

So after scrolling through the entire list, FileAsset showed up twice and Generic never appeared at all.


Root Cause

Both bugs trace back to two lines in ContentTypeAPIImpl.search():

Problem 1 — The ensure item lookup was called with the client's offset:

// Before: on page 2 (offset=3), this query asks for 'FileAsset' starting at row 3.
// FileAsset is only 1 row — OFFSET 3 skips past it and returns nothing.
// With no IDs in the exclusion set, the normal query returns FileAsset again at its natural position.
contentTypeFactory.find(requestedContentTypes, null, offset, limit, orderBy);

Problem 2 — The normal paginated query used the raw client offset, unaware that page 1 had "borrowed" a slot:

// Before: on page 2, offset=3 skips positions 0,1,2 — but position 2 was displaced by ensure on page 1.
// That displaced item is never reachable.
performSearch(..., offset, includedIds);

The Fix

Three targeted changes in ContentTypeAPIImpl.search():

  1. Always look up ensure items from offset=0 — their IDs are needed every time to build the exclusion list for the normal query.
  2. Only add ensure items to the response on page 1 (offset == 0) — subsequent pages don't re-include them.
  3. Subtract ensureCount from the offset on pages 2+ — this shifts the window back so the displaced item is no longer skipped.
// After: ensure items always resolved from offset=0
contentTypeFactory.find(requestedContentTypes, null, 0, limit, orderBy);

// Their IDs always go into the exclusion set — prevents them reappearing in normal results
includedIds.add(contentType.inode());
includedIds.add(contentType.id());

// Only add to the response body on page 1
if (offset == 0 && ...) returnTypes.add(contentType);

// Adjust the offset on pages 2+ to recover displaced items
int adjustedOffset = offset > 0
    ? Math.max(0, offset - authorizedIncluded.size())
    : offset;

Result with the fix:

Page offset adjustedOffset Normal query returns Final page
1 0 0 Blog, DotAsset (limit=2, FileAsset excluded) Blog, DotAsset, FileAsset
2 3 2 Generic, News, Product (offset 2, FileAsset excluded) Generic, News, Product
3 6 5 Widget Widget

All 7 items returned exactly once.


Proposed Changes

  • ContentTypeAPIImpl.search() — fix ensure item lookup offset, gate page-1-only inclusion, apply adjusted offset to normal paginated query

Additional Info

This fix covers the single base-type path (the path taken by Content Drive when no base-type filter is active — the scenario that triggered the original bug report). The multi-type UNION path (ContentTypeFactoryImpl.searchMultipleTypes) has analogous code and the same latent bugs, but it is only reached when multiple base-type filters are selected simultaneously — a separate, lower-priority follow-up.

Videos

Issue

video.mov

Fix

video.mov

This PR fixes: #35213

This PR fixes: #35213

@claude
Copy link
Copy Markdown
Contributor

claude bot commented Apr 13, 2026

Claude finished @nollymar's task in 3m 28s —— View job


Review

Clean fix for the primary use case (single ensure item). A few things worth flagging:


1. Unchecked cast to ContentTypeFactoryImplClassCastException risk

ContentTypeAPIImpl.java lines 902–905:

((ContentTypeFactoryImpl) this.contentTypeFactory).extractOrderByForComparator(...)
((ContentTypeFactoryImpl) this.contentTypeFactory).createContentTypeComparator(...)

contentTypeFactory is typed as the interface ContentTypeFactory. Any test or future code that injects a mock/subclass will throw ClassCastException here. extractOrderByForComparator and createContentTypeComparator should be either promoted to the interface, made static helpers, or moved to a standalone utility. Fix this →


2. Offset adjustment uses authorizedIncluded.size() but exclusion covers all ensure items

ContentTypeAPIImpl.java line 885:

adjustedOffset = Math.max(0, offset - authorizedIncluded.size());

includedIds is populated from includedContentTypes (all resolved ensure items, including unauthorized ones), but the offset is only shifted by authorizedIncluded.size() (the permitted subset). If an ensure item exists but the user can't see it, the item is still excluded from the DB result window — displacing a normal item — but the offset is not adjusted for that displacement. In practice this edge case requires an attacker-controlled ensure param with a hidden type's variable name, but the silent data gap is worth noting.


3. Re-sort on every page is unnecessary on pages 2+

ContentTypeAPIImpl.java lines 900–906: The in-memory sort is applied unconditionally. On pages 2+ there are no ensure items in returnTypes; results are already DB-ordered. The sort only matters on page 1 where an out-of-order ensure item is prepended. Minor cost, not a bug, but the guard if (offset == 0 && ...) would make the intent clearer.


4. Multi-ensure offset math has an edge case with non-contiguous natural positions

With multiple ensure items whose natural positions don't form a contiguous block, the simple offset - authorizedIncluded.size() formula can misalign the window on later pages (e.g., ensure=[D,G,I] with page size 3: page 4 re-fetches H which already appeared on page 3). The PR scopes itself to the single-item case and that's fine, but testSearch_withEnsure_multipleItems_noDuplicatesNoSkipped uses E,F (contiguous, near the end) — a case that doesn't stress the non-contiguous scenario. The test passes but does not guard against the known gap.


SQLUtil.sanitizeSortBy fix and ContentTypeFactoryImpl.find lowercase fix — both are correct and necessary companions to the main fix.

@rjvelazco rjvelazco changed the title fix(Content Drive): duplicated contenttype in Content Drive filter li… fix(content-type): fix ensure parameter causing duplicate entries and skipped items in paginated results Apr 13, 2026
…set-duplicate-in-content-type-filter-after-reload-and-become-unselectable
…set-duplicate-in-content-type-filter-after-reload-and-become-unselectable
…set-duplicate-in-content-type-filter-after-reload-and-become-unselectable
Content type id and inode are the same value, so tracking both in
includedIds was redundant. Removed duplicate inode() calls in favor
of id() only.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@nollymar nollymar enabled auto-merge April 15, 2026 22:26
nollymar and others added 4 commits April 15, 2026 17:57
…set-duplicate-in-content-type-filter-after-reload-and-become-unselectable
…set-duplicate-in-content-type-filter-after-reload-and-become-unselectable
…SortBy

SQLUtil.sanitizeSortBy() applied toLowerCase() after stripping ASC/DESC
suffixes, so uppercase inputs like "upper(name) ASC" were never stripped,
leaving "upper(name) asc" which is not in the whitelist — returning empty
string and producing "ORDER BY " (SQL syntax error).

Fix: apply toLowerCase() first so both "ASC" and "DESC" variants are
correctly stripped before the whitelist check. Also fixes the return path
for DESC which had the same case-sensitivity issue.

Additionally guard ContentTypeFactoryImpl.find() against appending
ORDER BY when sanitization returns empty, as a defensive layer.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…) SQL

SELECT_BY_VAR_NAMES uses LOWER(velocity_var_name) on the column side but
was binding the parameter as-is. PostgreSQL = is case-sensitive for varchar,
so mixed-case callers never matched. Lowercasing the param aligns with the
SQL's intent and fixes the ensure-item lookup introduced by this branch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@nollymar nollymar added this pull request to the merge queue Apr 17, 2026
Merged via the queue into main with commit 0c93eef Apr 17, 2026
49 checks passed
@nollymar nollymar deleted the issue-35213-bug-content-drive-dotassetfileasset-duplicate-in-content-type-filter-after-reload-and-become-unselectable branch April 17, 2026 05:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AI: Safe To Rollback Area : Backend PR changes Java/Maven backend code

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

[BUG] Content Drive: dotAsset/FileAsset duplicate in content type filter after reload and become unselectable

4 participants