'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 $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 $input * @return array */ 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 $args * @return array{items: list, next_cursor: ?string, warnings: list} */ 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 $args * @return array{items: list, next_cursor: ?string, warnings: list} */ 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); } }