diff --git a/db/migrations/2023_12_06_000000_add_user_id_to_log_entries.php b/db/migrations/2023_12_06_000000_add_user_id_to_log_entries.php
new file mode 100644
index 00000000..a3bd8029
--- /dev/null
+++ b/db/migrations/2023_12_06_000000_add_user_id_to_log_entries.php
@@ -0,0 +1,38 @@
+schema->table('log_entries', function (Blueprint $table): void {
+ $table->unsignedInteger('user_id')->after('id')->nullable()->default(null);
+ $table->foreign('user_id')
+ ->references('id')->on('users')
+ ->onUpdate('cascade')
+ ->nullOnDelete();
+ });
+ }
+
+ /**
+ * Reverse the migration
+ */
+ public function down(): void
+ {
+ $this->schema->table('log_entries', function (Blueprint $table): void {
+ $table->dropForeign('log_entries_user_id_foreign');
+ $table->dropColumn('user_id');
+ });
+ }
+}
diff --git a/db/migrations/2023_12_06_000001_add_logs_all_permission.php b/db/migrations/2023_12_06_000001_add_logs_all_permission.php
new file mode 100644
index 00000000..41a2df79
--- /dev/null
+++ b/db/migrations/2023_12_06_000001_add_logs_all_permission.php
@@ -0,0 +1,44 @@
+schema->getConnection();
+ $db->table('privileges')
+ ->insert([
+ ['name' => 'logs.all', 'description' => 'View all logs'],
+ ]);
+
+ $logsAll = $db->table('privileges')
+ ->where('name', 'logs.all')
+ ->get(['id'])
+ ->first();
+
+ $bureaucrat = 80;
+ $db->table('group_privileges')
+ ->insertOrIgnore([
+ ['group_id' => $bureaucrat, 'privilege_id' => $logsAll->id],
+ ]);
+ }
+
+ /**
+ * Reverse the migration
+ */
+ public function down(): void
+ {
+ $db = $this->schema->getConnection();
+ $db->table('privileges')
+ ->where('name', 'logs.all')
+ ->delete();
+ }
+}
diff --git a/db/migrations/Reference.php b/db/migrations/Reference.php
index 7f502f3a..0a242e9b 100644
--- a/db/migrations/Reference.php
+++ b/db/migrations/Reference.php
@@ -30,7 +30,7 @@ trait Reference
$table->primary($fromColumn);
}
- $this->addReference($table, $fromColumn, $targetTable, $targetColumn ?: 'id');
+ $this->addReference($table, $fromColumn, $targetTable, $targetColumn);
return $col;
}
diff --git a/resources/lang/de_DE/default.po b/resources/lang/de_DE/default.po
index 2899b481..c9ca1961 100644
--- a/resources/lang/de_DE/default.po
+++ b/resources/lang/de_DE/default.po
@@ -1407,6 +1407,9 @@ msgstr "Du kannst hier Markdown verwenden"
msgid "form.required"
msgstr "Pflichtfeld"
+msgid "form.user_select"
+msgstr "Wähle einen User"
+
msgid "schedule.import"
msgstr "Programm importieren"
@@ -1531,6 +1534,9 @@ msgstr "Suchen"
msgid "log.log"
msgstr "Logs"
+msgid "log.only_own"
+msgstr "Du siehst hier deine eigenen Logs. Die logs anderer User können nur Bürokraten sehen."
+
msgid "log.time"
msgstr "Zeit"
diff --git a/resources/lang/en_US/default.po b/resources/lang/en_US/default.po
index 06c97b45..f7891cb1 100644
--- a/resources/lang/en_US/default.po
+++ b/resources/lang/en_US/default.po
@@ -115,6 +115,9 @@ msgstr "Required"
msgid "form.markdown"
msgstr "You can use Markdown here"
+msgid "form.user_select"
+msgstr "Select a user"
+
msgid "schedule.import"
msgstr "Import schedule"
@@ -244,6 +247,9 @@ msgstr "Search"
msgid "log.log"
msgstr "Logs"
+msgid "log.only_own"
+msgstr "You can view your own logs. The logs of other users can be checked by bureaucrats."
+
msgid "log.time"
msgstr "Time"
diff --git a/resources/views/admin/log.twig b/resources/views/admin/log.twig
index b4a76da8..87ea55cb 100644
--- a/resources/views/admin/log.twig
+++ b/resources/views/admin/log.twig
@@ -6,7 +6,7 @@
{% block content %}
-
{{ block('title') }}
+
{{ block('title') }} ({{ entries|length }})
+ {% if not has_permission_to('logs.all') %}
+
+ {{ m.alert(__('log.only_own')) }}
+
+ {% endif %}
+
{{ __('log.time') }} |
{{ __('log.level') }} |
+ {{ __('general.user') }} |
{{ __('log.message') }} |
{% for entry in entries %}
@@ -47,10 +67,11 @@
{%- endif %}
- {{ entry.created_at.format(__('general.datetime')) }} |
-
+ | {{ entry.created_at.format(__('general.datetime')) }} |
+
{{ entry.level|capitalize }}
|
+ {% if entry.user %}{{ m.user(entry.user) }}{% endif %} |
{{ entry.message|nl2br }} |
{% endfor %}
diff --git a/src/Controllers/Admin/LogsController.php b/src/Controllers/Admin/LogsController.php
index 77469238..10f25240 100644
--- a/src/Controllers/Admin/LogsController.php
+++ b/src/Controllers/Admin/LogsController.php
@@ -5,9 +5,12 @@ declare(strict_types=1);
namespace Engelsystem\Controllers\Admin;
use Engelsystem\Controllers\BaseController;
+use Engelsystem\Helpers\Authenticator;
use Engelsystem\Http\Request;
use Engelsystem\Http\Response;
use Engelsystem\Models\LogEntry;
+use Engelsystem\Models\User\User;
+use Illuminate\Support\Collection;
class LogsController extends BaseController
{
@@ -16,18 +19,33 @@ class LogsController extends BaseController
'admin_log',
];
- public function __construct(protected LogEntry $log, protected Response $response)
+ public function __construct(protected LogEntry $log, protected Response $response, protected Authenticator $auth)
{
}
public function index(Request $request): Response
{
+ $searchUserId = (int) $request->input('search_user_id') ?: null;
$search = $request->input('search');
- $entries = $this->log->filter($search);
+ $userId = $this->auth->user()?->id;
+
+ if ($this->auth->can('logs.all')) {
+ $userId = $searchUserId;
+ }
+
+ $entries = $this->log->filter($search, $userId);
+
+ /** @var Collection $users */
+ $users = User::with('personalData')
+ ->orderBy('name')
+ ->get()
+ ->mapWithKeys(function (User $u) {
+ return [$u->id => $u->displayName];
+ });
return $this->response->withView(
'admin/log.twig',
- ['entries' => $entries, 'search' => $search]
+ ['entries' => $entries, 'search' => $search, 'users' => $users, 'search_user_id' => $searchUserId]
);
}
}
diff --git a/src/Logger/Logger.php b/src/Logger/Logger.php
index 2b5bcdbe..f697a2a8 100644
--- a/src/Logger/Logger.php
+++ b/src/Logger/Logger.php
@@ -44,7 +44,7 @@ class Logger extends AbstractLogger
$message .= $this->formatException($context['exception']);
}
- $this->log->create(['level' => $level, 'message' => $message]);
+ $this->createEntry(['level' => $level, 'message' => $message]);
}
/**
@@ -81,4 +81,9 @@ class Logger extends AbstractLogger
{
return in_array($level, $this->allowedLevels);
}
+
+ protected function createEntry(array $data): void
+ {
+ $this->log->create($data);
+ }
}
diff --git a/src/Logger/UserAwareLogger.php b/src/Logger/UserAwareLogger.php
index 7d40c0d7..dc4f187b 100644
--- a/src/Logger/UserAwareLogger.php
+++ b/src/Logger/UserAwareLogger.php
@@ -5,24 +5,21 @@ declare(strict_types=1);
namespace Engelsystem\Logger;
use Engelsystem\Helpers\Authenticator;
-use Psr\Log\InvalidArgumentException;
-use Stringable;
class UserAwareLogger extends Logger
{
- protected Authenticator $auth;
+ protected ?Authenticator $auth;
/**
- * Logs with an arbitrary level and prepends the user
- * @throws InvalidArgumentException
+ * Adds the authenticated user to the log message
*/
- public function log(mixed $level, string|Stringable $message, array $context = []): void
+ public function createEntry(array $data): void
{
if ($this->auth && ($user = $this->auth->user())) {
- $message = sprintf('%s (%u): %s', $user->name, $user->id, $message);
+ $data['user_id'] = $user->id;
}
- parent::log($level, $message, $context);
+ parent::createEntry($data);
}
public function setAuth(Authenticator $auth): void
diff --git a/src/Models/LogEntry.php b/src/Models/LogEntry.php
index f8577f85..ce53f70c 100644
--- a/src/Models/LogEntry.php
+++ b/src/Models/LogEntry.php
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Engelsystem\Models;
use Carbon\Carbon;
+use Engelsystem\Models\User\UsesUserModel;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Query\Builder as QueryBuilder;
@@ -23,35 +24,48 @@ use Illuminate\Support\Collection as SupportCollection;
*/
class LogEntry extends BaseModel
{
+ use UsesUserModel;
+
/** @var bool enable timestamps for created_at */
public $timestamps = true; // phpcs:ignore
/** @var null Disable updated_at */
public const UPDATED_AT = null;
+ /** @var array */
+ protected $casts = [ // phpcs:ignore
+ 'user_id' => 'integer',
+ ];
+
/**
* The attributes that are mass assignable.
*/
protected $fillable = [ // phpcs:ignore
'level',
'message',
+ 'user_id',
];
/**
* @return Builder[]|Collection|SupportCollection|LogEntry[]
*/
- public static function filter(string $keyword = null): array|Collection|SupportCollection
+ public static function filter(?string $keyword = null, ?int $userId = null): array|Collection|SupportCollection
{
- $query = self::query()
- ->select()
+ $query = self::with(['user', 'user.personalData', 'user.state'])
->orderByDesc('created_at')
->orderByDesc('id')
->limit(10000);
+ if (!empty($userId)) {
+ $query->where('user_id', $userId);
+ }
+
if (!empty($keyword)) {
$query
- ->where('level', '=', $keyword)
- ->orWhere('message', 'LIKE', '%' . $keyword . '%');
+ ->where(function (Builder $query) use ($keyword): void {
+ $query->where('level', '=', $keyword)
+ ->orWhere('message', 'LIKE', '%' . $keyword . '%');
+ });
}
return $query->get();
diff --git a/tests/Unit/Controllers/Admin/LogsControllerTest.php b/tests/Unit/Controllers/Admin/LogsControllerTest.php
index 9034cd1c..9cc7f9ad 100644
--- a/tests/Unit/Controllers/Admin/LogsControllerTest.php
+++ b/tests/Unit/Controllers/Admin/LogsControllerTest.php
@@ -4,10 +4,13 @@ declare(strict_types=1);
namespace Engelsystem\Test\Unit\Controllers\Admin;
+use Engelsystem\Config\Config;
use Engelsystem\Controllers\Admin\LogsController;
+use Engelsystem\Helpers\Authenticator;
use Engelsystem\Http\Request;
use Engelsystem\Http\Response;
use Engelsystem\Models\LogEntry;
+use Engelsystem\Models\User\User;
use Engelsystem\Test\Unit\HasDatabase;
use Engelsystem\Test\Unit\TestCase;
use Illuminate\Database\Eloquent\Collection;
@@ -25,22 +28,36 @@ class LogsControllerTest extends TestCase
{
$log = new LogEntry();
$alert = $log->create(['level' => LogLevel::ALERT, 'message' => 'Alert test']);
- $alert = $log->find($alert)->first();
+ $alert = $log->with('user')->find($alert)->first();
$error = $log->create(['level' => LogLevel::ERROR, 'message' => 'Error test']);
- $error = $log->find($error)->first();
+ $error = $log->with('user')->find($error)->first();
+
+ $auth = $this->createMock(Authenticator::class);
+ $this->setExpects($auth, 'user', null, null, 2);
+ $this->setExpects($auth, 'can', ['logs.all'], true, 2);
$response = $this->createMock(Response::class);
$response->expects($this->exactly(2))
->method('withView')
->withConsecutive(
- ['admin/log.twig', ['entries' => new Collection([$error, $alert]), 'search' => null]],
- ['admin/log.twig', ['entries' => new Collection([$error]), 'search' => 'error']]
+ ['admin/log.twig', [
+ 'entries' => new Collection([$error, $alert]),
+ 'search' => null,
+ 'users' => new Collection(),
+ 'search_user_id' => null,
+ ]],
+ ['admin/log.twig', [
+ 'entries' => new Collection([$error]),
+ 'search' => 'error',
+ 'users' => new Collection(),
+ 'search_user_id' => null,
+ ]]
)
->willReturn($response);
$request = Request::create('/');
- $controller = new LogsController($log, $response);
+ $controller = new LogsController($log, $response, $auth);
$controller->index($request);
$request->request->set('search', 'error');
@@ -48,12 +65,48 @@ class LogsControllerTest extends TestCase
}
/**
- * Setup the DB
+ * @covers \Engelsystem\Controllers\Admin\LogsController::index
+ */
+ public function testIndexUser(): void
+ {
+ User::factory()->create();
+ $user = User::with(['personalData', 'state'])->first();
+
+ $log = new LogEntry();
+ $alert = $log->create(['level' => LogLevel::ALERT, 'message' => 'Users message', 'user_id' => $user->id]);
+ /** @var LogEntry $alert */
+ $alert = $log->with('user')->find($alert)->first();
+ $log->create(['level' => LogLevel::ERROR, 'message' => 'Error test']);
+
+ $auth = $this->createMock(Authenticator::class);
+ $this->setExpects($auth, 'user', null, $user);
+ $this->setExpects($auth, 'can', ['logs.all'], false);
+
+ $response = $this->createMock(Response::class);
+ $response->expects($this->once())
+ ->method('withView')
+ ->willReturnCallback(function (string $view, array $data) use ($alert, $response) {
+ $this->assertEquals('admin/log.twig', $view);
+ $this->assertArrayHasKey('entries', $data);
+ $this->assertCount(1, $data['entries']);
+ $this->assertEquals($alert->message, $data['entries'][0]['message']);
+ return $response;
+ });
+
+ $request = Request::create('/');
+
+ $controller = new LogsController($log, $response, $auth);
+ $controller->index($request);
+ }
+
+ /**
+ * Set up the DB
*/
public function setUp(): void
{
parent::setUp();
$this->initDatabase();
+ $this->app->instance('config', new Config([]));
}
}
diff --git a/tests/Unit/Logger/LoggerTest.php b/tests/Unit/Logger/LoggerTest.php
index 329a8e1b..277b4ba3 100644
--- a/tests/Unit/Logger/LoggerTest.php
+++ b/tests/Unit/Logger/LoggerTest.php
@@ -45,6 +45,7 @@ class LoggerTest extends ServiceProviderTest
/**
* @covers \Engelsystem\Logger\Logger::log
+ * @covers \Engelsystem\Logger\Logger::createEntry
* @dataProvider provideLogLevels
*/
public function testAllLevels(string $level): void
diff --git a/tests/Unit/Logger/UserAwareLoggerTest.php b/tests/Unit/Logger/UserAwareLoggerTest.php
index eeadeed0..30444547 100644
--- a/tests/Unit/Logger/UserAwareLoggerTest.php
+++ b/tests/Unit/Logger/UserAwareLoggerTest.php
@@ -8,18 +8,23 @@ use Engelsystem\Helpers\Authenticator;
use Engelsystem\Logger\UserAwareLogger;
use Engelsystem\Models\LogEntry;
use Engelsystem\Models\User\User;
-use Engelsystem\Test\Unit\ServiceProviderTest;
+use Engelsystem\Test\Unit\HasDatabase;
+use Engelsystem\Test\Unit\TestCase;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\Log\LogLevel;
-class UserAwareLoggerTest extends ServiceProviderTest
+class UserAwareLoggerTest extends TestCase
{
+ use HasDatabase;
+
/**
- * @covers \Engelsystem\Logger\UserAwareLogger::log
+ * @covers \Engelsystem\Logger\UserAwareLogger::createEntry
* @covers \Engelsystem\Logger\UserAwareLogger::setAuth
*/
public function testLog(): void
{
+ $this->initDatabase(); // To be able to run the test by itself
+
$user = User::factory(['id' => 1, 'name' => 'admin'])->make();
/** @var LogEntry|MockObject $logEntry */
@@ -30,7 +35,7 @@ class UserAwareLoggerTest extends ServiceProviderTest
->method('create')
->withConsecutive(
[['level' => LogLevel::INFO, 'message' => 'Some more informational foo']],
- [['level' => LogLevel::INFO, 'message' => 'admin (1): Some even more informational bar']]
+ [['level' => LogLevel::INFO, 'message' => 'Some even more informational bar', 'user_id' => 1]]
);
/** @var Authenticator|MockObject $auth */
diff --git a/tests/Unit/Models/LogEntryTest.php b/tests/Unit/Models/LogEntryTest.php
index 4aca5d83..944d6ec3 100644
--- a/tests/Unit/Models/LogEntryTest.php
+++ b/tests/Unit/Models/LogEntryTest.php
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Engelsystem\Test\Unit\Models;
use Engelsystem\Models\LogEntry;
+use Engelsystem\Models\User\User;
use Psr\Log\LogLevel;
class LogEntryTest extends ModelTest
@@ -14,6 +15,8 @@ class LogEntryTest extends ModelTest
*/
public function testFilter(): void
{
+ $user = User::factory()->create();
+ (new LogEntry(['level' => LogLevel::DEBUG, 'message' => 'Some users fault', 'user_id' => $user->id]))->save();
foreach (
[
'I\'m an info' => LogLevel::INFO,
@@ -31,9 +34,10 @@ class LogEntryTest extends ModelTest
(new LogEntry(['level' => $level, 'message' => $message]))->save();
}
- $this->assertCount(10, LogEntry::filter());
+ $this->assertCount(11, LogEntry::filter());
$this->assertCount(3, LogEntry::filter(LogLevel::INFO));
$this->assertCount(1, LogEntry::filter('Oops'));
+ $this->assertCount(1, LogEntry::filter(null, $user->id));
/** @var LogEntry $first */
$first = LogEntry::filter()->first();