From 60d4f2d61f53551ca973222d7a770370fa078520 Mon Sep 17 00:00:00 2001 From: Shane Rosenthal Date: Fri, 11 Sep 2026 20:31:17 -0400 Subject: [PATCH 1/2] Add Admin MCP media upload and blog hero attach. Growth can set Filament blog heroes via admin-upload-media (public disk blog/heroes WebP) and admin-update-blog-post (media_id/path or URL) without emailing support. --- app/Mcp/Servers/AdminNativePhpServer.php | 16 +- app/Mcp/Tools/Admin/AdminGetBlogPost.php | 2 + app/Mcp/Tools/Admin/AdminUpdateBlogPost.php | 56 +++- app/Mcp/Tools/Admin/AdminUploadMedia.php | 129 ++++++++ app/Services/AdminArticleMediaService.php | 328 ++++++++++++++++++++ tests/Feature/Mcp/AdminMcpOAuthTest.php | 1 + tests/Feature/Mcp/AdminUploadMediaTest.php | 297 ++++++++++++++++++ 7 files changed, 818 insertions(+), 11 deletions(-) create mode 100644 app/Mcp/Tools/Admin/AdminUploadMedia.php create mode 100644 app/Services/AdminArticleMediaService.php create mode 100644 tests/Feature/Mcp/AdminUploadMediaTest.php diff --git a/app/Mcp/Servers/AdminNativePhpServer.php b/app/Mcp/Servers/AdminNativePhpServer.php index 44d98760..f581aeeb 100644 --- a/app/Mcp/Servers/AdminNativePhpServer.php +++ b/app/Mcp/Servers/AdminNativePhpServer.php @@ -15,6 +15,7 @@ use App\Mcp\Tools\Admin\AdminSearchSupportTickets; use App\Mcp\Tools\Admin\AdminSearchUsers; use App\Mcp\Tools\Admin\AdminUpdateBlogPost; +use App\Mcp\Tools\Admin\AdminUploadMedia; use Laravel\Mcp\Server; use Laravel\Mcp\Server\Tool; @@ -30,16 +31,18 @@ class AdminNativePhpServer extends Server Rules: - Never return passwords, remember tokens, GitHub tokens, license keys, Stripe secrets, or raw API credentials. - Blog: create/update unpublished drafts in v1 (no publish tool; slug changes refused once published). + - Hero images: upload via admin-upload-media (same public disk / blog/heroes as Filament), optionally attach in one shot; or set hero on admin-update-blog-post via media_id/path or URL. - Other tools are read-only ops helpers (signups, users, companies, plugins, sales summaries, support). Tools: 1. admin-create-blog-post — create an unpublished article. - 2. admin-update-blog-post — patch title/content/excerpt/slug (no publish/unpublish). - 3. admin-get-blog-post / admin-list-blog-posts — inspect drafts and published posts. - 4. admin-list-signups / admin-search-users / admin-get-user — user support lookups. - 5. admin-list-companies / admin-get-company — email-domain company rollups. - 6. admin-search-plugins / admin-sales-summary — marketplace/plugin ops (no secrets). - 7. admin-search-support-tickets / admin-get-support-ticket — support summaries. + 2. admin-upload-media — upload a blog hero image (base64 → public disk blog/heroes WebP); optional article_id/slug attach. + 3. admin-update-blog-post — patch title/content/excerpt/slug/hero (no publish/unpublish). + 4. admin-get-blog-post / admin-list-blog-posts — inspect drafts and published posts. + 5. admin-list-signups / admin-search-users / admin-get-user — user support lookups. + 6. admin-list-companies / admin-get-company — email-domain company rollups. + 7. admin-search-plugins / admin-sales-summary — marketplace/plugin ops (no secrets). + 8. admin-search-support-tickets / admin-get-support-ticket — support summaries. MARKDOWN; /** @@ -47,6 +50,7 @@ class AdminNativePhpServer extends Server */ protected array $tools = [ AdminCreateBlogPost::class, + AdminUploadMedia::class, AdminUpdateBlogPost::class, AdminGetBlogPost::class, AdminListBlogPosts::class, diff --git a/app/Mcp/Tools/Admin/AdminGetBlogPost.php b/app/Mcp/Tools/Admin/AdminGetBlogPost.php index 9a5906e0..93ea0cd6 100644 --- a/app/Mcp/Tools/Admin/AdminGetBlogPost.php +++ b/app/Mcp/Tools/Admin/AdminGetBlogPost.php @@ -60,6 +60,8 @@ public function handle(Request $request): Response 'title' => $article->title, 'excerpt' => $article->excerpt, 'content' => $article->content, + 'hero_image' => $article->hero_image, + 'hero_image_url' => $article->getHeroImageUrl(), 'published' => $article->isPublished(), 'published_at' => optional($article->published_at)?->toIso8601String(), 'author' => [ diff --git a/app/Mcp/Tools/Admin/AdminUpdateBlogPost.php b/app/Mcp/Tools/Admin/AdminUpdateBlogPost.php index c7e6ce21..5c55860b 100644 --- a/app/Mcp/Tools/Admin/AdminUpdateBlogPost.php +++ b/app/Mcp/Tools/Admin/AdminUpdateBlogPost.php @@ -5,6 +5,7 @@ use App\Filament\Resources\ArticleResource; use App\Mcp\Tools\Concerns\RequiresAdmin; use App\Models\Article; +use App\Services\AdminArticleMediaService; use Illuminate\Contracts\JsonSchema\JsonSchema; use Illuminate\JsonSchema\Types\Type; use Illuminate\Support\Str; @@ -13,13 +14,16 @@ use Laravel\Mcp\Server\Attributes\Description; use Laravel\Mcp\Server\Attributes\Name; use Laravel\Mcp\Server\Tool; +use RuntimeException; #[Name('admin-update-blog-post')] -#[Description('Update an existing blog article by id or slug. Patches only provided fields (title, content, excerpt, slug). Does not publish or unpublish. When id is provided, slug is treated as the new slug (drafts only; refused if published). When only slug is provided, it identifies the article.')] +#[Description('Update an existing blog article by id or slug. Patches only provided fields (title, content, excerpt, slug, hero image). Hero can be set via media_id/hero_image_path (from admin-upload-media) or hero_image_url. Does not publish or unpublish. When id is provided, slug is treated as the new slug (drafts only; refused if published). When only slug is provided, it identifies the article.')] class AdminUpdateBlogPost extends Tool { use RequiresAdmin; + public function __construct(protected AdminArticleMediaService $media) {} + public function handle(Request $request): Response { if ($denied = $this->ensureAdmin($request)) { @@ -32,6 +36,9 @@ public function handle(Request $request): Response 'title' => ['nullable', 'string', 'max:255'], 'content' => ['nullable', 'string'], 'excerpt' => ['nullable', 'string', 'max:5000'], + 'media_id' => ['nullable', 'string', 'max:500'], + 'hero_image_path' => ['nullable', 'string', 'max:500'], + 'hero_image_url' => ['nullable', 'string', 'max:2000'], ]); if (empty($validated['id']) && empty($validated['slug'])) { @@ -44,9 +51,12 @@ public function handle(Request $request): Response $updatingExcerpt = array_key_exists('excerpt', $input); // Slug is an update field only when identifying by id (same arg name as create). $updatingSlug = ! empty($validated['id']) && array_key_exists('slug', $input); + $updatingHero = array_key_exists('media_id', $input) + || array_key_exists('hero_image_path', $input) + || array_key_exists('hero_image_url', $input); - if (! $updatingTitle && ! $updatingContent && ! $updatingExcerpt && ! $updatingSlug) { - return Response::error('Provide at least one field to update: title, content, excerpt, or slug.'); + if (! $updatingTitle && ! $updatingContent && ! $updatingExcerpt && ! $updatingSlug && ! $updatingHero) { + return Response::error('Provide at least one field to update: title, content, excerpt, slug, media_id, hero_image_path, or hero_image_url.'); } $article = Article::query() @@ -105,8 +115,38 @@ public function handle(Request $request): Response $updates['slug'] = $slug; } - $article->fill($updates); - $article->save(); + if ($updates !== []) { + $article->fill($updates); + $article->save(); + } + + $heroMeta = null; + + if ($updatingHero) { + $mediaId = $validated['media_id'] ?? $validated['hero_image_path'] ?? null; + $heroUrl = $validated['hero_image_url'] ?? null; + + if (filled($mediaId) && filled($heroUrl)) { + return Response::error('Provide only one of media_id/hero_image_path or hero_image_url.'); + } + + if (! filled($mediaId) && ! filled($heroUrl)) { + return Response::error('media_id, hero_image_path, or hero_image_url cannot be empty.'); + } + + try { + $stored = filled($mediaId) + ? $this->media->resolveExistingMedia((string) $mediaId) + : $this->media->storeHeroFromUrl((string) $heroUrl); + + $article = $this->media->attachHeroToArticle($article->fresh(), $stored['path']); + $heroMeta = $stored; + } catch (RuntimeException $e) { + return Response::error($e->getMessage()); + } + } + + $article = $article->fresh(); $editUrl = null; @@ -122,6 +162,9 @@ public function handle(Request $request): Response 'title' => $article->title, 'excerpt' => $article->excerpt, 'content' => $article->content, + 'hero_image' => $article->hero_image, + 'hero_image_url' => $article->getHeroImageUrl(), + 'media' => $heroMeta, 'published' => $article->isPublished(), 'published_at' => optional($article->published_at)?->toIso8601String(), 'author_id' => $article->author_id, @@ -144,6 +187,9 @@ public function schema(JsonSchema $schema): array 'title' => $schema->string()->description('Optional new title.'), 'content' => $schema->string()->description('Optional new Markdown body.'), 'excerpt' => $schema->string()->description('Optional new excerpt.'), + 'media_id' => $schema->string()->description('Optional media_id/path from admin-upload-media (under blog/heroes) to set as the hero image.'), + 'hero_image_path' => $schema->string()->description('Alias for media_id: public-disk relative path under blog/heroes.'), + 'hero_image_url' => $schema->string()->description('Optional http(s) URL to download and set as the hero image (re-encoded to WebP on the public disk).'), ]; } } diff --git a/app/Mcp/Tools/Admin/AdminUploadMedia.php b/app/Mcp/Tools/Admin/AdminUploadMedia.php new file mode 100644 index 00000000..576822a8 --- /dev/null +++ b/app/Mcp/Tools/Admin/AdminUploadMedia.php @@ -0,0 +1,129 @@ +ensureAdmin($request)) { + return $denied; + } + + $validated = $request->validate([ + 'filename' => ['required', 'string', 'max:255'], + 'contentType' => ['required', 'string', 'max:100'], + 'content' => ['required', 'string'], + 'article_id' => ['nullable', 'integer', 'min:1'], + 'slug' => ['nullable', 'string', 'max:255'], + ]); + + try { + $stored = $this->media->storeHeroFromBase64( + $validated['content'], + $validated['contentType'], + $validated['filename'], + ); + } catch (RuntimeException $e) { + return Response::error($e->getMessage()); + } + + $payload = [ + 'path' => $stored['path'], + 'media_id' => $stored['media_id'], + 'url' => $stored['url'], + 'content_type' => $stored['content_type'], + 'bytes' => $stored['bytes'], + 'width' => $stored['width'], + 'height' => $stored['height'], + 'original_filename' => $stored['original_filename'], + 'disk' => AdminArticleMediaService::DISK, + 'directory' => AdminArticleMediaService::HERO_DIRECTORY, + 'attached' => false, + 'article' => null, + ]; + + $articleId = $validated['article_id'] ?? null; + $slug = $validated['slug'] ?? null; + + if ($articleId || filled($slug)) { + $article = Article::query() + ->when($articleId, fn ($q) => $q->where('id', $articleId)) + ->when(! $articleId && filled($slug), fn ($q) => $q->where('slug', $slug)) + ->first(); + + if (! $article) { + return Response::error('Image uploaded, but article not found for attach. media_id='.$stored['media_id']); + } + + try { + $article = $this->media->attachHeroToArticle($article, $stored['path']); + } catch (RuntimeException $e) { + return Response::error('Image uploaded, but attach failed: '.$e->getMessage().' media_id='.$stored['media_id']); + } + + $payload['attached'] = true; + $payload['article'] = $this->articlePayload($article); + } + + return Response::text($this->toJson($payload)); + } + + /** + * @return array + */ + protected function articlePayload(Article $article): array + { + $editUrl = null; + + try { + $editUrl = ArticleResource::getUrl('edit', ['record' => $article]); + } catch (\Throwable) { + $editUrl = url('/admin/articles/'.$article->id.'/edit'); + } + + return [ + 'id' => $article->id, + 'slug' => $article->slug, + 'title' => $article->title, + 'hero_image' => $article->hero_image, + 'hero_image_url' => $article->getHeroImageUrl(), + 'published' => $article->isPublished(), + 'admin_edit_url' => $editUrl, + 'preview_url' => route('article', $article), + ]; + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'filename' => $schema->string()->description('Original filename (used for logging; stored name is a UUID .webp).')->required(), + 'contentType' => $schema->string()->description('MIME type: image/webp (preferred), image/jpeg, or image/png.')->required(), + 'content' => $schema->string()->description('Base64-encoded image bytes with no data: URI prefix. Decoded size hard-capped at a few MB; re-encoded to WebP.')->required(), + 'article_id' => $schema->integer()->description('Optional article id to attach this upload as the hero/featured image.'), + 'slug' => $schema->string()->description('Optional article slug to attach as hero when article_id is omitted.'), + ]; + } +} diff --git a/app/Services/AdminArticleMediaService.php b/app/Services/AdminArticleMediaService.php new file mode 100644 index 00000000..5a41f5db --- /dev/null +++ b/app/Services/AdminArticleMediaService.php @@ -0,0 +1,328 @@ + + */ + public const ALLOWED_CONTENT_TYPES = [ + 'image/webp', + 'image/jpeg', + 'image/jpg', + 'image/png', + ]; + + public function __construct(protected ArticleImageService $articleImageService) {} + + /** + * Store a hero image from raw binary onto the same disk/path Filament uses. + * + * @return array{ + * path: string, + * media_id: string, + * url: string, + * content_type: string, + * bytes: int, + * width: int, + * height: int, + * original_filename: string|null + * } + */ + public function storeHeroFromBinary(string $binary, string $contentType, ?string $filename = null): array + { + $contentType = $this->normalizeContentType($contentType); + + if (! in_array($contentType, self::ALLOWED_CONTENT_TYPES, true)) { + throw new RuntimeException('contentType must be image/webp, image/jpeg, or image/png.'); + } + + $size = strlen($binary); + + if ($size === 0) { + throw new RuntimeException('Image content is empty.'); + } + + if ($size > self::MAX_DECODED_BYTES) { + throw new RuntimeException('Image exceeds the '.self::MAX_DECODED_BYTES.' byte decoded size limit.'); + } + + try { + $image = ImageManager::gd()->read($binary); + } catch (\Throwable $e) { + throw new RuntimeException('Could not decode image content: '.$e->getMessage(), previous: $e); + } + + $width = $image->width(); + $height = $image->height(); + + if ($width < ArticleImageService::OG_WIDTH || $height < ArticleImageService::OG_HEIGHT) { + throw new RuntimeException( + 'Image must be at least '.ArticleImageService::OG_WIDTH.'×'.ArticleImageService::OG_HEIGHT.'px (got '.$width.'×'.$height.').' + ); + } + + if ($width > ArticleImageService::HERO_MAX_WIDTH) { + $image->scaleDown(width: ArticleImageService::HERO_MAX_WIDTH); + $width = $image->width(); + $height = $image->height(); + } + + // Prefer WebP for MCP-uploaded heroes (smaller for CDN). + $encoded = $image->toWebp(self::WEBP_QUALITY); + $encodedBinary = $encoded->toString(); + + if (strlen($encodedBinary) > self::MAX_DECODED_BYTES) { + throw new RuntimeException('Encoded WebP still exceeds the size limit; provide a smaller source image.'); + } + + $disk = Storage::disk(self::DISK); + $disk->makeDirectory(self::HERO_DIRECTORY); + + $basename = Str::uuid()->toString().'.webp'; + $path = self::HERO_DIRECTORY.'/'.$basename; + + $disk->put($path, $encodedBinary, 'public'); + + return [ + 'path' => $path, + 'media_id' => $path, + 'url' => $this->publicUrl($path), + 'content_type' => 'image/webp', + 'bytes' => strlen($encodedBinary), + 'width' => $width, + 'height' => $height, + 'original_filename' => $filename, + ]; + } + + /** + * Decode base64 (no data: prefix) and store as a hero image. + * + * @return array{ + * path: string, + * media_id: string, + * url: string, + * content_type: string, + * bytes: int, + * width: int, + * height: int, + * original_filename: string|null + * } + */ + public function storeHeroFromBase64(string $base64, string $contentType, ?string $filename = null): array + { + $base64 = trim($base64); + + if (str_starts_with($base64, 'data:')) { + throw new RuntimeException('content must be raw base64 without a data: URI prefix.'); + } + + $binary = base64_decode($base64, true); + + if ($binary === false) { + throw new RuntimeException('content is not valid base64.'); + } + + return $this->storeHeroFromBinary($binary, $contentType, $filename); + } + + /** + * Download an image from a public URL and store it as a hero. + * + * @return array{ + * path: string, + * media_id: string, + * url: string, + * content_type: string, + * bytes: int, + * width: int, + * height: int, + * original_filename: string|null + * } + */ + public function storeHeroFromUrl(string $url): array + { + $url = trim($url); + + if (! preg_match('#^https?://#i', $url)) { + throw new RuntimeException('hero_image_url must be an http(s) URL.'); + } + + // If the URL already points at our public storage heroes path, reuse the file. + if ($existing = $this->pathFromPublicUrl($url)) { + if (! Storage::disk(self::DISK)->exists($existing)) { + throw new RuntimeException('Referenced media path does not exist on the public disk.'); + } + + if (! str_starts_with($existing, self::HERO_DIRECTORY.'/')) { + throw new RuntimeException('media_id/path must be under '.self::HERO_DIRECTORY.'.'); + } + + return $this->describeExisting($existing); + } + + $response = Http::timeout(30) + ->withHeaders(['Accept' => 'image/webp,image/jpeg,image/png,*/*']) + ->withOptions(['allow_redirects' => ['max' => 3]]) + ->get($url); + + if (! $response->successful()) { + throw new RuntimeException('Failed to download hero_image_url (HTTP '.$response->status().').'); + } + + $binary = $response->body(); + $contentType = $response->header('Content-Type') ?: 'image/jpeg'; + $contentType = strtok($contentType, ';') ?: $contentType; + + $filename = basename(parse_url($url, PHP_URL_PATH) ?: 'hero'); + + return $this->storeHeroFromBinary($binary, $contentType, $filename); + } + + /** + * Resolve a media_id / path that already lives on the public disk. + * + * @return array{ + * path: string, + * media_id: string, + * url: string, + * content_type: string, + * bytes: int, + * width: int, + * height: int, + * original_filename: string|null + * } + */ + public function resolveExistingMedia(string $mediaIdOrPath): array + { + $path = ltrim(trim($mediaIdOrPath), '/'); + + if ($fromUrl = $this->pathFromPublicUrl($path)) { + $path = $fromUrl; + } + + if (! str_starts_with($path, self::HERO_DIRECTORY.'/')) { + throw new RuntimeException('media_id/path must be under '.self::HERO_DIRECTORY.'.'); + } + + if (! Storage::disk(self::DISK)->exists($path)) { + throw new RuntimeException('Referenced media path does not exist on the public disk.'); + } + + return $this->describeExisting($path); + } + + /** + * Attach a stored hero path to an article and regenerate derived images. + */ + public function attachHeroToArticle(Article $article, string $path): Article + { + if (! str_starts_with($path, self::HERO_DIRECTORY.'/')) { + throw new RuntimeException('Hero path must be under '.self::HERO_DIRECTORY.'.'); + } + + if (! Storage::disk(self::DISK)->exists($path)) { + throw new RuntimeException('Hero path does not exist on the public disk.'); + } + + $article->fill([ + 'hero_image' => $path, + 'og_image_crop' => null, + 'card_image_crop' => null, + 'header_image_crop' => null, + ]); + $article->save(); + + $this->articleImageService->refreshImages($article->fresh()); + + return $article->fresh(); + } + + public function publicUrl(string $path): string + { + return Storage::disk(self::DISK)->url($path); + } + + /** + * @return array{ + * path: string, + * media_id: string, + * url: string, + * content_type: string, + * bytes: int, + * width: int, + * height: int, + * original_filename: string|null + * } + */ + protected function describeExisting(string $path): array + { + $binary = Storage::disk(self::DISK)->get($path); + $image = ImageManager::gd()->read($binary); + $extension = strtolower(pathinfo($path, PATHINFO_EXTENSION)); + $contentType = match ($extension) { + 'webp' => 'image/webp', + 'png' => 'image/png', + 'jpg', 'jpeg' => 'image/jpeg', + default => 'application/octet-stream', + }; + + return [ + 'path' => $path, + 'media_id' => $path, + 'url' => $this->publicUrl($path), + 'content_type' => $contentType, + 'bytes' => strlen($binary), + 'width' => $image->width(), + 'height' => $image->height(), + 'original_filename' => basename($path), + ]; + } + + protected function normalizeContentType(string $contentType): string + { + $contentType = strtolower(trim(strtok($contentType, ';') ?: $contentType)); + + return $contentType === 'image/jpg' ? 'image/jpeg' : $contentType; + } + + protected function pathFromPublicUrl(string $url): ?string + { + $trimmed = ltrim(trim($url), '/'); + + if (str_starts_with($trimmed, self::HERO_DIRECTORY.'/')) { + return $trimmed; + } + + $path = parse_url($url, PHP_URL_PATH); + + if (! is_string($path) || $path === '') { + return null; + } + + if (preg_match('~/storage/('.preg_quote(self::HERO_DIRECTORY, '~').'/[^?#]+)$~', $path, $matches)) { + return $matches[1]; + } + + return null; + } +} diff --git a/tests/Feature/Mcp/AdminMcpOAuthTest.php b/tests/Feature/Mcp/AdminMcpOAuthTest.php index dfd940b3..07ebbfef 100644 --- a/tests/Feature/Mcp/AdminMcpOAuthTest.php +++ b/tests/Feature/Mcp/AdminMcpOAuthTest.php @@ -83,6 +83,7 @@ public function test_admin_can_connect_via_oauth_and_use_admin_mcp_server(): voi $names = collect($tools->json('result.tools'))->pluck('name')->all(); $this->assertContains('admin-create-blog-post', $names); + $this->assertContains('admin-upload-media', $names); $this->assertContains('admin-update-blog-post', $names); $this->assertContains('admin-list-signups', $names); $this->assertContains('admin-list-companies', $names); diff --git a/tests/Feature/Mcp/AdminUploadMediaTest.php b/tests/Feature/Mcp/AdminUploadMediaTest.php new file mode 100644 index 00000000..157812eb --- /dev/null +++ b/tests/Feature/Mcp/AdminUploadMediaTest.php @@ -0,0 +1,297 @@ +configureMcpOAuthKeys(); + $this->admin = User::factory()->create(['email' => 'admin-media@nativephp.com']); + config(['filament.users' => [$this->admin->email]]); + } + + /** + * @return array{binary: string, base64: string} + */ + protected function makeHeroPayload(int $width = 1600, int $height = 840, string $format = 'jpeg'): array + { + $image = ImageManager::gd()->create($width, $height)->fill('3366ff'); + + $encoded = match ($format) { + 'png' => $image->toPng(), + 'webp' => $image->toWebp(80), + default => $image->toJpeg(85), + }; + + $binary = $encoded->toString(); + + return [ + 'binary' => $binary, + 'base64' => base64_encode($binary), + ]; + } + + public function test_upload_media_stores_webp_on_public_blog_heroes_disk(): void + { + $payload = $this->makeHeroPayload(); + $tokens = $this->issueMcpOAuthTokens($this->admin); + + $response = $this->callMcpTool($tokens['access_token'], 'admin-upload-media', [ + 'filename' => 'hero-source.jpg', + 'contentType' => 'image/jpeg', + 'content' => $payload['base64'], + ])->assertOk(); + + $this->assertFalse($response->json('result.isError')); + $result = json_decode((string) data_get($response->json(), 'result.content.0.text'), true); + + $this->assertSame('image/webp', $result['content_type']); + $this->assertSame('public', $result['disk']); + $this->assertSame('blog/heroes', $result['directory']); + $this->assertStringStartsWith('blog/heroes/', $result['path']); + $this->assertSame($result['path'], $result['media_id']); + $this->assertFalse($result['attached']); + $this->assertNull($result['article']); + $this->assertGreaterThan(0, $result['bytes']); + $this->assertSame(1600, $result['width']); + $this->assertSame(840, $result['height']); + + Storage::disk('public')->assertExists($result['path']); + $this->assertStringEndsWith('.webp', $result['path']); + } + + public function test_upload_media_rejects_data_uri_prefix_and_invalid_base64(): void + { + $tokens = $this->issueMcpOAuthTokens($this->admin); + $payload = $this->makeHeroPayload(); + + $dataUri = $this->callMcpTool($tokens['access_token'], 'admin-upload-media', [ + 'filename' => 'hero.webp', + 'contentType' => 'image/webp', + 'content' => 'data:image/webp;base64,'.$payload['base64'], + ])->assertOk(); + $this->assertTrue($dataUri->json('result.isError')); + $this->assertStringContainsString('data:', (string) data_get($dataUri->json(), 'result.content.0.text')); + + $bad = $this->callMcpTool($tokens['access_token'], 'admin-upload-media', [ + 'filename' => 'hero.webp', + 'contentType' => 'image/webp', + 'content' => '%%%not-base64%%%', + ])->assertOk(); + $this->assertTrue($bad->json('result.isError')); + $this->assertStringContainsString('base64', (string) data_get($bad->json(), 'result.content.0.text')); + } + + public function test_upload_media_rejects_oversized_decoded_payload(): void + { + $tokens = $this->issueMcpOAuthTokens($this->admin); + + // Build a binary larger than the hard cap without needing a huge real image. + $oversized = str_repeat('A', AdminArticleMediaService::MAX_DECODED_BYTES + 1); + + $response = $this->callMcpTool($tokens['access_token'], 'admin-upload-media', [ + 'filename' => 'huge.jpg', + 'contentType' => 'image/jpeg', + 'content' => base64_encode($oversized), + ])->assertOk(); + + $this->assertTrue($response->json('result.isError')); + $this->assertStringContainsString('exceeds', (string) data_get($response->json(), 'result.content.0.text')); + } + + public function test_upload_media_rejects_undersized_dimensions(): void + { + $tokens = $this->issueMcpOAuthTokens($this->admin); + $payload = $this->makeHeroPayload(800, 400); + + $response = $this->callMcpTool($tokens['access_token'], 'admin-upload-media', [ + 'filename' => 'small.jpg', + 'contentType' => 'image/jpeg', + 'content' => $payload['base64'], + ])->assertOk(); + + $this->assertTrue($response->json('result.isError')); + $this->assertStringContainsString((string) ArticleImageService::OG_WIDTH, (string) data_get($response->json(), 'result.content.0.text')); + } + + public function test_upload_media_can_attach_to_article_by_id(): void + { + $article = Article::factory()->create([ + 'author_id' => $this->admin->id, + 'slug' => 'attach-by-id', + 'published_at' => null, + 'hero_image' => null, + ]); + + $payload = $this->makeHeroPayload(); + $tokens = $this->issueMcpOAuthTokens($this->admin); + + $response = $this->callMcpTool($tokens['access_token'], 'admin-upload-media', [ + 'filename' => 'hero.jpg', + 'contentType' => 'image/jpeg', + 'content' => $payload['base64'], + 'article_id' => $article->id, + ])->assertOk(); + + $this->assertFalse($response->json('result.isError')); + $result = json_decode((string) data_get($response->json(), 'result.content.0.text'), true); + + $this->assertTrue($result['attached']); + $this->assertSame($article->id, $result['article']['id']); + $this->assertSame($result['path'], $article->fresh()->hero_image); + + Storage::disk('public')->assertExists($result['path']); + Storage::disk('public')->assertExists('og-images/'.$article->slug.'.png'); + Storage::disk('public')->assertExists('blog/cards/'.$article->slug.'.jpg'); + Storage::disk('public')->assertExists('blog/headers/'.$article->slug.'.jpg'); + } + + public function test_upload_media_can_attach_to_article_by_slug(): void + { + $article = Article::factory()->create([ + 'author_id' => $this->admin->id, + 'slug' => 'attach-by-slug', + 'published_at' => null, + ]); + + $payload = $this->makeHeroPayload(format: 'png'); + $tokens = $this->issueMcpOAuthTokens($this->admin); + + $response = $this->callMcpTool($tokens['access_token'], 'admin-upload-media', [ + 'filename' => 'hero.png', + 'contentType' => 'image/png', + 'content' => $payload['base64'], + 'slug' => 'attach-by-slug', + ])->assertOk(); + + $result = json_decode((string) data_get($response->json(), 'result.content.0.text'), true); + $this->assertTrue($result['attached']); + $this->assertSame($article->id, $result['article']['id']); + $this->assertSame($result['path'], $article->fresh()->hero_image); + } + + public function test_update_blog_post_sets_hero_from_media_id(): void + { + $article = Article::factory()->create([ + 'author_id' => $this->admin->id, + 'slug' => 'hero-from-media', + 'published_at' => null, + ]); + + $payload = $this->makeHeroPayload(); + $tokens = $this->issueMcpOAuthTokens($this->admin); + + $upload = $this->callMcpTool($tokens['access_token'], 'admin-upload-media', [ + 'filename' => 'hero.jpg', + 'contentType' => 'image/jpeg', + 'content' => $payload['base64'], + ])->assertOk(); + $uploaded = json_decode((string) data_get($upload->json(), 'result.content.0.text'), true); + + $response = $this->callMcpTool($tokens['access_token'], 'admin-update-blog-post', [ + 'id' => $article->id, + 'media_id' => $uploaded['media_id'], + ])->assertOk(); + + $this->assertFalse($response->json('result.isError')); + $result = json_decode((string) data_get($response->json(), 'result.content.0.text'), true); + + $this->assertSame($uploaded['path'], $result['hero_image']); + $this->assertSame($uploaded['path'], $article->fresh()->hero_image); + $this->assertNotNull($result['hero_image_url']); + Storage::disk('public')->assertExists('og-images/'.$article->slug.'.png'); + } + + public function test_update_blog_post_sets_hero_from_url(): void + { + $article = Article::factory()->create([ + 'author_id' => $this->admin->id, + 'slug' => 'hero-from-url', + 'published_at' => null, + ]); + + $payload = $this->makeHeroPayload(); + Http::fake([ + 'https://cdn.example.test/heroes/sample.jpg' => Http::response($payload['binary'], 200, [ + 'Content-Type' => 'image/jpeg', + ]), + ]); + + $tokens = $this->issueMcpOAuthTokens($this->admin); + $response = $this->callMcpTool($tokens['access_token'], 'admin-update-blog-post', [ + 'slug' => 'hero-from-url', + 'hero_image_url' => 'https://cdn.example.test/heroes/sample.jpg', + ])->assertOk(); + + $this->assertFalse($response->json('result.isError')); + $result = json_decode((string) data_get($response->json(), 'result.content.0.text'), true); + + $this->assertNotNull($result['hero_image']); + $this->assertStringStartsWith('blog/heroes/', $result['hero_image']); + $this->assertSame($result['hero_image'], $article->fresh()->hero_image); + Storage::disk('public')->assertExists($result['hero_image']); + } + + public function test_update_blog_post_rejects_media_id_and_url_together(): void + { + $article = Article::factory()->create([ + 'author_id' => $this->admin->id, + 'published_at' => null, + ]); + + $tokens = $this->issueMcpOAuthTokens($this->admin); + $response = $this->callMcpTool($tokens['access_token'], 'admin-update-blog-post', [ + 'id' => $article->id, + 'media_id' => 'blog/heroes/x.webp', + 'hero_image_url' => 'https://cdn.example.test/x.jpg', + ])->assertOk(); + + $this->assertTrue($response->json('result.isError')); + $this->assertStringContainsString('only one', (string) data_get($response->json(), 'result.content.0.text')); + } + + public function test_tool_rejects_when_authenticated_user_is_no_longer_admin(): void + { + $tokens = $this->issueMcpOAuthTokens($this->admin); + + // Token subject is still the admin user, but they are no longer in filament.users. + config(['filament.users' => ['someone-else@nativephp.com']]); + + $payload = $this->makeHeroPayload(); + $response = $this->callMcpTool($tokens['access_token'], 'admin-upload-media', [ + 'filename' => 'hero.jpg', + 'contentType' => 'image/jpeg', + 'content' => $payload['base64'], + ]); + + // Middleware may 403 when admin scope checks fail, or the tool returns an error. + $this->assertTrue( + $response->status() === 403 + || ($response->status() === 200 && $response->json('result.isError') === true) + ); + + if ($response->status() === 200) { + $this->assertStringContainsString('site admins', (string) data_get($response->json(), 'result.content.0.text')); + } + } +} From bf8f1d143f3c1af9784bc6496076ce80f604456a Mon Sep 17 00:00:00 2001 From: Shane Rosenthal Date: Fri, 11 Sep 2026 20:46:43 -0400 Subject: [PATCH 2/2] Make admin-upload-media directory-generic. Default uploads to website-images; callers must pass directory blog/heroes for article hero attach. Sanitize relative paths and reject traversal. --- app/Mcp/Servers/AdminNativePhpServer.php | 4 +- app/Mcp/Tools/Admin/AdminUploadMedia.php | 11 ++- app/Services/AdminArticleMediaService.php | 71 ++++++++++++++--- tests/Feature/Mcp/AdminUploadMediaTest.php | 90 ++++++++++++++++++++-- 4 files changed, 153 insertions(+), 23 deletions(-) diff --git a/app/Mcp/Servers/AdminNativePhpServer.php b/app/Mcp/Servers/AdminNativePhpServer.php index f581aeeb..cab5ae5b 100644 --- a/app/Mcp/Servers/AdminNativePhpServer.php +++ b/app/Mcp/Servers/AdminNativePhpServer.php @@ -31,12 +31,12 @@ class AdminNativePhpServer extends Server Rules: - Never return passwords, remember tokens, GitHub tokens, license keys, Stripe secrets, or raw API credentials. - Blog: create/update unpublished drafts in v1 (no publish tool; slug changes refused once published). - - Hero images: upload via admin-upload-media (same public disk / blog/heroes as Filament), optionally attach in one shot; or set hero on admin-update-blog-post via media_id/path or URL. + - Media: upload via admin-upload-media to the public disk (default directory website-images; pass directory: "blog/heroes" for Filament article heroes). Optionally attach in one shot with article_id/slug only when directory is blog/heroes; or set hero on admin-update-blog-post via media_id/path or URL. - Other tools are read-only ops helpers (signups, users, companies, plugins, sales summaries, support). Tools: 1. admin-create-blog-post — create an unpublished article. - 2. admin-upload-media — upload a blog hero image (base64 → public disk blog/heroes WebP); optional article_id/slug attach. + 2. admin-upload-media — upload an image (base64 → public disk WebP); optional directory (default website-images); for heroes pass directory: "blog/heroes" and optional article_id/slug attach. 3. admin-update-blog-post — patch title/content/excerpt/slug/hero (no publish/unpublish). 4. admin-get-blog-post / admin-list-blog-posts — inspect drafts and published posts. 5. admin-list-signups / admin-search-users / admin-get-user — user support lookups. diff --git a/app/Mcp/Tools/Admin/AdminUploadMedia.php b/app/Mcp/Tools/Admin/AdminUploadMedia.php index 576822a8..96a98b7b 100644 --- a/app/Mcp/Tools/Admin/AdminUploadMedia.php +++ b/app/Mcp/Tools/Admin/AdminUploadMedia.php @@ -16,7 +16,7 @@ use RuntimeException; #[Name('admin-upload-media')] -#[Description('Upload a blog hero/featured image to the same public disk path Filament uses (blog/heroes). Accepts base64 content (no data: prefix). Prefer image/webp; jpeg/png allowed. Optionally attach to an article via article_id and/or slug in one shot. Does not publish.')] +#[Description('Upload an image to the public media disk (WebP re-encode). Optional directory is a relative path under the public disk (no "..", no absolute paths); nested paths like blog/heroes are allowed. Default directory is website-images. Attaching via article_id/slug does NOT force blog/heroes — pass directory: "blog/heroes" when uploading a blog hero/featured image. Prefer image/webp; jpeg/png allowed. Does not publish.')] class AdminUploadMedia extends Tool { use RequiresAdmin; @@ -33,6 +33,7 @@ public function handle(Request $request): Response 'filename' => ['required', 'string', 'max:255'], 'contentType' => ['required', 'string', 'max:100'], 'content' => ['required', 'string'], + 'directory' => ['nullable', 'string', 'max:255'], 'article_id' => ['nullable', 'integer', 'min:1'], 'slug' => ['nullable', 'string', 'max:255'], ]); @@ -42,6 +43,7 @@ public function handle(Request $request): Response $validated['content'], $validated['contentType'], $validated['filename'], + $validated['directory'] ?? null, ); } catch (RuntimeException $e) { return Response::error($e->getMessage()); @@ -57,7 +59,7 @@ public function handle(Request $request): Response 'height' => $stored['height'], 'original_filename' => $stored['original_filename'], 'disk' => AdminArticleMediaService::DISK, - 'directory' => AdminArticleMediaService::HERO_DIRECTORY, + 'directory' => $stored['directory'], 'attached' => false, 'article' => null, ]; @@ -122,8 +124,9 @@ public function schema(JsonSchema $schema): array 'filename' => $schema->string()->description('Original filename (used for logging; stored name is a UUID .webp).')->required(), 'contentType' => $schema->string()->description('MIME type: image/webp (preferred), image/jpeg, or image/png.')->required(), 'content' => $schema->string()->description('Base64-encoded image bytes with no data: URI prefix. Decoded size hard-capped at a few MB; re-encoded to WebP.')->required(), - 'article_id' => $schema->integer()->description('Optional article id to attach this upload as the hero/featured image.'), - 'slug' => $schema->string()->description('Optional article slug to attach as hero when article_id is omitted.'), + 'directory' => $schema->string()->description('Optional relative path under the public media disk (no "..", no absolute paths). Nested paths allowed (e.g. blog/heroes). Defaults to website-images. When attaching as an article hero via article_id/slug, pass directory: "blog/heroes" — attach does not force that folder.'), + 'article_id' => $schema->integer()->description('Optional article id to attach this upload as the hero/featured image. Requires directory "blog/heroes" (or a path already under it).'), + 'slug' => $schema->string()->description('Optional article slug to attach as hero when article_id is omitted. Requires directory "blog/heroes".'), ]; } } diff --git a/app/Services/AdminArticleMediaService.php b/app/Services/AdminArticleMediaService.php index 5a41f5db..1819ece6 100644 --- a/app/Services/AdminArticleMediaService.php +++ b/app/Services/AdminArticleMediaService.php @@ -13,8 +13,12 @@ class AdminArticleMediaService { public const DISK = 'public'; + /** Filament article hero/featured uploads. */ public const HERO_DIRECTORY = 'blog/heroes'; + /** Default directory for generic MCP media uploads when none is provided. */ + public const DEFAULT_DIRECTORY = 'website-images'; + /** Hard cap on decoded upload bytes (a few MB). */ public const MAX_DECODED_BYTES = 5 * 1024 * 1024; @@ -34,7 +38,7 @@ class AdminArticleMediaService public function __construct(protected ArticleImageService $articleImageService) {} /** - * Store a hero image from raw binary onto the same disk/path Filament uses. + * Store an image from raw binary onto the public disk under a sanitized directory. * * @return array{ * path: string, @@ -44,12 +48,14 @@ public function __construct(protected ArticleImageService $articleImageService) * bytes: int, * width: int, * height: int, - * original_filename: string|null + * original_filename: string|null, + * directory: string * } */ - public function storeHeroFromBinary(string $binary, string $contentType, ?string $filename = null): array + public function storeHeroFromBinary(string $binary, string $contentType, ?string $filename = null, ?string $directory = null): array { $contentType = $this->normalizeContentType($contentType); + $directory = $this->sanitizeDirectory($directory); if (! in_array($contentType, self::ALLOWED_CONTENT_TYPES, true)) { throw new RuntimeException('contentType must be image/webp, image/jpeg, or image/png.'); @@ -86,7 +92,7 @@ public function storeHeroFromBinary(string $binary, string $contentType, ?string $height = $image->height(); } - // Prefer WebP for MCP-uploaded heroes (smaller for CDN). + // Prefer WebP for MCP uploads (smaller for CDN). $encoded = $image->toWebp(self::WEBP_QUALITY); $encodedBinary = $encoded->toString(); @@ -95,10 +101,10 @@ public function storeHeroFromBinary(string $binary, string $contentType, ?string } $disk = Storage::disk(self::DISK); - $disk->makeDirectory(self::HERO_DIRECTORY); + $disk->makeDirectory($directory); $basename = Str::uuid()->toString().'.webp'; - $path = self::HERO_DIRECTORY.'/'.$basename; + $path = $directory.'/'.$basename; $disk->put($path, $encodedBinary, 'public'); @@ -111,11 +117,12 @@ public function storeHeroFromBinary(string $binary, string $contentType, ?string 'width' => $width, 'height' => $height, 'original_filename' => $filename, + 'directory' => $directory, ]; } /** - * Decode base64 (no data: prefix) and store as a hero image. + * Decode base64 (no data: prefix) and store under the given public-disk directory. * * @return array{ * path: string, @@ -125,10 +132,11 @@ public function storeHeroFromBinary(string $binary, string $contentType, ?string * bytes: int, * width: int, * height: int, - * original_filename: string|null + * original_filename: string|null, + * directory: string * } */ - public function storeHeroFromBase64(string $base64, string $contentType, ?string $filename = null): array + public function storeHeroFromBase64(string $base64, string $contentType, ?string $filename = null, ?string $directory = null): array { $base64 = trim($base64); @@ -136,13 +144,19 @@ public function storeHeroFromBase64(string $base64, string $contentType, ?string throw new RuntimeException('content must be raw base64 without a data: URI prefix.'); } + // Reject before decode so oversized payloads cannot exhaust memory. + // Base64 expands 3 bytes → 4 chars; floor(len*3/4) is a safe decoded upper bound. + if ((int) floor(strlen($base64) * 3 / 4) > self::MAX_DECODED_BYTES) { + throw new RuntimeException('Image exceeds the '.self::MAX_DECODED_BYTES.' byte decoded size limit.'); + } + $binary = base64_decode($base64, true); if ($binary === false) { throw new RuntimeException('content is not valid base64.'); } - return $this->storeHeroFromBinary($binary, $contentType, $filename); + return $this->storeHeroFromBinary($binary, $contentType, $filename, $directory); } /** @@ -195,7 +209,7 @@ public function storeHeroFromUrl(string $url): array $filename = basename(parse_url($url, PHP_URL_PATH) ?: 'hero'); - return $this->storeHeroFromBinary($binary, $contentType, $filename); + return $this->storeHeroFromBinary($binary, $contentType, $filename, self::HERO_DIRECTORY); } /** @@ -298,6 +312,41 @@ protected function describeExisting(string $path): array ]; } + /** + * Sanitize a relative public-disk directory path. + * + * Rejects absolute paths and ".." segments. Nested paths like blog/heroes are allowed. + * When null/blank, returns DEFAULT_DIRECTORY (website-images). + */ + public function sanitizeDirectory(?string $directory): string + { + if ($directory === null || trim($directory) === '') { + return self::DEFAULT_DIRECTORY; + } + + $directory = str_replace('\\', '/', trim($directory)); + + if (str_starts_with($directory, '/') || preg_match('#^[A-Za-z]:/#', $directory)) { + throw new RuntimeException('directory must be a relative path under the public media disk (no absolute paths).'); + } + + if (str_contains($directory, '..')) { + throw new RuntimeException('directory must not contain "..".'); + } + + $directory = trim(preg_replace('#/+#', '/', $directory) ?? $directory, '/'); + + if ($directory === '') { + return self::DEFAULT_DIRECTORY; + } + + if (! preg_match('#^[A-Za-z0-9][A-Za-z0-9/_-]*$#', $directory)) { + throw new RuntimeException('directory may only contain letters, numbers, hyphens, underscores, and slashes.'); + } + + return $directory; + } + protected function normalizeContentType(string $contentType): string { $contentType = strtolower(trim(strtok($contentType, ';') ?: $contentType)); diff --git a/tests/Feature/Mcp/AdminUploadMediaTest.php b/tests/Feature/Mcp/AdminUploadMediaTest.php index 157812eb..1aeb8c77 100644 --- a/tests/Feature/Mcp/AdminUploadMediaTest.php +++ b/tests/Feature/Mcp/AdminUploadMediaTest.php @@ -51,7 +51,7 @@ protected function makeHeroPayload(int $width = 1600, int $height = 840, string ]; } - public function test_upload_media_stores_webp_on_public_blog_heroes_disk(): void + public function test_upload_media_defaults_to_website_images_directory(): void { $payload = $this->makeHeroPayload(); $tokens = $this->issueMcpOAuthTokens($this->admin); @@ -67,8 +67,8 @@ public function test_upload_media_stores_webp_on_public_blog_heroes_disk(): void $this->assertSame('image/webp', $result['content_type']); $this->assertSame('public', $result['disk']); - $this->assertSame('blog/heroes', $result['directory']); - $this->assertStringStartsWith('blog/heroes/', $result['path']); + $this->assertSame('website-images', $result['directory']); + $this->assertStringStartsWith('website-images/', $result['path']); $this->assertSame($result['path'], $result['media_id']); $this->assertFalse($result['attached']); $this->assertNull($result['article']); @@ -80,6 +80,48 @@ public function test_upload_media_stores_webp_on_public_blog_heroes_disk(): void $this->assertStringEndsWith('.webp', $result['path']); } + public function test_upload_media_respects_explicit_directory(): void + { + $payload = $this->makeHeroPayload(); + $tokens = $this->issueMcpOAuthTokens($this->admin); + + $response = $this->callMcpTool($tokens['access_token'], 'admin-upload-media', [ + 'filename' => 'hero-source.jpg', + 'contentType' => 'image/jpeg', + 'content' => $payload['base64'], + 'directory' => 'blog/heroes', + ])->assertOk(); + + $this->assertFalse($response->json('result.isError')); + $result = json_decode((string) data_get($response->json(), 'result.content.0.text'), true); + + $this->assertSame('blog/heroes', $result['directory']); + $this->assertStringStartsWith('blog/heroes/', $result['path']); + Storage::disk('public')->assertExists($result['path']); + } + + public function test_upload_media_rejects_unsafe_directories(): void + { + $payload = $this->makeHeroPayload(); + $tokens = $this->issueMcpOAuthTokens($this->admin); + + foreach (['../etc', '/absolute', 'blog/../../secrets'] as $directory) { + $response = $this->callMcpTool($tokens['access_token'], 'admin-upload-media', [ + 'filename' => 'hero.jpg', + 'contentType' => 'image/jpeg', + 'content' => $payload['base64'], + 'directory' => $directory, + ])->assertOk(); + + $this->assertTrue($response->json('result.isError'), 'Expected rejection for directory '.$directory); + $message = (string) data_get($response->json(), 'result.content.0.text'); + $this->assertTrue( + str_contains($message, '..') || str_contains($message, 'absolute') || str_contains($message, 'directory'), + 'Unexpected message for '.$directory.': '.$message + ); + } + } + public function test_upload_media_rejects_data_uri_prefix_and_invalid_base64(): void { $tokens = $this->issueMcpOAuthTokens($this->admin); @@ -106,13 +148,13 @@ public function test_upload_media_rejects_oversized_decoded_payload(): void { $tokens = $this->issueMcpOAuthTokens($this->admin); - // Build a binary larger than the hard cap without needing a huge real image. - $oversized = str_repeat('A', AdminArticleMediaService::MAX_DECODED_BYTES + 1); + // Oversized base64 (valid alphabet) without allocating a huge decoded binary first. + $oversizedBase64 = str_repeat('A', (int) ceil(AdminArticleMediaService::MAX_DECODED_BYTES * 4 / 3) + 16); $response = $this->callMcpTool($tokens['access_token'], 'admin-upload-media', [ 'filename' => 'huge.jpg', 'contentType' => 'image/jpeg', - 'content' => base64_encode($oversized), + 'content' => $oversizedBase64, ])->assertOk(); $this->assertTrue($response->json('result.isError')); @@ -150,6 +192,7 @@ public function test_upload_media_can_attach_to_article_by_id(): void 'filename' => 'hero.jpg', 'contentType' => 'image/jpeg', 'content' => $payload['base64'], + 'directory' => 'blog/heroes', 'article_id' => $article->id, ])->assertOk(); @@ -157,6 +200,7 @@ public function test_upload_media_can_attach_to_article_by_id(): void $result = json_decode((string) data_get($response->json(), 'result.content.0.text'), true); $this->assertTrue($result['attached']); + $this->assertSame('blog/heroes', $result['directory']); $this->assertSame($article->id, $result['article']['id']); $this->assertSame($result['path'], $article->fresh()->hero_image); @@ -166,6 +210,38 @@ public function test_upload_media_can_attach_to_article_by_id(): void Storage::disk('public')->assertExists('blog/headers/'.$article->slug.'.jpg'); } + public function test_upload_media_attach_does_not_force_blog_heroes_directory(): void + { + $article = Article::factory()->create([ + 'author_id' => $this->admin->id, + 'slug' => 'no-force-heroes', + 'published_at' => null, + 'hero_image' => null, + ]); + + $payload = $this->makeHeroPayload(); + $tokens = $this->issueMcpOAuthTokens($this->admin); + + // article_id alone must not silently rewrite directory to blog/heroes. + $response = $this->callMcpTool($tokens['access_token'], 'admin-upload-media', [ + 'filename' => 'hero.jpg', + 'contentType' => 'image/jpeg', + 'content' => $payload['base64'], + 'article_id' => $article->id, + ])->assertOk(); + + $this->assertTrue($response->json('result.isError')); + $message = (string) data_get($response->json(), 'result.content.0.text'); + $this->assertStringContainsString('attach failed', $message); + $this->assertStringContainsString('blog/heroes', $message); + $this->assertNull($article->fresh()->hero_image); + + // Confirm the upload itself landed in the default generic directory. + $this->assertMatchesRegularExpression('#media_id=website-images/[^\s]+#', $message); + preg_match('#media_id=(website-images/\S+)#', $message, $matches); + Storage::disk('public')->assertExists($matches[1]); + } + public function test_upload_media_can_attach_to_article_by_slug(): void { $article = Article::factory()->create([ @@ -181,6 +257,7 @@ public function test_upload_media_can_attach_to_article_by_slug(): void 'filename' => 'hero.png', 'contentType' => 'image/png', 'content' => $payload['base64'], + 'directory' => 'blog/heroes', 'slug' => 'attach-by-slug', ])->assertOk(); @@ -205,6 +282,7 @@ public function test_update_blog_post_sets_hero_from_media_id(): void 'filename' => 'hero.jpg', 'contentType' => 'image/jpeg', 'content' => $payload['base64'], + 'directory' => 'blog/heroes', ])->assertOk(); $uploaded = json_decode((string) data_get($upload->json(), 'result.content.0.text'), true);