diff --git a/app/Http/Controllers/Api/PostController.php b/app/Http/Controllers/Api/PostController.php index 59f7e9fc..17cfa8de 100644 --- a/app/Http/Controllers/Api/PostController.php +++ b/app/Http/Controllers/Api/PostController.php @@ -7,8 +7,10 @@ use App\Actions\Post\CreatePost; use App\Actions\Post\DeletePost; use App\Actions\Post\UpdatePost; +use App\Enums\Media\Type as MediaType; use App\Enums\Post\Action as PostAction; -use App\Http\Requests\Api\Post\AttachMediaRequest; +use App\Http\Requests\Api\Post\AttachMediaFromUrlRequest; +use App\Http\Requests\Api\Post\StoreMediaRequest; use App\Http\Requests\Api\Post\StorePostRequest; use App\Http\Requests\Api\Post\UpdatePostRequest; use App\Http\Resources\Api\PostMediaAttachResource; @@ -20,6 +22,7 @@ use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\AnonymousResourceCollection; +use Illuminate\Validation\ValidationException; use Symfony\Component\HttpFoundation\Response; class PostController extends Controller @@ -83,7 +86,42 @@ public function destroy(Request $request, Post $post): JsonResponse return response()->json(null, Response::HTTP_NO_CONTENT); } - public function attachMedia(AttachMediaRequest $request, Post $post): PostMediaAttachResource + public function storeMedia(StoreMediaRequest $request, Post $post): PostResource + { + $this->authorize('update', $post); + + $file = $request->file('media'); + $type = MediaType::fromMime((string) $file->getMimeType()); + + if ($type === null || ! in_array($type, $post->allowedMediaTypes(), true)) { + throw ValidationException::withMessages([ + 'media' => 'This file type is not supported by the platforms enabled on the post.', + ]); + } + + if ($file->getSize() > $type->maxSizeInBytes()) { + throw ValidationException::withMessages([ + 'media' => 'File size exceeds the maximum allowed for this media type.', + ]); + } + + $media = $post->workspace->addMedia($file, 'assets'); + + $post->appendMedia([[ + 'id' => $media->id, + 'path' => $media->path, + 'url' => $media->url, + 'type' => $media->type, + 'mime_type' => $media->mime_type, + 'original_filename' => $media->original_filename, + ]]); + + $post->refresh()->load(['postPlatforms.socialAccount', 'labels']); + + return new PostResource($post); + } + + public function attachMediaFromUrl(AttachMediaFromUrlRequest $request, Post $post): PostMediaAttachResource { $this->authorize('update', $post); diff --git a/app/Http/Requests/Api/Post/AttachMediaRequest.php b/app/Http/Requests/Api/Post/AttachMediaFromUrlRequest.php similarity index 89% rename from app/Http/Requests/Api/Post/AttachMediaRequest.php rename to app/Http/Requests/Api/Post/AttachMediaFromUrlRequest.php index 4e2071c5..629196de 100644 --- a/app/Http/Requests/Api/Post/AttachMediaRequest.php +++ b/app/Http/Requests/Api/Post/AttachMediaFromUrlRequest.php @@ -6,7 +6,7 @@ use Illuminate\Foundation\Http\FormRequest; -class AttachMediaRequest extends FormRequest +class AttachMediaFromUrlRequest extends FormRequest { public function authorize(): bool { diff --git a/app/Http/Requests/Api/Post/StoreMediaRequest.php b/app/Http/Requests/Api/Post/StoreMediaRequest.php new file mode 100644 index 00000000..d31a265b --- /dev/null +++ b/app/Http/Requests/Api/Post/StoreMediaRequest.php @@ -0,0 +1,38 @@ +> + */ + public function rules(): array + { + $allowedMimes = [ + ...MediaType::Image->allowedMimeTypes(), + ...MediaType::Video->allowedMimeTypes(), + ]; + + return [ + // Use the largest per-type cap as the upper bound; per-type + // and per-post enforcement happens in the controller. + 'media' => [ + 'required', + 'file', + 'max:'.MediaType::Video->maxSizeInKb(), + 'mimetypes:'.implode(',', $allowedMimes), + ], + ]; + } +} diff --git a/app/Http/Resources/Api/PostMediaAttachResource.php b/app/Http/Resources/Api/PostMediaAttachResource.php index 8c20d9d3..427d11c4 100644 --- a/app/Http/Resources/Api/PostMediaAttachResource.php +++ b/app/Http/Resources/Api/PostMediaAttachResource.php @@ -9,7 +9,7 @@ use Illuminate\Http\Resources\Json\JsonResource; /** - * Wraps a Post with the result of an attach-media operation + * Wraps a Post with the result of an attach-media-from-url operation * (counts + list of failed URLs). */ class PostMediaAttachResource extends JsonResource diff --git a/app/Services/Post/MediaAttacher.php b/app/Services/Post/MediaAttacher.php index 6398a3ca..924917ad 100644 --- a/app/Services/Post/MediaAttacher.php +++ b/app/Services/Post/MediaAttacher.php @@ -11,7 +11,8 @@ /** * Downloads public URLs and attaches them as media to a post — used by - * the MCP `AttachMediaFromUrlTool` and the REST attach-media endpoint. + * the MCP `AttachMediaFromUrlTool` and the REST `attach-media-from-url` + * endpoint. * * URL syntax + DNS resolvability are validated at the request layer * (`url:http,https`, `active_url`). Locking, intersection of accepted diff --git a/routes/api.php b/routes/api.php index e4cad2e6..3484ae9b 100644 --- a/routes/api.php +++ b/routes/api.php @@ -18,7 +18,8 @@ Route::get('/posts/{post}', [PostController::class, 'show'])->name('api.posts.show'); Route::put('/posts/{post}', [PostController::class, 'update'])->name('api.posts.update'); Route::delete('/posts/{post}', [PostController::class, 'destroy'])->name('api.posts.destroy'); - Route::post('/posts/{post}/media', [PostController::class, 'attachMedia'])->name('api.posts.attach-media'); + Route::post('/posts/{post}/media', [PostController::class, 'storeMedia'])->name('api.posts.store-media'); + Route::post('/posts/{post}/media/from-url', [PostController::class, 'attachMediaFromUrl'])->name('api.posts.attach-media-from-url'); Route::get('/posts/{post}/metrics', [PostController::class, 'metrics'])->name('api.posts.metrics'); Route::get('/posts/{post}/preview', [PostController::class, 'preview'])->name('api.posts.preview'); diff --git a/tests/Feature/Api/PostMediaApiTest.php b/tests/Feature/Api/PostMediaApiTest.php index df19d322..4a58edd6 100644 --- a/tests/Feature/Api/PostMediaApiTest.php +++ b/tests/Feature/Api/PostMediaApiTest.php @@ -8,6 +8,7 @@ use App\Models\PostPlatform; use App\Models\SocialAccount; use App\Models\Workspace; +use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Storage; @@ -46,7 +47,7 @@ ]); $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken]) - ->postJson(route('api.posts.attach-media', $this->post), [ + ->postJson(route('api.posts.attach-media-from-url', $this->post), [ 'urls' => ['https://example.com/photo.png'], ]) ->assertOk() @@ -63,7 +64,7 @@ ]); $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken]) - ->postJson(route('api.posts.attach-media', $this->post), [ + ->postJson(route('api.posts.attach-media-from-url', $this->post), [ 'urls' => ['https://example.com/missing.png'], ]) ->assertOk() @@ -76,7 +77,7 @@ $post = Post::factory()->create(['workspace_id' => $other->id, 'user_id' => $this->user->id]); $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken]) - ->postJson(route('api.posts.attach-media', $post), [ + ->postJson(route('api.posts.attach-media-from-url', $post), [ 'urls' => ['https://example.com/photo.png'], ]) ->assertNotFound(); @@ -143,7 +144,78 @@ it('rejects attach media payload without urls', function () { $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken]) - ->postJson(route('api.posts.attach-media', $this->post), []) + ->postJson(route('api.posts.attach-media-from-url', $this->post), []) ->assertUnprocessable() ->assertJsonValidationErrors(['urls']); }); + +it('uploads a media file and attaches it to the post', function () { + $file = UploadedFile::fake()->createWithContent( + 'photo.png', + file_get_contents(__DIR__.'/../../fixtures/1x1.png'), + ); + + $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken, 'Accept' => 'application/json']) + ->post(route('api.posts.store-media', $this->post), ['media' => $file]) + ->assertOk(); + + expect(Media::where('mediable_id', $this->workspace->id)->count())->toBe(1); + expect($this->post->fresh()->media)->toHaveCount(1); +}); + +it('rejects upload of an unsupported mime type', function () { + $file = UploadedFile::fake()->createWithContent('doc.txt', 'plain text content'); + + $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken, 'Accept' => 'application/json']) + ->post(route('api.posts.store-media', $this->post), ['media' => $file]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['media']); +}); + +it('rejects upload when the file type is not supported by enabled platforms', function () { + $tiktokAccount = SocialAccount::factory()->tiktok()->create([ + 'workspace_id' => $this->workspace->id, + ]); + + $tiktokOnlyPost = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + ]); + + PostPlatform::factory()->tiktok()->create([ + 'post_id' => $tiktokOnlyPost->id, + 'social_account_id' => $tiktokAccount->id, + 'enabled' => true, + ]); + + $file = UploadedFile::fake()->createWithContent( + 'photo.png', + file_get_contents(__DIR__.'/../../fixtures/1x1.png'), + ); + + $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken, 'Accept' => 'application/json']) + ->post(route('api.posts.store-media', $tiktokOnlyPost), ['media' => $file]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['media']); +}); + +it('cannot upload media to a post from another workspace', function () { + $other = Workspace::factory()->create(); + $post = Post::factory()->create(['workspace_id' => $other->id, 'user_id' => $this->user->id]); + + $file = UploadedFile::fake()->createWithContent( + 'photo.png', + file_get_contents(__DIR__.'/../../fixtures/1x1.png'), + ); + + $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken, 'Accept' => 'application/json']) + ->post(route('api.posts.store-media', $post), ['media' => $file]) + ->assertNotFound(); +}); + +it('rejects upload without a media file', function () { + $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken]) + ->postJson(route('api.posts.store-media', $this->post), []) + ->assertUnprocessable() + ->assertJsonValidationErrors(['media']); +});