From 2e29a6cd4d87c9f4217abb63433cc8ac04612886 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sat, 26 Sep 2026 02:37:43 +0200 Subject: [PATCH] feat: media upload by URL and base64 with whitelist and size limit MediaImporter streams URL imports via wp_safe_remote_get with limit_response_size, checks Content-Length, intersects the MIME whitelist with get_allowed_mime_types and validates real content; base64 is size-checked before decoding. Server-side failures are write_failed; a failed alt text deletes the new attachment again (or partial_write). MediaUploads is the shared permission/result helper of both upload abilities. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7 --- docs/guides/overview.md | 4 +- src/Integrations/Media/MediaImporter.php | 279 ++++++++++++++++ src/Integrations/Media/MediaIntegration.php | 8 +- src/Integrations/Media/MediaUploadAbility.php | 96 ++++++ .../Media/MediaUploadFromUrlAbility.php | 88 +++++ src/Integrations/Media/MediaUploads.php | 154 +++++++++ .../Integrations/Media/MediaImporterTest.php | 302 ++++++++++++++++++ .../Media/MediaIntegrationTest.php | 2 + .../Media/MediaUploadAbilitiesTest.php | 157 +++++++++ 9 files changed, 1086 insertions(+), 4 deletions(-) create mode 100644 src/Integrations/Media/MediaImporter.php create mode 100644 src/Integrations/Media/MediaUploadAbility.php create mode 100644 src/Integrations/Media/MediaUploadFromUrlAbility.php create mode 100644 src/Integrations/Media/MediaUploads.php create mode 100644 tests/Unit/Integrations/Media/MediaImporterTest.php create mode 100644 tests/Unit/Integrations/Media/MediaUploadAbilitiesTest.php diff --git a/docs/guides/overview.md b/docs/guides/overview.md index ea7ccf0..b54f0f6 100644 --- a/docs/guides/overview.md +++ b/docs/guides/overview.md @@ -19,14 +19,14 @@ des verbundenen WordPress-Benutzers. Direkt sichtbar: `lit-guide`, `lit-site-status`, `lit-content-types`, `lit-content-query`, `lit-content-get`, `lit-content-create`, `lit-content-update`, `lit-post-publish`, `lit-media-search`, -`lit-media-preview`, `lit-media-download`. +`lit-media-preview`, `lit-media-download`, `lit-media-upload-from-url`. Über `mcp-adapter-discover-abilities` / `mcp-adapter-execute-ability` (Ability-Namen `lit/…`; in Guides und Hinweisen immer so geschrieben): Status (`lit/post-unpublish`, `lit/post-schedule`, `lit/post-trash`), Kopien (`lit/post-duplicate`, `lit/post-replace-with-copy`), Revisionen (`lit/revisions-list`, `lit/revision-restore`), Menüs (`lit/menu-list`, `lit/menu-get`, `lit/menu-create`, `lit/menu-set-items`, `lit/menu-assign-location`), -Einstellungen (`lit/wp-settings-get`, `lit/wp-settings-update`), Medien (`lit/media-get`). +Einstellungen (`lit/wp-settings-get`, `lit/wp-settings-update`), Medien (`lit/media-get`, `lit/media-upload`). Weitere Themen: `lit-guide` mit `topic` = `content-types` oder `workflows`. diff --git a/src/Integrations/Media/MediaImporter.php b/src/Integrations/Media/MediaImporter.php new file mode 100644 index 0000000..59312b6 --- /dev/null +++ b/src/Integrations/Media/MediaImporter.php @@ -0,0 +1,279 @@ + 'image/jpeg', + 'png' => 'image/png', + 'gif' => 'image/gif', + 'webp' => 'image/webp', + 'avif' => 'image/avif', + 'pdf' => 'application/pdf', + 'mp4|m4v' => 'video/mp4', + 'mp3' => 'audio/mpeg', + ]; + + private const TIMEOUT = 30; + private const ALT_KEY = '_wp_attachment_image_alt'; + + public function __construct(private Limits $limits) + { + } + + /** @param array{title?: string, alt?: string, post_id?: int} $meta */ + public function fromUrl(string $url, ?string $filename, array $meta): int|WP_Error + { + $scheme = strtolower((string) wp_parse_url($url, PHP_URL_SCHEME)); + if (! in_array($scheme, ['http', 'https'], true) || ! wp_http_validate_url($url)) { + return Errors::invalidInput( + 'Die URL ist nicht erlaubt.', + ['url' => 'nur öffentlich erreichbare http(s)-Adressen'], + 'Eine öffentlich erreichbare Datei-URL angeben (keine internen oder lokalen Adressen).' + ); + } + + AdminIncludes::load(); + $tmp = (string) wp_tempnam('lit-mcp-import'); + try { + $response = $this->download($url, $tmp); + if ($response instanceof WP_Error) { + return $response; + } + + $name = $filename ?? wp_basename((string) wp_parse_url($url, PHP_URL_PATH)); + + return $this->import($tmp, $this->withExtension($name, $response), $meta); + } finally { + $this->removeTemp($tmp); + } + } + + /** @param array{title?: string, alt?: string, post_id?: int} $meta */ + public function fromBase64(string $data, string $filename, array $meta): int|WP_Error + { + $estimated = intdiv(strlen($data) * 3, 4); + if ($estimated > $this->limits->uploadBytes() + 2) { + return $this->tooLarge($estimated); + } + + $bytes = base64_decode($data, true); + if ($bytes === false || $bytes === '') { + return Errors::invalidInput( + 'data ist kein gültiges Base64.', + ['data' => 'Base64-kodierter Dateiinhalt erwartet (ohne data:-Präfix)'] + ); + } + + if (strlen($bytes) > $this->limits->uploadBytes()) { + return $this->tooLarge(strlen($bytes)); + } + + AdminIncludes::load(); + $tmp = (string) wp_tempnam('lit-mcp-upload'); + try { + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- temp file. + if ($tmp === '' || file_put_contents($tmp, $bytes) === false) { + return Errors::writeFailed('Die temporäre Datei konnte nicht geschrieben werden.'); + } + + return $this->import($tmp, $filename, $meta); + } finally { + $this->removeTemp($tmp); + } + } + + /** @return array|WP_Error The response (body streamed into $tmp). */ + private function download(string $url, string $tmp): array|WP_Error + { + $response = wp_safe_remote_get($url, [ + 'timeout' => self::TIMEOUT, + 'stream' => true, + 'filename' => $tmp, + 'limit_response_size' => $this->limits->uploadBytes() + 1, + ]); + if ($response instanceof WP_Error) { + return $this->downloadFailed($response->get_error_message()); + } + + $status = (int) wp_remote_retrieve_response_code($response); + if ($status !== 200) { + return $this->downloadFailed(sprintf('HTTP-Status %d', $status)); + } + + $length = (string) wp_remote_retrieve_header($response, 'content-length'); + if (ctype_digit($length) && (int) $length > $this->limits->uploadBytes()) { + return $this->tooLarge((int) $length, 'url'); + } + + return $response; + } + + /** @param array{title?: string, alt?: string, post_id?: int} $meta */ + private function import(string $tmp, string $filename, array $meta): int|WP_Error + { + clearstatcache(true, $tmp); + $size = (int) filesize($tmp); + if ($size > $this->limits->uploadBytes()) { + return $this->tooLarge($size); + } + + $mimes = $this->allowedMimes(); + $name = sanitize_file_name($filename !== '' ? $filename : 'datei'); + $check = wp_check_filetype_and_ext($tmp, $name, $mimes); + if (empty($check['type']) || empty($check['ext'])) { + return Errors::invalidInput( + 'Dateityp nicht erlaubt oder Inhalt passt nicht zur Endung.', + ['filename' => 'erlaubt: ' . str_replace('|', ', ', implode(', ', array_keys($mimes)))], + 'Nur erlaubte Dateitypen hochladen (Bilder, PDF, MP4, MP3) und die passende Endung verwenden.' + ); + } + + if (! empty($check['proper_filename'])) { + $name = (string) $check['proper_filename']; + } + + $title = isset($meta['title']) ? sanitize_text_field((string) $meta['title']) : null; + $attachmentId = media_handle_sideload( + ['name' => $name, 'tmp_name' => $tmp], + (int) ($meta['post_id'] ?? 0), + $title + ); + if ($attachmentId instanceof WP_Error) { + $reason = 'WordPress hat die Datei nicht übernommen: ' . $attachmentId->get_error_message(); + + return Errors::writeFailed($reason); + } + + $attachmentId = (int) $attachmentId; + if (isset($meta['alt']) && $meta['alt'] !== '') { + $this->saveAlt($attachmentId, sanitize_text_field((string) $meta['alt'])); + } + + return $attachmentId; + } + + /** @throws WriteFailed When the alt text cannot be saved (attachment deleted again if possible). */ + private function saveAlt(int $attachmentId, string $alt): void + { + // update_post_meta() is also false when the value is already stored (alt from IPTC data). + $stored = update_post_meta($attachmentId, self::ALT_KEY, $alt) !== false + || (string) get_post_meta($attachmentId, self::ALT_KEY, true) === $alt; + if ($stored) { + return; + } + + $reason = 'Der Alt-Text konnte nicht gespeichert werden.'; + $detail = sprintf('alt text of new attachment %d could not be saved', $attachmentId); + try { + $deleted = (bool) wp_delete_attachment($attachmentId, true); + } catch (Throwable $e) { + $deleted = false; + $detail .= '; delete failed: ' . $e->getMessage(); + } + + if ($deleted) { + throw new WriteFailed($reason, true, null, null, null, $detail); + } + + throw new WriteFailed( + $reason, + false, + $attachmentId, + null, + null, + $detail . '; attachment could not be deleted', + sprintf('Medium %d wurde trotzdem angelegt, aber ohne Alt-Text.', $attachmentId), + 'Den Aufruf nicht wiederholen (sonst liegt die Datei doppelt vor). Medium mit lit/media-get prüfen, den ' + . 'Alt-Text in der Mediathek nachtragen und dem Nutzer Bescheid geben.', + ['attachment_id' => $attachmentId] + ); + } + + /** + * Whitelist (filterable) restricted to what WordPress allows the current user. + * + * @return array + */ + private function allowedMimes(): array + { + $mimes = apply_filters('lit_mcp_upload_mimes', self::MIMES); + $mimes = is_array($mimes) ? $mimes : self::MIMES; + $allowed = get_allowed_mime_types(); + + return array_intersect_key($mimes, is_array($allowed) ? $allowed : []); + } + + /** + * URLs without an extension (CDNs, image services): take it from the Content-Type + * header, if that type is allowed. The real content is checked afterwards anyway. + * + * @param array $response + */ + private function withExtension(string $name, array $response): string + { + if ($name !== '' && pathinfo($name, PATHINFO_EXTENSION) !== '') { + return $name; + } + + $type = strtolower(trim(explode(';', (string) wp_remote_retrieve_header($response, 'content-type'))[0])); + foreach ($this->allowedMimes() as $extensions => $mime) { + if ($mime === $type) { + return ($name !== '' ? $name : 'datei') . '.' . explode('|', $extensions)[0]; + } + } + + return $name; + } + + private function removeTemp(string $tmp): void + { + if ($tmp !== '' && file_exists($tmp)) { + wp_delete_file($tmp); + } + } + + private function downloadFailed(string $reason): WP_Error + { + return Errors::invalidInput( + 'Download fehlgeschlagen: ' . $reason, + ['url' => 'nicht abrufbar'], + 'URL prüfen; nur öffentlich erreichbare Dateien können importiert werden.' + ); + } + + private function tooLarge(int $bytes, string $field = 'data'): WP_Error + { + return Errors::invalidInput( + sprintf('Datei zu groß (%.1f MB, Limit %d MB).', $bytes / 1048576, $this->limits->uploadMegabytes()), + [$field => 'über dem Upload-Limit'], + 'Kleinere Datei verwenden oder das Limit unter Einstellungen → LIT MCP → Limits erhöhen lassen.' + ); + } +} diff --git a/src/Integrations/Media/MediaIntegration.php b/src/Integrations/Media/MediaIntegration.php index 9a6ebd6..5bb34f1 100644 --- a/src/Integrations/Media/MediaIntegration.php +++ b/src/Integrations/Media/MediaIntegration.php @@ -10,8 +10,8 @@ use LIT\MCP\Integrations\WordPress\Schema\ContentSchema; use LIT\MCP\Limits; /** - * Media library tools (spec §6.3). Always available. $log and $limits are used by the - * write tools that follow (upload, update, delete). + * Media library tools (spec §6.3). Always available. $log and $limits serve the write + * tools (upload now, update and delete to follow). */ final class MediaIntegration implements Integration { @@ -33,11 +33,15 @@ final class MediaIntegration implements Integration { $presenter = new MediaPresenter(); $finder = new MediaUsageFinder(fn (): array => $this->schema->mediaFieldKeys()); + $importer = new MediaImporter($this->limits); + $uploads = new MediaUploads($presenter, $this->log, $this->schema); return [ new MediaSearchAbility($presenter, $finder), new MediaGetAbility($presenter, $finder), new MediaPreviewAbility(), + new MediaUploadFromUrlAbility($importer, $uploads), + new MediaUploadAbility($importer, $uploads, $this->limits), new MediaDownloadAbility($this->limits), ]; } diff --git a/src/Integrations/Media/MediaUploadAbility.php b/src/Integrations/Media/MediaUploadAbility.php new file mode 100644 index 0000000..1e88eae --- /dev/null +++ b/src/Integrations/Media/MediaUploadAbility.php @@ -0,0 +1,96 @@ +limits->uploadMegabytes() + ); + } + + public function inputSchema(): array + { + return [ + 'type' => 'object', + 'properties' => array_merge( + [ + 'filename' => ['type' => 'string', 'minLength' => 1, 'description' => 'Dateiname mit Endung.'], + 'data' => [ + 'type' => 'string', + 'minLength' => 1, + 'description' => 'Dateiinhalt als Base64 (ohne data:-Präfix).', + ], + ], + MediaUploads::metaProperties() + ), + 'required' => ['filename', 'data'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return MediaUploads::outputSchema(); + } + + public function annotations(): array + { + return ['readonly' => false, 'destructive' => false, 'idempotent' => false]; + } + + public function isDirect(): bool + { + return false; + } + + public function permission(array $input): bool|WP_Error + { + return $this->uploads->permission($input); + } + + public function execute(array $input): mixed + { + return $this->uploads->run( + $this->name(), + fn (): int|WP_Error => $this->importer->fromBase64( + (string) $input['data'], + (string) $input['filename'], + MediaUploads::meta($input) + ), + 'Datei "%s" hochgeladen (ID %d)' + ); + } +} diff --git a/src/Integrations/Media/MediaUploadFromUrlAbility.php b/src/Integrations/Media/MediaUploadFromUrlAbility.php new file mode 100644 index 0000000..7bfe6dd --- /dev/null +++ b/src/Integrations/Media/MediaUploadFromUrlAbility.php @@ -0,0 +1,88 @@ + 'object', + 'properties' => array_merge( + [ + 'url' => ['type' => 'string', 'minLength' => 1, 'description' => 'http(s)-URL der Datei.'], + 'filename' => [ + 'type' => 'string', + 'description' => 'Optional: Dateiname mit Endung (Standard: aus der URL).', + ], + ], + MediaUploads::metaProperties() + ), + 'required' => ['url'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return MediaUploads::outputSchema(); + } + + public function annotations(): array + { + return ['readonly' => false, 'destructive' => false, 'idempotent' => false]; + } + + public function isDirect(): bool + { + return true; + } + + public function permission(array $input): bool|WP_Error + { + return $this->uploads->permission($input); + } + + public function execute(array $input): mixed + { + $filename = isset($input['filename']) && $input['filename'] !== '' ? (string) $input['filename'] : null; + $url = (string) $input['url']; + + return $this->uploads->run( + $this->name(), + fn (): int|WP_Error => $this->importer->fromUrl($url, $filename, MediaUploads::meta($input)), + 'Datei "%s" von URL importiert (ID %d)' + ); + } +} diff --git a/src/Integrations/Media/MediaUploads.php b/src/Integrations/Media/MediaUploads.php new file mode 100644 index 0000000..e3f3080 --- /dev/null +++ b/src/Integrations/Media/MediaUploads.php @@ -0,0 +1,154 @@ + $input + */ + public function permission(array $input): bool|WP_Error + { + if (! current_user_can('upload_files')) { + return Errors::forbidden('Keine Berechtigung zum Hochladen (upload_files).'); + } + + if (! isset($input['post_id'])) { + return true; + } + + $parent = PostGuard::editable($input['post_id'], $this->schema); + + return $parent instanceof WP_Error ? $parent : true; + } + + /** The meta fields the importer takes from the input. */ + public static function meta(array $input): array + { + return array_intersect_key($input, ['title' => true, 'alt' => true, 'post_id' => true]); + } + + /** + * Runs $import (returns the new attachment id or an error) and builds the result. + * + * @param callable(): (int|WP_Error) $import + * @param string $done Summary with "%s" = title and "%d" = id, + * e.g. 'Datei "%s" hochgeladen (ID %d)'. + * @return array|WP_Error + * @throws WriteFailed From the importer; a partial one is logged as partial first. + */ + public function run(string $ability, callable $import, string $done): array|WP_Error + { + try { + $attachmentId = $import(); + } catch (WriteFailed $e) { + if (! $e->rolledBack && $e->postId !== null) { + $summary = sprintf('Medium %d: Upload nur teilweise gespeichert', $e->postId); + $this->log->recordPartial($ability, 'attachment', (string) $e->postId, $summary); + } + throw $e; + } + + if ($attachmentId instanceof WP_Error) { + return $attachmentId; + } + + return $this->result($ability, (int) $attachmentId, $done); + } + + /** @return array */ + private function result(string $ability, int $attachmentId, string $done): array + { + $warnings = []; + $saved = sprintf('Medium %d ist gespeichert.', $attachmentId); + $attachment = get_post($attachmentId); + $title = $attachment instanceof WP_Post ? (string) $attachment->post_title : ''; + $details = $attachment instanceof WP_Post + ? FollowUp::run( + fn (): array => $this->presenter->summary($attachment), + $ability . ' summary', + 'Die Angaben zum Medium konnten nicht gelesen werden; ' . $saved, + $warnings + ) + : null; + + $summary = sprintf($done, $title, $attachmentId); + $logId = FollowUp::run( + fn (): ?int => $this->log->record($ability, 'attachment', (string) $attachmentId, $summary), + $ability . ' change log', + 'Änderungsprotokoll konnte nicht geschrieben werden; ' . $saved, + $warnings + ); + + return ToolResult::ok($summary . '.') + ->changed((string) $attachmentId, 'attachment', 'created', $title) + ->logId(is_int($logId) ? $logId : null) + ->warnings($warnings) + ->urls(null, (string) get_edit_post_link($attachmentId, 'raw')) + ->with('attachment', is_array($details) ? $details : $this->fallback($attachmentId, $title)) + ->toArray(); + } + + /** @return array The required summary fields when the attachment cannot be read. */ + private function fallback(int $attachmentId, string $title): array + { + return [ + 'id' => $attachmentId, + 'title' => $title, + 'mime_type' => '', + 'url' => (string) wp_get_attachment_url($attachmentId), + 'alt' => '', + ]; + } + + /** @return array */ + public static function outputSchema(): array + { + return ToolResult::schema(['attachment' => MediaPresenter::summarySchema()], ['attachment']); + } + + /** + * Input properties both abilities share. + * + * @return array> + */ + public static function metaProperties(): array + { + return [ + 'title' => ['type' => 'string', 'description' => 'Optional: Titel in der Mediathek.'], + 'alt' => ['type' => 'string', 'description' => 'Optional: Alt-Text (bei Bildern empfohlen).'], + 'post_id' => [ + 'type' => 'integer', + 'minimum' => 1, + 'description' => 'Optional: Eintrag, dem das Medium zugeordnet wird (braucht Bearbeitungsrecht).', + ], + ]; + } +} diff --git a/tests/Unit/Integrations/Media/MediaImporterTest.php b/tests/Unit/Integrations/Media/MediaImporterTest.php new file mode 100644 index 0000000..1931932 --- /dev/null +++ b/tests/Unit/Integrations/Media/MediaImporterTest.php @@ -0,0 +1,302 @@ + */ + private array $tempFiles = []; + + protected function setUp(): void + { + parent::setUp(); + Functions\when('get_option')->justReturn(['upload_mb' => 1]); + Functions\when('wp_tempnam')->alias(function (): string { + $file = (string) tempnam(sys_get_temp_dir(), 'lit-up'); + $this->tempFiles[] = $file; + return $file; + }); + Functions\when('wp_delete_file')->alias(static function (string $file): void { + if (is_file($file)) { + unlink($file); + } + }); + Functions\when('sanitize_file_name')->returnArg(); + Functions\when('wp_basename')->alias('basename'); + Functions\when('wp_parse_url')->alias('parse_url'); + Functions\when('get_allowed_mime_types')->justReturn(MediaImporter::MIMES); + } + + protected function tearDown(): void + { + foreach ($this->tempFiles as $file) { + if (is_file($file)) { + unlink($file); + } + } + parent::tearDown(); + } + + private function importer(): MediaImporter + { + return new MediaImporter(new Limits()); + } + + private function assertTempFilesRemoved(): void + { + foreach ($this->tempFiles as $file) { + self::assertFileDoesNotExist($file); + } + } + + /** Lets the next wp_safe_remote_get write $body into the stream target and answer with $headers. */ + private function respond(string $body, int $code = 200, array $headers = []): void + { + Functions\when('wp_safe_remote_get')->alias(static function (string $url, array $args) use ($body, $code, $headers): array { + file_put_contents($args['filename'], $body); + return ['response' => ['code' => $code], 'headers' => $headers, 'filename' => $args['filename']]; + }); + $this->responseReaders(); + } + + private function responseReaders(): void + { + Functions\when('wp_http_validate_url')->returnArg(); + Functions\when('wp_remote_retrieve_response_code')->alias(static fn (array $r): int => $r['response']['code']); + Functions\when('wp_remote_retrieve_header')->alias( + static fn (array $r, string $name): string => (string) ($r['headers'][$name] ?? '') + ); + } + + public function testBase64UploadIsSideloadedWithAltText(): void + { + Functions\when('wp_check_filetype_and_ext')->justReturn(['ext' => 'png', 'type' => 'image/png', 'proper_filename' => false]); + Functions\expect('media_handle_sideload')->once() + ->with(Mockery::on(static fn (array $file): bool => $file['name'] === 'logo.png' && is_file($file['tmp_name'])), 5, 'Logo') + ->andReturnUsing(static function (array $file): int { + unlink($file['tmp_name']); // WordPress moves the temp file into uploads. + return 55; + }); + Functions\expect('update_post_meta')->once()->with(55, '_wp_attachment_image_alt', 'Firmenlogo')->andReturn(true); + + $id = $this->importer()->fromBase64(base64_encode('PNGDATA'), 'logo.png', [ + 'title' => 'Logo', + 'alt' => 'Firmenlogo', + 'post_id' => 5, + ]); + + self::assertSame(55, $id); + } + + public function testOversizedBase64IsRejectedBeforeDecoding(): void + { + $result = $this->importer()->fromBase64(str_repeat('A', 2 * 1024 * 1024), 'big.png', []); + + self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertStringContainsString('Limit 1 MB', $result->get_error_message()); + self::assertSame([], $this->tempFiles, 'no temp file may be written for an oversized upload'); + } + + public function testInvalidBase64IsRejected(): void + { + $result = $this->importer()->fromBase64('***', 'x.png', []); + + self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertSame([], $this->tempFiles); + } + + public function testDisguisedFileIsRejectedAndTheTempFileRemoved(): void + { + Functions\when('wp_check_filetype_and_ext')->justReturn(['ext' => false, 'type' => false, 'proper_filename' => false]); + Functions\expect('media_handle_sideload')->never(); + + $result = $this->importer()->fromBase64(base64_encode('get_error_code()); + self::assertNotSame([], $this->tempFiles); + $this->assertTempFilesRemoved(); + } + + public function testTheWhitelistOnlyKeepsTypesWordPressAllowsTheUser(): void + { + Functions\when('get_allowed_mime_types')->justReturn(['png' => 'image/png', 'svg' => 'image/svg+xml']); + Functions\expect('wp_check_filetype_and_ext')->once() + ->with(Mockery::type('string'), 'logo.png', ['png' => 'image/png']) + ->andReturn(['ext' => false, 'type' => false, 'proper_filename' => false]); + + $result = $this->importer()->fromBase64(base64_encode('PNGDATA'), 'logo.png', []); + + self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertStringContainsString('png', $result->get_error_message()); + } + + public function testSideloadFailureIsAWriteFailureAndCleansUp(): void + { + Functions\when('wp_check_filetype_and_ext')->justReturn(['ext' => 'png', 'type' => 'image/png', 'proper_filename' => false]); + Functions\when('media_handle_sideload')->justReturn(new WP_Error('upload_error', 'Verzeichnis nicht beschreibbar')); + + $result = $this->importer()->fromBase64(base64_encode('PNGDATA'), 'logo.png', []); + + self::assertSame('lit_write_failed', $result->get_error_code()); + self::assertStringContainsString('Verzeichnis nicht beschreibbar', $result->get_error_message()); + $this->assertTempFilesRemoved(); + } + + public function testFailedAltTextDeletesTheNewAttachmentAgain(): void + { + Functions\when('wp_check_filetype_and_ext')->justReturn(['ext' => 'png', 'type' => 'image/png', 'proper_filename' => false]); + Functions\when('media_handle_sideload')->justReturn(55); + Functions\when('update_post_meta')->justReturn(false); + Functions\when('get_post_meta')->justReturn(''); + Functions\expect('wp_delete_attachment')->once()->with(55, true)->andReturn(new \stdClass()); + + try { + $this->importer()->fromBase64(base64_encode('PNGDATA'), 'logo.png', ['alt' => 'Logo']); + self::fail('WriteFailed expected'); + } catch (WriteFailed $e) { + self::assertTrue($e->rolledBack); + self::assertSame('lit_write_failed', $e->toError()->get_error_code()); + } + $this->assertTempFilesRemoved(); + } + + public function testAltTextFailureWithoutRollbackIsAPartialWriteNamingTheAttachment(): void + { + Functions\when('wp_check_filetype_and_ext')->justReturn(['ext' => 'png', 'type' => 'image/png', 'proper_filename' => false]); + Functions\when('media_handle_sideload')->justReturn(55); + Functions\when('update_post_meta')->justReturn(false); + Functions\when('get_post_meta')->justReturn(''); + Functions\when('wp_delete_attachment')->justReturn(false); + + try { + $this->importer()->fromBase64(base64_encode('PNGDATA'), 'logo.png', ['alt' => 'Logo']); + self::fail('WriteFailed expected'); + } catch (WriteFailed $e) { + self::assertFalse($e->rolledBack); + self::assertSame(55, $e->postId); + $error = $e->toError(); + self::assertSame('lit_partial_write', $error->get_error_code()); + self::assertStringContainsString('Medium 55', $error->get_error_message()); + self::assertSame(55, $error->get_error_data()['attachment_id']); + } + } + + public function testAnAltTextAlreadyTakenFromTheImageIsNoFailure(): void + { + Functions\when('wp_check_filetype_and_ext')->justReturn(['ext' => 'png', 'type' => 'image/png', 'proper_filename' => false]); + Functions\when('media_handle_sideload')->justReturn(55); + Functions\when('update_post_meta')->justReturn(false); // Same value already stored (IPTC alt). + Functions\when('get_post_meta')->justReturn('Logo'); + Functions\expect('wp_delete_attachment')->never(); + + self::assertSame(55, $this->importer()->fromBase64(base64_encode('PNGDATA'), 'logo.png', ['alt' => 'Logo'])); + } + + public function testInternalUrlsAreRejectedWithoutRequest(): void + { + Functions\when('wp_http_validate_url')->justReturn(false); + Functions\expect('wp_safe_remote_get')->never(); + + $result = $this->importer()->fromUrl('http://127.0.0.1/wp-config.php', null, []); + + self::assertSame('lit_validation_failed', $result->get_error_code()); + } + + public function testOnlyHttpUrlsAreFetched(): void + { + Functions\when('wp_http_validate_url')->returnArg(); + Functions\expect('wp_safe_remote_get')->never(); + + $result = $this->importer()->fromUrl('ftp://example.com/logo.png', null, []); + + self::assertSame('lit_validation_failed', $result->get_error_code()); + } + + public function testUrlImportDownloadsSafelyWithTheSizeLimit(): void + { + $this->responseReaders(); + Functions\expect('wp_safe_remote_get')->once()->with( + 'https://example.com/bilder/logo.png', + Mockery::on(static function (array $args): bool { + file_put_contents($args['filename'], 'PNGDATA'); + return $args['stream'] === true && $args['limit_response_size'] === 1024 * 1024 + 1; + }) + )->andReturn(['response' => ['code' => 200], 'headers' => []]); + Functions\when('wp_check_filetype_and_ext')->justReturn(['ext' => 'png', 'type' => 'image/png', 'proper_filename' => false]); + Functions\expect('media_handle_sideload')->once() + ->with(Mockery::on(static fn (array $file): bool => $file['name'] === 'logo.png'), 0, null) + ->andReturn(56); + + self::assertSame(56, $this->importer()->fromUrl('https://example.com/bilder/logo.png', null, [])); + $this->assertTempFilesRemoved(); + } + + public function testAMissingExtensionIsTakenFromTheContentType(): void + { + $this->respond('PNGDATA', 200, ['content-type' => 'image/png; charset=binary']); + Functions\when('wp_check_filetype_and_ext')->justReturn(['ext' => 'png', 'type' => 'image/png', 'proper_filename' => false]); + Functions\expect('media_handle_sideload')->once() + ->with(Mockery::on(static fn (array $file): bool => $file['name'] === 'foto.png'), 0, null) + ->andReturn(57); + + self::assertSame(57, $this->importer()->fromUrl('https://cdn.example.com/foto?w=800', null, [])); + } + + public function testFailedDownloadIsReportedAndCleansUp(): void + { + Functions\when('wp_http_validate_url')->returnArg(); + Functions\when('wp_safe_remote_get')->justReturn(new WP_Error('http_request_failed', 'Not Found')); + + $result = $this->importer()->fromUrl('https://example.com/fehlt.jpg', null, []); + + self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertStringContainsString('Not Found', $result->get_error_message()); + $this->assertTempFilesRemoved(); + } + + public function testHttpErrorStatusIsReported(): void + { + $this->respond('404', 404); + Functions\expect('media_handle_sideload')->never(); + + $result = $this->importer()->fromUrl('https://example.com/fehlt.jpg', null, []); + + self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertStringContainsString('404', $result->get_error_message()); + $this->assertTempFilesRemoved(); + } + + public function testOversizedContentLengthIsRejected(): void + { + $this->respond('x', 200, ['content-length' => (string) (5 * 1024 * 1024)]); + Functions\expect('media_handle_sideload')->never(); + + $result = $this->importer()->fromUrl('https://example.com/gross.jpg', null, []); + + self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertStringContainsString('Limit 1 MB', $result->get_error_message()); + $this->assertTempFilesRemoved(); + } + + public function testDownloadCutOffAtTheLimitIsRejected(): void + { + $this->respond(str_repeat('x', 1024 * 1024 + 1)); + Functions\expect('media_handle_sideload')->never(); + + $result = $this->importer()->fromUrl('https://example.com/gross.jpg', null, []); + + self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertStringContainsString('Limit 1 MB', $result->get_error_message()); + $this->assertTempFilesRemoved(); + } +} diff --git a/tests/Unit/Integrations/Media/MediaIntegrationTest.php b/tests/Unit/Integrations/Media/MediaIntegrationTest.php index c635092..47a9fa4 100644 --- a/tests/Unit/Integrations/Media/MediaIntegrationTest.php +++ b/tests/Unit/Integrations/Media/MediaIntegrationTest.php @@ -19,6 +19,8 @@ final class MediaIntegrationTest extends TestCase 'lit/media-search', 'lit/media-get', 'lit/media-preview', + 'lit/media-upload-from-url', + 'lit/media-upload', 'lit/media-download', ]; diff --git a/tests/Unit/Integrations/Media/MediaUploadAbilitiesTest.php b/tests/Unit/Integrations/Media/MediaUploadAbilitiesTest.php new file mode 100644 index 0000000..041ad1c --- /dev/null +++ b/tests/Unit/Integrations/Media/MediaUploadAbilitiesTest.php @@ -0,0 +1,157 @@ +allows('summary')->andReturn(['id' => 55, 'title' => 'Logo']); + $schema = Mockery::mock(ContentSchema::class); + $schema->allows('isManaged')->andReturn(true); + + return new MediaUploads($presenter, $log ?? Mockery::mock(ChangeLog::class), $schema); + } + + private function savedAttachment(): void + { + Functions\when('get_post')->justReturn(new WP_Post(['ID' => 55, 'post_type' => 'attachment', 'post_title' => 'Logo'])); + Functions\when('get_edit_post_link')->justReturn('https://kunde.de/wp-admin/post.php?post=55&action=edit'); + } + + public function testUploadFromUrlReportsTheNewAttachment(): void + { + $this->savedAttachment(); + $importer = Mockery::mock(MediaImporter::class); + $importer->expects('fromUrl')->with('https://example.com/logo.png', null, ['alt' => 'Logo'])->andReturn(55); + $log = Mockery::mock(ChangeLog::class); + $log->expects('record')->with('lit/media-upload-from-url', 'attachment', '55', Mockery::type('string'))->andReturn(3); + + $result = (new MediaUploadFromUrlAbility($importer, $this->uploads($log))) + ->execute(['url' => 'https://example.com/logo.png', 'alt' => 'Logo']); + + self::assertSame(['id' => 55, 'title' => 'Logo'], $result['attachment']); + self::assertSame(['id' => '55', 'type' => 'attachment', 'action' => 'created', 'label' => 'Logo'], $result['changed'][0]); + self::assertSame(3, $result['log_id']); + self::assertSame('https://kunde.de/wp-admin/post.php?post=55&action=edit', $result['edit_url']); + self::assertSame([], $result['warnings']); + } + + public function testBase64UploadPassesFilenameDataAndMeta(): void + { + $this->savedAttachment(); + $importer = Mockery::mock(MediaImporter::class); + $importer->expects('fromBase64')->with('AAAA', 'x.png', ['title' => 'Logo', 'post_id' => 5])->andReturn(55); + $log = Mockery::mock(ChangeLog::class); + $log->expects('record')->with('lit/media-upload', 'attachment', '55', Mockery::type('string'))->andReturn(4); + + $result = (new MediaUploadAbility($importer, $this->uploads($log), new Limits())) + ->execute(['filename' => 'x.png', 'data' => 'AAAA', 'title' => 'Logo', 'post_id' => 5]); + + self::assertTrue($result['ok']); + self::assertSame(4, $result['log_id']); + } + + public function testAFailingChangeLogIsOnlyAWarning(): void + { + $this->savedAttachment(); + $importer = Mockery::mock(MediaImporter::class); + $importer->allows('fromUrl')->andReturn(55); + $log = Mockery::mock(ChangeLog::class); + $log->allows('record')->andThrow(new RuntimeException('db down')); + // error_log() is a PHP internal and cannot be stubbed; redirect it to a temp file instead. + $file = (string) tempnam(sys_get_temp_dir(), 'lit-log'); + $previous = ini_set('error_log', $file); + try { + $result = (new MediaUploadFromUrlAbility($importer, $this->uploads($log))) + ->execute(['url' => 'https://example.com/logo.png']); + self::assertStringContainsString('db down', (string) file_get_contents($file)); + } finally { + ini_set('error_log', (string) $previous); + unlink($file); + } + + self::assertTrue($result['ok']); + self::assertNull($result['log_id']); + self::assertStringContainsString('Medium 55 ist gespeichert', $result['warnings'][0]); + } + + public function testImporterErrorsArePassedThrough(): void + { + $importer = Mockery::mock(MediaImporter::class); + $importer->allows('fromBase64')->andReturn(Errors::invalidInput('zu groß')); + + $result = (new MediaUploadAbility($importer, $this->uploads(), new Limits())) + ->execute(['filename' => 'x.png', 'data' => 'AAAA']); + + self::assertSame('lit_validation_failed', $result->get_error_code()); + } + + public function testAPartialUploadIsLoggedAsPartialAndRethrown(): void + { + $failure = new WriteFailed('Alt-Text fehlt.', false, 55); + $importer = Mockery::mock(MediaImporter::class); + $importer->allows('fromUrl')->andThrow($failure); + $log = Mockery::mock(ChangeLog::class); + $log->expects('recordPartial')->with('lit/media-upload-from-url', 'attachment', '55', Mockery::type('string')); + + $this->expectExceptionObject($failure); + (new MediaUploadFromUrlAbility($importer, $this->uploads($log)))->execute(['url' => 'https://example.com/a.png']); + } + + public function testPermissionNeedsUploadRightsAndEditRightsOnTheParent(): void + { + Functions\when('current_user_can')->alias(static fn (string $cap, int $id = 0): bool => $cap === 'upload_files'); + Functions\when('get_post')->justReturn(new WP_Post(['ID' => 5, 'post_type' => 'page', 'post_status' => 'publish'])); + $ability = new MediaUploadFromUrlAbility(Mockery::mock(MediaImporter::class), $this->uploads()); + + self::assertTrue($ability->permission(['url' => 'https://example.com/a.png'])); + self::assertSame('lit_forbidden', $ability->permission(['url' => 'https://example.com/a.png', 'post_id' => 5])->get_error_code()); + } + + public function testPermissionRefusesUsersWithoutUploadRights(): void + { + Functions\when('current_user_can')->justReturn(false); + $ability = new MediaUploadAbility(Mockery::mock(MediaImporter::class), $this->uploads(), new Limits()); + + self::assertSame('lit_forbidden', $ability->permission(['filename' => 'a.png', 'data' => 'AAAA'])->get_error_code()); + } + + public function testUploadsForAParentThatDoesNotExistAreRefused(): void + { + Functions\when('current_user_can')->justReturn(true); + Functions\when('get_post')->justReturn(null); + $ability = new MediaUploadAbility(Mockery::mock(MediaImporter::class), $this->uploads(), new Limits()); + + self::assertSame('lit_not_found', $ability->permission(['filename' => 'a.png', 'data' => 'AAAA', 'post_id' => 9])->get_error_code()); + } + + public function testDescriptionNamesTheConfiguredLimit(): void + { + Functions\when('get_option')->justReturn(['upload_mb' => 8]); + $ability = new MediaUploadAbility(Mockery::mock(MediaImporter::class), $this->uploads(), new Limits()); + + self::assertStringContainsString('8 MB', $ability->description()); + self::assertFalse($ability->isDirect()); + self::assertTrue((new MediaUploadFromUrlAbility(Mockery::mock(MediaImporter::class), $this->uploads()))->isDirect()); + } +}