Files
WP-LIT-MCP/src/Integrations/WordPress/Content/NewDraft.php
T
janniclas.loosenandClaude Opus 5.5 b24eb6c3e9 fix(security): no direct HTTP access to plugin PHP files (audit L2, cheap part)
Every file under src/ exits outside WordPress (defined('ABSPATH') ||
exit; after the use block, so a direct request no longer prints a fatal
error with the server path); divi-convert-fixture.php gets the WP_CLI
guard the other integration scripts have. A unit test keeps both true;
PSR1 side-effects warning excluded for the guard.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
2026-09-26 19:32:12 +02:00

137 lines
5.3 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\Content;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\Rollback;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\ChangeLog\ChangeLog;
use RuntimeException;
use Throwable;
use WP_Error;
defined('ABSPATH') || exit;
/**
* The transaction of lit/content-create: inserting the draft and every way back out of it.
* The three exits side by side:
* - insert(): a throwing hook leaves the draft's existence unknown – partial write without id.
* - refuse(): a check that needs the draft failed – the draft is deleted and the refusal is
* returned like any invalid input; partial write if the delete fails.
* - discard(): writing after the insert failed – created terms and the draft are deleted;
* rolled back, or partial write if that cleanup failed.
* Partial writes are logged with result "partial".
*/
final class NewDraft
{
public const REASON_INSERT = 'WordPress hat beim Anlegen des Eintrags einen Fehler gemeldet.';
public const REASON_EXTRAS = 'Felder, Begriffe oder Beitragsbild konnten nicht gespeichert werden.';
public const REASON_LAYOUT = 'Das Divi-Layout konnte nicht gespeichert werden.';
public function __construct(
private PostWriter $writer,
private ChangeLog $log,
private string $ability,
private string $postType
) {
}
/**
* @param array<string, mixed> $data wp_insert_post() data (unslashed).
* @return int|WP_Error The new draft's ID, or WordPress' refusal as invalid input.
* @throws WriteFailed When a hook threw during the insert.
*/
public function insert(array $data): int|WP_Error
{
try {
$postId = wp_insert_post(wp_slash($data), true);
} catch (Throwable $e) {
// A hook threw – the row may or may not exist and its ID is unknown; nothing is
// searched or deleted, the client is told to check (lit_partial_write without id).
$this->logPartial('', 'Anlegen abgebrochen; Entwurf möglicherweise vorhanden');
throw WriteFailed::partial(self::REASON_INSERT, $e);
}
if ($postId instanceof WP_Error) {
return Errors::invalidInput('WordPress hat den Eintrag abgelehnt: ' . $postId->get_error_message());
}
return (int) $postId;
}
/**
* Some checks need the draft: meta rights (add_post_meta needs its ID) and the Divi layout
* against the new entry's own dynamic content options. A refusal removes the brand-new draft
* again and is reported like any invalid input.
*
* @param array<string, string>|WP_Error $denied fields.<key> => error, or the error itself.
* @throws WriteFailed When the draft cannot be deleted.
*/
public function refuse(int $postId, array|WP_Error $denied): WP_Error
{
try {
$deleted = (bool) wp_delete_post($postId, true);
} catch (Throwable $e) {
$deleted = false;
}
if (! $deleted) {
$summary = sprintf('Entwurf %d: Anlegen nicht zurückgenommen', $postId);
$this->logPartial((string) $postId, $summary);
$fields = $denied instanceof WP_Error ? Errors::fieldsOf($denied) : $denied;
throw WriteFailed::partial(
($denied instanceof WP_Error ? 'Ungültiger Divi-Inhalt: ' : 'Keine Berechtigung für ')
. implode(', ', array_keys($fields)) . '.',
detail: sprintf('New post %d refused after the insert; draft could not be deleted', $postId),
postId: $postId
);
}
return $denied instanceof WP_Error
? $denied
: Errors::invalidInput('Eingaben sind ungültig; nichts wurde gespeichert.', $denied);
}
/**
* Rolls the brand-new draft back: terms it created, then the draft itself (force –
* it cannot have been used anywhere yet). $reason is the client text of the error.
*
* @throws WriteFailed Always.
*/
public function discard(int $postId, Throwable $cause, string $reason): never
{
$problems = Rollback::attempt(
fn () => $this->writer->deleteCreatedTerms(),
static fn () => self::deleteDraft($postId)
);
$detail = sprintf('Extras of new post %d could not be written: %s', $postId, $cause->getMessage());
if ($problems === []) {
throw WriteFailed::rolledBack($reason, $cause, $detail);
}
$summary = sprintf('Entwurf %d: Anlegen nur teilweise zurückgenommen', $postId);
$this->logPartial((string) $postId, $summary);
throw WriteFailed::partial($reason, $cause, $detail . '; rollback: ' . implode('; ', $problems), $postId);
}
/** @throws RuntimeException When the draft is not deleted. */
private static function deleteDraft(int $postId): void
{
try {
$deleted = wp_delete_post($postId, true);
} catch (Throwable $e) {
throw new RuntimeException(sprintf('draft %d could not be deleted: %s', $postId, $e->getMessage()), 0, $e);
}
if (! $deleted) {
throw new RuntimeException(sprintf('draft %d could not be deleted', $postId));
}
}
private function logPartial(string $postId, string $summary): void
{
$this->log->recordPartial($this->ability, $this->postType, $postId, $summary);
}
}