Events are {resource}.created, .updated, .deleted and .restored for content,
the inbox, redirects, repositories and media, taken from the audit log so the
two agree on what happened. Subscriptions take wildcards. Users, tokens,
webhooks and settings are never sent, nor what the application does by
itself; the inbox's own words only with personal data enabled.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
235 lines
7.3 KiB
PHP
235 lines
7.3 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Support\Audit;
|
|
|
|
use App\Models\ApiToken;
|
|
use App\Models\Application;
|
|
use App\Models\AuditLog;
|
|
use App\Models\Call;
|
|
use App\Models\Category;
|
|
use App\Models\Client;
|
|
use App\Models\Media;
|
|
use App\Models\Member;
|
|
use App\Models\Page;
|
|
use App\Models\Project;
|
|
use App\Models\Redirect;
|
|
use App\Models\Repository;
|
|
use App\Models\Service;
|
|
use App\Models\Setting;
|
|
use App\Models\Tag;
|
|
use App\Models\Talk;
|
|
use App\Models\Technology;
|
|
use App\Models\Testimonial;
|
|
use App\Models\User;
|
|
use App\Models\Webhook;
|
|
use App\Support\AdminContent;
|
|
use App\Support\ContentVersion;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Str;
|
|
|
|
/**
|
|
* Writes the audit log.
|
|
*
|
|
* Only what a person did: a repository's numbers refreshed by the sync job, or
|
|
* a 404 reported by the frontend, are the application at work, and logging
|
|
* them would bury the entries anybody comes here for.
|
|
*/
|
|
final class Auditor
|
|
{
|
|
/**
|
|
* What is audited, by the name the entries use for it.
|
|
*
|
|
* @var array<class-string<Model>, string>
|
|
*/
|
|
public const AUDITED = [
|
|
Page::class => 'pages',
|
|
Category::class => 'categories',
|
|
Tag::class => 'tags',
|
|
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<string, mixed>|null $changes
|
|
*/
|
|
public static function record(string $action, ?Model $subject = null, ?array $changes = null, ?User $actor = null): ?AuditLog
|
|
{
|
|
$actor ??= Auth::user();
|
|
|
|
if (! $actor instanceof User) {
|
|
return null;
|
|
}
|
|
|
|
$request = app()->bound('request') ? request() : null;
|
|
$route = $request?->route();
|
|
$token = $actor->currentAccessToken();
|
|
|
|
return 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<string, array{old: mixed, new: mixed}>
|
|
*/
|
|
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<string, mixed> $attributes
|
|
* @return array<string, mixed>
|
|
*/
|
|
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();
|
|
}
|
|
}
|