- Displays the currently tracked activities in the system.
Related to #2173 and #1167
/**
* 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;
--- /dev/null
+<?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,
+ ]);
+ }
+}
* 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
{
// 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';
}
--- /dev/null
+<?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');
+ });
+ }
+}
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"
"shelf-sort": shelfSort,
"sidebar": sidebar,
"sortable-list": sortableList,
+ "submit-on-change": submitOnChange,
"tabs": tabs,
"tag-manager": tagManager,
"template-manager": templateManager,
--- /dev/null
+/**
+ * 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
'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',
position: relative;
}
+.flex-container-row {
+ display: flex;
+ flex-direction: row;
+}
+
.flex-container-column {
display: flex;
flex-direction: column;
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
--- /dev/null
+@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
<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
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');
--- /dev/null
+<?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