mirror of
https://github.com/BookStackApp/BookStack.git
synced 2024-10-30 07:32:39 +01:00
Merge branch 'recycle_bin_api_endpoints' into development
This commit is contained in:
commit
d5b7fff102
@ -10,10 +10,16 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int $deleted_by
|
||||
* @property string $deletable_type
|
||||
* @property int $deletable_id
|
||||
* @property Deletable $deletable
|
||||
*/
|
||||
class Deletion extends Model implements Loggable
|
||||
{
|
||||
protected $hidden = [];
|
||||
|
||||
/**
|
||||
* Get the related deletable record.
|
||||
*/
|
||||
|
36
app/Entities/Repos/DeletionRepo.php
Normal file
36
app/Entities/Repos/DeletionRepo.php
Normal file
@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Repos;
|
||||
|
||||
use BookStack\Actions\ActivityType;
|
||||
use BookStack\Entities\Models\Deletion;
|
||||
use BookStack\Entities\Tools\TrashCan;
|
||||
use BookStack\Facades\Activity;
|
||||
|
||||
class DeletionRepo
|
||||
{
|
||||
private TrashCan $trashCan;
|
||||
|
||||
public function __construct(TrashCan $trashCan)
|
||||
{
|
||||
$this->trashCan = $trashCan;
|
||||
}
|
||||
|
||||
public function restore(int $id): int
|
||||
{
|
||||
/** @var Deletion $deletion */
|
||||
$deletion = Deletion::query()->findOrFail($id);
|
||||
Activity::add(ActivityType::RECYCLE_BIN_RESTORE, $deletion);
|
||||
|
||||
return $this->trashCan->restoreFromDeletion($deletion);
|
||||
}
|
||||
|
||||
public function destroy(int $id): int
|
||||
{
|
||||
/** @var Deletion $deletion */
|
||||
$deletion = Deletion::query()->findOrFail($id);
|
||||
Activity::add(ActivityType::RECYCLE_BIN_DESTROY, $deletion);
|
||||
|
||||
return $this->trashCan->destroyFromDeletion($deletion);
|
||||
}
|
||||
}
|
90
app/Http/Controllers/Api/RecycleBinApiController.php
Normal file
90
app/Http/Controllers/Api/RecycleBinApiController.php
Normal file
@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Http\Controllers\Api;
|
||||
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\BookChild;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Deletion;
|
||||
use BookStack\Entities\Repos\DeletionRepo;
|
||||
use Closure;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class RecycleBinApiController extends ApiController
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware(function ($request, $next) {
|
||||
$this->checkPermission('settings-manage');
|
||||
$this->checkPermission('restrictions-manage-all');
|
||||
|
||||
return $next($request);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a top-level listing of the items in the recycle bin.
|
||||
* The "deletable" property will reflect the main item deleted.
|
||||
* For books and chapters, counts of child pages/chapters will
|
||||
* be loaded within this "deletable" data.
|
||||
* For chapters & pages, the parent item will be loaded within this "deletable" data.
|
||||
* Requires permission to manage both system settings and permissions.
|
||||
*/
|
||||
public function list()
|
||||
{
|
||||
return $this->apiListingResponse(Deletion::query()->with('deletable'), [
|
||||
'id',
|
||||
'deleted_by',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'deletable_type',
|
||||
'deletable_id',
|
||||
], [Closure::fromCallable([$this, 'listFormatter'])]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a single deletion from the recycle bin.
|
||||
* Requires permission to manage both system settings and permissions.
|
||||
*/
|
||||
public function restore(DeletionRepo $deletionRepo, string $deletionId)
|
||||
{
|
||||
$restoreCount = $deletionRepo->restore(intval($deletionId));
|
||||
|
||||
return response()->json(['restore_count' => $restoreCount]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a single deletion from the recycle bin.
|
||||
* Use this endpoint carefully as it will entirely remove the underlying deleted items from the system.
|
||||
* Requires permission to manage both system settings and permissions.
|
||||
*/
|
||||
public function destroy(DeletionRepo $deletionRepo, string $deletionId)
|
||||
{
|
||||
$deleteCount = $deletionRepo->destroy(intval($deletionId));
|
||||
|
||||
return response()->json(['delete_count' => $deleteCount]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load some related details for the deletion listing.
|
||||
*/
|
||||
protected function listFormatter(Deletion $deletion)
|
||||
{
|
||||
$deletable = $deletion->deletable;
|
||||
$withTrashedQuery = fn(Builder $query) => $query->withTrashed();
|
||||
|
||||
if ($deletable instanceof BookChild) {
|
||||
$parent = $deletable->getParent();
|
||||
$parent->setAttribute('type', $parent->getType());
|
||||
$deletable->setRelation('parent', $parent);
|
||||
}
|
||||
|
||||
if ($deletable instanceof Book || $deletable instanceof Chapter) {
|
||||
$countsToLoad = ['pages' => $withTrashedQuery];
|
||||
if ($deletable instanceof Book) {
|
||||
$countsToLoad['chapters'] = $withTrashedQuery;
|
||||
}
|
||||
$deletable->loadCount($countsToLoad);
|
||||
}
|
||||
}
|
||||
}
|
@ -5,6 +5,7 @@ namespace BookStack\Http\Controllers;
|
||||
use BookStack\Actions\ActivityType;
|
||||
use BookStack\Entities\Models\Deletion;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Repos\DeletionRepo;
|
||||
use BookStack\Entities\Tools\TrashCan;
|
||||
|
||||
class RecycleBinController extends Controller
|
||||
@ -73,12 +74,9 @@ class RecycleBinController extends Controller
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function restore(string $id)
|
||||
public function restore(DeletionRepo $deletionRepo, string $id)
|
||||
{
|
||||
/** @var Deletion $deletion */
|
||||
$deletion = Deletion::query()->findOrFail($id);
|
||||
$this->logActivity(ActivityType::RECYCLE_BIN_RESTORE, $deletion);
|
||||
$restoreCount = (new TrashCan())->restoreFromDeletion($deletion);
|
||||
$restoreCount = $deletionRepo->restore((int) $id);
|
||||
|
||||
$this->showSuccessNotification(trans('settings.recycle_bin_restore_notification', ['count' => $restoreCount]));
|
||||
|
||||
@ -103,12 +101,9 @@ class RecycleBinController extends Controller
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function destroy(string $id)
|
||||
public function destroy(DeletionRepo $deletionRepo, string $id)
|
||||
{
|
||||
/** @var Deletion $deletion */
|
||||
$deletion = Deletion::query()->findOrFail($id);
|
||||
$this->logActivity(ActivityType::RECYCLE_BIN_DESTROY, $deletion);
|
||||
$deleteCount = (new TrashCan())->destroyFromDeletion($deletion);
|
||||
$deleteCount = $deletionRepo->destroy((int) $id);
|
||||
|
||||
$this->showSuccessNotification(trans('settings.recycle_bin_destroy_notification', ['count' => $deleteCount]));
|
||||
|
||||
|
@ -51,12 +51,12 @@ class AppServiceProvider extends ServiceProvider
|
||||
// Allow longer string lengths after upgrade to utf8mb4
|
||||
Schema::defaultStringLength(191);
|
||||
|
||||
// Set morph-map due to namespace changes
|
||||
Relation::morphMap([
|
||||
'BookStack\\Bookshelf' => Bookshelf::class,
|
||||
'BookStack\\Book' => Book::class,
|
||||
'BookStack\\Chapter' => Chapter::class,
|
||||
'BookStack\\Page' => Page::class,
|
||||
// Set morph-map for our relations to friendlier aliases
|
||||
Relation::enforceMorphMap([
|
||||
'bookshelf' => Bookshelf::class,
|
||||
'book' => Book::class,
|
||||
'chapter' => Chapter::class,
|
||||
'page' => Page::class,
|
||||
]);
|
||||
|
||||
// View Composers
|
||||
|
@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class UpdatePolymorphicTypes extends Migration
|
||||
{
|
||||
/**
|
||||
* Mapping of old polymorphic types to new simpler values.
|
||||
*/
|
||||
protected $changeMap = [
|
||||
'BookStack\\Bookshelf' => 'bookshelf',
|
||||
'BookStack\\Book' => 'book',
|
||||
'BookStack\\Chapter' => 'chapter',
|
||||
'BookStack\\Page' => 'page',
|
||||
];
|
||||
|
||||
/**
|
||||
* Mapping of tables and columns that contain polymorphic types.
|
||||
*/
|
||||
protected $columnsByTable = [
|
||||
'activities' => 'entity_type',
|
||||
'comments' => 'entity_type',
|
||||
'deletions' => 'deletable_type',
|
||||
'entity_permissions' => 'restrictable_type',
|
||||
'favourites' => 'favouritable_type',
|
||||
'joint_permissions' => 'entity_type',
|
||||
'search_terms' => 'entity_type',
|
||||
'tags' => 'entity_type',
|
||||
'views' => 'viewable_type',
|
||||
];
|
||||
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
foreach ($this->columnsByTable as $table => $column) {
|
||||
foreach ($this->changeMap as $oldVal => $newVal) {
|
||||
DB::table($table)
|
||||
->where([$column => $oldVal])
|
||||
->update([$column => $newVal]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
foreach ($this->columnsByTable as $table => $column) {
|
||||
foreach ($this->changeMap as $oldVal => $newVal) {
|
||||
DB::table($table)
|
||||
->where([$column => $newVal])
|
||||
->update([$column => $oldVal]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
3
dev/api/responses/recycle-bin-destroy.json
Normal file
3
dev/api/responses/recycle-bin-destroy.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"delete_count": 2
|
||||
}
|
64
dev/api/responses/recycle-bin-list.json
Normal file
64
dev/api/responses/recycle-bin-list.json
Normal file
@ -0,0 +1,64 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": 18,
|
||||
"deleted_by": 1,
|
||||
"created_at": "2022-04-20T12:57:46.000000Z",
|
||||
"updated_at": "2022-04-20T12:57:46.000000Z",
|
||||
"deletable_type": "page",
|
||||
"deletable_id": 2582,
|
||||
"deletable": {
|
||||
"id": 2582,
|
||||
"book_id": 25,
|
||||
"chapter_id": 0,
|
||||
"name": "A Wonderful Page",
|
||||
"slug": "a-wonderful-page",
|
||||
"priority": 9,
|
||||
"created_at": "2022-02-08T00:44:45.000000Z",
|
||||
"updated_at": "2022-04-20T12:57:46.000000Z",
|
||||
"created_by": 1,
|
||||
"updated_by": 1,
|
||||
"draft": false,
|
||||
"revision_count": 1,
|
||||
"template": false,
|
||||
"owned_by": 1,
|
||||
"editor": "wysiwyg",
|
||||
"book_slug": "a-great-book",
|
||||
"parent": {
|
||||
"id": 25,
|
||||
"name": "A Great Book",
|
||||
"slug": "a-great-book",
|
||||
"description": "",
|
||||
"created_at": "2022-01-24T16:14:28.000000Z",
|
||||
"updated_at": "2022-03-06T15:14:50.000000Z",
|
||||
"created_by": 1,
|
||||
"updated_by": 1,
|
||||
"owned_by": 1,
|
||||
"type": "book"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"deleted_by": 1,
|
||||
"created_at": "2022-04-25T16:07:46.000000Z",
|
||||
"updated_at": "2022-04-25T16:07:46.000000Z",
|
||||
"deletable_type": "book",
|
||||
"deletable_id": 13,
|
||||
"deletable": {
|
||||
"id": 13,
|
||||
"name": "A Big Book!",
|
||||
"slug": "a-big-book",
|
||||
"description": "This is a very large book with loads of cool stuff in it!",
|
||||
"created_at": "2021-11-08T11:26:43.000000Z",
|
||||
"updated_at": "2022-04-25T16:07:47.000000Z",
|
||||
"created_by": 27,
|
||||
"updated_by": 1,
|
||||
"owned_by": 1,
|
||||
"pages_count": 208,
|
||||
"chapters_count": 50
|
||||
}
|
||||
}
|
||||
],
|
||||
"total": 2
|
||||
}
|
3
dev/api/responses/recycle-bin-restore.json
Normal file
3
dev/api/responses/recycle-bin-restore.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"restore_count": 2
|
||||
}
|
@ -9,6 +9,7 @@ use BookStack\Http\Controllers\Api\ChapterApiController;
|
||||
use BookStack\Http\Controllers\Api\ChapterExportApiController;
|
||||
use BookStack\Http\Controllers\Api\PageApiController;
|
||||
use BookStack\Http\Controllers\Api\PageExportApiController;
|
||||
use BookStack\Http\Controllers\Api\RecycleBinApiController;
|
||||
use BookStack\Http\Controllers\Api\SearchApiController;
|
||||
use BookStack\Http\Controllers\Api\UserApiController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
@ -72,3 +73,7 @@ Route::post('users', [UserApiController::class, 'create']);
|
||||
Route::get('users/{id}', [UserApiController::class, 'read']);
|
||||
Route::put('users/{id}', [UserApiController::class, 'update']);
|
||||
Route::delete('users/{id}', [UserApiController::class, 'delete']);
|
||||
|
||||
Route::get('recycle-bin', [RecycleBinApiController::class, 'list']);
|
||||
Route::put('recycle-bin/{deletionId}', [RecycleBinApiController::class, 'restore']);
|
||||
Route::delete('recycle-bin/{deletionId}', [RecycleBinApiController::class, 'destroy']);
|
||||
|
184
tests/Api/RecycleBinApiTest.php
Normal file
184
tests/Api/RecycleBinApiTest.php
Normal file
@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Api;
|
||||
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Deletion;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use Illuminate\Support\Collection;
|
||||
use Tests\TestCase;
|
||||
|
||||
class RecycleBinApiTest extends TestCase
|
||||
{
|
||||
use TestsApi;
|
||||
|
||||
protected string $baseEndpoint = '/api/recycle-bin';
|
||||
|
||||
protected array $endpointMap = [
|
||||
['get', '/api/recycle-bin'],
|
||||
['put', '/api/recycle-bin/1'],
|
||||
['delete', '/api/recycle-bin/1'],
|
||||
];
|
||||
|
||||
public function test_settings_manage_permission_needed_for_all_endpoints()
|
||||
{
|
||||
$editor = $this->getEditor();
|
||||
$this->giveUserPermissions($editor, ['settings-manage']);
|
||||
$this->actingAs($editor);
|
||||
|
||||
foreach ($this->endpointMap as [$method, $uri]) {
|
||||
$resp = $this->json($method, $uri);
|
||||
$resp->assertStatus(403);
|
||||
$resp->assertJson($this->permissionErrorResponse());
|
||||
}
|
||||
}
|
||||
|
||||
public function test_restrictions_manage_all_permission_needed_for_all_endpoints()
|
||||
{
|
||||
$editor = $this->getEditor();
|
||||
$this->giveUserPermissions($editor, ['restrictions-manage-all']);
|
||||
$this->actingAs($editor);
|
||||
|
||||
foreach ($this->endpointMap as [$method, $uri]) {
|
||||
$resp = $this->json($method, $uri);
|
||||
$resp->assertStatus(403);
|
||||
$resp->assertJson($this->permissionErrorResponse());
|
||||
}
|
||||
}
|
||||
|
||||
public function test_index_endpoint_returns_expected_page()
|
||||
{
|
||||
$admin = $this->getAdmin();
|
||||
|
||||
$page = Page::query()->first();
|
||||
$book = Book::query()->first();
|
||||
$this->actingAs($admin)->delete($page->getUrl());
|
||||
$this->delete($book->getUrl());
|
||||
|
||||
$deletions = Deletion::query()->orderBy('id')->get();
|
||||
|
||||
$resp = $this->getJson($this->baseEndpoint);
|
||||
|
||||
$expectedData = $deletions
|
||||
->zip([$page, $book])
|
||||
->map(function (Collection $data) use ($admin) {
|
||||
return [
|
||||
'id' => $data[0]->id,
|
||||
'deleted_by' => $admin->id,
|
||||
'created_at' => $data[0]->created_at->toJson(),
|
||||
'updated_at' => $data[0]->updated_at->toJson(),
|
||||
'deletable_type' => $data[1]->getMorphClass(),
|
||||
'deletable_id' => $data[1]->id,
|
||||
'deletable' => [
|
||||
'name' => $data[1]->name,
|
||||
],
|
||||
];
|
||||
});
|
||||
|
||||
$resp->assertJson([
|
||||
'data' => $expectedData->values()->all(),
|
||||
'total' => 2,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_index_endpoint_returns_children_count()
|
||||
{
|
||||
$admin = $this->getAdmin();
|
||||
|
||||
$book = Book::query()->whereHas('pages')->whereHas('chapters')->withCount(['pages', 'chapters'])->first();
|
||||
$this->actingAs($admin)->delete($book->getUrl());
|
||||
|
||||
$deletion = Deletion::query()->orderBy('id')->first();
|
||||
|
||||
$resp = $this->getJson($this->baseEndpoint);
|
||||
|
||||
$expectedData = [
|
||||
[
|
||||
'id' => $deletion->id,
|
||||
'deletable' => [
|
||||
'pages_count' => $book->pages_count,
|
||||
'chapters_count' => $book->chapters_count,
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$resp->assertJson([
|
||||
'data' => $expectedData,
|
||||
'total' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_index_endpoint_returns_parent()
|
||||
{
|
||||
$admin = $this->getAdmin();
|
||||
$page = Page::query()->whereHas('chapter')->with('chapter')->first();
|
||||
|
||||
$this->actingAs($admin)->delete($page->getUrl());
|
||||
$deletion = Deletion::query()->orderBy('id')->first();
|
||||
|
||||
$resp = $this->getJson($this->baseEndpoint);
|
||||
|
||||
$expectedData = [
|
||||
[
|
||||
'id' => $deletion->id,
|
||||
'deletable' => [
|
||||
'parent' => [
|
||||
'id' => $page->chapter->id,
|
||||
'name' => $page->chapter->name,
|
||||
'type' => 'chapter'
|
||||
]
|
||||
]
|
||||
],
|
||||
];
|
||||
|
||||
$resp->assertJson([
|
||||
'data' => $expectedData,
|
||||
'total' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_restore_endpoint()
|
||||
{
|
||||
$page = Page::query()->first();
|
||||
$this->asAdmin()->delete($page->getUrl());
|
||||
$page->refresh();
|
||||
|
||||
$deletion = Deletion::query()->orderBy('id')->first();
|
||||
|
||||
$this->assertDatabaseHas('pages', [
|
||||
'id' => $page->id,
|
||||
'deleted_at' => $page->deleted_at,
|
||||
]);
|
||||
|
||||
$resp = $this->putJson($this->baseEndpoint . '/' . $deletion->id);
|
||||
$resp->assertJson([
|
||||
'restore_count' => 1
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('pages', [
|
||||
'id' => $page->id,
|
||||
'deleted_at' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_destroy_endpoint()
|
||||
{
|
||||
$page = Page::query()->first();
|
||||
$this->asAdmin()->delete($page->getUrl());
|
||||
$page->refresh();
|
||||
|
||||
$deletion = Deletion::query()->orderBy('id')->first();
|
||||
|
||||
$this->assertDatabaseHas('pages', [
|
||||
'id' => $page->id,
|
||||
'deleted_at' => $page->deleted_at,
|
||||
]);
|
||||
|
||||
$resp = $this->deleteJson($this->baseEndpoint . '/' . $deletion->id);
|
||||
$resp->assertJson([
|
||||
'delete_count' => 1
|
||||
]);
|
||||
|
||||
$this->assertDatabaseMissing('pages', ['id' => $page->id]);
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user