diff --git a/app/Facades/Activity.php b/app/Facades/Activity.php index 3dfb49aa1..86e2bd31c 100644 --- a/app/Facades/Activity.php +++ b/app/Facades/Activity.php @@ -10,11 +10,10 @@ use Pterodactyl\Services\Activity\ActivityLogService; * @method static ActivityLogService anonymous() * @method static ActivityLogService event(string $action) * @method static ActivityLogService description(?string $description) - * @method static ActivityLogService subject(Model $subject) + * @method static ActivityLogService subject(Model|Model[] $subject) * @method static ActivityLogService actor(Model $actor) - * @method static ActivityLogService withProperties(\Illuminate\Support\Collection|array $properties) * @method static ActivityLogService withRequestMetadata() - * @method static ActivityLogService property(string $key, mixed $value) + * @method static ActivityLogService property(string|array $key, mixed $value = null) * @method static \Pterodactyl\Models\ActivityLog log(string $description = null) * @method static ActivityLogService clone() * @method static mixed transaction(\Closure $callback) diff --git a/app/Http/Controllers/Api/Client/Servers/BackupController.php b/app/Http/Controllers/Api/Client/Servers/BackupController.php index 67b54cd28..bfd9b68ba 100644 --- a/app/Http/Controllers/Api/Client/Servers/BackupController.php +++ b/app/Http/Controllers/Api/Client/Servers/BackupController.php @@ -5,8 +5,8 @@ namespace Pterodactyl\Http\Controllers\Api\Client\Servers; use Illuminate\Http\Request; use Pterodactyl\Models\Backup; use Pterodactyl\Models\Server; -use Pterodactyl\Models\AuditLog; use Illuminate\Http\JsonResponse; +use Pterodactyl\Facades\Activity; use Pterodactyl\Models\Permission; use Illuminate\Auth\Access\AuthorizationException; use Pterodactyl\Services\Backups\DeleteBackupService; @@ -77,25 +77,23 @@ class BackupController extends ClientApiController */ public function store(StoreBackupRequest $request, Server $server): array { - /** @var \Pterodactyl\Models\Backup $backup */ - $backup = $server->audit(AuditLog::SERVER__BACKUP_STARTED, function (AuditLog $model, Server $server) use ($request) { - $action = $this->initiateBackupService - ->setIgnoredFiles(explode(PHP_EOL, $request->input('ignored') ?? '')); + $action = $this->initiateBackupService + ->setIgnoredFiles(explode(PHP_EOL, $request->input('ignored') ?? '')); - // Only set the lock status if the user even has permission to delete backups, - // otherwise ignore this status. This gets a little funky since it isn't clear - // how best to allow a user to create a backup that is locked without also preventing - // them from just filling up a server with backups that can never be deleted? - if ($request->user()->can(Permission::ACTION_BACKUP_DELETE, $server)) { - $action->setIsLocked((bool) $request->input('is_locked')); - } + // Only set the lock status if the user even has permission to delete backups, + // otherwise ignore this status. This gets a little funky since it isn't clear + // how best to allow a user to create a backup that is locked without also preventing + // them from just filling up a server with backups that can never be deleted? + if ($request->user()->can(Permission::ACTION_BACKUP_DELETE, $server)) { + $action->setIsLocked((bool) $request->input('is_locked')); + } - $backup = $action->handle($server, $request->input('name')); + $backup = $action->handle($server, $request->input('name')); - $model->metadata = ['backup_uuid' => $backup->uuid]; - - return $backup; - }); + Activity::event('server:backup.start') + ->subject($backup) + ->property(['name' => $backup->name, 'locked' => (bool) $request->input('is_locked')]) + ->log(); return $this->fractal->item($backup) ->transformWith($this->getTransformer(BackupTransformer::class)) @@ -114,14 +112,11 @@ class BackupController extends ClientApiController throw new AuthorizationException(); } - $action = $backup->is_locked ? AuditLog::SERVER__BACKUP_UNLOCKED : AuditLog::SERVER__BACKUP_LOCKED; - $server->audit($action, function (AuditLog $audit) use ($backup) { - $audit->metadata = ['backup_uuid' => $backup->uuid]; + $action = $backup->is_locked ? 'server:backup.unlock' : 'server:backup.lock'; - $backup->update(['is_locked' => !$backup->is_locked]); - }); + $backup->update(['is_locked' => !$backup->is_locked]); - $backup->refresh(); + Activity::event($action)->subject($backup)->property('name', $backup->name)->log(); return $this->fractal->item($backup) ->transformWith($this->getTransformer(BackupTransformer::class)) @@ -156,11 +151,12 @@ class BackupController extends ClientApiController throw new AuthorizationException(); } - $server->audit(AuditLog::SERVER__BACKUP_DELETED, function (AuditLog $audit) use ($backup) { - $audit->metadata = ['backup_uuid' => $backup->uuid]; + $this->deleteBackupService->handle($backup); - $this->deleteBackupService->handle($backup); - }); + Activity::event('server:backup.delete') + ->subject($backup) + ->property(['name' => $backup->name, 'failed' => !$backup->is_successful]) + ->log(); return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT); } @@ -184,9 +180,8 @@ class BackupController extends ClientApiController } $url = $this->downloadLinkService->handle($backup, $request->user()); - $server->audit(AuditLog::SERVER__BACKUP_DOWNLOADED, function (AuditLog $audit) use ($backup) { - $audit->metadata = ['backup_uuid' => $backup->uuid]; - }); + + Activity::event('server:backup.download')->subject($backup)->property('name', $backup->name)->log(); return new JsonResponse([ 'object' => 'signed_url', @@ -221,9 +216,11 @@ class BackupController extends ClientApiController throw new BadRequestHttpException('This backup cannot be restored at this time: not completed or failed.'); } - $server->audit(AuditLog::SERVER__BACKUP_RESTORE_STARTED, function (AuditLog $audit, Server $server) use ($backup, $request) { - $audit->metadata = ['backup_uuid' => $backup->uuid]; + $log = Activity::event('server:backup.restore') + ->subject($backup) + ->property(['name' => $backup->name, 'truncate' => $request->input('truncate')]); + $log->transaction(function () use ($backup, $server, $request) { // If the backup is for an S3 file we need to generate a unique Download link for // it that will allow Wings to actually access the file. if ($backup->disk === Backup::ADAPTER_AWS_S3) { diff --git a/app/Http/Controllers/Api/Remote/Backups/BackupStatusController.php b/app/Http/Controllers/Api/Remote/Backups/BackupStatusController.php index f4e7bd6ea..17f62329f 100644 --- a/app/Http/Controllers/Api/Remote/Backups/BackupStatusController.php +++ b/app/Http/Controllers/Api/Remote/Backups/BackupStatusController.php @@ -5,9 +5,8 @@ namespace Pterodactyl\Http\Controllers\Api\Remote\Backups; use Carbon\CarbonImmutable; use Illuminate\Http\Request; use Pterodactyl\Models\Backup; -use Pterodactyl\Models\Server; -use Pterodactyl\Models\AuditLog; use Illuminate\Http\JsonResponse; +use Pterodactyl\Facades\Activity; use League\Flysystem\AwsS3v3\AwsS3Adapter; use Pterodactyl\Exceptions\DisplayException; use Pterodactyl\Http\Controllers\Controller; @@ -46,15 +45,12 @@ class BackupStatusController extends Controller throw new BadRequestHttpException('Cannot update the status of a backup that is already marked as completed.'); } - $action = $request->input('successful') - ? AuditLog::SERVER__BACKUP_COMPELTED - : AuditLog::SERVER__BACKUP_FAILED; - - $model->server->audit($action, function (AuditLog $audit) use ($model, $request) { - $audit->is_system = true; - $audit->metadata = ['backup_uuid' => $model->uuid]; + $action = $request->boolean('successful') ? 'server:backup.complete' : 'server:backup.failed'; + $log = Activity::event($action)->subject($model, $model->server)->property('name', $model->name); + $log->transaction(function () use ($model, $request) { $successful = $request->boolean('successful'); + $model->fill([ 'is_successful' => $successful, // Change the lock state to unlocked if this was a failed backup so that it can be @@ -93,17 +89,13 @@ class BackupStatusController extends Controller { /** @var \Pterodactyl\Models\Backup $model */ $model = Backup::query()->where('uuid', $backup)->firstOrFail(); - $action = $request->get('successful') - ? AuditLog::SERVER__BACKUP_RESTORE_COMPLETED - : AuditLog::SERVER__BACKUP_RESTORE_FAILED; - // Just create a new audit entry for this event and update the server state - // so that power actions, file management, and backups can resume as normal. - $model->server->audit($action, function (AuditLog $audit, Server $server) use ($backup) { - $audit->is_system = true; - $audit->metadata = ['backup_uuid' => $backup]; - $server->update(['status' => null]); - }); + $model->server->update(['status' => null]); + + Activity::event($request->boolean('successful') ? 'server:backup.restore-complete' : 'server.backup.restore-failed') + ->subject($model, $model->server) + ->property('name', $model->name) + ->log(); return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT); } diff --git a/app/Http/Controllers/Api/Remote/Servers/ServerDetailsController.php b/app/Http/Controllers/Api/Remote/Servers/ServerDetailsController.php index 6b49fafa3..0e93c60ca 100644 --- a/app/Http/Controllers/Api/Remote/Servers/ServerDetailsController.php +++ b/app/Http/Controllers/Api/Remote/Servers/ServerDetailsController.php @@ -4,8 +4,10 @@ namespace Pterodactyl\Http\Controllers\Api\Remote\Servers; use Illuminate\Http\Request; use Pterodactyl\Models\Server; +use Pterodactyl\Models\Backup; use Pterodactyl\Models\AuditLog; use Illuminate\Http\JsonResponse; +use Pterodactyl\Facades\Activity; use Illuminate\Database\Query\Builder; use Illuminate\Database\Query\JoinClause; use Pterodactyl\Http\Controllers\Controller; @@ -107,7 +109,6 @@ class ServerDetailsController extends Controller // // For each of those servers we'll track a new audit log entry to mark them as // failed and then update them all to be in a valid state. - /** @var \Pterodactyl\Models\Server[] $servers */ $servers = Server::query() ->select('servers.*') ->selectRaw('JSON_UNQUOTE(JSON_EXTRACT(started.metadata, "$.backup_uuid")) as backup_uuid') @@ -130,14 +131,17 @@ class ServerDetailsController extends Controller ->where('servers.status', Server::STATUS_RESTORING_BACKUP) ->get(); + $backups = Backup::query()->whereIn('uuid', $servers->pluck('backup_uuid'))->get(); + + /** @var \Pterodactyl\Models\Server $server */ foreach ($servers as $server) { - // Just create a new audit entry for this event and update the server state - // so that power actions, file management, and backups can resume as normal. - $server->audit(AuditLog::SERVER__BACKUP_RESTORE_FAILED, function (AuditLog $audit, Server $server) { - $audit->is_system = true; - $audit->metadata = ['backup_uuid' => $server->getAttribute('backup_uuid')]; - $server->update(['status' => null]); - }); + $server->update(['status' => null]); + + if ($backup = $backups->where('uuid', $server->getAttribute('backup_uuid'))->first()) { + // Just create a new audit entry for this event and update the server state + // so that power actions, file management, and backups can resume as normal. + Activity::event('server:backup.restore-failed')->subject($server, $backup)->log(); + } } // Update any server marked as installing or restoring as being in a normal state diff --git a/app/Models/ActivityLog.php b/app/Models/ActivityLog.php index 47ef911ca..c8be7249e 100644 --- a/app/Models/ActivityLog.php +++ b/app/Models/ActivityLog.php @@ -16,16 +16,13 @@ use Illuminate\Database\Eloquent\Model as IlluminateModel; * @property string|null $description * @property string|null $actor_type * @property int|null $actor_id - * @property string|null $subject_type - * @property int|null $subject_id * @property \Illuminate\Support\Collection $properties * @property string $timestamp * @property IlluminateModel|\Eloquent $actor * @property IlluminateModel|\Eloquent $subject * - * @method static Builder|ActivityLog forAction(string $action) + * @method static Builder|ActivityLog forEvent(string $event) * @method static Builder|ActivityLog forActor(\Illuminate\Database\Eloquent\Model $actor) - * @method static Builder|ActivityLog forSubject(\Illuminate\Database\Eloquent\Model $subject) * @method static Builder|ActivityLog newModelQuery() * @method static Builder|ActivityLog newQuery() * @method static Builder|ActivityLog query() @@ -37,8 +34,6 @@ use Illuminate\Database\Eloquent\Model as IlluminateModel; * @method static Builder|ActivityLog whereId($value) * @method static Builder|ActivityLog whereIp($value) * @method static Builder|ActivityLog whereProperties($value) - * @method static Builder|ActivityLog whereSubjectId($value) - * @method static Builder|ActivityLog whereSubjectType($value) * @method static Builder|ActivityLog whereTimestamp($value) * @mixin \Eloquent */ @@ -68,14 +63,9 @@ class ActivityLog extends Model return $this->morphTo(); } - public function subject(): MorphTo + public function scopeForEvent(Builder $builder, string $action): Builder { - return $this->morphTo(); - } - - public function scopeForAction(Builder $builder, string $action): Builder - { - return $builder->where('action', $action); + return $builder->where('event', $action); } /** @@ -85,12 +75,4 @@ class ActivityLog extends Model { return $builder->whereMorphedTo('actor', $actor); } - - /** - * Scopes a query to only return results where the subject is the given model. - */ - public function scopeForSubject(Builder $builder, IlluminateModel $subject): Builder - { - return $builder->whereMorphedTo('subject', $subject); - } } diff --git a/app/Models/ActivityLogSubject.php b/app/Models/ActivityLogSubject.php new file mode 100644 index 000000000..47264dbd6 --- /dev/null +++ b/app/Models/ActivityLogSubject.php @@ -0,0 +1,40 @@ +belongsTo(ActivityLog::class); + } + + public function subject() + { + return $this->morphTo(); + } +} diff --git a/app/Models/Server.php b/app/Models/Server.php index b71315832..7fe28674a 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -2,10 +2,10 @@ namespace Pterodactyl\Models; -use Closure; use Illuminate\Notifications\Notifiable; use Illuminate\Database\Query\JoinClause; use Znck\Eloquent\Traits\BelongsToThrough; +use Illuminate\Database\Eloquent\Relations\MorphToMany; use Pterodactyl\Exceptions\Http\Server\ServerStateConflictException; /** @@ -41,8 +41,6 @@ use Pterodactyl\Exceptions\Http\Server\ServerStateConflictException; * @property \Pterodactyl\Models\Allocation|null $allocation * @property \Illuminate\Database\Eloquent\Collection|\Pterodactyl\Models\Allocation[] $allocations * @property int|null $allocations_count - * @property \Illuminate\Database\Eloquent\Collection|\Pterodactyl\Models\AuditLog[] $audits - * @property int|null $audits_count * @property \Illuminate\Database\Eloquent\Collection|\Pterodactyl\Models\Backup[] $backups * @property int|null $backups_count * @property \Illuminate\Database\Eloquent\Collection|\Pterodactyl\Models\Database[] $databases @@ -373,48 +371,11 @@ class Server extends Model } /** - * Returns a fresh AuditLog model for the server. This model is not saved to the - * database when created, so it is up to the caller to correctly store it as needed. - * - * @return \Pterodactyl\Models\AuditLog + * Returns all of the activity log entries where the server is the subject. */ - public function newAuditEvent(string $action, array $metadata = []): AuditLog + public function activity(): MorphToMany { - return AuditLog::instance($action, $metadata)->fill([ - 'server_id' => $this->id, - ]); - } - - /** - * Stores a new audit event for a server by using a transaction. If the transaction - * fails for any reason everything executed within will be rolled back. The callback - * passed in will receive the AuditLog model before it is saved and the second argument - * will be the current server instance. The callback should modify the audit entry as - * needed before finishing, any changes will be persisted. - * - * The response from the callback is returned to the caller. - * - * @return mixed - * - * @throws \Throwable - */ - public function audit(string $action, Closure $callback) - { - return $this->getConnection()->transaction(function () use ($action, $callback) { - $model = $this->newAuditEvent($action); - $response = $callback($model, $this); - $model->save(); - - return $response; - }); - } - - /** - * @return \Illuminate\Database\Eloquent\Relations\HasMany - */ - public function audits() - { - return $this->hasMany(AuditLog::class); + return $this->morphToMany(ActivityLog::class, 'subject', 'activity_log_subjects'); } /** diff --git a/app/Models/User.php b/app/Models/User.php index c8fe64214..0570bbf31 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -14,6 +14,7 @@ use Illuminate\Auth\Passwords\CanResetPassword; use Pterodactyl\Traits\Helpers\AvailableLanguages; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\Access\Authorizable; +use Illuminate\Database\Eloquent\Relations\MorphToMany; use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract; use Illuminate\Contracts\Auth\Access\Authorizable as AuthorizableContract; use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract; @@ -273,6 +274,15 @@ class User extends Model implements return $this->hasMany(UserSSHKey::class); } + /** + * Returns all of the activity logs where this user is the subject — not to + * be confused by activity logs where this user is the _actor_. + */ + public function activity(): MorphToMany + { + return $this->morphToMany(ActivityLog::class, 'subject', 'activity_log_subjects'); + } + /** * Returns all of the servers that a user can access by way of being the owner of the * server, or because they are assigned as a subuser for that server. diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 51fa6e7fd..a55aa297e 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -5,11 +5,15 @@ namespace Pterodactyl\Providers; use View; use Cache; use Illuminate\Support\Str; +use Pterodactyl\Models\User; +use Pterodactyl\Models\Server; +use Pterodactyl\Models\Backup; use Illuminate\Support\Facades\URL; use Illuminate\Pagination\Paginator; use Illuminate\Support\Facades\Schema; use Illuminate\Support\ServiceProvider; use Pterodactyl\Extensions\Themes\Theme; +use Illuminate\Database\Eloquent\Relations\Relation; class AppServiceProvider extends ServiceProvider { @@ -33,6 +37,12 @@ class AppServiceProvider extends ServiceProvider if (Str::startsWith(config('app.url') ?? '', 'https://')) { URL::forceScheme('https'); } + + Relation::enforceMorphMap([ + 'backup' => Backup::class, + 'server' => Server::class, + 'user' => User::class, + ]); } /** diff --git a/app/Services/Activity/ActivityLogService.php b/app/Services/Activity/ActivityLogService.php index 8ad5af53b..7c8227d2e 100644 --- a/app/Services/Activity/ActivityLogService.php +++ b/app/Services/Activity/ActivityLogService.php @@ -2,20 +2,28 @@ namespace Pterodactyl\Services\Activity; +use Illuminate\Support\Arr; +use Webmozart\Assert\Assert; use Illuminate\Support\Collection; use Pterodactyl\Models\ActivityLog; use Illuminate\Contracts\Auth\Factory; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\Request; +use Pterodactyl\Models\ActivityLogSubject; use Illuminate\Database\ConnectionInterface; class ActivityLogService { protected ?ActivityLog $activity = null; + protected array $subjects = []; + protected Factory $manager; + protected ConnectionInterface $connection; + protected AcitvityLogBatchService $batch; + protected ActivityLogTargetableService $targetable; public function __construct( @@ -65,10 +73,22 @@ class ActivityLogService /** * Sets the subject model instance. + * + * @param \Illuminate\Database\Eloquent\Model|\Illuminate\Database\Eloquent\Model[] $subjects */ - public function subject(Model $subject): self + public function subject(...$subjects): self { - $this->getActivity()->subject()->associate($subject); + foreach (Arr::wrap($subjects) as $subject) { + foreach ($this->subjects as $entry) { + // If this subject is already tracked in our array of subjects just skip over + // it and move on to the next one in the list. + if ($entry->is($subject)) { + continue 2; + } + } + + $this->subjects[] = $subject; + } return $this; } @@ -83,26 +103,18 @@ class ActivityLogService return $this; } - /** - * Sets the custom properties for the activity log instance. - * - * @param \Illuminate\Support\Collection|array $properties - */ - public function withProperties($properties): self - { - $this->getActivity()->properties = Collection::make($properties); - - return $this; - } - /** * Sets a custom property on the activty log instance. * + * @param string|array $key * @param mixed $value */ - public function property(string $key, $value): self + public function property($key, $value = null): self { - $this->getActivity()->properties = $this->getActivity()->properties->put($key, $value); + $properties = $this->getActivity()->properties; + $this->activity->properties = is_array($key) + ? $properties->merge($key) + : $properties->put($key, $value); return $this; } @@ -112,10 +124,10 @@ class ActivityLogService */ public function withRequestMetadata(): self { - $this->property('ip', Request::getClientIp()); - $this->property('useragent', Request::userAgent()); - - return $this; + return $this->property([ + 'ip' => Request::getClientIp(), + 'useragent' => Request::userAgent(), + ]); } /** @@ -130,11 +142,7 @@ class ActivityLogService $activity->description = $description; } - $activity->save(); - - $this->activity = null; - - return $activity; + return $this->save(); } /** @@ -155,17 +163,12 @@ class ActivityLogService * * @throws \Throwable */ - public function transaction(\Closure $callback, string $description = null) + public function transaction(\Closure $callback) { - if (!is_null($description)) { - $this->description($description); - } - return $this->connection->transaction(function () use ($callback) { $response = $callback($activity = $this->getActivity()); - $activity->save(); - $this->activity = null; + $this->save($activity); return $response; }); @@ -200,4 +203,38 @@ class ActivityLogService return $this->activity; } + + /** + * Saves the activity log instance and attaches all of the subject models. + * + * @throws \Throwable + */ + protected function save(ActivityLog $activity = null): ActivityLog + { + $activity = $activity ?? $this->activity; + + Assert::notNull($activity); + + $response = $this->connection->transaction(function () use ($activity) { + $activity->save(); + + $subjects = Collection::make($this->subjects) + ->map(fn (Model $subject) => [ + 'activity_log_id' => $this->activity->id, + 'subject_id' => $subject->getKey(), + 'subject_type' => $subject->getMorphClass(), + ]) + ->values() + ->toArray(); + + ActivityLogSubject::insert($subjects); + + return $activity; + }); + + $this->activity = null; + $this->subjects = []; + + return $response; + } } diff --git a/database/migrations/2022_05_28_135717_create_activity_logs_table.php b/database/migrations/2022_05_28_135717_create_activity_logs_table.php index 0624a5a66..448439dc8 100644 --- a/database/migrations/2022_05_28_135717_create_activity_logs_table.php +++ b/database/migrations/2022_05_28_135717_create_activity_logs_table.php @@ -20,7 +20,6 @@ class CreateActivityLogsTable extends Migration $table->string('ip'); $table->text('description')->nullable(); $table->nullableNumericMorphs('actor'); - $table->nullableNumericMorphs('subject'); $table->json('properties'); $table->timestamp('timestamp')->useCurrent()->onUpdate(null); }); diff --git a/database/migrations/2022_05_29_140349_create_activity_log_actors_table.php b/database/migrations/2022_05_29_140349_create_activity_log_actors_table.php new file mode 100644 index 000000000..8be57bc1c --- /dev/null +++ b/database/migrations/2022_05_29_140349_create_activity_log_actors_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('activity_log_id')->references('id')->on('activity_logs'); + $table->numericMorphs('subject'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('activity_log_subject'); + } +}