Files
WP-LIT-MCP/src/Integrations/WordPress/Content/ContentQueryAbility.php
T
janniclas.loosenandClaude Opus 5.5 0890e6f165 fix: restrict content-query by cap before pagination, filter hidden fields
Review fix round 1: content-query now excludes posts/statuses the user
cannot edit before querying (author restriction without edit_others_posts,
status filtering by edit_private_posts/edit_published_posts) instead of
only dropping them after fetching, and rejects unknown field/taxonomy
filters. content-types' not-found hint only lists editable types.
PostPresenter skips Pods password fields and readonly protected meta.
Query input gains search/terms length limits.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
2026-09-25 22:48:05 +02:00

300 lines
11 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Content;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Cursor;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Integrations\WordPress\PostGuard;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use WP_Error;
use WP_Post;
final class ContentQueryAbility implements Ability
{
private const STATUSES = ['publish', 'draft', 'pending', 'private', 'future'];
private const DATE_PATTERN = '^\d{4}-\d{2}-\d{2}$';
public function __construct(private ContentSchema $schema, private PostPresenter $presenter)
{
}
public function name(): string
{
return 'lit/content-query';
}
public function label(): string
{
return 'Inhalte suchen';
}
public function description(): string
{
return 'Sucht Einträge eines Inhaltstyps. Filter: Status, Volltext, Taxonomie-Begriffe (IDs oder Slugs), '
. 'Feldwert, Datum, übergeordneter Eintrag; sortierbar; Cursor-Paginierung. Liefert Kurzdaten '
. '(ID, Titel, Status, URLs, Divi ja/nein) – Details über lit-content-get.';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => array_merge([
'post_type' => ['type' => 'string', 'description' => 'Inhaltstyp, z. B. page oder post.'],
'status' => [
'type' => 'array',
'items' => ['type' => 'string', 'enum' => self::STATUSES],
'description' => 'Standard: alle außer Papierkorb.',
],
'search' => ['type' => 'string', 'maxLength' => 200],
'taxonomy' => [
'type' => 'object',
'properties' => [
'taxonomy' => ['type' => 'string'],
'terms' => [
'type' => 'array',
'minItems' => 1,
'maxItems' => 50,
'items' => ['type' => ['integer', 'string']],
],
],
'required' => ['taxonomy', 'terms'],
'additionalProperties' => false,
],
'field' => [
'type' => 'object',
'properties' => [
'key' => ['type' => 'string'],
'value' => ['type' => ['string', 'number', 'boolean']],
'compare' => ['type' => 'string', 'enum' => ['=', '!=', 'LIKE', 'EXISTS', 'NOT EXISTS']],
],
'required' => ['key'],
'additionalProperties' => false,
],
'date_after' => ['type' => 'string', 'pattern' => self::DATE_PATTERN],
'date_before' => ['type' => 'string', 'pattern' => self::DATE_PATTERN],
'parent' => ['type' => 'integer', 'minimum' => 0],
'orderby' => ['type' => 'string', 'enum' => ['date', 'modified', 'title', 'menu_order']],
'order' => ['type' => 'string', 'enum' => ['asc', 'desc']],
], Cursor::inputProperties()),
'required' => ['post_type'],
'additionalProperties' => false,
];
}
public function outputSchema(): ?array
{
return [
'type' => 'object',
'properties' => [
'items' => ['type' => 'array', 'items' => PostPresenter::summarySchema()],
'next_cursor' => ['type' => ['string', 'null']],
],
'required' => ['items', 'next_cursor'],
];
}
public function annotations(): array
{
return ['readonly' => true, 'destructive' => false, 'idempotent' => true];
}
public function isDirect(): bool
{
return true;
}
public function permission(array $input): bool|WP_Error
{
$type = PostGuard::postType($input['post_type'] ?? null, $this->schema, 'edit_posts');
return $type instanceof WP_Error ? $type : true;
}
public function execute(array $input): mixed
{
$offset = Cursor::decode(isset($input['cursor']) ? (string) $input['cursor'] : null);
if ($offset === null) {
return Cursor::invalid();
}
$postType = (string) $input['post_type'];
if (isset($input['field']) && is_array($input['field'])) {
$error = $this->rejectUnlessKnownField($postType, (string) $input['field']['key']);
if ($error !== null) {
return $error;
}
}
if (isset($input['taxonomy']) && is_array($input['taxonomy'])) {
$error = $this->rejectUnlessKnownTaxonomy($postType, (string) $input['taxonomy']['taxonomy']);
if ($error !== null) {
return $error;
}
}
$limit = Cursor::limit($input['limit'] ?? null);
$posts = get_posts(array_merge($this->queryArgs($input), [
'posts_per_page' => $limit + 1,
'offset' => $offset,
]));
$page = Cursor::page(array_values($posts), $limit, $offset);
$items = [];
foreach ($page['items'] as $post) {
if ($post instanceof WP_Post && current_user_can('edit_post', (int) $post->ID)) {
$items[] = $this->presenter->summary($post);
}
}
return ['items' => $items, 'next_cursor' => $page['next_cursor']];
}
/**
* @param array<string, mixed> $input
* @return array<string, mixed>
*/
private function queryArgs(array $input): array
{
$postType = (string) $input['post_type'];
$typeObject = get_post_type_object($postType);
$caps = is_object($typeObject) && isset($typeObject->cap) ? $typeObject->cap : null;
$args = [
'post_type' => $postType,
'post_status' => $this->allowedStatuses((array) ($input['status'] ?? self::STATUSES), $caps),
'orderby' => (string) ($input['orderby'] ?? 'date'),
'order' => strtoupper((string) ($input['order'] ?? 'desc')),
'no_found_rows' => true,
'ignore_sticky_posts' => true,
'suppress_filters' => false,
];
// Without edit_others_posts, even a status the user may edit in principle must
// stay limited to their own posts – otherwise the query would leak drafts etc.
// of other authors that happen to pass the per-status gate below.
if (! current_user_can($this->capability($caps, 'edit_others_posts'))) {
$args['author'] = get_current_user_id();
}
if (isset($input['search']) && $input['search'] !== '') {
$args['s'] = (string) $input['search'];
}
if (isset($input['parent'])) {
$args['post_parent'] = (int) $input['parent'];
}
if (isset($input['taxonomy']) && is_array($input['taxonomy'])) {
$args['tax_query'] = $this->taxQuery($input['taxonomy']);
}
if (isset($input['field']) && is_array($input['field'])) {
$compare = (string) ($input['field']['compare'] ?? '=');
$clause = ['key' => (string) $input['field']['key'], 'compare' => $compare];
if (! in_array($compare, ['EXISTS', 'NOT EXISTS'], true)) {
$clause['value'] = $input['field']['value'] ?? '';
}
$args['meta_query'] = [$clause];
}
$date = [];
if (isset($input['date_after'])) {
$date['after'] = (string) $input['date_after'];
}
if (isset($input['date_before'])) {
$date['before'] = (string) $input['date_before'];
}
if ($date !== []) {
$args['date_query'] = [array_merge($date, ['inclusive' => true])];
}
return $args;
}
/**
* @param array<string, mixed> $filter
* @return array<int|string, mixed>
*/
private function taxQuery(array $filter): array
{
$taxonomy = (string) $filter['taxonomy'];
$terms = (array) $filter['terms'];
$ids = array_values(array_filter($terms, 'is_int'));
$slugs = array_values(array_filter($terms, 'is_string'));
$clauses = [];
if ($ids !== []) {
$clauses[] = ['taxonomy' => $taxonomy, 'field' => 'term_id', 'terms' => $ids];
}
if ($slugs !== []) {
$clauses[] = ['taxonomy' => $taxonomy, 'field' => 'slug', 'terms' => $slugs];
}
return count($clauses) > 1 ? array_merge(['relation' => 'OR'], $clauses) : $clauses;
}
/**
* Drops statuses the current user has no right to edit at all, so the query never
* even fetches rows that would only be discarded by the per-post check afterwards
* (which would otherwise waste a page slot and reveal the post existed).
*
* @param list<mixed> $statuses
* @return list<string>
*/
private function allowedStatuses(array $statuses, ?object $caps): array
{
$gates = [
'private' => $this->capability($caps, 'edit_private_posts'),
'publish' => $this->capability($caps, 'edit_published_posts'),
'future' => $this->capability($caps, 'edit_published_posts'),
];
return array_values(array_filter(
array_map('strval', $statuses),
static fn (string $status): bool => ! isset($gates[$status]) || current_user_can($gates[$status])
));
}
private function capability(?object $caps, string $name): string
{
return $caps !== null && isset($caps->{$name}) ? (string) $caps->{$name} : $name;
}
private function rejectUnlessKnownField(string $postType, string $key): ?WP_Error
{
$allowed = array_keys($this->schema->fields($postType));
if (in_array($key, $allowed, true)) {
return null;
}
return Errors::invalidInput(
sprintf('Feld "%s" ist für Inhaltstyp "%s" nicht verfügbar.', $key, $postType),
['field.key' => 'unbekanntes Feld'],
$allowed === []
? 'Für diesen Inhaltstyp sind keine Felder verfügbar.'
: 'Verfügbare Felder: ' . implode(', ', $allowed) . '.'
);
}
private function rejectUnlessKnownTaxonomy(string $postType, string $taxonomy): ?WP_Error
{
$allowed = array_column($this->schema->taxonomies($postType), 'name');
if (in_array($taxonomy, $allowed, true)) {
return null;
}
return Errors::invalidInput(
sprintf('Taxonomie "%s" ist für Inhaltstyp "%s" nicht verfügbar.', $taxonomy, $postType),
['taxonomy.taxonomy' => 'unbekannte Taxonomie'],
$allowed === []
? 'Für diesen Inhaltstyp sind keine Taxonomien verfügbar.'
: 'Verfügbare Taxonomien: ' . implode(', ', $allowed) . '.'
);
}
}