Merge pull request #6108 from BookStackApp/view_revisions_permission

Permissions: Started addition of revision-view permission
This commit is contained in:
Dan Brown
2026-04-19 16:39:29 +01:00
committed by GitHub
11 changed files with 152 additions and 6 deletions

View File

@@ -34,6 +34,7 @@ class PageRevisionController extends Controller
*/
public function index(Request $request, string $bookSlug, string $pageSlug)
{
$this->checkPermission(Permission::RevisionViewAll);
$page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
$listOptions = SimpleListOptions::fromRequest($request, 'page_revisions', true)->withSortOptions([
'id' => trans('entities.pages_revisions_sort_number')
@@ -65,6 +66,8 @@ class PageRevisionController extends Controller
*/
public function show(string $bookSlug, string $pageSlug, int $revisionId)
{
$this->checkPermission(Permission::RevisionViewAll);
$page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
/** @var ?PageRevision $revision */
$revision = $page->revisions()->where('id', '=', $revisionId)->first();
@@ -94,6 +97,8 @@ class PageRevisionController extends Controller
*/
public function changes(string $bookSlug, string $pageSlug, int $revisionId)
{
$this->checkPermission(Permission::RevisionViewAll);
$page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
/** @var ?PageRevision $revision */
$revision = $page->revisions()->where('id', '=', $revisionId)->first();
@@ -129,6 +134,7 @@ class PageRevisionController extends Controller
*/
public function restore(string $bookSlug, string $pageSlug, int $revisionId)
{
$this->checkPermission(Permission::RevisionViewAll);
$page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
$this->checkOwnablePermission(Permission::PageUpdate, $page);
@@ -144,6 +150,7 @@ class PageRevisionController extends Controller
*/
public function destroy(string $bookSlug, string $pageSlug, int $revId)
{
$this->checkPermission(Permission::RevisionViewAll);
$page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
$this->checkOwnablePermission(Permission::PageDelete, $page);

View File

@@ -118,6 +118,8 @@ enum Permission: string
case PageViewAll = 'page-view-all';
case PageViewOwn = 'page-view-own';
case RevisionViewAll = 'revision-view-all';
/**
* Get the generic permissions which may be queried for entities.
*/

View File

@@ -0,0 +1,67 @@
<?php
use Carbon\Carbon;
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// Create new revision-view-all permission
$permissionId = DB::table('role_permissions')->insertGetId([
'name' => 'revision-view-all',
'created_at' => Carbon::now()->toDateTimeString(),
'updated_at' => Carbon::now()->toDateTimeString(),
]);
// Get ids of page view permissions
$pageViewPermissions = DB::table('role_permissions')
->whereIn('name', [
'page-view-own',
'page-view-all',
])->get();
if ($pageViewPermissions->count() === 0) {
return;
}
// Get role ids which have page view permission
$applicableRoleIds = DB::table('permission_role')
->whereIn('permission_id', $pageViewPermissions->pluck('id'))
->pluck('role_id')
->unique()
->all();
// Assign the new permission to relevant roles
$newPermissionRoles = array_values(array_map(function (int $roleId) use ($permissionId) {
return [
'role_id' => $roleId,
'permission_id' => $permissionId,
];
}, $applicableRoleIds));
DB::table('permission_role')->insert($newPermissionRoles);
}
/**
* Reverse the migrations.
*/
public function down(): void
{
// Get the permission to remove
$revisionViewPermission = DB::table('role_permissions')
->where('name', '=', 'revision-view-all')
->first();
if (!$revisionViewPermission) {
return;
}
// Remove the permission, and its use on roles, from the database
DB::table('permission_role')->where('permission_id', '=', $revisionViewPermission->id)->delete();
DB::table('role_permissions')->where('id', '=', $revisionViewPermission->id)->delete();
}
};

View File

@@ -207,6 +207,7 @@ return [
'role_all' => 'All',
'role_own' => 'Own',
'role_controlled_by_asset' => 'Controlled by the asset they are uploaded to',
'role_controlled_by_page_delete' => 'Controlled by page delete permissions',
'role_save' => 'Save Role',
'role_users' => 'Users in this role',
'role_users_none' => 'No users are currently assigned to this role',

View File

@@ -9,7 +9,7 @@
</div>
@endif
@if ($entity->isA('page'))
@if ($entity->isA('page') && userCan(\BookStack\Permissions\Permission::RevisionViewAll))
<a href="{{ $entity->getUrl('/revisions') }}" class="entity-meta-item">
@icon('history'){{ trans('entities.meta_revision', ['revisionCount' => $entity->revision_count]) }}
</a>

View File

@@ -1,5 +1,5 @@
<div class="entity-meta">
@if ($entity->isA('page'))
@if ($entity->isA('page') && userCan(\BookStack\Permissions\Permission::RevisionViewAll))
@icon('history'){{ trans('entities.meta_revision', ['revisionCount' => $entity->revision_count]) }} <br>
@endif

View File

@@ -24,10 +24,12 @@
</a>
@endif
@endif
<a href="{{ $page->getUrl('/revisions') }}" data-shortcut="revisions" class="icon-list-item">
<span>@icon('history')</span>
<span>{{ trans('entities.revisions') }}</span>
</a>
@if(userCan(\BookStack\Permissions\Permission::RevisionViewAll))
<a href="{{ $page->getUrl('/revisions') }}" data-shortcut="revisions" class="icon-list-item">
<span>@icon('history')</span>
<span>{{ trans('entities.revisions') }}</span>
</a>
@endif
@if(userCan(\BookStack\Permissions\Permission::RestrictionsManage, $page))
<a href="{{ $page->getUrl('/permissions') }}" data-shortcut="permissions" class="icon-list-item">
<span>@icon('lock')</span>

View File

@@ -79,6 +79,7 @@
@include('settings.roles.parts.asset-permissions-row', ['title' => trans('entities.books'), 'permissionPrefix' => 'book'])
@include('settings.roles.parts.asset-permissions-row', ['title' => trans('entities.chapters'), 'permissionPrefix' => 'chapter'])
@include('settings.roles.parts.asset-permissions-row', ['title' => trans('entities.pages'), 'permissionPrefix' => 'page'])
@include('settings.roles.parts.revisions-permissions-row', ['title' => trans('entities.revisions'), 'permissionPrefix' => 'revision'])
@include('settings.roles.parts.related-asset-permissions-row', ['title' => trans('entities.images'), 'permissionPrefix' => 'image'])
@include('settings.roles.parts.related-asset-permissions-row', ['title' => trans('entities.attachments'), 'permissionPrefix' => 'attachment'])
@include('settings.roles.parts.related-asset-permissions-row', ['title' => trans('entities.comments'), 'permissionPrefix' => 'comment'])

View File

@@ -0,0 +1,22 @@
<div class="item-list-row flex-container-row items-center wrap">
<div class="flex py-s px-m min-width-s">
<strong>{{ $title }}</strong> <br>
<a href="#" refs="permissions-table@toggle-row" class="text-small text-link">{{ trans('common.toggle_all') }}</a>
</div>
<div class="flex py-s px-m min-width-xxs">
<small class="hide-over-m bold">{{ trans('common.create') }}<br></small>
<strong class="text-muted opacity-70 text-large">-</strong>
</div>
<div class="flex py-s px-m min-width-xxs">
<small class="hide-over-m bold">{{ trans('common.view') }}<br></small>
@include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-view-all', 'label' => trans('settings.role_all')])
</div>
<div class="flex py-s px-m min-width-xxs">
<small class="hide-over-m bold">{{ trans('common.edit') }}<br></small>
<strong class="text-muted opacity-70 text-large">-</strong>
</div>
<div class="flex py-s px-m min-width-xxs">
<small class="hide-over-m bold">{{ trans('common.delete') }}<br></small>
<small>{{ trans('settings.role_controlled_by_page_delete') }}</small>
</div>
</div>

View File

@@ -4,6 +4,8 @@ namespace Tests\Entity;
use BookStack\Activity\ActivityType;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Models\PageRevision;
use BookStack\Permissions\Permission;
use Tests\TestCase;
class PageRevisionTest extends TestCase
@@ -257,6 +259,33 @@ class PageRevisionTest extends TestCase
$revisionView->assertDontSee('dontwantthishere');
}
public function test_access_to_revision_operation_requires_revision_view_all_permission()
{
$editor = $this->users->editor();
$this->actingAs($editor);
$page = $this->entities->page();
$this->createRevisions($page, 3);
/** @var PageRevision $revision */
$revision = $page->revisions()->orderBy('id', 'desc')->first();
$this->get($page->getUrl())->assertSee($page->getUrl('/revisions'), false);
$this->get($page->getUrl('/revisions'))->assertOk();
$this->get($revision->getUrl())->assertOk();
$this->get($revision->getUrl('/changes'))->assertOk();
$this->put($revision->getUrl('/restore'))->assertRedirect($page->getUrl());
$this->delete($revision->getUrl('/delete'))->assertRedirect($page->getUrl('/revisions'));
$this->permissions->removeUserRolePermissions($editor, [Permission::RevisionViewAll]);
$this->get($page->getUrl())->assertDontSee($page->getUrl('/revisions'), false);
$this->assertPermissionError($this->get($page->getUrl('/revisions')));
$this->assertPermissionError($this->get($revision->getUrl()));
$this->assertPermissionError($this->get($revision->getUrl('/changes')));
$this->assertPermissionError($this->put($revision->getUrl('/restore')));
$this->assertPermissionError($this->delete($revision->getUrl('/delete')));
}
public function test_revision_restore_action_only_visible_with_permission()
{
$page = $this->entities->page();

View File

@@ -5,6 +5,7 @@ namespace Tests\Exports;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Page;
use BookStack\Permissions\Permission;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
@@ -229,6 +230,20 @@ class HtmlExportTest extends TestCase
$resp->assertDontSee('ExportWizardTheFifth');
}
public function test_page_export_only_includes_revision_count_if_user_has_revision_view_permissions()
{
$editor = $this->users->editor();
$page = $this->entities->page();
$resp = $this->actingAs($editor)->get($page->getUrl('/export/html'));
$resp->assertSee('Revision #');
$this->permissions->removeUserRolePermissions($editor, [Permission::RevisionViewAll]);
$resp = $this->actingAs($editor)->get($page->getUrl('/export/html'));
$resp->assertDontSee('Revision #');
}
public function test_html_exports_contain_csp_meta_tag()
{
$entities = [