1
0
mirror of https://github.com/Kovah/LinkAce.git synced 2025-01-17 13:18:21 +01:00

Add audit logs for settings (#467)

This commit is contained in:
Kovah 2022-06-10 12:11:03 +02:00
parent 18089253fe
commit d67a04ebee
No known key found for this signature in database
GPG Key ID: AAAA031BA9830D7B
20 changed files with 435 additions and 17 deletions

View File

@ -0,0 +1,15 @@
<?php
namespace App\Audits\Modifiers;
class DarkmodeSettingModifier implements ModifierInterface
{
public function modify($value): string
{
return match ((int)$value) {
0 => trans('settings.darkmode_disabled'),
1 => trans('settings.darkmode_permanent'),
2 => trans('settings.darkmode_auto'),
};
}
}

View File

@ -0,0 +1,16 @@
<?php
namespace App\Audits\Modifiers;
class DisplayModeSettingModifier implements ModifierInterface
{
public function modify($value): string
{
return match ((int)$value) {
0 => 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'),
};
}
}

View File

@ -0,0 +1,11 @@
<?php
namespace App\Audits\Modifiers;
class LocaleSettingModifier implements ModifierInterface
{
public function modify($value): string
{
return config('app.available_locales.' . $value);
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace App\Http\Controllers\App;
use App\Http\Controllers\Controller;
use App\Models\Setting;
use OwenIt\Auditing\Models\Audit;
class AuditController extends Controller
{
public function __invoke()
{
$settingsHistory = Audit::where('auditable_type', Setting::class)->with('auditable')
->latest()->paginate(pageName: 'settings');
return view('app.audit-logs', [
'settings_history' => $settingsHistory,
]);
}
}

View File

@ -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');

View File

@ -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();
}

View File

@ -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'));
}
}

View File

@ -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])) {

View File

@ -0,0 +1,98 @@
<?php
namespace App\View\Components\History;
use App\Models\Setting;
use Illuminate\View\Component;
use OwenIt\Auditing\Models\Audit;
class SettingsEntry extends Component
{
public function __construct(private Audit $entry, private array $changes = [])
{
}
public function render()
{
$timestamp = formatDateTime($this->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];
}
}

View File

@ -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])) {

8
lang/en_US/audit.php Normal file
View File

@ -0,0 +1,8 @@
<?php
return [
'log' => 'Audit Log',
'settings_history' => 'Settings History',
];

View File

@ -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. (<small>Check <a href="https://caniuse.com/#search=prefers-color-scheme">here</a> if your browser supports automatic detection</small>)',
'darkmode_disabled' => 'Disabled',
'darkmode_auto' => 'Automatically',

View File

@ -7,4 +7,5 @@ return [
'email' => 'Email',
'hello' => 'Hello :user!',
'for_user' => 'for User',
];

View File

@ -0,0 +1,22 @@
@extends('layouts.app')
@section('content')
<div class="card">
<div class="card-header">
@lang('audit.settings_history')
</div>
<div class="card-body">
<div class="history mb-6">
@foreach($settings_history as $entry)
<x-history.settings-entry :entry="$entry"/>
@endforeach
</div>
{!! $settings_history->onEachSide(1)->links() !!}
</div>
</div>
@endsection

View File

@ -1,7 +1,7 @@
<div class="mb-3 my-5">
<h5>
@lang('settings.darkmode')
@lang('settings.darkmode_setting')
</h5>
<p class="my-3 small">@lang('settings.darkmode_help')</p>

View File

@ -12,7 +12,7 @@
<div class="mb-4">
<label class="form-label" for="locale">
@lang('settings.language')
@lang('settings.locale')
</label>
<select id="locale" name="locale"
class="simple-select {{ $errors->has('locale') ? ' is-invalid' : '' }}">
@ -23,7 +23,7 @@
</option>
@endforeach
</select>
@if ($errors->has('locale'))
@if($errors->has('locale'))
<p class="invalid-feedback" role="alert">
{{ $errors->first('locale') }}
</p>

View File

@ -1,7 +1,7 @@
<div class="mb-3 my-5">
<h5>
@lang('settings.darkmode')
@lang('settings.darkmode_setting')
</h5>
<p class="my-3 small">@lang('settings.darkmode_help')</p>

View File

@ -28,6 +28,9 @@
<a href="{{ route('get-systemsettings') }}" class="dropdown-item">
@lang('settings.system_settings')
</a>
<a href="{{ route('system-audit') }}" class="dropdown-item">
@lang('audit.log')
</a>
<a href="{{ route('system-logs') }}" class="dropdown-item">
@lang('linkace.system_logs')
</a>

View File

@ -1,5 +1,6 @@
<?php
use App\Http\Controllers\App\AuditController;
use App\Http\Controllers\App\BookmarkletController;
use App\Http\Controllers\App\DashboardController;
use App\Http\Controllers\App\ExportController;
@ -136,6 +137,8 @@ Route::group(['middleware' => ['auth']], function () {
Route::get('system/logs', [LogViewerController::class, 'index'])
->name('system-logs');
Route::get('system/audit', AuditController::class)->name('system-audit');
});
// Guest access routes

View File

@ -0,0 +1,140 @@
<?php
namespace Tests\Components\History;
use App\Models\Setting;
use App\Models\User;
use App\View\Components\History\SettingsEntry;
use Illuminate\Foundation\Testing\RefreshDatabase;
use OwenIt\Auditing\Models\Audit;
use Tests\TestCase;
class SettingsEntryTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$user = User::factory()->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 <code>Europe/Berlin</code> to <code>UTC</code>',
$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 <code>Yes</code> to <code>No</code>', $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 <code>Permanent</code> to <code>Automatically</code>',
$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 <code>cards with less details</code> to <code>list with less details</code>',
$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 <code>English</code> to <code>Deutsch</code>',
$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 <code>Yes</code> to <code>No</code>',
$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 <code>24</code> to <code>60</code>',
$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 <code>English</code> to <code>Deutsch</code>',
$output
);
}
}