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
300 lines
11 KiB
PHP
300 lines
11 KiB
PHP
<?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) . '.'
|
||
);
|
||
}
|
||
}
|