]> BookStack Code Mirror - bookstack/commitdiff
Added audit log interface
authorDan Brown <redacted>
Sat, 19 Sep 2020 11:06:45 +0000 (12:06 +0100)
committerDan Brown <redacted>
Sat, 19 Sep 2020 11:06:45 +0000 (12:06 +0100)
- Displays the currently tracked activities in the system.

Related to #2173 and #1167

13 files changed:
app/Entities/Entity.php
app/Http/Controllers/AuditLogController.php [new file with mode: 0644]
app/helpers.php
database/migrations/2020_09_19_094251_add_activity_indexes.php [new file with mode: 0644]
resources/js/components/index.js
resources/js/components/submit-on-change.js [new file with mode: 0644]
resources/lang/en/settings.php
resources/sass/_layout.scss
resources/sass/styles.scss
resources/views/settings/audit.blade.php [new file with mode: 0644]
resources/views/settings/navbar.blade.php
routes/web.php
tests/AuditLogTest.php [new file with mode: 0644]

index 6a5894cacb91941115e6e01225fd2aa5b100c89b..120290d8ff12317529bd9d60d05519189098d0c8 100644 (file)
@@ -238,10 +238,8 @@ class Entity extends Ownable
 
     /**
      * Gets a limited-length version of the entities name.
-     * @param int $length
-     * @return string
      */
-    public function getShortName($length = 25)
+    public function getShortName(int $length = 25): string
     {
         if (mb_strlen($this->name) <= $length) {
             return $this->name;
diff --git a/app/Http/Controllers/AuditLogController.php b/app/Http/Controllers/AuditLogController.php
new file mode 100644 (file)
index 0000000..a3ef01b
--- /dev/null
@@ -0,0 +1,51 @@
+<?php
+
+namespace BookStack\Http\Controllers;
+
+use BookStack\Actions\Activity;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
+
+class AuditLogController extends Controller
+{
+
+    public function index(Request $request)
+    {
+        $this->checkPermission('settings-manage');
+        $this->checkPermission('users-manage');
+
+        $listDetails = [
+            'order' => $request->get('order', 'desc'),
+            'event' => $request->get('event', ''),
+            'sort' => $request->get('sort', 'created_at'),
+            'date_from' => $request->get('date_from', ''),
+            'date_to' => $request->get('date_to', ''),
+        ];
+
+        $query = Activity::query()
+            ->with(['entity', 'user'])
+            ->orderBy($listDetails['sort'], $listDetails['order']);
+
+        if ($listDetails['event']) {
+            $query->where('key', '=', $listDetails['event']);
+        }
+
+        if ($listDetails['date_from']) {
+            $query->where('created_at', '>=', $listDetails['date_from']);
+        }
+        if ($listDetails['date_to']) {
+            $query->where('created_at', '<=', $listDetails['date_to']);
+        }
+
+        $activities = $query->paginate(100);
+        $activities->appends($listDetails);
+
+        $keys = DB::table('activities')->select('key')->distinct()->pluck('key');
+        $this->setPageTitle(trans('settings.audit'));
+        return view('settings.audit', [
+            'activities' => $activities,
+            'listDetails' => $listDetails,
+            'activityKeys' => $keys,
+        ]);
+    }
+}
index 65da1853b54e54bb43a8cb8fe5d9959a3a877578..83017c37dddda3c81043c7f29b1aad080e8e346e 100644 (file)
@@ -153,10 +153,6 @@ function icon(string $name, array $attrs = []): string
  * Generate a url with multiple parameters for sorting purposes.
  * Works out the logic to set the correct sorting direction
  * Discards empty parameters and allows overriding.
- * @param string $path
- * @param array $data
- * @param array $overrideData
- * @return string
  */
 function sortUrl(string $path, array $data, array $overrideData = []): string
 {
@@ -166,7 +162,7 @@ function sortUrl(string $path, array $data, array $overrideData = []): string
     // Change sorting direction is already sorted on current attribute
     if (isset($overrideData['sort']) && $overrideData['sort'] === $data['sort']) {
         $queryData['order'] = ($data['order'] === 'asc') ? 'desc' : 'asc';
-    } else {
+    } elseif (isset($overrideData['sort'])) {
         $queryData['order'] = 'asc';
     }
 
diff --git a/database/migrations/2020_09_19_094251_add_activity_indexes.php b/database/migrations/2020_09_19_094251_add_activity_indexes.php
new file mode 100644 (file)
index 0000000..544b01e
--- /dev/null
@@ -0,0 +1,34 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+class AddActivityIndexes extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::table('activities', function(Blueprint $table) {
+            $table->index('key');
+            $table->index('created_at');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::table('activities', function(Blueprint $table) {
+            $table->dropIndex('key');
+            $table->dropIndex('created_at');
+        });
+    }
+}
index 9a5f2d7d7140bee5002c43258e26ec520aa745e6..87c496c91c591718a269b663d3ac329230a778ab 100644 (file)
@@ -42,6 +42,7 @@ import settingColorPicker from "./setting-color-picker.js"
 import shelfSort from "./shelf-sort.js"
 import sidebar from "./sidebar.js"
 import sortableList from "./sortable-list.js"
+import submitOnChange from "./submit-on-change.js"
 import tabs from "./tabs.js"
 import tagManager from "./tag-manager.js"
 import templateManager from "./template-manager.js"
@@ -94,6 +95,7 @@ const componentMapping = {
     "shelf-sort": shelfSort,
     "sidebar": sidebar,
     "sortable-list": sortableList,
+    "submit-on-change": submitOnChange,
     "tabs": tabs,
     "tag-manager": tagManager,
     "template-manager": templateManager,
diff --git a/resources/js/components/submit-on-change.js b/resources/js/components/submit-on-change.js
new file mode 100644 (file)
index 0000000..9799672
--- /dev/null
@@ -0,0 +1,19 @@
+/**
+ * Submit on change
+ * Simply submits a parent form when this input is changed.
+ * @extends {Component}
+ */
+class SubmitOnChange {
+
+    setup() {
+        this.$el.addEventListener('change', () => {
+            const form = this.$el.closest('form');
+            if (form) {
+                form.submit();
+            }
+        });
+    }
+
+}
+
+export default SubmitOnChange;
\ No newline at end of file
index 679d4b8a805d0f83327683df463401f542075563..2bd314cf0f28561f9a2f296b373483df42480f89 100755 (executable)
@@ -81,6 +81,20 @@ return [
     'maint_send_test_email_mail_greeting' => 'Email delivery seems to work!',
     'maint_send_test_email_mail_text' => 'Congratulations! As you received this email notification, your email settings seem to be configured properly.',
 
+    // Audit Log
+    'audit' => 'Audit Log',
+    'audit_desc' => 'This audit log displays a list of activities tracked in the system. This list is unfiltered unlike similar activity lists in the system where permission filters are applied.',
+    'audit_event_filter' => 'Event Filter',
+    'audit_event_filter_no_filter' => 'No Filter',
+    'audit_deleted_item' => 'Deleted Item',
+    'audit_deleted_item_name' => 'Name: :name',
+    'audit_table_user' => 'User',
+    'audit_table_event' => 'Event',
+    'audit_table_item' => 'Related Item',
+    'audit_table_date' => 'Activity Date',
+    'audit_date_from' => 'Date Range From',
+    'audit_date_to' => 'Date Range To',
+
     // Role Settings
     'roles' => 'Roles',
     'role_user_roles' => 'User Roles',
index 439bf8512361c3eae0546484ceb1bc89d84ea9c6..cf2a1630e48b1de1eab30c008176463859dc5c32 100644 (file)
@@ -121,6 +121,11 @@ body.flexbox {
   position: relative;
 }
 
+.flex-container-row {
+  display: flex;
+  flex-direction: row;
+}
+
 .flex-container-column {
   display: flex;
   flex-direction: column;
index f89fe039eefa894e0561cddb9aa45a30f5fc2fa2..376541b5dcba5d67b476f269b1c60ab8aab61131 100644 (file)
@@ -288,4 +288,15 @@ $btt-size: 40px;
       transform: rotate(180deg);
     }
   }
+}
+
+table a.audit-log-user {
+  display: grid;
+  grid-template-columns: 42px 1fr;
+  align-items: center;
+}
+table a.icon-list-item {
+  display: grid;
+  grid-template-columns: 36px 1fr;
+  align-items: center;
 }
\ No newline at end of file
diff --git a/resources/views/settings/audit.blade.php b/resources/views/settings/audit.blade.php
new file mode 100644 (file)
index 0000000..9b97f06
--- /dev/null
@@ -0,0 +1,98 @@
+@extends('simple-layout')
+
+@section('body')
+<div class="container">
+
+    <div class="grid left-focus v-center no-row-gap">
+        <div class="py-m">
+            @include('settings.navbar', ['selected' => 'audit'])
+        </div>
+    </div>
+
+    <div class="card content-wrap auto-height">
+        <h2 class="list-heading">{{ trans('settings.audit') }}</h2>
+        <p class="text-muted">{{ trans('settings.audit_desc') }}</p>
+
+        <div class="flex-container-row">
+            <div component="dropdown" class="list-sort-type dropdown-container mr-m">
+                <label for="">{{ trans('settings.audit_event_filter') }}</label>
+                <button refs="dropdown@toggle" aria-haspopup="true" aria-expanded="false" aria-label="{{ trans('common.sort_options') }}" class="input-base text-left">{{ $listDetails['event'] ?: trans('settings.audit_event_filter_no_filter') }}</button>
+                <ul refs="dropdown@menu" class="dropdown-menu">
+                    <li @if($listDetails['event'] === '') class="active" @endif><a href="{{ sortUrl('/settings/audit', $listDetails, ['event' => '']) }}">{{ trans('settings.audit_event_filter_no_filter') }}</a></li>
+                    @foreach($activityKeys as $key)
+                        <li @if($key === $listDetails['event']) class="active" @endif><a href="{{ sortUrl('/settings/audit', $listDetails, ['event' => $key]) }}">{{ $key }}</a></li>
+                    @endforeach
+                </ul>
+            </div>
+
+            @foreach(['date_from', 'date_to'] as $filterKey)
+                <form action="{{ url('/settings/audit') }}" method="get" class="block mr-m">
+                    @foreach($listDetails as $param => $val)
+                        @if(!empty($val) && $param !== $filterKey)
+                            <input type="hidden" name="{{ $param }}" value="{{ $val }}">
+                        @endif
+                    @endforeach
+                    <label for="audit_filter_{{ $filterKey }}">{{ trans('settings.audit_' . $filterKey) }}</label>
+                    <input id="audit_filter_{{ $filterKey }}"
+                           component="submit-on-change"
+                           type="date"
+                           name="{{ $filterKey }}"
+                           value="{{ $listDetails[$filterKey] ?? '' }}">
+                </form>
+            @endforeach
+        </div>
+
+        <hr class="mt-l mb-s">
+
+        {{ $activities->links() }}
+
+        <table class="table">
+            <tbody>
+            <tr>
+                <th>{{ trans('settings.audit_table_user') }}</th>
+                <th>
+                    <a href="{{ sortUrl('/settings/audit', $listDetails, ['sort' => 'key']) }}">{{ trans('settings.audit_table_event') }}</a>
+                </th>
+                <th>{{ trans('settings.audit_table_item') }}</th>
+                <th>
+                    <a href="{{ sortUrl('/settings/audit', $listDetails, ['sort' => 'created_at']) }}">{{ trans('settings.audit_table_date') }}</a></th>
+            </tr>
+            @foreach($activities as $activity)
+                <tr>
+                    <td>
+                        @if($activity->user)
+                            <a href="{{ $activity->user->getEditUrl() }}" class="audit-log-user">
+                                <div><img class="avatar block" src="{{ $activity->user->getAvatar(40)}}" alt="{{ $activity->user->name }}"></div>
+                                <div>{{ $activity->user->name }}</div>
+                            </a>
+                        @else
+                            [ID: {{ $activity->user_id }}] {{ trans('common.deleted_user') }}
+                        @endif
+                    </td>
+                    <td>{{ $activity->key }}</td>
+                    <td>
+                        @if($activity->entity)
+                            <a href="{{ $activity->entity->getUrl() }}" class="icon-list-item">
+                                <span role="presentation" class="icon text-{{$activity->entity->getType()}}">@icon($activity->entity->getType())</span>
+                                <div class="text-{{ $activity->entity->getType() }}">
+                                    {{ $activity->entity->name }}
+                                </div>
+                            </a>
+                        @elseif($activity->extra)
+                            <div class="px-m">
+                                {{ trans('settings.audit_deleted_item') }} <br>
+                                {{ trans('settings.audit_deleted_item_name', ['name' => $activity->extra]) }}
+                            </div>
+                        @endif
+                    </td>
+                    <td>{{ $activity->created_at }}</td>
+                </tr>
+            @endforeach
+            </tbody>
+        </table>
+
+        {{ $activities->links() }}
+    </div>
+
+</div>
+@stop
index 896de9d97477c0c3e510ca806ed7e33cbf12e611..af8b2aaf7ee3b134e915511760a33d456905389f 100644 (file)
@@ -4,6 +4,9 @@
         <a href="{{ url('/settings') }}" @if($selected == 'settings') class="active" @endif>@icon('settings'){{ trans('settings.settings') }}</a>
         <a href="{{ url('/settings/maintenance') }}" @if($selected == 'maintenance') class="active" @endif>@icon('spanner'){{ trans('settings.maint') }}</a>
     @endif
+    @if($currentUser->can('settings-manage') && $currentUser->can('users-manage'))
+        <a href="{{ url('/settings/audit') }}" @if($selected == 'audit') class="active" @endif>@icon('open-book'){{ trans('settings.audit') }}</a>
+    @endif
     @if($currentUser->can('users-manage'))
         <a href="{{ url('/settings/users') }}" @if($selected == 'users') class="active" @endif>@icon('users'){{ trans('settings.users') }}</a>
     @endif
index 307c6f516feb962e14d25d01df4fdd972500a1dd..acbcb4e8fd4eb8f8e78002adc51584ec79306f02 100644 (file)
@@ -166,6 +166,9 @@ Route::group(['middleware' => 'auth'], function () {
         Route::delete('/maintenance/cleanup-images', 'MaintenanceController@cleanupImages');
         Route::post('/maintenance/send-test-email', 'MaintenanceController@sendTestEmail');
 
+        // Audit Log
+        Route::get('/audit', 'AuditLogController@index');
+
         // Users
         Route::get('/users', 'UserController@index');
         Route::get('/users/create', 'UserController@create');
diff --git a/tests/AuditLogTest.php b/tests/AuditLogTest.php
new file mode 100644 (file)
index 0000000..a2cdc33
--- /dev/null
@@ -0,0 +1,109 @@
+<?php namespace Tests;
+
+use BookStack\Actions\Activity;
+use BookStack\Actions\ActivityService;
+use BookStack\Auth\UserRepo;
+use BookStack\Entities\Page;
+use BookStack\Entities\Repos\PageRepo;
+use Carbon\Carbon;
+
+class AuditLogTest extends TestCase
+{
+
+    public function test_only_accessible_with_right_permissions()
+    {
+        $viewer = $this->getViewer();
+        $this->actingAs($viewer);
+
+        $resp = $this->get('/settings/audit');
+        $this->assertPermissionError($resp);
+
+        $this->giveUserPermissions($viewer, ['settings-manage']);
+        $resp = $this->get('/settings/audit');
+        $this->assertPermissionError($resp);
+
+        $this->giveUserPermissions($viewer, ['users-manage']);
+        $resp = $this->get('/settings/audit');
+        $resp->assertStatus(200);
+        $resp->assertSeeText('Audit Log');
+    }
+
+    public function test_shows_activity()
+    {
+        $admin = $this->getAdmin();
+        $this->actingAs($admin);
+        $page = Page::query()->first();
+        app(ActivityService::class)->add($page, 'page_create', $page->book->id);
+        $activity = Activity::query()->orderBy('id', 'desc')->first();
+
+        $resp = $this->get('settings/audit');
+        $resp->assertSeeText($page->name);
+        $resp->assertSeeText('page_create');
+        $resp->assertSeeText($activity->created_at->toDateTimeString());
+        $resp->assertElementContains('.audit-log-user', $admin->name);
+    }
+
+    public function test_shows_name_for_deleted_items()
+    {
+        $this->actingAs( $this->getAdmin());
+        $page = Page::query()->first();
+        $pageName = $page->name;
+        app(ActivityService::class)->add($page, 'page_create', $page->book->id);
+
+        app(PageRepo::class)->destroy($page);
+
+        $resp = $this->get('settings/audit');
+        $resp->assertSeeText('Deleted Item');
+        $resp->assertSeeText('Name: ' . $pageName);
+    }
+
+    public function test_shows_activity_for_deleted_users()
+    {
+        $viewer = $this->getViewer();
+        $this->actingAs($viewer);
+        $page = Page::query()->first();
+        app(ActivityService::class)->add($page, 'page_create', $page->book->id);
+
+        $this->actingAs($this->getAdmin());
+        app(UserRepo::class)->destroy($viewer);
+
+        $resp = $this->get('settings/audit');
+        $resp->assertSeeText("[ID: {$viewer->id}] Deleted User");
+    }
+
+    public function test_filters_by_key()
+    {
+        $this->actingAs($this->getAdmin());
+        $page = Page::query()->first();
+        app(ActivityService::class)->add($page, 'page_create', $page->book->id);
+
+        $resp = $this->get('settings/audit');
+        $resp->assertSeeText($page->name);
+
+        $resp = $this->get('settings/audit?event=page_delete');
+        $resp->assertDontSeeText($page->name);
+    }
+
+    public function test_date_filters()
+    {
+        $this->actingAs($this->getAdmin());
+        $page = Page::query()->first();
+        app(ActivityService::class)->add($page, 'page_create', $page->book->id);
+
+        $yesterday = (Carbon::now()->subDay()->format('Y-m-d'));
+        $tomorrow = (Carbon::now()->addDay()->format('Y-m-d'));
+
+        $resp = $this->get('settings/audit?date_from=' . $yesterday);
+        $resp->assertSeeText($page->name);
+
+        $resp = $this->get('settings/audit?date_from=' . $tomorrow);
+        $resp->assertDontSeeText($page->name);
+
+        $resp = $this->get('settings/audit?date_to=' . $tomorrow);
+        $resp->assertSeeText($page->name);
+
+        $resp = $this->get('settings/audit?date_to=' . $yesterday);
+        $resp->assertDontSeeText($page->name);
+    }
+
+}
\ No newline at end of file