From d67a04ebee3d5d76fb5e2a2c8693321cff7ea18c Mon Sep 17 00:00:00 2001 From: Kovah Date: Fri, 10 Jun 2022 12:11:03 +0200 Subject: [PATCH] Add audit logs for settings (#467) --- .../Modifiers/DarkmodeSettingModifier.php | 15 ++ .../Modifiers/DisplayModeSettingModifier.php | 16 ++ .../Modifiers/LocaleSettingModifier.php | 11 ++ app/Http/Controllers/App/AuditController.php | 20 +++ .../App/SystemSettingsController.php | 5 +- .../App/UserSettingsController.php | 7 +- app/Models/Setting.php | 81 +++++++++- app/View/Components/History/ListEntry.php | 4 +- app/View/Components/History/SettingsEntry.php | 98 ++++++++++++ app/View/Components/History/TagEntry.php | 4 +- lang/en_US/audit.php | 8 + lang/en_US/settings.php | 6 +- lang/en_US/user.php | 1 + resources/views/app/audit-logs.blade.php | 22 +++ .../partials/system/guest/dark-mode.blade.php | 2 +- .../partials/user/app-settings.blade.php | 4 +- .../user/app-settings/dark-mode.blade.php | 2 +- resources/views/partials/nav-user.blade.php | 3 + routes/web.php | 3 + .../Components/History/SettingsEntryTest.php | 140 ++++++++++++++++++ 20 files changed, 435 insertions(+), 17 deletions(-) create mode 100644 app/Audits/Modifiers/DarkmodeSettingModifier.php create mode 100644 app/Audits/Modifiers/DisplayModeSettingModifier.php create mode 100644 app/Audits/Modifiers/LocaleSettingModifier.php create mode 100644 app/Http/Controllers/App/AuditController.php create mode 100644 app/View/Components/History/SettingsEntry.php create mode 100644 lang/en_US/audit.php create mode 100644 resources/views/app/audit-logs.blade.php create mode 100644 tests/Components/History/SettingsEntryTest.php diff --git a/app/Audits/Modifiers/DarkmodeSettingModifier.php b/app/Audits/Modifiers/DarkmodeSettingModifier.php new file mode 100644 index 00000000..edf09452 --- /dev/null +++ b/app/Audits/Modifiers/DarkmodeSettingModifier.php @@ -0,0 +1,15 @@ + trans('settings.darkmode_disabled'), + 1 => trans('settings.darkmode_permanent'), + 2 => trans('settings.darkmode_auto'), + }; + } +} diff --git a/app/Audits/Modifiers/DisplayModeSettingModifier.php b/app/Audits/Modifiers/DisplayModeSettingModifier.php new file mode 100644 index 00000000..632d45c4 --- /dev/null +++ b/app/Audits/Modifiers/DisplayModeSettingModifier.php @@ -0,0 +1,16 @@ + trans('settings.display_mode_list_detailed'), + 1 => trans('settings.display_mode_cards'), + 2 => trans('settings.display_mode_list_simple'), + 3 => trans('settings.display_mode_cards_detailed'), + }; + } +} diff --git a/app/Audits/Modifiers/LocaleSettingModifier.php b/app/Audits/Modifiers/LocaleSettingModifier.php new file mode 100644 index 00000000..126a57c1 --- /dev/null +++ b/app/Audits/Modifiers/LocaleSettingModifier.php @@ -0,0 +1,11 @@ +with('auditable') + ->latest()->paginate(pageName: 'settings'); + + return view('app.audit-logs', [ + 'settings_history' => $settingsHistory, + ]); + } +} diff --git a/app/Http/Controllers/App/SystemSettingsController.php b/app/Http/Controllers/App/SystemSettingsController.php index 89b7782b..896f0ab1 100644 --- a/app/Http/Controllers/App/SystemSettingsController.php +++ b/app/Http/Controllers/App/SystemSettingsController.php @@ -52,20 +52,21 @@ class SystemSettingsController extends Controller if ($guestSharingSettings) { foreach (config('sharing.services') as $service => $details) { - $toggle = array_key_exists($service, $guestSharingSettings); + $toggle = (int)array_key_exists($service, $guestSharingSettings); Setting::updateOrCreate([ 'user_id' => null, 'key' => 'guest_share_' . $service, ], [ 'key' => 'guest_share_' . $service, - 'value' => $toggle, + 'value' => (string)$toggle, 'user_id' => null, ]); } } Cache::forget('systemsettings'); + Cache::forget('settings_keys'); flash(trans('settings.settings_saved')); return redirect()->route('get-systemsettings'); diff --git a/app/Http/Controllers/App/UserSettingsController.php b/app/Http/Controllers/App/UserSettingsController.php index 2e314e39..32ee412e 100644 --- a/app/Http/Controllers/App/UserSettingsController.php +++ b/app/Http/Controllers/App/UserSettingsController.php @@ -12,6 +12,7 @@ use Illuminate\Contracts\View\View; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; @@ -73,16 +74,18 @@ class UserSettingsController extends Controller $userServices = $userServices['share'] ?? []; foreach (config('sharing.services') as $service => $details) { - $toggle = array_key_exists($service, $userServices); + $toggle = (int)array_key_exists($service, $userServices); Setting::updateOrCreate([ 'user_id' => $userId, 'key' => 'share_' . $service, ], [ - 'value' => $toggle, + 'value' => (string)$toggle, ]); } + Cache::forget('settings_keys'); + flash(trans('settings.settings_saved'), 'success'); return redirect()->back(); } diff --git a/app/Models/Setting.php b/app/Models/Setting.php index ac8a24d5..8aeb6ed6 100644 --- a/app/Models/Setting.php +++ b/app/Models/Setting.php @@ -2,8 +2,16 @@ namespace App\Models; +use App\Audits\Modifiers\BooleanModifier; +use App\Audits\Modifiers\DarkmodeSettingModifier; +use App\Audits\Modifiers\DisplayModeSettingModifier; +use App\Audits\Modifiers\LocaleSettingModifier; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Support\Facades\Cache; +use OwenIt\Auditing\Auditable as AuditableTrait; +use OwenIt\Auditing\Contracts\Auditable; /** * Class Setting @@ -16,8 +24,10 @@ use Illuminate\Database\Eloquent\Model; * @method static Builder|Setting byUser($user_id) * @method static Builder|Setting systemOnly() */ -class Setting extends Model +class Setting extends Model implements Auditable { + use AuditableTrait; + public $timestamps = false; public $fillable = [ @@ -31,8 +41,53 @@ class Setting extends Model ]; /* - | ======================================================================== - | SCOPES + * ======================================================================== + * AUDIT SETTINGS + */ + + protected $auditEvents = [ + 'updated', + ]; + + public static array $auditModifiers = [ + 'archive_backups_enabled' => BooleanModifier::class, + 'archive_private_backups_enabled' => BooleanModifier::class, + 'darkmode_setting' => DarkmodeSettingModifier::class, + 'link_display_mode' => DisplayModeSettingModifier::class, + 'links_new_tab' => BooleanModifier::class, + 'links_private_default' => BooleanModifier::class, + 'lists_private_default' => BooleanModifier::class, + 'locale' => LocaleSettingModifier::class, + 'markdown_for_text' => BooleanModifier::class, + 'notes_private_default' => BooleanModifier::class, + 'private_default' => BooleanModifier::class, + 'share_service' => BooleanModifier::class, + 'system_guest_access' => BooleanModifier::class, + 'tags_private_default' => BooleanModifier::class, + ]; + + /** + * Instead of having 'value' as the changed field, use the actual settings + * key as the changed field. + * + * @param array $data + * @return array + */ + public function transformAudit(array $data): array + { + $keys = self::getSettingKeys(); + $key = $keys[$data['auditable_id']]; + + $data['old_values'][$key] = $data['old_values']['value']; + $data['new_values'][$key] = $data['new_values']['value']; + unset($data['old_values']['value'], $data['new_values']['value']); + + return $data; + } + + /* + * ======================================================================== + * SCOPES */ /** @@ -57,4 +112,24 @@ class Setting extends Model { return $query->whereNull('user_id'); } + + /* + * ======================================================================== + * RELATIONSHIPS + */ + + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } + + /* + * ======================================================================== + * METHODS + */ + + public static function getSettingKeys() + { + return Cache::rememberForever('settings_keys', fn() => Setting::get()->pluck('key', 'id')); + } } diff --git a/app/View/Components/History/ListEntry.php b/app/View/Components/History/ListEntry.php index 565075a7..485e056d 100644 --- a/app/View/Components/History/ListEntry.php +++ b/app/View/Components/History/ListEntry.php @@ -2,7 +2,7 @@ namespace App\View\Components\History; -use App\Models\Link; +use App\Models\LinkList; use Illuminate\View\Component; use OwenIt\Auditing\Models\Audit; @@ -71,7 +71,7 @@ class ListEntry extends Component $oldValue = $changeData['old'] ?? null; $newValue = $changeData['new'] ?? null; - /** @var Link $model */ + /** @var LinkList $model */ $model = app($this->entry->auditable_type); if (isset($model->auditModifiers[$field])) { diff --git a/app/View/Components/History/SettingsEntry.php b/app/View/Components/History/SettingsEntry.php new file mode 100644 index 00000000..28b48000 --- /dev/null +++ b/app/View/Components/History/SettingsEntry.php @@ -0,0 +1,98 @@ +entry->created_at); + + foreach ($this->entry->getModified() as $field => $change) { + $this->processChange($field, $change); + } + + return view('components.history-entry', [ + 'timestamp' => $timestamp, + 'changes' => $this->changes, + ]); + } + + protected function processChange(string $field, array $changeData): void + { + $fieldName = $this->processFieldName($field); + [$oldValue, $newValue] = $this->processValues($field, $changeData); + + $change = trans('linkace.history_changed', [ + 'fieldname' => $fieldName, + 'oldvalue' => htmlspecialchars($oldValue), + 'newvalue' => htmlspecialchars($newValue), + ]); + + $this->changes[] = $change; + } + + /** + * Change the field name appearance to make sure it is properly displayed + * in the audit log. + * All guest settings will get the 'guest_' prefix removed and prepend + * 'Guest Setting:' to the field name. + * If the setting of a user was changed, append this info to the field. + * + * @param string $field + * @return string + */ + protected function processFieldName(string $field) + { + if (str_starts_with($field, 'guest_')) { + $field = str_replace('guest_', '', $field); + $prepend = trans('settings.guest_settings') . ': '; + } + + if (str_starts_with($field, 'share_')) { + $service = str_replace('share_', '', $field); + return trans('settings.sharing') . ': ' . trans('sharing.service.' . $service); + } + + if ($this->entry->auditable->user_id !== null) { + $append = sprintf(' %s %s', trans('user.for_user'), $this->entry->auditable->user_id); + } + + return ($prepend ?? '') . trans('settings.' . $field) . ($append ?? ''); + } + + /** + * Apply specialized methods for different fields to handle particular + * formatting needs of these fields. + * + * @param string $field + * @param array $changeData + * @return array + */ + protected function processValues(string $field, array $changeData): array + { + $oldValue = $changeData['old'] ?? null; + $newValue = $changeData['new'] ?? null; + + if (str_contains($field, 'guest_share_') || str_contains($field, 'share_')) { + $field = 'share_service'; + } + + if (isset(Setting::$auditModifiers[$field])) { + $modifier = app(Setting::$auditModifiers[$field]); + $oldValue = $modifier->modify($oldValue); + $newValue = $modifier->modify($newValue); + return [$oldValue, $newValue]; + } + + return [$oldValue, $newValue]; + } +} diff --git a/app/View/Components/History/TagEntry.php b/app/View/Components/History/TagEntry.php index 379787af..0bbcaea6 100644 --- a/app/View/Components/History/TagEntry.php +++ b/app/View/Components/History/TagEntry.php @@ -2,7 +2,7 @@ namespace App\View\Components\History; -use App\Models\Link; +use App\Models\Tag; use Illuminate\View\Component; use OwenIt\Auditing\Models\Audit; @@ -71,7 +71,7 @@ class TagEntry extends Component $oldValue = $changeData['old'] ?? null; $newValue = $changeData['new'] ?? null; - /** @var Link $model */ + /** @var Tag $model */ $model = app($this->entry->auditable_type); if (isset($model->auditModifiers[$field])) { diff --git a/lang/en_US/audit.php b/lang/en_US/audit.php new file mode 100644 index 00000000..17ab3a52 --- /dev/null +++ b/lang/en_US/audit.php @@ -0,0 +1,8 @@ + 'Audit Log', + 'settings_history' => 'Settings History', + +]; diff --git a/lang/en_US/settings.php b/lang/en_US/settings.php index fbdeca49..f77a3e32 100644 --- a/lang/en_US/settings.php +++ b/lang/en_US/settings.php @@ -7,7 +7,7 @@ return [ 'system_settings' => 'System Settings', 'guest_settings' => 'Guest Settings', - 'language' => 'Language', + 'locale' => 'Language', 'timezone' => 'Timezone', 'date_format' => 'Date Format', 'time_format' => 'Time Format', @@ -34,6 +34,7 @@ return [ 'archive_private_backups_enabled' => 'Enable backups for private links', 'archive_private_backups_enabled_help' => 'If enabled, private links will also be saved. Backups must be enabled.', + 'link_display_mode' => 'Link Display Mode', 'display_mode' => 'Display links as', 'display_mode_list_detailed' => 'list with many details', 'display_mode_list_simple' => 'list with less details', @@ -41,10 +42,11 @@ return [ 'display_mode_cards_detailed' => 'cards with many details', 'sharing' => 'Link Sharing', + 'guest_sharing' => 'Guest Link Sharing', 'sharing_help' => 'Enable all services you want to display for links, to be able to share them easily with one click.', 'sharing_toggle' => 'Toggle all on/off', - 'darkmode' => 'Darkmode', + 'darkmode_setting' => 'Darkmode', 'darkmode_help' => 'You can either choose to turn on permanently or automatically based on your device settings. (Check here if your browser supports automatic detection)', 'darkmode_disabled' => 'Disabled', 'darkmode_auto' => 'Automatically', diff --git a/lang/en_US/user.php b/lang/en_US/user.php index 89b65c9c..98c89057 100644 --- a/lang/en_US/user.php +++ b/lang/en_US/user.php @@ -7,4 +7,5 @@ return [ 'email' => 'Email', 'hello' => 'Hello :user!', + 'for_user' => 'for User', ]; diff --git a/resources/views/app/audit-logs.blade.php b/resources/views/app/audit-logs.blade.php new file mode 100644 index 00000000..c8837e71 --- /dev/null +++ b/resources/views/app/audit-logs.blade.php @@ -0,0 +1,22 @@ +@extends('layouts.app') + +@section('content') + +
+
+ @lang('audit.settings_history') +
+
+ +
+ @foreach($settings_history as $entry) + + @endforeach +
+ + {!! $settings_history->onEachSide(1)->links() !!} + +
+
+ +@endsection diff --git a/resources/views/app/settings/partials/system/guest/dark-mode.blade.php b/resources/views/app/settings/partials/system/guest/dark-mode.blade.php index 48870dad..79e3575c 100644 --- a/resources/views/app/settings/partials/system/guest/dark-mode.blade.php +++ b/resources/views/app/settings/partials/system/guest/dark-mode.blade.php @@ -1,7 +1,7 @@
- @lang('settings.darkmode') + @lang('settings.darkmode_setting')

@lang('settings.darkmode_help')

diff --git a/resources/views/app/settings/partials/user/app-settings.blade.php b/resources/views/app/settings/partials/user/app-settings.blade.php index 39ff09fc..36e50f15 100644 --- a/resources/views/app/settings/partials/user/app-settings.blade.php +++ b/resources/views/app/settings/partials/user/app-settings.blade.php @@ -12,7 +12,7 @@
- @if ($errors->has('locale')) + @if($errors->has('locale')) diff --git a/resources/views/app/settings/partials/user/app-settings/dark-mode.blade.php b/resources/views/app/settings/partials/user/app-settings/dark-mode.blade.php index b5b9eaf0..c04aee99 100644 --- a/resources/views/app/settings/partials/user/app-settings/dark-mode.blade.php +++ b/resources/views/app/settings/partials/user/app-settings/dark-mode.blade.php @@ -1,7 +1,7 @@
- @lang('settings.darkmode') + @lang('settings.darkmode_setting')

@lang('settings.darkmode_help')

diff --git a/resources/views/partials/nav-user.blade.php b/resources/views/partials/nav-user.blade.php index d6adb00c..93c30c17 100644 --- a/resources/views/partials/nav-user.blade.php +++ b/resources/views/partials/nav-user.blade.php @@ -28,6 +28,9 @@ @lang('settings.system_settings') + + @lang('audit.log') + @lang('linkace.system_logs') diff --git a/routes/web.php b/routes/web.php index 41c6810d..500d71cf 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,5 +1,6 @@ ['auth']], function () { Route::get('system/logs', [LogViewerController::class, 'index']) ->name('system-logs'); + + Route::get('system/audit', AuditController::class)->name('system-audit'); }); // Guest access routes diff --git a/tests/Components/History/SettingsEntryTest.php b/tests/Components/History/SettingsEntryTest.php new file mode 100644 index 00000000..1149f64f --- /dev/null +++ b/tests/Components/History/SettingsEntryTest.php @@ -0,0 +1,140 @@ +create(['name' => 'TestUser']); + $this->actingAs($user); + } + + public function testStringSettingsChange(): void + { + $setting = Setting::create(['key' => 'timezone', 'value' => 'Europe/Berlin']); + $setting->update(['value' => 'UTC']); + + $historyEntry = Audit::where('auditable_type', Setting::class)->with('auditable')->latest()->first(); + + $output = (new SettingsEntry($historyEntry))->render(); + + $this->assertStringContainsString( + 'Changed Timezone from Europe/Berlin to UTC', + $output + ); + } + + public function testBooleanSettingsChange(): void + { + $setting = Setting::create(['key' => 'archive_backups_enabled', 'value' => true]); + $setting->update(['value' => false]); + + $historyEntry = Audit::where('auditable_type', Setting::class)->with('auditable')->latest()->first(); + + $output = (new SettingsEntry($historyEntry))->render(); + + $this->assertStringContainsString('Changed Enable backups from Yes to No', $output); + } + + public function testDarkmodeSettingsChange(): void + { + $setting = Setting::create(['key' => 'darkmode_setting', 'value' => 1]); + $setting->update(['value' => 2]); + + $historyEntry = Audit::where('auditable_type', Setting::class)->with('auditable')->latest()->first(); + + $output = (new SettingsEntry($historyEntry))->render(); + + $this->assertStringContainsString( + 'Changed Darkmode from Permanent to Automatically', + $output + ); + } + + public function testDisplayModeSettingsChange(): void + { + $setting = Setting::create(['key' => 'link_display_mode', 'value' => 1]); + $setting->update(['value' => 2]); + + $historyEntry = Audit::where('auditable_type', Setting::class)->with('auditable')->latest()->first(); + + $output = (new SettingsEntry($historyEntry))->render(); + + $this->assertStringContainsString( + 'Changed Link Display Mode from cards with less details to list with less details', + $output + ); + } + + public function testLocaleSettingsChange(): void + { + $setting = Setting::create(['key' => 'locale', 'value' => 'en_US']); + $setting->update(['value' => 'de_DE']); + + $historyEntry = Audit::where('auditable_type', Setting::class)->with('auditable')->latest()->first(); + + $output = (new SettingsEntry($historyEntry))->render(); + + $this->assertStringContainsString( + 'Changed Language from English to Deutsch', + $output + ); + } + + public function testSharingSettingsChange(): void + { + $setting = Setting::create(['key' => 'share_email', 'value' => true]); + $setting->update(['value' => false]); + + $historyEntry = Audit::where('auditable_type', Setting::class)->with('auditable')->latest()->first(); + + $output = (new SettingsEntry($historyEntry))->render(); + + $this->assertStringContainsString( + 'Changed Link Sharing: Email from Yes to No', + $output + ); + } + + public function testGuestSettingsChange(): void + { + $setting = Setting::create(['key' => 'guest_listitem_count', 'value' => 24]); + $setting->update(['value' => 60]); + + $historyEntry = Audit::where('auditable_type', Setting::class)->with('auditable')->latest()->first(); + + $output = (new SettingsEntry($historyEntry))->render(); + + $this->assertStringContainsString( + 'Changed Guest Settings: Number of Items in Lists from 24 to 60', + $output + ); + } + + public function testUserSettingsChange(): void + { + $setting = Setting::create(['key' => 'locale', 'value' => 'en_US', 'user_id' => 1]); + $setting->update(['value' => 'de_DE']); + + $historyEntry = Audit::where('auditable_type', Setting::class)->with('auditable')->latest()->first(); + + $output = (new SettingsEntry($historyEntry))->render(); + + $this->assertStringContainsString( + 'Changed Language for User 1 from English to Deutsch', + $output + ); + } +}