'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 $input * @return array */ 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 $filter * @return array */ 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 $statuses * @return list */ 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) . '.' ); } }