diff --git a/app/Http/Controllers/Api/PostController.php b/app/Http/Controllers/Api/PostController.php index 30be5e48..525dbd7c 100644 --- a/app/Http/Controllers/Api/PostController.php +++ b/app/Http/Controllers/Api/PostController.php @@ -18,6 +18,7 @@ use App\Http\Resources\Api\PostPreviewResource; use App\Http\Resources\Api\PostResource; use App\Models\Post; +use App\Models\Workspace; use App\Services\Post\MediaAttacher; use App\Support\PostStatusRules; use Illuminate\Http\JsonResponse; @@ -49,11 +50,18 @@ public function show(Request $request, Post $post): PostResource public function store(StorePostRequest $request): JsonResponse { - $post = CreatePost::execute( - $request->user()->currentWorkspace, - $request->user()->currentWorkspace->owner, - $request->validated() - ); + $workspace = $request->user()->currentWorkspace; + $data = $request->validated(); + + if (array_key_exists('media', $data)) { + $data['media'] = $this->hostInlineMedia( + $workspace, + Post::allowedMediaTypesFor($request->selectedPlatforms()), + $data['media'], + ); + } + + $post = CreatePost::execute($workspace, $workspace->owner, $data); $post->load(['postPlatforms.socialAccount']); @@ -66,7 +74,17 @@ public function update(UpdatePostRequest $request, Post $post): PostResource|Jso { $this->authorize('update', $post); - $result = UpdatePost::execute($request->user()->currentWorkspace, $post, $request->validated()); + $data = $request->validated(); + + if (array_key_exists('media', $data)) { + $data['media'] = $this->hostInlineMedia( + $request->user()->currentWorkspace, + $post->allowedMediaTypes(), + $data['media'], + ); + } + + $result = UpdatePost::execute($request->user()->currentWorkspace, $post, $data); if (data_get($result, 'action') === PostAction::Finalized) { return response()->json( @@ -81,6 +99,36 @@ public function update(UpdatePostRequest $request, Post $post): PostResource|Jso return new PostResource($updated); } + /** + * Download and host any external media URLs in an inline media array so the + * stored post never references a third-party URL (which can 404 or change at + * publish time). Items already on our storage pass through. Rejects the + * request when any URL can't be fetched, so a post is never persisted with + * broken media. + * + * @param array $allowedTypes + * @param array> $media + * @return array> + * + * @throws ValidationException + */ + private function hostInlineMedia(Workspace $workspace, array $allowedTypes, array $media): array + { + if ($media === []) { + return $media; + } + + $result = app(MediaAttacher::class)->resolveInlineMedia($workspace, $allowedTypes, $media); + + if ($result['failed'] !== []) { + throw ValidationException::withMessages([ + 'media' => ['Could not fetch media from URL: '.implode(', ', $result['failed'])], + ]); + } + + return $result['media']; + } + public function destroy(Request $request, Post $post): JsonResponse { $this->authorize('delete', $post); diff --git a/app/Http/Requests/Api/Post/StorePostRequest.php b/app/Http/Requests/Api/Post/StorePostRequest.php index cbdb4c0a..52972eae 100644 --- a/app/Http/Requests/Api/Post/StorePostRequest.php +++ b/app/Http/Requests/Api/Post/StorePostRequest.php @@ -36,6 +36,14 @@ public function rules(): array ), ], 'media' => ['sometimes', 'array'], + 'media.*.id' => ['sometimes', 'nullable', 'string'], + 'media.*.path' => ['sometimes', 'nullable', 'string', 'max:500'], + 'media.*.url' => ['required', 'string', 'max:2048', 'url:http,https'], + 'media.*.type' => ['sometimes', 'nullable', 'string', 'max:32'], + 'media.*.mime_type' => ['sometimes', 'nullable', 'string', 'max:255'], + 'media.*.original_filename' => ['sometimes', 'nullable', 'string', 'max:500'], + 'media.*.size' => ['sometimes', 'nullable', 'integer'], + 'media.*.meta' => ['sometimes', 'nullable', 'array'], 'platforms' => ['required', 'array', 'min:1'], 'platforms.*.social_account_id' => [ 'required', @@ -60,6 +68,17 @@ public function rules(): array ]; } + /** + * The distinct platforms selected in this request, used to compute the + * media types acceptable for the post being created. + * + * @return Collection + */ + public function selectedPlatforms(): Collection + { + return $this->resolveSelectedPlatforms($this->user()->currentWorkspace->id)->values(); + } + /** * @return Collection */ diff --git a/app/Http/Requests/Api/Post/UpdatePostRequest.php b/app/Http/Requests/Api/Post/UpdatePostRequest.php index 7f226fb8..a6bf68ea 100644 --- a/app/Http/Requests/Api/Post/UpdatePostRequest.php +++ b/app/Http/Requests/Api/Post/UpdatePostRequest.php @@ -45,6 +45,14 @@ public function rules(): array ), ], 'media' => ['sometimes', 'array'], + 'media.*.id' => ['sometimes', 'nullable', 'string'], + 'media.*.path' => ['sometimes', 'nullable', 'string', 'max:500'], + 'media.*.url' => ['required', 'string', 'max:2048', 'url:http,https'], + 'media.*.type' => ['sometimes', 'nullable', 'string', 'max:32'], + 'media.*.mime_type' => ['sometimes', 'nullable', 'string', 'max:255'], + 'media.*.original_filename' => ['sometimes', 'nullable', 'string', 'max:500'], + 'media.*.size' => ['sometimes', 'nullable', 'integer'], + 'media.*.meta' => ['sometimes', 'nullable', 'array'], 'platforms' => ['sometimes', 'array'], 'platforms.*.id' => ['required', 'uuid', Rule::exists('post_platforms', 'id')->where('post_id', $this->route('post') instanceof Post ? $this->route('post')->id : $this->route('post'))], 'platforms.*.content_type' => [ diff --git a/app/Models/Post.php b/app/Models/Post.php index eb7cffa6..64150180 100644 --- a/app/Models/Post.php +++ b/app/Models/Post.php @@ -7,6 +7,7 @@ use App\DataTransferObjects\MediaItem; use App\Enums\Media\Type; use App\Enums\Post\Status as PostStatus; +use App\Enums\SocialAccount\Platform; use App\Observers\PostObserver; use Database\Factories\PostFactory; use Illuminate\Database\Eloquent\Attributes\ObservedBy; @@ -150,12 +151,26 @@ public function allowedMediaTypes(): array ->pluck('socialAccount.platform') ->filter(); + return self::allowedMediaTypesFor($platforms); + } + + /** + * The media types acceptable across a set of platforms: the intersection of + * what each platform allows. With no platform, accept anything. Lets callers + * that don't have a persisted Post yet (e.g. the API create flow) compute the + * same constraint from the platforms in the request. + * + * @param Collection $platforms + * @return array + */ + public static function allowedMediaTypesFor(Collection $platforms): array + { if ($platforms->isEmpty()) { return Type::cases(); } $sets = $platforms - ->map(fn ($platform) => array_map(fn ($type) => $type->value, $platform->allowedMediaTypes())) + ->map(fn (Platform $platform) => array_map(fn ($type) => $type->value, $platform->allowedMediaTypes())) ->all(); return array_map( diff --git a/app/Services/Post/MediaAttacher.php b/app/Services/Post/MediaAttacher.php index 924917ad..b4b7019b 100644 --- a/app/Services/Post/MediaAttacher.php +++ b/app/Services/Post/MediaAttacher.php @@ -6,6 +6,7 @@ use App\Enums\Media\Type as MediaType; use App\Models\Post; +use App\Models\Workspace; use Illuminate\Support\Facades\Http; use RuntimeException; @@ -31,7 +32,7 @@ public function attachFromUrls(Post $post, array $urls): array $failed = []; foreach ($urls as $url) { - ($item = $this->attachOne($post, $url)) === null + ($item = $this->fetchToWorkspace($post->workspace, $post->allowedMediaTypes(), $url)) === null ? $failed[] = $url : $attached[] = $item; } @@ -44,9 +45,47 @@ public function attachFromUrls(Post $post, array $urls): array } /** + * Resolve an inline media array (as accepted by the public API on post + * create/update) into fully hosted media items. Items already stored on our + * disk (they carry a `path`) pass through untouched; every other item is + * treated as an external `url` to download and host, so publishing never + * depends on a third-party URL staying alive. + * + * @param array $allowedTypes + * @param array> $items + * @return array{media: array>, failed: array} + */ + public function resolveInlineMedia(Workspace $workspace, array $allowedTypes, array $items): array + { + $media = []; + $failed = []; + + foreach ($items as $item) { + if (filled(data_get($item, 'path'))) { + $media[] = $item; + + continue; + } + + $url = (string) data_get($item, 'url', ''); + + ($hosted = $this->fetchToWorkspace($workspace, $allowedTypes, $url)) === null + ? $failed[] = $url + : $media[] = $hosted; + } + + return ['media' => $media, 'failed' => $failed]; + } + + /** + * Download a public URL, validate it against the accepted media types, and + * store it on the workspace. Returns the media item, or null on any failure + * (download error, disallowed type, oversized). + * + * @param array $allowedTypes * @return array|null */ - private function attachOne(Post $post, string $url): ?array + public function fetchToWorkspace(Workspace $workspace, array $allowedTypes, string $url): ?array { $download = $this->download($url); @@ -57,7 +96,7 @@ private function attachOne(Post $post, string $url): ?array try { $type = MediaType::fromMime($download['mime'] ?? ''); - if ($type === null || ! in_array($type, $post->allowedMediaTypes(), true)) { + if ($type === null || ! in_array($type, $allowedTypes, true)) { return null; } @@ -66,7 +105,7 @@ private function attachOne(Post $post, string $url): ?array } $name = basename(parse_url($url, PHP_URL_PATH) ?? '') ?: 'download.bin'; - $media = $post->workspace->addMediaFromPath($download['path'], $name, 'assets'); + $media = $workspace->addMediaFromPath($download['path'], $name, 'assets'); return [ 'id' => $media->id, diff --git a/tests/Feature/Api/PostMediaApiTest.php b/tests/Feature/Api/PostMediaApiTest.php index 4a58edd6..b9179946 100644 --- a/tests/Feature/Api/PostMediaApiTest.php +++ b/tests/Feature/Api/PostMediaApiTest.php @@ -219,3 +219,114 @@ ->assertUnprocessable() ->assertJsonValidationErrors(['media']); }); + +it('downloads and hosts an external media url when creating a post', function () { + $this->socialAccount->update(['is_active' => true]); + + Http::fake([ + 'cdn.example.com/listing.jpg' => Http::response( + file_get_contents(__DIR__.'/../../fixtures/1x1.png'), + 200, + ['Content-Type' => 'image/png'], + ), + ]); + + $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken]) + ->postJson(route('api.posts.store'), [ + 'content' => 'External media post', + 'media' => [['url' => 'https://cdn.example.com/listing.jpg']], + 'platforms' => [ + ['social_account_id' => $this->socialAccount->id, 'content_type' => 'linkedin_post'], + ], + ]) + ->assertCreated(); + + $media = Post::where('content', 'External media post')->firstOrFail()->media; + + expect($media)->toHaveCount(1) + ->and(data_get($media, '0.path'))->not->toBeNull() + ->and(data_get($media, '0.url'))->not->toContain('cdn.example.com'); + expect(Media::where('mediable_id', $this->workspace->id)->count())->toBe(1); +}); + +it('rejects creating a post when an external media url cannot be fetched', function () { + $this->socialAccount->update(['is_active' => true]); + + Http::fake(['cdn.example.com/missing.jpg' => Http::response(null, 404)]); + + $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken]) + ->postJson(route('api.posts.store'), [ + 'content' => 'Broken media post', + 'media' => [['url' => 'https://cdn.example.com/missing.jpg']], + 'platforms' => [ + ['social_account_id' => $this->socialAccount->id, 'content_type' => 'linkedin_post'], + ], + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['media']); + + expect(Post::where('content', 'Broken media post')->exists())->toBeFalse(); + expect(Media::where('mediable_id', $this->workspace->id)->count())->toBe(0); +}); + +it('passes already-hosted media through on create without downloading', function () { + $this->socialAccount->update(['is_active' => true]); + Http::preventStrayRequests(); + + $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken]) + ->postJson(route('api.posts.store'), [ + 'content' => 'Hosted media post', + 'media' => [[ + 'id' => 'media-1', + 'path' => 'assets/foo.jpg', + 'url' => 'https://cdn.trypost.test/assets/foo.jpg', + 'type' => 'image', + ]], + 'platforms' => [ + ['social_account_id' => $this->socialAccount->id, 'content_type' => 'linkedin_post'], + ], + ]) + ->assertCreated(); + + expect(data_get(Post::where('content', 'Hosted media post')->firstOrFail()->media, '0.path'))->toBe('assets/foo.jpg'); + expect(Media::where('mediable_id', $this->workspace->id)->count())->toBe(0); +}); + +it('downloads and hosts an external media url when updating a post', function () { + Http::fake([ + 'cdn.example.com/listing.jpg' => Http::response( + file_get_contents(__DIR__.'/../../fixtures/1x1.png'), + 200, + ['Content-Type' => 'image/png'], + ), + ]); + + $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken]) + ->putJson(route('api.posts.update', $this->post), [ + 'status' => 'draft', + 'media' => [['url' => 'https://cdn.example.com/listing.jpg']], + ]) + ->assertOk(); + + $media = $this->post->fresh()->media; + + expect($media)->toHaveCount(1) + ->and(data_get($media, '0.path'))->not->toBeNull() + ->and(data_get($media, '0.url'))->not->toContain('cdn.example.com'); +}); + +it('rejects updating a post when an external media url cannot be fetched', function () { + Http::fake(['cdn.example.com/missing.jpg' => Http::response(null, 404)]); + + $original = $this->post->fresh()->media; + + $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken]) + ->putJson(route('api.posts.update', $this->post), [ + 'status' => 'draft', + 'media' => [['url' => 'https://cdn.example.com/missing.jpg']], + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['media']); + + expect($this->post->fresh()->media)->toBe($original); +});