Files
WP-LIT-MCP/src/Integrations/WordPress/PostGuard.php
T

193 lines
7.5 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 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;
}
}