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
157 lines
6.0 KiB
PHP
157 lines
6.0 KiB
PHP
<?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;
|
||
}
|
||
}
|