diff --git a/app/Actions/Bootstrap/GetBootstrap.php b/app/Actions/Bootstrap/GetBootstrap.php index a5ddbf596..b674ad8b5 100644 --- a/app/Actions/Bootstrap/GetBootstrap.php +++ b/app/Actions/Bootstrap/GetBootstrap.php @@ -2,6 +2,7 @@ namespace App\Actions\Bootstrap; +use App\Enums\ScriptEventHookEvent; use App\Models\GithubApp; use App\Tooling\ToolingRegistry; use Illuminate\Support\Facades\Cache; @@ -95,6 +96,17 @@ private function configs(): array 'installed' => GithubApp::query()->exists(), ], 'tooling' => $this->tooling(), + 'script_event_hooks' => [ + 'events' => array_map( + fn (ScriptEventHookEvent $e): array => [ + 'value' => $e->value, + 'label' => $e->getText(), + 'color' => $e->getColor(), + 'variables' => array_keys($e->variables()), + ], + ScriptEventHookEvent::cases() + ), + ], ]; } diff --git a/app/Actions/Script/ExecuteScript.php b/app/Actions/Script/ExecuteScript.php index 957986e02..09a2842d9 100644 --- a/app/Actions/Script/ExecuteScript.php +++ b/app/Actions/Script/ExecuteScript.php @@ -5,15 +5,34 @@ use App\Enums\ScriptExecutionStatus; use App\Jobs\Script\ExecuteJob; use App\Models\Script; +use App\Models\ScriptEventHook; use App\Models\ScriptExecution; use App\Models\Server; use App\Models\ServerLog; use App\Models\User; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Validator; use Illuminate\Validation\Rule; class ExecuteScript { + /** + * Execute a script triggered by an event hook, injecting event context as variables. + * + * @param array $eventVariables + */ + public function executeForHook(ScriptEventHook $hook, array $eventVariables): ScriptExecution + { + $script = $hook->script; + + $variables = []; + foreach ($script->getVariables() as $variable) { + $variables[$variable] = $this->sanitizeVariable($eventVariables[$variable] ?? ''); + } + + return $this->startExecution($script, $hook->server, $hook->user, $variables); + } + /** * @param array $input */ @@ -35,26 +54,46 @@ public function execute(Script $script, User $user, array $input): ScriptExecuti abort(403, 'You do not have permission to execute scripts on this server.'); } - $execution = new ScriptExecution([ - 'script_id' => $script->id, - 'server_id' => $input['server'], - 'user' => $input['user'], - 'variables' => $variables, - 'status' => ScriptExecutionStatus::EXECUTING, - ]); - $execution->save(); + return $this->startExecution($script, $server, $input['user'], $variables); + } + + /** + * @param array $variables + */ + private function startExecution(Script $script, Server $server, string $user, array $variables): ScriptExecution + { + /** @var array{0: ScriptExecution, 1: ServerLog} $result */ + $result = DB::transaction(function () use ($script, $server, $user, $variables): array { + $execution = new ScriptExecution([ + 'script_id' => $script->id, + 'server_id' => $server->id, + 'user' => $user, + 'variables' => $variables, + 'status' => ScriptExecutionStatus::EXECUTING, + ]); + $execution->save(); - $log = ServerLog::newLog($execution->server, 'script-'.$script->id.'-'.strtotime('now')); - $log->save(); + $log = ServerLog::newLog($server, 'script-'.$script->id.'-'.strtotime('now')); + $log->save(); - $execution->server_log_id = $log->id; - $execution->save(); + $execution->server_log_id = $log->id; + $execution->save(); + + return [$execution, $log]; + }); + + [$execution, $log] = $result; dispatch(new ExecuteJob($execution, $log))->onQueue('ssh'); return $execution; } + private function sanitizeVariable(string $value): string + { + return preg_replace('/[^A-Za-z0-9 ._\-\/:@]/', '', $value) ?? ''; + } + private function validate(Script $script, array $input): void { $users = ['root']; diff --git a/app/Actions/ScriptEventHook/CreateScriptEventHook.php b/app/Actions/ScriptEventHook/CreateScriptEventHook.php new file mode 100644 index 000000000..692c48395 --- /dev/null +++ b/app/Actions/ScriptEventHook/CreateScriptEventHook.php @@ -0,0 +1,80 @@ + $input + * + * @throws ValidationException + */ + public function create(User $user, Script $script, array $input): ScriptEventHook + { + $server = $this->validate($input); + + if (! $user->can('update', $server)) { + abort(403, 'You do not have permission to run scripts on this server.'); + } + + $hook = new ScriptEventHook([ + 'script_id' => $script->id, + 'user_id' => $user->id, + 'project_id' => $server->project_id, + 'server_id' => $server->id, + 'event' => $input['event'], + 'user' => $input['user'], + 'enabled' => $input['enabled'] ?? true, + ]); + $hook->save(); + + return $hook; + } + + /** + * @param array $input + * + * @throws ValidationException + */ + private function validate(array $input): Server + { + $server = null; + $users = ['root']; + if (isset($input['server_id'])) { + $server = Server::query()->find($input['server_id']); + if ($server) { + $users = $server->getSshUsers(); + } + } + + Validator::make($input, [ + 'event' => [ + 'required', + Rule::enum(ScriptEventHookEvent::class), + ], + 'server_id' => [ + 'required', + Rule::exists('servers', 'id'), + ], + 'user' => [ + 'required', + Rule::in($users), + ], + 'enabled' => [ + 'boolean', + ], + ])->validate(); + + /** @var Server $server */ + return $server; + } +} diff --git a/app/Actions/ScriptEventHook/UpdateScriptEventHook.php b/app/Actions/ScriptEventHook/UpdateScriptEventHook.php new file mode 100644 index 000000000..2c42e59e1 --- /dev/null +++ b/app/Actions/ScriptEventHook/UpdateScriptEventHook.php @@ -0,0 +1,77 @@ + $input + * + * @throws ValidationException + */ + public function update(ScriptEventHook $hook, User $user, array $input): ScriptEventHook + { + $server = $this->validate($input); + + if ($server && ! $user->can('update', $server)) { + abort(403, 'You do not have permission to run scripts on this server.'); + } + + $hook->fill([ + 'event' => $input['event'] ?? $hook->event->value, + 'server_id' => $server->id ?? $hook->server_id, + 'project_id' => $server->project_id ?? $hook->project_id, + 'user' => $input['user'] ?? $hook->user, + 'enabled' => $input['enabled'] ?? $hook->enabled, + ]); + $hook->save(); + + return $hook; + } + + /** + * @param array $input + * + * @throws ValidationException + */ + private function validate(array $input): ?Server + { + $users = ['root']; + $server = null; + if (isset($input['server_id'])) { + $server = Server::query()->find($input['server_id']); + if ($server) { + $users = $server->getSshUsers(); + } + } + + Validator::make($input, [ + 'event' => [ + 'sometimes', + Rule::enum(ScriptEventHookEvent::class), + ], + 'server_id' => [ + 'sometimes', + Rule::exists('servers', 'id'), + ], + 'user' => [ + 'required_with:server_id', + Rule::in($users), + ], + 'enabled' => [ + 'sometimes', + 'boolean', + ], + ])->validate(); + + return $server; + } +} diff --git a/app/Actions/Server/DeleteServer.php b/app/Actions/Server/DeleteServer.php index 62be7c9fa..e34e57b8d 100644 --- a/app/Actions/Server/DeleteServer.php +++ b/app/Actions/Server/DeleteServer.php @@ -2,6 +2,7 @@ namespace App\Actions\Server; +use App\Events\ServerDeletedEvent; use App\Models\Server; use App\ServerProviders\Custom; use Illuminate\Support\Facades\Validator; @@ -23,7 +24,14 @@ public function delete(Server $server, array $input): void $server->deleteFromProvider = filter_var($input['delete_from_provider'], FILTER_VALIDATE_BOOLEAN); } + $serverId = $server->id; + $serverName = $server->name; + $serverIp = $server->ip; + $projectId = $server->project_id; + $server->delete(); + + ServerDeletedEvent::dispatch($serverId, $serverName, $serverIp, $projectId); } /** diff --git a/app/Actions/Server/InstallServer.php b/app/Actions/Server/InstallServer.php index d8088bfaa..6831922ca 100644 --- a/app/Actions/Server/InstallServer.php +++ b/app/Actions/Server/InstallServer.php @@ -5,6 +5,7 @@ use App\DTOs\SocketEventDTO; use App\Enums\ServerStatus; use App\Enums\ServiceStatus; +use App\Events\ServerInstalledEvent; use App\Events\SocketEvent; use App\Exceptions\SSHConnectionError; use App\Exceptions\SSHError; @@ -47,6 +48,7 @@ public function run(Server $server): void ]); dispatch(new RefreshServerIpsJob($this->server))->onQueue('ssh'); Notifier::send($this->server, new ServerInstallationSucceed($this->server)); + ServerInstalledEvent::dispatch($this->server); } /** diff --git a/app/Enums/ScriptEventHookEvent.php b/app/Enums/ScriptEventHookEvent.php new file mode 100644 index 000000000..ef89a3c8a --- /dev/null +++ b/app/Enums/ScriptEventHookEvent.php @@ -0,0 +1,74 @@ + 'Site Created', + self::SITE_DELETED => 'Site Deleted', + self::SERVER_INSTALLED => 'Server Installed', + self::SERVER_DELETED => 'Server Deleted', + self::SERVICE_INSTALLED => 'Service Installed', + self::SERVICE_UNINSTALLED => 'Service Uninstalled', + }; + } + + /** + * @return array + */ + public function variables(): array + { + return match ($this) { + self::SITE_CREATED => [ + 'site_domain' => 'The domain of the created site', + 'site_path' => 'The path of the created site', + 'site_type' => 'The type of the created site', + 'server_name' => 'The name of the server', + 'server_ip' => 'The IP address of the server', + ], + self::SITE_DELETED => [ + 'site_domain' => 'The domain of the deleted site', + 'server_name' => 'The name of the server', + 'server_ip' => 'The IP address of the server', + ], + self::SERVER_INSTALLED => [ + 'server_name' => 'The name of the installed server', + 'server_ip' => 'The IP address of the installed server', + ], + self::SERVER_DELETED => [ + 'server_name' => 'The name of the deleted server', + 'server_ip' => 'The IP address of the deleted server', + ], + self::SERVICE_INSTALLED => [ + 'service_name' => 'The name of the installed service', + 'service_type' => 'The type of the installed service', + 'service_version' => 'The version of the installed service', + 'server_name' => 'The name of the server', + 'server_ip' => 'The IP address of the server', + ], + self::SERVICE_UNINSTALLED => [ + 'service_name' => 'The name of the uninstalled service', + 'service_type' => 'The type of the uninstalled service', + 'server_name' => 'The name of the server', + 'server_ip' => 'The IP address of the server', + ], + }; + } +} diff --git a/app/Events/ServerDeletedEvent.php b/app/Events/ServerDeletedEvent.php new file mode 100644 index 000000000..9793a5296 --- /dev/null +++ b/app/Events/ServerDeletedEvent.php @@ -0,0 +1,17 @@ +authorize('viewAny', [ScriptEventHook::class, $script]); + + return Inertia::render('scripts/hooks', [ + 'script' => $script, + 'hooks' => ScriptEventHookResource::collection( + $script->hooks()->with('server')->latest()->simplePaginate(config('web.pagination_size')) + ), + ]); + } + + #[Post('/', name: 'scripts.hooks.store')] + public function store(Request $request, Script $script): RedirectResponse + { + $this->authorize('create', [ScriptEventHook::class, $script]); + + app(CreateScriptEventHook::class)->create(user(), $script, $request->input()); + + return back()->with('success', 'Hook created.'); + } + + #[Put('/{hook}', name: 'scripts.hooks.update')] + public function update(Request $request, Script $script, ScriptEventHook $hook): RedirectResponse + { + abort_if($hook->script_id !== $script->id, 404); + $this->authorize('update', $hook); + + app(UpdateScriptEventHook::class)->update($hook, user(), $request->input()); + + return back()->with('success', 'Hook updated.'); + } + + #[Delete('/{hook}', name: 'scripts.hooks.destroy')] + public function destroy(Script $script, ScriptEventHook $hook): RedirectResponse + { + abort_if($hook->script_id !== $script->id, 404); + $this->authorize('delete', $hook); + + $hook->delete(); + + return back()->with('success', 'Hook deleted.'); + } +} diff --git a/app/Http/Resources/ScriptEventHookResource.php b/app/Http/Resources/ScriptEventHookResource.php new file mode 100644 index 000000000..af3055512 --- /dev/null +++ b/app/Http/Resources/ScriptEventHookResource.php @@ -0,0 +1,33 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'script_id' => $this->script_id, + 'user_id' => $this->user_id, + 'project_id' => $this->project_id, + 'server_id' => $this->server_id, + 'server' => new ServerResource($this->whenLoaded('server')), + 'event' => $this->event->getText(), + 'event_value' => $this->event->value, + 'event_color' => $this->event->getColor(), + 'user' => $this->user, + 'enabled' => $this->enabled, + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + ]; + } +} diff --git a/app/Jobs/Service/InstallJob.php b/app/Jobs/Service/InstallJob.php index 979d1a7a8..50ee5db14 100644 --- a/app/Jobs/Service/InstallJob.php +++ b/app/Jobs/Service/InstallJob.php @@ -4,6 +4,7 @@ use App\DTOs\SocketEventDTO; use App\Enums\ServiceStatus; +use App\Events\ServiceInstalledEvent; use App\Events\SocketEvent; use App\Http\Resources\ServiceResource; use App\Models\ServerLog; @@ -33,6 +34,7 @@ public function handle(): void $this->service->installed_version = $this->service->handler()->version(); $this->service->save(); $this->broadcastServiceUpdate('service.updated'); + ServiceInstalledEvent::dispatch($this->service); Log::info("Service ID {$this->service->id} installed successfully"); }); } diff --git a/app/Jobs/Service/UninstallJob.php b/app/Jobs/Service/UninstallJob.php index 3bd89b158..17ce7d199 100644 --- a/app/Jobs/Service/UninstallJob.php +++ b/app/Jobs/Service/UninstallJob.php @@ -4,6 +4,7 @@ use App\DTOs\SocketEventDTO; use App\Enums\ServiceStatus; +use App\Events\ServiceUninstalledEvent; use App\Events\SocketEvent; use App\Http\Resources\ServiceResource; use App\Models\ServerLog; @@ -25,8 +26,12 @@ public function handle(): void $this->run("server-{$this->service->server_id}", function () { $projectId = $this->service->server->project_id; $serviceId = $this->service->id; + $serviceName = $this->service->name; + $serviceType = $this->service->type; + $serverId = $this->service->server_id; $this->service->handler()->uninstall(); $this->service->delete(); + ServiceUninstalledEvent::dispatch($serviceId, $serviceName, $serviceType, $serverId, $projectId); SocketEvent::dispatch(new SocketEventDTO( projectId: $projectId, diff --git a/app/Listeners/RunScriptEventHooks.php b/app/Listeners/RunScriptEventHooks.php new file mode 100644 index 000000000..c00c09992 --- /dev/null +++ b/app/Listeners/RunScriptEventHooks.php @@ -0,0 +1,125 @@ +extractContext($event); + + ScriptEventHook::query() + ->where('project_id', $projectId) + ->where('event', $eventEnum->value) + ->where('enabled', true) + ->with(['script', 'server']) + ->get() + ->each(function (ScriptEventHook $hook) use ($eventEnum, $variables): void { + try { + app(ExecuteScript::class)->executeForHook($hook, $variables); + } catch (Throwable $e) { + Log::error('Script event hook failed', [ + 'hook_id' => $hook->id, + 'event' => $eventEnum->value, + 'error' => $e->getMessage(), + ]); + } + }); + } + + /** + * @return array{0: ScriptEventHookEvent, 1: int, 2: array} + */ + private function extractContext( + SiteCreatedEvent|SiteDeletedEvent|ServerInstalledEvent|ServerDeletedEvent|ServiceInstalledEvent|ServiceUninstalledEvent $event + ): array { + if ($event instanceof SiteCreatedEvent) { + return [ + ScriptEventHookEvent::SITE_CREATED, + $event->site->server->project_id, + [ + 'site_domain' => $event->site->domain, + 'site_path' => $event->site->path, + 'site_type' => $event->site->type, + 'server_name' => $event->site->server->name, + 'server_ip' => $event->site->server->ip, + ], + ]; + } + + if ($event instanceof SiteDeletedEvent) { + return [ + ScriptEventHookEvent::SITE_DELETED, + $event->server->project_id, + [ + 'site_domain' => $event->domain, + 'server_name' => $event->server->name, + 'server_ip' => $event->server->ip, + ], + ]; + } + + if ($event instanceof ServerInstalledEvent) { + return [ + ScriptEventHookEvent::SERVER_INSTALLED, + $event->server->project_id, + [ + 'server_name' => $event->server->name, + 'server_ip' => $event->server->ip, + ], + ]; + } + + if ($event instanceof ServerDeletedEvent) { + return [ + ScriptEventHookEvent::SERVER_DELETED, + $event->projectId, + [ + 'server_name' => $event->serverName, + 'server_ip' => $event->serverIp, + ], + ]; + } + + if ($event instanceof ServiceInstalledEvent) { + return [ + ScriptEventHookEvent::SERVICE_INSTALLED, + $event->service->server->project_id, + [ + 'service_name' => $event->service->name, + 'service_type' => $event->service->type, + 'service_version' => $event->service->installed_version ?? $event->service->version, + 'server_name' => $event->service->server->name, + 'server_ip' => $event->service->server->ip, + ], + ]; + } + + $server = Server::query()->find($event->serverId); + + return [ + ScriptEventHookEvent::SERVICE_UNINSTALLED, + $event->projectId, + [ + 'service_name' => $event->serviceName, + 'service_type' => $event->serviceType, + 'server_name' => $server->name ?? '', + 'server_ip' => $server->ip ?? '', + ], + ]; + } +} diff --git a/app/Models/Script.php b/app/Models/Script.php index 55b5adf6b..34acca98f 100644 --- a/app/Models/Script.php +++ b/app/Models/Script.php @@ -23,6 +23,7 @@ * @property User $user * @property ?int $project_id * @property ?Project $project + * @property Collection $hooks */ class Script extends AbstractModel { @@ -94,6 +95,14 @@ public function lastExecution(): HasOne return $this->hasOne(ScriptExecution::class)->latest(); } + /** + * @return HasMany + */ + public function hooks(): HasMany + { + return $this->hasMany(ScriptEventHook::class); + } + /** * @return Builder