diff --git a/app/Http/Controllers/Api/Client/ActivityLogController.php b/app/Http/Controllers/Api/Client/ActivityLogController.php new file mode 100644 index 000000000..70e1b2393 --- /dev/null +++ b/app/Http/Controllers/Api/Client/ActivityLogController.php @@ -0,0 +1,30 @@ +user()->activity()) + ->with('actor') + ->allowedFilters([ + AllowedFilter::exact('ip'), + AllowedFilter::partial('event'), + ]) + ->paginate(min($request->query('per_page', 50), 100)) + ->appends($request->query()); + + return $this->fractal->collection($activity) + ->transformWith($this->getTransformer(ActivityLogTransformer::class)) + ->toArray(); + } +} diff --git a/app/Models/ActivityLog.php b/app/Models/ActivityLog.php index 4312e332e..b914d9513 100644 --- a/app/Models/ActivityLog.php +++ b/app/Models/ActivityLog.php @@ -22,7 +22,7 @@ use Illuminate\Database\Eloquent\Model as IlluminateModel; * @property string|null $actor_type * @property int|null $actor_id * @property \Illuminate\Support\Collection|null $properties - * @property string $timestamp + * @property \Carbon\Carbon $timestamp * @property IlluminateModel|\Eloquent $actor * @property \Illuminate\Database\Eloquent\Collection|\Pterodactyl\Models\ActivityLogSubject[] $subjects * @property int|null $subjects_count @@ -47,6 +47,8 @@ class ActivityLog extends Model { use MassPrunable; + public const RESOURCE_NAME = 'activity_log'; + public $timestamps = false; protected $guarded = [ @@ -56,6 +58,7 @@ class ActivityLog extends Model protected $casts = [ 'properties' => 'collection', + 'timestamp' => 'datetime', ]; protected $with = ['subjects']; diff --git a/app/Transformers/Api/Client/ActivityLogTransformer.php b/app/Transformers/Api/Client/ActivityLogTransformer.php new file mode 100644 index 000000000..077b11b7f --- /dev/null +++ b/app/Transformers/Api/Client/ActivityLogTransformer.php @@ -0,0 +1,37 @@ + $model->batch, + 'event' => $model->event, + 'ip' => $model->ip, + 'description' => $model->description, + 'properties' => $model->properties, + 'timestamp' => $model->timestamp->toIso8601String(), + ]; + } + + public function includeActor(ActivityLog $model) + { + if (!$model->actor instanceof User) { + return $this->null(); + } + + return $this->item($model->actor, $this->makeTransformer(UserTransformer::class), User::RESOURCE_NAME); + } +} diff --git a/resources/scripts/api/definitions/helpers.ts b/resources/scripts/api/definitions/helpers.ts new file mode 100644 index 000000000..5e1afd656 --- /dev/null +++ b/resources/scripts/api/definitions/helpers.ts @@ -0,0 +1,26 @@ +import { FractalResponseData, FractalResponseList } from '@/api/http'; + +type Transformer = (callback: FractalResponseData) => T; + +const isList = (data: FractalResponseList | FractalResponseData): data is FractalResponseList => data.object === 'list'; + +function transform(data: null | undefined, transformer: Transformer, missing?: M): M; +function transform(data: FractalResponseData | null | undefined, transformer: Transformer, missing?: M): T | M; +function transform(data: FractalResponseList | null | undefined, transformer: Transformer, missing?: M): T[] | M; +function transform (data: FractalResponseData | FractalResponseList | null | undefined, transformer: Transformer, missing = undefined) { + if (data === undefined || data === null) { + return missing; + } + + if (isList(data)) { + return data.data.map(transformer); + } + + if (!data || !data.attributes || data.object === 'null_resource') { + return missing; + } + + return transformer(data); +} + +export { transform }; diff --git a/resources/scripts/api/definitions/index.d.ts b/resources/scripts/api/definitions/index.d.ts index 68925c863..161287384 100644 --- a/resources/scripts/api/definitions/index.d.ts +++ b/resources/scripts/api/definitions/index.d.ts @@ -1,4 +1,5 @@ import { MarkRequired } from 'ts-essentials'; +import { FractalResponseData, FractalResponseList } from '../http'; export type UUID = string; @@ -6,7 +7,7 @@ export type UUID = string; export interface Model {} interface ModelWithRelationships extends Model { - relationships: Record; + relationships: Record; } /** diff --git a/resources/scripts/api/definitions/user/models.d.ts b/resources/scripts/api/definitions/user/models.d.ts index 51bea475c..1f26683aa 100644 --- a/resources/scripts/api/definitions/user/models.d.ts +++ b/resources/scripts/api/definitions/user/models.d.ts @@ -1,4 +1,16 @@ -import { Model } from '@/api/definitions'; +import { Model, UUID } from '@/api/definitions'; +import { SubuserPermission } from '@/state/server/subusers'; + +interface User extends Model { + uuid: string; + username: string; + email: string; + image: string; + twoFactorEnabled: boolean; + createdAt: Date; + permissions: SubuserPermission[]; + can (permission: SubuserPermission): boolean; +} interface SSHKey extends Model { name: string; @@ -6,3 +18,15 @@ interface SSHKey extends Model { fingerprint: string; createdAt: Date; } + +interface ActivityLog extends Model<'actor'> { + batch: UUID | null; + event: string; + ip: string; + description: string | null; + properties: Record; + timestamp: Date; + relationships: { + actor: User | null; + } +} diff --git a/resources/scripts/api/definitions/user/transformers.ts b/resources/scripts/api/definitions/user/transformers.ts index 89adbad75..6532cdd93 100644 --- a/resources/scripts/api/definitions/user/transformers.ts +++ b/resources/scripts/api/definitions/user/transformers.ts @@ -1,7 +1,9 @@ -import { SSHKey } from '@definitions/user/models'; +import * as Models from '@definitions/user/models'; +import { FractalResponseData } from '@/api/http'; +import { transform } from '@definitions/helpers'; export default class Transformers { - static toSSHKey (data: Record): SSHKey { + static toSSHKey (data: Record): Models.SSHKey { return { name: data.name, publicKey: data.public_key, @@ -9,6 +11,37 @@ export default class Transformers { createdAt: new Date(data.created_at), }; } + + static toUser ({ attributes }: FractalResponseData): Models.User { + return { + uuid: attributes.uuid, + username: attributes.username, + email: attributes.email, + image: attributes.image, + twoFactorEnabled: attributes['2fa_enabled'], + permissions: attributes.permissions || [], + createdAt: new Date(attributes.created_at), + can (permission): boolean { + return this.permissions.includes(permission); + }, + }; + } + + static toActivityLog ({ attributes }: FractalResponseData): Models.ActivityLog { + const { actor } = attributes.relationships || {}; + + return { + batch: attributes.batch, + event: attributes.event, + ip: attributes.ip, + description: attributes.description, + properties: attributes.properties, + timestamp: new Date(attributes.timestamp), + relationships: { + actor: transform(actor as FractalResponseData, this.toUser, null), + }, + }; + } } export class MetaTransformers { diff --git a/resources/scripts/api/http.ts b/resources/scripts/api/http.ts index 78aa393ee..cd6b8e512 100644 --- a/resources/scripts/api/http.ts +++ b/resources/scripts/api/http.ts @@ -68,7 +68,7 @@ export interface FractalResponseData { object: string; attributes: { [k: string]: any; - relationships?: Record; + relationships?: Record; }; } diff --git a/routes/api-client.php b/routes/api-client.php index 3099ea0d8..dc88eaee0 100644 --- a/routes/api-client.php +++ b/routes/api-client.php @@ -30,6 +30,8 @@ Route::prefix('/account')->middleware(AccountActivitySubject::class)->group(func Route::put('/email', [Client\AccountController::class, 'updateEmail'])->name('api:client.account.update-email'); Route::put('/password', [Client\AccountController::class, 'updatePassword'])->name('api:client.account.update-password'); + Route::get('/activity', Client\ActivityLogController::class)->name('api:client.account.activity'); + Route::get('/api-keys', [Client\ApiKeyController::class, 'index']); Route::post('/api-keys', [Client\ApiKeyController::class, 'store']); Route::delete('/api-keys/{identifier}', [Client\ApiKeyController::class, 'delete']);