From 11a99d3f45661687fac20e2128395a3d5bc7e441 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Viguier?= Date: Wed, 29 Jan 2025 18:55:57 +0100 Subject: [PATCH] Sharing propagation backend. (#2950) --- app/Actions/Sharing/Propagate.php | 148 ++++++++++++++++++ .../Controllers/Gallery/SharingController.php | 25 +++ .../Sharing/PropagateSharingRequest.php | 58 +++++++ routes/api_v2.php | 1 + tests/Feature_v2/Album/SharingTest.php | 125 +++++++++++++++ tests/Feature_v2/Base/BaseApiV2Test.php | 15 ++ 6 files changed, 372 insertions(+) create mode 100644 app/Actions/Sharing/Propagate.php create mode 100644 app/Http/Requests/Sharing/PropagateSharingRequest.php diff --git a/app/Actions/Sharing/Propagate.php b/app/Actions/Sharing/Propagate.php new file mode 100644 index 00000000000..0599cd49627 --- /dev/null +++ b/app/Actions/Sharing/Propagate.php @@ -0,0 +1,148 @@ + $this->applyUpdate($album)); + + return; + // @codeCoverageIgnoreEnd + } + + $this->applyUpdate($album); + } + + /** + * Apply the current album access permissions to all descendants. + * + * @param Album $album + * + * @return void + */ + private function applyUpdate(Album $album): void + { + // for each descendant, create a new permission if it does not exist. + // or update the existing permission. + /** @var Collection $descendants */ + $descendants = $album->descendants()->select('id')->pluck('id'); + $permissions = $album->access_permissions()->whereNotNull('user_id')->get(); + + // This is super inefficient. + // It would be better to do it in a single query... + // But how? + $descendants->each(function (string $descendant, int|string $idx) use ($permissions) { + $permissions->each(function (AccessPermission $permission) use ($descendant) { + $perm = AccessPermission::updateOrCreate([ + APC::BASE_ALBUM_ID => $descendant, + APC::USER_ID => $permission->user_id, + ], [ + APC::GRANTS_FULL_PHOTO_ACCESS => $permission->grants_full_photo_access, + APC::GRANTS_DOWNLOAD => $permission->grants_download, + APC::GRANTS_UPLOAD => $permission->grants_upload, + APC::GRANTS_EDIT => $permission->grants_edit, + APC::GRANTS_DELETE => $permission->grants_delete, + ]); + $perm->save(); + }); + }); + } + + /** + * Overwrite all descendants with the current album access permissions. + * + * @param Album $album + * + * @return void + */ + public function overwrite(Album $album): void + { + if (!App::runningUnitTests()) { + // @codeCoverageIgnoreStart + DB::transaction(fn () => $this->applyOverwrite($album)); + + return; + // @codeCoverageIgnoreEnd + } + + $this->applyOverwrite($album); + } + + /** + * Apply the overwrite of all descendants with the current album access permissions. + * + * @param Album $album + * + * @return void + */ + private function applyOverwrite(Album $album): void + { + // override permission for all descendants albums. + // Faster done by: + // 1. clearing all the permissions. + // 2. applying the new permissions. + + DB::table(APC::ACCESS_PERMISSIONS) + ->whereNotNull('user_id') + ->whereIn( + 'base_album_id', + DB::table('albums') + ->select('id') + ->where('_lft', '>', $album->_lft) + ->where('_rgt', '<', $album->_rgt) + ) + ->delete(); + + $descendant_ids = DB::table('albums') + ->select('id') + ->where('_lft', '>', $album->_lft) + ->where('_rgt', '<', $album->_rgt) + ->pluck('id'); + + $access_permissions = $album->access_permissions()->whereNotNull('user_id')->get(); + + $new_perm = $access_permissions->reduce( + fn (?array $acc, AccessPermission $permission) => array_merge( + $acc ?? [], + $descendant_ids->map( + fn ($descendant_id) => [ + APC::BASE_ALBUM_ID => $descendant_id, + APC::USER_ID => $permission->user_id, + APC::GRANTS_FULL_PHOTO_ACCESS => $permission->grants_full_photo_access, + APC::GRANTS_DOWNLOAD => $permission->grants_download, + APC::GRANTS_UPLOAD => $permission->grants_upload, + APC::GRANTS_EDIT => $permission->grants_edit, + APC::GRANTS_DELETE => $permission->grants_delete, + ] + )->all() + ) + ); + + DB::table(APC::ACCESS_PERMISSIONS)->insert($new_perm); + } +} diff --git a/app/Http/Controllers/Gallery/SharingController.php b/app/Http/Controllers/Gallery/SharingController.php index 444029e6b64..84499ae8bf4 100644 --- a/app/Http/Controllers/Gallery/SharingController.php +++ b/app/Http/Controllers/Gallery/SharingController.php @@ -8,15 +8,19 @@ namespace App\Http\Controllers\Gallery; +use App\Actions\Sharing\Propagate; use App\Actions\Sharing\Share; use App\Constants\AccessPermissionConstants as APC; +use App\Exceptions\Internal\LycheeLogicException; use App\Http\Requests\Sharing\AddSharingRequest; use App\Http\Requests\Sharing\DeleteSharingRequest; use App\Http\Requests\Sharing\EditSharingRequest; use App\Http\Requests\Sharing\ListAllSharingRequest; use App\Http\Requests\Sharing\ListSharingRequest; +use App\Http\Requests\Sharing\PropagateSharingRequest; use App\Http\Resources\Models\AccessPermissionResource; use App\Models\AccessPermission; +use App\Models\Album; use App\Models\BaseAlbumImpl; use Illuminate\Routing\Controller; use Illuminate\Support\Collection; @@ -122,4 +126,25 @@ public function delete(DeleteSharingRequest $request): void { AccessPermission::query()->where('id', '=', $request->perm()->id)->delete(); } + + /** + * Propagate sharing permissions. + * + * @param PropagateSharingRequest $request + * + * @return void + */ + public function propagate(PropagateSharingRequest $request, Propagate $propagate): void + { + $album = $request->album(); + if (!$album instanceof Album) { + throw new LycheeLogicException('Only albums can have any descandants.'); + } + + if ($request->shallOverride) { + $propagate->overwrite($album); + } else { + $propagate->update($album); + } + } } \ No newline at end of file diff --git a/app/Http/Requests/Sharing/PropagateSharingRequest.php b/app/Http/Requests/Sharing/PropagateSharingRequest.php new file mode 100644 index 00000000000..cbc8e22404e --- /dev/null +++ b/app/Http/Requests/Sharing/PropagateSharingRequest.php @@ -0,0 +1,58 @@ +album]); + } + + /** + * {@inheritDoc} + */ + public function rules(): array + { + return [ + RequestAttribute::ALBUM_ID_ATTRIBUTE => ['required', new RandomIDRule(false)], + RequestAttribute::SHALL_OVERRIDE_ATTRIBUTE => ['required', 'boolean'], + ]; + } + + /** + * {@inheritDoc} + */ + protected function processValidatedValues(array $values, array $files): void + { + $this->album = $this->albumFactory->findBaseAlbumOrFail($values[RequestAttribute::ALBUM_ID_ATTRIBUTE]); + $this->shallOverride = static::toBoolean($values[RequestAttribute::SHALL_OVERRIDE_ATTRIBUTE]); + } +} \ No newline at end of file diff --git a/routes/api_v2.php b/routes/api_v2.php index 794ffd29de7..f5dabb5a22e 100644 --- a/routes/api_v2.php +++ b/routes/api_v2.php @@ -74,6 +74,7 @@ */ Route::get('/Sharing', [Gallery\SharingController::class, 'list']); Route::post('/Sharing', [Gallery\SharingController::class, 'create']); +Route::put('/Sharing', [Gallery\SharingController::class, 'propagate']); Route::patch('/Sharing', [Gallery\SharingController::class, 'edit']); Route::delete('/Sharing', [Gallery\SharingController::class, 'delete']); Route::get('/Sharing::all', [Gallery\SharingController::class, 'listAll']); diff --git a/tests/Feature_v2/Album/SharingTest.php b/tests/Feature_v2/Album/SharingTest.php index 902f60b51bc..6ee2a2a54bb 100644 --- a/tests/Feature_v2/Album/SharingTest.php +++ b/tests/Feature_v2/Album/SharingTest.php @@ -18,6 +18,9 @@ namespace Tests\Feature_v2\Album; +use App\Constants\AccessPermissionConstants as APC; +use App\Models\AccessPermission; +use App\Models\Album; use Tests\Feature_v2\Base\BaseApiV2Test; class SharingTest extends BaseApiV2Test @@ -74,4 +77,126 @@ public function testUserGet(): void $response = $this->actingAs($this->userMayUpload2)->deleteJson('Sharing', ['perm_id' => $id]); $this->assertNoContent($response); } + + public function testUpdateOverrideForbidden(): void + { + $response = $this->putJson('Sharing', []); + $this->assertUnprocessable($response); + + $response = $this->putJson('Sharing', [ + 'album_id' => $this->album1->id, + 'shall_override' => true, + ]); + $this->assertUnauthorized($response); + + $response = $this->actingAs($this->userMayUpload2)->putJson('Sharing', []); + $this->assertUnprocessable($response); + + $response = $this->actingAs($this->userMayUpload2)->putJson('Sharing', [ + 'album_id' => $this->album1->id, + 'shall_override' => true, + ]); + $this->assertForbidden($response); + } + + public function testUpdate(): void + { + $response = $this->actingAs($this->userMayUpload1)->putJson('Sharing', []); + $this->assertUnprocessable($response); + + // Update sub album permission. + $response = $this->actingAs($this->userMayUpload1)->putJson('Sharing', [ + 'album_id' => $this->album1->id, + 'shall_override' => false, + ]); + $this->assertNoContent($response); + self::assertEquals(1, AccessPermission::where(APC::BASE_ALBUM_ID, '=', $this->subAlbum1->id)->count()); + $perm = AccessPermission::where(APC::BASE_ALBUM_ID, '=', $this->subAlbum1->id)->first(); + + // Update the permission with false + $response = $this->actingAs($this->userMayUpload1)->patchJson('Sharing', [ + 'perm_id' => $perm->id, + 'grants_edit' => false, + 'grants_delete' => false, + 'grants_download' => false, + 'grants_full_photo_access' => false, + 'grants_upload' => false, + ]); + $this->assertOk($response); + + // Verify the permission + $perm = AccessPermission::where(APC::BASE_ALBUM_ID, '=', $this->subAlbum1->id)->first(); + self::assertFalse($perm->grants_edit); + self::assertFalse($perm->grants_delete); + self::assertFalse($perm->grants_download); + self::assertFalse($perm->grants_full_photo_access); + self::assertFalse($perm->grants_upload); + + // Apply update again + $response = $this->actingAs($this->userMayUpload1)->putJson('Sharing', [ + 'album_id' => $this->album1->id, + 'shall_override' => false, + ]); + $this->assertNoContent($response); + // Verify the count is still 1. + self::assertEquals(1, AccessPermission::where(APC::BASE_ALBUM_ID, '=', $this->subAlbum1->id)->count()); + + // Verify the permission + $perm = AccessPermission::where(APC::BASE_ALBUM_ID, '=', $this->subAlbum1->id)->first(); + self::assertTrue($perm->grants_edit); + self::assertTrue($perm->grants_delete); + self::assertTrue($perm->grants_download); + self::assertTrue($perm->grants_full_photo_access); + self::assertTrue($perm->grants_upload); + } + + public function testOverride(): void + { + // Set up the permission in subSlbum + $response = $this->actingAs($this->userMayUpload1)->postJson('Sharing', [ + 'user_ids' => [$this->userLocked->id], + 'album_ids' => [$this->subAlbum1->id], + 'grants_edit' => true, + 'grants_delete' => true, + 'grants_download' => true, + 'grants_full_photo_access' => true, + 'grants_upload' => true, + ]); + $this->assertOk($response); + self::assertEquals(1, AccessPermission::where(APC::BASE_ALBUM_ID, '=', $this->subAlbum1->id)->count()); + + $response = $this->actingAs($this->userMayUpload1)->postJson('Sharing', [ + 'user_ids' => [$this->userNoUpload->id], + 'album_ids' => [$this->album1->id], + 'grants_edit' => true, + 'grants_delete' => true, + 'grants_download' => true, + 'grants_full_photo_access' => true, + 'grants_upload' => true, + ]); + $this->assertOk($response); + self::assertEquals(2, AccessPermission::where(APC::BASE_ALBUM_ID, '=', $this->album1->id)->count()); + + // Update sub album permission. + $response = $this->actingAs($this->userMayUpload1)->putJson('Sharing', [ + 'album_id' => $this->album1->id, + 'shall_override' => true, + ]); + $this->assertNoContent($response); + self::assertEquals(0, + AccessPermission::query() + ->where(APC::BASE_ALBUM_ID, '=', $this->subAlbum1->id) + ->where(APC::USER_ID, '=', $this->userLocked->id) + ->count()); + self::assertEquals(1, + AccessPermission::query() + ->where(APC::BASE_ALBUM_ID, '=', $this->subAlbum1->id) + ->where(APC::USER_ID, '=', $this->userMayUpload2->id) + ->count()); + self::assertEquals(1, + AccessPermission::query() + ->where(APC::BASE_ALBUM_ID, '=', $this->subAlbum1->id) + ->where(APC::USER_ID, '=', $this->userNoUpload->id) + ->count()); + } } \ No newline at end of file diff --git a/tests/Feature_v2/Base/BaseApiV2Test.php b/tests/Feature_v2/Base/BaseApiV2Test.php index 2e2155207f1..2735c7706c7 100644 --- a/tests/Feature_v2/Base/BaseApiV2Test.php +++ b/tests/Feature_v2/Base/BaseApiV2Test.php @@ -114,6 +114,21 @@ public function patchJson($uri, array $data = [], array $headers = [], $options return $this->json('PATCH', self::API_PREFIX . ltrim($uri, '/'), $data, $headers, $options); } + /** + * Visit the given URI with a PUT request, expecting a JSON response. + * + * @param Uri|string $uri + * @param array $data + * @param array $headers + * @param int $options + * + * @return TestResponse<\Illuminate\Http\JsonResponse> + */ + public function putJson($uri, array $data = [], array $headers = [], $options = 0) + { + return $this->json('PUT', self::API_PREFIX . ltrim($uri, '/'), $data, $headers, $options); + } + /** * Visit the given URI with a DELETE request, expecting a JSON response. *