fix: lit/library-list search by title, own entries for authors, cursor without hidden rows (L3)

search uses search_columns post_title; users without edit_others_posts on
et_pb_layout query their own entries only (PostGuard::ownPostsOnly); the
cursor counts the entries shown, never the rows left out.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-27 04:18:04 +02:00
co-authored by Claude Opus 5.5
parent ce4dc776e5
commit d8d7bb67be
4 changed files with 83 additions and 15 deletions
@@ -70,8 +70,9 @@ class DiviLibraryStore
/**
* Published entries, $limit + 1 of them (Cursor::page() tells whether there are more).
*
* @param array{layout_type?: string, scope?: string, search?: string, category?: string} $filters
* scope: "global" or "normal"; category: name of a layout_category term.
* @param array{layout_type?: string, scope?: string, search?: string, category?: string, author?: int} $filters
* scope: "global" or "normal"; search: in the title; category: name of a layout_category
* term; author: only this user's entries.
* @return list<WP_Post>
*/
public function find(array $filters, int $limit, int $offset): array
@@ -101,6 +102,12 @@ class DiviLibraryStore
];
if (($filters['search'] ?? '') !== '') {
$args['s'] = $filters['search'];
// Audit L3: the title only – content is block JSON, and entries the user may not read
// must not be findable by their content.
$args['search_columns'] = ['post_title'];
}
if (is_int($filters['author'] ?? null)) {
$args['author'] = $filters['author'];
}
if ($taxQuery !== []) {
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- the library is small.
@@ -29,7 +29,7 @@ defined('ABSPATH') || exit;
*/
final class LibraryListAbility implements Ability
{
/** At most this many entries are looked at per call while skipping entries the user may not edit. */
/** Rows looked at per call beyond the entries already shown (the cursor), skipping hidden ones. */
public const MAX_SCAN = 500;
public function __construct(
@@ -158,42 +158,48 @@ final class LibraryListAbility implements Ability
],
static fn (mixed $value): bool => is_string($value) && $value !== ''
);
// Audit L3: without edit_others_posts the query holds the own entries only (PostGuard).
$author = PostGuard::ownPostsOnly(DiviLibraryStore::POST_TYPE);
if ($author !== null) {
$filters['author'] = $author;
}
return $this->page($filters, Cursor::limit($input['limit'] ?? null), $offset);
}
/**
* One page of the entries the user may edit. The cursor counts the store's rows, so entries
* left out never shift a page; next_cursor points at the next readable entry, null when none
* follows (or the scan cap is reached with nothing left).
* One page of the entries the user may edit. The cursor counts the entries this user was shown
* ($skip), never the rows left out (audit L3), so it tells nothing about hidden entries; each call
* reads the rows from the start again and skips the first $skip readable ones. next_cursor is
* null when no readable entry follows (or the scan cap is reached with nothing found).
*
* @param array<string, string> $filters
* @param array<string, string|int> $filters
* @return array{items: list<array<string, mixed>>, next_cursor: ?string}
*/
private function page(array $filters, int $limit, int $offset): array
private function page(array $filters, int $limit, int $skip): array
{
$store = $this->access->store();
$items = [];
$position = $offset;
while ($position - $offset < self::MAX_SCAN) {
$readable = 0;
$position = 0;
while ($position < $skip + self::MAX_SCAN) {
$rows = $store->find($filters, $limit, $position);
foreach ($rows as $post) {
if ($this->access->canRead($post) !== null) {
++$position;
++$position;
if ($this->access->canRead($post) !== null || $readable++ < $skip) {
continue;
}
if (count($items) === $limit) {
return ['items' => $items, 'next_cursor' => Cursor::encode($position)];
return ['items' => $items, 'next_cursor' => Cursor::encode($skip + $limit)];
}
$items[] = $store->describe($post);
++$position;
}
if (count($rows) <= $limit) {
return ['items' => $items, 'next_cursor' => null];
}
}
return ['items' => $items, 'next_cursor' => Cursor::encode($position)];
return ['items' => $items, 'next_cursor' => $items === [] ? null : Cursor::encode($skip + count($items))];
}
/**