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
+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