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
286 lines
10 KiB
PHP
286 lines
10 KiB
PHP
<?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);
|
||
}
|
||
}
|