Files
WP-LIT-MCP/src/Integrations/WordPress/PostGuard.php
T
janniclas.loosenandClaude Opus 5.5 b0da2cdecc fix: restrict media-search to the query, not just editable() after it
I1: query the attachments themselves author-restricted when the user
lacks edit_others_posts (PostGuard::ownPostsOnly, extracted from
ContentQueryAbility so content-query reuses the exact same rule)
instead of relying solely on a post-query editable() filter, which
could leak the existence of other users' attachments via an empty
page plus next_cursor. editable() stays as a backstop.

Also: M1 document that only status=inherit attachments are listed;
M2 tie-break ordering by ID (content-query and media-search) for
stable pagination; M3 no next_cursor once the last candidate was
examined; M4 reject an explicit non-image mime with missing_alt;
M5 additional coverage (posts_per_page, cursor offset pass-through,
invalid cursor, post_status/date/mime args); mime maxLength 100.

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

157 lines
6.0 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;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use WP_Error;
use WP_Post;
/**
* Loads entries and post types for abilities, including the capability checks
* of spec §4.5. Every error is ready to be returned from permission()/execute().
* Input is not schema-validated yet when permission() runs – hence the defensive id().
*/
final class PostGuard
{
/**
* Loads an entry the current user may edit. Trashed entries are refused (every write
* on them would be surprising: publishing, scheduling or updating a trashed entry);
* readers and lit/post-trash pass $allowTrashed.
*/
public static function editable(
mixed $postId,
ContentSchema $schema,
string $field = 'post_id',
bool $allowTrashed = false
): WP_Post|WP_Error {
$id = self::id($postId);
if ($id === null) {
return Errors::invalidInput(
sprintf('%s fehlt oder ist ungültig.', $field),
[$field => 'positive Ganzzahl erwartet']
);
}
$post = get_post($id);
if (! $post instanceof WP_Post || ! $schema->isManaged((string) $post->post_type)) {
return Errors::notFound(sprintf('Eintrag %d nicht gefunden (oder kein unterstützter Inhaltstyp).', $id));
}
if (! current_user_can('edit_post', $id)) {
return Errors::forbidden(sprintf('Keine Berechtigung, Eintrag %d zu bearbeiten.', $id));
}
if (! $allowTrashed && $post->post_status === 'trash') {
return Errors::invalidInput(
sprintf('Eintrag %d liegt im Papierkorb.', $id),
[$field => 'Eintrag liegt im Papierkorb'],
'Eintrag zuerst in WordPress wiederherstellen.'
);
}
return $post;
}
/**
* A scheduled entry whose publication time has passed (WordPress' cron missed it).
* wp_update_post() publishes such an entry on any save, so writes must not touch it.
*/
public static function missedSchedule(WP_Post $post): bool
{
$gmt = (string) $post->post_date_gmt;
if ($post->post_status !== 'future' || $gmt === '' || $gmt === '0000-00-00 00:00:00') {
return false;
}
$time = strtotime($gmt . ' UTC');
return $time !== false && $time <= time();
}
/**
* Refuses a write that would change the status as a side effect (spec §2: status is only
* changed on explicit request) – currently a missed schedule, which any save would publish.
*/
public static function statusStable(WP_Post $post, string $field = 'post_id'): ?WP_Error
{
if (! self::missedSchedule($post)) {
return null;
}
return Errors::invalidInput(
sprintf(
'Eintrag %d ist geplant, der Veröffentlichungszeitpunkt ist aber schon verstrichen; jedes Speichern '
. 'würde ihn sofort veröffentlichen. Nichts wurde gespeichert.',
(int) $post->ID
),
[$field => 'geplanter Zeitpunkt verstrichen'],
'Beim Nutzer nachfragen: jetzt veröffentlichen (lit-post-publish) oder neu planen (lit/post-schedule '
. 'über mcp-adapter-execute-ability); danach die Änderung wiederholen.'
);
}
/**
* The brief declares this `: object|WP_Error`, but `object` already covers every
* class type, so PHP refuses `object` inside a union ("Type object cannot be
* part of a union type" – fatal at compile time). WP_Error is itself an object,
* so a plain `object` return type is accurate and still lets callers branch with
* `is_wp_error()` / `instanceof WP_Error`, exactly like editable() above.
*/
public static function postType(mixed $postType, ContentSchema $schema, string $cap): object
{
$name = is_string($postType) ? $postType : '';
$object = $name !== '' && $schema->isManaged($name) ? get_post_type_object($name) : null;
if (! is_object($object)) {
return Errors::notFound(
sprintf('Inhaltstyp "%s" ist nicht verfügbar.', $name),
'Verfügbare Inhaltstypen über lit-content-types abrufen.'
);
}
$capability = isset($object->cap->{$cap}) ? (string) $object->cap->{$cap} : $cap;
if (! current_user_can($capability)) {
return Errors::forbidden(sprintf('Keine Berechtigung für Inhaltstyp "%s".', $name));
}
return $object;
}
/**
* Whether a list/search query for this post type must stay restricted to the current
* user's own entries because they lack edit_others_posts. Filtering per row after the
* query (e.g. current_user_can('edit_post', $id)) is not enough on its own: without this,
* a restricted user's query still runs unrestricted, so a page can come back empty (or
* short) with a next_cursor that quietly reveals other users' entries exist without ever
* showing them – the same defect ContentQueryAbility guards against for content-query.
*
* @return int|null The current user id when restricted, null when the query may run
* unrestricted (author filter must then be left out entirely).
*/
public static function ownPostsOnly(string $postType): ?int
{
$typeObject = get_post_type_object($postType);
$caps = is_object($typeObject) && isset($typeObject->cap) ? $typeObject->cap : null;
$capability = $caps !== null && isset($caps->edit_others_posts)
? (string) $caps->edit_others_posts
: 'edit_others_posts';
return current_user_can($capability) ? null : get_current_user_id();
}
public static function id(mixed $value): ?int
{
if (is_int($value)) {
return $value > 0 ? $value : null;
}
if (is_string($value) && ctype_digit($value) && (int) $value > 0) {
return (int) $value;
}
return null;
}
}