fix: never publish a missed schedule, refuse trashed posts, fail on meta read errors

- PostGuard::editable() refuses trashed entries unless the caller opts in
  (content-get, revisions-list, post-trash); publish/schedule/unpublish/update/
  restore/duplicate/replace now refuse them.
- PostGuard::statusStable(): content-update, revision-restore and
  replace-with-copy refuse a future post whose date has passed (any save would
  publish it); the content-update rollback and PostCloner row writes re-check.
- PostCloner::ownMeta() treats a failed SELECT as an error; replace() reads the
  copy before its first write.
- replace-with-copy needs create_posts (backup clone) and reports backup_id in
  write_failed/partial_write.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
2026-09-26 01:20:21 +02:00
co-authored by Claude Opus 5.5
parent 6a7193424c
commit d831cf59ca
24 changed files with 501 additions and 43 deletions
+8 -3
View File
@@ -98,15 +98,20 @@ final class Errors
return self::make('unsupported', $message, $hint, 400);
}
/** A write failed and was rolled back completely. */
public static function writeFailed(string $reason): WP_Error
/**
* A write failed and was rolled back completely.
*
* @param array<string, mixed> $details Extra error details (e.g. a backup that stays).
*/
public static function writeFailed(string $reason, array $details = []): WP_Error
{
return self::make(
'write_failed',
trim('Speichern fehlgeschlagen: ' . $reason) . ' Nichts wurde gespeichert.',
'Eingaben prüfen, ggf. korrigieren und erneut versuchen; tritt der Fehler wieder auf, den Administrator '
. 'informieren (Details im PHP-Error-Log der Website).',
500
500,
$details
);
}
+4 -4
View File
@@ -13,12 +13,12 @@ use WP_Error;
* written. getMessage() is the internal detail for the PHP error log; $reason is the
* client-safe German text. The AbilityRegistrar logs the detail and returns toError():
* lit_write_failed when everything was rolled back, lit_partial_write otherwise.
* $state/$hint/$details customise the partial-write error for writes that are not about a
* post (see Errors::partialWrite()).
* $state/$hint customise the partial-write error for writes that are not about a post (see
* Errors::partialWrite()); $details are added to either error.
*/
final class WriteFailed extends RuntimeException
{
/** @param array<string, mixed> $details Extra error details for an incomplete rollback. */
/** @param array<string, mixed> $details Extra error details for the client. */
public function __construct(
public readonly string $reason,
public readonly bool $rolledBack,
@@ -41,7 +41,7 @@ final class WriteFailed extends RuntimeException
public function toError(): WP_Error
{
return $this->rolledBack
? Errors::writeFailed($this->reason)
? Errors::writeFailed($this->reason, $this->details)
: Errors::partialWrite(
$this->reason,
$this->postId,
@@ -59,14 +59,14 @@ final class ContentGetAbility implements Ability
public function permission(array $input): bool|WP_Error
{
$post = PostGuard::editable($input['post_id'] ?? null, $this->schema);
$post = PostGuard::editable($input['post_id'] ?? null, $this->schema, 'post_id', true);
return $post instanceof WP_Error ? $post : true;
}
public function execute(array $input): mixed
{
$post = PostGuard::editable($input['post_id'] ?? null, $this->schema);
$post = PostGuard::editable($input['post_id'] ?? null, $this->schema, 'post_id', true);
if ($post instanceof WP_Error) {
return $post;
}
@@ -113,6 +113,11 @@ final class ContentUpdateAbility implements Ability
return Errors::revMismatch();
}
$unstable = PostGuard::statusStable($post);
if ($unstable !== null) {
return $unstable;
}
if (array_key_exists('content', $input) && PostPresenter::usesDivi($post)) {
return Errors::unsupported(
'Divi-Inhalte können nicht als Rohtext überschrieben werden; nichts wurde gespeichert.',
@@ -279,6 +284,12 @@ final class ContentUpdateAbility implements Ability
return;
}
// Both restore paths save through wp_update_post(), which would publish a missed schedule.
$current = get_post((int) $post->ID);
if ($current instanceof WP_Post && PostGuard::missedSchedule($current)) {
throw new RuntimeException('Post row not restored: saving it now would publish a missed schedule.');
}
$fromRevision = array_values(array_intersect(array_keys($before), self::REVISIONED));
if ($revisionId !== null && $fromRevision !== [] && wp_revisions_enabled($post)) {
$restored = wp_restore_post_revision($revisionId, $fromRevision);
+57 -2
View File
@@ -16,8 +16,17 @@ use WP_Post;
*/
final class PostGuard
{
public static function editable(mixed $postId, ContentSchema $schema, string $field = 'post_id'): WP_Post|WP_Error
{
/**
* 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(
@@ -35,9 +44,55 @@ final class PostGuard
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
@@ -111,6 +111,12 @@ final class RevisionRestoreAbility implements Ability
return Errors::revMismatch();
}
// wp_restore_post_revision() saves via wp_update_post(), which would publish a missed schedule.
$unstable = PostGuard::statusStable($post, 'revision_id');
if ($unstable !== null) {
return $unstable;
}
$backupId = RevisionPoint::capture($postId);
$restored = wp_restore_post_revision((int) $revision->ID);
if (! $restored) {
@@ -98,14 +98,14 @@ final class RevisionsListAbility implements Ability
public function permission(array $input): bool|WP_Error
{
$post = PostGuard::editable($input['post_id'] ?? null, $this->schema);
$post = PostGuard::editable($input['post_id'] ?? null, $this->schema, 'post_id', true);
return $post instanceof WP_Error ? $post : true;
}
public function execute(array $input): mixed
{
$post = PostGuard::editable($input['post_id'] ?? null, $this->schema);
$post = PostGuard::editable($input['post_id'] ?? null, $this->schema, 'post_id', true);
if ($post instanceof WP_Error) {
return $post;
}
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Status;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\Integrations\WordPress\PostGuard;
use RuntimeException;
use Throwable;
use WP_Error;
@@ -79,7 +80,8 @@ class PostCloner
/**
* Writes title (without " (Kopie)"), content, excerpt, meta and terms of the copy
* into the original. Status, slug, date and author of the original stay.
* into the original. Status, slug, date and author of the original stay. Everything is
* read before the first write, so a failed read leaves the original untouched.
*
* @throws ReplaceFailed Naming the parts that were (possibly) written.
*/
@@ -88,14 +90,15 @@ class PostCloner
$originalId = (int) $original->ID;
$parts = [];
try {
$meta = $this->ownMeta((int) $copy->ID);
$terms = $this->termIds($copy);
$this->assertNoMissedSchedule($originalId);
$parts[] = 'post';
$this->updateRow($originalId, [
'post_title' => self::withoutSuffix((string) $copy->post_title),
'post_content' => (string) $copy->post_content,
'post_excerpt' => (string) $copy->post_excerpt,
]);
$meta = $this->ownMeta((int) $copy->ID);
$terms = $this->termIds($copy);
$parts[] = 'meta';
$this->replaceMeta($originalId, $meta);
$parts[] = 'terms';
@@ -225,6 +228,7 @@ class PostCloner
*/
private function updateRow(int $postId, array $columns): void
{
$this->assertNoMissedSchedule($postId);
$result = wp_update_post(wp_slash(array_merge(['ID' => $postId], $columns)), true);
if ($result instanceof WP_Error || ! $result) {
$message = $result instanceof WP_Error ? $result->get_error_message() : 'unknown error';
@@ -232,14 +236,33 @@ class PostCloner
}
}
/**
* wp_update_post() publishes a scheduled post whose date has passed – never rewrite such a row.
*
* @throws RuntimeException
*/
private function assertNoMissedSchedule(int $postId): void
{
$post = get_post($postId);
if ($post instanceof WP_Post && PostGuard::missedSchedule($post)) {
throw new RuntimeException(sprintf(
'Post %d not written: saving it now would publish a missed schedule.',
$postId
));
}
}
/**
* Stored meta of a post without SKIP_META: meta_value strings exactly as in the database.
* A failed query is an error – treating it as "no meta" would wipe meta on replace().
*
* @return array<string, list<string|null>>
* @throws RuntimeException
*/
private function ownMeta(int $postId): array
{
$db = $this->db();
$db->last_error = '';
$rows = $db->get_results(
$db->prepare(
"SELECT meta_key, meta_value FROM {$db->postmeta} WHERE post_id = %d ORDER BY meta_id",
@@ -247,9 +270,16 @@ class PostCloner
),
'ARRAY_A'
);
if (! is_array($rows) || (string) $db->last_error !== '') {
throw new RuntimeException(sprintf(
'Meta of post %d could not be read: %s',
$postId,
(string) $db->last_error !== '' ? (string) $db->last_error : 'no result'
));
}
$meta = [];
foreach ((array) $rows as $row) {
foreach ($rows as $row) {
$key = (string) $row['meta_key'];
if (! in_array($key, self::SKIP_META, true)) {
$meta[$key][] = $row['meta_value'] === null ? null : (string) $row['meta_value'];
@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Status;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\FollowUp;
use LIT\MCP\Abilities\Rev;
use LIT\MCP\Abilities\ToolResult;
@@ -126,14 +125,6 @@ final class PostDuplicateAbility implements Ability
return $post;
}
if ($post->post_status === 'trash') {
return Errors::invalidInput(
'Der Eintrag liegt im Papierkorb.',
['post_id' => 'Eintrag liegt im Papierkorb'],
'Eintrag zuerst in WordPress wiederherstellen.'
);
}
$type = PostGuard::postType((string) $post->post_type, $this->schema, 'create_posts');
return $type instanceof WP_Error ? $type : $post;
@@ -134,9 +134,9 @@ final class PostReplaceWithCopyAbility implements Ability
try {
$this->cloner->replace($original, $copy);
} catch (ReplaceFailed $e) {
$this->rollBack($original, $snapshot, $e->parts, $revisionId, $e->getPrevious() ?? $e);
$this->rollBack($original, $snapshot, $e->parts, $revisionId, $backupId, $e->getPrevious() ?? $e);
} catch (Throwable $e) {
$this->rollBack($original, $snapshot, PostCloner::PARTS, $revisionId, $e);
$this->rollBack($original, $snapshot, PostCloner::PARTS, $revisionId, $backupId, $e);
}
return $this->result($original, $copy, $input, $revisionId, $backupId);
@@ -169,14 +169,6 @@ final class PostReplaceWithCopyAbility implements Ability
);
}
if ($original->post_status === 'trash') {
return Errors::invalidInput(
'Das Original liegt im Papierkorb.',
['original_id' => 'Original liegt im Papierkorb'],
'Original zuerst in WordPress wiederherstellen.'
);
}
if (! in_array($copy->post_status, ['draft', 'pending'], true)) {
return Errors::invalidInput(
sprintf('Die Kopie hat den Status "%s"; übernommen wird nur ein Entwurf.', $copy->post_status),
@@ -189,6 +181,11 @@ final class PostReplaceWithCopyAbility implements Ability
return Errors::revMismatch();
}
$unstable = PostGuard::statusStable($original, 'original_id');
if ($unstable !== null) {
return $unstable;
}
if (! ($this->trashEnabled)()) {
return Errors::unsupported(
'Der Papierkorb ist deaktiviert (EMPTY_TRASH_DAYS = 0); die Kopie würde endgültig gelöscht. '
@@ -269,17 +266,20 @@ final class PostReplaceWithCopyAbility implements Ability
* @param array{post: array<string, string>, meta: array<string, list<mixed>>, terms: array<string, list<int>>}
* $snapshot Output of PostCloner::snapshot().
* @param list<string> $parts Parts replace() had (possibly) written.
* @throws WriteFailed Always – rolledBack tells whether the original was restored completely.
* @throws WriteFailed Always – rolledBack tells whether the original was restored completely;
* both name the backup (backup_id), which stays in the trash.
*/
private function rollBack(
WP_Post $original,
array $snapshot,
array $parts,
?int $revisionId,
int $backupId,
Throwable $cause
): never {
$originalId = (int) $original->ID;
$detail = sprintf('Replace of post %d failed: %s', $originalId, $cause->getMessage());
$details = ['backup_id' => $backupId];
try {
$this->cloner->restore($original, $snapshot, $parts);
} catch (Throwable $e) {
@@ -296,11 +296,33 @@ final class PostReplaceWithCopyAbility implements Ability
$originalId,
$revisionId,
$cause,
$detail . '; rollback incomplete: ' . $e->getMessage()
$detail . '; rollback incomplete: ' . $e->getMessage(),
sprintf(
'Eintrag %d ist möglicherweise teilweise geändert worden (Stand vorher: Revision %s); die '
. 'vollständige Sicherung des vorherigen Stands liegt als Eintrag %d im Papierkorb.',
$originalId,
$revisionId === null ? '–' : (string) $revisionId,
$backupId
),
'Den Aufruf nicht einfach wiederholen. Zuerst den Stand mit lit-content-get prüfen; Titel, Inhalt '
. 'und Auszug lassen sich mit lit/revision-restore (revision_id) zurückholen, Felder/Meta und '
. 'Begriffe stehen in der Sicherung backup_id (in WordPress aus dem Papierkorb holen). Dem '
. 'Nutzer Bescheid geben.',
$details
);
}
throw new WriteFailed(self::REASON, true, $originalId, $revisionId, $cause, $detail);
throw new WriteFailed(
self::REASON,
true,
$originalId,
$revisionId,
$cause,
$detail,
null,
null,
$details
);
}
/**
@@ -323,6 +345,9 @@ final class PostReplaceWithCopyAbility implements Ability
return Errors::forbidden('Keine Berechtigung, die Kopie in den Papierkorb zu legen.');
}
return [$original, $copy];
// The swap first clones the original into a backup entry of the same type.
$type = PostGuard::postType((string) $original->post_type, $this->schema, 'create_posts');
return $type instanceof WP_Error ? $type : [$original, $copy];
}
}
@@ -144,7 +144,7 @@ final class PostTrashAbility implements Ability
/** @param array<string, mixed> $input */
private function load(array $input): WP_Post|WP_Error
{
$post = PostGuard::editable($input['post_id'] ?? null, $this->schema);
$post = PostGuard::editable($input['post_id'] ?? null, $this->schema, 'post_id', true);
if ($post instanceof WP_Error) {
return $post;
}