diff --git a/docs/guides/media.md b/docs/guides/media.md index 4a4772d..7374bb4 100644 --- a/docs/guides/media.md +++ b/docs/guides/media.md @@ -27,10 +27,10 @@ nie auf „indirekt“ – ihr Bild- bzw. Dateiergebnis wird nur bei direktem Au - `lit-media-upload-from-url` (direkt): lädt eine öffentlich erreichbare `http(s)`-Datei; interne oder lokale Adressen werden abgelehnt. -- `lit/media-upload` (indirekt): lädt eine Datei als Base64. +- `lit/media-upload` (indirekt): lädt eine Datei als Base64, höchstens 10 MB – größere Dateien per URL. - Beide: optional `title`, `alt`, `post_id` (Eintrag, dem das Medium zugeordnet wird – braucht Bearbeitungsrecht); erlaubte Dateitypen jpg, png, gif, webp, avif, pdf, mp4, mp3; Größenlimit siehe die - Tool-Beschreibung (Standard 5 MB, einstellbar unter Einstellungen → LIT MCP → Limits). Beide legen bei + Tool-Beschreibung (Standard 5 MB, einstellbar bis 32 MB unter Einstellungen → LIT MCP → Limits). Beide legen bei jedem Aufruf ein neues Medium an (nicht idempotent). ## Ändern, herunterladen, löschen @@ -43,7 +43,7 @@ nie auf „indirekt“ – ihr Bild- bzw. Dateiergebnis wird nur bei direktem Au nicht bei einer reinen Alt-Text-Änderung. Bei Erfolg meldet `results` jeden Eintrag einzeln (mit neuem `modified`). - `lit-media-download`: Datei (Original oder eine Bildgröße aus `sizes` in `lit/media-get`) als eingebettete - Ressource, nur bis zum Upload-Limit – darüber die URL aus `lit/media-get` verwenden. + Ressource, nur bis zum Upload-Limit und höchstens 10 MB – darüber die URL aus `lit/media-get` verwenden. - `lit/media-delete`: nur nach Rückfrage (`confirm: true`). Wird das Medium verwendet (auch nur „möglicherweise“), kommt `in_use` mit der Liste der Verwendungen – erst nach ausdrücklicher Zustimmung des Nutzers mit `force: true` löschen. Medien im Papierkorb werden nicht gelöscht. Ohne aktivierten diff --git a/readme.txt b/readme.txt index 44b6196..52b182d 100644 --- a/readme.txt +++ b/readme.txt @@ -40,7 +40,7 @@ Einstellungen: Einstellungen → LIT MCP (Verbindung, Zugriff, Tools, Protokoll, Tabelle {prefix}lit_mcp_log (nur Verweise, keine Inhalte), täglicher Cron-Job lit_mcp_log_cleanup, Transient lit_mcp_endpoint_check. * Uploads über LIT MCP: nur Bilder (jpg, png, gif, webp, avif), PDF, MP4, MP3; Größenlimit einstellbar - (Standard 5 MB); Import per URL nur von öffentlich erreichbaren Adressen (interne/lokale Adressen werden + (Standard 5 MB, höchstens 32 MB; Base64-Upload und Download höchstens 10 MB); Import per URL nur von öffentlich erreichbaren Adressen (interne/lokale Adressen werden abgelehnt). == Rückgängig machen == diff --git a/src/Admin/LimitsTab.php b/src/Admin/LimitsTab.php index 36a6e1b..f591d53 100644 --- a/src/Admin/LimitsTab.php +++ b/src/Admin/LimitsTab.php @@ -42,7 +42,15 @@ final class LimitsTab implements AdminTab $values = (new Limits())->all(); $bounds = Limits::bounds(); $labels = [ - 'upload_mb' => __('Maximale Upload-/Download-Größe (MB)', 'wp-lit-mcp'), + 'upload_mb' => sprintf( + /* translators: 1: maximum value in MB, 2: cap for Base64 transfers in MB */ + __( + 'Maximale Upload-/Download-Größe (MB, höchstens %1$d; Base64-Upload/Download höchstens %2$d MB)', + 'wp-lit-mcp' + ), + $bounds['upload_mb'][1], + Limits::INLINE_MAX_MB + ), 'log_retention_days' => __('Protokoll aufbewahren (Tage)', 'wp-lit-mcp'), 'snapshot_count' => __('Anzahl Designsystem-Snapshots (ab Etappe 4)', 'wp-lit-mcp'), ]; diff --git a/src/Integrations/Media/MediaDownloadAbility.php b/src/Integrations/Media/MediaDownloadAbility.php index 8033945..142a24c 100644 --- a/src/Integrations/Media/MediaDownloadAbility.php +++ b/src/Integrations/Media/MediaDownloadAbility.php @@ -13,8 +13,9 @@ use WP_Error; * Delivers a media file (original or a named image size) as an MCP embedded resource * (spec §4.3). Direct tool: like ImageContent (lit-media-preview), an embedded resource * is only turned into a content block by mcp-adapter for a tool called directly, never - * via mcp-adapter-execute-ability (ruling Q6). Files above the upload limit (Limits) are - * refused; the caller falls back to the URL. + * via mcp-adapter-execute-ability (ruling Q6). The whole file is read into memory and sent + * as Base64, so files above Limits::inlineBytes() (upload limit, at most 10 MB) are refused; + * the caller falls back to the file URL. */ final class MediaDownloadAbility implements Ability { @@ -35,7 +36,8 @@ final class MediaDownloadAbility implements Ability public function description(): string { return 'Liefert eine Datei der Mediathek (Original oder Bildgröße, siehe sizes in lit/media-get) als ' - . 'eingebettete Ressource. Dateien über dem Upload-Limit werden nicht übertragen; dann die URL nutzen.'; + . 'eingebettete Ressource. Dateien über dem Upload-Limit (höchstens 10 MB) werden nicht übertragen; ' + . 'dann die Datei-URL nutzen.'; } public function inputSchema(): array @@ -103,14 +105,14 @@ final class MediaDownloadAbility implements Ability } $bytesOnDisk = (int) filesize($path); - if ($bytesOnDisk > $this->limits->uploadBytes()) { + if ($bytesOnDisk > $this->limits->inlineBytes()) { return Errors::unsupported( sprintf( 'Die Datei ist zu groß für die Übertragung (%.1f MB, Limit %d MB).', $bytesOnDisk / 1048576, - $this->limits->uploadMegabytes() + $this->limits->inlineMegabytes() ), - 'Kleinere Bildgröße (size) wählen oder die URL aus lit/media-get verwenden.' + 'Die Datei-URL (url aus lit/media-get) verwenden oder eine kleinere Bildgröße (size) wählen.' ); } diff --git a/src/Integrations/Media/MediaImporter.php b/src/Integrations/Media/MediaImporter.php index 12335c7..8472791 100644 --- a/src/Integrations/Media/MediaImporter.php +++ b/src/Integrations/Media/MediaImporter.php @@ -16,7 +16,8 @@ use WP_Error; * - URL: only http(s), checked with wp_http_validate_url() before any request and fetched * with wp_safe_remote_get() (rejects internal addresses, also after redirects), streamed * into a temp file and cut off one byte above the upload limit. - * - Base64: decoded strictly; the size is checked before anything is decoded or written. + * - Base64: decoded strictly; the size is checked against Limits::inlineBytes() (upload limit, + * at most 10 MB – the whole file sits in memory) before anything is decoded or written. * - Type: whitelist (filter lit_mcp_upload_mimes) intersected with what WordPress allows the * current user (SVG etc. only if WordPress itself allows it), checked against the real * file content with wp_check_filetype_and_ext() – disguised files fail. @@ -83,8 +84,8 @@ class MediaImporter 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); + if ($estimated > $this->limits->inlineBytes() + 2) { + return $this->tooLargeInline($estimated); } $bytes = base64_decode($data, true); @@ -95,8 +96,8 @@ class MediaImporter ); } - if (strlen($bytes) > $this->limits->uploadBytes()) { - return $this->tooLarge(strlen($bytes)); + if (strlen($bytes) > $this->limits->inlineBytes()) { + return $this->tooLargeInline(strlen($bytes)); } AdminIncludes::load(); @@ -337,6 +338,20 @@ class MediaImporter ); } + private function tooLargeInline(int $bytes): WP_Error + { + if ($this->limits->inlineMegabytes() === $this->limits->uploadMegabytes()) { + return $this->tooLarge($bytes); + } + + return Errors::invalidInput( + sprintf('Datei zu groß (%.1f MB, Limit %d MB).', $bytes / 1048576, $this->limits->inlineMegabytes()), + ['data' => 'über dem Limit für Base64-Uploads'], + 'Größere Dateien über eine öffentliche URL mit lit/media-upload-from-url (direkt: ' + . 'lit-media-upload-from-url) importieren.' + ); + } + private function tooLarge(int $bytes, string $field = 'data'): WP_Error { return Errors::invalidInput( diff --git a/src/Integrations/Media/MediaUploadAbility.php b/src/Integrations/Media/MediaUploadAbility.php index 1e88eae..cb0d3c3 100644 --- a/src/Integrations/Media/MediaUploadAbility.php +++ b/src/Integrations/Media/MediaUploadAbility.php @@ -10,7 +10,8 @@ use WP_Error; /** * Uploads a Base64-encoded file into the media library (spec §6.3), up to the configured - * upload limit. Checks and cleanup: MediaImporter; permission and result: MediaUploads. + * upload limit (at most Limits::INLINE_MAX_MB, the request carries the whole file). + * Checks and cleanup: MediaImporter; permission and result: MediaUploads. */ final class MediaUploadAbility implements Ability { @@ -36,8 +37,8 @@ final class MediaUploadAbility implements Ability return sprintf( 'Lädt eine Datei (Bild, PDF, MP4, MP3) als Base64 in die Mediathek, höchstens %d MB; optional mit ' . 'Titel, Alt-Text und Zuordnung zu einem Eintrag (post_id). Nicht idempotent: jeder Aufruf legt ' - . 'ein neues Medium an.', - $this->limits->uploadMegabytes() + . 'ein neues Medium an. Größere Dateien per URL mit lit/media-upload-from-url importieren.', + $this->limits->inlineMegabytes() ); } diff --git a/src/Limits.php b/src/Limits.php index aaedb71..ec4d665 100644 --- a/src/Limits.php +++ b/src/Limits.php @@ -11,7 +11,14 @@ final class Limits { public const OPTION = 'lit_mcp_limits'; public const DEFAULTS = ['upload_mb' => 5, 'log_retention_days' => 90, 'snapshot_count' => 20]; - private const BOUNDS = ['upload_mb' => [1, 100], 'log_retention_days' => [1, 3650], 'snapshot_count' => [1, 100]]; + /** + * Hard cap for transfers held in memory as a whole and passed through the MCP response or + * request as Base64 (lit-media-download, lit/media-upload): above it, PHP memory and the + * JSON payload become the problem, whatever upload_mb says. URL imports stream to disk + * and only use upload_mb. + */ + public const INLINE_MAX_MB = 10; + private const BOUNDS = ['upload_mb' => [1, 32], 'log_retention_days' => [1, 3650], 'snapshot_count' => [1, 100]]; public function uploadBytes(): int { @@ -23,6 +30,17 @@ final class Limits return $this->all()['upload_mb']; } + /** Limit for Base64 transfers: the upload limit, at most INLINE_MAX_MB. */ + public function inlineBytes(): int + { + return $this->inlineMegabytes() * 1024 * 1024; + } + + public function inlineMegabytes(): int + { + return min($this->uploadMegabytes(), self::INLINE_MAX_MB); + } + public function logRetentionDays(): int { return $this->all()['log_retention_days']; diff --git a/tests/Unit/Admin/LimitsTabTest.php b/tests/Unit/Admin/LimitsTabTest.php index c233d43..34b7f56 100644 --- a/tests/Unit/Admin/LimitsTabTest.php +++ b/tests/Unit/Admin/LimitsTabTest.php @@ -40,7 +40,9 @@ final class LimitsTabTest extends TestCase self::assertSame(3, substr_count($html, 'type="number"')); self::assertStringContainsString('name="lit_mcp_limits[upload_mb]" value="8"', $html); self::assertStringContainsString('name="lit_mcp_limits[log_retention_days]" value="90"', $html); - self::assertStringContainsString('min="1" max="100"', $html); + self::assertStringContainsString('min="1" max="32"', $html); + self::assertStringContainsString('höchstens 32', $html); + self::assertStringContainsString('10 MB', $html); self::assertStringContainsString('min="1" max="3650"', $html); } @@ -50,7 +52,7 @@ final class LimitsTabTest extends TestCase Functions\stubTranslationFunctions(); Functions\when('settings_fields')->justReturn(null); Functions\when('submit_button')->justReturn(null); - Functions\when('get_option')->justReturn(['upload_mb' => 50]); + Functions\when('get_option')->justReturn(['upload_mb' => 20]); Functions\when('wp_max_upload_size')->justReturn(2 * 1024 * 1024); ob_start(); @@ -58,7 +60,7 @@ final class LimitsTabTest extends TestCase $html = (string) ob_get_clean(); self::assertStringContainsString('class="description"', $html); - self::assertMatchesRegularExpression('/50 MB.*2 MB/s', $html); + self::assertMatchesRegularExpression('/20 MB.*2 MB/s', $html); } public function testNoWarningWhenUploadMbIsWithinTheServersOwnUploadLimit(): void diff --git a/tests/Unit/Integrations/Media/MediaDownloadAbilityTest.php b/tests/Unit/Integrations/Media/MediaDownloadAbilityTest.php index 1d97609..7817abf 100644 --- a/tests/Unit/Integrations/Media/MediaDownloadAbilityTest.php +++ b/tests/Unit/Integrations/Media/MediaDownloadAbilityTest.php @@ -62,6 +62,18 @@ final class MediaDownloadAbilityTest extends TestCase self::assertSame('lit_unsupported', $result->get_error_code()); } + public function testDownloadsAreCappedAtTenMegabytesEvenWithAHigherUploadLimit(): void + { + Functions\when('get_option')->justReturn(['upload_mb' => 32]); + file_put_contents($this->file, str_repeat('x', 10 * 1024 * 1024 + 1)); + + $result = (new MediaDownloadAbility(new Limits()))->execute(['id' => 40]); + + self::assertSame('lit_unsupported', $result->get_error_code()); + self::assertStringContainsString('Limit 10 MB', $result->get_error_message()); + self::assertStringContainsString('URL', (string) $result->get_error_data()['hint']); + } + public function testUnknownSizeIsNotFound(): void { Functions\when('get_option')->justReturn([]); diff --git a/tests/Unit/Integrations/Media/MediaImporterTest.php b/tests/Unit/Integrations/Media/MediaImporterTest.php index 1fbb6f5..22f9c3f 100644 --- a/tests/Unit/Integrations/Media/MediaImporterTest.php +++ b/tests/Unit/Integrations/Media/MediaImporterTest.php @@ -131,6 +131,18 @@ final class MediaImporterTest extends TestCase self::assertSame([], $this->tempFiles, 'no temp file may be written for an oversized upload'); } + public function testBase64UploadsAreCappedAtTenMegabytesEvenWithAHigherUploadLimit(): void + { + Functions\when('get_option')->justReturn(['upload_mb' => 32]); + + $result = $this->importer()->fromBase64(str_repeat('A', 16 * 1024 * 1024), 'big.png', []); + + self::assertSame('lit_validation_failed', $result->get_error_code()); + self::assertStringContainsString('Limit 10 MB', $result->get_error_message()); + self::assertStringContainsString('lit/media-upload-from-url', (string) $result->get_error_data()['hint']); + self::assertSame([], $this->tempFiles); + } + public function testInvalidBase64IsRejected(): void { $result = $this->importer()->fromBase64('***', 'x.png', []); diff --git a/tests/Unit/Integrations/Media/MediaUploadAbilitiesTest.php b/tests/Unit/Integrations/Media/MediaUploadAbilitiesTest.php index 041ad1c..c40508f 100644 --- a/tests/Unit/Integrations/Media/MediaUploadAbilitiesTest.php +++ b/tests/Unit/Integrations/Media/MediaUploadAbilitiesTest.php @@ -154,4 +154,12 @@ final class MediaUploadAbilitiesTest extends TestCase self::assertFalse($ability->isDirect()); self::assertTrue((new MediaUploadFromUrlAbility(Mockery::mock(MediaImporter::class), $this->uploads()))->isDirect()); } + + public function testDescriptionNamesTheTenMegabyteCapForBase64(): void + { + Functions\when('get_option')->justReturn(['upload_mb' => 32]); + $ability = new MediaUploadAbility(Mockery::mock(MediaImporter::class), $this->uploads(), new Limits()); + + self::assertStringContainsString('höchstens 10 MB', $ability->description()); + } } diff --git a/tests/Unit/LimitsTest.php b/tests/Unit/LimitsTest.php index 7e4ce6d..4472508 100644 --- a/tests/Unit/LimitsTest.php +++ b/tests/Unit/LimitsTest.php @@ -24,7 +24,7 @@ final class LimitsTest extends TestCase { Functions\when('get_option')->justReturn(['upload_mb' => 500, 'log_retention_days' => 0, 'snapshot_count' => '7']); - self::assertSame(['upload_mb' => 100, 'log_retention_days' => 1, 'snapshot_count' => 7], (new Limits())->all()); + self::assertSame(['upload_mb' => 32, 'log_retention_days' => 1, 'snapshot_count' => 7], (new Limits())->all()); } public function testCorruptOptionFallsBackToDefaults(): void @@ -45,7 +45,7 @@ final class LimitsTest extends TestCase public function testBoundsMatchesWhatSanitizeClampsTo(): void { - self::assertSame([1, 100], Limits::bounds()['upload_mb']); + self::assertSame([1, 32], Limits::bounds()['upload_mb']); self::assertSame([1, 3650], Limits::bounds()['log_retention_days']); self::assertSame([1, 100], Limits::bounds()['snapshot_count']); @@ -61,4 +61,23 @@ final class LimitsTest extends TestCase Limits::sanitize($tooHigh) ); } + + public function testInlineTransfersAreCappedAtTenMegabytes(): void + { + Functions\when('get_option')->justReturn(['upload_mb' => 32]); + $limits = new Limits(); + + self::assertSame(32 * 1024 * 1024, $limits->uploadBytes()); + self::assertSame(10 * 1024 * 1024, $limits->inlineBytes()); + self::assertSame(10, $limits->inlineMegabytes()); + } + + public function testInlineLimitFollowsALowerUploadLimit(): void + { + Functions\when('get_option')->justReturn(['upload_mb' => 3]); + $limits = new Limits(); + + self::assertSame(3 * 1024 * 1024, $limits->inlineBytes()); + self::assertSame(3, $limits->inlineMegabytes()); + } }