Files
WP-LIT-MCP/src/Integrations/Media/MediaDeleteAbility.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

208 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\Media;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Annotations;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\FollowUp;
use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\ChangeLog\ChangeLog;
use WP_Error;
use WP_Post;
defined('ABSPATH') || exit;
/**
* lit/media-delete: deletes one attachment after confirmation. WordPress has no media trash by
* default (MEDIA_TRASH false): wp_delete_attachment($id, false) then deletes the attachment and
* all its files permanently. Media that is used – certainly or possibly – needs force: true.
* Trashed attachments are refused (MediaGuard): deleting them would always be permanent.
*/
final class MediaDeleteAbility implements Ability
{
/** @var callable(): bool Whether wp_delete_attachment($id, false) moves to the trash. */
private $mediaTrash;
public function __construct(
private MediaUsageFinder $finder,
private ChangeLog $log,
?callable $mediaTrash = null
) {
$this->mediaTrash = $mediaTrash ?? static fn (): bool => defined('MEDIA_TRASH') && MEDIA_TRASH
&& defined('EMPTY_TRASH_DAYS') && EMPTY_TRASH_DAYS;
}
public function name(): string
{
return 'lit/media-delete';
}
public function label(): string
{
return 'Medium löschen';
}
public function description(): string
{
return 'Löscht ein Medium samt Datei und allen Bildgrößen – ohne aktivierten Medien-Papierkorb '
. '(WordPress-Standard) endgültig, das lässt sich nicht rückgängig machen. Ohne confirm: true kommt '
. 'eine Rückfrage (confirm_required) mit den gefundenen Verwendungen; erst nach Zustimmung des Nutzers '
. 'mit confirm: true erneut aufrufen. Wird das Medium verwendet (auch nur "möglicherweise"), lehnt das '
. 'Tool mit in_use ab, außer force: true. Medien im Papierkorb werden nicht gelöscht. '
. 'Die Verwendungsprüfung findet NICHT: Term-Meta, Benutzer-Meta, Pods-Tabellen (pods_*, podsrel), '
. 'ACF-Felder (außer als Bildfeld im Schema mit einer ID pro Meta-Eintrag), serialisierte oder '
. 'kommagetrennte ID-Listen in Meta-Feldern und Optionen anderer Plugins – im Zweifel den Nutzer fragen.';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => [
'id' => ['type' => 'integer', 'minimum' => 1],
'confirm' => [
'type' => 'boolean',
'description' => 'Erst nach Rückfrage beim Nutzer true setzen.',
],
'force' => ['type' => 'boolean', 'description' => 'Trotz gefundener Verwendungen löschen.'],
],
'required' => ['id'],
'additionalProperties' => false,
];
}
public function outputSchema(): ?array
{
return ToolResult::schema(
['usages' => ['type' => 'array', 'items' => MediaUsageFinder::usageSchema()]],
['usages']
);
}
public function annotations(): array
{
return Annotations::destructive(false);
}
public function isDirect(): bool
{
return false;
}
public function permission(array $input): bool|WP_Error
{
$attachment = MediaGuard::attachment($input['id'] ?? null, 'delete_post');
return $attachment instanceof WP_Error ? $attachment : true;
}
public function execute(array $input): mixed
{
$attachment = MediaGuard::attachment($input['id'] ?? null, 'delete_post');
if ($attachment instanceof WP_Error) {
return $attachment;
}
$id = (int) $attachment->ID;
$title = (string) $attachment->post_title;
$trash = ($this->mediaTrash)();
// Same search as MediaUsageFinder::isUsed(): certain and possible usages both count.
$usages = $this->finder->usages($id);
if (($input['confirm'] ?? false) !== true) {
return Errors::confirmRequired($this->question($id, $title, $trash, $usages), ['usages' => $usages]);
}
if ($usages !== [] && ($input['force'] ?? false) !== true) {
return Errors::inUse(
sprintf(
'Medium "%s" wird noch verwendet (%d Stelle(n)); nichts wurde gelöscht.',
$title,
count($usages)
),
$usages
);
}
$file = (string) get_post_meta($id, '_wp_attached_file', true);
if (! wp_delete_attachment($id, false)) {
return $this->refused($id, $title, $file);
}
$warnings = [];
$summary = sprintf('Medium "%s" %s', $title, $trash ? 'in den Papierkorb gelegt' : 'gelöscht');
$logId = FollowUp::run(
fn (): ?int => $this->log->record($this->name(), 'attachment', (string) $id, $summary),
$this->name() . ' change log',
'Änderungsprotokoll konnte nicht geschrieben werden; das Medium ist trotzdem '
. ($trash ? 'im Papierkorb.' : 'gelöscht.'),
$warnings
);
return ToolResult::ok($summary . '.')
->changed((string) $id, 'attachment', $trash ? 'trashed' : 'deleted', $title)
->logId(is_int($logId) ? $logId : null)
->warnings($warnings)
->with('usages', $usages)
->toArray();
}
/**
* wp_delete_attachment() returned false/null. It deletes meta, terms and the row before the
* files, so a failure can leave half a deletion: only an attachment that is still there with
* its file reference intact counts as "nothing deleted".
*/
private function refused(int $id, string $title, string $file): WP_Error
{
$reason = sprintf('WordPress hat das Löschen von Medium %d abgelehnt.', $id);
$current = get_post($id);
$intact = $current instanceof WP_Post
&& $current->post_type === 'attachment'
&& (string) get_post_meta($id, '_wp_attached_file', true) === $file;
if ($intact) {
return Errors::writeFailed($reason);
}
$this->log->recordPartial(
$this->name(),
'attachment',
(string) $id,
sprintf('Medium "%s": Löschen nur teilweise ausgeführt', $title)
);
return Errors::partialWrite(
$reason,
null,
null,
sprintf('Medium %d ist möglicherweise teilweise gelöscht (Daten oder Dateien fehlen).', $id),
'Den Aufruf nicht einfach wiederholen. Das Medium mit lit/media-get bzw. in der WordPress-Mediathek '
. 'prüfen und dem Nutzer Bescheid geben.',
['attachment_id' => $id]
);
}
/** @param list<array<string, mixed>> $usages */
private function question(int $id, string $title, bool $trash, array $usages): string
{
$message = sprintf(
'Medium "%s" (ID %d) %s?',
$title,
$id,
$trash
? 'in den Papierkorb legen'
: 'endgültig löschen – samt Datei und allen Bildgrößen, nicht rückgängig zu machen'
);
if ($usages === []) {
return $message . ' Keine Verwendung gefunden (die Suche deckt nicht alle Speicherorte ab).';
}
$used = ' Es wird an %d Stelle(n) verwendet (siehe usages); löschen nur mit force: true.';
return $message . sprintf($used, count($usages));
}
}