]> BookStack Code Mirror - bookstack/commitdiff
Perms: Added transactions around permission effecting actions
authorDan Brown <redacted>
Wed, 2 Jul 2025 21:25:59 +0000 (22:25 +0100)
committerDan Brown <redacted>
Wed, 2 Jul 2025 21:25:59 +0000 (22:25 +0100)
13 files changed:
app/Entities/Controllers/BookController.php
app/Entities/Controllers/ChapterController.php
app/Entities/Repos/BookRepo.php
app/Entities/Repos/BookshelfRepo.php
app/Entities/Repos/ChapterRepo.php
app/Entities/Repos/PageRepo.php
app/Entities/Tools/HierarchyTransformer.php
app/Entities/Tools/TrashCan.php
app/Permissions/JointPermissionBuilder.php
app/Permissions/PermissionsController.php
app/Permissions/PermissionsRepo.php
app/Sorting/BookSortController.php
app/Util/DatabaseTransaction.php [new file with mode: 0644]

index b1685081a3df1dc2eb8d214791ae5215a49578ab..5d3d67f645c36afbf66599a7cabd0558096835b3 100644 (file)
@@ -18,6 +18,7 @@ use BookStack\Exceptions\NotFoundException;
 use BookStack\Facades\Activity;
 use BookStack\Http\Controller;
 use BookStack\References\ReferenceFetcher;
+use BookStack\Util\DatabaseTransaction;
 use BookStack\Util\SimpleListOptions;
 use Illuminate\Http\Request;
 use Illuminate\Validation\ValidationException;
@@ -263,7 +264,9 @@ class BookController extends Controller
         $this->checkPermission('bookshelf-create-all');
         $this->checkPermission('book-create-all');
 
-        $shelf = $transformer->transformBookToShelf($book);
+        $shelf = (new DatabaseTransaction(function () use ($book, $transformer) {
+            return $transformer->transformBookToShelf($book);
+        }))->run();
 
         return redirect($shelf->getUrl());
     }
index 4274589e26055c5d1e378156f7d7b0ccd76f87bc..677745500c39ab065ca3cde843852cb35fc98950 100644 (file)
@@ -18,6 +18,7 @@ use BookStack\Exceptions\NotifyException;
 use BookStack\Exceptions\PermissionsException;
 use BookStack\Http\Controller;
 use BookStack\References\ReferenceFetcher;
+use BookStack\Util\DatabaseTransaction;
 use Illuminate\Http\Request;
 use Illuminate\Validation\ValidationException;
 use Throwable;
@@ -269,7 +270,9 @@ class ChapterController extends Controller
         $this->checkOwnablePermission('chapter-delete', $chapter);
         $this->checkPermission('book-create-all');
 
-        $book = $transformer->transformChapterToBook($chapter);
+        $book = (new DatabaseTransaction(function () use ($chapter, $transformer) {
+            return $transformer->transformChapterToBook($chapter);
+        }))->run();
 
         return redirect($book->getUrl());
     }
index 92e6a81c337fcc45dbe7d15c477082454526adf2..6d28d5d6aabe1796a0f219d641dc5abf816badd3 100644 (file)
@@ -10,6 +10,7 @@ use BookStack\Exceptions\ImageUploadException;
 use BookStack\Facades\Activity;
 use BookStack\Sorting\SortRule;
 use BookStack\Uploads\ImageRepo;
+use BookStack\Util\DatabaseTransaction;
 use Exception;
 use Illuminate\Http\UploadedFile;
 
@@ -28,19 +29,22 @@ class BookRepo
      */
     public function create(array $input): Book
     {
-        $book = new Book();
-        $this->baseRepo->create($book, $input);
-        $this->baseRepo->updateCoverImage($book, $input['image'] ?? null);
-        $this->baseRepo->updateDefaultTemplate($book, intval($input['default_template_id'] ?? null));
-        Activity::add(ActivityType::BOOK_CREATE, $book);
+        return (new DatabaseTransaction(function () use ($input) {
+            $book = new Book();
 
-        $defaultBookSortSetting = intval(setting('sorting-book-default', '0'));
-        if ($defaultBookSortSetting && SortRule::query()->find($defaultBookSortSetting)) {
-            $book->sort_rule_id = $defaultBookSortSetting;
-            $book->save();
-        }
+            $this->baseRepo->create($book, $input);
+            $this->baseRepo->updateCoverImage($book, $input['image'] ?? null);
+            $this->baseRepo->updateDefaultTemplate($book, intval($input['default_template_id'] ?? null));
+            Activity::add(ActivityType::BOOK_CREATE, $book);
 
-        return $book;
+            $defaultBookSortSetting = intval(setting('sorting-book-default', '0'));
+            if ($defaultBookSortSetting && SortRule::query()->find($defaultBookSortSetting)) {
+                $book->sort_rule_id = $defaultBookSortSetting;
+                $book->save();
+            }
+
+            return $book;
+        }))->run();
     }
 
     /**
index a00349ef1aeaec8cc79949932e4132ea14bc8d73..5d4d604bc309afdbc08c3a04864f1158fb14f87a 100644 (file)
@@ -7,6 +7,7 @@ use BookStack\Entities\Models\Bookshelf;
 use BookStack\Entities\Queries\BookQueries;
 use BookStack\Entities\Tools\TrashCan;
 use BookStack\Facades\Activity;
+use BookStack\Util\DatabaseTransaction;
 use Exception;
 
 class BookshelfRepo
@@ -23,13 +24,13 @@ class BookshelfRepo
      */
     public function create(array $input, array $bookIds): Bookshelf
     {
-        $shelf = new Bookshelf();
-        $this->baseRepo->create($shelf, $input);
-        $this->baseRepo->updateCoverImage($shelf, $input['image'] ?? null);
-        $this->updateBooks($shelf, $bookIds);
-        Activity::add(ActivityType::BOOKSHELF_CREATE, $shelf);
-
-        return $shelf;
+        return (new DatabaseTransaction(function () use ($input, $bookIds) {
+            $shelf = new Bookshelf();
+            $this->baseRepo->create($shelf, $input);
+            $this->baseRepo->updateCoverImage($shelf, $input['image'] ?? null);
+            $this->updateBooks($shelf, $bookIds);
+            Activity::add(ActivityType::BOOKSHELF_CREATE, $shelf);
+        }))->run();
     }
 
     /**
index fdf2de4e20235b81d39d3fed3ed7b837cc473cc4..2ab6e671f6d5679b3d293dbc72804c113d2b5c38 100644 (file)
@@ -11,6 +11,7 @@ use BookStack\Entities\Tools\TrashCan;
 use BookStack\Exceptions\MoveOperationException;
 use BookStack\Exceptions\PermissionsException;
 use BookStack\Facades\Activity;
+use BookStack\Util\DatabaseTransaction;
 use Exception;
 
 class ChapterRepo
@@ -27,16 +28,16 @@ class ChapterRepo
      */
     public function create(array $input, Book $parentBook): Chapter
     {
-        $chapter = new Chapter();
-        $chapter->book_id = $parentBook->id;
-        $chapter->priority = (new BookContents($parentBook))->getLastPriority() + 1;
-        $this->baseRepo->create($chapter, $input);
-        $this->baseRepo->updateDefaultTemplate($chapter, intval($input['default_template_id'] ?? null));
-        Activity::add(ActivityType::CHAPTER_CREATE, $chapter);
-
-        $this->baseRepo->sortParent($chapter);
-
-        return $chapter;
+        return (new DatabaseTransaction(function () use ($input, $parentBook) {
+            $chapter = new Chapter();
+            $chapter->book_id = $parentBook->id;
+            $chapter->priority = (new BookContents($parentBook))->getLastPriority() + 1;
+            $this->baseRepo->create($chapter, $input);
+            $this->baseRepo->updateDefaultTemplate($chapter, intval($input['default_template_id'] ?? null));
+            Activity::add(ActivityType::CHAPTER_CREATE, $chapter);
+
+            $this->baseRepo->sortParent($chapter);
+        }))->run();
     }
 
     /**
@@ -88,12 +89,14 @@ class ChapterRepo
             throw new PermissionsException('User does not have permission to create a chapter within the chosen book');
         }
 
-        $chapter->changeBook($parent->id);
-        $chapter->rebuildPermissions();
-        Activity::add(ActivityType::CHAPTER_MOVE, $chapter);
+        return (new DatabaseTransaction(function () use ($chapter, $parent) {
+            $chapter->changeBook($parent->id);
+            $chapter->rebuildPermissions();
+            Activity::add(ActivityType::CHAPTER_MOVE, $chapter);
 
-        $this->baseRepo->sortParent($chapter);
+            $this->baseRepo->sortParent($chapter);
 
-        return $parent;
+            return $parent;
+        }))->run();
     }
 }
index f081b33dc3aa1558f4f0606090c56563a9fdf1b6..63e8b8370ee046ad20b14de6b122ce18e050ecd6 100644 (file)
@@ -18,6 +18,7 @@ use BookStack\Exceptions\PermissionsException;
 use BookStack\Facades\Activity;
 use BookStack\References\ReferenceStore;
 use BookStack\References\ReferenceUpdater;
+use BookStack\Util\DatabaseTransaction;
 use Exception;
 
 class PageRepo
@@ -61,8 +62,10 @@ class PageRepo
             ]);
         }
 
-        $page->save();
-        $page->refresh()->rebuildPermissions();
+        (new DatabaseTransaction(function () use ($page) {
+            $page->save();
+            $page->refresh()->rebuildPermissions();
+        }))->run();
 
         return $page;
     }
@@ -72,21 +75,23 @@ class PageRepo
      */
     public function publishDraft(Page $draft, array $input): Page
     {
-        $draft->draft = false;
-        $draft->revision_count = 1;
-        $draft->priority = $this->getNewPriority($draft);
-        $this->updateTemplateStatusAndContentFromInput($draft, $input);
-        $this->baseRepo->update($draft, $input);
-        $draft->rebuildPermissions();
-
-        $summary = trim($input['summary'] ?? '') ?: trans('entities.pages_initial_revision');
-        $this->revisionRepo->storeNewForPage($draft, $summary);
-        $draft->refresh();
-
-        Activity::add(ActivityType::PAGE_CREATE, $draft);
-        $this->baseRepo->sortParent($draft);
-
-        return $draft;
+        return (new DatabaseTransaction(function () use ($draft, $input) {
+            $draft->draft = false;
+            $draft->revision_count = 1;
+            $draft->priority = $this->getNewPriority($draft);
+            $this->updateTemplateStatusAndContentFromInput($draft, $input);
+            $this->baseRepo->update($draft, $input);
+            $draft->rebuildPermissions();
+
+            $summary = trim($input['summary'] ?? '') ?: trans('entities.pages_initial_revision');
+            $this->revisionRepo->storeNewForPage($draft, $summary);
+            $draft->refresh();
+
+            Activity::add(ActivityType::PAGE_CREATE, $draft);
+            $this->baseRepo->sortParent($draft);
+
+            return $draft;
+        }))->run();
     }
 
     /**
@@ -117,7 +122,7 @@ class PageRepo
         $page->revision_count++;
         $page->save();
 
-        // Remove all update drafts for this user & page.
+        // Remove all update drafts for this user and page.
         $this->revisionRepo->deleteDraftsForCurrentUser($page);
 
         // Save a revision after updating
@@ -270,16 +275,18 @@ class PageRepo
             throw new PermissionsException('User does not have permission to create a page within the new parent');
         }
 
-        $page->chapter_id = ($parent instanceof Chapter) ? $parent->id : null;
-        $newBookId = ($parent instanceof Chapter) ? $parent->book->id : $parent->id;
-        $page->changeBook($newBookId);
-        $page->rebuildPermissions();
+        return (new DatabaseTransaction(function () use ($page, $parent) {
+            $page->chapter_id = ($parent instanceof Chapter) ? $parent->id : null;
+            $newBookId = ($parent instanceof Chapter) ? $parent->book->id : $parent->id;
+            $page->changeBook($newBookId);
+            $page->rebuildPermissions();
 
-        Activity::add(ActivityType::PAGE_MOVE, $page);
+            Activity::add(ActivityType::PAGE_MOVE, $page);
 
-        $this->baseRepo->sortParent($page);
+            $this->baseRepo->sortParent($page);
 
-        return $parent;
+            return $parent;
+        }))->run();
     }
 
     /**
index cd6c548fe581b1f9179e5332dd3174461ea4dbe1..b0d8880f402ecb0efd2a1241cd7d750312061d4e 100644 (file)
@@ -13,17 +13,12 @@ use BookStack\Facades\Activity;
 
 class HierarchyTransformer
 {
-    protected BookRepo $bookRepo;
-    protected BookshelfRepo $shelfRepo;
-    protected Cloner $cloner;
-    protected TrashCan $trashCan;
-
-    public function __construct(BookRepo $bookRepo, BookshelfRepo $shelfRepo, Cloner $cloner, TrashCan $trashCan)
-    {
-        $this->bookRepo = $bookRepo;
-        $this->shelfRepo = $shelfRepo;
-        $this->cloner = $cloner;
-        $this->trashCan = $trashCan;
+    public function __construct(
+        protected BookRepo $bookRepo,
+        protected BookshelfRepo $shelfRepo,
+        protected Cloner $cloner,
+        protected TrashCan $trashCan
+    ) {
     }
 
     /**
index 39c982cdc92a25219b0df21bfaadac9c4b52a372..5e8a9371942ea246098bf066851956401e942c1c 100644 (file)
@@ -15,6 +15,7 @@ use BookStack\Exceptions\NotifyException;
 use BookStack\Facades\Activity;
 use BookStack\Uploads\AttachmentService;
 use BookStack\Uploads\ImageService;
+use BookStack\Util\DatabaseTransaction;
 use Exception;
 use Illuminate\Database\Eloquent\Builder;
 use Illuminate\Support\Carbon;
@@ -357,25 +358,26 @@ class TrashCan
 
     /**
      * Destroy the given entity.
+     * Returns the number of total entities destroyed in the operation.
      *
      * @throws Exception
      */
     public function destroyEntity(Entity $entity): int
     {
-        if ($entity instanceof Page) {
-            return $this->destroyPage($entity);
-        }
-        if ($entity instanceof Chapter) {
-            return $this->destroyChapter($entity);
-        }
-        if ($entity instanceof Book) {
-            return $this->destroyBook($entity);
-        }
-        if ($entity instanceof Bookshelf) {
-            return $this->destroyShelf($entity);
-        }
+        $result = (new DatabaseTransaction(function () use ($entity) {
+            if ($entity instanceof Page) {
+                return $this->destroyPage($entity);
+            } else if ($entity instanceof Chapter) {
+                return $this->destroyChapter($entity);
+            } else if ($entity instanceof Book) {
+                return $this->destroyBook($entity);
+            } else if ($entity instanceof Bookshelf) {
+                return $this->destroyShelf($entity);
+            }
+            return null;
+        }))->run();
 
-        return 0;
+        return $result ?? 0;
     }
 
     /**
index c2922cdc9611481b1ba58a64d968f6e9d6dd18a8..56b22ad1604fc96fc18b2fe2265fc22f72a49c24 100644 (file)
@@ -29,7 +29,7 @@ class JointPermissionBuilder
     /**
      * Re-generate all entity permission from scratch.
      */
-    public function rebuildForAll()
+    public function rebuildForAll(): void
     {
         JointPermission::query()->truncate();
 
@@ -51,7 +51,7 @@ class JointPermissionBuilder
     /**
      * Rebuild the entity jointPermissions for a particular entity.
      */
-    public function rebuildForEntity(Entity $entity)
+    public function rebuildForEntity(Entity $entity): void
     {
         $entities = [$entity];
         if ($entity instanceof Book) {
@@ -119,7 +119,7 @@ class JointPermissionBuilder
     /**
      * Build joint permissions for the given book and role combinations.
      */
-    protected function buildJointPermissionsForBooks(EloquentCollection $books, array $roles, bool $deleteOld = false)
+    protected function buildJointPermissionsForBooks(EloquentCollection $books, array $roles, bool $deleteOld = false): void
     {
         $entities = clone $books;
 
@@ -143,7 +143,7 @@ class JointPermissionBuilder
     /**
      * Rebuild the entity jointPermissions for a collection of entities.
      */
-    protected function buildJointPermissionsForEntities(array $entities)
+    protected function buildJointPermissionsForEntities(array $entities): void
     {
         $roles = Role::query()->get()->values()->all();
         $this->deleteManyJointPermissionsForEntities($entities);
@@ -155,21 +155,19 @@ class JointPermissionBuilder
      *
      * @param Entity[] $entities
      */
-    protected function deleteManyJointPermissionsForEntities(array $entities)
+    protected function deleteManyJointPermissionsForEntities(array $entities): void
     {
         $simpleEntities = $this->entitiesToSimpleEntities($entities);
         $idsByType = $this->entitiesToTypeIdMap($simpleEntities);
 
-        DB::transaction(function () use ($idsByType) {
-            foreach ($idsByType as $type => $ids) {
-                foreach (array_chunk($ids, 1000) as $idChunk) {
-                    DB::table('joint_permissions')
-                        ->where('entity_type', '=', $type)
-                        ->whereIn('entity_id', $idChunk)
-                        ->delete();
-                }
+        foreach ($idsByType as $type => $ids) {
+            foreach (array_chunk($ids, 1000) as $idChunk) {
+                DB::table('joint_permissions')
+                    ->where('entity_type', '=', $type)
+                    ->whereIn('entity_id', $idChunk)
+                    ->delete();
             }
-        });
+        }
     }
 
     /**
@@ -195,7 +193,7 @@ class JointPermissionBuilder
      * @param Entity[] $originalEntities
      * @param Role[]   $roles
      */
-    protected function createManyJointPermissions(array $originalEntities, array $roles)
+    protected function createManyJointPermissions(array $originalEntities, array $roles): void
     {
         $entities = $this->entitiesToSimpleEntities($originalEntities);
         $jointPermissions = [];
@@ -225,11 +223,9 @@ class JointPermissionBuilder
             }
         }
 
-        DB::transaction(function () use ($jointPermissions) {
-            foreach (array_chunk($jointPermissions, 1000) as $jointPermissionChunk) {
-                DB::table('joint_permissions')->insert($jointPermissionChunk);
-            }
-        });
+        foreach (array_chunk($jointPermissions, 1000) as $jointPermissionChunk) {
+            DB::table('joint_permissions')->insert($jointPermissionChunk);
+        }
     }
 
     /**
index 5d2035870bccd79ba4328ec973a18a4140201475..9dcfe242ec054285773dd7d1199d71d1d2d6ee9e 100644 (file)
@@ -7,6 +7,7 @@ use BookStack\Entities\Tools\PermissionsUpdater;
 use BookStack\Http\Controller;
 use BookStack\Permissions\Models\EntityPermission;
 use BookStack\Users\Models\Role;
+use BookStack\Util\DatabaseTransaction;
 use Illuminate\Http\Request;
 
 class PermissionsController extends Controller
@@ -40,7 +41,9 @@ class PermissionsController extends Controller
         $page = $this->queries->pages->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
         $this->checkOwnablePermission('restrictions-manage', $page);
 
-        $this->permissionsUpdater->updateFromPermissionsForm($page, $request);
+        (new DatabaseTransaction(function () use ($page, $request) {
+            $this->permissionsUpdater->updateFromPermissionsForm($page, $request);
+        }))->run();
 
         $this->showSuccessNotification(trans('entities.pages_permissions_success'));
 
@@ -70,7 +73,9 @@ class PermissionsController extends Controller
         $chapter = $this->queries->chapters->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
         $this->checkOwnablePermission('restrictions-manage', $chapter);
 
-        $this->permissionsUpdater->updateFromPermissionsForm($chapter, $request);
+        (new DatabaseTransaction(function () use ($chapter, $request) {
+            $this->permissionsUpdater->updateFromPermissionsForm($chapter, $request);
+        }))->run();
 
         $this->showSuccessNotification(trans('entities.chapters_permissions_success'));
 
@@ -100,7 +105,9 @@ class PermissionsController extends Controller
         $book = $this->queries->books->findVisibleBySlugOrFail($slug);
         $this->checkOwnablePermission('restrictions-manage', $book);
 
-        $this->permissionsUpdater->updateFromPermissionsForm($book, $request);
+        (new DatabaseTransaction(function () use ($book, $request) {
+            $this->permissionsUpdater->updateFromPermissionsForm($book, $request);
+        }))->run();
 
         $this->showSuccessNotification(trans('entities.books_permissions_updated'));
 
@@ -130,7 +137,9 @@ class PermissionsController extends Controller
         $shelf = $this->queries->shelves->findVisibleBySlugOrFail($slug);
         $this->checkOwnablePermission('restrictions-manage', $shelf);
 
-        $this->permissionsUpdater->updateFromPermissionsForm($shelf, $request);
+        (new DatabaseTransaction(function () use ($shelf, $request) {
+            $this->permissionsUpdater->updateFromPermissionsForm($shelf, $request);
+        }))->run();
 
         $this->showSuccessNotification(trans('entities.shelves_permissions_updated'));
 
@@ -145,7 +154,10 @@ class PermissionsController extends Controller
         $shelf = $this->queries->shelves->findVisibleBySlugOrFail($slug);
         $this->checkOwnablePermission('restrictions-manage', $shelf);
 
-        $updateCount = $this->permissionsUpdater->updateBookPermissionsFromShelf($shelf);
+        $updateCount = (new DatabaseTransaction(function () use ($shelf) {
+            return $this->permissionsUpdater->updateBookPermissionsFromShelf($shelf);
+        }))->run();
+
         $this->showSuccessNotification(trans('entities.shelves_copy_permission_success', ['count' => $updateCount]));
 
         return redirect($shelf->getUrl());
index b41612968b4692629a9a7392f563416c59b741c3..6ced7b7511ce46c2b478fac65690ef6a43caf547 100644 (file)
@@ -7,6 +7,7 @@ use BookStack\Exceptions\PermissionsException;
 use BookStack\Facades\Activity;
 use BookStack\Permissions\Models\RolePermission;
 use BookStack\Users\Models\Role;
+use BookStack\Util\DatabaseTransaction;
 use Exception;
 use Illuminate\Database\Eloquent\Collection;
 
@@ -48,38 +49,42 @@ class PermissionsRepo
      */
     public function saveNewRole(array $roleData): Role
     {
-        $role = new Role($roleData);
-        $role->mfa_enforced = boolval($roleData['mfa_enforced'] ?? false);
-        $role->save();
+        return (new DatabaseTransaction(function () use ($roleData) {
+            $role = new Role($roleData);
+            $role->mfa_enforced = boolval($roleData['mfa_enforced'] ?? false);
+            $role->save();
 
-        $permissions = $roleData['permissions'] ?? [];
-        $this->assignRolePermissions($role, $permissions);
-        $this->permissionBuilder->rebuildForRole($role);
+            $permissions = $roleData['permissions'] ?? [];
+            $this->assignRolePermissions($role, $permissions);
+            $this->permissionBuilder->rebuildForRole($role);
 
-        Activity::add(ActivityType::ROLE_CREATE, $role);
+            Activity::add(ActivityType::ROLE_CREATE, $role);
 
-        return $role;
+            return $role;
+        }))->run();
     }
 
     /**
      * Updates an existing role.
-     * Ensures Admin system role always have core permissions.
+     * Ensures the Admin system role always has core permissions.
      */
     public function updateRole($roleId, array $roleData): Role
     {
         $role = $this->getRoleById($roleId);
 
-        if (isset($roleData['permissions'])) {
-            $this->assignRolePermissions($role, $roleData['permissions']);
-        }
+        return (new DatabaseTransaction(function () use ($role, $roleData) {
+            if (isset($roleData['permissions'])) {
+                $this->assignRolePermissions($role, $roleData['permissions']);
+            }
 
-        $role->fill($roleData);
-        $role->save();
-        $this->permissionBuilder->rebuildForRole($role);
+            $role->fill($roleData);
+            $role->save();
+            $this->permissionBuilder->rebuildForRole($role);
 
-        Activity::add(ActivityType::ROLE_UPDATE, $role);
+            Activity::add(ActivityType::ROLE_UPDATE, $role);
 
-        return $role;
+            return $role;
+        }))->run();
     }
 
     /**
@@ -114,7 +119,7 @@ class PermissionsRepo
     /**
      * Delete a role from the system.
      * Check it's not an admin role or set as default before deleting.
-     * If a migration Role ID is specified the users assign to the current role
+     * If a migration Role ID is specified, the users assigned to the current role
      * will be added to the role of the specified id.
      *
      * @throws PermissionsException
@@ -131,17 +136,19 @@ class PermissionsRepo
             throw new PermissionsException(trans('errors.role_registration_default_cannot_delete'));
         }
 
-        if ($migrateRoleId !== 0) {
-            $newRole = Role::query()->find($migrateRoleId);
-            if ($newRole) {
-                $users = $role->users()->pluck('id')->toArray();
-                $newRole->users()->sync($users);
+        (new DatabaseTransaction(function () use ($migrateRoleId, $role) {
+            if ($migrateRoleId !== 0) {
+                $newRole = Role::query()->find($migrateRoleId);
+                if ($newRole) {
+                    $users = $role->users()->pluck('id')->toArray();
+                    $newRole->users()->sync($users);
+                }
             }
-        }
 
-        $role->entityPermissions()->delete();
-        $role->jointPermissions()->delete();
-        Activity::add(ActivityType::ROLE_DELETE, $role);
-        $role->delete();
+            $role->entityPermissions()->delete();
+            $role->jointPermissions()->delete();
+            Activity::add(ActivityType::ROLE_DELETE, $role);
+            $role->delete();
+        }))->run();
     }
 }
index 479d1972440dceca53e3c7291fa696d54c92b65b..d70d0e6565acc33f5113fdb352cc6c76252fd997 100644 (file)
@@ -7,6 +7,7 @@ use BookStack\Entities\Queries\BookQueries;
 use BookStack\Entities\Tools\BookContents;
 use BookStack\Facades\Activity;
 use BookStack\Http\Controller;
+use BookStack\Util\DatabaseTransaction;
 use Illuminate\Http\Request;
 
 class BookSortController extends Controller
@@ -55,16 +56,18 @@ class BookSortController extends Controller
 
         // Sort via map
         if ($request->filled('sort-tree')) {
-            $sortMap = BookSortMap::fromJson($request->get('sort-tree'));
-            $booksInvolved = $sorter->sortUsingMap($sortMap);
+            (new DatabaseTransaction(function () use ($book, $request, $sorter, &$loggedActivityForBook) {
+                $sortMap = BookSortMap::fromJson($request->get('sort-tree'));
+                $booksInvolved = $sorter->sortUsingMap($sortMap);
 
-            // Rebuild permissions and add activity for involved books.
-            foreach ($booksInvolved as $bookInvolved) {
-                Activity::add(ActivityType::BOOK_SORT, $bookInvolved);
-                if ($bookInvolved->id === $book->id) {
-                    $loggedActivityForBook = true;
+                // Add activity for involved books.
+                foreach ($booksInvolved as $bookInvolved) {
+                    Activity::add(ActivityType::BOOK_SORT, $bookInvolved);
+                    if ($bookInvolved->id === $book->id) {
+                        $loggedActivityForBook = true;
+                    }
                 }
-            }
+            }))->run();
         }
 
         if ($request->filled('auto-sort')) {
diff --git a/app/Util/DatabaseTransaction.php b/app/Util/DatabaseTransaction.php
new file mode 100644 (file)
index 0000000..e36bd2e
--- /dev/null
@@ -0,0 +1,42 @@
+<?php
+
+namespace BookStack\Util;
+
+use Closure;
+use Illuminate\Support\Facades\DB;
+use Throwable;
+
+/**
+ * Run the given code within a database transactions.
+ * Wraps Laravel's own transaction method, but sets a specific runtime isolation method.
+ * This sets a session level since this won't cause issues if already within a transaction,
+ * and this should apply to the next transactions anyway.
+ *
+ * "READ COMMITTED" ensures that changes from other transactions can be read within
+ * a transaction, even if started afterward (and for example, it was blocked by the initial
+ * transaction). This is quite important for things like permission generation, where we would
+ * want to consider the changes made by other committed transactions by the time we come to
+ * regenerate permission access.
+ *
+ * @throws Throwable
+ * @template TReturn of mixed
+ */
+class DatabaseTransaction
+{
+    /**
+     * @param  (Closure(static): TReturn)  $callback
+     */
+    public function __construct(
+        protected Closure $callback
+    ) {
+    }
+
+    /**
+     * @return TReturn
+     */
+    public function run(): mixed
+    {
+        DB::statement('SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED');
+        return DB::transaction($this->callback);
+    }
+}