, string> */ public const AUDITED = [ Page::class => 'pages', Category::class => 'categories', Project::class => 'projects', Member::class => 'members', Service::class => 'services', Client::class => 'clients', Testimonial::class => 'testimonials', Talk::class => 'talks', Technology::class => 'technologies', Call::class => 'calls', Application::class => 'applications', Redirect::class => 'redirects', Repository::class => 'repositories', Media::class => 'media', Setting::class => 'settings', User::class => 'users', ApiToken::class => 'tokens', Webhook::class => 'webhooks', ]; /** * Never written down, whatever changed. */ protected const SECRET = ['password', 'remember_token', 'token_hash', 'secret']; protected const IGNORED = ['created_at', 'updated_at', 'last_used_at', 'last_login_at']; /** * @param array|null $changes */ public static function record(string $action, ?Model $subject = null, ?array $changes = null, ?User $actor = null): void { $actor ??= Auth::user(); if (! $actor instanceof User) { return; } $request = app()->bound('request') ? request() : null; $route = $request?->route(); $token = $actor->currentAccessToken(); AuditLog::query()->create([ 'user_id' => $actor->id, 'user_name' => $actor->name, 'via' => self::via($route?->getName(), $token !== null), 'api_token_id' => $token?->id, 'ip' => $route ? $request->ip() : null, 'action' => $action, 'subject_type' => $subject ? (self::AUDITED[$subject::class] ?? class_basename($subject)) : null, 'subject_id' => $subject ? (string) $subject->getKey() : null, 'subject_label' => $subject ? Str::limit(self::label($subject), 250) : null, 'changes' => $changes ?: null, ]); } /** * From a model event: `pages.created`, `pages.updated` with what changed, * `pages.deleted`… */ public static function modelEvent(string $event, Model $model): void { $resource = self::AUDITED[$model::class] ?? null; // Bumped by every content edit, by the application: the edit itself is // what is logged. if ($resource === null || ($model instanceof Setting && $model->getKey() === ContentVersion::KEY)) { return; } $changes = match ($event) { 'created' => self::values($model->getAttributes()), 'updated' => self::diff($model), default => null, }; // Saved with nothing anyone would call a change. if ($event === 'updated' && $changes === []) { return; } self::record("{$resource}.{$event}", $model, $changes); } protected static function via(?string $routeName, bool $token): string { return match (true) { $token => 'token', $routeName !== null && str_starts_with($routeName, 'api.') => 'api', $routeName !== null && str_starts_with($routeName, 'filament.') => 'panel', $routeName !== null, app()->runningInConsole() === false => 'web', default => 'console', }; } /** * @return array */ protected static function diff(Model $model): array { $changes = []; foreach ($model->getChanges() as $attribute => $new) { if (in_array($attribute, self::IGNORED, true)) { continue; } $changes[$attribute] = in_array($attribute, self::SECRET, true) ? ['old' => '••••', 'new' => '••••'] : ['old' => self::value($model->getRawOriginal($attribute)), 'new' => self::value($model->getAttributes()[$attribute] ?? null)]; } return $changes; } /** * @param array $attributes * @return array */ protected static function values(array $attributes): array { $values = []; foreach ($attributes as $attribute => $value) { if (in_array($attribute, self::IGNORED, true) || $value === null) { continue; } $values[$attribute] = in_array($attribute, self::SECRET, true) ? '••••' : self::value($value); } return $values; } /** * Short enough to keep a log a log: what changed is here in outline, and * in full in the record's own history. */ protected static function value(mixed $value): mixed { if ($value instanceof \BackedEnum) { return $value->value; } if ($value instanceof \DateTimeInterface) { return $value->format('c'); } if (is_string($value)) { return Str::limit($value, 300); } if (is_array($value) || is_object($value)) { return Str::limit((string) json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 300); } return $value; } protected static function label(Model $model): string { foreach (['title', 'name', 'full_name', 'from', 'key', 'path', 'email'] as $attribute) { // Only real columns: `Repository::name()` is a method, and asking // for it as an attribute would treat it as a relation. if (! array_key_exists($attribute, $model->getAttributes())) { continue; } $value = $model->getAttribute($attribute); if (is_array($value)) { $value = $value[app()->getLocale()] ?? reset($value); } if (method_exists($model, 'isTranslatableAttribute') && $model->isTranslatableAttribute($attribute)) { $value = $model->getTranslation($attribute, app()->getLocale(), true); } if (filled($value) && is_scalar($value)) { return (string) $value; } } return (AdminContent::nameOf($model) ?? class_basename($model)).' #'.$model->getKey(); } }