Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
193 lines
7.5 KiB
PHP
193 lines
7.5 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 RuntimeException;
|
||
use WP_Error;
|
||
use WP_Post;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* 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
|
||
{
|
||
private const ZERO_DATE = '0000-00-00 00:00:00';
|
||
|
||
/** wp_insert_post() publishes a "future" post on save when its date is this close (MINUTE_IN_SECONDS). */
|
||
public const PUBLISH_WINDOW = 60;
|
||
|
||
/**
|
||
* 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 self::notFound($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;
|
||
}
|
||
|
||
/** The refusal for an ID that names no entry the tools manage – the same for every such ID. */
|
||
public static function notFound(int $id): WP_Error
|
||
{
|
||
return Errors::notFound(sprintf('Eintrag %d nicht gefunden (oder kein unterstützter Inhaltstyp).', $id));
|
||
}
|
||
|
||
/**
|
||
* A scheduled entry whose publication time has passed (WordPress' cron missed it) or is
|
||
* less than a minute away: wp_insert_post() turns "future" into "publish" on any save when
|
||
* the date is within the next MINUTE_IN_SECONDS, so writes must not touch such an entry.
|
||
* A zero post_date_gmt (never set) falls back to the local post_date, like core does.
|
||
*/
|
||
public static function missedSchedule(WP_Post $post): bool
|
||
{
|
||
if ($post->post_status !== 'future') {
|
||
return false;
|
||
}
|
||
|
||
$gmt = (string) $post->post_date_gmt;
|
||
if ($gmt === '' || $gmt === self::ZERO_DATE) {
|
||
$local = (string) $post->post_date;
|
||
$gmt = $local === '' || $local === self::ZERO_DATE ? '' : (string) get_gmt_from_date($local);
|
||
}
|
||
|
||
$time = $gmt === '' ? false : strtotime($gmt . ' UTC');
|
||
|
||
return $time !== false && $time <= time() + self::PUBLISH_WINDOW;
|
||
}
|
||
|
||
/**
|
||
* For internal writes (rollbacks, clone rows): wp_update_post() would publish a missed
|
||
* schedule (see missedSchedule()), so the entry is re-read and such a save is refused.
|
||
* $message is the internal log text of the RuntimeException.
|
||
*
|
||
* @throws RuntimeException
|
||
*/
|
||
public static function assertNoMissedSchedule(int $postId, string $message): void
|
||
{
|
||
$post = get_post($postId);
|
||
if ($post instanceof WP_Post && self::missedSchedule($post)) {
|
||
throw new RuntimeException($message);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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, direkt: 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 (direkt: 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;
|
||
}
|
||
}
|