diff --git a/app/Http/Controllers/V2/Files/UploadController.php b/app/Http/Controllers/V2/Files/UploadController.php index d764e46d3..7a00279cb 100644 --- a/app/Http/Controllers/V2/Files/UploadController.php +++ b/app/Http/Controllers/V2/Files/UploadController.php @@ -9,6 +9,7 @@ use App\Models\V2\MediaModel; use Exception; use Illuminate\Support\Arr; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Validator; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -47,7 +48,12 @@ public function bulkUrlUpload(BulkUploadRequest $request, string $collection, Me // The downloadable file gets shuttled through the internals of Spatie without a chance for us to run // our own validations on them. png/jpg are the only mimes allowed for the photos collection according // to config/file-handling.php, and we disallow other collections than 'photos' above. - $handler = $mediaModel->addMediaFromUrl($data['download_url'], 'image/png', 'image/jpg'); + $handler = $mediaModel->addMediaFromUrl( + $data['download_url'], + 'image/png', + 'image/jpg', + 'image/jpeg' + ); $this->prepHandler($handler, $data, $mediaModel, $config, $collection); $details = $this->executeHandler($handler, $collection); @@ -127,6 +133,7 @@ private function saveAdditionalFileProperties($media, $data, $config) { $media->file_type = $this->getType($media, $config); $media->is_public = $data['is_public'] ?? true; + $media->created_by = Auth::user()->id; $media->save(); } diff --git a/app/Http/Controllers/V2/MediaController.php b/app/Http/Controllers/V2/MediaController.php index ec96ad489..4631e0024 100644 --- a/app/Http/Controllers/V2/MediaController.php +++ b/app/Http/Controllers/V2/MediaController.php @@ -3,10 +3,13 @@ namespace App\Http\Controllers\V2; use App\Http\Controllers\Controller; +use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Auth; use Spatie\MediaLibrary\MediaCollections\Models\Media; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; class MediaController extends Controller { @@ -32,4 +35,27 @@ public function delete(Request $request, string $uuid, string $collection = ''): return response()->json(['success' => 'media has been deleted'], 202); } + + public function bulkDelete(Request $request): JsonResponse + { + if (! Auth::user()->can('media-manage')) { + throw new AuthorizationException('No permission to bulk delete'); + } + + $uuids = $request->input('uuids'); + if (empty($uuids)) { + throw new NotFoundHttpException(); + } + + $media = Media::whereIn('uuid', $uuids)->where('created_by', Auth::user()->id); + if ($media->count() != count($uuids)) { + // If the bulk delete endpoint is being called for some media that weren't created by this user, + // avoid deleting any of them. + throw new AuthorizationException('Some of the media you are trying to delete were not created by you.'); + } + + $media->delete(); + + return response()->json(['success' => 'media has been deleted'], 202); + } } diff --git a/database/migrations/2024_04_19_190707_add_created_by_to_media_table.php b/database/migrations/2024_04_19_190707_add_created_by_to_media_table.php new file mode 100644 index 000000000..77cbd2698 --- /dev/null +++ b/database/migrations/2024_04_19_190707_add_created_by_to_media_table.php @@ -0,0 +1,28 @@ +foreignIdFor(User::class, 'created_by')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('media', function (Blueprint $table) { + $table->dropColumn(['created_by']); + }); + } +}; diff --git a/openapi-src/V2/paths/_index.yml b/openapi-src/V2/paths/_index.yml index f7b12bcbe..ec43250d3 100644 --- a/openapi-src/V2/paths/_index.yml +++ b/openapi-src/V2/paths/_index.yml @@ -931,6 +931,22 @@ type: array items: $ref: '../definitions/_index.yml#/V2FileRead' +/v2/media: + delete: + summary: Bulk delete a set of media by UUID + operationId: v2-bulk-delete-media + tags: + - Media + parameters: + - type: array + name: uuids[] + in: query + required: true + items: + type: string + responses: + '200': + description: OK /v2/files/{UUID}: put: summary: Update properties of a specific file diff --git a/resources/docs/swagger-v2.yml b/resources/docs/swagger-v2.yml index 18ba8da3e..74a86ac98 100644 --- a/resources/docs/swagger-v2.yml +++ b/resources/docs/swagger-v2.yml @@ -69769,6 +69769,22 @@ paths: type: boolean created_at: type: string + /v2/media: + delete: + summary: Bulk delete a set of media by UUID + operationId: v2-bulk-delete-media + tags: + - Media + parameters: + - type: array + name: 'uuids[]' + in: query + required: true + items: + type: string + responses: + '200': + description: OK '/v2/files/{UUID}': put: summary: Update properties of a specific file diff --git a/routes/api_v2.php b/routes/api_v2.php index 452e0d758..426143a3e 100644 --- a/routes/api_v2.php +++ b/routes/api_v2.php @@ -232,6 +232,7 @@ }); Route::prefix('media')->group(function () { + Route::delete('', [MediaController::class, 'bulkDelete']); Route::delete('/{uuid}', [MediaController::class, 'delete']); Route::delete('/{uuid}/{collection}', [MediaController::class, 'delete']); }); diff --git a/tests/V2/Files/UploadControllerTest.php b/tests/V2/Files/UploadControllerTest.php index e11fe2c8c..c98fffb77 100644 --- a/tests/V2/Files/UploadControllerTest.php +++ b/tests/V2/Files/UploadControllerTest.php @@ -546,10 +546,8 @@ public function test_bulk_upload_functionality() $service = User::factory()->serviceAccount()->create(); Artisan::call('v2migration:roles'); $site = Site::factory()->create(); - // It's not ideal for the testing suite to use a real hosted image, but I haven't found a way to fake a - // http download URL in phpunit/spatie. - $url = 'https://new-wri-prod.wri-restoration-marketplace-api.com/images/V2/land-tenures/national-protected-area.png'; - $badMimeUrl = 'https://www.terramatch.org/images/landing-page-hero-banner.webp'; + $url = 'http://localhost/images/V2/land-tenures/national-protected-area.png'; + $badMimeUrl = 'http://localhost/images/email_logo.gif'; // Check a valid upload $this->actingAs($service) diff --git a/tests/V2/MediaControllerTest.php b/tests/V2/MediaControllerTest.php new file mode 100644 index 000000000..e558db516 --- /dev/null +++ b/tests/V2/MediaControllerTest.php @@ -0,0 +1,60 @@ +serviceAccount()->create(); + $admin = User::factory()->admin()->create(); + Artisan::call('v2migration:roles'); + + $site = Site::factory()->ppc()->create(); + $photo1 = $site->addMedia(UploadedFile::fake()->image('photo1'))->toMediaCollection('photos'); + $photo1->update(['created_by' => $service->id]); + $photo2 = $site->addMedia(UploadedFile::fake()->image('photo2'))->toMediaCollection('photos'); + $photo2->update(['created_by' => $admin->id]); + $photo3 = $site->addMedia(UploadedFile::fake()->image('photo3'))->toMediaCollection('photos'); + $photo3->update(['created_by' => $service->id]); + + // No UUIDS is a 404 + $this->actingAs($service) + ->delete('/api/v2/media') + ->assertNotFound(); + + // Can't delete photo created by admin + $this->actingAs($service) + ->delete($this->buildBulkDeleteUrl([$photo1->uuid, $photo2->uuid])) + ->assertForbidden(); + $this->assertEquals(3, $site->refresh()->getMedia('photos')->count()); + + // Only service accounts can use bulk delete + $this->actingAs($admin) + ->delete($this->buildBulkDeleteUrl([$photo2->uuid])) + ->assertForbidden(); + $this->assertEquals(3, $site->refresh()->getMedia('photos')->count()); + + // Success case + $this->actingAs($service) + ->delete($this->buildBulkDeleteUrl([$photo1->uuid, $photo3->uuid])) + ->assertSuccessful(); + $this->assertEquals(1, $site->refresh()->getMedia('photos')->count()); + } + + private function buildBulkDeleteUrl($uuids): string + { + return '/api/v2/media?uuids[]=' . implode('&uuids[]=', $uuids); + } +}