Files
WP-LIT-MCP/src/Integrations/Media/MediaSearchAbility.php
T
janniclas.loosenandClaude Opus 5.5 b0da2cdecc fix: restrict media-search to the query, not just editable() after it
I1: query the attachments themselves author-restricted when the user
lacks edit_others_posts (PostGuard::ownPostsOnly, extracted from
ContentQueryAbility so content-query reuses the exact same rule)
instead of relying solely on a post-query editable() filter, which
could leak the existence of other users' attachments via an empty
page plus next_cursor. editable() stays as a backstop.

Also: M1 document that only status=inherit attachments are listed;
M2 tie-break ordering by ID (content-query and media-search) for
stable pagination; M3 no next_cursor once the last candidate was
examined; M4 reject an explicit non-image mime with missing_alt;
M5 additional coverage (posts_per_page, cursor offset pass-through,
invalid cursor, post_status/date/mime args); mime maxLength 100.

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

286 lines
10 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\Cursor;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Integrations\WordPress\PostGuard;
use WP_Error;
use WP_Post;
/**
* Media library search (spec §6.3). Direct tool. `unused` only ever checks a small,
* bounded number of candidates per call via MediaUsageFinder::isUsed() (each call does one
* full content scan – expensive): it stops after MAX_SCAN candidates and reports the
* interruption as a warning together with a next_cursor to continue.
*/
final class MediaSearchAbility implements Ability
{
/** Max. attachments checked with isUsed() per call – keeps the tool cheap (spec/ruling Q8). */
private const MAX_SCAN = 20;
private const DATE_PATTERN = '^\d{4}-\d{2}-\d{2}$';
public function __construct(private MediaPresenter $presenter, private MediaUsageFinder $finder)
{
}
public function name(): string
{
return 'lit/media-search';
}
public function label(): string
{
return 'Medien suchen';
}
public function description(): string
{
return 'Sucht in der Mediathek nach Text (Titel, Dateiname, Beschreibung), MIME-Typ (z. B. image oder '
. 'application/pdf) und Zeitraum. Nur Medien mit Status inherit (reguläre Mediathek-Einträge, kein '
. 'Papierkorb), die der Nutzer bearbeiten darf. missing_alt: Bilder ohne Alt-Text (mime muss dabei leer '
. 'oder ein Bild-Typ sein). unused: Medien ohne gefundene Verwendung – "keine Verwendung gefunden" ist '
. 'keine Garantie (Details je Medium über lit/media-get); als "möglicherweise verwendet" erkannte '
. 'Medien zählen bereits als verwendet und werden nicht gelistet. Prüft dafür je Aufruf höchstens '
. self::MAX_SCAN . ' Medien; wird die Prüfung dadurch unterbrochen, folgt eine Warnung und mit '
. 'next_cursor kann fortgesetzt werden.';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => array_merge([
'search' => ['type' => 'string', 'maxLength' => 200],
'mime' => ['type' => 'string', 'maxLength' => 100, 'pattern' => '^[a-z]+(/[a-z0-9.+-]+)?$'],
'date_after' => ['type' => 'string', 'pattern' => self::DATE_PATTERN],
'date_before' => ['type' => 'string', 'pattern' => self::DATE_PATTERN],
'missing_alt' => ['type' => 'boolean'],
'unused' => ['type' => 'boolean'],
], Cursor::inputProperties()),
'additionalProperties' => false,
];
}
public function outputSchema(): ?array
{
return [
'type' => 'object',
'properties' => [
'items' => ['type' => 'array', 'items' => MediaPresenter::summarySchema()],
'next_cursor' => ['type' => ['string', 'null']],
'warnings' => ['type' => 'array', 'items' => ['type' => 'string']],
],
'required' => ['items', 'next_cursor', 'warnings'],
];
}
public function annotations(): array
{
return ['readonly' => true, 'destructive' => false, 'idempotent' => true];
}
public function isDirect(): bool
{
return true;
}
public function permission(array $input): bool|WP_Error
{
return current_user_can('upload_files')
? true
: Errors::forbidden('Keine Berechtigung für die Mediathek (upload_files).');
}
public function execute(array $input): mixed
{
$offset = Cursor::decode(isset($input['cursor']) ? (string) $input['cursor'] : null);
if ($offset === null) {
return Cursor::invalid();
}
$mimeError = $this->rejectNonImageMimeForMissingAlt($input);
if ($mimeError !== null) {
return $mimeError;
}
$limit = Cursor::limit($input['limit'] ?? null);
$args = $this->queryArgs($input);
$page = ($input['unused'] ?? false) === true
? $this->unusedPage($args, $limit, $offset)
: $this->searchPage($args, $limit, $offset);
return [
'items' => array_map(
fn (WP_Post $attachment): array => $this->presenter->summary($attachment),
$page['items']
),
'next_cursor' => $page['next_cursor'],
'warnings' => $page['warnings'],
];
}
/**
* missing_alt only makes sense for images; an explicit non-image mime would otherwise
* be silently ignored (post_mime_type overwritten) and quietly return nothing.
*
* @param array<string, mixed> $input
*/
private function rejectNonImageMimeForMissingAlt(array $input): ?WP_Error
{
$mime = isset($input['mime']) ? (string) $input['mime'] : '';
$isImage = $mime === '' || $mime === 'image' || str_starts_with($mime, 'image/');
if (($input['missing_alt'] ?? false) !== true || $isImage) {
return null;
}
return Errors::invalidInput(
'missing_alt gilt nur für Bilder.',
['mime' => 'kein Bild-MIME-Typ'],
'mime weglassen, auf "image" setzen oder einen "image/…"-Subtyp verwenden.'
);
}
/**
* @param array<string, mixed> $input
* @return array<string, mixed>
*/
private function queryArgs(array $input): array
{
$args = [
'post_type' => 'attachment',
'post_status' => 'inherit',
// A secondary ID tie-breaker keeps paging stable when several attachments share
// the exact same date (common with bulk uploads).
'orderby' => ['date' => 'DESC', 'ID' => 'DESC'],
'no_found_rows' => true,
'suppress_filters' => false,
];
// Without edit_others_posts, restrict the query itself to the user's own uploads –
// filtering with editable() afterwards is not enough on its own: an unrestricted
// query could otherwise come back empty (or short) with a next_cursor that quietly
// reveals other users' attachments exist without ever showing them.
$ownerId = PostGuard::ownPostsOnly('attachment');
if ($ownerId !== null) {
$args['author'] = $ownerId;
}
if (isset($input['search']) && $input['search'] !== '') {
$args['s'] = (string) $input['search'];
}
if (isset($input['mime']) && $input['mime'] !== '') {
$args['post_mime_type'] = (string) $input['mime'];
}
if (($input['missing_alt'] ?? false) === true) {
$args['post_mime_type'] ??= 'image';
$args['meta_query'] = [
'relation' => 'OR',
['key' => '_wp_attachment_image_alt', 'compare' => 'NOT EXISTS'],
['key' => '_wp_attachment_image_alt', 'value' => '', 'compare' => '='],
];
}
$date = [];
if (isset($input['date_after'])) {
$date['after'] = (string) $input['date_after'];
}
if (isset($input['date_before'])) {
$date['before'] = (string) $input['date_before'];
}
if ($date !== []) {
$args['date_query'] = [array_merge($date, ['inclusive' => true])];
}
return $args;
}
/**
* @param array<string, mixed> $args
* @return array{items: list<WP_Post>, next_cursor: ?string, warnings: list<string>}
*/
private function searchPage(array $args, int $limit, int $offset): array
{
$rows = get_posts(array_merge($args, ['posts_per_page' => $limit + 1, 'offset' => $offset]));
$page = Cursor::page(array_values($rows), $limit, $offset);
return [
// editable() stays as a backstop even though the query is already author-restricted
// above: e.g. a per-attachment cap denial (a filter, or the author losing rights
// after upload) must still drop the row instead of exposing it.
'items' => array_values(array_filter($page['items'], [self::class, 'editable'])),
'next_cursor' => $page['next_cursor'],
'warnings' => [],
];
}
/**
* Fetches at most MAX_SCAN + 1 candidates (the extra one only tells us whether more exist,
* it is never passed to isUsed()) so at most MAX_SCAN expensive isUsed() checks happen here.
*
* @param array<string, mixed> $args
* @return array{items: list<WP_Post>, next_cursor: ?string, warnings: list<string>}
*/
private function unusedPage(array $args, int $limit, int $offset): array
{
$candidates = array_values(get_posts(array_merge($args, [
'posts_per_page' => self::MAX_SCAN + 1,
'offset' => $offset,
])));
$items = [];
$position = $offset;
$examined = 0;
$reachedLimit = false;
foreach ($candidates as $attachment) {
if ($examined >= self::MAX_SCAN) {
break;
}
$position++;
$examined++;
if (self::editable($attachment) && ! $this->finder->isUsed((int) $attachment->ID)) {
$items[] = $attachment;
if (count($items) === $limit) {
$reachedLimit = true;
break;
}
}
}
// The extra (MAX_SCAN + 1)th candidate, when present, was deliberately never examined –
// its mere presence is enough to know more candidates remain either way.
if ($examined >= count($candidates)) {
return ['items' => $items, 'next_cursor' => null, 'warnings' => []];
}
if ($reachedLimit) {
return ['items' => $items, 'next_cursor' => Cursor::encode($position), 'warnings' => []];
}
return [
'items' => $items,
'next_cursor' => Cursor::encode($position),
'warnings' => [sprintf(
'Suche nach unbenutzten Medien nach %d geprüften Medien unterbrochen; mit next_cursor fortsetzen.',
self::MAX_SCAN
)],
];
}
/** Backstop: editable() is checked even though the query is already author-restricted. */
private static function editable(WP_Post $attachment): bool
{
return current_user_can('edit_post', (int) $attachment->ID);
}
}