diff --git a/app/Facades/Activity.php b/app/Facades/Activity.php new file mode 100644 index 000000000..eb7359ece --- /dev/null +++ b/app/Facades/Activity.php @@ -0,0 +1,25 @@ +route()->parameter('server'); + if ($server instanceof Server) { + LogTarget::setActor($request->user()); + LogTarget::setSubject($server); + } + + return $next($request); + } +} diff --git a/app/Models/ActivityLog.php b/app/Models/ActivityLog.php new file mode 100644 index 000000000..f58d827c6 --- /dev/null +++ b/app/Models/ActivityLog.php @@ -0,0 +1,94 @@ + 'collection', + ]; + + public static $validationRules = [ + 'event' => ['required', 'string'], + 'batch' => ['nullable', 'uuid'], + 'description' => ['nullable', 'string'], + 'properties' => ['nullable', 'array'], + ]; + + public function actor(): MorphTo + { + return $this->morphTo(); + } + + public function subject(): MorphTo + { + return $this->morphTo(); + } + + public function scopeForAction(Builder $builder, string $action): Builder + { + return $builder->where('action', $action); + } + + /** + * Scopes a query to only return results where the actor is a given model. + */ + public function scopeForActor(Builder $builder, IlluminateModel $actor): Builder + { + 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/Providers/ActivityLogServiceProvider.php b/app/Providers/ActivityLogServiceProvider.php new file mode 100644 index 000000000..b410b4c80 --- /dev/null +++ b/app/Providers/ActivityLogServiceProvider.php @@ -0,0 +1,20 @@ +app->scoped(AcitvityLogBatchService::class); + $this->app->scoped(ActivityLogTargetableService::class); + } +} diff --git a/app/Services/Activity/AcitvityLogBatchService.php b/app/Services/Activity/AcitvityLogBatchService.php new file mode 100644 index 000000000..af62e9e4d --- /dev/null +++ b/app/Services/Activity/AcitvityLogBatchService.php @@ -0,0 +1,62 @@ +uuid; + } + + /** + * Starts a new batch transaction. If there is already a transaction present + * this will be nested. + */ + public function start(): void + { + if ($this->transaction === 0) { + $this->uuid = Uuid::uuid4()->toString(); + } + + ++$this->transaction; + } + + /** + * Ends a batch transaction, if this is the last transaction in the stack + * the UUID will be cleared out. + */ + public function end(): void + { + $this->transaction = max(0, $this->transaction - 1); + + if ($this->transaction === 0) { + $this->uuid = null; + } + } + + /** + * Executes the logic provided within the callback in the scope of an activity + * log batch transaction. + * + * @param \Closure $callback + * @return mixed + */ + public function transaction(\Closure $callback) + { + $this->start(); + $result = $callback($this->uuid()); + $this->end(); + + return $result; + } +} diff --git a/app/Services/Activity/ActivityLogService.php b/app/Services/Activity/ActivityLogService.php new file mode 100644 index 000000000..d34262a33 --- /dev/null +++ b/app/Services/Activity/ActivityLogService.php @@ -0,0 +1,177 @@ +manager = $manager; + $this->batch = $batch; + $this->targetable = $targetable; + $this->connection = $connection; + } + + /** + * Sets the activity logger as having been caused by an anonymous + * user type. + */ + public function anonymous(): self + { + $this->getActivity()->actor_id = null; + $this->getActivity()->actor_type = null; + $this->getActivity()->setRelation('actor', null); + + return $this; + } + + /** + * Sets the action for this activity log. + */ + public function event(string $action): self + { + $this->getActivity()->event = $action; + + return $this; + } + + /** + * Set the description for this activity. + */ + public function withDescription(?string $description): self + { + $this->getActivity()->description = $description; + + return $this; + } + + /** + * Sets the subject model instance. + */ + public function withSubject(Model $subject): self + { + $this->getActivity()->subject()->associate($subject); + + return $this; + } + + /** + * Sets the actor model instance. + */ + public function withActor(Model $actor): self + { + $this->getActivity()->actor()->associate($actor); + + 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 mixed $value + */ + public function withProperty(string $key, $value): self + { + $this->getActivity()->properties = $this->getActivity()->properties->put($key, $value); + + return $this; + } + + /** + * Logs an activity log entry with the set values and then returns the + * model instance to the caller. + */ + public function log(string $description): ActivityLog + { + $this->withDescription($description); + + $activity = $this->activity; + $activity->save(); + $this->activity = null; + + return $activity; + } + + /** + * Executes the provided callback within the scope of a database transaction + * and will only save the activity log entry if everything else succesfully + * settles. + * + * @return mixed + * + * @throws \Throwable + */ + public function transaction(\Closure $callback, string $description = null) + { + if (!is_null($description)) { + $this->withDescription($description); + } + + return $this->connection->transaction(function () use ($callback) { + $response = $callback($activity = $this->getActivity()); + + $activity->save(); + $this->activity = null; + + return $response; + }); + } + + /** + * Returns the current activity log instance. + */ + protected function getActivity(): ActivityLog + { + if ($this->activity) { + return $this->activity; + } + + $this->activity = new ActivityLog([ + 'batch_uuid' => $this->batch->uuid(), + 'properties' => Collection::make([]), + ]); + + if ($subject = $this->targetable->subject()) { + $this->withSubject($subject); + } + + if ($actor = $this->targetable->actor()) { + $this->withActor($actor); + } elseif ($user = $this->manager->guard()->user()) { + if ($user instanceof Model) { + $this->withActor($user); + } + } + + return $this->activity; + } +} diff --git a/app/Services/Activity/ActivityLogTargetableService.php b/app/Services/Activity/ActivityLogTargetableService.php new file mode 100644 index 000000000..6883a4ce1 --- /dev/null +++ b/app/Services/Activity/ActivityLogTargetableService.php @@ -0,0 +1,47 @@ +actor)) { + throw new InvalidArgumentException('Cannot call ' . __METHOD__ . ' when an actor is already set on the instance.'); + } + + $this->actor = $actor; + } + + public function setSubject(Model $subject): void + { + if (!is_null($this->subject)) { + throw new InvalidArgumentException('Cannot call ' . __METHOD__ . ' when a target is already set on the instance.'); + } + + $this->subject = $subject; + } + + public function actor(): ?Model + { + return $this->actor; + } + + public function subject(): ?Model + { + return $this->subject; + } + + public function reset(): void + { + $this->actor = null; + $this->subject = null; + } +} diff --git a/config/app.php b/config/app.php index f57036f95..8d48b4631 100644 --- a/config/app.php +++ b/config/app.php @@ -173,6 +173,7 @@ return [ /* * Application Service Providers... */ + Pterodactyl\Providers\ActivityLogServiceProvider::class, Pterodactyl\Providers\AppServiceProvider::class, Pterodactyl\Providers\AuthServiceProvider::class, Pterodactyl\Providers\BackupsServiceProvider::class, 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 new file mode 100644 index 000000000..570cc4060 --- /dev/null +++ b/database/migrations/2022_05_28_135717_create_activity_logs_table.php @@ -0,0 +1,37 @@ +id(); + $table->uuid('batch')->nullable(); + $table->string('event')->index(); + $table->text('description')->nullable(); + $table->nullableNumericMorphs('actor'); + $table->nullableNumericMorphs('subject'); + $table->json('properties')->nullable(); + $table->timestamp('timestamp')->useCurrent()->onUpdate(null); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('activity_logs'); + } +}