From 5848b07fc24499c611584b58328965b5e856c2bc Mon Sep 17 00:00:00 2001 From: Yanluis Fermin <32645451+Jacxk@users.noreply.github.com> Date: Tue, 29 Jul 2025 21:42:47 -0400 Subject: [PATCH 001/151] feat(api): add endpoint to retrieve database logs by UUID --- .../Controllers/Api/DatabasesController.php | 101 ++++++++++++++++++ bootstrap/helpers/docker.php | 13 +++ routes/api.php | 1 + 3 files changed, 115 insertions(+) diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 504665f6a..fc2b7b6d0 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -1535,6 +1535,107 @@ public function create_database(Request $request, NewDatabaseTypes $type) return response()->json(['message' => 'Invalid database type requested.'], 400); } + #[OA\Get( + summary: 'Get database logs.', + description: 'Get database logs by UUID.', + path: '/databases/{uuid}/logs', + operationId: 'get-database-logs-by-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Databases'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the database.', + required: true, + schema: new OA\Schema( + type: 'string', + format: 'uuid', + ) + ), + new OA\Parameter( + name: 'lines', + in: 'query', + description: 'Number of lines to show from the end of the logs.', + required: false, + schema: new OA\Schema( + type: 'integer', + format: 'int32', + default: 100, + ) + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Get database logs by UUID.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'logs' => ['type' => 'string'], + ] + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + ] + )] + public function logs_by_uuid(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + $uuid = $request->route('uuid'); + if (! $uuid) { + return response()->json(['message' => 'UUID is required.'], 400); + } + $database = queryDatabaseByUuidWithinTeam($uuid, $teamId); + if (! $database) { + return response()->json(['message' => 'Database not found.'], 404); + } + + $containers = getCurrentDatabaseContainerStatus($database->destination->server, $database->id); + + if ($containers->count() == 0) { + return response()->json([ + 'message' => 'Database is not running.', + ], 400); + } + + $container = $containers->first(); + + $status = getContainerStatus($database->destination->server, $container['Names']); + if ($status !== 'running') { + return response()->json([ + 'message' => 'Database is not running.', + ], 400); + } + + $lines = $request->query->get('lines', 100) ?: 100; + $logs = getContainerLogs($database->destination->server, $container['ID'], $lines); + + return response()->json([ + 'logs' => $logs, + ]); + } #[OA\Delete( summary: 'Delete', diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index 944c51e3c..cac8ffb6c 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -53,6 +53,19 @@ function getCurrentServiceContainerStatus(Server $server, int $id): Collection return $containers; } +function getCurrentDatabaseContainerStatus(Server $server, int $id): Collection +{ + $containers = collect([]); + if (! $server->isSwarm()) { + $containers = instant_remote_process(["docker ps -a --filter='label=coolify.databaseId={$id}' --format '{{json .}}' "], $server); + $containers = format_docker_command_output_to_json($containers); + + return $containers->filter(); + } + + return $containers; +} + function format_docker_command_output_to_json($rawOutput): Collection { $outputLines = explode(PHP_EOL, $rawOutput); diff --git a/routes/api.php b/routes/api.php index d63e3ee0e..958c88fda 100644 --- a/routes/api.php +++ b/routes/api.php @@ -112,6 +112,7 @@ Route::get('/databases/{uuid}', [DatabasesController::class, 'database_by_uuid'])->middleware(['api.ability:read']); Route::patch('/databases/{uuid}', [DatabasesController::class, 'update_by_uuid'])->middleware(['api.ability:write']); Route::delete('/databases/{uuid}', [DatabasesController::class, 'delete_by_uuid'])->middleware(['api.ability:write']); + Route::get('/databases/{uuid}/logs', [DatabasesController::class, 'logs_by_uuid'])->middleware(['api.ability:read']); Route::match(['get', 'post'], '/databases/{uuid}/start', [DatabasesController::class, 'action_deploy'])->middleware(['api.ability:write']); Route::match(['get', 'post'], '/databases/{uuid}/restart', [DatabasesController::class, 'action_restart'])->middleware(['api.ability:write']); From bc9bfaefc78cb0436795f59ab3a6f98c5d1e7039 Mon Sep 17 00:00:00 2001 From: Yanluis Fermin <32645451+Jacxk@users.noreply.github.com> Date: Tue, 29 Jul 2025 22:40:02 -0400 Subject: [PATCH 002/151] feat(api): add endpoints to retrieve service logs by UUID for each container --- .../Controllers/Api/ServicesController.php | 115 ++++++++++++++++++ routes/api.php | 1 + 2 files changed, 116 insertions(+) diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 542be83de..61ff80a60 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -448,6 +448,121 @@ public function service_by_uuid(Request $request) return response()->json($this->removeSensitiveData($service)); } + #[OA\Get( + summary: 'Get service logs.', + description: 'Get service logs by UUID.', + path: '/services/{uuid}/containers/{container_id}/logs', + operationId: 'get-service-logs-by-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Services'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the service.', + required: true, + schema: new OA\Schema( + type: 'string', + format: 'uuid', + ) + ), + new OA\Parameter( + name: 'container_id', + in: 'path', + description: 'Container ID.', + required: true, + schema: new OA\Schema(type: 'string'), + ), + new OA\Parameter( + name: 'lines', + in: 'query', + description: 'Number of lines to show from the end of the logs.', + required: false, + schema: new OA\Schema( + type: 'integer', + format: 'int32', + default: 100, + ) + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Get service logs by UUID.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'logs' => ['type' => 'string'], + ] + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + ] + )] + public function logs_by_uuid(Request $request) + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + $uuid = $request->route('uuid'); + if (! $uuid) { + return response()->json(['message' => 'UUID is required.'], 400); + } + $service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first(); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + $containers = getCurrentServiceContainerStatus($service->destination->server, $service->id); + + if ($containers->count() == 0) { + return response()->json([ + 'message' => 'Service is not running.', + ], 400); + } + + $container = $containers->first(function ($container) use ($request) { + return $container['ID'] === $request->container_id; + }); + + if (! $container) { + return response()->json(['message' => 'Container not found.'], 404); + } + + $status = getContainerStatus($service->destination->server, $container['Names']); + if ($status !== 'running') { + return response()->json([ + 'message' => 'Container is not running.', + ], 400); + } + + $lines = $request->query->get('lines', 100) ?: 100; + $logs = getContainerLogs($service->destination->server, $container['ID'], $lines); + + return response()->json([ + 'logs' => $logs, + ]); + } + #[OA\Delete( summary: 'Delete', description: 'Delete service by UUID.', diff --git a/routes/api.php b/routes/api.php index 958c88fda..c790627b8 100644 --- a/routes/api.php +++ b/routes/api.php @@ -130,6 +130,7 @@ Route::patch('/services/{uuid}/envs/bulk', [ServicesController::class, 'create_bulk_envs'])->middleware(['api.ability:write']); Route::patch('/services/{uuid}/envs', [ServicesController::class, 'update_env_by_uuid'])->middleware(['api.ability:write']); Route::delete('/services/{uuid}/envs/{env_uuid}', [ServicesController::class, 'delete_env_by_uuid'])->middleware(['api.ability:write']); + Route::get('/services/{uuid}/containers/{container_id}/logs', [ServicesController::class, 'logs_by_uuid'])->middleware(['api.ability:read']); Route::match(['get', 'post'], '/services/{uuid}/start', [ServicesController::class, 'action_deploy'])->middleware(['api.ability:write']); Route::match(['get', 'post'], '/services/{uuid}/restart', [ServicesController::class, 'action_restart'])->middleware(['api.ability:write']); From 28e20473da9abd641dfc6dd6306f01808c36beac Mon Sep 17 00:00:00 2001 From: Yanluis Fermin <32645451+Jacxk@users.noreply.github.com> Date: Wed, 30 Jul 2025 11:29:27 -0400 Subject: [PATCH 003/151] refactor(api): update service logs endpoint to use sub service name --- .../Controllers/Api/ServicesController.php | 18 ++++++++++-------- bootstrap/helpers/docker.php | 13 +++++++++++++ routes/api.php | 2 +- 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 61ff80a60..299af54e0 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -451,7 +451,7 @@ public function service_by_uuid(Request $request) #[OA\Get( summary: 'Get service logs.', description: 'Get service logs by UUID.', - path: '/services/{uuid}/containers/{container_id}/logs', + path: '/services/{uuid}/logs', operationId: 'get-service-logs-by-uuid', security: [ ['bearerAuth' => []], @@ -469,9 +469,9 @@ public function service_by_uuid(Request $request) ) ), new OA\Parameter( - name: 'container_id', - in: 'path', - description: 'Container ID.', + name: 'sub_service_name', + in: 'query', + description: 'Sub service name.', required: true, schema: new OA\Schema(type: 'string'), ), @@ -527,12 +527,16 @@ public function logs_by_uuid(Request $request) if (! $uuid) { return response()->json(['message' => 'UUID is required.'], 400); } + $subServiceName = $request->query->get('sub_service_name'); + if (! $subServiceName) { + return response()->json(['message' => 'Sub service name is required.'], 400); + } $service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first(); if (! $service) { return response()->json(['message' => 'Service not found.'], 404); } - $containers = getCurrentServiceContainerStatus($service->destination->server, $service->id); + $containers = getCurrentServiceSubContainerStatus($service->destination->server, $service->id, $subServiceName); if ($containers->count() == 0) { return response()->json([ @@ -540,9 +544,7 @@ public function logs_by_uuid(Request $request) ], 400); } - $container = $containers->first(function ($container) use ($request) { - return $container['ID'] === $request->container_id; - }); + $container = $containers->first(); if (! $container) { return response()->json(['message' => 'Container not found.'], 404); diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index cac8ffb6c..26704060c 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -66,6 +66,19 @@ function getCurrentDatabaseContainerStatus(Server $server, int $id): Collection return $containers; } +function getCurrentServiceSubContainerStatus(Server $server, int $id, string $subName): Collection +{ + $containers = collect([]); + if (! $server->isSwarm()) { + $containers = instant_remote_process(["docker ps -a --filter='label=coolify.serviceId={$id}' --filter='label=coolify.service.subName={$subName}' --format '{{json .}}' "], $server); + $containers = format_docker_command_output_to_json($containers); + + return $containers->filter(); + } + + return $containers; +} + function format_docker_command_output_to_json($rawOutput): Collection { $outputLines = explode(PHP_EOL, $rawOutput); diff --git a/routes/api.php b/routes/api.php index c790627b8..8fec3e3a5 100644 --- a/routes/api.php +++ b/routes/api.php @@ -130,7 +130,7 @@ Route::patch('/services/{uuid}/envs/bulk', [ServicesController::class, 'create_bulk_envs'])->middleware(['api.ability:write']); Route::patch('/services/{uuid}/envs', [ServicesController::class, 'update_env_by_uuid'])->middleware(['api.ability:write']); Route::delete('/services/{uuid}/envs/{env_uuid}', [ServicesController::class, 'delete_env_by_uuid'])->middleware(['api.ability:write']); - Route::get('/services/{uuid}/containers/{container_id}/logs', [ServicesController::class, 'logs_by_uuid'])->middleware(['api.ability:read']); + Route::get('/services/{uuid}/logs', [ServicesController::class, 'logs_by_uuid'])->middleware(['api.ability:read']); Route::match(['get', 'post'], '/services/{uuid}/start', [ServicesController::class, 'action_deploy'])->middleware(['api.ability:write']); Route::match(['get', 'post'], '/services/{uuid}/restart', [ServicesController::class, 'action_restart'])->middleware(['api.ability:write']); From c239b8bbba07c0f9b6f9f5507581f2d07853a11a Mon Sep 17 00:00:00 2001 From: Yanluis Fermin <32645451+Jacxk@users.noreply.github.com> Date: Wed, 30 Jul 2025 13:41:17 -0400 Subject: [PATCH 004/151] refactor(api): modify service sub container retrieval filter to use coolify.name --- app/Http/Controllers/Api/ServicesController.php | 10 ++-------- bootstrap/helpers/docker.php | 4 ++-- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 299af54e0..edec32db5 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -536,14 +536,8 @@ public function logs_by_uuid(Request $request) return response()->json(['message' => 'Service not found.'], 404); } - $containers = getCurrentServiceSubContainerStatus($service->destination->server, $service->id, $subServiceName); - - if ($containers->count() == 0) { - return response()->json([ - 'message' => 'Service is not running.', - ], 400); - } - + $name = "{$subServiceName}-{$service->uuid}"; + $containers = getCurrentServiceSubContainerStatus($service->destination->server, $service->id, $name); $container = $containers->first(); if (! $container) { diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index 26704060c..87dc47336 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -66,11 +66,11 @@ function getCurrentDatabaseContainerStatus(Server $server, int $id): Collection return $containers; } -function getCurrentServiceSubContainerStatus(Server $server, int $id, string $subName): Collection +function getCurrentServiceSubContainerStatus(Server $server, int $id, string $name): Collection { $containers = collect([]); if (! $server->isSwarm()) { - $containers = instant_remote_process(["docker ps -a --filter='label=coolify.serviceId={$id}' --filter='label=coolify.service.subName={$subName}' --format '{{json .}}' "], $server); + $containers = instant_remote_process(["docker ps -a --filter='label=coolify.serviceId={$id}' --filter='label=coolify.name={$name}' --format '{{json .}}' "], $server); $containers = format_docker_command_output_to_json($containers); return $containers->filter(); From 0eb2ea86e85693199e89606afeb960acbba2b5cf Mon Sep 17 00:00:00 2001 From: Yanluis Fermin <32645451+Jacxk@users.noreply.github.com> Date: Wed, 30 Jul 2025 21:32:11 -0400 Subject: [PATCH 005/151] feat(api): add 'show_timestamps' parameter to logs endpoints --- .../Controllers/Api/ApplicationsController.php | 12 ++++++++++-- app/Http/Controllers/Api/DatabasesController.php | 12 ++++++++++-- app/Http/Controllers/Api/ServicesController.php | 12 ++++++++++-- bootstrap/helpers/docker.php | 16 ++++++++-------- 4 files changed, 38 insertions(+), 14 deletions(-) diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 0860c7133..f2015de67 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -1553,6 +1553,13 @@ public function application_by_uuid(Request $request) default: 100, ) ), + new OA\Parameter( + name: 'show_timestamps', + in: 'query', + description: 'Show timestamps in the logs.', + required: false, + schema: new OA\Schema(type: 'boolean', default: false), + ), ], responses: [ new OA\Response( @@ -1616,8 +1623,9 @@ public function logs_by_uuid(Request $request) ], 400); } - $lines = $request->query->get('lines', 100) ?: 100; - $logs = getContainerLogs($application->destination->server, $container['ID'], $lines); + $lines = $request->query->get('lines', 100); + $showTimestamps = $request->query->get('show_timestamps', false); + $logs = getContainerLogs($application->destination->server, $container['ID'], $lines, $showTimestamps); return response()->json([ 'logs' => $logs, diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index fc2b7b6d0..dd4165c90 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -1566,6 +1566,13 @@ public function create_database(Request $request, NewDatabaseTypes $type) default: 100, ) ), + new OA\Parameter( + name: 'show_timestamps', + in: 'query', + description: 'Show timestamps in the logs.', + required: false, + schema: new OA\Schema(type: 'boolean', default: false), + ), ], responses: [ new OA\Response( @@ -1629,8 +1636,9 @@ public function logs_by_uuid(Request $request) ], 400); } - $lines = $request->query->get('lines', 100) ?: 100; - $logs = getContainerLogs($database->destination->server, $container['ID'], $lines); + $lines = $request->query->get('lines', 100); + $showTimestamps = $request->query->get('show_timestamps', false); + $logs = getContainerLogs($database->destination->server, $container['ID'], $lines, $showTimestamps); return response()->json([ 'logs' => $logs, diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index edec32db5..1fc4bb765 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -486,6 +486,13 @@ public function service_by_uuid(Request $request) default: 100, ) ), + new OA\Parameter( + name: 'show_timestamps', + in: 'query', + description: 'Show timestamps in the logs.', + required: false, + schema: new OA\Schema(type: 'boolean', default: false), + ), ], responses: [ new OA\Response( @@ -551,8 +558,9 @@ public function logs_by_uuid(Request $request) ], 400); } - $lines = $request->query->get('lines', 100) ?: 100; - $logs = getContainerLogs($service->destination->server, $container['ID'], $lines); + $lines = $request->query->get('lines', 100); + $showTimestamps = $request->query->get('show_timestamps', false); + $logs = getContainerLogs($service->destination->server, $container['ID'], $lines, $showTimestamps); return response()->json([ 'logs' => $logs, diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index 87dc47336..771937ce0 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -1115,18 +1115,18 @@ function validateComposeFile(string $compose, int $server_id): string|Throwable } } -function getContainerLogs(Server $server, string $container_id, int $lines = 100): string +function getContainerLogs(Server $server, string $container_id, int $lines = 100, bool $showTimestamps = false): string { + $command = "docker logs -n {$lines} {$container_id}"; if ($server->isSwarm()) { - $output = instant_remote_process([ - "docker service logs -n {$lines} {$container_id}", - ], $server); - } else { - $output = instant_remote_process([ - "docker logs -n {$lines} {$container_id}", - ], $server); + $command = "docker service logs -n {$lines} {$container_id}"; } + if ($showTimestamps) { + $command .= ' --timestamps'; + } + + $output = instant_remote_process([$command], $server); $output .= removeAnsiColors($output); return $output; From 555b46a7d84d22a0f9ff0f30f22d3797ff8b3171 Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Tue, 24 Feb 2026 00:45:33 +0530 Subject: [PATCH 006/151] chore(repo): improve contributor guidelines --- CONTRIBUTING.md | 463 +++++++++++++++++++++--------------------------- 1 file changed, 205 insertions(+), 258 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9aec08420..af8c7503c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,298 +1,245 @@ # Contributing to Coolify +We’re happy that you’re interested in contributing to Coolify! -> "First, thanks for considering contributing to my project. It really means a lot!" - [@andrasbacsai](https://github.com/andrasbacsai) +There are many ways to help: +- Answer questions in GitHub Discussions or Discord +- Report reproducible bugs +- Submit pull requests to fix issues +- Add new one-click services +- Improve documentation -You can ask for guidance anytime on our [Discord server](https://coollabs.io/discord) in the `#contribute` channel. +Coolify is a PaaS used by 400,000+ people worldwide and maintained by two active maintainers. Contributions are welcome — but **alignment matters more than quantity**. -To understand the tech stack, please refer to the [Tech Stack](TECH_STACK.md) document. - -## Table of Contents - -1. [Setup Development Environment](#1-setup-development-environment) -2. [Verify Installation](#2-verify-installation-optional) -3. [Fork and Setup Local Repository](#3-fork-and-setup-local-repository) -4. [Set up Environment Variables](#4-set-up-environment-variables) -5. [Start Coolify](#5-start-coolify) -6. [Start Development](#6-start-development) -7. [Create a Pull Request](#7-create-a-pull-request) -8. [Development Notes](#development-notes) -9. [Resetting Development Environment](#resetting-development-environment) -10. [Additional Contribution Guidelines](#additional-contribution-guidelines) - -## 1. Setup Development Environment - -Follow the steps below for your operating system: - -
-Windows - -1. Install `docker-ce`, Docker Desktop (or similar): - - Docker CE (recommended): - - Install Windows Subsystem for Linux v2 (WSL2) by following this guide: [Install WSL](https://learn.microsoft.com/en-us/windows/wsl/install?ref=coolify) - - After installing WSL2, install Docker CE for your Linux distribution by following this guide: [Install Docker Engine](https://docs.docker.com/engine/install/?ref=coolify) - - Make sure to choose the appropriate Linux distribution (e.g., Ubuntu) when following the Docker installation guide - - Install Docker Desktop (easier): - - Download and install [Docker Desktop for Windows](https://docs.docker.com/desktop/install/windows-install/?ref=coolify) - - Ensure WSL2 backend is enabled in Docker Desktop settings - -2. Install Spin: - - Follow the instructions to install Spin on Windows from the [Spin documentation](https://serversideup.net/open-source/spin/docs/installation/install-windows#download-and-install-spin-into-wsl2?ref=coolify) - -
- -
-MacOS - -1. Install Orbstack, Docker Desktop (or similar): - - Orbstack (recommended, as it is a faster and lighter alternative to Docker Desktop): - - Download and install [Orbstack](https://docs.orbstack.dev/quick-start#installation?ref=coolify) - - Docker Desktop: - - Download and install [Docker Desktop for Mac](https://docs.docker.com/desktop/install/mac-install/?ref=coolify) - -2. Install Spin: - - Follow the instructions to install Spin on MacOS from the [Spin documentation](https://serversideup.net/open-source/spin/docs/installation/install-macos/#download-and-install-spin?ref=coolify) - -
- -
-Linux - -1. Install Docker Engine, Docker Desktop (or similar): - - Docker Engine (recommended, as there is no VM overhead): - - Follow the official [Docker Engine installation guide](https://docs.docker.com/engine/install/?ref=coolify) for your Linux distribution - - Docker Desktop: - - If you want a GUI, you can use [Docker Desktop for Linux](https://docs.docker.com/desktop/install/linux-install/?ref=coolify) - -2. Install Spin: - - Follow the instructions to install Spin on Linux from the [Spin documentation](https://serversideup.net/open-source/spin/docs/installation/install-linux#configure-docker-permissions?ref=coolify) - -
- -## 2. Verify Installation (Optional) - -After installing Docker (or Orbstack) and Spin, verify the installation: - -1. Open a terminal or command prompt -2. Run the following commands: - ```bash - docker --version - spin --version - ``` - You should see version information for both Docker and Spin. - -## 3. Fork and Setup Local Repository - -1. Fork the [Coolify](https://github.com/coollabsio/coolify) repository to your GitHub account. - -2. Install a code editor on your machine (choose one): - - | Editor | Platform | Download Link | - |--------|----------|---------------| - | Visual Studio Code (recommended free) | Windows/macOS/Linux | [Download](https://code.visualstudio.com/download?ref=coolify) | - | Cursor (recommended but paid) | Windows/macOS/Linux | [Download](https://www.cursor.com/?ref=coolify) | - | Zed (very fast) | macOS/Linux | [Download](https://zed.dev/download?ref=coolify) | - -3. Clone the Coolify Repository from your fork to your local machine - - Use `git clone` in the command line, or - - Use GitHub Desktop (recommended): - - Download and install from [https://desktop.github.com/](https://desktop.github.com/?ref=coolify) - - Open GitHub Desktop and login with your GitHub account - - Click on `File` -> `Clone Repository` select `github.com` as the repository location, then select your forked Coolify repository, choose the local path and then click `Clone` - -4. Open the cloned Coolify Repository in your chosen code editor. - -## 4. Set up Environment Variables - -1. In the Code Editor, locate the `.env.development.example` file in the root directory of your local Coolify repository. -2. Duplicate the `.env.development.example` file and rename the copy to `.env`. -3. Open the new `.env` file and review its contents. Adjust any environment variables as needed for your development setup. -4. If you encounter errors during database migrations, update the database connection settings in your `.env` file. Use the IP address or hostname of your PostgreSQL database container. You can find this information by running `docker ps` after executing `spin up`. -5. Save the changes to your `.env` file. - -## 5. Start Coolify - -1. Open a terminal in the local Coolify directory. -2. Run the following command in the terminal (leave that terminal open): - ```bash - spin up - ``` - -> [!NOTE] -> You may see some errors, but don't worry; this is expected. - -3. If you encounter permission errors, especially on macOS, use: - ```bash - sudo spin up - ``` - -> [!NOTE] -> If you change environment variables afterwards or anything seems broken, press Ctrl + C to stop the process and run `spin up` again. - -## 6. Start Development - -1. Access your Coolify instance: - - URL: `http://localhost:8000` - - Login: `test@example.com` - - Password: `password` - -2. Additional development tools: - - | Tool | URL | Note | - |------|-----|------| - | Laravel Horizon (scheduler) | `http://localhost:8000/horizon` | Only accessible when logged in as root user | - | Mailpit (email catcher) | `http://localhost:8025` | | - | Telescope (debugging tool) | `http://localhost:8000/telescope` | Disabled by default | - -> [!NOTE] -> To enable Telescope, add the following to your `.env` file: -> ```env -> TELESCOPE_ENABLED=true -> ``` - -## 7. Create a Pull Request +This guide explains **what kind of contributions are likely to be accepted** and how to submit them properly. Following it saves time for both you and the maintainers. > [!IMPORTANT] -> Please read the [Pull Request Guidelines](#pull-request-guidelines) carefully before creating your PR. +> These guidelines may feel stricter than in many open-source projects. That is intentional. +> Clear structure and boundaries prevent maintainer burnout and keep the project sustainable long-term. -1. After making changes or adding a new service: - - Commit your changes to your forked repository. - - Push the changes to your GitHub account. -2. Creating the Pull Request (PR): - - Navigate to the main Coolify repository on GitHub. - - Click the "Pull requests" tab. - - Click the green "New pull request" button. - - Choose your fork and `next` branch as the compare branch. - - Click "Create pull request". +## High-Level Expectations +- Coolify has a clear product direction. +- Ownership and decisions are centralized. +- Review capacity is limited. +- Not every contribution will be accepted — even if technically correct. -3. Filling out the PR details: - - Give your PR a descriptive title. - - Use the Pull Request Template provided and fill in the details. +This is normal for a two-maintainer project. -> [!IMPORTANT] -> Always set the base branch for your PR to the `next` branch of the Coolify repository, not the `v4.x` branch. -4. Submit your PR: - - Review your changes one last time. - - Click "Create pull request" to submit. +## State of the Project +Coolify is currently at v4 and is still in beta. While v4 is stable, it has some limitations, including: +- Limited scaling support +- A more complex user experience +- Other smaller issues that need refinement -> [!NOTE] -> Make sure your PR is out of draft mode as soon as it's ready for review. PRs that are in draft mode for a long time may be closed by maintainers. +These limitations will be addressed in Coolify v5, which is in the planning stage. However, the maintainers are focused on releasing a stable v4 version before dedicating time to v5 development. Because of this, major features, architectural changes, or significant UI changes will not be accepted for v4 at this stage. -After submission, maintainers will review your PR and may request changes or provide feedback. +We welcome contributions that help stabilize v4, but larger changes will be saved for v5 once we have a stable v4 release. -#### Pull Request Guidelines -To maintain high-quality contributions and efficient review process: -- **Target Branch**: Always target the `next` branch, never `v4.x` or any other branch. PRs targeting incorrect branches will be closed without review. -- **Descriptive Titles**: Use clear, concise PR titles that describe the change (e.g., "fix: one click postgresql database stuck in restart loop" instead of "Fix database"). -- **PR Descriptions**: Provide detailed, meaningful descriptions. Avoid generic or AI-generated fluff. Include: - - What the change does - - Why it's needed - - How to test it - - Any breaking changes - - Screenshot or video recording of your changes working without any issues - - Links to related issues -- **Link to Issues**: All PRs must link to an existing GitHub issue. If no issue exists, create one first. Unrelated PRs may be closed. -- **Single Responsibility**: Each PR should address one issue or feature. Do not bundle unrelated changes. -- **Draft Mode**: Use draft PRs for work-in-progress. Convert to ready-for-review only when complete and tested. -- **Review Readiness**: Ensure your PR is ready for review within a reasonable timeframe (max 7 days in draft). Stale drafts may be closed. -- **Current Focus**: We are currently prioritizing stability and bug fixes over new features. PRs adding new features may not be reviewed, or may be closed without review to maintain focus. -- **Language Translations**: Coolify currently supports only English. Pull requests for new language translations will not be accepted. Multi-language support may be considered in the next major version (v5). -- **AI Usage Policy**: We are not against AI tools—we use them ourselves. However, AI discourse is mandatory: You must fully understand the changes in your PR and be able to explain them clearly. Many PRs using AI lack this understanding, leading to untested or incorrect submissions. If you use AI, ensure you can articulate what the code does, why it was changed, and how it was tested. -#### Review Process -- **Response Time**: Maintainers will review PRs promptly, but complex changes may take time. Be patient and responsive to feedback. -- **Revisions**: Address all review comments. Unresolved feedback may lead to PR closure. -- **Merge Criteria**: PRs are merged only after: - - All tests pass (including CI) - - Code review approval -- **Closing PRs**: PRs may be closed for: - - Inactivity (>7 days without response) - - Failure to meet guidelines - - Duplicate or superseded work - - Security or quality concerns +## What Makes a Strong Contribution +The following types of contributions are most likely to be accepted: -#### Code Quality, Testing, and Bounty Submissions -All contributions must adhere to the highest standards of code quality and testing: +- **Bug fixes** (with clear reproduction steps) +- **Documentation improvements** (typos, clarifications, examples, guides) +- **Features discussed and aligned beforehand** +- **New service templates** requested by the community +- **Small, focused pull requests** -- **Testing Required**: Every PR must include steps to test your changes. Untested code will not be reviewed or merged. -- **Local Verification**: Ensure your changes work in the development environment. Test all affected features thoroughly. -- **Code Standards**: Follow the existing code style, conventions, and patterns in the codebase. -- **No AI-Generated Code**: Do not submit code generated by AI tools without fully understanding and verifying it. AI-generated submissions that are untested or incorrect will be rejected immediately. +If your change is small and obvious (typo fix, small bug, minor docs update), you may open a pull request directly. -**For PRs that claim bounties:** -- **Eligibility**: Bounty PRs must strictly follow all guidelines above. Untested, poorly described, or non-compliant PRs will not qualify for bounty rewards. -- **Original Work**: Bounties are for genuine contributions. Submitting AI-generated or copied code solely for bounty claims will result in disqualification and potential removal from contributing. -- **Quality Standards**: Bounty submissions are held to even higher standards. Ensure comprehensive testing, clear documentation, and alignment with project goals. When maintainers review the changes, they should work as expected (the things mentioned in the PR description plus what the bounty issuer needs). -- **Claim Process**: Only successfully merged PRs that pass all reviews (core maintainers + bounty issuer) and meet bounty criteria will be awarded. Follow the issue's bounty guidelines precisely. -- **Prioritization**: Contributor PRs are prioritized over first-time or new contributors. -- **Developer Experience**: We highly advise beginners to avoid participating in bug bounties for our codebase. Most of the time, they don't know what they are changing, how it affects other parts of the system, or if their changes are even correct. -- **Review Comments**: When maintainers ask questions, you should be able to respond properly without generic or AI-generated fluff. +## Keep Changes Focused +Only modify what is necessary to achieve your goal. -## Development Notes +If you are fixing a bug in `file.yaml`, do not: +- Reformat unrelated files +- Refactor unrelated code +- Fix style issues elsewhere +- Combine multiple unrelated changes -When working on Coolify, keep the following in mind: +Even “improvements” increase review complexity. -1. **Database Migrations**: After switching branches or making changes to the database structure, always run migrations: - ```bash - docker exec -it coolify php artisan migrate - ``` +**One pull request = one logical change.** -2. **Resetting Development Setup**: To reset your development setup to a clean database with default values: - ```bash - docker exec -it coolify php artisan migrate:fresh --seed - ``` +If you want to refactor or clean up code, discuss it first and submit it separately. -3. **Troubleshooting**: If you encounter unexpected behavior, ensure your database is up-to-date with the latest migrations and if possible reset the development setup to eliminate any environment-specific issues. -> [!IMPORTANT] -> Forgetting to migrate the database can cause problems, so make it a habit to run migrations after pulling changes or switching branches. +## Discussion Is Required for Larger Changes +For anything beyond a small fix, you must discuss it before opening a pull request. -## Resetting Development Environment +This includes: +- New features +- UI/UX changes +- Changes to default behavior +- Refactors or cleanup work +- Performance rewrites +- Architectural changes +- Changes touching many files -If you encounter issues or break your database or something else, follow these steps to start from a clean slate (works since `v4.0.0-beta.342`): +Discussion happens in GitHub Discussions: https://github.com/coollabsio/coolify/discussions/categories/general -1. Stop all running containers `ctrl + c`. +Pull requests introducing major changes without prior discussion will be closed without review. -2. Remove all Coolify containers: - ```bash - docker rm coolify coolify-db coolify-redis coolify-realtime coolify-testing-host coolify-minio coolify-vite-1 coolify-mail - ``` +This ensures alignment before significant work is done. -3. Remove Coolify volumes (it is possible that the volumes have no `coolify` prefix on your machine, in that case remove the prefix from the command): - ```bash - docker volume rm coolify_dev_backups_data coolify_dev_postgres_data coolify_dev_redis_data coolify_dev_coolify_data coolify_dev_minio_data - ``` -4. Remove unused images: - ```bash - docker image prune -a - ``` +## What This Project Is Not +To set clear expectations: +- Coolify is not optimized for first-time open-source contributors +- We do not provide beginner-focused mentorship issues +- Large unsolicited changes are unlikely to be accepted +- Broad refactors or style rewrites are not helpful +- Low-effort AI-generated pull requests will be closed -5. Start Coolify again: - ```bash - spin up - ``` +AI usage is allowed. However, contributors must fully understand what their changes do and why. -6. Run database migrations and seeders: - ```bash - docker exec -it coolify php artisan migrate:fresh --seed - ``` +Clear expectations help everyone use their time effectively. -After completing these steps, you'll have a fresh development setup. -> [!IMPORTANT] -> Always run database migrations and seeders after switching branches or pulling updates to ensure your local database structure matches the current codebase and includes necessary seed data. +# Ways to Contribute +## 1. Support Contributions +We use Discord for most support requests and GitHub Discussions for help. -## Additional Contribution Guidelines +### Requesting Support +If you need help: +- Provide complete and detailed information +- Include logs, screenshots, and steps to reproduce +- Be respectful — support is voluntary -### Contributing a New Service +Do not ping people for attention. They respond when available. -To add a new service to Coolify, please refer to our documentation: -[Adding a New Service](https://coolify.io/docs/get-started/contribute/service) +### Providing Support +If you help others: +- Verify your information before sharing +- Be patient and respectful +- Remember that not everyone has the same experience level -### Contributing to Documentation -To contribute to the Coolify documentation, please refer to this guide: -[Contributing to the Coolify Documentation](https://github.com/coollabsio/documentation-coolify/blob/main/readme.md) +## 2. Bug Report Contributions +Create a GitHub issue **only** if: +- The bug is reproducible +- You have confirmed no existing issue already covers it + +For questions or general help, use GitHub Discussions or the Discord support channel. + +Bug reports must include: +- Clear reproduction steps +- Expected result +- Actual result + +Incomplete reports may be closed. + + +## 3. Code Contributions +Maintainers may close pull requests at their discretion, without explanation. + +### Issue Requirement +Every pull request should reference and close an Issue or Discussion. + +If none exists, create one first. + +Pull requests without linked issue or discussions may not be reviewed and can be closed at any time. + + +## Commit Message Format +All commits must start with an action and category: +- `fix(ui):` — UI-related fixes +- `feat(api):` — API-related changes +- `feat(service):` — One-click service changes + +Examples: +- `fix(api): version endpoint returns wrong data` +- `feat(service): add supabase` + +Use the commit description only for concise context. + +Walls of text listing every change in description will be rejected. + + +## Pull Request Title Format +Pull request titles follow the same format: +- `fix(ui):` +- `feat(api):` +- `feat(service):` + +Examples: +- `fix(api): version endpoint returns wrong data` +- `feat(service): add supabase` + + +## AI Usage Disclosure +If AI tools were used at any stage, mention it in the pull request description. + +AI is allowed. + +However: +- You must understand every change +- You must verify correctness +- You must ensure it follows project patterns + +AI-generated pull requests without clear understanding will be closed. + + +## Test Before Submitting +Before submitting a pull request: +- Test your changes thoroughly +- Verify they work in a clean environment +- Provide detailed testing steps in the PR description + +If maintainers cannot reproduce working behavior, the PR will be closed without further review. + + +## Submitting a Pull Request +- GitHub will auto-populate the PR template +- The contributor agreement must remain intact +- Pull requests without the contributor agreement will be closed +- All pull requests must target the `next` branch +- PRs targeting other branches will be closed without review + + +## Bounty Issues +Community members may create bounty issues for features or improvements. + +Important: +- Only existing contributors are eligible +- First-time contributors targeting bounty issues will be automatically closed and maybe excluded from contributing. + +This policy exists to prevent spam. We close 30+ bounty-targeted spam PRs per week. + + +## FAQ +**Q: Should I ask before fixing a typo or a small bug?** +A: No, small, obvious fixes like typos or narrowly-scoped bug fixes can be submitted as a PR directly. + +**Q: I have an idea for a new feature.** +A: Awesome! Discuss it first in GitHub Discussions or Discord. **Do not** open a PR for new features without prior alignment. + +**Q: My PR was closed without detailed feedback.** +A: This usually means it didn’t align with the project’s direction, required more review bandwidth than available, or targeted major changes not allowed in v4. This is normal for a two-maintainer project. + +**Q: Can I work on an open issue?** +A: Comment on the issue first to confirm it’s still relevant and that no one else is actively working on it. For anything beyond a small fix, discuss your approach before implementing. + +**Q: I noticed code that could be cleaned up while working on my change.** +A: Focus only on your stated goal. Cleanups or refactors should be submitted as separate PRs after discussion. + +**Q: Can I use AI to help with my PR?** +A: Yes, AI-assisted contributions are allowed. But you must fully understand and verify the changes. PRs that appear to be generated by AI without context understanding will be closed. + +**Q: Can I work on a bounty issue as a first-time contributor?** +A: No, bounty issues are reserved for existing contributors. First-time contributors targeting bounty issues will have their PRs closed without review. This prevents spam and ensures bounties go to contributors familiar with the project. + +**Q: My PR was closed without review. Can I submit a new one?** +A: Yes, but keep in mind a PR closure is feedback, not a rejection of your effort. It usually means the PR didn’t match the project goals or guidelines. Address these issues first — repeating the same approach may hurt your standing with maintainers. + + +# Development Guides +## Local Development +To build and run Coolify locally, see: [Development](./DEVELOPMENT.md) + +## Adding a New Service +To add a new one-click service, follow: https://coolify.io/docs/get-started/contribute/service + +## Contributing to Documentation +To contribute to documentation, see: https://coolify.io/docs/get-started/contribute/documentation \ No newline at end of file From 4776a572317cc1afc362c40a09dcad41b7fecfd8 Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Tue, 24 Feb 2026 00:46:28 +0530 Subject: [PATCH 007/151] chore(repo): improve development guide --- DEVELOPMENT.md | 212 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 212 insertions(+) create mode 100644 DEVELOPMENT.md diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 000000000..69f2dc760 --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,212 @@ +# Contributing to Coolify +> "First, thanks for considering contributing to my project. It really means a lot!" - [@andrasbacsai](https://github.com/andrasbacsai) + +You can ask for guidance anytime on our [Discord server](https://coollabs.io/discord) in the `#contribute` channel. + +To understand the tech stack, please refer to the [Tech Stack](TECH_STACK.md) document. + + +## Table of Contents +1. [Setup Development Environment](#1-setup-development-environment) +2. [Verify Installation](#2-verify-installation-optional) +3. [Fork and Setup Local Repository](#3-fork-and-setup-local-repository) +4. [Set up Environment Variables](#4-set-up-environment-variables) +5. [Start Coolify](#5-start-coolify) +6. [Start Development](#6-start-development) +7. [Create a Pull Request](#7-create-a-pull-request) +8. [Development Notes](#development-notes) +9. [Resetting Development Environment](#resetting-development-environment) +10. [Additional Contribution Guidelines](#additional-contribution-guidelines) + + +## 1. Setup Development Environment +Follow the steps below for your operating system: + +
+Windows + +1. Install `docker-ce`, Docker Desktop (or similar): + - Docker CE (recommended): + - Install Windows Subsystem for Linux v2 (WSL2) by following this guide: [Install WSL](https://learn.microsoft.com/en-us/windows/wsl/install?ref=coolify) + - After installing WSL2, install Docker CE for your Linux distribution by following this guide: [Install Docker Engine](https://docs.docker.com/engine/install/?ref=coolify) + - Make sure to choose the appropriate Linux distribution (e.g., Ubuntu) when following the Docker installation guide + - Install Docker Desktop (easier): + - Download and install [Docker Desktop for Windows](https://docs.docker.com/desktop/install/windows-install/?ref=coolify) + - Ensure WSL2 backend is enabled in Docker Desktop settings + +2. Install Spin: + - Follow the instructions to install Spin on Windows from the [Spin documentation](https://serversideup.net/open-source/spin/docs/installation/install-windows#download-and-install-spin-into-wsl2?ref=coolify) + +
+ +
+MacOS + +1. Install Orbstack, Docker Desktop (or similar): + - Orbstack (recommended, as it is a faster and lighter alternative to Docker Desktop): + - Download and install [Orbstack](https://docs.orbstack.dev/quick-start#installation?ref=coolify) + - Docker Desktop: + - Download and install [Docker Desktop for Mac](https://docs.docker.com/desktop/install/mac-install/?ref=coolify) + +2. Install Spin: + - Follow the instructions to install Spin on MacOS from the [Spin documentation](https://serversideup.net/open-source/spin/docs/installation/install-macos/#download-and-install-spin?ref=coolify) + +
+ +
+Linux + +1. Install Docker Engine, Docker Desktop (or similar): + - Docker Engine (recommended, as there is no VM overhead): + - Follow the official [Docker Engine installation guide](https://docs.docker.com/engine/install/?ref=coolify) for your Linux distribution + - Docker Desktop: + - If you want a GUI, you can use [Docker Desktop for Linux](https://docs.docker.com/desktop/install/linux-install/?ref=coolify) + +2. Install Spin: + - Follow the instructions to install Spin on Linux from the [Spin documentation](https://serversideup.net/open-source/spin/docs/installation/install-linux#configure-docker-permissions?ref=coolify) + +
+ + +## 2. Verify Installation (Optional) +After installing Docker (or Orbstack) and Spin, verify the installation: + +1. Open a terminal or command prompt +2. Run the following commands: + ```bash + docker --version + spin --version + ``` + You should see version information for both Docker and Spin. + + +## 3. Fork and Setup Local Repository +1. Fork the [Coolify](https://github.com/coollabsio/coolify) repository to your GitHub account. + +2. Install a code editor on your machine (choose one): + + | Editor | Platform | Download Link | + |--------|----------|---------------| + | Visual Studio Code (recommended free) | Windows/macOS/Linux | [Download](https://code.visualstudio.com/download?ref=coolify) | + | Cursor (recommended but paid) | Windows/macOS/Linux | [Download](https://www.cursor.com/?ref=coolify) | + | Zed (very fast) | Windows/macOS/Linux | [Download](https://zed.dev/download?ref=coolify) | + +3. Clone the Coolify Repository from your fork to your local machine + - Use `git clone` in the command line, or + - Use GitHub Desktop (recommended): + - Download and install from [https://desktop.github.com/](https://desktop.github.com/?ref=coolify) + - Open GitHub Desktop and login with your GitHub account + - Click on `File` -> `Clone Repository` select `github.com` as the repository location, then select your forked Coolify repository, choose the local path and then click `Clone` + +4. Open the cloned Coolify Repository in your chosen code editor. + + +## 4. Set up Environment Variables +1. In the Code Editor, locate the `.env.development.example` file in the root directory of your local Coolify repository. +2. Duplicate the `.env.development.example` file and rename the copy to `.env`. +3. Open the new `.env` file and review its contents. Adjust any environment variables as needed for your development setup. +4. If you encounter errors during database migrations, update the database connection settings in your `.env` file. Use the IP address or hostname of your PostgreSQL database container. You can find this information by running `docker ps` after executing `spin up`. +5. Save the changes to your `.env` file. + + +## 5. Start Coolify +1. Open a terminal in the local Coolify directory. +2. Run the following command in the terminal (leave that terminal open): + ```bash + spin up + ``` + +> [!NOTE] +> You may see some errors, but don't worry; this is expected. + +3. If you encounter permission errors, especially on macOS, use: + ```bash + sudo spin up + ``` + +> [!NOTE] +> If you change environment variables afterwards or anything seems broken, press Ctrl + C to stop the process and run `spin up` again. + + +## 6. Start Development +1. Access your Coolify instance: + - URL: `http://localhost:8000` + - Login: `test@example.com` + - Password: `password` + +2. Additional development tools: + + | Tool | URL | Note | + |------|-----|------| + | Laravel Horizon (scheduler) | `http://localhost:8000/horizon` | Only accessible when logged in as root user | + | Mailpit (email catcher) | `http://localhost:8025` | | + | Telescope (debugging tool) | `http://localhost:8000/telescope` | Disabled by default | + +> [!NOTE] +> To enable Telescope, add the following to your `.env` file: +> ```env +> TELESCOPE_ENABLED=true +> ``` + + +## Development Notes +When working on Coolify, keep the following in mind: + +1. **Database Migrations**: After switching branches or making changes to the database structure, always run migrations: + ```bash + docker exec -it coolify php artisan migrate + ``` + +2. **Resetting Development Setup**: To reset your development setup to a clean database with default values: + ```bash + docker exec -it coolify php artisan migrate:fresh --seed + ``` + +3. **Troubleshooting**: If you encounter unexpected behavior, ensure your database is up-to-date with the latest migrations and if possible reset the development setup to eliminate any environment-specific issues. + +> [!IMPORTANT] +> Forgetting to migrate the database can cause problems, so make it a habit to run migrations after pulling changes or switching branches. + + +## Resetting Development Environment +If you encounter issues or break your database or something else, follow these steps to start from a clean slate (works since `v4.0.0-beta.342`): + +1. Stop all running containers `ctrl + c`. + +2. Remove all Coolify containers: + ```bash + docker rm coolify coolify-db coolify-redis coolify-realtime coolify-testing-host coolify-minio coolify-vite-1 coolify-mail + ``` + +3. Remove Coolify volumes (it is possible that the volumes have no `coolify` prefix on your machine, in that case remove the prefix from the command): + ```bash + docker volume rm coolify_dev_backups_data coolify_dev_postgres_data coolify_dev_redis_data coolify_dev_coolify_data coolify_dev_minio_data + ``` + +4. Remove unused images: + ```bash + docker image prune -a + ``` + +5. Start Coolify again: + ```bash + spin up + ``` + +6. Run database migrations and seeders: + ```bash + docker exec -it coolify php artisan migrate:fresh --seed + ``` + +After completing these steps, you'll have a fresh development setup. + +> [!IMPORTANT] +> Always run database migrations and seeders after switching branches or pulling updates to ensure your local database structure matches the current codebase and includes necessary seed data. + + +## Additional Development Guidelines +### Adding a New Service +To add a new service to Coolify, please refer to our documentation: [Adding a New Service](https://coolify.io/docs/get-started/contribute/service) + +### Development for Documentation +To contribute to the Coolify documentation, please refer to this guide: [Contributing to the Coolify Documentation](https://coolify.io/docs/get-started/contribute/documentation) \ No newline at end of file From 84224d63666a3d66ebc25fb3f79843dcfaff7099 Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Tue, 24 Feb 2026 00:49:45 +0530 Subject: [PATCH 008/151] chore(repo): improve bug report issue template --- .github/ISSUE_TEMPLATE/01_BUG_REPORT.yml | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/01_BUG_REPORT.yml b/.github/ISSUE_TEMPLATE/01_BUG_REPORT.yml index 42df4785e..b785ba5be 100644 --- a/.github/ISSUE_TEMPLATE/01_BUG_REPORT.yml +++ b/.github/ISSUE_TEMPLATE/01_BUG_REPORT.yml @@ -1,7 +1,7 @@ name: 🐞 Bug Report description: "File a new bug report." title: "[Bug]: " -labels: ["🐛 Bug", "🔍 Triage"] +labels: ["🔍 Triage"] body: - type: markdown attributes: @@ -14,10 +14,22 @@ body: - type: textarea attributes: - label: Error Message and Logs + label: Description and Error Message description: Provide a detailed description of the error or exception you encountered, along with any relevant log output. validations: required: true + + - type: textarea + attributes: + label: Expected Behavior + description: Please describe what you expected to happen instead of the issue. Be as detailed as possible. + value: | + 1. + 2. + 3. + 4. + validations: + required: true - type: textarea attributes: @@ -58,6 +70,12 @@ body: label: Operating System and Version (self-hosted) description: Run `cat /etc/os-release` or `lsb_release -a` in your terminal and provide the operating system and version. placeholder: "Ubuntu 22.04" + + - type: textarea + attributes: + label: Screenshots / Visuals + description: If possible, provide screenshots, screen recordings, or diagrams to help illustrate the issue. + placeholder: "Attach images or provide links to recordings demonstrating the problem." - type: textarea attributes: From 2b04153dece1a131910bf2fcd4cfeb97f87e4e1f Mon Sep 17 00:00:00 2001 From: ShadowArcanist <162910371+ShadowArcanist@users.noreply.github.com> Date: Tue, 24 Feb 2026 00:50:46 +0530 Subject: [PATCH 009/151] chore(repo): improve enhancement bounty issue template --- .../ISSUE_TEMPLATE/02_ENHANCEMENT_BOUNTY.yml | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/.github/ISSUE_TEMPLATE/02_ENHANCEMENT_BOUNTY.yml b/.github/ISSUE_TEMPLATE/02_ENHANCEMENT_BOUNTY.yml index ef26125e0..b71f32bd2 100644 --- a/.github/ISSUE_TEMPLATE/02_ENHANCEMENT_BOUNTY.yml +++ b/.github/ISSUE_TEMPLATE/02_ENHANCEMENT_BOUNTY.yml @@ -20,6 +20,7 @@ body: - New Feature - New Service - Improvement + - Bug Fix validations: required: true @@ -29,3 +30,25 @@ body: description: Provide a detailed description of the feature, improvement, or service you are proposing. validations: required: true + + - type: textarea + attributes: + label: Requirements / How It Should Work + description: Describe in detail how the feature, service, or improvement should function. Include user flow, expected behavior, or technical implementation notes if applicable. + value: | + 1. + 2. + 3. + validations: + required: true + + - type: textarea + attributes: + label: Criteria to Satisfy Bounty + description: Define the conditions that must be met for the PR or contribution to be considered complete and eligible for the bounty. Be specific about functionality, tests, or documentation requirements. + value: | + 1. + 2. + 3. + validations: + required: true \ No newline at end of file From 86b05b902aedbbb074e73bfe233b3ed006f19b39 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 25 Feb 2026 14:20:29 +0100 Subject: [PATCH 010/151] fix(auth): enforce authorization checks across API and Livewire components - Add authorization checks to API controller endpoints (view, create, update, delete) - Wrap Livewire component methods with try-catch for consistent error handling - Add AuthorizesRequests trait to components requiring authorization checks - Ensure all sensitive operations verify user permissions before execution - Implement unified error handling with handleError() helper function --- .../Api/CloudProviderTokensController.php | 4 + app/Http/Controllers/Api/GithubController.php | 3 + .../Controllers/Api/HetznerController.php | 1 + .../Controllers/Api/ProjectController.php | 6 + .../Controllers/Api/SecurityController.php | 4 + .../Controllers/Api/ServersController.php | 5 + app/Http/Controllers/Api/TeamController.php | 2 + app/Livewire/Project/Application/Heading.php | 166 +++--- app/Livewire/Project/Application/Previews.php | 24 +- app/Livewire/Project/Database/BackupNow.php | 10 +- app/Livewire/Project/Database/Heading.php | 20 +- .../Project/Database/ScheduledBackups.php | 28 +- app/Livewire/Project/DeleteEnvironment.php | 24 +- app/Livewire/Project/DeleteProject.php | 24 +- app/Livewire/Project/Service/Heading.php | 56 +- app/Livewire/Project/Shared/Destination.php | 38 +- app/Livewire/Project/Shared/HealthChecks.php | 8 +- .../Project/Shared/ResourceOperations.php | 494 +++++++++--------- app/Livewire/Security/ApiTokens.php | 14 + app/Livewire/Security/CloudInitScriptForm.php | 20 +- .../Security/CloudProviderTokenForm.php | 6 +- app/Livewire/Security/CloudProviderTokens.php | 8 +- app/Livewire/Security/PrivateKey/Index.php | 10 +- app/Livewire/Server/New/ByHetzner.php | 18 +- app/Livewire/Server/Proxy.php | 12 +- .../Proxy/DynamicConfigurationNavbar.php | 50 +- app/Livewire/Server/Resources.php | 33 +- app/Livewire/Server/Security/Patches.php | 24 +- app/Livewire/Server/Show.php | 26 +- app/Livewire/Server/ValidateAndInstall.php | 46 +- app/Livewire/SharedVariables/Project/Show.php | 10 +- app/Livewire/SharedVariables/Team/Index.php | 10 +- app/Livewire/Storage/Show.php | 6 +- app/Livewire/Team/Index.php | 34 +- app/Models/Team.php | 2 +- app/Policies/ApiTokenPolicy.php | 44 +- app/Policies/ApplicationPolicy.php | 91 ++-- app/Policies/ApplicationPreviewPolicy.php | 51 +- app/Policies/ApplicationSettingPolicy.php | 29 +- app/Policies/DatabasePolicy.php | 60 ++- app/Policies/EnvironmentPolicy.php | 29 +- app/Policies/EnvironmentVariablePolicy.php | 33 +- app/Policies/GithubAppPolicy.php | 22 +- app/Policies/NotificationPolicy.php | 17 +- app/Policies/ProjectPolicy.php | 18 +- app/Policies/ResourceCreatePolicy.php | 6 +- app/Policies/ServerPolicy.php | 21 +- app/Policies/ServiceApplicationPolicy.php | 15 +- app/Policies/ServiceDatabasePolicy.php | 23 +- app/Policies/ServicePolicy.php | 72 ++- .../SharedEnvironmentVariablePolicy.php | 18 +- app/Policies/StandaloneDockerPolicy.php | 7 +- app/Policies/SwarmDockerPolicy.php | 7 +- .../applications/advanced.blade.php | 4 +- .../components/notification/navbar.blade.php | 2 +- .../components/services/advanced.blade.php | 8 +- .../project/application/heading.blade.php | 154 +++--- .../project/database/heading.blade.php | 128 ++--- .../project/service/heading.blade.php | 14 +- .../project/shared/health-checks.blade.php | 12 +- .../livewire/security/api-tokens.blade.php | 9 +- .../views/livewire/server/navbar.blade.php | 2 +- .../views/livewire/server/resources.blade.php | 32 +- tasks/lessons.md | 11 + tests/Feature/TeamPolicyTest.php | 48 ++ tests/Unit/Policies/ApiTokenPolicyTest.php | 167 ++++++ tests/Unit/Policies/ApplicationPolicyTest.php | 237 +++++++++ .../Policies/ApplicationPreviewPolicyTest.php | 239 +++++++++ .../Policies/ApplicationSettingPolicyTest.php | 178 +++++++ tests/Unit/Policies/DatabasePolicyTest.php | 221 ++++++++ tests/Unit/Policies/EnvironmentPolicyTest.php | 155 ++++++ .../EnvironmentVariablePolicyTest.php | 199 +++++++ tests/Unit/Policies/GithubAppPolicyTest.php | 189 +++++++ .../Unit/Policies/NotificationPolicyTest.php | 175 +++++++ tests/Unit/Policies/PrivateKeyPolicyTest.php | 73 +-- tests/Unit/Policies/ProjectPolicyTest.php | 122 +++++ .../Policies/ResourceCreatePolicyTest.php | 61 +++ tests/Unit/Policies/ServerPolicyTest.php | 157 ++++++ .../Policies/ServiceApplicationPolicyTest.php | 37 ++ .../Policies/ServiceDatabasePolicyTest.php | 37 ++ tests/Unit/Policies/ServicePolicyTest.php | 233 +++++++++ .../SharedEnvironmentVariablePolicyTest.php | 144 +++++ .../Policies/StandaloneDockerPolicyTest.php | 122 +++++ tests/Unit/Policies/SwarmDockerPolicyTest.php | 122 +++++ 84 files changed, 4046 insertions(+), 1055 deletions(-) create mode 100644 tasks/lessons.md create mode 100644 tests/Unit/Policies/ApiTokenPolicyTest.php create mode 100644 tests/Unit/Policies/ApplicationPolicyTest.php create mode 100644 tests/Unit/Policies/ApplicationPreviewPolicyTest.php create mode 100644 tests/Unit/Policies/ApplicationSettingPolicyTest.php create mode 100644 tests/Unit/Policies/DatabasePolicyTest.php create mode 100644 tests/Unit/Policies/EnvironmentPolicyTest.php create mode 100644 tests/Unit/Policies/EnvironmentVariablePolicyTest.php create mode 100644 tests/Unit/Policies/GithubAppPolicyTest.php create mode 100644 tests/Unit/Policies/NotificationPolicyTest.php create mode 100644 tests/Unit/Policies/ProjectPolicyTest.php create mode 100644 tests/Unit/Policies/ResourceCreatePolicyTest.php create mode 100644 tests/Unit/Policies/ServerPolicyTest.php create mode 100644 tests/Unit/Policies/ServiceApplicationPolicyTest.php create mode 100644 tests/Unit/Policies/ServiceDatabasePolicyTest.php create mode 100644 tests/Unit/Policies/ServicePolicyTest.php create mode 100644 tests/Unit/Policies/SharedEnvironmentVariablePolicyTest.php create mode 100644 tests/Unit/Policies/StandaloneDockerPolicyTest.php create mode 100644 tests/Unit/Policies/SwarmDockerPolicyTest.php diff --git a/app/Http/Controllers/Api/CloudProviderTokensController.php b/app/Http/Controllers/Api/CloudProviderTokensController.php index 5be82a31c..5ca7212fd 100644 --- a/app/Http/Controllers/Api/CloudProviderTokensController.php +++ b/app/Http/Controllers/Api/CloudProviderTokensController.php @@ -176,6 +176,7 @@ public function show(Request $request) if (is_null($token)) { return response()->json(['message' => 'Cloud provider token not found.'], 404); } + $this->authorize('view', $token); return response()->json($this->removeSensitiveData($token)); } @@ -242,6 +243,7 @@ public function store(Request $request) if (is_null($teamId)) { return invalidTokenResponse(); } + $this->authorize('create', [CloudProviderToken::class]); $return = validateIncomingRequest($request); if ($return instanceof \Illuminate\Http\JsonResponse) { @@ -386,6 +388,7 @@ public function update(Request $request) if (! $token) { return response()->json(['message' => 'Cloud provider token not found.'], 404); } + $this->authorize('update', $token); $token->update(array_intersect_key($body, array_flip($allowedFields))); @@ -459,6 +462,7 @@ public function destroy(Request $request) if (! $token) { return response()->json(['message' => 'Cloud provider token not found.'], 404); } + $this->authorize('delete', $token); if ($token->hasServers()) { return response()->json(['message' => 'Cannot delete token that is used by servers.'], 400); diff --git a/app/Http/Controllers/Api/GithubController.php b/app/Http/Controllers/Api/GithubController.php index f6a6b3513..b5cabcde0 100644 --- a/app/Http/Controllers/Api/GithubController.php +++ b/app/Http/Controllers/Api/GithubController.php @@ -180,6 +180,7 @@ public function create_github_app(Request $request) if (is_null($teamId)) { return invalidTokenResponse(); } + $this->authorize('create', [GithubApp::class]); $return = validateIncomingRequest($request); if ($return instanceof \Illuminate\Http\JsonResponse) { return $return; @@ -555,6 +556,7 @@ public function update_github_app(Request $request, $github_app_id) $githubApp = GithubApp::where('id', $github_app_id) ->where('team_id', $teamId) ->firstOrFail(); + $this->authorize('update', $githubApp); // Define allowed fields for update $allowedFields = [ @@ -721,6 +723,7 @@ public function delete_github_app($github_app_id) $githubApp = GithubApp::where('id', $github_app_id) ->where('team_id', $teamId) ->firstOrFail(); + $this->authorize('delete', $githubApp); // Check if the GitHub app is being used by any applications if ($githubApp->applications->isNotEmpty()) { diff --git a/app/Http/Controllers/Api/HetznerController.php b/app/Http/Controllers/Api/HetznerController.php index 2645c2df1..761e951ea 100644 --- a/app/Http/Controllers/Api/HetznerController.php +++ b/app/Http/Controllers/Api/HetznerController.php @@ -548,6 +548,7 @@ public function createServer(Request $request) if (is_null($teamId)) { return invalidTokenResponse(); } + $this->authorize('create', [Server::class]); $return = validateIncomingRequest($request); if ($return instanceof \Illuminate\Http\JsonResponse) { diff --git a/app/Http/Controllers/Api/ProjectController.php b/app/Http/Controllers/Api/ProjectController.php index da553a68c..33b28fc59 100644 --- a/app/Http/Controllers/Api/ProjectController.php +++ b/app/Http/Controllers/Api/ProjectController.php @@ -96,6 +96,7 @@ public function project_by_uuid(Request $request) if (! $project) { return response()->json(['message' => 'Project not found.'], 404); } + $this->authorize('view', $project); $project->load(['environments']); @@ -232,6 +233,7 @@ public function create_project(Request $request) if (is_null($teamId)) { return invalidTokenResponse(); } + $this->authorize('create', [Project::class]); $return = validateIncomingRequest($request); if ($return instanceof \Illuminate\Http\JsonResponse) { @@ -378,6 +380,7 @@ public function update_project(Request $request) if (! $project) { return response()->json(['message' => 'Project not found.'], 404); } + $this->authorize('update', $project); $project->update($request->only($allowedFields)); @@ -455,6 +458,7 @@ public function delete_project(Request $request) if (! $project) { return response()->json(['message' => 'Project not found.'], 404); } + $this->authorize('delete', $project); if (! $project->isEmpty()) { return response()->json(['message' => 'Project has resources, so it cannot be deleted.'], 400); } @@ -630,6 +634,7 @@ public function create_environment(Request $request) if (! $project) { return response()->json(['message' => 'Project not found.'], 404); } + $this->authorize('update', $project); $existingEnvironment = $project->environments()->where('name', $request->name)->first(); if ($existingEnvironment) { @@ -717,6 +722,7 @@ public function delete_environment(Request $request) if (! $environment) { return response()->json(['message' => 'Environment not found.'], 404); } + $this->authorize('delete', $environment); if (! $environment->isEmpty()) { return response()->json(['message' => 'Environment has resources, so it cannot be deleted.'], 400); diff --git a/app/Http/Controllers/Api/SecurityController.php b/app/Http/Controllers/Api/SecurityController.php index e7b36cb9a..4fe738871 100644 --- a/app/Http/Controllers/Api/SecurityController.php +++ b/app/Http/Controllers/Api/SecurityController.php @@ -109,6 +109,7 @@ public function key_by_uuid(Request $request) 'message' => 'Private Key not found.', ], 404); } + $this->authorize('view', $key); return response()->json($this->removeSensitiveData($key)); } @@ -175,6 +176,7 @@ public function create_key(Request $request) if (is_null($teamId)) { return invalidTokenResponse(); } + $this->authorize('create', [PrivateKey::class]); $return = validateIncomingRequest($request); if ($return instanceof \Illuminate\Http\JsonResponse) { return $return; @@ -330,6 +332,7 @@ public function update_key(Request $request) 'message' => 'Private Key not found.', ], 404); } + $this->authorize('update', $foundKey); $foundKey->update($request->all()); return response()->json(serializeApiResponse([ @@ -406,6 +409,7 @@ public function delete_key(Request $request) if (is_null($key)) { return response()->json(['message' => 'Private Key not found.'], 404); } + $this->authorize('delete', $key); if ($key->isInUse()) { return response()->json([ diff --git a/app/Http/Controllers/Api/ServersController.php b/app/Http/Controllers/Api/ServersController.php index 29c6b854a..67f61f347 100644 --- a/app/Http/Controllers/Api/ServersController.php +++ b/app/Http/Controllers/Api/ServersController.php @@ -144,6 +144,7 @@ public function server_by_uuid(Request $request) if (is_null($server)) { return response()->json(['message' => 'Server not found.'], 404); } + $this->authorize('view', $server); if ($with_resources) { $server['resources'] = $server->definedResources()->map(function ($resource) { $payload = [ @@ -464,6 +465,7 @@ public function create_server(Request $request) if (is_null($teamId)) { return invalidTokenResponse(); } + $this->authorize('create', [ModelsServer::class]); $return = validateIncomingRequest($request); if ($return instanceof \Illuminate\Http\JsonResponse) { @@ -664,6 +666,7 @@ public function update_server(Request $request) if (! $server) { return response()->json(['message' => 'Server not found.'], 404); } + $this->authorize('update', $server); if ($request->proxy_type) { $validProxyTypes = collect(ProxyTypes::cases())->map(function ($proxyType) { return str($proxyType->value)->lower(); @@ -757,6 +760,7 @@ public function delete_server(Request $request) if (! $server) { return response()->json(['message' => 'Server not found.'], 404); } + $this->authorize('delete', $server); if ($server->definedResources()->count() > 0) { return response()->json(['message' => 'Server has resources, so you need to delete them before.'], 400); } @@ -835,6 +839,7 @@ public function validate_server(Request $request) if (! $server) { return response()->json(['message' => 'Server not found.'], 404); } + $this->authorize('update', $server); ValidateServer::dispatch($server); return response()->json(['message' => 'Validation started.'], 201); diff --git a/app/Http/Controllers/Api/TeamController.php b/app/Http/Controllers/Api/TeamController.php index fd0282d96..bd0f48809 100644 --- a/app/Http/Controllers/Api/TeamController.php +++ b/app/Http/Controllers/Api/TeamController.php @@ -118,6 +118,7 @@ public function team_by_id(Request $request) if (is_null($team)) { return response()->json(['message' => 'Team not found.'], 404); } + $this->authorize('view', $team); $team = $this->removeSensitiveData($team); return response()->json( @@ -176,6 +177,7 @@ public function members_by_id(Request $request) if (is_null($team)) { return response()->json(['message' => 'Team not found.'], 404); } + $this->authorize('view', $team); $members = $team->members; $members->makeHidden([ 'pivot', diff --git a/app/Livewire/Project/Application/Heading.php b/app/Livewire/Project/Application/Heading.php index a46b2f19c..eb5b5f06c 100644 --- a/app/Livewire/Project/Application/Heading.php +++ b/app/Livewire/Project/Application/Heading.php @@ -65,58 +65,66 @@ public function manualCheckStatus() public function force_deploy_without_cache() { - $this->authorize('deploy', $this->application); + try { + $this->authorize('deploy', $this->application); - $this->deploy(force_rebuild: true); + $this->deploy(force_rebuild: true); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function deploy(bool $force_rebuild = false) { - $this->authorize('deploy', $this->application); + try { + $this->authorize('deploy', $this->application); - if ($this->application->build_pack === 'dockercompose' && is_null($this->application->docker_compose_raw)) { - $this->dispatch('error', 'Failed to deploy', 'Please load a Compose file first.'); + if ($this->application->build_pack === 'dockercompose' && is_null($this->application->docker_compose_raw)) { + $this->dispatch('error', 'Failed to deploy', 'Please load a Compose file first.'); - return; + return; + } + if ($this->application->destination->server->isSwarm() && str($this->application->docker_registry_image_name)->isEmpty()) { + $this->dispatch('error', 'Failed to deploy.', 'To deploy to a Swarm cluster you must set a Docker image name first.'); + + return; + } + if (data_get($this->application, 'settings.is_build_server_enabled') && str($this->application->docker_registry_image_name)->isEmpty()) { + $this->dispatch('error', 'Failed to deploy.', 'To use a build server, you must first set a Docker image.
More information here: documentation'); + + return; + } + if ($this->application->additional_servers->count() > 0 && str($this->application->docker_registry_image_name)->isEmpty()) { + $this->dispatch('error', 'Failed to deploy.', 'Before deploying to multiple servers, you must first set a Docker image in the General tab.
More information here: documentation'); + + return; + } + $this->setDeploymentUuid(); + $result = queue_application_deployment( + application: $this->application, + deployment_uuid: $this->deploymentUuid, + force_rebuild: $force_rebuild, + ); + if ($result['status'] === 'queue_full') { + $this->dispatch('error', 'Deployment queue full', $result['message']); + + return; + } + if ($result['status'] === 'skipped') { + $this->dispatch('error', 'Deployment skipped', $result['message']); + + return; + } + + return $this->redirectRoute('project.application.deployment.show', [ + 'project_uuid' => $this->parameters['project_uuid'], + 'application_uuid' => $this->parameters['application_uuid'], + 'deployment_uuid' => $this->deploymentUuid, + 'environment_uuid' => $this->parameters['environment_uuid'], + ], navigate: false); + } catch (\Throwable $e) { + return handleError($e, $this); } - if ($this->application->destination->server->isSwarm() && str($this->application->docker_registry_image_name)->isEmpty()) { - $this->dispatch('error', 'Failed to deploy.', 'To deploy to a Swarm cluster you must set a Docker image name first.'); - - return; - } - if (data_get($this->application, 'settings.is_build_server_enabled') && str($this->application->docker_registry_image_name)->isEmpty()) { - $this->dispatch('error', 'Failed to deploy.', 'To use a build server, you must first set a Docker image.
More information here: documentation'); - - return; - } - if ($this->application->additional_servers->count() > 0 && str($this->application->docker_registry_image_name)->isEmpty()) { - $this->dispatch('error', 'Failed to deploy.', 'Before deploying to multiple servers, you must first set a Docker image in the General tab.
More information here: documentation'); - - return; - } - $this->setDeploymentUuid(); - $result = queue_application_deployment( - application: $this->application, - deployment_uuid: $this->deploymentUuid, - force_rebuild: $force_rebuild, - ); - if ($result['status'] === 'queue_full') { - $this->dispatch('error', 'Deployment queue full', $result['message']); - - return; - } - if ($result['status'] === 'skipped') { - $this->dispatch('error', 'Deployment skipped', $result['message']); - - return; - } - - return $this->redirectRoute('project.application.deployment.show', [ - 'project_uuid' => $this->parameters['project_uuid'], - 'application_uuid' => $this->parameters['application_uuid'], - 'deployment_uuid' => $this->deploymentUuid, - 'environment_uuid' => $this->parameters['environment_uuid'], - ], navigate: false); } protected function setDeploymentUuid() @@ -127,45 +135,53 @@ protected function setDeploymentUuid() public function stop() { - $this->authorize('deploy', $this->application); + try { + $this->authorize('deploy', $this->application); - $this->dispatch('info', 'Gracefully stopping application.
It could take a while depending on the application.'); - StopApplication::dispatch($this->application, false, $this->docker_cleanup); + $this->dispatch('info', 'Gracefully stopping application.
It could take a while depending on the application.'); + StopApplication::dispatch($this->application, false, $this->docker_cleanup); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function restart() { - $this->authorize('deploy', $this->application); + try { + $this->authorize('deploy', $this->application); - if ($this->application->additional_servers->count() > 0 && str($this->application->docker_registry_image_name)->isEmpty()) { - $this->dispatch('error', 'Failed to deploy', 'Before deploying to multiple servers, you must first set a Docker image in the General tab.
More information here: documentation'); + if ($this->application->additional_servers->count() > 0 && str($this->application->docker_registry_image_name)->isEmpty()) { + $this->dispatch('error', 'Failed to deploy', 'Before deploying to multiple servers, you must first set a Docker image in the General tab.
More information here: documentation'); - return; + return; + } + + $this->setDeploymentUuid(); + $result = queue_application_deployment( + application: $this->application, + deployment_uuid: $this->deploymentUuid, + restart_only: true, + ); + if ($result['status'] === 'queue_full') { + $this->dispatch('error', 'Deployment queue full', $result['message']); + + return; + } + if ($result['status'] === 'skipped') { + $this->dispatch('success', 'Deployment skipped', $result['message']); + + return; + } + + return $this->redirectRoute('project.application.deployment.show', [ + 'project_uuid' => $this->parameters['project_uuid'], + 'application_uuid' => $this->parameters['application_uuid'], + 'deployment_uuid' => $this->deploymentUuid, + 'environment_uuid' => $this->parameters['environment_uuid'], + ], navigate: false); + } catch (\Throwable $e) { + return handleError($e, $this); } - - $this->setDeploymentUuid(); - $result = queue_application_deployment( - application: $this->application, - deployment_uuid: $this->deploymentUuid, - restart_only: true, - ); - if ($result['status'] === 'queue_full') { - $this->dispatch('error', 'Deployment queue full', $result['message']); - - return; - } - if ($result['status'] === 'skipped') { - $this->dispatch('success', 'Deployment skipped', $result['message']); - - return; - } - - return $this->redirectRoute('project.application.deployment.show', [ - 'project_uuid' => $this->parameters['project_uuid'], - 'application_uuid' => $this->parameters['application_uuid'], - 'deployment_uuid' => $this->deploymentUuid, - 'environment_uuid' => $this->parameters['environment_uuid'], - ], navigate: false); } public function render() diff --git a/app/Livewire/Project/Application/Previews.php b/app/Livewire/Project/Application/Previews.php index 41f352c14..50acf76b2 100644 --- a/app/Livewire/Project/Application/Previews.php +++ b/app/Livewire/Project/Application/Previews.php @@ -215,24 +215,31 @@ public function add(int $pull_request_id, ?string $pull_request_html_url = null) public function force_deploy_without_cache(int $pull_request_id, ?string $pull_request_html_url = null) { - $this->authorize('deploy', $this->application); + try { + $this->authorize('deploy', $this->application); - $this->deploy($pull_request_id, $pull_request_html_url, force_rebuild: true); + $this->deploy($pull_request_id, $pull_request_html_url, force_rebuild: true); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function add_and_deploy(int $pull_request_id, ?string $pull_request_html_url = null) { - $this->authorize('deploy', $this->application); + try { + $this->authorize('deploy', $this->application); - $this->add($pull_request_id, $pull_request_html_url); - $this->deploy($pull_request_id, $pull_request_html_url); + $this->add($pull_request_id, $pull_request_html_url); + $this->deploy($pull_request_id, $pull_request_html_url); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function deploy(int $pull_request_id, ?string $pull_request_html_url = null, bool $force_rebuild = false) { - $this->authorize('deploy', $this->application); - try { + $this->authorize('deploy', $this->application); $this->setDeploymentUuid(); $found = ApplicationPreview::where('application_id', $this->application->id)->where('pull_request_id', $pull_request_id)->first(); if (! $found && ! is_null($pull_request_html_url)) { @@ -291,9 +298,8 @@ private function stopContainers(array $containers, $server) public function stop(int $pull_request_id) { - $this->authorize('deploy', $this->application); - try { + $this->authorize('deploy', $this->application); $server = $this->application->destination->server; if ($this->application->destination->server->isSwarm()) { diff --git a/app/Livewire/Project/Database/BackupNow.php b/app/Livewire/Project/Database/BackupNow.php index decd59a4c..e4ed2a366 100644 --- a/app/Livewire/Project/Database/BackupNow.php +++ b/app/Livewire/Project/Database/BackupNow.php @@ -14,9 +14,13 @@ class BackupNow extends Component public function backupNow() { - $this->authorize('manageBackups', $this->backup->database); + try { + $this->authorize('manageBackups', $this->backup->database); - DatabaseBackupJob::dispatch($this->backup); - $this->dispatch('success', 'Backup queued. It will be available in a few minutes.'); + DatabaseBackupJob::dispatch($this->backup); + $this->dispatch('success', 'Backup queued. It will be available in a few minutes.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } } } diff --git a/app/Livewire/Project/Database/Heading.php b/app/Livewire/Project/Database/Heading.php index 8d3d8e294..ef2163f14 100644 --- a/app/Livewire/Project/Database/Heading.php +++ b/app/Livewire/Project/Database/Heading.php @@ -86,18 +86,26 @@ public function stop() public function restart() { - $this->authorize('manage', $this->database); + try { + $this->authorize('manage', $this->database); - $activity = RestartDatabase::run($this->database); - $this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class); + $activity = RestartDatabase::run($this->database); + $this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function start() { - $this->authorize('manage', $this->database); + try { + $this->authorize('manage', $this->database); - $activity = StartDatabase::run($this->database); - $this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class); + $activity = StartDatabase::run($this->database); + $this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function render() diff --git a/app/Livewire/Project/Database/ScheduledBackups.php b/app/Livewire/Project/Database/ScheduledBackups.php index 1cf5e53f6..06473c56a 100644 --- a/app/Livewire/Project/Database/ScheduledBackups.php +++ b/app/Livewire/Project/Database/ScheduledBackups.php @@ -56,22 +56,30 @@ public function setSelectedBackup($backupId, $force = false) public function setCustomType() { - $this->authorize('update', $this->database); + try { + $this->authorize('update', $this->database); - $this->database->custom_type = $this->custom_type; - $this->database->save(); - $this->dispatch('success', 'Database type set.'); - $this->refreshScheduledBackups(); + $this->database->custom_type = $this->custom_type; + $this->database->save(); + $this->dispatch('success', 'Database type set.'); + $this->refreshScheduledBackups(); + } catch (\Throwable $e) { + handleError($e, $this); + } } public function delete($scheduled_backup_id): void { - $backup = $this->database->scheduledBackups->find($scheduled_backup_id); - $this->authorize('manageBackups', $this->database); + try { + $this->authorize('manageBackups', $this->database); - $backup->delete(); - $this->dispatch('success', 'Scheduled backup deleted.'); - $this->refreshScheduledBackups(); + $backup = $this->database->scheduledBackups->find($scheduled_backup_id); + $backup->delete(); + $this->dispatch('success', 'Scheduled backup deleted.'); + $this->refreshScheduledBackups(); + } catch (\Throwable $e) { + handleError($e, $this); + } } public function refreshScheduledBackups(?int $id = null): void diff --git a/app/Livewire/Project/DeleteEnvironment.php b/app/Livewire/Project/DeleteEnvironment.php index aa6e95975..28027ce5a 100644 --- a/app/Livewire/Project/DeleteEnvironment.php +++ b/app/Livewire/Project/DeleteEnvironment.php @@ -30,18 +30,22 @@ public function mount() public function delete() { - $this->validate([ - 'environment_id' => 'required|int', - ]); - $environment = Environment::findOrFail($this->environment_id); - $this->authorize('delete', $environment); + try { + $this->validate([ + 'environment_id' => 'required|int', + ]); + $environment = Environment::findOrFail($this->environment_id); + $this->authorize('delete', $environment); - if ($environment->isEmpty()) { - $environment->delete(); + if ($environment->isEmpty()) { + $environment->delete(); - return redirectRoute($this, 'project.show', ['project_uuid' => $this->parameters['project_uuid']]); + return redirectRoute($this, 'project.show', ['project_uuid' => $this->parameters['project_uuid']]); + } + + return $this->dispatch('error', "Environment {$environment->name} has defined resources, please delete them first."); + } catch (\Throwable $e) { + return handleError($e, $this); } - - return $this->dispatch('error', "Environment {$environment->name} has defined resources, please delete them first."); } } diff --git a/app/Livewire/Project/DeleteProject.php b/app/Livewire/Project/DeleteProject.php index a018046fd..0b99f57a4 100644 --- a/app/Livewire/Project/DeleteProject.php +++ b/app/Livewire/Project/DeleteProject.php @@ -26,18 +26,22 @@ public function mount() public function delete() { - $this->validate([ - 'project_id' => 'required|int', - ]); - $project = Project::findOrFail($this->project_id); - $this->authorize('delete', $project); + try { + $this->validate([ + 'project_id' => 'required|int', + ]); + $project = Project::findOrFail($this->project_id); + $this->authorize('delete', $project); - if ($project->isEmpty()) { - $project->delete(); + if ($project->isEmpty()) { + $project->delete(); - return redirectRoute($this, 'project.index'); + return redirectRoute($this, 'project.index'); + } + + return $this->dispatch('error', "Project {$project->name} has resources defined, please delete them first."); + } catch (\Throwable $e) { + return handleError($e, $this); } - - return $this->dispatch('error', "Project {$project->name} has resources defined, please delete them first."); } } diff --git a/app/Livewire/Project/Service/Heading.php b/app/Livewire/Project/Service/Heading.php index c8a08d8f9..adc2b151b 100644 --- a/app/Livewire/Project/Service/Heading.php +++ b/app/Livewire/Project/Service/Heading.php @@ -7,12 +7,15 @@ use App\Actions\Service\StopService; use App\Enums\ProcessStatus; use App\Models\Service; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Auth; use Livewire\Component; use Spatie\Activitylog\Models\Activity; class Heading extends Component { + use AuthorizesRequests; + public Service $service; public array $parameters; @@ -99,13 +102,19 @@ public function checkDeployments() public function start() { - $activity = StartService::run($this->service, pullLatestImages: true); - $this->dispatch('activityMonitor', $activity->id); + try { + $this->authorize('deploy', $this->service); + $activity = StartService::run($this->service, pullLatestImages: true); + $this->dispatch('activityMonitor', $activity->id); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function forceDeploy() { try { + $this->authorize('deploy', $this->service); $activities = Activity::where('properties->type_uuid', $this->service->uuid) ->where(function ($q) { $q->where('properties->status', ProcessStatus::IN_PROGRESS->value) @@ -117,42 +126,53 @@ public function forceDeploy() } $activity = StartService::run($this->service, pullLatestImages: true, stopBeforeStart: true); $this->dispatch('activityMonitor', $activity->id); - } catch (\Exception $e) { - $this->dispatch('error', $e->getMessage()); + } catch (\Throwable $e) { + return handleError($e, $this); } } public function stop() { try { + $this->authorize('stop', $this->service); StopService::dispatch($this->service, false, $this->docker_cleanup); - } catch (\Exception $e) { - $this->dispatch('error', $e->getMessage()); + } catch (\Throwable $e) { + return handleError($e, $this); } } public function restart() { - $this->checkDeployments(); - if ($this->isDeploymentProgress) { - $this->dispatch('error', 'There is a deployment in progress.'); + try { + $this->authorize('deploy', $this->service); + $this->checkDeployments(); + if ($this->isDeploymentProgress) { + $this->dispatch('error', 'There is a deployment in progress.'); - return; + return; + } + $activity = StartService::run($this->service, stopBeforeStart: true); + $this->dispatch('activityMonitor', $activity->id); + } catch (\Throwable $e) { + return handleError($e, $this); } - $activity = StartService::run($this->service, stopBeforeStart: true); - $this->dispatch('activityMonitor', $activity->id); } public function pullAndRestartEvent() { - $this->checkDeployments(); - if ($this->isDeploymentProgress) { - $this->dispatch('error', 'There is a deployment in progress.'); + try { + $this->authorize('deploy', $this->service); + $this->checkDeployments(); + if ($this->isDeploymentProgress) { + $this->dispatch('error', 'There is a deployment in progress.'); - return; + return; + } + $activity = StartService::run($this->service, pullLatestImages: true, stopBeforeStart: true); + $this->dispatch('activityMonitor', $activity->id); + } catch (\Throwable $e) { + return handleError($e, $this); } - $activity = StartService::run($this->service, pullLatestImages: true, stopBeforeStart: true); - $this->dispatch('activityMonitor', $activity->id); } public function render() diff --git a/app/Livewire/Project/Shared/Destination.php b/app/Livewire/Project/Shared/Destination.php index 7ab81b7d1..1eb1dc580 100644 --- a/app/Livewire/Project/Shared/Destination.php +++ b/app/Livewire/Project/Shared/Destination.php @@ -7,12 +7,15 @@ use App\Events\ApplicationStatusChanged; use App\Models\Server; use App\Models\StandaloneDocker; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; use Livewire\Component; use Visus\Cuid2\Cuid2; class Destination extends Component { + use AuthorizesRequests; + public $resource; public Collection $networks; @@ -59,6 +62,7 @@ public function loadData() public function stop($serverId) { try { + $this->authorize('deploy', $this->resource); $server = Server::ownedByCurrentTeam()->findOrFail($serverId); StopApplicationOneServer::run($this->resource, $server); $this->refreshServers(); @@ -70,6 +74,7 @@ public function stop($serverId) public function redeploy(int $network_id, int $server_id) { try { + $this->authorize('deploy', $this->resource); if ($this->resource->additional_servers->count() > 0 && str($this->resource->docker_registry_image_name)->isEmpty()) { $this->dispatch('error', 'Failed to deploy.', 'Before deploying to multiple servers, you must first set a Docker image in the General tab.
More information here: documentation'); @@ -110,15 +115,20 @@ public function redeploy(int $network_id, int $server_id) public function promote(int $network_id, int $server_id) { - $main_destination = $this->resource->destination; - $this->resource->update([ - 'destination_id' => $network_id, - 'destination_type' => StandaloneDocker::class, - ]); - $this->resource->additional_networks()->detach($network_id, ['server_id' => $server_id]); - $this->resource->additional_networks()->attach($main_destination->id, ['server_id' => $main_destination->server->id]); - $this->refreshServers(); - $this->resource->refresh(); + try { + $this->authorize('update', $this->resource); + $main_destination = $this->resource->destination; + $this->resource->update([ + 'destination_id' => $network_id, + 'destination_type' => StandaloneDocker::class, + ]); + $this->resource->additional_networks()->detach($network_id, ['server_id' => $server_id]); + $this->resource->additional_networks()->attach($main_destination->id, ['server_id' => $main_destination->server->id]); + $this->refreshServers(); + $this->resource->refresh(); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function refreshServers() @@ -130,13 +140,19 @@ public function refreshServers() public function addServer(int $network_id, int $server_id) { - $this->resource->additional_networks()->attach($network_id, ['server_id' => $server_id]); - $this->dispatch('refresh'); + try { + $this->authorize('update', $this->resource); + $this->resource->additional_networks()->attach($network_id, ['server_id' => $server_id]); + $this->dispatch('refresh'); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function removeServer(int $network_id, int $server_id, $password) { try { + $this->authorize('update', $this->resource); if (! verifyPasswordConfirmation($password, $this)) { return; } diff --git a/app/Livewire/Project/Shared/HealthChecks.php b/app/Livewire/Project/Shared/HealthChecks.php index df2de5142..0a47034fd 100644 --- a/app/Livewire/Project/Shared/HealthChecks.php +++ b/app/Livewire/Project/Shared/HealthChecks.php @@ -70,8 +70,12 @@ class HealthChecks extends Component public function mount() { - $this->authorize('view', $this->resource); - $this->syncData(); + try { + $this->authorize('view', $this->resource); + $this->syncData(); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function syncData(bool $toModel = false): void diff --git a/app/Livewire/Project/Shared/ResourceOperations.php b/app/Livewire/Project/Shared/ResourceOperations.php index e769e4bcb..25545c4b0 100644 --- a/app/Livewire/Project/Shared/ResourceOperations.php +++ b/app/Livewire/Project/Shared/ResourceOperations.php @@ -47,224 +47,87 @@ public function toggleVolumeCloning(bool $value) public function cloneTo($destination_id) { - $this->authorize('update', $this->resource); + try { + $this->authorize('update', $this->resource); - $teamScope = fn ($q) => $q->where('team_id', currentTeam()->id); - $new_destination = StandaloneDocker::whereHas('server', $teamScope)->find($destination_id); - if (! $new_destination) { - $new_destination = SwarmDocker::whereHas('server', $teamScope)->find($destination_id); - } - if (! $new_destination) { - return $this->addError('destination_id', 'Destination not found.'); - } - $uuid = (string) new Cuid2; - $server = $new_destination->server; - - if ($this->resource->getMorphClass() === \App\Models\Application::class) { - $new_resource = clone_application($this->resource, $new_destination, ['uuid' => $uuid], $this->cloneVolumeData); - - $route = route('project.application.configuration', [ - 'project_uuid' => $this->projectUuid, - 'environment_uuid' => $this->environmentUuid, - 'application_uuid' => $new_resource->uuid, - ]).'#resource-operations'; - - return redirect()->to($route); - } elseif ( - $this->resource->getMorphClass() === \App\Models\StandalonePostgresql::class || - $this->resource->getMorphClass() === \App\Models\StandaloneMongodb::class || - $this->resource->getMorphClass() === \App\Models\StandaloneMysql::class || - $this->resource->getMorphClass() === \App\Models\StandaloneMariadb::class || - $this->resource->getMorphClass() === \App\Models\StandaloneRedis::class || - $this->resource->getMorphClass() === \App\Models\StandaloneKeydb::class || - $this->resource->getMorphClass() === \App\Models\StandaloneDragonfly::class || - $this->resource->getMorphClass() === \App\Models\StandaloneClickhouse::class - ) { + $teamScope = fn ($q) => $q->where('team_id', currentTeam()->id); + $new_destination = StandaloneDocker::whereHas('server', $teamScope)->find($destination_id); + if (! $new_destination) { + $new_destination = SwarmDocker::whereHas('server', $teamScope)->find($destination_id); + } + if (! $new_destination) { + return $this->addError('destination_id', 'Destination not found.'); + } $uuid = (string) new Cuid2; - $new_resource = $this->resource->replicate([ - 'id', - 'created_at', - 'updated_at', - ])->fill([ - 'uuid' => $uuid, - 'name' => $this->resource->name.'-clone-'.$uuid, - 'status' => 'exited', - 'started_at' => null, - 'destination_id' => $new_destination->id, - ]); - $new_resource->save(); + $server = $new_destination->server; - $tags = $this->resource->tags; - foreach ($tags as $tag) { - $new_resource->tags()->attach($tag->id); - } + if ($this->resource->getMorphClass() === \App\Models\Application::class) { + $new_resource = clone_application($this->resource, $new_destination, ['uuid' => $uuid], $this->cloneVolumeData); - $new_resource->persistentStorages()->delete(); - $persistentVolumes = $this->resource->persistentStorages()->get(); - foreach ($persistentVolumes as $volume) { - $originalName = $volume->name; - $newName = ''; + $route = route('project.application.configuration', [ + 'project_uuid' => $this->projectUuid, + 'environment_uuid' => $this->environmentUuid, + 'application_uuid' => $new_resource->uuid, + ]).'#resource-operations'; - if (str_starts_with($originalName, 'postgres-data-')) { - $newName = 'postgres-data-'.$new_resource->uuid; - } elseif (str_starts_with($originalName, 'mysql-data-')) { - $newName = 'mysql-data-'.$new_resource->uuid; - } elseif (str_starts_with($originalName, 'redis-data-')) { - $newName = 'redis-data-'.$new_resource->uuid; - } elseif (str_starts_with($originalName, 'clickhouse-data-')) { - $newName = 'clickhouse-data-'.$new_resource->uuid; - } elseif (str_starts_with($originalName, 'mariadb-data-')) { - $newName = 'mariadb-data-'.$new_resource->uuid; - } elseif (str_starts_with($originalName, 'mongodb-data-')) { - $newName = 'mongodb-data-'.$new_resource->uuid; - } elseif (str_starts_with($originalName, 'keydb-data-')) { - $newName = 'keydb-data-'.$new_resource->uuid; - } elseif (str_starts_with($originalName, 'dragonfly-data-')) { - $newName = 'dragonfly-data-'.$new_resource->uuid; - } else { - if (str_starts_with($volume->name, $this->resource->uuid)) { - $newName = str($volume->name)->replace($this->resource->uuid, $new_resource->uuid); - } else { - $newName = $new_resource->uuid.'-'.$volume->name; - } - } - - $newPersistentVolume = $volume->replicate([ - 'id', - 'created_at', - 'updated_at', - ])->fill([ - 'name' => $newName, - 'resource_id' => $new_resource->id, - ]); - $newPersistentVolume->save(); - - if ($this->cloneVolumeData) { - try { - StopDatabase::dispatch($this->resource); - $sourceVolume = $volume->name; - $targetVolume = $newPersistentVolume->name; - $sourceServer = $this->resource->destination->server; - $targetServer = $new_resource->destination->server; - - VolumeCloneJob::dispatch($sourceVolume, $targetVolume, $sourceServer, $targetServer, $newPersistentVolume); - - StartDatabase::dispatch($this->resource); - } catch (\Exception $e) { - \Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage()); - } - } - } - - $fileStorages = $this->resource->fileStorages()->get(); - foreach ($fileStorages as $storage) { - $newStorage = $storage->replicate([ - 'id', - 'created_at', - 'updated_at', - ])->fill([ - 'resource_id' => $new_resource->id, - ]); - $newStorage->save(); - } - - $scheduledBackups = $this->resource->scheduledBackups()->get(); - foreach ($scheduledBackups as $backup) { + return redirect()->to($route); + } elseif ( + $this->resource->getMorphClass() === \App\Models\StandalonePostgresql::class || + $this->resource->getMorphClass() === \App\Models\StandaloneMongodb::class || + $this->resource->getMorphClass() === \App\Models\StandaloneMysql::class || + $this->resource->getMorphClass() === \App\Models\StandaloneMariadb::class || + $this->resource->getMorphClass() === \App\Models\StandaloneRedis::class || + $this->resource->getMorphClass() === \App\Models\StandaloneKeydb::class || + $this->resource->getMorphClass() === \App\Models\StandaloneDragonfly::class || + $this->resource->getMorphClass() === \App\Models\StandaloneClickhouse::class + ) { $uuid = (string) new Cuid2; - $newBackup = $backup->replicate([ + $new_resource = $this->resource->replicate([ 'id', 'created_at', 'updated_at', ])->fill([ 'uuid' => $uuid, - 'database_id' => $new_resource->id, - 'database_type' => $new_resource->getMorphClass(), - 'team_id' => currentTeam()->id, - ]); - $newBackup->save(); - } - - $environmentVaribles = $this->resource->environment_variables()->get(); - foreach ($environmentVaribles as $environmentVarible) { - $payload = [ - 'resourceable_id' => $new_resource->id, - 'resourceable_type' => $new_resource->getMorphClass(), - ]; - $newEnvironmentVariable = $environmentVarible->replicate([ - 'id', - 'created_at', - 'updated_at', - ])->fill($payload); - $newEnvironmentVariable->save(); - } - - $route = route('project.database.configuration', [ - 'project_uuid' => $this->projectUuid, - 'environment_uuid' => $this->environmentUuid, - 'database_uuid' => $new_resource->uuid, - ]).'#resource-operations'; - - return redirect()->to($route); - } elseif ($this->resource->type() === 'service') { - $uuid = (string) new Cuid2; - $new_resource = $this->resource->replicate([ - 'id', - 'created_at', - 'updated_at', - ])->fill([ - 'uuid' => $uuid, - 'name' => $this->resource->name.'-clone-'.$uuid, - 'destination_id' => $new_destination->id, - 'destination_type' => $new_destination->getMorphClass(), - 'server_id' => $new_destination->server_id, // server_id is probably not needed anymore because of the new polymorphic relationships (here it is needed for clone to a different server to work - but maybe we can drop the column) - ]); - - $new_resource->save(); - - $tags = $this->resource->tags; - foreach ($tags as $tag) { - $new_resource->tags()->attach($tag->id); - } - - $scheduledTasks = $this->resource->scheduled_tasks()->get(); - foreach ($scheduledTasks as $task) { - $newTask = $task->replicate([ - 'id', - 'created_at', - 'updated_at', - ])->fill([ - 'uuid' => (string) new Cuid2, - 'service_id' => $new_resource->id, - 'team_id' => currentTeam()->id, - ]); - $newTask->save(); - } - - $environmentVariables = $this->resource->environment_variables()->get(); - foreach ($environmentVariables as $environmentVariable) { - $newEnvironmentVariable = $environmentVariable->replicate([ - 'id', - 'created_at', - 'updated_at', - ])->fill([ - 'resourceable_id' => $new_resource->id, - 'resourceable_type' => $new_resource->getMorphClass(), - ]); - $newEnvironmentVariable->save(); - } - - foreach ($new_resource->applications() as $application) { - $application->update([ + 'name' => $this->resource->name.'-clone-'.$uuid, 'status' => 'exited', + 'started_at' => null, + 'destination_id' => $new_destination->id, ]); + $new_resource->save(); - $persistentVolumes = $application->persistentStorages()->get(); + $tags = $this->resource->tags; + foreach ($tags as $tag) { + $new_resource->tags()->attach($tag->id); + } + + $new_resource->persistentStorages()->delete(); + $persistentVolumes = $this->resource->persistentStorages()->get(); foreach ($persistentVolumes as $volume) { + $originalName = $volume->name; $newName = ''; - if (str_starts_with($volume->name, $volume->resource->uuid)) { - $newName = str($volume->name)->replace($volume->resource->uuid, $application->uuid); + + if (str_starts_with($originalName, 'postgres-data-')) { + $newName = 'postgres-data-'.$new_resource->uuid; + } elseif (str_starts_with($originalName, 'mysql-data-')) { + $newName = 'mysql-data-'.$new_resource->uuid; + } elseif (str_starts_with($originalName, 'redis-data-')) { + $newName = 'redis-data-'.$new_resource->uuid; + } elseif (str_starts_with($originalName, 'clickhouse-data-')) { + $newName = 'clickhouse-data-'.$new_resource->uuid; + } elseif (str_starts_with($originalName, 'mariadb-data-')) { + $newName = 'mariadb-data-'.$new_resource->uuid; + } elseif (str_starts_with($originalName, 'mongodb-data-')) { + $newName = 'mongodb-data-'.$new_resource->uuid; + } elseif (str_starts_with($originalName, 'keydb-data-')) { + $newName = 'keydb-data-'.$new_resource->uuid; + } elseif (str_starts_with($originalName, 'dragonfly-data-')) { + $newName = 'dragonfly-data-'.$new_resource->uuid; } else { - $newName = $application->uuid.'-'.str($volume->name)->afterLast('-'); + if (str_starts_with($volume->name, $this->resource->uuid)) { + $newName = str($volume->name)->replace($this->resource->uuid, $new_resource->uuid); + } else { + $newName = $new_resource->uuid.'-'.$volume->name; + } } $newPersistentVolume = $volume->replicate([ @@ -273,79 +136,220 @@ public function cloneTo($destination_id) 'updated_at', ])->fill([ 'name' => $newName, - 'resource_id' => $application->id, + 'resource_id' => $new_resource->id, ]); $newPersistentVolume->save(); if ($this->cloneVolumeData) { try { - StopService::dispatch($application); + StopDatabase::dispatch($this->resource); $sourceVolume = $volume->name; $targetVolume = $newPersistentVolume->name; - $sourceServer = $application->service->destination->server; + $sourceServer = $this->resource->destination->server; $targetServer = $new_resource->destination->server; VolumeCloneJob::dispatch($sourceVolume, $targetVolume, $sourceServer, $targetServer, $newPersistentVolume); - StartService::dispatch($application); + StartDatabase::dispatch($this->resource); } catch (\Exception $e) { \Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage()); } } } - } - foreach ($new_resource->databases() as $database) { - $database->update([ - 'status' => 'exited', - ]); - - $persistentVolumes = $database->persistentStorages()->get(); - foreach ($persistentVolumes as $volume) { - $newName = ''; - if (str_starts_with($volume->name, $volume->resource->uuid)) { - $newName = str($volume->name)->replace($volume->resource->uuid, $database->uuid); - } else { - $newName = $database->uuid.'-'.str($volume->name)->afterLast('-'); - } - - $newPersistentVolume = $volume->replicate([ + $fileStorages = $this->resource->fileStorages()->get(); + foreach ($fileStorages as $storage) { + $newStorage = $storage->replicate([ 'id', 'created_at', 'updated_at', ])->fill([ - 'name' => $newName, - 'resource_id' => $database->id, + 'resource_id' => $new_resource->id, ]); - $newPersistentVolume->save(); + $newStorage->save(); + } - if ($this->cloneVolumeData) { - try { - StopService::dispatch($database->service); - $sourceVolume = $volume->name; - $targetVolume = $newPersistentVolume->name; - $sourceServer = $database->service->destination->server; - $targetServer = $new_resource->destination->server; + $scheduledBackups = $this->resource->scheduledBackups()->get(); + foreach ($scheduledBackups as $backup) { + $uuid = (string) new Cuid2; + $newBackup = $backup->replicate([ + 'id', + 'created_at', + 'updated_at', + ])->fill([ + 'uuid' => $uuid, + 'database_id' => $new_resource->id, + 'database_type' => $new_resource->getMorphClass(), + 'team_id' => currentTeam()->id, + ]); + $newBackup->save(); + } - VolumeCloneJob::dispatch($sourceVolume, $targetVolume, $sourceServer, $targetServer, $newPersistentVolume); + $environmentVaribles = $this->resource->environment_variables()->get(); + foreach ($environmentVaribles as $environmentVarible) { + $payload = [ + 'resourceable_id' => $new_resource->id, + 'resourceable_type' => $new_resource->getMorphClass(), + ]; + $newEnvironmentVariable = $environmentVarible->replicate([ + 'id', + 'created_at', + 'updated_at', + ])->fill($payload); + $newEnvironmentVariable->save(); + } - StartService::dispatch($database->service); - } catch (\Exception $e) { - \Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage()); + $route = route('project.database.configuration', [ + 'project_uuid' => $this->projectUuid, + 'environment_uuid' => $this->environmentUuid, + 'database_uuid' => $new_resource->uuid, + ]).'#resource-operations'; + + return redirect()->to($route); + } elseif ($this->resource->type() === 'service') { + $uuid = (string) new Cuid2; + $new_resource = $this->resource->replicate([ + 'id', + 'created_at', + 'updated_at', + ])->fill([ + 'uuid' => $uuid, + 'name' => $this->resource->name.'-clone-'.$uuid, + 'destination_id' => $new_destination->id, + 'destination_type' => $new_destination->getMorphClass(), + 'server_id' => $new_destination->server_id, // server_id is probably not needed anymore because of the new polymorphic relationships (here it is needed for clone to a different server to work - but maybe we can drop the column) + ]); + + $new_resource->save(); + + $tags = $this->resource->tags; + foreach ($tags as $tag) { + $new_resource->tags()->attach($tag->id); + } + + $scheduledTasks = $this->resource->scheduled_tasks()->get(); + foreach ($scheduledTasks as $task) { + $newTask = $task->replicate([ + 'id', + 'created_at', + 'updated_at', + ])->fill([ + 'uuid' => (string) new Cuid2, + 'service_id' => $new_resource->id, + 'team_id' => currentTeam()->id, + ]); + $newTask->save(); + } + + $environmentVariables = $this->resource->environment_variables()->get(); + foreach ($environmentVariables as $environmentVariable) { + $newEnvironmentVariable = $environmentVariable->replicate([ + 'id', + 'created_at', + 'updated_at', + ])->fill([ + 'resourceable_id' => $new_resource->id, + 'resourceable_type' => $new_resource->getMorphClass(), + ]); + $newEnvironmentVariable->save(); + } + + foreach ($new_resource->applications() as $application) { + $application->update([ + 'status' => 'exited', + ]); + + $persistentVolumes = $application->persistentStorages()->get(); + foreach ($persistentVolumes as $volume) { + $newName = ''; + if (str_starts_with($volume->name, $volume->resource->uuid)) { + $newName = str($volume->name)->replace($volume->resource->uuid, $application->uuid); + } else { + $newName = $application->uuid.'-'.str($volume->name)->afterLast('-'); + } + + $newPersistentVolume = $volume->replicate([ + 'id', + 'created_at', + 'updated_at', + ])->fill([ + 'name' => $newName, + 'resource_id' => $application->id, + ]); + $newPersistentVolume->save(); + + if ($this->cloneVolumeData) { + try { + StopService::dispatch($application); + $sourceVolume = $volume->name; + $targetVolume = $newPersistentVolume->name; + $sourceServer = $application->service->destination->server; + $targetServer = $new_resource->destination->server; + + VolumeCloneJob::dispatch($sourceVolume, $targetVolume, $sourceServer, $targetServer, $newPersistentVolume); + + StartService::dispatch($application); + } catch (\Exception $e) { + \Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage()); + } } } } + + foreach ($new_resource->databases() as $database) { + $database->update([ + 'status' => 'exited', + ]); + + $persistentVolumes = $database->persistentStorages()->get(); + foreach ($persistentVolumes as $volume) { + $newName = ''; + if (str_starts_with($volume->name, $volume->resource->uuid)) { + $newName = str($volume->name)->replace($volume->resource->uuid, $database->uuid); + } else { + $newName = $database->uuid.'-'.str($volume->name)->afterLast('-'); + } + + $newPersistentVolume = $volume->replicate([ + 'id', + 'created_at', + 'updated_at', + ])->fill([ + 'name' => $newName, + 'resource_id' => $database->id, + ]); + $newPersistentVolume->save(); + + if ($this->cloneVolumeData) { + try { + StopService::dispatch($database->service); + $sourceVolume = $volume->name; + $targetVolume = $newPersistentVolume->name; + $sourceServer = $database->service->destination->server; + $targetServer = $new_resource->destination->server; + + VolumeCloneJob::dispatch($sourceVolume, $targetVolume, $sourceServer, $targetServer, $newPersistentVolume); + + StartService::dispatch($database->service); + } catch (\Exception $e) { + \Log::error('Failed to copy volume data for '.$volume->name.': '.$e->getMessage()); + } + } + } + } + + $new_resource->parse(); + + $route = route('project.service.configuration', [ + 'project_uuid' => $this->projectUuid, + 'environment_uuid' => $this->environmentUuid, + 'service_uuid' => $new_resource->uuid, + ]).'#resource-operations'; + + return redirect()->to($route); } - - $new_resource->parse(); - - $route = route('project.service.configuration', [ - 'project_uuid' => $this->projectUuid, - 'environment_uuid' => $this->environmentUuid, - 'service_uuid' => $new_resource->uuid, - ]).'#resource-operations'; - - return redirect()->to($route); + } catch (\Throwable $e) { + return handleError($e, $this); } } diff --git a/app/Livewire/Security/ApiTokens.php b/app/Livewire/Security/ApiTokens.php index a263acedf..d22d5d9fc 100644 --- a/app/Livewire/Security/ApiTokens.php +++ b/app/Livewire/Security/ApiTokens.php @@ -23,6 +23,8 @@ class ApiTokens extends Component public bool $canUseWritePermissions = false; + public bool $canUseDeployPermissions = false; + public function render() { return view('livewire.security.api-tokens'); @@ -33,6 +35,7 @@ public function mount() $this->isApiEnabled = InstanceSettings::get()->is_api_enabled; $this->canUseRootPermissions = auth()->user()->can('useRootPermissions', PersonalAccessToken::class); $this->canUseWritePermissions = auth()->user()->can('useWritePermissions', PersonalAccessToken::class); + $this->canUseDeployPermissions = auth()->user()->can('useDeployPermissions', PersonalAccessToken::class); $this->getTokens(); } @@ -60,6 +63,13 @@ public function updatedPermissions($permissionToUpdate) return; } + if ($permissionToUpdate == 'deploy' && ! $this->canUseDeployPermissions) { + $this->dispatch('error', 'You do not have permission to use deploy permissions.'); + $this->permissions = array_diff($this->permissions, ['deploy']); + + return; + } + if ($permissionToUpdate == 'root') { $this->permissions = ['root']; } elseif ($permissionToUpdate == 'read:sensitive' && ! in_array('read', $this->permissions)) { @@ -88,6 +98,10 @@ public function addNewToken() throw new \Exception('You do not have permission to create tokens with write permissions.'); } + if (in_array('deploy', $this->permissions) && ! $this->canUseDeployPermissions) { + throw new \Exception('You do not have permission to create tokens with deploy permissions.'); + } + $this->validate([ 'description' => 'required|min:3|max:255', ]); diff --git a/app/Livewire/Security/CloudInitScriptForm.php b/app/Livewire/Security/CloudInitScriptForm.php index 33beff334..5e4ca9853 100644 --- a/app/Livewire/Security/CloudInitScriptForm.php +++ b/app/Livewire/Security/CloudInitScriptForm.php @@ -20,15 +20,19 @@ class CloudInitScriptForm extends Component public function mount(?int $scriptId = null) { - if ($scriptId) { - $this->scriptId = $scriptId; - $cloudInitScript = CloudInitScript::ownedByCurrentTeam()->findOrFail($scriptId); - $this->authorize('update', $cloudInitScript); + try { + if ($scriptId) { + $this->scriptId = $scriptId; + $cloudInitScript = CloudInitScript::ownedByCurrentTeam()->findOrFail($scriptId); + $this->authorize('update', $cloudInitScript); - $this->name = $cloudInitScript->name; - $this->script = $cloudInitScript->script; - } else { - $this->authorize('create', CloudInitScript::class); + $this->name = $cloudInitScript->name; + $this->script = $cloudInitScript->script; + } else { + $this->authorize('create', CloudInitScript::class); + } + } catch (\Throwable $e) { + return handleError($e, $this); } } diff --git a/app/Livewire/Security/CloudProviderTokenForm.php b/app/Livewire/Security/CloudProviderTokenForm.php index 7affb1531..ec4513ff3 100644 --- a/app/Livewire/Security/CloudProviderTokenForm.php +++ b/app/Livewire/Security/CloudProviderTokenForm.php @@ -21,7 +21,11 @@ class CloudProviderTokenForm extends Component public function mount() { - $this->authorize('create', CloudProviderToken::class); + try { + $this->authorize('create', CloudProviderToken::class); + } catch (\Throwable $e) { + return handleError($e, $this); + } } protected function rules(): array diff --git a/app/Livewire/Security/CloudProviderTokens.php b/app/Livewire/Security/CloudProviderTokens.php index cfef30772..b7f389534 100644 --- a/app/Livewire/Security/CloudProviderTokens.php +++ b/app/Livewire/Security/CloudProviderTokens.php @@ -14,8 +14,12 @@ class CloudProviderTokens extends Component public function mount() { - $this->authorize('viewAny', CloudProviderToken::class); - $this->loadTokens(); + try { + $this->authorize('viewAny', CloudProviderToken::class); + $this->loadTokens(); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function getListeners() diff --git a/app/Livewire/Security/PrivateKey/Index.php b/app/Livewire/Security/PrivateKey/Index.php index 1eb66ae3e..0362b65fa 100644 --- a/app/Livewire/Security/PrivateKey/Index.php +++ b/app/Livewire/Security/PrivateKey/Index.php @@ -21,8 +21,12 @@ public function render() public function cleanupUnusedKeys() { - $this->authorize('create', PrivateKey::class); - PrivateKey::cleanupUnusedKeys(); - $this->dispatch('success', 'Unused keys have been cleaned up.'); + try { + $this->authorize('create', PrivateKey::class); + PrivateKey::cleanupUnusedKeys(); + $this->dispatch('success', 'Unused keys have been cleaned up.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } } } diff --git a/app/Livewire/Server/New/ByHetzner.php b/app/Livewire/Server/New/ByHetzner.php index f1ffa60f2..e8df99d65 100644 --- a/app/Livewire/Server/New/ByHetzner.php +++ b/app/Livewire/Server/New/ByHetzner.php @@ -78,14 +78,18 @@ class ByHetzner extends Component public function mount() { - $this->authorize('viewAny', CloudProviderToken::class); - $this->loadTokens(); - $this->loadSavedCloudInitScripts(); - $this->server_name = generate_random_name(); - $this->private_keys = PrivateKey::ownedAndOnlySShKeys()->where('id', '!=', 0)->get(); + try { + $this->authorize('viewAny', CloudProviderToken::class); + $this->loadTokens(); + $this->loadSavedCloudInitScripts(); + $this->server_name = generate_random_name(); + $this->private_keys = PrivateKey::ownedAndOnlySShKeys()->where('id', '!=', 0)->get(); - if ($this->private_keys->count() > 0) { - $this->private_key_id = $this->private_keys->first()->id; + if ($this->private_keys->count() > 0) { + $this->private_key_id = $this->private_keys->first()->id; + } + } catch (\Throwable $e) { + return handleError($e, $this); } } diff --git a/app/Livewire/Server/Proxy.php b/app/Livewire/Server/Proxy.php index 1a14baf89..6c163a112 100644 --- a/app/Livewire/Server/Proxy.php +++ b/app/Livewire/Server/Proxy.php @@ -96,11 +96,15 @@ public function getConfigurationFilePathProperty(): string public function changeProxy() { - $this->authorize('update', $this->server); - $this->server->proxy = null; - $this->server->save(); + try { + $this->authorize('update', $this->server); + $this->server->proxy = null; + $this->server->save(); - $this->dispatch('reloadWindow'); + $this->dispatch('reloadWindow'); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function selectProxy($proxy_type) diff --git a/app/Livewire/Server/Proxy/DynamicConfigurationNavbar.php b/app/Livewire/Server/Proxy/DynamicConfigurationNavbar.php index c67591cf5..f7db1257c 100644 --- a/app/Livewire/Server/Proxy/DynamicConfigurationNavbar.php +++ b/app/Livewire/Server/Proxy/DynamicConfigurationNavbar.php @@ -22,34 +22,38 @@ class DynamicConfigurationNavbar extends Component public function delete(string $fileName) { - $this->authorize('update', $this->server); - $proxy_path = $this->server->proxyPath(); - $proxy_type = $this->server->proxyType(); + try { + $this->authorize('update', $this->server); + $proxy_path = $this->server->proxyPath(); + $proxy_type = $this->server->proxyType(); - // Decode filename: pipes are used to encode dots for Livewire property binding - // (e.g., 'my|service.yaml' -> 'my.service.yaml') - // This must happen BEFORE validation because validateShellSafePath() correctly - // rejects pipe characters as dangerous shell metacharacters - $file = str_replace('|', '.', $fileName); + // Decode filename: pipes are used to encode dots for Livewire property binding + // (e.g., 'my|service.yaml' -> 'my.service.yaml') + // This must happen BEFORE validation because validateShellSafePath() correctly + // rejects pipe characters as dangerous shell metacharacters + $file = str_replace('|', '.', $fileName); - // Validate filename to prevent command injection - validateShellSafePath($file, 'proxy configuration filename'); + // Validate filename to prevent command injection + validateShellSafePath($file, 'proxy configuration filename'); - if ($proxy_type === 'CADDY' && $file === 'Caddyfile') { - $this->dispatch('error', 'Cannot delete Caddyfile.'); + if ($proxy_type === 'CADDY' && $file === 'Caddyfile') { + $this->dispatch('error', 'Cannot delete Caddyfile.'); - return; + return; + } + + $fullPath = "{$proxy_path}/dynamic/{$file}"; + $escapedPath = escapeshellarg($fullPath); + instant_remote_process(["rm -f {$escapedPath}"], $this->server); + if ($proxy_type === 'CADDY') { + $this->server->reloadCaddy(); + } + $this->dispatch('success', 'File deleted.'); + $this->dispatch('loadDynamicConfigurations'); + $this->dispatch('refresh'); + } catch (\Throwable $e) { + return handleError($e, $this); } - - $fullPath = "{$proxy_path}/dynamic/{$file}"; - $escapedPath = escapeshellarg($fullPath); - instant_remote_process(["rm -f {$escapedPath}"], $this->server); - if ($proxy_type === 'CADDY') { - $this->server->reloadCaddy(); - } - $this->dispatch('success', 'File deleted.'); - $this->dispatch('loadDynamicConfigurations'); - $this->dispatch('refresh'); } public function render() diff --git a/app/Livewire/Server/Resources.php b/app/Livewire/Server/Resources.php index a21b0372b..31e57b301 100644 --- a/app/Livewire/Server/Resources.php +++ b/app/Livewire/Server/Resources.php @@ -29,23 +29,38 @@ public function getListeners() public function startUnmanaged($id) { - $this->server->startUnmanaged($id); - $this->dispatch('success', 'Container started.'); - $this->loadUnmanagedContainers(); + try { + $this->authorize('update', $this->server); + $this->server->startUnmanaged($id); + $this->dispatch('success', 'Container started.'); + $this->loadUnmanagedContainers(); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function restartUnmanaged($id) { - $this->server->restartUnmanaged($id); - $this->dispatch('success', 'Container restarted.'); - $this->loadUnmanagedContainers(); + try { + $this->authorize('update', $this->server); + $this->server->restartUnmanaged($id); + $this->dispatch('success', 'Container restarted.'); + $this->loadUnmanagedContainers(); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function stopUnmanaged($id) { - $this->server->stopUnmanaged($id); - $this->dispatch('success', 'Container stopped.'); - $this->loadUnmanagedContainers(); + try { + $this->authorize('update', $this->server); + $this->server->stopUnmanaged($id); + $this->dispatch('success', 'Container stopped.'); + $this->loadUnmanagedContainers(); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function refreshStatus() diff --git a/app/Livewire/Server/Security/Patches.php b/app/Livewire/Server/Security/Patches.php index b4d151424..087836da3 100644 --- a/app/Livewire/Server/Security/Patches.php +++ b/app/Livewire/Server/Security/Patches.php @@ -41,7 +41,11 @@ public function mount() { $this->parameters = get_route_parameters(); $this->server = Server::ownedByCurrentTeam()->whereUuid($this->parameters['server_uuid'])->firstOrFail(); - $this->authorize('viewSecurity', $this->server); + try { + $this->authorize('viewSecurity', $this->server); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function checkForUpdatesDispatch() @@ -69,14 +73,14 @@ public function checkForUpdates() public function updateAllPackages() { - $this->authorize('update', $this->server); - if (! $this->packageManager || ! $this->osId) { - $this->dispatch('error', message: 'Run "Check for updates" first.'); - - return; - } - try { + $this->authorize('update', $this->server); + if (! $this->packageManager || ! $this->osId) { + $this->dispatch('error', message: 'Run "Check for updates" first.'); + + return; + } + $activity = UpdatePackage::run( server: $this->server, packageManager: $this->packageManager, @@ -84,8 +88,8 @@ public function updateAllPackages() all: true ); $this->dispatch('activityMonitor', $activity->id, ServerPackageUpdated::class); - } catch (\Exception $e) { - $this->dispatch('error', message: $e->getMessage()); + } catch (\Throwable $e) { + return handleError($e, $this); } } diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php index 83c63a81c..38053386e 100644 --- a/app/Livewire/Server/Show.php +++ b/app/Livewire/Server/Show.php @@ -286,18 +286,22 @@ public function validateServer($install = true) public function checkLocalhostConnection() { - $this->syncData(true); - ['uptime' => $uptime, 'error' => $error] = $this->server->validateConnection(); - if ($uptime) { - $this->dispatch('success', 'Server is reachable.'); - $this->server->settings->is_reachable = $this->isReachable = true; - $this->server->settings->is_usable = $this->isUsable = true; - $this->server->settings->save(); - ServerReachabilityChanged::dispatch($this->server); - } else { - $this->dispatch('error', 'Server is not reachable.', 'Please validate your configuration and connection.

Check this documentation for further help.

Error: '.$error); + try { + $this->syncData(true); + ['uptime' => $uptime, 'error' => $error] = $this->server->validateConnection(); + if ($uptime) { + $this->dispatch('success', 'Server is reachable.'); + $this->server->settings->is_reachable = $this->isReachable = true; + $this->server->settings->is_usable = $this->isUsable = true; + $this->server->settings->save(); + ServerReachabilityChanged::dispatch($this->server); + } else { + $this->dispatch('error', 'Server is not reachable.', 'Please validate your configuration and connection.

Check this documentation for further help.

Error: '.$error); - return; + return; + } + } catch (\Throwable $e) { + return handleError($e, $this); } } diff --git a/app/Livewire/Server/ValidateAndInstall.php b/app/Livewire/Server/ValidateAndInstall.php index 1a5bd381b..9b0f02573 100644 --- a/app/Livewire/Server/ValidateAndInstall.php +++ b/app/Livewire/Server/ValidateAndInstall.php @@ -72,31 +72,39 @@ public function startValidatingAfterAsking() public function retry() { - $this->authorize('update', $this->server); - $this->uptime = null; - $this->supported_os_type = null; - $this->prerequisites_installed = null; - $this->docker_installed = null; - $this->docker_compose_installed = null; - $this->docker_version = null; - $this->error = null; - $this->number_of_tries = 0; - $this->init(); + try { + $this->authorize('update', $this->server); + $this->uptime = null; + $this->supported_os_type = null; + $this->prerequisites_installed = null; + $this->docker_installed = null; + $this->docker_compose_installed = null; + $this->docker_version = null; + $this->error = null; + $this->number_of_tries = 0; + $this->init(); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function validateConnection() { - $this->authorize('update', $this->server); - ['uptime' => $this->uptime, 'error' => $error] = $this->server->validateConnection(); - if (! $this->uptime) { - $this->error = 'Server is not reachable. Please validate your configuration and connection.
Check this documentation for further help.

Error: '.$error.'
'; - $this->server->update([ - 'validation_logs' => $this->error, - ]); + try { + $this->authorize('update', $this->server); + ['uptime' => $this->uptime, 'error' => $error] = $this->server->validateConnection(); + if (! $this->uptime) { + $this->error = 'Server is not reachable. Please validate your configuration and connection.
Check this documentation for further help.

Error: '.$error.'
'; + $this->server->update([ + 'validation_logs' => $this->error, + ]); - return; + return; + } + $this->dispatch('validateOS'); + } catch (\Throwable $e) { + return handleError($e, $this); } - $this->dispatch('validateOS'); } public function validateOS() diff --git a/app/Livewire/SharedVariables/Project/Show.php b/app/Livewire/SharedVariables/Project/Show.php index b205ea1ec..008f4af5a 100644 --- a/app/Livewire/SharedVariables/Project/Show.php +++ b/app/Livewire/SharedVariables/Project/Show.php @@ -57,9 +57,13 @@ public function mount() public function switch() { - $this->authorize('view', $this->project); - $this->view = $this->view === 'normal' ? 'dev' : 'normal'; - $this->getDevView(); + try { + $this->authorize('view', $this->project); + $this->view = $this->view === 'normal' ? 'dev' : 'normal'; + $this->getDevView(); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function getDevView() diff --git a/app/Livewire/SharedVariables/Team/Index.php b/app/Livewire/SharedVariables/Team/Index.php index e420686f0..93e12f376 100644 --- a/app/Livewire/SharedVariables/Team/Index.php +++ b/app/Livewire/SharedVariables/Team/Index.php @@ -51,9 +51,13 @@ public function mount() public function switch() { - $this->authorize('view', $this->team); - $this->view = $this->view === 'normal' ? 'dev' : 'normal'; - $this->getDevView(); + try { + $this->authorize('view', $this->team); + $this->view = $this->view === 'normal' ? 'dev' : 'normal'; + $this->getDevView(); + } catch (\Throwable $e) { + return handleError($e, $this); + } } public function getDevView() diff --git a/app/Livewire/Storage/Show.php b/app/Livewire/Storage/Show.php index fdf3d0d28..fd8c12292 100644 --- a/app/Livewire/Storage/Show.php +++ b/app/Livewire/Storage/Show.php @@ -18,7 +18,11 @@ public function mount() if (! $this->storage) { abort(404); } - $this->authorize('view', $this->storage); + try { + $this->authorize('view', $this->storage); + } catch (\Illuminate\Auth\Access\AuthorizationException) { + return $this->redirectRoute('storage.index', navigate: true); + } } public function render() diff --git a/app/Livewire/Team/Index.php b/app/Livewire/Team/Index.php index 8a943e6b6..e5ceb2cc9 100644 --- a/app/Livewire/Team/Index.php +++ b/app/Livewire/Team/Index.php @@ -95,23 +95,27 @@ public function submit() public function delete() { - $currentTeam = currentTeam(); - $this->authorize('delete', $currentTeam); - $currentTeam->delete(); + try { + $currentTeam = currentTeam(); + $this->authorize('delete', $currentTeam); + $currentTeam->delete(); - $currentTeam->members->each(function ($user) use ($currentTeam) { - if ($user->id === Auth::id()) { - return; - } - $user->teams()->detach($currentTeam); - $session = DB::table('sessions')->where('user_id', $user->id)->first(); - if ($session) { - DB::table('sessions')->where('id', $session->id)->delete(); - } - }); + $currentTeam->members->each(function ($user) use ($currentTeam) { + if ($user->id === Auth::id()) { + return; + } + $user->teams()->detach($currentTeam); + $session = DB::table('sessions')->where('user_id', $user->id)->first(); + if ($session) { + DB::table('sessions')->where('id', $session->id)->delete(); + } + }); - refreshSession(); + refreshSession(); - return redirect()->route('team.index'); + return redirect()->route('team.index'); + } catch (\Throwable $e) { + return handleError($e, $this); + } } } diff --git a/app/Models/Team.php b/app/Models/Team.php index e32526169..92fed1128 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -59,7 +59,7 @@ protected static function booted() $team->webhookNotificationSettings()->create(); }); - static::saving(function ($team) { + static::updating(function ($team) { if (auth()->user()?->isMember()) { throw new \Exception('You are not allowed to update this team.'); } diff --git a/app/Policies/ApiTokenPolicy.php b/app/Policies/ApiTokenPolicy.php index 761227118..5eb1a05eb 100644 --- a/app/Policies/ApiTokenPolicy.php +++ b/app/Policies/ApiTokenPolicy.php @@ -12,11 +12,6 @@ class ApiTokenPolicy */ public function viewAny(User $user): bool { - // Authorization temporarily disabled - /* - // Users can view their own API tokens - return true; - */ return true; } @@ -25,12 +20,7 @@ public function viewAny(User $user): bool */ public function view(User $user, PersonalAccessToken $token): bool { - // Authorization temporarily disabled - /* - // Users can only view their own tokens return $user->id === $token->tokenable_id && $token->tokenable_type === User::class; - */ - return true; } /** @@ -38,11 +28,6 @@ public function view(User $user, PersonalAccessToken $token): bool */ public function create(User $user): bool { - // Authorization temporarily disabled - /* - // All authenticated users can create their own API tokens - return true; - */ return true; } @@ -51,12 +36,7 @@ public function create(User $user): bool */ public function update(User $user, PersonalAccessToken $token): bool { - // Authorization temporarily disabled - /* - // Users can only update their own tokens return $user->id === $token->tokenable_id && $token->tokenable_type === User::class; - */ - return true; } /** @@ -64,12 +44,7 @@ public function update(User $user, PersonalAccessToken $token): bool */ public function delete(User $user, PersonalAccessToken $token): bool { - // Authorization temporarily disabled - /* - // Users can only delete their own tokens return $user->id === $token->tokenable_id && $token->tokenable_type === User::class; - */ - return true; } /** @@ -77,11 +52,6 @@ public function delete(User $user, PersonalAccessToken $token): bool */ public function manage(User $user): bool { - // Authorization temporarily disabled - /* - // All authenticated users can manage their own API tokens - return true; - */ return true; } @@ -90,7 +60,6 @@ public function manage(User $user): bool */ public function useRootPermissions(User $user): bool { - // Only admins and owners can use root permissions return $user->isAdmin() || $user->isOwner(); } @@ -99,11 +68,14 @@ public function useRootPermissions(User $user): bool */ public function useWritePermissions(User $user): bool { - // Authorization temporarily disabled - /* - // Only admins and owners can use write permissions return $user->isAdmin() || $user->isOwner(); - */ - return true; + } + + /** + * Determine whether the user can use deploy permissions for API tokens. + */ + public function useDeployPermissions(User $user): bool + { + return $user->isAdmin() || $user->isOwner(); } } diff --git a/app/Policies/ApplicationPolicy.php b/app/Policies/ApplicationPolicy.php index d64a436ad..7a992f2fd 100644 --- a/app/Policies/ApplicationPolicy.php +++ b/app/Policies/ApplicationPolicy.php @@ -13,10 +13,6 @@ class ApplicationPolicy */ public function viewAny(User $user): bool { - // Authorization temporarily disabled - /* - return true; - */ return true; } @@ -25,11 +21,9 @@ public function viewAny(User $user): bool */ public function view(User $user, Application $application): bool { - // Authorization temporarily disabled - /* - return true; - */ - return true; + $teamId = $this->getTeamId($application); + + return $teamId !== null && $user->teams->contains('id', $teamId); } /** @@ -37,15 +31,7 @@ public function view(User $user, Application $application): bool */ public function create(User $user): bool { - // Authorization temporarily disabled - /* - if ($user->isAdmin()) { - return true; - } - - return false; - */ - return true; + return $user->isAdmin(); } /** @@ -53,15 +39,17 @@ public function create(User $user): bool */ public function update(User $user, Application $application): Response { - // Authorization temporarily disabled - /* - if ($user->isAdmin()) { + $teamId = $this->getTeamId($application); + + if ($teamId === null) { + return Response::deny('Application team not found.'); + } + + if ($user->isAdminOfTeam($teamId)) { return Response::allow(); } - return Response::deny('As a member, you cannot update this application.

You need at least admin or owner permissions.'); - */ - return Response::allow(); + return Response::deny('You need at least admin or owner permissions to update this application.'); } /** @@ -69,15 +57,9 @@ public function update(User $user, Application $application): Response */ public function delete(User $user, Application $application): bool { - // Authorization temporarily disabled - /* - if ($user->isAdmin()) { - return true; - } + $teamId = $this->getTeamId($application); - return false; - */ - return true; + return $teamId !== null && $user->isAdminOfTeam($teamId); } /** @@ -85,11 +67,7 @@ public function delete(User $user, Application $application): bool */ public function restore(User $user, Application $application): bool { - // Authorization temporarily disabled - /* - return true; - */ - return true; + return false; } /** @@ -97,11 +75,7 @@ public function restore(User $user, Application $application): bool */ public function forceDelete(User $user, Application $application): bool { - // Authorization temporarily disabled - /* - return $user->isAdmin() && $user->teams->contains('id', $application->team()->first()->id); - */ - return true; + return false; } /** @@ -109,11 +83,9 @@ public function forceDelete(User $user, Application $application): bool */ public function deploy(User $user, Application $application): bool { - // Authorization temporarily disabled - /* - return $user->teams->contains('id', $application->team()->first()->id); - */ - return true; + $teamId = $this->getTeamId($application); + + return $teamId !== null && $user->isAdminOfTeam($teamId); } /** @@ -121,11 +93,9 @@ public function deploy(User $user, Application $application): bool */ public function manageDeployments(User $user, Application $application): bool { - // Authorization temporarily disabled - /* - return $user->isAdmin() && $user->teams->contains('id', $application->team()->first()->id); - */ - return true; + $teamId = $this->getTeamId($application); + + return $teamId !== null && $user->isAdminOfTeam($teamId); } /** @@ -133,11 +103,9 @@ public function manageDeployments(User $user, Application $application): bool */ public function manageEnvironment(User $user, Application $application): bool { - // Authorization temporarily disabled - /* - return $user->isAdmin() && $user->teams->contains('id', $application->team()->first()->id); - */ - return true; + $teamId = $this->getTeamId($application); + + return $teamId !== null && $user->isAdminOfTeam($teamId); } /** @@ -145,10 +113,11 @@ public function manageEnvironment(User $user, Application $application): bool */ public function cleanupDeploymentQueue(User $user): bool { - // Authorization temporarily disabled - /* return $user->isAdmin(); - */ - return true; + } + + private function getTeamId(Application $application): ?int + { + return $application->team()?->id; } } diff --git a/app/Policies/ApplicationPreviewPolicy.php b/app/Policies/ApplicationPreviewPolicy.php index 4d371cc38..f3c13acd9 100644 --- a/app/Policies/ApplicationPreviewPolicy.php +++ b/app/Policies/ApplicationPreviewPolicy.php @@ -21,8 +21,9 @@ public function viewAny(User $user): bool */ public function view(User $user, ApplicationPreview $applicationPreview): bool { - // return $user->teams->contains('id', $applicationPreview->application->team()->first()->id); - return true; + $teamId = $this->getTeamId($applicationPreview); + + return $teamId !== null && $user->teams->contains('id', $teamId); } /** @@ -30,21 +31,25 @@ public function view(User $user, ApplicationPreview $applicationPreview): bool */ public function create(User $user): bool { - // return $user->isAdmin(); - return true; + return $user->isAdmin(); } /** * Determine whether the user can update the model. */ - public function update(User $user, ApplicationPreview $applicationPreview) + public function update(User $user, ApplicationPreview $applicationPreview): Response { - // if ($user->isAdmin()) { - // return Response::allow(); - // } + $teamId = $this->getTeamId($applicationPreview); - // return Response::deny('As a member, you cannot update this preview.

You need at least admin or owner permissions.'); - return true; + if ($teamId === null) { + return Response::deny('Application preview team not found.'); + } + + if ($user->isAdminOfTeam($teamId)) { + return Response::allow(); + } + + return Response::deny('You need at least admin or owner permissions to update this preview.'); } /** @@ -52,8 +57,9 @@ public function update(User $user, ApplicationPreview $applicationPreview) */ public function delete(User $user, ApplicationPreview $applicationPreview): bool { - // return $user->isAdmin() && $user->teams->contains('id', $applicationPreview->application->team()->first()->id); - return true; + $teamId = $this->getTeamId($applicationPreview); + + return $teamId !== null && $user->isAdminOfTeam($teamId); } /** @@ -61,8 +67,7 @@ public function delete(User $user, ApplicationPreview $applicationPreview): bool */ public function restore(User $user, ApplicationPreview $applicationPreview): bool { - // return $user->isAdmin() && $user->teams->contains('id', $applicationPreview->application->team()->first()->id); - return true; + return false; } /** @@ -70,8 +75,7 @@ public function restore(User $user, ApplicationPreview $applicationPreview): boo */ public function forceDelete(User $user, ApplicationPreview $applicationPreview): bool { - // return $user->isAdmin() && $user->teams->contains('id', $applicationPreview->application->team()->first()->id); - return true; + return false; } /** @@ -79,8 +83,9 @@ public function forceDelete(User $user, ApplicationPreview $applicationPreview): */ public function deploy(User $user, ApplicationPreview $applicationPreview): bool { - // return $user->teams->contains('id', $applicationPreview->application->team()->first()->id); - return true; + $teamId = $this->getTeamId($applicationPreview); + + return $teamId !== null && $user->isAdminOfTeam($teamId); } /** @@ -88,7 +93,13 @@ public function deploy(User $user, ApplicationPreview $applicationPreview): bool */ public function manageDeployments(User $user, ApplicationPreview $applicationPreview): bool { - // return $user->isAdmin() && $user->teams->contains('id', $applicationPreview->application->team()->first()->id); - return true; + $teamId = $this->getTeamId($applicationPreview); + + return $teamId !== null && $user->isAdminOfTeam($teamId); + } + + private function getTeamId(ApplicationPreview $applicationPreview): ?int + { + return $applicationPreview->application?->team()?->id; } } diff --git a/app/Policies/ApplicationSettingPolicy.php b/app/Policies/ApplicationSettingPolicy.php index 848dc9aee..be2137cb8 100644 --- a/app/Policies/ApplicationSettingPolicy.php +++ b/app/Policies/ApplicationSettingPolicy.php @@ -20,8 +20,9 @@ public function viewAny(User $user): bool */ public function view(User $user, ApplicationSetting $applicationSetting): bool { - // return $user->teams->contains('id', $applicationSetting->application->team()->first()->id); - return true; + $teamId = $this->getTeamId($applicationSetting); + + return $teamId !== null && $user->teams->contains('id', $teamId); } /** @@ -29,8 +30,7 @@ public function view(User $user, ApplicationSetting $applicationSetting): bool */ public function create(User $user): bool { - // return $user->isAdmin(); - return true; + return $user->isAdmin(); } /** @@ -38,8 +38,9 @@ public function create(User $user): bool */ public function update(User $user, ApplicationSetting $applicationSetting): bool { - // return $user->isAdmin() && $user->teams->contains('id', $applicationSetting->application->team()->first()->id); - return true; + $teamId = $this->getTeamId($applicationSetting); + + return $teamId !== null && $user->isAdminOfTeam($teamId); } /** @@ -47,8 +48,9 @@ public function update(User $user, ApplicationSetting $applicationSetting): bool */ public function delete(User $user, ApplicationSetting $applicationSetting): bool { - // return $user->isAdmin() && $user->teams->contains('id', $applicationSetting->application->team()->first()->id); - return true; + $teamId = $this->getTeamId($applicationSetting); + + return $teamId !== null && $user->isAdminOfTeam($teamId); } /** @@ -56,8 +58,7 @@ public function delete(User $user, ApplicationSetting $applicationSetting): bool */ public function restore(User $user, ApplicationSetting $applicationSetting): bool { - // return $user->isAdmin() && $user->teams->contains('id', $applicationSetting->application->team()->first()->id); - return true; + return false; } /** @@ -65,7 +66,11 @@ public function restore(User $user, ApplicationSetting $applicationSetting): boo */ public function forceDelete(User $user, ApplicationSetting $applicationSetting): bool { - // return $user->isAdmin() && $user->teams->contains('id', $applicationSetting->application->team()->first()->id); - return true; + return false; + } + + private function getTeamId(ApplicationSetting $applicationSetting): ?int + { + return $applicationSetting->application?->team()?->id; } } diff --git a/app/Policies/DatabasePolicy.php b/app/Policies/DatabasePolicy.php index f8e8af637..6a5348224 100644 --- a/app/Policies/DatabasePolicy.php +++ b/app/Policies/DatabasePolicy.php @@ -20,8 +20,9 @@ public function viewAny(User $user): bool */ public function view(User $user, $database): bool { - // return $user->teams->contains('id', $database->team()->first()->id); - return true; + $teamId = $this->getTeamId($database); + + return $teamId !== null && $user->teams->contains('id', $teamId); } /** @@ -29,21 +30,25 @@ public function view(User $user, $database): bool */ public function create(User $user): bool { - // return $user->isAdmin(); - return true; + return $user->isAdmin(); } /** * Determine whether the user can update the model. */ - public function update(User $user, $database) + public function update(User $user, $database): Response { - // if ($user->isAdmin() && $user->teams->contains('id', $database->team()->first()->id)) { - // return Response::allow(); - // } + $teamId = $this->getTeamId($database); - // return Response::deny('As a member, you cannot update this database.

You need at least admin or owner permissions.'); - return true; + if ($teamId === null) { + return Response::deny('Database team not found.'); + } + + if ($user->isAdminOfTeam($teamId)) { + return Response::allow(); + } + + return Response::deny('You need at least admin or owner permissions to update this database.'); } /** @@ -51,8 +56,9 @@ public function update(User $user, $database) */ public function delete(User $user, $database): bool { - // return $user->isAdmin() && $user->teams->contains('id', $database->team()->first()->id); - return true; + $teamId = $this->getTeamId($database); + + return $teamId !== null && $user->isAdminOfTeam($teamId); } /** @@ -60,8 +66,7 @@ public function delete(User $user, $database): bool */ public function restore(User $user, $database): bool { - // return $user->isAdmin() && $user->teams->contains('id', $database->team()->first()->id); - return true; + return false; } /** @@ -69,8 +74,7 @@ public function restore(User $user, $database): bool */ public function forceDelete(User $user, $database): bool { - // return $user->isAdmin() && $user->teams->contains('id', $database->team()->first()->id); - return true; + return false; } /** @@ -78,8 +82,9 @@ public function forceDelete(User $user, $database): bool */ public function manage(User $user, $database): bool { - // return $user->isAdmin() && $user->teams->contains('id', $database->team()->first()->id); - return true; + $teamId = $this->getTeamId($database); + + return $teamId !== null && $user->isAdminOfTeam($teamId); } /** @@ -87,8 +92,9 @@ public function manage(User $user, $database): bool */ public function manageBackups(User $user, $database): bool { - // return $user->isAdmin() && $user->teams->contains('id', $database->team()->first()->id); - return true; + $teamId = $this->getTeamId($database); + + return $teamId !== null && $user->isAdminOfTeam($teamId); } /** @@ -96,7 +102,17 @@ public function manageBackups(User $user, $database): bool */ public function manageEnvironment(User $user, $database): bool { - // return $user->isAdmin() && $user->teams->contains('id', $database->team()->first()->id); - return true; + $teamId = $this->getTeamId($database); + + return $teamId !== null && $user->isAdminOfTeam($teamId); + } + + private function getTeamId($database): ?int + { + if (method_exists($database, 'team')) { + return $database->team()?->id; + } + + return null; } } diff --git a/app/Policies/EnvironmentPolicy.php b/app/Policies/EnvironmentPolicy.php index 7199abb25..e400ec903 100644 --- a/app/Policies/EnvironmentPolicy.php +++ b/app/Policies/EnvironmentPolicy.php @@ -20,8 +20,9 @@ public function viewAny(User $user): bool */ public function view(User $user, Environment $environment): bool { - // return $user->teams->contains('id', $environment->project->team_id); - return true; + $teamId = $this->getTeamId($environment); + + return $teamId !== null && $user->teams->contains('id', $teamId); } /** @@ -29,8 +30,7 @@ public function view(User $user, Environment $environment): bool */ public function create(User $user): bool { - // return $user->isAdmin(); - return true; + return $user->isAdmin(); } /** @@ -38,8 +38,9 @@ public function create(User $user): bool */ public function update(User $user, Environment $environment): bool { - // return $user->isAdmin() && $user->teams->contains('id', $environment->project->team_id); - return true; + $teamId = $this->getTeamId($environment); + + return $teamId !== null && $user->isAdminOfTeam($teamId); } /** @@ -47,8 +48,9 @@ public function update(User $user, Environment $environment): bool */ public function delete(User $user, Environment $environment): bool { - // return $user->isAdmin() && $user->teams->contains('id', $environment->project->team_id); - return true; + $teamId = $this->getTeamId($environment); + + return $teamId !== null && $user->isAdminOfTeam($teamId); } /** @@ -56,8 +58,7 @@ public function delete(User $user, Environment $environment): bool */ public function restore(User $user, Environment $environment): bool { - // return $user->isAdmin() && $user->teams->contains('id', $environment->project->team_id); - return true; + return false; } /** @@ -65,7 +66,11 @@ public function restore(User $user, Environment $environment): bool */ public function forceDelete(User $user, Environment $environment): bool { - // return $user->isAdmin() && $user->teams->contains('id', $environment->project->team_id); - return true; + return false; + } + + private function getTeamId(Environment $environment): ?int + { + return $environment->project?->team_id; } } diff --git a/app/Policies/EnvironmentVariablePolicy.php b/app/Policies/EnvironmentVariablePolicy.php index 21e2ea443..dd0f58918 100644 --- a/app/Policies/EnvironmentVariablePolicy.php +++ b/app/Policies/EnvironmentVariablePolicy.php @@ -20,7 +20,9 @@ public function viewAny(User $user): bool */ public function view(User $user, EnvironmentVariable $environmentVariable): bool { - return true; + $teamId = $this->getTeamId($environmentVariable); + + return $teamId !== null && $user->teams->contains('id', $teamId); } /** @@ -28,7 +30,7 @@ public function view(User $user, EnvironmentVariable $environmentVariable): bool */ public function create(User $user): bool { - return true; + return $user->isAdmin(); } /** @@ -36,7 +38,9 @@ public function create(User $user): bool */ public function update(User $user, EnvironmentVariable $environmentVariable): bool { - return true; + $teamId = $this->getTeamId($environmentVariable); + + return $teamId !== null && $user->isAdminOfTeam($teamId); } /** @@ -44,7 +48,9 @@ public function update(User $user, EnvironmentVariable $environmentVariable): bo */ public function delete(User $user, EnvironmentVariable $environmentVariable): bool { - return true; + $teamId = $this->getTeamId($environmentVariable); + + return $teamId !== null && $user->isAdminOfTeam($teamId); } /** @@ -52,7 +58,7 @@ public function delete(User $user, EnvironmentVariable $environmentVariable): bo */ public function restore(User $user, EnvironmentVariable $environmentVariable): bool { - return true; + return false; } /** @@ -60,7 +66,7 @@ public function restore(User $user, EnvironmentVariable $environmentVariable): b */ public function forceDelete(User $user, EnvironmentVariable $environmentVariable): bool { - return true; + return false; } /** @@ -68,6 +74,19 @@ public function forceDelete(User $user, EnvironmentVariable $environmentVariable */ public function manageEnvironment(User $user, EnvironmentVariable $environmentVariable): bool { - return true; + $teamId = $this->getTeamId($environmentVariable); + + return $teamId !== null && $user->isAdminOfTeam($teamId); + } + + private function getTeamId(EnvironmentVariable $environmentVariable): ?int + { + $resource = $environmentVariable->resourceable; + + if (! $resource || ! method_exists($resource, 'team')) { + return null; + } + + return $resource->team()?->id; } } diff --git a/app/Policies/GithubAppPolicy.php b/app/Policies/GithubAppPolicy.php index 56bec7032..79dd79838 100644 --- a/app/Policies/GithubAppPolicy.php +++ b/app/Policies/GithubAppPolicy.php @@ -20,8 +20,11 @@ public function viewAny(User $user): bool */ public function view(User $user, GithubApp $githubApp): bool { - // return $user->teams->contains('id', $githubApp->team_id) || $githubApp->is_system_wide; - return true; + if ($githubApp->is_system_wide) { + return true; + } + + return $user->teams->contains('id', $githubApp->team_id); } /** @@ -29,8 +32,7 @@ public function view(User $user, GithubApp $githubApp): bool */ public function create(User $user): bool { - // return $user->isAdmin(); - return true; + return $user->isAdmin(); } /** @@ -39,12 +41,10 @@ public function create(User $user): bool public function update(User $user, GithubApp $githubApp): bool { if ($githubApp->is_system_wide) { - // return $user->isAdmin(); - return true; + return $user->canAccessSystemResources(); } - // return $user->isAdmin() && $user->teams->contains('id', $githubApp->team_id); - return true; + return $user->isAdminOfTeam($githubApp->team_id); } /** @@ -53,12 +53,10 @@ public function update(User $user, GithubApp $githubApp): bool public function delete(User $user, GithubApp $githubApp): bool { if ($githubApp->is_system_wide) { - // return $user->isAdmin(); - return true; + return $user->canAccessSystemResources(); } - // return $user->isAdmin() && $user->teams->contains('id', $githubApp->team_id); - return true; + return $user->isAdminOfTeam($githubApp->team_id); } /** diff --git a/app/Policies/NotificationPolicy.php b/app/Policies/NotificationPolicy.php index 4f3be431d..e8764bf13 100644 --- a/app/Policies/NotificationPolicy.php +++ b/app/Policies/NotificationPolicy.php @@ -12,13 +12,11 @@ class NotificationPolicy */ public function view(User $user, Model $notificationSettings): bool { - // Check if the notification settings belong to the user's current team if (! $notificationSettings->team) { return false; } - // return $user->teams()->where('teams.id', $notificationSettings->team->id)->exists(); - return true; + return $user->teams->contains('id', $notificationSettings->team->id); } /** @@ -26,14 +24,13 @@ public function view(User $user, Model $notificationSettings): bool */ public function update(User $user, Model $notificationSettings): bool { - // Check if the notification settings belong to the user's current team if (! $notificationSettings->team) { return false; } - // Only owners and admins can update notification settings - // return $user->isAdmin() || $user->isOwner(); - return true; + $teamId = $notificationSettings->team->id; + + return $user->isAdminOfTeam($teamId); } /** @@ -41,8 +38,7 @@ public function update(User $user, Model $notificationSettings): bool */ public function manage(User $user, Model $notificationSettings): bool { - // return $this->update($user, $notificationSettings); - return true; + return $this->update($user, $notificationSettings); } /** @@ -50,7 +46,6 @@ public function manage(User $user, Model $notificationSettings): bool */ public function sendTest(User $user, Model $notificationSettings): bool { - // return $this->update($user, $notificationSettings); - return true; + return $this->update($user, $notificationSettings); } } diff --git a/app/Policies/ProjectPolicy.php b/app/Policies/ProjectPolicy.php index e188c293f..9d65b9130 100644 --- a/app/Policies/ProjectPolicy.php +++ b/app/Policies/ProjectPolicy.php @@ -20,8 +20,7 @@ public function viewAny(User $user): bool */ public function view(User $user, Project $project): bool { - // return $user->teams->contains('id', $project->team_id); - return true; + return $user->teams->contains('id', $project->team_id); } /** @@ -29,8 +28,7 @@ public function view(User $user, Project $project): bool */ public function create(User $user): bool { - // return $user->isAdmin(); - return true; + return $user->isAdmin(); } /** @@ -38,8 +36,7 @@ public function create(User $user): bool */ public function update(User $user, Project $project): bool { - // return $user->isAdmin() && $user->teams->contains('id', $project->team_id); - return true; + return $user->isAdminOfTeam($project->team_id); } /** @@ -47,8 +44,7 @@ public function update(User $user, Project $project): bool */ public function delete(User $user, Project $project): bool { - // return $user->isAdmin() && $user->teams->contains('id', $project->team_id); - return true; + return $user->isAdminOfTeam($project->team_id); } /** @@ -56,8 +52,7 @@ public function delete(User $user, Project $project): bool */ public function restore(User $user, Project $project): bool { - // return $user->isAdmin() && $user->teams->contains('id', $project->team_id); - return true; + return false; } /** @@ -65,7 +60,6 @@ public function restore(User $user, Project $project): bool */ public function forceDelete(User $user, Project $project): bool { - // return $user->isAdmin() && $user->teams->contains('id', $project->team_id); - return true; + return false; } } diff --git a/app/Policies/ResourceCreatePolicy.php b/app/Policies/ResourceCreatePolicy.php index 9ed2b66ab..a7a855402 100644 --- a/app/Policies/ResourceCreatePolicy.php +++ b/app/Policies/ResourceCreatePolicy.php @@ -38,8 +38,7 @@ class ResourceCreatePolicy */ public function createAny(User $user): bool { - // return $user->isAdmin(); - return true; + return $user->isAdmin(); } /** @@ -51,8 +50,7 @@ public function create(User $user, string $resourceClass): bool return false; } - // return $user->isAdmin(); - return true; + return $user->isAdmin(); } /** diff --git a/app/Policies/ServerPolicy.php b/app/Policies/ServerPolicy.php index 6d2396a7d..32436987c 100644 --- a/app/Policies/ServerPolicy.php +++ b/app/Policies/ServerPolicy.php @@ -28,8 +28,7 @@ public function view(User $user, Server $server): bool */ public function create(User $user): bool { - // return $user->isAdmin(); - return true; + return $user->isAdmin(); } /** @@ -37,8 +36,7 @@ public function create(User $user): bool */ public function update(User $user, Server $server): bool { - // return $user->isAdmin() && $user->teams->contains('id', $server->team_id); - return true; + return $user->isAdminOfTeam($server->team_id); } /** @@ -46,8 +44,7 @@ public function update(User $user, Server $server): bool */ public function delete(User $user, Server $server): bool { - // return $user->isAdmin() && $user->teams->contains('id', $server->team_id); - return true; + return $user->isAdminOfTeam($server->team_id); } /** @@ -71,8 +68,7 @@ public function forceDelete(User $user, Server $server): bool */ public function manageProxy(User $user, Server $server): bool { - // return $user->isAdmin() && $user->teams->contains('id', $server->team_id); - return true; + return $user->isAdminOfTeam($server->team_id); } /** @@ -80,8 +76,7 @@ public function manageProxy(User $user, Server $server): bool */ public function manageSentinel(User $user, Server $server): bool { - // return $user->isAdmin() && $user->teams->contains('id', $server->team_id); - return true; + return $user->isAdminOfTeam($server->team_id); } /** @@ -89,8 +84,7 @@ public function manageSentinel(User $user, Server $server): bool */ public function manageCaCertificate(User $user, Server $server): bool { - // return $user->isAdmin() && $user->teams->contains('id', $server->team_id); - return true; + return $user->isAdminOfTeam($server->team_id); } /** @@ -98,7 +92,6 @@ public function manageCaCertificate(User $user, Server $server): bool */ public function viewSecurity(User $user, Server $server): bool { - // return $user->isAdmin() && $user->teams->contains('id', $server->team_id); - return true; + return $user->isAdminOfTeam($server->team_id); } } diff --git a/app/Policies/ServiceApplicationPolicy.php b/app/Policies/ServiceApplicationPolicy.php index af380a90f..c730ab0c6 100644 --- a/app/Policies/ServiceApplicationPolicy.php +++ b/app/Policies/ServiceApplicationPolicy.php @@ -21,8 +21,7 @@ public function view(User $user, ServiceApplication $serviceApplication): bool */ public function create(User $user): bool { - // return $user->isAdmin(); - return true; + return $user->isAdmin(); } /** @@ -30,8 +29,7 @@ public function create(User $user): bool */ public function update(User $user, ServiceApplication $serviceApplication): bool { - // return Gate::allows('update', $serviceApplication->service); - return true; + return Gate::allows('update', $serviceApplication->service); } /** @@ -39,8 +37,7 @@ public function update(User $user, ServiceApplication $serviceApplication): bool */ public function delete(User $user, ServiceApplication $serviceApplication): bool { - // return Gate::allows('delete', $serviceApplication->service); - return true; + return Gate::allows('delete', $serviceApplication->service); } /** @@ -48,8 +45,7 @@ public function delete(User $user, ServiceApplication $serviceApplication): bool */ public function restore(User $user, ServiceApplication $serviceApplication): bool { - // return Gate::allows('update', $serviceApplication->service); - return true; + return false; } /** @@ -57,7 +53,6 @@ public function restore(User $user, ServiceApplication $serviceApplication): boo */ public function forceDelete(User $user, ServiceApplication $serviceApplication): bool { - // return Gate::allows('delete', $serviceApplication->service); - return true; + return false; } } diff --git a/app/Policies/ServiceDatabasePolicy.php b/app/Policies/ServiceDatabasePolicy.php index f72f1f327..e5cbe91a0 100644 --- a/app/Policies/ServiceDatabasePolicy.php +++ b/app/Policies/ServiceDatabasePolicy.php @@ -13,7 +13,7 @@ class ServiceDatabasePolicy */ public function view(User $user, ServiceDatabase $serviceDatabase): bool { - return true; + return Gate::allows('view', $serviceDatabase->service); } /** @@ -21,8 +21,7 @@ public function view(User $user, ServiceDatabase $serviceDatabase): bool */ public function create(User $user): bool { - // return $user->isAdmin(); - return true; + return $user->isAdmin(); } /** @@ -30,9 +29,7 @@ public function create(User $user): bool */ public function update(User $user, ServiceDatabase $serviceDatabase): bool { - - // return Gate::allows('update', $serviceDatabase->service); - return true; + return Gate::allows('update', $serviceDatabase->service); } /** @@ -40,8 +37,7 @@ public function update(User $user, ServiceDatabase $serviceDatabase): bool */ public function delete(User $user, ServiceDatabase $serviceDatabase): bool { - // return Gate::allows('delete', $serviceDatabase->service); - return true; + return Gate::allows('delete', $serviceDatabase->service); } /** @@ -49,8 +45,7 @@ public function delete(User $user, ServiceDatabase $serviceDatabase): bool */ public function restore(User $user, ServiceDatabase $serviceDatabase): bool { - // return Gate::allows('update', $serviceDatabase->service); - return true; + return false; } /** @@ -58,12 +53,14 @@ public function restore(User $user, ServiceDatabase $serviceDatabase): bool */ public function forceDelete(User $user, ServiceDatabase $serviceDatabase): bool { - // return Gate::allows('delete', $serviceDatabase->service); - return true; + return false; } + /** + * Determine whether the user can manage database backups. + */ public function manageBackups(User $user, ServiceDatabase $serviceDatabase): bool { - return true; + return Gate::allows('update', $serviceDatabase->service); } } diff --git a/app/Policies/ServicePolicy.php b/app/Policies/ServicePolicy.php index 7ab0fe7d0..d48728cdf 100644 --- a/app/Policies/ServicePolicy.php +++ b/app/Policies/ServicePolicy.php @@ -20,7 +20,9 @@ public function viewAny(User $user): bool */ public function view(User $user, Service $service): bool { - return true; + $teamId = $this->getTeamId($service); + + return $teamId !== null && $user->teams->contains('id', $teamId); } /** @@ -28,8 +30,7 @@ public function view(User $user, Service $service): bool */ public function create(User $user): bool { - // return $user->isAdmin(); - return true; + return $user->isAdmin(); } /** @@ -37,13 +38,9 @@ public function create(User $user): bool */ public function update(User $user, Service $service): bool { - $team = $service->team(); - if (! $team) { - return false; - } + $teamId = $this->getTeamId($service); - // return $user->isAdmin() && $user->teams->contains('id', $team->id); - return true; + return $teamId !== null && $user->isAdminOfTeam($teamId); } /** @@ -51,12 +48,9 @@ public function update(User $user, Service $service): bool */ public function delete(User $user, Service $service): bool { - // if ($user->isAdmin()) { - // return true; - // } + $teamId = $this->getTeamId($service); - // return false; - return true; + return $teamId !== null && $user->isAdminOfTeam($teamId); } /** @@ -64,8 +58,7 @@ public function delete(User $user, Service $service): bool */ public function restore(User $user, Service $service): bool { - // return true; - return true; + return false; } /** @@ -73,23 +66,17 @@ public function restore(User $user, Service $service): bool */ public function forceDelete(User $user, Service $service): bool { - // if ($user->isAdmin()) { - // return true; - // } - - // return false; - return true; + return false; } + /** + * Determine whether the user can stop the service. + */ public function stop(User $user, Service $service): bool { - $team = $service->team(); - if (! $team) { - return false; - } + $teamId = $this->getTeamId($service); - // return $user->teams->contains('id', $team->id); - return true; + return $teamId !== null && $user->isAdminOfTeam($teamId); } /** @@ -97,13 +84,9 @@ public function stop(User $user, Service $service): bool */ public function manageEnvironment(User $user, Service $service): bool { - $team = $service->team(); - if (! $team) { - return false; - } + $teamId = $this->getTeamId($service); - // return $user->isAdmin() && $user->teams->contains('id', $team->id); - return true; + return $teamId !== null && $user->isAdminOfTeam($teamId); } /** @@ -111,18 +94,23 @@ public function manageEnvironment(User $user, Service $service): bool */ public function deploy(User $user, Service $service): bool { - $team = $service->team(); - if (! $team) { - return false; - } + $teamId = $this->getTeamId($service); - // return $user->teams->contains('id', $team->id); - return true; + return $teamId !== null && $user->isAdminOfTeam($teamId); } + /** + * Determine whether the user can access the terminal. + */ public function accessTerminal(User $user, Service $service): bool { - // return $user->isAdmin() || $user->teams->contains('id', $service->team()->id); - return true; + $teamId = $this->getTeamId($service); + + return $teamId !== null && $user->isAdminOfTeam($teamId); + } + + private function getTeamId(Service $service): ?int + { + return $service->team()?->id; } } diff --git a/app/Policies/SharedEnvironmentVariablePolicy.php b/app/Policies/SharedEnvironmentVariablePolicy.php index b465d8a0c..21b6acb27 100644 --- a/app/Policies/SharedEnvironmentVariablePolicy.php +++ b/app/Policies/SharedEnvironmentVariablePolicy.php @@ -28,8 +28,7 @@ public function view(User $user, SharedEnvironmentVariable $sharedEnvironmentVar */ public function create(User $user): bool { - // return $user->isAdmin(); - return true; + return $user->isAdmin(); } /** @@ -37,8 +36,7 @@ public function create(User $user): bool */ public function update(User $user, SharedEnvironmentVariable $sharedEnvironmentVariable): bool { - // return $user->isAdmin() && $user->teams->contains('id', $sharedEnvironmentVariable->team_id); - return true; + return $user->isAdminOfTeam($sharedEnvironmentVariable->team_id); } /** @@ -46,8 +44,7 @@ public function update(User $user, SharedEnvironmentVariable $sharedEnvironmentV */ public function delete(User $user, SharedEnvironmentVariable $sharedEnvironmentVariable): bool { - // return $user->isAdmin() && $user->teams->contains('id', $sharedEnvironmentVariable->team_id); - return true; + return $user->isAdminOfTeam($sharedEnvironmentVariable->team_id); } /** @@ -55,8 +52,7 @@ public function delete(User $user, SharedEnvironmentVariable $sharedEnvironmentV */ public function restore(User $user, SharedEnvironmentVariable $sharedEnvironmentVariable): bool { - // return $user->isAdmin() && $user->teams->contains('id', $sharedEnvironmentVariable->team_id); - return true; + return false; } /** @@ -64,8 +60,7 @@ public function restore(User $user, SharedEnvironmentVariable $sharedEnvironment */ public function forceDelete(User $user, SharedEnvironmentVariable $sharedEnvironmentVariable): bool { - // return $user->isAdmin() && $user->teams->contains('id', $sharedEnvironmentVariable->team_id); - return true; + return false; } /** @@ -73,7 +68,6 @@ public function forceDelete(User $user, SharedEnvironmentVariable $sharedEnviron */ public function manageEnvironment(User $user, SharedEnvironmentVariable $sharedEnvironmentVariable): bool { - // return $user->isAdmin() && $user->teams->contains('id', $sharedEnvironmentVariable->team_id); - return true; + return $user->isAdminOfTeam($sharedEnvironmentVariable->team_id); } } diff --git a/app/Policies/StandaloneDockerPolicy.php b/app/Policies/StandaloneDockerPolicy.php index 3e1f83d12..33eda183a 100644 --- a/app/Policies/StandaloneDockerPolicy.php +++ b/app/Policies/StandaloneDockerPolicy.php @@ -28,8 +28,7 @@ public function view(User $user, StandaloneDocker $standaloneDocker): bool */ public function create(User $user): bool { - // return $user->isAdmin(); - return true; + return $user->isAdmin(); } /** @@ -37,7 +36,7 @@ public function create(User $user): bool */ public function update(User $user, StandaloneDocker $standaloneDocker): bool { - return $user->teams->contains('id', $standaloneDocker->server->team_id); + return $user->isAdminOfTeam($standaloneDocker->server->team_id); } /** @@ -45,7 +44,7 @@ public function update(User $user, StandaloneDocker $standaloneDocker): bool */ public function delete(User $user, StandaloneDocker $standaloneDocker): bool { - return $user->teams->contains('id', $standaloneDocker->server->team_id); + return $user->isAdminOfTeam($standaloneDocker->server->team_id); } /** diff --git a/app/Policies/SwarmDockerPolicy.php b/app/Policies/SwarmDockerPolicy.php index 82a75910b..b19ab4907 100644 --- a/app/Policies/SwarmDockerPolicy.php +++ b/app/Policies/SwarmDockerPolicy.php @@ -28,8 +28,7 @@ public function view(User $user, SwarmDocker $swarmDocker): bool */ public function create(User $user): bool { - // return $user->isAdmin(); - return true; + return $user->isAdmin(); } /** @@ -37,7 +36,7 @@ public function create(User $user): bool */ public function update(User $user, SwarmDocker $swarmDocker): bool { - return $user->teams->contains('id', $swarmDocker->server->team_id); + return $user->isAdminOfTeam($swarmDocker->server->team_id); } /** @@ -45,7 +44,7 @@ public function update(User $user, SwarmDocker $swarmDocker): bool */ public function delete(User $user, SwarmDocker $swarmDocker): bool { - return $user->teams->contains('id', $swarmDocker->server->team_id); + return $user->isAdminOfTeam($swarmDocker->server->team_id); } /** diff --git a/resources/views/components/applications/advanced.blade.php b/resources/views/components/applications/advanced.blade.php index e36583741..5964abb4e 100644 --- a/resources/views/components/applications/advanced.blade.php +++ b/resources/views/components/applications/advanced.blade.php @@ -3,7 +3,7 @@ Advanced @if ($application->status === 'running') -
- - + @if ($isPasswordHiddenForMember) + + + @else + + + @endif
@can('validateConnection', $storage) From 66dc1515d43781347fd79ca7f713eeaded4b0150 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 27 Feb 2026 22:58:44 +0100 Subject: [PATCH 027/151] fix(security): prevent snapshot replay in API token permission checks Never trust Livewire component properties for authorization decisions, as snapshots can be replayed from another user's session. Re-evaluate all permission checks fresh using auth()->user()->can() against current policies to ensure the authenticated user is being authorized, not a replayed copy. - Replace cached canUse* booleans with fresh policy evaluation - Add comprehensive security tests for token creation permissions - Update API authorization tests to verify middleware blocking behavior --- app/Livewire/Security/ApiTokens.php | 24 +-- .../Authorization/ApiAuthorizationTest.php | 4 +- .../Authorization/ApiTokenPermissionTest.php | 3 + .../Security/ApiTokenCreationSecurityTest.php | 166 ++++++++++++++++++ 4 files changed, 183 insertions(+), 14 deletions(-) create mode 100644 tests/Feature/Security/ApiTokenCreationSecurityTest.php diff --git a/app/Livewire/Security/ApiTokens.php b/app/Livewire/Security/ApiTokens.php index af2ae189a..ffd8f28cf 100644 --- a/app/Livewire/Security/ApiTokens.php +++ b/app/Livewire/Security/ApiTokens.php @@ -50,31 +50,29 @@ private function getTokens() public function updatedPermissions($permissionToUpdate) { - // Check if user is trying to use restricted permissions - if ($permissionToUpdate == 'root' && ! $this->canUseRootPermissions) { + // Re-evaluate policies fresh — never trust stored snapshot booleans + if ($permissionToUpdate == 'root' && ! auth()->user()->can('useRootPermissions', PersonalAccessToken::class)) { $this->dispatch('error', 'You do not have permission to use root permissions.'); - // Remove root from permissions if it was somehow added $this->permissions = array_diff($this->permissions, ['root']); return; } - if (in_array($permissionToUpdate, ['write', 'write:sensitive']) && ! $this->canUseWritePermissions) { + if (in_array($permissionToUpdate, ['write', 'write:sensitive']) && ! auth()->user()->can('useWritePermissions', PersonalAccessToken::class)) { $this->dispatch('error', 'You do not have permission to use write permissions.'); - // Remove write permissions if they were somehow added $this->permissions = array_diff($this->permissions, ['write', 'write:sensitive']); return; } - if ($permissionToUpdate == 'deploy' && ! $this->canUseDeployPermissions) { + if ($permissionToUpdate == 'deploy' && ! auth()->user()->can('useDeployPermissions', PersonalAccessToken::class)) { $this->dispatch('error', 'You do not have permission to use deploy permissions.'); $this->permissions = array_diff($this->permissions, ['deploy']); return; } - if ($permissionToUpdate == 'read:sensitive' && ! $this->canUseSensitivePermissions) { + if ($permissionToUpdate == 'read:sensitive' && ! auth()->user()->can('useSensitivePermissions', PersonalAccessToken::class)) { $this->dispatch('error', 'You do not have permission to use read:sensitive permissions.'); $this->permissions = array_diff($this->permissions, ['read:sensitive']); @@ -100,20 +98,22 @@ public function addNewToken() try { $this->authorize('create', PersonalAccessToken::class); - // Validate permissions based on user role - if (in_array('root', $this->permissions) && ! $this->canUseRootPermissions) { + // Re-evaluate policies fresh against the current authenticated user. + // Never trust $this->canUse* booleans — they come from the Livewire + // snapshot which can be replayed from another user's session. + if (in_array('root', $this->permissions) && ! auth()->user()->can('useRootPermissions', PersonalAccessToken::class)) { throw new \Exception('You do not have permission to create tokens with root permissions.'); } - if (array_intersect(['write', 'write:sensitive'], $this->permissions) && ! $this->canUseWritePermissions) { + if (array_intersect(['write', 'write:sensitive'], $this->permissions) && ! auth()->user()->can('useWritePermissions', PersonalAccessToken::class)) { throw new \Exception('You do not have permission to create tokens with write permissions.'); } - if (in_array('deploy', $this->permissions) && ! $this->canUseDeployPermissions) { + if (in_array('deploy', $this->permissions) && ! auth()->user()->can('useDeployPermissions', PersonalAccessToken::class)) { throw new \Exception('You do not have permission to create tokens with deploy permissions.'); } - if (in_array('read:sensitive', $this->permissions) && ! $this->canUseSensitivePermissions) { + if (in_array('read:sensitive', $this->permissions) && ! auth()->user()->can('useSensitivePermissions', PersonalAccessToken::class)) { throw new \Exception('You do not have permission to create tokens with read:sensitive permissions.'); } diff --git a/tests/Feature/Authorization/ApiAuthorizationTest.php b/tests/Feature/Authorization/ApiAuthorizationTest.php index 59bfd9659..66a6900a3 100644 --- a/tests/Feature/Authorization/ApiAuthorizationTest.php +++ b/tests/Feature/Authorization/ApiAuthorizationTest.php @@ -123,10 +123,10 @@ // --- Member with root token (policy should deny mutations) --- -test('member with root token can view project', function () { +test('member with root token is blocked by middleware', function () { $this->withToken($this->memberRootToken->plainTextToken) ->getJson("/api/v1/projects/{$this->project->uuid}") - ->assertSuccessful(); + ->assertStatus(403); }); test('member with root token cannot delete project', function () { diff --git a/tests/Feature/Authorization/ApiTokenPermissionTest.php b/tests/Feature/Authorization/ApiTokenPermissionTest.php index 44efb7e06..b10afb58e 100644 --- a/tests/Feature/Authorization/ApiTokenPermissionTest.php +++ b/tests/Feature/Authorization/ApiTokenPermissionTest.php @@ -1,5 +1,6 @@ 0], ['is_api_enabled' => true]); + $this->team = Team::factory()->create(); $this->user = User::factory()->create(); $this->team->members()->attach($this->user->id, ['role' => 'owner']); diff --git a/tests/Feature/Security/ApiTokenCreationSecurityTest.php b/tests/Feature/Security/ApiTokenCreationSecurityTest.php new file mode 100644 index 000000000..0e5d363de --- /dev/null +++ b/tests/Feature/Security/ApiTokenCreationSecurityTest.php @@ -0,0 +1,166 @@ + 0], ['is_api_enabled' => true]); + + $this->team = Team::factory()->create(); + + $this->owner = User::factory()->create(); + $this->owner->teams()->attach($this->team, ['role' => 'owner']); + + $this->member = User::factory()->create(); + $this->member->teams()->attach($this->team, ['role' => 'member']); +}); + +describe('Livewire ApiTokens — member cannot create elevated tokens', function () { + test('member cannot create token with root permissions', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::test(ApiTokens::class) + ->set('description', 'my-root-token') + ->set('permissions', ['root']) + ->call('addNewToken') + ->assertDispatched('error'); + + expect($this->member->tokens()->count())->toBe(0); + }); + + test('member cannot create token with write permissions', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::test(ApiTokens::class) + ->set('description', 'my-write-token') + ->set('permissions', ['write']) + ->call('addNewToken') + ->assertDispatched('error'); + + expect($this->member->tokens()->count())->toBe(0); + }); + + test('member cannot create token with deploy permissions', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::test(ApiTokens::class) + ->set('description', 'my-deploy-token') + ->set('permissions', ['deploy']) + ->call('addNewToken') + ->assertDispatched('error'); + + expect($this->member->tokens()->count())->toBe(0); + }); + + test('member cannot create token with read:sensitive permissions', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::test(ApiTokens::class) + ->set('description', 'my-sensitive-token') + ->set('permissions', ['read', 'read:sensitive']) + ->call('addNewToken') + ->assertDispatched('error'); + + expect($this->member->tokens()->count())->toBe(0); + }); + + test('member cannot bypass by setting canUseRootPermissions property', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + // Simulate snapshot replay: force the boolean to true + Livewire::test(ApiTokens::class) + ->set('canUseRootPermissions', true) + ->set('description', 'sneaky-root-token') + ->set('permissions', ['root']) + ->call('addNewToken') + ->assertDispatched('error'); + + expect($this->member->tokens()->count())->toBe(0); + }); + + test('member can create token with read permissions', function () { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::test(ApiTokens::class) + ->set('description', 'my-read-token') + ->set('permissions', ['read']) + ->call('addNewToken') + ->assertNotDispatched('error'); + + expect($this->member->tokens()->count())->toBe(1); + expect($this->member->tokens()->first()->abilities)->toBe(['read']); + }); + + test('owner can create token with root permissions', function () { + $this->actingAs($this->owner); + session(['currentTeam' => $this->team]); + + Livewire::test(ApiTokens::class) + ->set('description', 'my-root-token') + ->set('permissions', ['root']) + ->call('addNewToken') + ->assertNotDispatched('error'); + + expect($this->owner->tokens()->count())->toBe(1); + expect($this->owner->tokens()->first()->abilities)->toBe(['root']); + }); +}); + +describe('ApiAbility middleware — member with elevated token blocked', function () { + test('member root token is blocked on team_id=0 (root team)', function () { + // Create root team with id=0 + $rootTeam = Team::factory()->create(['id' => 0]); + $member = User::factory()->create(); + $rootTeam->members()->attach($member->id, ['role' => 'member']); + + session(['currentTeam' => $rootTeam]); + $token = $member->createToken('root-token', ['root']); + + $this->withToken($token->plainTextToken) + ->getJson('/api/v1/projects') + ->assertStatus(403); + }); + + test('admin root token passes on team_id=0 (root team)', function () { + $rootTeam = Team::factory()->create(['id' => 0]); + $admin = User::factory()->create(); + $rootTeam->members()->attach($admin->id, ['role' => 'admin']); + + session(['currentTeam' => $rootTeam]); + $token = $admin->createToken('root-token', ['root']); + + $this->withToken($token->plainTextToken) + ->getJson('/api/v1/projects') + ->assertSuccessful(); + }); + + test('member root token is blocked on non-zero team', function () { + session(['currentTeam' => $this->team]); + $token = $this->member->createToken('root-token', ['root']); + + $this->withToken($token->plainTextToken) + ->getJson('/api/v1/projects') + ->assertStatus(403); + }); + + test('member read token passes on non-zero team', function () { + session(['currentTeam' => $this->team]); + $token = $this->member->createToken('read-token', ['read']); + + $this->withToken($token->plainTextToken) + ->getJson('/api/v1/projects') + ->assertSuccessful(); + }); +}); From b2f09f4df068f8b7bab2dda93938ca88501266e2 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 27 Feb 2026 23:11:03 +0100 Subject: [PATCH 028/151] fix(auth): resolve current team from Sanctum token for API requests Add fallback to resolve team from Sanctum access token when session team is unavailable, enabling proper team context for stateless API requests. --- app/Models/User.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/Models/User.php b/app/Models/User.php index 4561cddb2..68ecc6b31 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -316,6 +316,11 @@ public function currentTeam(): ?Team { $sessionTeamId = data_get(session('currentTeam'), 'id'); + // Fallback for stateless API requests: resolve team from Sanctum token + if (is_null($sessionTeamId) && $this->currentAccessToken()) { + $sessionTeamId = data_get($this->currentAccessToken(), 'team_id'); + } + if (is_null($sessionTeamId)) { return null; } From b38ce26e34d185c283f40fbe76919f6540f6e15f Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 1 Mar 2026 14:08:29 +0100 Subject: [PATCH 029/151] fix(auth): preserve Sanctum token prefix for lookups Sanctum uses the numeric prefix (e.g. "69|...") in plaintext tokens to index and look up tokens. Stripping this prefix breaks token resolution. --- app/Livewire/Security/ApiTokens.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/Livewire/Security/ApiTokens.php b/app/Livewire/Security/ApiTokens.php index ffd8f28cf..97d2bfb44 100644 --- a/app/Livewire/Security/ApiTokens.php +++ b/app/Livewire/Security/ApiTokens.php @@ -4,7 +4,6 @@ use App\Models\InstanceSettings; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; -use Illuminate\Support\Str; use Laravel\Sanctum\PersonalAccessToken; use Livewire\Component; @@ -122,7 +121,8 @@ public function addNewToken() ]); $token = auth()->user()->createToken($this->description, array_values($this->permissions)); $this->getTokens(); - session()->flash('token', Str::after($token->plainTextToken, '|')); + // Do NOT strip the numeric prefix (e.g. "69|...") — Sanctum uses it to index and look up tokens. + session()->flash('token', $token->plainTextToken); } catch (\Exception $e) { return handleError($e, $this); } From 7f46b2ed71230413385c7ec9dc59b0ea3f4cd19d Mon Sep 17 00:00:00 2001 From: Tobias Thiele Date: Mon, 30 Mar 2026 14:17:49 +0200 Subject: [PATCH 030/151] =?UTF-8?q?=E2=9C=A8=20Populate=20docker=5Fcompose?= =?UTF-8?q?=5Fdomains=20for=20dockercompose=20apps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add logic to populate docker_compose_domains in applicationParser for dockercompose build packs, ensuring proper domain handling for KEY-based SERVICE_FQDN variables. --- .../Api/ApplicationsController.php | 6 +- bootstrap/helpers/parsers.php | 22 +++ ...licationParserDockerComposeDomainsTest.php | 144 ++++++++++++++++++ 3 files changed, 169 insertions(+), 3 deletions(-) create mode 100644 tests/Feature/ApplicationParserDockerComposeDomainsTest.php diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 77f4e626f..f72d77ae6 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -1229,7 +1229,7 @@ private function create_application(Request $request, $type) $request->offsetUnset('docker_compose_domains'); } if ($dockerComposeDomainsJson->count() > 0) { - $application->docker_compose_domains = $dockerComposeDomainsJson; + $application->docker_compose_domains = json_encode($dockerComposeDomainsJson); } $repository_url_parsed = Url::fromString($request->git_repository); $git_host = $repository_url_parsed->getHost(); @@ -1461,7 +1461,7 @@ private function create_application(Request $request, $type) $request->offsetUnset('docker_compose_domains'); } if ($dockerComposeDomainsJson->count() > 0) { - $application->docker_compose_domains = $dockerComposeDomainsJson; + $application->docker_compose_domains = json_encode($dockerComposeDomainsJson); } $application->fqdn = $fqdn; $application->git_repository = str($gitRepository)->trim()->toString(); @@ -1665,7 +1665,7 @@ private function create_application(Request $request, $type) $request->offsetUnset('docker_compose_domains'); } if ($dockerComposeDomainsJson->count() > 0) { - $application->docker_compose_domains = $dockerComposeDomainsJson; + $application->docker_compose_domains = json_encode($dockerComposeDomainsJson); } $application->fqdn = $fqdn; $application->private_key_id = $privateKey->id; diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index 751851283..aac075746 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -504,6 +504,28 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int 'is_preview' => false, ]); } + + // Also populate docker_compose_domains for dockercompose apps (mirrors Path 1 at lines 601-627) + if ($resource->build_pack === 'dockercompose') { + $normalizedFqdnFor = str($fqdnFor)->replace('-', '_')->replace('.', '_')->value(); + $serviceExists = false; + foreach ($services as $serviceNameKey => $svc) { + if (str($serviceNameKey)->replace('-', '_')->replace('.', '_')->value() === $normalizedFqdnFor) { + $serviceExists = true; + break; + } + } + if ($serviceExists) { + $domains = collect(json_decode(data_get($resource, 'docker_compose_domains'))) ?? collect([]); + $domainExists = data_get($domains->get($normalizedFqdnFor), 'domain'); + if (is_null($domainExists)) { + $domainValue = $fqdn; + $domains->put($normalizedFqdnFor, ['domain' => $domainValue]); + $resource->docker_compose_domains = $domains->toJson(); + $resource->save(); + } + } + } } } diff --git a/tests/Feature/ApplicationParserDockerComposeDomainsTest.php b/tests/Feature/ApplicationParserDockerComposeDomainsTest.php new file mode 100644 index 000000000..3d8ab15fb --- /dev/null +++ b/tests/Feature/ApplicationParserDockerComposeDomainsTest.php @@ -0,0 +1,144 @@ +user = User::factory()->create(); + $this->team = Team::factory()->create(); + $this->user->teams()->attach($this->team); + + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create([ + 'project_id' => $this->project->id, + ]); + + $ecKey = EC::createKey('Ed25519'); + $privateKeyContent = $ecKey->toString('OpenSSH'); + + $privateKey = PrivateKey::create([ + 'name' => 'test-key', + 'private_key' => $privateKeyContent, + 'team_id' => $this->team->id, + ]); + + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $privateKey->id, + ]); + + ServerSetting::create([ + 'server_id' => $this->server->id, + 'wildcard_domain' => 'http://127.0.0.1.sslip.io', + ]); + + $this->destination = StandaloneDocker::factory()->create([ + 'server_id' => $this->server->id, + 'network' => 'test-network-'.fake()->uuid(), + ]); +}); + +test('applicationParser populates docker_compose_domains for KEY-based SERVICE_FQDN variables', function () { + $dockerCompose = <<<'YAML' +services: + backend: + image: myapp/backend:latest + environment: + - SERVICE_FQDN_BACKEND_8000=${BACKEND_URL} + frontend: + image: myapp/frontend:latest + environment: + - SERVICE_FQDN_FRONTEND=${FRONTEND_URL} +YAML; + + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => StandaloneDocker::class, + 'build_pack' => 'dockercompose', + 'docker_compose_raw' => $dockerCompose, + 'fqdn' => null, + 'docker_compose_domains' => null, + ]); + + applicationParser($application); + + $application->refresh(); + + $domains = json_decode($application->docker_compose_domains, true); + + expect($domains)->not->toBeNull() + ->and($domains)->toBeArray() + ->and($domains)->toHaveKey('backend') + ->and($domains['backend'])->toHaveKey('domain') + ->and($domains['backend']['domain'])->not->toBeEmpty(); +}); + +test('applicationParser populates docker_compose_domains for KEY-based SERVICE_FQDN without port', function () { + $dockerCompose = <<<'YAML' +services: + frontend: + image: myapp/frontend:latest + environment: + - SERVICE_FQDN_FRONTEND=${FRONTEND_URL} +YAML; + + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => StandaloneDocker::class, + 'build_pack' => 'dockercompose', + 'docker_compose_raw' => $dockerCompose, + 'fqdn' => null, + 'docker_compose_domains' => null, + ]); + + applicationParser($application); + + $application->refresh(); + + $domains = json_decode($application->docker_compose_domains, true); + + expect($domains)->not->toBeNull() + ->and($domains)->toHaveKey('frontend') + ->and($domains['frontend'])->toHaveKey('domain'); +}); + +test('applicationParser does not populate docker_compose_domains for non-dockercompose build_pack', function () { + $dockerCompose = <<<'YAML' +services: + backend: + image: myapp/backend:latest + environment: + - SERVICE_FQDN_BACKEND_8000=${BACKEND_URL} +YAML; + + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => StandaloneDocker::class, + 'build_pack' => 'dockerfile', + 'docker_compose_raw' => $dockerCompose, + 'fqdn' => null, + 'docker_compose_domains' => null, + ]); + + applicationParser($application); + + $application->refresh(); + + // For non-dockercompose, docker_compose_domains should remain empty + $domains = json_decode($application->docker_compose_domains, true); + expect($domains)->toBeNull(); +}); From 8b12aae2665b484b328ab2c46c463600c78eaf32 Mon Sep 17 00:00:00 2001 From: Tobias Thiele Date: Thu, 2 Apr 2026 09:23:47 +0200 Subject: [PATCH 031/151] fix(parsers): remove unused $svc variable in dockercompose domain loop Use array_keys() instead of key-value iteration since only the service name keys are needed for comparison. Addresses CodeRabbit review feedback. --- bootstrap/helpers/parsers.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index aac075746..d1cad5bb6 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -509,7 +509,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int if ($resource->build_pack === 'dockercompose') { $normalizedFqdnFor = str($fqdnFor)->replace('-', '_')->replace('.', '_')->value(); $serviceExists = false; - foreach ($services as $serviceNameKey => $svc) { + foreach (array_keys($services) as $serviceNameKey) { if (str($serviceNameKey)->replace('-', '_')->replace('.', '_')->value() === $normalizedFqdnFor) { $serviceExists = true; break; From 8a715489cbc27693b5e00d3eec0f3acb30d48bc9 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 19 Apr 2026 17:42:17 +0200 Subject: [PATCH 032/151] Delete lessons.md --- .ai/lessons.md | 20 -------------------- 1 file changed, 20 deletions(-) delete mode 100644 .ai/lessons.md diff --git a/.ai/lessons.md b/.ai/lessons.md deleted file mode 100644 index 6bd6dcbaa..000000000 --- a/.ai/lessons.md +++ /dev/null @@ -1,20 +0,0 @@ -# Lessons Learned - -## Docker / Worktree Setup -- The Docker dev container mounts from `young-stork` worktree, NOT `ivory-raccoon` -- Do NOT copy files to `young-stork` or use `docker cp` — only modify files in the `ivory-raccoon` worktree -- Do NOT use `docker exec` to run tests — work entirely within the `ivory-raccoon` worktree - -## Policy Tests -- Policy methods have typed parameters (e.g., `Server $server`) — anonymous classes cause TypeError -- Must use `Mockery::mock(Model::class)->makePartial()` instead of anonymous classes for model stubs -- Use `shouldReceive('getAttribute')->with('property')->andReturn(value)` for model properties accessed via relationship chains - -## Browser Tests (Pest Browser Plugin) -- Plugin runs an in-process HTTP server (Amphp) sharing the same SQLite :memory: database as the test process -- Model boot events that call external services (e.g., `StandaloneDocker::created` runs docker commands) WILL fail in tests — use `Model::withoutEvents()` to wrap creation -- Livewire full-page components that fail during `mount()` silently redirect to the previous URL instead of showing an error page -- `Server::proxySet()` requires `isFunctional()` which requires `is_reachable=true` AND `is_usable=true` in ServerSetting — tests without a validated server won't show proxy controls -- Application/Database/Service pages require complex model chains (Application → Environment → Project → Team, with StandaloneDocker destination) that are difficult to fully set up for browser tests due to Livewire mount() redirecting on any chain failure -- The `currentTeam()` helper reads from session (`data_get(session('currentTeam'), 'id')`) — set during browser login flow -- `Project::created` auto-creates a "production" environment — don't manually create one with that name From 6a5fd40a5c4a2c0dd5af4692ffa24b54fd2c616a Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 20 Apr 2026 11:51:35 +0200 Subject: [PATCH 033/151] refactor(policies): add uploadBackup ability and enforce it on backup upload endpoint Introduce a dedicated `uploadBackup` ability on Application, Database, Service, and ServiceDatabase policies (admin/owner only) and call `$this->authorize('uploadBackup', $resource)` in `UploadController::upload` so the backup-upload endpoint goes through the same policy layer as the rest of the authorization refactor. Adds Pest coverage for each policy variant plus HTTP-level checks. Co-Authored-By: Claude Opus 4.7 --- app/Http/Controllers/UploadController.php | 6 + app/Policies/ApplicationPolicy.php | 18 ++ app/Policies/DatabasePolicy.php | 18 ++ app/Policies/ServiceDatabasePolicy.php | 8 + app/Policies/ServicePolicy.php | 10 + .../UploadBackupAuthorizationTest.php | 186 ++++++++++++++++++ 6 files changed, 246 insertions(+) create mode 100644 tests/Feature/Authorization/UploadBackupAuthorizationTest.php diff --git a/app/Http/Controllers/UploadController.php b/app/Http/Controllers/UploadController.php index 93847589a..895564caa 100644 --- a/app/Http/Controllers/UploadController.php +++ b/app/Http/Controllers/UploadController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Http\Request; use Illuminate\Http\UploadedFile; use Illuminate\Routing\Controller as BaseController; @@ -11,6 +12,8 @@ class UploadController extends BaseController { + use AuthorizesRequests; + public function upload(Request $request) { $databaseIdentifier = request()->route('databaseUuid'); @@ -18,6 +21,9 @@ public function upload(Request $request) if (is_null($resource)) { return response()->json(['error' => 'You do not have permission for this database'], 500); } + + $this->authorize('uploadBackup', $resource); + $receiver = new FileReceiver('file', $request, HandlerFactory::classFromRequest($request)); if ($receiver->isUploaded() === false) { diff --git a/app/Policies/ApplicationPolicy.php b/app/Policies/ApplicationPolicy.php index 7a992f2fd..1a1290b55 100644 --- a/app/Policies/ApplicationPolicy.php +++ b/app/Policies/ApplicationPolicy.php @@ -78,6 +78,24 @@ public function forceDelete(User $user, Application $application): bool return false; } + /** + * Determine whether the user can upload a backup archive for this application. + */ + public function uploadBackup(User $user, Application $application): Response + { + $teamId = $this->getTeamId($application); + + if ($teamId === null) { + return Response::deny('Application team not found.'); + } + + if ($user->isAdminOfTeam($teamId)) { + return Response::allow(); + } + + return Response::deny('You need at least admin or owner permissions to upload backups for this application.'); + } + /** * Determine whether the user can deploy the application. */ diff --git a/app/Policies/DatabasePolicy.php b/app/Policies/DatabasePolicy.php index f62ffdde2..4217432b5 100644 --- a/app/Policies/DatabasePolicy.php +++ b/app/Policies/DatabasePolicy.php @@ -87,6 +87,24 @@ public function manage(User $user, $database): bool return $teamId !== null && $user->isAdminOfTeam($teamId); } + /** + * Determine whether the user can upload a backup archive for this database. + */ + public function uploadBackup(User $user, $database): Response + { + $teamId = $this->getTeamId($database); + + if ($teamId === null) { + return Response::deny('Database team not found.'); + } + + if ($user->isAdminOfTeam($teamId)) { + return Response::allow(); + } + + return Response::deny('You need at least admin or owner permissions to upload backups for this database.'); + } + /** * Determine whether the user can manage database backups. */ diff --git a/app/Policies/ServiceDatabasePolicy.php b/app/Policies/ServiceDatabasePolicy.php index e5cbe91a0..e94658e29 100644 --- a/app/Policies/ServiceDatabasePolicy.php +++ b/app/Policies/ServiceDatabasePolicy.php @@ -63,4 +63,12 @@ public function manageBackups(User $user, ServiceDatabase $serviceDatabase): boo { return Gate::allows('update', $serviceDatabase->service); } + + /** + * Determine whether the user can upload a backup archive for this service database. + */ + public function uploadBackup(User $user, ServiceDatabase $serviceDatabase): bool + { + return Gate::allows('uploadBackup', $serviceDatabase->service); + } } diff --git a/app/Policies/ServicePolicy.php b/app/Policies/ServicePolicy.php index d48728cdf..6ca79b42a 100644 --- a/app/Policies/ServicePolicy.php +++ b/app/Policies/ServicePolicy.php @@ -89,6 +89,16 @@ public function manageEnvironment(User $user, Service $service): bool return $teamId !== null && $user->isAdminOfTeam($teamId); } + /** + * Determine whether the user can upload a backup archive for a database within this service. + */ + public function uploadBackup(User $user, Service $service): bool + { + $teamId = $this->getTeamId($service); + + return $teamId !== null && $user->isAdminOfTeam($teamId); + } + /** * Determine whether the user can deploy the service. */ diff --git a/tests/Feature/Authorization/UploadBackupAuthorizationTest.php b/tests/Feature/Authorization/UploadBackupAuthorizationTest.php new file mode 100644 index 000000000..126e6ef52 --- /dev/null +++ b/tests/Feature/Authorization/UploadBackupAuthorizationTest.php @@ -0,0 +1,186 @@ + 0]); + + $this->team = Team::factory()->create(); + + $this->admin = User::factory()->create(); + $this->admin->teams()->attach($this->team, ['role' => 'admin']); + + $this->member = User::factory()->create(); + $this->member->teams()->attach($this->team, ['role' => 'member']); + + $keyId = DB::table('private_keys')->insertGetId([ + 'uuid' => (string) Str::uuid(), + 'name' => 'Test Key', + 'private_key' => 'test-key', + 'team_id' => $this->team->id, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $keyId, + ]); + + StandaloneDocker::withoutEvents(function () { + $this->destination = StandaloneDocker::firstOrCreate( + ['server_id' => $this->server->id, 'network' => 'coolify'], + ['uuid' => (string) Str::uuid(), 'name' => 'test-docker'] + ); + }); + + $this->project = Project::create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'Test Project', + 'team_id' => $this->team->id, + ]); + + $this->environment = $this->project->environments()->first(); + + $this->database = StandalonePostgresql::create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'Test DB', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'testdb', + 'image' => 'postgres:15', + 'status' => 'running', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $this->application = Application::factory()->create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'Test App', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'status' => 'running', + ]); + + $this->service = Service::create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'test-service', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'docker_compose_raw' => 'version: "3"', + ]); + + $this->serviceDatabase = ServiceDatabase::create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'test-service-db', + 'service_id' => $this->service->id, + ]); +}); + +// --- DatabasePolicy::uploadBackup (covers Standalone* databases) --- + +test('admin can upload backup for standalone database', function () { + expect($this->admin->can('uploadBackup', $this->database))->toBeTrue(); +}); + +test('member cannot upload backup for standalone database', function () { + expect($this->member->can('uploadBackup', $this->database))->toBeFalse(); +}); + +// --- ApplicationPolicy::uploadBackup --- + +test('admin can upload backup for application', function () { + expect($this->admin->can('uploadBackup', $this->application))->toBeTrue(); +}); + +test('member cannot upload backup for application', function () { + expect($this->member->can('uploadBackup', $this->application))->toBeFalse(); +}); + +// --- ServicePolicy::uploadBackup --- + +test('admin can upload backup for service', function () { + expect($this->admin->can('uploadBackup', $this->service))->toBeTrue(); +}); + +test('member cannot upload backup for service', function () { + expect($this->member->can('uploadBackup', $this->service))->toBeFalse(); +}); + +// --- ServiceDatabasePolicy::uploadBackup (delegates to ServicePolicy) --- + +test('admin can upload backup for service database', function () { + expect($this->admin->can('uploadBackup', $this->serviceDatabase))->toBeTrue(); +}); + +test('member cannot upload backup for service database', function () { + expect($this->member->can('uploadBackup', $this->serviceDatabase))->toBeFalse(); +}); + +// --- Cross-team isolation --- + +test('user from different team cannot upload backup', function () { + $otherTeam = Team::factory()->create(); + $otherUser = User::factory()->create(); + $otherUser->teams()->attach($otherTeam, ['role' => 'admin']); + + expect($otherUser->can('uploadBackup', $this->database))->toBeFalse(); + expect($otherUser->can('uploadBackup', $this->application))->toBeFalse(); + expect($otherUser->can('uploadBackup', $this->service))->toBeFalse(); + expect($otherUser->can('uploadBackup', $this->serviceDatabase))->toBeFalse(); +}); + +// --- HTTP endpoint: POST /upload/backup/{uuid} --- + +test('member gets 403 from POST /upload/backup and no file lands on disk', function () { + $uploadDir = storage_path('app/upload/'.$this->database->uuid); + if (File::exists($uploadDir)) { + File::deleteDirectory($uploadDir); + } + + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + $response = $this->post(route('upload.backup', ['databaseUuid' => $this->database->uuid])); + + $response->assertForbidden(); + + expect(File::exists($uploadDir.'/restore'))->toBeFalse(); +}); + +test('user from different team hits null-resource branch with 500', function () { + $otherTeam = Team::factory()->create(); + $otherUser = User::factory()->create(); + $otherUser->teams()->attach($otherTeam, ['role' => 'admin']); + + $this->actingAs($otherUser); + session(['currentTeam' => $otherTeam]); + + $response = $this->post(route('upload.backup', ['databaseUuid' => $this->database->uuid])); + + $response->assertStatus(500); +}); + +test('unauthenticated request is redirected to login', function () { + $response = $this->post(route('upload.backup', ['databaseUuid' => $this->database->uuid])); + + $response->assertRedirect('/login'); +}); From 8b7dbbafb25eb8ac4e572b6dd16a65bac1f524a0 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 30 Apr 2026 11:28:06 +0200 Subject: [PATCH 034/151] fix(api): hide sensitive fields by default, expose via makeVisible for privileged tokens Models now declare $hidden for passwords, tokens, db URLs, and compose fields. API controllers flip from makeHidden-on-deny to makeVisible-on- allow (can_read_sensitive=true), fixing fields that were never hidden. Also adds missing fields (mysql/mariadb passwords, logdrain keys, etc.) to privileged disclosure lists. Tests added: Feature/Security/ApiSensitiveFieldsTest and Unit/Models/SensitiveFieldsHiddenTest cover all affected models and controllers. --- .../Api/ApplicationsController.php | 11 +- .../Controllers/Api/DatabasesController.php | 13 +- .../Controllers/Api/ServersController.php | 16 +- .../Controllers/Api/ServicesController.php | 4 +- app/Models/Application.php | 18 ++ app/Models/EnvironmentVariable.php | 14 +- app/Models/Server.php | 10 + app/Models/ServerSetting.php | 14 ++ app/Models/Service.php | 11 ++ app/Models/StandaloneClickhouse.php | 11 ++ app/Models/StandaloneDragonfly.php | 11 ++ app/Models/StandaloneKeydb.php | 11 ++ app/Models/StandaloneMariadb.php | 12 ++ app/Models/StandaloneMongodb.php | 11 ++ app/Models/StandaloneMysql.php | 12 ++ app/Models/StandalonePostgresql.php | 12 ++ app/Models/StandaloneRedis.php | 11 ++ .../Security/ApiSensitiveFieldsTest.php | 184 ++++++++++++++++++ .../Unit/Models/SensitiveFieldsHiddenTest.php | 179 +++++++++++++++++ 19 files changed, 549 insertions(+), 16 deletions(-) create mode 100644 tests/Feature/Security/ApiSensitiveFieldsTest.php create mode 100644 tests/Unit/Models/SensitiveFieldsHiddenTest.php diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index bb72ebabe..9d9c5d33a 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -43,8 +43,8 @@ private function removeSensitiveData($application) 'resourceable_id', 'resourceable_type', ]); - if (request()->attributes->get('can_read_sensitive', false) === false) { - $application->makeHidden([ + if (request()->attributes->get('can_read_sensitive', false) === true) { + $application->makeVisible([ 'custom_labels', 'dockerfile', 'docker_compose', @@ -53,10 +53,13 @@ private function removeSensitiveData($application) 'manual_webhook_secret_gitea', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', - 'private_key_id', + 'http_basic_auth_password', 'value', 'real_value', - 'http_basic_auth_password', + ]); + } else { + $application->makeHidden([ + 'private_key_id', ]); } diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index dc9b6f5b5..acfc64355 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -33,16 +33,21 @@ private function removeSensitiveData($database) 'id', 'laravel_through_key', ]); - if (request()->attributes->get('can_read_sensitive', false) === false) { - $database->makeHidden([ + if (request()->attributes->get('can_read_sensitive', false) === true) { + $database->makeVisible([ 'internal_db_url', 'external_db_url', + 'init_scripts', 'postgres_password', 'dragonfly_password', 'redis_password', 'mongo_initdb_root_password', 'keydb_password', 'clickhouse_admin_password', + 'mysql_password', + 'mysql_root_password', + 'mariadb_password', + 'mariadb_root_password', ]); } @@ -2957,8 +2962,8 @@ private function removeSensitiveEnvData($env) 'resourceable_id', 'resourceable_type', ]); - if (request()->attributes->get('can_read_sensitive', false) === false) { - $env->makeHidden([ + if (request()->attributes->get('can_read_sensitive', false) === true) { + $env->makeVisible([ 'value', 'real_value', ]); diff --git a/app/Http/Controllers/Api/ServersController.php b/app/Http/Controllers/Api/ServersController.php index 6c3b2da00..cf50990dc 100644 --- a/app/Http/Controllers/Api/ServersController.php +++ b/app/Http/Controllers/Api/ServersController.php @@ -22,9 +22,14 @@ class ServersController extends Controller { private function removeSensitiveDataFromSettings($settings) { - if (request()->attributes->get('can_read_sensitive', false) === false) { - $settings = $settings->makeHidden([ + if (request()->attributes->get('can_read_sensitive', false) === true) { + $settings = $settings->makeVisible([ 'sentinel_token', + 'sentinel_custom_url', + 'logdrain_newrelic_license_key', + 'logdrain_axiom_api_key', + 'logdrain_custom_config', + 'logdrain_custom_config_parser', ]); } @@ -36,8 +41,11 @@ private function removeSensitiveData($server) $server->makeHidden([ 'id', ]); - if (request()->attributes->get('can_read_sensitive', false) === false) { - // Do nothing + if (request()->attributes->get('can_read_sensitive', false) === true) { + $server->makeVisible([ + 'logdrain_axiom_api_key', + 'logdrain_newrelic_license_key', + ]); } return serializeApiResponse($server); diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 11a23d46c..4a04f499d 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -30,8 +30,8 @@ private function removeSensitiveData($service) 'resourceable_id', 'resourceable_type', ]); - if (request()->attributes->get('can_read_sensitive', false) === false) { - $service->makeHidden([ + if (request()->attributes->get('can_read_sensitive', false) === true) { + $service->makeVisible([ 'docker_compose_raw', 'docker_compose', 'value', diff --git a/app/Models/Application.php b/app/Models/Application.php index 85e94bfd6..f342112b9 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -215,6 +215,24 @@ class Application extends BaseModel protected $appends = ['server_status']; + /** + * Sensitive fields hidden by default in serialized output (toArray/toJson). + * API controllers should call makeVisible([...]) for callers with the + * `read:sensitive` or `root` token ability. Internal serializers (deployment + * job, compose generation) must makeVisible explicitly before toArray(). + */ + protected $hidden = [ + 'http_basic_auth_password', + 'manual_webhook_secret_github', + 'manual_webhook_secret_gitlab', + 'manual_webhook_secret_bitbucket', + 'manual_webhook_secret_gitea', + 'dockerfile', + 'docker_compose', + 'docker_compose_raw', + 'custom_labels', + ]; + protected function casts(): array { return [ diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php index 83212267c..7dd9992a6 100644 --- a/app/Models/EnvironmentVariable.php +++ b/app/Models/EnvironmentVariable.php @@ -76,9 +76,19 @@ class EnvironmentVariable extends BaseModel protected $appends = ['real_value', 'is_shared', 'is_really_required', 'is_nixpacks', 'is_coolify']; + /** + * Sensitive fields hidden by default in serialized output (toArray/toJson). + * API controllers should call makeVisible([...]) for callers with the + * `read:sensitive` or `root` token ability. + */ + protected $hidden = [ + 'value', + 'real_value', + ]; + protected static function booted() { - static::created(function (EnvironmentVariable $environment_variable) { + static::created(function (ModelsEnvironmentVariable $environment_variable) { if ($environment_variable->resourceable_type === Application::class && ! $environment_variable->is_preview) { $found = ModelsEnvironmentVariable::where('key', $environment_variable->key) ->where('resourceable_type', Application::class) @@ -109,7 +119,7 @@ protected static function booted() ]); }); - static::saving(function (EnvironmentVariable $environmentVariable) { + static::saving(function (ModelsEnvironmentVariable $environmentVariable) { $environmentVariable->updateIsShared(); }); } diff --git a/app/Models/Server.php b/app/Models/Server.php index 74e8ba5b0..1c7eca844 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -254,6 +254,16 @@ public static function flushIdentityMap(): void 'force_disabled' => 'boolean', ]; + /** + * Sensitive fields hidden by default in serialized output (toArray/toJson). + * API controllers should call makeVisible([...]) for callers with the + * `read:sensitive` or `root` token ability. + */ + protected $hidden = [ + 'logdrain_axiom_api_key', + 'logdrain_newrelic_license_key', + ]; + protected $schemalessAttributes = [ 'proxy', ]; diff --git a/app/Models/ServerSetting.php b/app/Models/ServerSetting.php index 8d85c8932..807dc82ef 100644 --- a/app/Models/ServerSetting.php +++ b/app/Models/ServerSetting.php @@ -113,6 +113,20 @@ class ServerSetting extends Model 'connection_timeout' => 'integer', ]; + /** + * Sensitive fields hidden by default in serialized output (toArray/toJson). + * API controllers should call makeVisible([...]) for callers with the + * `read:sensitive` or `root` token ability. + */ + protected $hidden = [ + 'sentinel_token', + 'sentinel_custom_url', + 'logdrain_newrelic_license_key', + 'logdrain_axiom_api_key', + 'logdrain_custom_config', + 'logdrain_custom_config_parser', + ]; + protected static function booted() { static::creating(function ($setting) { diff --git a/app/Models/Service.php b/app/Models/Service.php index 11189b4ac..42b0dbede 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -67,6 +67,17 @@ class Service extends BaseModel protected $appends = ['server_status', 'status']; + /** + * Sensitive fields hidden by default in serialized output (toArray/toJson). + * API controllers should call makeVisible([...]) for callers with the + * `read:sensitive` or `root` token ability. Internal compose generators + * must makeVisible explicitly before toArray(). + */ + protected $hidden = [ + 'docker_compose', + 'docker_compose_raw', + ]; + protected static function booted() { static::creating(function ($service) { diff --git a/app/Models/StandaloneClickhouse.php b/app/Models/StandaloneClickhouse.php index 784e2c937..eb70c7070 100644 --- a/app/Models/StandaloneClickhouse.php +++ b/app/Models/StandaloneClickhouse.php @@ -48,6 +48,17 @@ class StandaloneClickhouse extends BaseModel protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; + /** + * Sensitive fields hidden by default in serialized output (toArray/toJson). + * API controllers should call makeVisible([...]) for callers with the + * `read:sensitive` or `root` token ability. + */ + protected $hidden = [ + 'clickhouse_admin_password', + 'internal_db_url', + 'external_db_url', + ]; + protected $casts = [ 'clickhouse_admin_password' => 'encrypted', 'public_port_timeout' => 'integer', diff --git a/app/Models/StandaloneDragonfly.php b/app/Models/StandaloneDragonfly.php index e07053c03..86250f8aa 100644 --- a/app/Models/StandaloneDragonfly.php +++ b/app/Models/StandaloneDragonfly.php @@ -47,6 +47,17 @@ class StandaloneDragonfly extends BaseModel protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; + /** + * Sensitive fields hidden by default in serialized output (toArray/toJson). + * API controllers should call makeVisible([...]) for callers with the + * `read:sensitive` or `root` token ability. + */ + protected $hidden = [ + 'dragonfly_password', + 'internal_db_url', + 'external_db_url', + ]; + protected $casts = [ 'dragonfly_password' => 'encrypted', 'public_port_timeout' => 'integer', diff --git a/app/Models/StandaloneKeydb.php b/app/Models/StandaloneKeydb.php index 979f45a3d..3b7990e09 100644 --- a/app/Models/StandaloneKeydb.php +++ b/app/Models/StandaloneKeydb.php @@ -48,6 +48,17 @@ class StandaloneKeydb extends BaseModel protected $appends = ['internal_db_url', 'external_db_url', 'server_status']; + /** + * Sensitive fields hidden by default in serialized output (toArray/toJson). + * API controllers should call makeVisible([...]) for callers with the + * `read:sensitive` or `root` token ability. + */ + protected $hidden = [ + 'keydb_password', + 'internal_db_url', + 'external_db_url', + ]; + protected $casts = [ 'keydb_password' => 'encrypted', 'public_port_timeout' => 'integer', diff --git a/app/Models/StandaloneMariadb.php b/app/Models/StandaloneMariadb.php index dba8a52f5..56d11cd94 100644 --- a/app/Models/StandaloneMariadb.php +++ b/app/Models/StandaloneMariadb.php @@ -51,6 +51,18 @@ class StandaloneMariadb extends BaseModel protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; + /** + * Sensitive fields hidden by default in serialized output (toArray/toJson). + * API controllers should call makeVisible([...]) for callers with the + * `read:sensitive` or `root` token ability. + */ + protected $hidden = [ + 'mariadb_password', + 'mariadb_root_password', + 'internal_db_url', + 'external_db_url', + ]; + protected $casts = [ 'mariadb_password' => 'encrypted', 'public_port_timeout' => 'integer', diff --git a/app/Models/StandaloneMongodb.php b/app/Models/StandaloneMongodb.php index e72f4f1c6..1acf09002 100644 --- a/app/Models/StandaloneMongodb.php +++ b/app/Models/StandaloneMongodb.php @@ -51,6 +51,17 @@ class StandaloneMongodb extends BaseModel protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; + /** + * Sensitive fields hidden by default in serialized output (toArray/toJson). + * API controllers should call makeVisible([...]) for callers with the + * `read:sensitive` or `root` token ability. + */ + protected $hidden = [ + 'mongo_initdb_root_password', + 'internal_db_url', + 'external_db_url', + ]; + protected $casts = [ 'public_port_timeout' => 'integer', 'restart_count' => 'integer', diff --git a/app/Models/StandaloneMysql.php b/app/Models/StandaloneMysql.php index 1c522d200..bebf60876 100644 --- a/app/Models/StandaloneMysql.php +++ b/app/Models/StandaloneMysql.php @@ -52,6 +52,18 @@ class StandaloneMysql extends BaseModel protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; + /** + * Sensitive fields hidden by default in serialized output (toArray/toJson). + * API controllers should call makeVisible([...]) for callers with the + * `read:sensitive` or `root` token ability. + */ + protected $hidden = [ + 'mysql_password', + 'mysql_root_password', + 'internal_db_url', + 'external_db_url', + ]; + protected $casts = [ 'mysql_password' => 'encrypted', 'mysql_root_password' => 'encrypted', diff --git a/app/Models/StandalonePostgresql.php b/app/Models/StandalonePostgresql.php index 57dfe5988..e76742d2c 100644 --- a/app/Models/StandalonePostgresql.php +++ b/app/Models/StandalonePostgresql.php @@ -54,6 +54,18 @@ class StandalonePostgresql extends BaseModel protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; + /** + * Sensitive fields hidden by default in serialized output (toArray/toJson). + * API controllers should call makeVisible([...]) for callers with the + * `read:sensitive` or `root` token ability. + */ + protected $hidden = [ + 'postgres_password', + 'init_scripts', + 'internal_db_url', + 'external_db_url', + ]; + protected $casts = [ 'init_scripts' => 'array', 'postgres_password' => 'encrypted', diff --git a/app/Models/StandaloneRedis.php b/app/Models/StandaloneRedis.php index ef42d7f18..08ba5fefe 100644 --- a/app/Models/StandaloneRedis.php +++ b/app/Models/StandaloneRedis.php @@ -47,6 +47,17 @@ class StandaloneRedis extends BaseModel protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status']; + /** + * Sensitive fields hidden by default in serialized output (toArray/toJson). + * API controllers should call makeVisible([...]) for callers with the + * `read:sensitive` or `root` token ability. + */ + protected $hidden = [ + 'redis_password', + 'internal_db_url', + 'external_db_url', + ]; + protected $casts = [ 'public_port_timeout' => 'integer', 'restart_count' => 'integer', diff --git a/tests/Feature/Security/ApiSensitiveFieldsTest.php b/tests/Feature/Security/ApiSensitiveFieldsTest.php new file mode 100644 index 000000000..b71881e3b --- /dev/null +++ b/tests/Feature/Security/ApiSensitiveFieldsTest.php @@ -0,0 +1,184 @@ + $team]); + $token = $user->createToken('sensitive-test', $abilities); + DB::table('personal_access_tokens')->where('id', $token->accessToken->id)->update([ + 'team_id' => $team->id, + ]); + + return $token->plainTextToken; +} + +function makeTeamUser(): array +{ + $team = Team::factory()->create(); + $user = User::factory()->create(); + $team->members()->attach($user->id, ['role' => 'owner']); + session(['currentTeam' => $team]); + + return [$team, $user]; +} + +beforeEach(function () { + InstanceSettings::query()->delete(); + $settings = new InstanceSettings; + $settings->id = 0; + $settings->save(); + + [$this->team, $this->user] = makeTeamUser(); + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->server->settings->forceFill([ + 'sentinel_token' => encrypt('super-secret-sentinel-token'), + 'sentinel_custom_url' => 'https://sentinel.internal', + 'logdrain_axiom_api_key' => encrypt('axiom-key-secret'), + 'logdrain_newrelic_license_key' => encrypt('newrelic-key-secret'), + 'logdrain_custom_config' => 'custom-config-data', + 'logdrain_custom_config_parser' => 'custom-parser-data', + ])->saveQuietly(); +}); + +describe('GET /api/v1/servers sensitive field gating', function () { + test('read token does not leak sentinel or logdrain fields', function () { + $token = makeApiToken($this->user, $this->team, ['read']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->getJson('/api/v1/servers'); + + $response->assertStatus(200); + + $body = $response->getContent(); + expect($body)->not->toContain('sentinel_token'); + expect($body)->not->toContain('sentinel_custom_url'); + expect($body)->not->toContain('logdrain_axiom_api_key'); + expect($body)->not->toContain('logdrain_newrelic_license_key'); + expect($body)->not->toContain('logdrain_custom_config'); + }); + + test('read sensitive token sees sentinel and logdrain fields', function () { + $token = makeApiToken($this->user, $this->team, ['read', 'read:sensitive']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->getJson('/api/v1/servers'); + + $response->assertStatus(200); + + $body = $response->getContent(); + expect($body)->toContain('sentinel_token'); + expect($body)->toContain('sentinel_custom_url'); + expect($body)->toContain('logdrain_axiom_api_key'); + }); + + test('root token sees sentinel and logdrain fields', function () { + $token = makeApiToken($this->user, $this->team, ['root']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->getJson('/api/v1/servers'); + + $response->assertStatus(200); + + $body = $response->getContent(); + expect($body)->toContain('sentinel_token'); + }); +}); + +describe('GET /api/v1/applications nested-relation scrubbing', function () { + beforeEach(function () { + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); + $destination = $this->server->standaloneDockers()->firstOrFail(); + + $this->application = Application::create([ + 'name' => 'sensitive-test-app', + 'git_repository' => 'https://github.com/test/test', + 'git_branch' => 'main', + 'build_pack' => 'nixpacks', + 'ports_exposes' => '3000', + 'environment_id' => $this->environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + }); + + test('read token does not leak sentinel_token via destination.server.settings', function () { + $token = makeApiToken($this->user, $this->team, ['read']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->getJson('/api/v1/applications'); + + $response->assertStatus(200); + + $body = $response->getContent(); + expect($body)->not->toContain('sentinel_token'); + expect($body)->not->toContain('sentinel_custom_url'); + expect($body)->not->toContain('logdrain_axiom_api_key'); + }); +}); + +describe('GET /api/v1/databases sensitive field gating', function () { + beforeEach(function () { + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); + $destination = $this->server->standaloneDockers()->firstOrFail(); + + $this->database = StandalonePostgresql::create([ + 'name' => 'sensitive-db', + 'description' => 'test', + 'postgres_user' => 'postgres', + 'postgres_password' => encrypt('super-secret-db-password'), + 'postgres_db' => 'app', + 'image' => 'postgres:16-alpine', + 'environment_id' => $this->environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + }); + + test('read token does not leak postgres_password or db urls', function () { + $token = makeApiToken($this->user, $this->team, ['read']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->getJson('/api/v1/databases'); + + $response->assertStatus(200); + + $body = $response->getContent(); + expect($body)->not->toContain('postgres_password'); + expect($body)->not->toContain('internal_db_url'); + expect($body)->not->toContain('external_db_url'); + }); + + test('read sensitive token sees postgres_password', function () { + $token = makeApiToken($this->user, $this->team, ['read', 'read:sensitive']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->getJson('/api/v1/databases'); + + $response->assertStatus(200); + + $body = $response->getContent(); + expect($body)->toContain('postgres_password'); + expect($body)->toContain('internal_db_url'); + }); +}); diff --git a/tests/Unit/Models/SensitiveFieldsHiddenTest.php b/tests/Unit/Models/SensitiveFieldsHiddenTest.php new file mode 100644 index 000000000..92e73c907 --- /dev/null +++ b/tests/Unit/Models/SensitiveFieldsHiddenTest.php @@ -0,0 +1,179 @@ +getHidden(); + + expect($hidden)->toContain( + 'sentinel_token', + 'sentinel_custom_url', + 'logdrain_newrelic_license_key', + 'logdrain_axiom_api_key', + 'logdrain_custom_config', + 'logdrain_custom_config_parser', + ); + }); + + test('Server hides logdrain api keys', function () { + $hidden = (new Server)->getHidden(); + + expect($hidden)->toContain( + 'logdrain_axiom_api_key', + 'logdrain_newrelic_license_key', + ); + }); + + test('Application hides webhook secrets, dockerfile, compose, and labels', function () { + $hidden = (new Application)->getHidden(); + + expect($hidden)->toContain( + 'http_basic_auth_password', + 'manual_webhook_secret_github', + 'manual_webhook_secret_gitlab', + 'manual_webhook_secret_bitbucket', + 'manual_webhook_secret_gitea', + 'dockerfile', + 'docker_compose', + 'docker_compose_raw', + 'custom_labels', + ); + }); + + test('EnvironmentVariable hides value and real_value', function () { + $hidden = (new EnvironmentVariable)->getHidden(); + + expect($hidden)->toContain('value', 'real_value'); + }); + + test('Service hides docker_compose and docker_compose_raw', function () { + $hidden = (new Service)->getHidden(); + + expect($hidden)->toContain('docker_compose', 'docker_compose_raw'); + }); + + test('StandalonePostgresql hides password, init_scripts, db urls', function () { + $hidden = (new StandalonePostgresql)->getHidden(); + + expect($hidden)->toContain( + 'postgres_password', + 'init_scripts', + 'internal_db_url', + 'external_db_url', + ); + }); + + test('StandaloneMysql hides passwords and db urls', function () { + $hidden = (new StandaloneMysql)->getHidden(); + + expect($hidden)->toContain( + 'mysql_password', + 'mysql_root_password', + 'internal_db_url', + 'external_db_url', + ); + }); + + test('StandaloneMariadb hides passwords and db urls', function () { + $hidden = (new StandaloneMariadb)->getHidden(); + + expect($hidden)->toContain( + 'mariadb_password', + 'mariadb_root_password', + 'internal_db_url', + 'external_db_url', + ); + }); + + test('StandaloneMongodb hides root password and db urls', function () { + $hidden = (new StandaloneMongodb)->getHidden(); + + expect($hidden)->toContain( + 'mongo_initdb_root_password', + 'internal_db_url', + 'external_db_url', + ); + }); + + test('StandaloneRedis hides password and db urls', function () { + $hidden = (new StandaloneRedis)->getHidden(); + + expect($hidden)->toContain( + 'redis_password', + 'internal_db_url', + 'external_db_url', + ); + }); + + test('StandaloneClickhouse hides password and db urls', function () { + $hidden = (new StandaloneClickhouse)->getHidden(); + + expect($hidden)->toContain( + 'clickhouse_admin_password', + 'internal_db_url', + 'external_db_url', + ); + }); + + test('StandaloneKeydb hides password and db urls', function () { + $hidden = (new StandaloneKeydb)->getHidden(); + + expect($hidden)->toContain( + 'keydb_password', + 'internal_db_url', + 'external_db_url', + ); + }); + + test('StandaloneDragonfly hides password and db urls', function () { + $hidden = (new StandaloneDragonfly)->getHidden(); + + expect($hidden)->toContain( + 'dragonfly_password', + 'internal_db_url', + 'external_db_url', + ); + }); +}); + +describe('Sensitive fields are absent from toArray() by default', function () { + test('ServerSetting::toArray() excludes sentinel_custom_url by default', function () { + $setting = new ServerSetting; + $setting->setRawAttributes([ + 'server_id' => 1, + 'sentinel_custom_url' => 'https://secret.example.com', + 'wildcard_domain' => 'public.example.com', + ], sync: true); + + $array = $setting->toArray(); + + expect($array)->not->toHaveKey('sentinel_custom_url'); + expect($array)->toHaveKey('wildcard_domain'); + }); + + test('ServerSetting::toArray() includes sentinel_custom_url after makeVisible', function () { + $setting = new ServerSetting; + $setting->setRawAttributes([ + 'server_id' => 1, + 'sentinel_custom_url' => 'https://secret.example.com', + ], sync: true); + + $setting->makeVisible(['sentinel_custom_url']); + $array = $setting->toArray(); + + expect($array['sentinel_custom_url'])->toBe('https://secret.example.com'); + }); +}); From 8dc79f4ed68b98ae3f9f772725f0357224093b18 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 30 Apr 2026 11:49:15 +0200 Subject: [PATCH 035/151] fix(api): expose nested server secrets for privileged tokens Add `exposeNestedServerSecrets()` to Applications, Databases, and Services controllers so that `read:sensitive`/`root` tokens see sentinel and logdrain fields on eager-loaded Server + ServerSetting relations. ServicesController handles both single models and Eloquent Collections (listing endpoint passes a Collection per project). Tests tightened to use JSON-key assertions (`"field":`) to avoid false positives from field names appearing in values. --- .../Api/ApplicationsController.php | 29 ++++++++++++++ .../Controllers/Api/DatabasesController.php | 28 ++++++++++++++ .../Controllers/Api/ServicesController.php | 38 +++++++++++++++++++ .../Security/ApiSensitiveFieldsTest.php | 27 ++++++++++--- 4 files changed, 116 insertions(+), 6 deletions(-) diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 9d9c5d33a..e189e6baa 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -57,6 +57,7 @@ private function removeSensitiveData($application) 'value', 'real_value', ]); + $this->exposeNestedServerSecrets($application); } else { $application->makeHidden([ 'private_key_id', @@ -66,6 +67,34 @@ private function removeSensitiveData($application) return serializeApiResponse($application); } + /** + * Expose sensitive fields on eager-loaded nested Server + ServerSetting + * relations for callers with the `read:sensitive` or `root` token ability. + * Models hide these by default via $hidden; this re-exposes them per-request. + */ + private function exposeNestedServerSecrets($model): void + { + $server = $model->destination?->server ?? null; + if (! $server) { + return; + } + $server->makeVisible([ + 'logdrain_axiom_api_key', + 'logdrain_newrelic_license_key', + ]); + $settings = $server->settings ?? null; + if ($settings) { + $settings->makeVisible([ + 'sentinel_token', + 'sentinel_custom_url', + 'logdrain_newrelic_license_key', + 'logdrain_axiom_api_key', + 'logdrain_custom_config', + 'logdrain_custom_config_parser', + ]); + } + } + #[OA\Get( summary: 'List', description: 'List all applications.', diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index acfc64355..d09c309f8 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -49,11 +49,39 @@ private function removeSensitiveData($database) 'mariadb_password', 'mariadb_root_password', ]); + $this->exposeNestedServerSecrets($database); } return serializeApiResponse($database); } + /** + * Expose sensitive fields on eager-loaded nested Server + ServerSetting + * relations for callers with the `read:sensitive` or `root` token ability. + */ + private function exposeNestedServerSecrets($model): void + { + $server = $model->destination?->server ?? null; + if (! $server) { + return; + } + $server->makeVisible([ + 'logdrain_axiom_api_key', + 'logdrain_newrelic_license_key', + ]); + $settings = $server->settings ?? null; + if ($settings) { + $settings->makeVisible([ + 'sentinel_token', + 'sentinel_custom_url', + 'logdrain_newrelic_license_key', + 'logdrain_axiom_api_key', + 'logdrain_custom_config', + 'logdrain_custom_config_parser', + ]); + } + } + #[OA\Get( summary: 'List', description: 'List all databases.', diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 4a04f499d..c3ced0e31 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -16,6 +16,7 @@ use App\Support\ValidationPatterns; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Validator; use OpenApi\Attributes as OA; use Symfony\Component\Yaml\Yaml; @@ -37,11 +38,48 @@ private function removeSensitiveData($service) 'value', 'real_value', ]); + $this->exposeNestedServerSecrets($service); } return serializeApiResponse($service); } + /** + * Expose sensitive fields on eager-loaded nested Server + ServerSetting + * relations for callers with the `read:sensitive` or `root` token ability. + * Handles both single models and Eloquent Collections (the listing endpoint + * passes a Collection of Services per project to removeSensitiveData()). + */ + private function exposeNestedServerSecrets($model): void + { + if ($model instanceof Collection || $model instanceof \Illuminate\Database\Eloquent\Collection) { + foreach ($model as $item) { + $this->exposeNestedServerSecrets($item); + } + + return; + } + $server = $model->destination?->server ?? $model->server ?? null; + if (! $server) { + return; + } + $server->makeVisible([ + 'logdrain_axiom_api_key', + 'logdrain_newrelic_license_key', + ]); + $settings = $server->settings ?? null; + if ($settings) { + $settings->makeVisible([ + 'sentinel_token', + 'sentinel_custom_url', + 'logdrain_newrelic_license_key', + 'logdrain_axiom_api_key', + 'logdrain_custom_config', + 'logdrain_custom_config_parser', + ]); + } + } + private function applyServiceUrls(Service $service, array $urlsArray, string $teamId, bool $forceDomainOverride = false): ?array { $errors = []; diff --git a/tests/Feature/Security/ApiSensitiveFieldsTest.php b/tests/Feature/Security/ApiSensitiveFieldsTest.php index b71881e3b..2275f7cc2 100644 --- a/tests/Feature/Security/ApiSensitiveFieldsTest.php +++ b/tests/Feature/Security/ApiSensitiveFieldsTest.php @@ -128,9 +128,23 @@ function makeTeamUser(): array $response->assertStatus(200); $body = $response->getContent(); - expect($body)->not->toContain('sentinel_token'); - expect($body)->not->toContain('sentinel_custom_url'); - expect($body)->not->toContain('logdrain_axiom_api_key'); + expect($body)->not->toContain('"sentinel_token":'); + expect($body)->not->toContain('"sentinel_custom_url":'); + expect($body)->not->toContain('"logdrain_axiom_api_key":'); + }); + + test('read-sensitive token sees nested sentinel_token via destination.server.settings', function () { + $token = makeApiToken($this->user, $this->team, ['read', 'read:sensitive']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->getJson('/api/v1/applications'); + + $response->assertStatus(200); + + $body = $response->getContent(); + expect($body)->toContain('"sentinel_token":'); + expect($body)->toContain('"sentinel_custom_url":'); }); }); @@ -168,7 +182,7 @@ function makeTeamUser(): array expect($body)->not->toContain('external_db_url'); }); - test('read sensitive token sees postgres_password', function () { + test('read sensitive token sees postgres_password and nested sentinel_token', function () { $token = makeApiToken($this->user, $this->team, ['read', 'read:sensitive']); $response = $this->withHeaders([ @@ -178,7 +192,8 @@ function makeTeamUser(): array $response->assertStatus(200); $body = $response->getContent(); - expect($body)->toContain('postgres_password'); - expect($body)->toContain('internal_db_url'); + expect($body)->toContain('"postgres_password":'); + expect($body)->toContain('"internal_db_url":'); + expect($body)->toContain('"sentinel_token":'); }); }); From 08a12c392a2e205bda411069950ec4a3ef304ef3 Mon Sep 17 00:00:00 2001 From: Josh Salway Date: Sat, 2 May 2026 23:11:22 +1000 Subject: [PATCH 036/151] fix(deploy): cast force param as boolean to prevent cache bust on every deploy $request->input('force') ?? false reads "false" as a non-empty string, which PHP coerces to true. Replace with $request->boolean() which uses filter_var(FILTER_VALIDATE_BOOLEAN), correctly mapping "false" -> false. --- app/Http/Controllers/Api/DeployController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Http/Controllers/Api/DeployController.php b/app/Http/Controllers/Api/DeployController.php index c93731d68..4036c5dd0 100644 --- a/app/Http/Controllers/Api/DeployController.php +++ b/app/Http/Controllers/Api/DeployController.php @@ -366,7 +366,7 @@ public function deploy(Request $request) $uuids = $request->input('uuid'); $tags = $request->input('tag'); - $force = $request->input('force') ?? false; + $force = $request->boolean('force'); $pullRequestId = $request->input('pull_request_id', $request->input('pr')); $pr = $pullRequestId ? max((int) $pullRequestId, 0) : 0; $dockerTag = $request->string('docker_tag')->trim()->value() ?: null; From 7c3723d20756c6f6f2de0da286c6d607fc672674 Mon Sep 17 00:00:00 2001 From: Josh Salway Date: Sat, 2 May 2026 23:26:22 +1000 Subject: [PATCH 037/151] test(deploy): assert force=false query param does not set force_rebuild Regression test for PHP string truthy coercion bug: - force=false as query string should not trigger --no-cache - force=true as query string should trigger --no-cache - missing force param defaults to false --- tests/Feature/DeployForceBooleanCastTest.php | 78 ++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 tests/Feature/DeployForceBooleanCastTest.php diff --git a/tests/Feature/DeployForceBooleanCastTest.php b/tests/Feature/DeployForceBooleanCastTest.php new file mode 100644 index 000000000..d00136ed2 --- /dev/null +++ b/tests/Feature/DeployForceBooleanCastTest.php @@ -0,0 +1,78 @@ +team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $plainTextToken = Str::random(40); + $token = $this->user->tokens()->create([ + 'name' => 'test-token', + 'token' => hash('sha256', $plainTextToken), + 'abilities' => ['*'], + 'team_id' => $this->team->id, + ]); + $this->bearerToken = $token->getKey().'|'.$plainTextToken; + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = StandaloneDocker::factory()->create([ + 'server_id' => $this->server->id, + 'network' => 'coolify-'.Str::lower(Str::random(8)), + ]); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); + + $this->application = Application::factory()->create([ + 'uuid' => (string) Str::uuid(), + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => StandaloneDocker::class, + ]); +}); + +// Regression test: $request->input('force') ?? false coerced the string "false" to bool true, +// causing every API deploy with force=false to run with --no-cache. +test('force=false query string param does not set force_rebuild', function () { + $response = $this->withHeaders(['Authorization' => 'Bearer '.$this->bearerToken]) + ->postJson('/api/v1/deploy?uuid='.$this->application->uuid.'&force=false'); + + $response->assertSuccessful(); + + $deployment = $this->application->deployment_queue()->latest('id')->first(); + expect($deployment)->not()->toBeNull(); + expect($deployment->force_rebuild)->toBeFalse(); +}); + +test('force=true query string param sets force_rebuild', function () { + $response = $this->withHeaders(['Authorization' => 'Bearer '.$this->bearerToken]) + ->postJson('/api/v1/deploy?uuid='.$this->application->uuid.'&force=true'); + + $response->assertSuccessful(); + + $deployment = $this->application->deployment_queue()->latest('id')->first(); + expect($deployment)->not()->toBeNull(); + expect($deployment->force_rebuild)->toBeTrue(); +}); + +test('omitting force param does not set force_rebuild', function () { + $response = $this->withHeaders(['Authorization' => 'Bearer '.$this->bearerToken]) + ->postJson('/api/v1/deploy?uuid='.$this->application->uuid); + + $response->assertSuccessful(); + + $deployment = $this->application->deployment_queue()->latest('id')->first(); + expect($deployment)->not()->toBeNull(); + expect($deployment->force_rebuild)->toBeFalse(); +}); From 6eb527d0ab818ffc4a960d90c93de8521258bc99 Mon Sep 17 00:00:00 2001 From: Josh Salway Date: Sat, 2 May 2026 23:37:57 +1000 Subject: [PATCH 038/151] test(deploy): expand force param tests to cover all coercion cases Adds datasets for falsy (false, 0) and truthy (true, 1) query string values, covering the full coercion table from the PR description. --- tests/Feature/DeployForceBooleanCastTest.php | 33 +++++++++++++++----- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/tests/Feature/DeployForceBooleanCastTest.php b/tests/Feature/DeployForceBooleanCastTest.php index d00136ed2..5dc08d29b 100644 --- a/tests/Feature/DeployForceBooleanCastTest.php +++ b/tests/Feature/DeployForceBooleanCastTest.php @@ -43,28 +43,47 @@ }); // Regression test: $request->input('force') ?? false coerced the string "false" to bool true, -// causing every API deploy with force=false to run with --no-cache. -test('force=false query string param does not set force_rebuild', function () { +// causing every API deploy with ?force=false to run with --no-cache and bypass layer caching. +// +// Input | input('force') ?? false (old) | boolean('force') (fix) +// ------------|-------------------------------|---------------------- +// "false" | true (bug — non-empty string)| false (correct) +// "true" | true | true +// "0" | false (PHP special-cases "0") | false +// "1" | true | true +// absent/null | false | false + +dataset('falsy force values', [ + 'string false' => ['false'], + 'string 0' => ['0'], +]); + +dataset('truthy force values', [ + 'string true' => ['true'], + 'string 1' => ['1'], +]); + +test('force= query string param does not set force_rebuild', function (string $value) { $response = $this->withHeaders(['Authorization' => 'Bearer '.$this->bearerToken]) - ->postJson('/api/v1/deploy?uuid='.$this->application->uuid.'&force=false'); + ->postJson('/api/v1/deploy?uuid='.$this->application->uuid.'&force='.$value); $response->assertSuccessful(); $deployment = $this->application->deployment_queue()->latest('id')->first(); expect($deployment)->not()->toBeNull(); expect($deployment->force_rebuild)->toBeFalse(); -}); +})->with('falsy force values'); -test('force=true query string param sets force_rebuild', function () { +test('force= query string param sets force_rebuild', function (string $value) { $response = $this->withHeaders(['Authorization' => 'Bearer '.$this->bearerToken]) - ->postJson('/api/v1/deploy?uuid='.$this->application->uuid.'&force=true'); + ->postJson('/api/v1/deploy?uuid='.$this->application->uuid.'&force='.$value); $response->assertSuccessful(); $deployment = $this->application->deployment_queue()->latest('id')->first(); expect($deployment)->not()->toBeNull(); expect($deployment->force_rebuild)->toBeTrue(); -}); +})->with('truthy force values'); test('omitting force param does not set force_rebuild', function () { $response = $this->withHeaders(['Authorization' => 'Bearer '.$this->bearerToken]) From 3b763d54cccc45fbe74370d3074814a93f5f3633 Mon Sep 17 00:00:00 2001 From: Josh Salway Date: Sun, 3 May 2026 00:03:14 +1000 Subject: [PATCH 039/151] test(deploy): remove coercion test to keep PR focused on the fix --- tests/Feature/DeployForceBooleanCastTest.php | 97 -------------------- 1 file changed, 97 deletions(-) delete mode 100644 tests/Feature/DeployForceBooleanCastTest.php diff --git a/tests/Feature/DeployForceBooleanCastTest.php b/tests/Feature/DeployForceBooleanCastTest.php deleted file mode 100644 index 5dc08d29b..000000000 --- a/tests/Feature/DeployForceBooleanCastTest.php +++ /dev/null @@ -1,97 +0,0 @@ -team = Team::factory()->create(); - $this->user = User::factory()->create(); - $this->team->members()->attach($this->user->id, ['role' => 'owner']); - - $plainTextToken = Str::random(40); - $token = $this->user->tokens()->create([ - 'name' => 'test-token', - 'token' => hash('sha256', $plainTextToken), - 'abilities' => ['*'], - 'team_id' => $this->team->id, - ]); - $this->bearerToken = $token->getKey().'|'.$plainTextToken; - - $this->server = Server::factory()->create(['team_id' => $this->team->id]); - $this->destination = StandaloneDocker::factory()->create([ - 'server_id' => $this->server->id, - 'network' => 'coolify-'.Str::lower(Str::random(8)), - ]); - $this->project = Project::factory()->create(['team_id' => $this->team->id]); - $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); - - $this->application = Application::factory()->create([ - 'uuid' => (string) Str::uuid(), - 'environment_id' => $this->environment->id, - 'destination_id' => $this->destination->id, - 'destination_type' => StandaloneDocker::class, - ]); -}); - -// Regression test: $request->input('force') ?? false coerced the string "false" to bool true, -// causing every API deploy with ?force=false to run with --no-cache and bypass layer caching. -// -// Input | input('force') ?? false (old) | boolean('force') (fix) -// ------------|-------------------------------|---------------------- -// "false" | true (bug — non-empty string)| false (correct) -// "true" | true | true -// "0" | false (PHP special-cases "0") | false -// "1" | true | true -// absent/null | false | false - -dataset('falsy force values', [ - 'string false' => ['false'], - 'string 0' => ['0'], -]); - -dataset('truthy force values', [ - 'string true' => ['true'], - 'string 1' => ['1'], -]); - -test('force= query string param does not set force_rebuild', function (string $value) { - $response = $this->withHeaders(['Authorization' => 'Bearer '.$this->bearerToken]) - ->postJson('/api/v1/deploy?uuid='.$this->application->uuid.'&force='.$value); - - $response->assertSuccessful(); - - $deployment = $this->application->deployment_queue()->latest('id')->first(); - expect($deployment)->not()->toBeNull(); - expect($deployment->force_rebuild)->toBeFalse(); -})->with('falsy force values'); - -test('force= query string param sets force_rebuild', function (string $value) { - $response = $this->withHeaders(['Authorization' => 'Bearer '.$this->bearerToken]) - ->postJson('/api/v1/deploy?uuid='.$this->application->uuid.'&force='.$value); - - $response->assertSuccessful(); - - $deployment = $this->application->deployment_queue()->latest('id')->first(); - expect($deployment)->not()->toBeNull(); - expect($deployment->force_rebuild)->toBeTrue(); -})->with('truthy force values'); - -test('omitting force param does not set force_rebuild', function () { - $response = $this->withHeaders(['Authorization' => 'Bearer '.$this->bearerToken]) - ->postJson('/api/v1/deploy?uuid='.$this->application->uuid); - - $response->assertSuccessful(); - - $deployment = $this->application->deployment_queue()->latest('id')->first(); - expect($deployment)->not()->toBeNull(); - expect($deployment->force_rebuild)->toBeFalse(); -}); From c97f9160528d1a242c7991696a549f92d57a9ff9 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 11 May 2026 11:46:05 +0200 Subject: [PATCH 040/151] fix(api): hide application compose PR fields Add compose PR fields to the Application hidden attributes and allow database list queries to eager load nested server settings when sensitive access is permitted. --- app/Http/Controllers/Api/DatabasesController.php | 9 +++++++-- app/Models/Application.php | 2 ++ app/Models/Project.php | 12 ++++++++++-- templates/service-templates-latest.json | 10 +++++----- templates/service-templates.json | 10 +++++----- tests/Feature/Security/ApiSensitiveFieldsTest.php | 10 ++++++++++ tests/Unit/Models/SensitiveFieldsHiddenTest.php | 2 ++ 7 files changed, 41 insertions(+), 14 deletions(-) diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index d09c309f8..839e52cb3 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -20,6 +20,7 @@ use App\Models\Server; use App\Models\StandalonePostgresql; use App\Support\ValidationPatterns; +use Illuminate\Database\Eloquent\Model; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; @@ -59,7 +60,7 @@ private function removeSensitiveData($database) * Expose sensitive fields on eager-loaded nested Server + ServerSetting * relations for callers with the `read:sensitive` or `root` token ability. */ - private function exposeNestedServerSecrets($model): void + private function exposeNestedServerSecrets(Model $model): void { $server = $model->destination?->server ?? null; if (! $server) { @@ -118,8 +119,12 @@ public function databases(Request $request) } $projects = Project::where('team_id', $teamId)->get(); $databases = collect(); + $databaseRelations = $request->attributes->get('can_read_sensitive', false) === true + ? ['destination.server.settings'] + : []; + foreach ($projects as $project) { - $databases = $databases->merge($project->databases()); + $databases = $databases->merge($project->databases($databaseRelations)); } $databaseIds = $databases->pluck('id')->toArray(); diff --git a/app/Models/Application.php b/app/Models/Application.php index f342112b9..6f2275ba8 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -229,7 +229,9 @@ class Application extends BaseModel 'manual_webhook_secret_gitea', 'dockerfile', 'docker_compose', + 'docker_compose_pr', 'docker_compose_raw', + 'docker_compose_pr_raw', 'custom_labels', ]; diff --git a/app/Models/Project.php b/app/Models/Project.php index 632787a07..236ef3a26 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -5,6 +5,7 @@ use App\Traits\ClearsGlobalSearchCache; use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Support\Collection; use OpenApi\Attributes as OA; use Visus\Cuid2\Cuid2; @@ -156,9 +157,16 @@ public function isEmpty() $this->services()->count() == 0; } - public function databases() + public function databases(array $with = []): Collection { - return $this->postgresqls()->get()->merge($this->redis()->get())->merge($this->mongodbs()->get())->merge($this->mysqls()->get())->merge($this->mariadbs()->get())->merge($this->keydbs()->get())->merge($this->dragonflies()->get())->merge($this->clickhouses()->get()); + return $this->postgresqls()->with($with)->get() + ->merge($this->redis()->with($with)->get()) + ->merge($this->mongodbs()->with($with)->get()) + ->merge($this->mysqls()->with($with)->get()) + ->merge($this->mariadbs()->with($with)->get()) + ->merge($this->keydbs()->with($with)->get()) + ->merge($this->dragonflies()->with($with)->get()) + ->merge($this->clickhouses()->with($with)->get()); } public function navigateTo() diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json index eb667fcb8..b57d9d29c 100644 --- a/templates/service-templates-latest.json +++ b/templates/service-templates-latest.json @@ -299,7 +299,7 @@ "bluesky-pds": { "documentation": "https://github.com/bluesky-social/pds?utm_source=coolify.io", "slogan": "Bluesky PDS (Personal Data Server)", - "compose": "c2VydmljZXM6CiAgcGRzOgogICAgaW1hZ2U6ICdnaGNyLmlvL2JsdWVza3ktc29jaWFsL3BkczowLjQuMTgyJwogICAgdm9sdW1lczoKICAgICAgLSAncGRzLWRhdGE6L3BkcycKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX1BEU18zMDAwCiAgICAgIC0gJ1BEU19IT1NUTkFNRT0ke1NFUlZJQ0VfRlFETl9QRFN9JwogICAgICAtICdQRFNfSldUX1NFQ1JFVD0ke1NFUlZJQ0VfSEVYXzMyX0pXVFNFQ1JFVH0nCiAgICAgIC0gJ1BEU19BRE1JTl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfQURNSU59JwogICAgICAtICdQRFNfQURNSU5fRU1BSUw9JHtQRFNfQURNSU5fRU1BSUx9JwogICAgICAtICdQRFNfUExDX1JPVEFUSU9OX0tFWV9LMjU2X1BSSVZBVEVfS0VZX0hFWD0ke1NFUlZJQ0VfSEVYXzMyX1JPVEFUSU9OS0VZfScKICAgICAgLSAnUERTX0RBVEFfRElSRUNUT1JZPSR7UERTX0RBVEFfRElSRUNUT1JZOi0vcGRzfScKICAgICAgLSAnUERTX0JMT0JTVE9SRV9ESVNLX0xPQ0FUSU9OPSR7UERTX0RBVEFfRElSRUNUT1JZOi0vcGRzfS9ibG9ja3MnCiAgICAgIC0gJ1BEU19CTE9CX1VQTE9BRF9MSU1JVD0ke1BEU19CTE9CX1VQTE9BRF9MSU1JVDotMTA0ODU3NjAwfScKICAgICAgLSAnUERTX0RJRF9QTENfVVJMPSR7UERTX0RJRF9QTENfVVJMOi1odHRwczovL3BsYy5kaXJlY3Rvcnl9JwogICAgICAtICdQRFNfRU1BSUxfRlJPTV9BRERSRVNTPSR7UERTX0VNQUlMX0ZST01fQUREUkVTU30nCiAgICAgIC0gJ1BEU19FTUFJTF9TTVRQX1VSTD0ke1BEU19FTUFJTF9TTVRQX1VSTH0nCiAgICAgIC0gJ1BEU19CU0tZX0FQUF9WSUVXX1VSTD0ke1BEU19CU0tZX0FQUF9WSUVXX1VSTDotaHR0cHM6Ly9hcGkuYnNreS5hcHB9JwogICAgICAtICdQRFNfQlNLWV9BUFBfVklFV19ESUQ9JHtQRFNfQlNLWV9BUFBfVklFV19ESUQ6LWRpZDp3ZWI6YXBpLmJza3kuYXBwfScKICAgICAgLSAnUERTX1JFUE9SVF9TRVJWSUNFX1VSTD0ke1BEU19SRVBPUlRfU0VSVklDRV9VUkw6LWh0dHBzOi8vbW9kLmJza3kuYXBwL3hycGMvY29tLmF0cHJvdG8ubW9kZXJhdGlvbi5jcmVhdGVSZXBvcnR9JwogICAgICAtICdQRFNfUkVQT1JUX1NFUlZJQ0VfRElEPSR7UERTX1JFUE9SVF9TRVJWSUNFX0RJRDotZGlkOnBsYzphcjdjNGJ5NDZxamR5ZGhkZXZ2cm5kYWN9JwogICAgICAtICdQRFNfQ1JBV0xFUlM9JHtQRFNfQ1JBV0xFUlM6LWh0dHBzOi8vYnNreS5uZXR3b3JrfScKICAgICAgLSAnTE9HX0VOQUJMRUQ9JHtMT0dfRU5BQkxFRDotdHJ1ZX0nCiAgICBjb21tYW5kOiAic2ggLWMgJ1xuICBzZXQgLWV1byBwaXBlZmFpbFxuICBlY2hvIFwiSW5zdGFsbGluZyByZXF1aXJlZCBwYWNrYWdlcyBhbmQgcGRzYWRtaW4uLi5cIlxuICBhcGsgYWRkIC0tbm8tY2FjaGUgb3BlbnNzbCBjdXJsIGJhc2gganEgY29yZXV0aWxzIGdudXBnIHV0aWwtbGludXgtbWlzYyA+L2Rldi9udWxsXG4gIGN1cmwgLW8gL3Vzci9sb2NhbC9iaW4vcGRzYWRtaW4uc2ggaHR0cHM6Ly9yYXcuZ2l0aHVidXNlcmNvbnRlbnQuY29tL2JsdWVza3ktc29jaWFsL3Bkcy9tYWluL3Bkc2FkbWluLnNoXG4gIGNobW9kIDcwMCAvdXNyL2xvY2FsL2Jpbi9wZHNhZG1pbi5zaFxuICBsbiAtc2YgL3Vzci9sb2NhbC9iaW4vcGRzYWRtaW4uc2ggL3Vzci9sb2NhbC9iaW4vcGRzYWRtaW5cbiAgZWNobyBcIkNyZWF0aW5nIGFuIGVtcHR5IHBkcy5lbnYgZmlsZSBzbyBwZHNhZG1pbiB3b3Jrcy4uLlwiXG4gIHRvdWNoICR7UERTX0RBVEFfRElSRUNUT1JZfS9wZHMuZW52XG4gIGVjaG8gXCJMYXVuY2hpbmcgUERTLCBlbmpveSEuLi5cIlxuICBleGVjIG5vZGUgLS1lbmFibGUtc291cmNlLW1hcHMgaW5kZXguanNcbidcbiIKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSB3Z2V0CiAgICAgICAgLSAnLS1zcGlkZXInCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTozMDAwL3hycGMvX2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxMAo=", + "compose": "c2VydmljZXM6CiAgcGRzOgogICAgaW1hZ2U6ICdnaGNyLmlvL2JsdWVza3ktc29jaWFsL3BkczowLjQuMTgyJwogICAgdm9sdW1lczoKICAgICAgLSAncGRzLWRhdGE6L3BkcycKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX1BEU18zMDAwCiAgICAgIC0gJ1BEU19IT1NUTkFNRT0ke1NFUlZJQ0VfRlFETl9QRFN9JwogICAgICAtICdQRFNfSldUX1NFQ1JFVD0ke1NFUlZJQ0VfSEVYXzY0X0pXVFNFQ1JFVH0nCiAgICAgIC0gJ1BEU19BRE1JTl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfQURNSU59JwogICAgICAtICdQRFNfQURNSU5fRU1BSUw9JHtQRFNfQURNSU5fRU1BSUx9JwogICAgICAtICdQRFNfUExDX1JPVEFUSU9OX0tFWV9LMjU2X1BSSVZBVEVfS0VZX0hFWD0ke1NFUlZJQ0VfSEVYXzY0X1JPVEFUSU9OS0VZfScKICAgICAgLSAnUERTX0RBVEFfRElSRUNUT1JZPSR7UERTX0RBVEFfRElSRUNUT1JZOi0vcGRzfScKICAgICAgLSAnUERTX0JMT0JTVE9SRV9ESVNLX0xPQ0FUSU9OPSR7UERTX0RBVEFfRElSRUNUT1JZOi0vcGRzfS9ibG9ja3MnCiAgICAgIC0gJ1BEU19CTE9CX1VQTE9BRF9MSU1JVD0ke1BEU19CTE9CX1VQTE9BRF9MSU1JVDotMTA0ODU3NjAwfScKICAgICAgLSAnUERTX0RJRF9QTENfVVJMPSR7UERTX0RJRF9QTENfVVJMOi1odHRwczovL3BsYy5kaXJlY3Rvcnl9JwogICAgICAtICdQRFNfRU1BSUxfRlJPTV9BRERSRVNTPSR7UERTX0VNQUlMX0ZST01fQUREUkVTU30nCiAgICAgIC0gJ1BEU19FTUFJTF9TTVRQX1VSTD0ke1BEU19FTUFJTF9TTVRQX1VSTH0nCiAgICAgIC0gJ1BEU19CU0tZX0FQUF9WSUVXX1VSTD0ke1BEU19CU0tZX0FQUF9WSUVXX1VSTDotaHR0cHM6Ly9hcGkuYnNreS5hcHB9JwogICAgICAtICdQRFNfQlNLWV9BUFBfVklFV19ESUQ9JHtQRFNfQlNLWV9BUFBfVklFV19ESUQ6LWRpZDp3ZWI6YXBpLmJza3kuYXBwfScKICAgICAgLSAnUERTX1JFUE9SVF9TRVJWSUNFX1VSTD0ke1BEU19SRVBPUlRfU0VSVklDRV9VUkw6LWh0dHBzOi8vbW9kLmJza3kuYXBwL3hycGMvY29tLmF0cHJvdG8ubW9kZXJhdGlvbi5jcmVhdGVSZXBvcnR9JwogICAgICAtICdQRFNfUkVQT1JUX1NFUlZJQ0VfRElEPSR7UERTX1JFUE9SVF9TRVJWSUNFX0RJRDotZGlkOnBsYzphcjdjNGJ5NDZxamR5ZGhkZXZ2cm5kYWN9JwogICAgICAtICdQRFNfQ1JBV0xFUlM9JHtQRFNfQ1JBV0xFUlM6LWh0dHBzOi8vYnNreS5uZXR3b3JrfScKICAgICAgLSAnTE9HX0VOQUJMRUQ9JHtMT0dfRU5BQkxFRDotdHJ1ZX0nCiAgICBjb21tYW5kOiAic2ggLWMgJ1xuICBzZXQgLWV1byBwaXBlZmFpbFxuICBlY2hvIFwiSW5zdGFsbGluZyByZXF1aXJlZCBwYWNrYWdlcyBhbmQgcGRzYWRtaW4uLi5cIlxuICBhcGsgYWRkIC0tbm8tY2FjaGUgb3BlbnNzbCBjdXJsIGJhc2gganEgY29yZXV0aWxzIGdudXBnIHV0aWwtbGludXgtbWlzYyA+L2Rldi9udWxsXG4gIGN1cmwgLW8gL3Vzci9sb2NhbC9iaW4vcGRzYWRtaW4uc2ggaHR0cHM6Ly9yYXcuZ2l0aHVidXNlcmNvbnRlbnQuY29tL2JsdWVza3ktc29jaWFsL3Bkcy9tYWluL3Bkc2FkbWluLnNoXG4gIGNobW9kIDcwMCAvdXNyL2xvY2FsL2Jpbi9wZHNhZG1pbi5zaFxuICBsbiAtc2YgL3Vzci9sb2NhbC9iaW4vcGRzYWRtaW4uc2ggL3Vzci9sb2NhbC9iaW4vcGRzYWRtaW5cbiAgZWNobyBcIkNyZWF0aW5nIGFuIGVtcHR5IHBkcy5lbnYgZmlsZSBzbyBwZHNhZG1pbiB3b3Jrcy4uLlwiXG4gIHRvdWNoICR7UERTX0RBVEFfRElSRUNUT1JZfS9wZHMuZW52XG4gIGVjaG8gXCJMYXVuY2hpbmcgUERTLCBlbmpveSEuLi5cIlxuICBleGVjIG5vZGUgLS1lbmFibGUtc291cmNlLW1hcHMgaW5kZXguanNcbidcbiIKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSB3Z2V0CiAgICAgICAgLSAnLS1zcGlkZXInCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTozMDAwL3hycGMvX2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxMAo=", "tags": [ "bluesky", "pds", @@ -730,7 +730,7 @@ "convex": { "documentation": "https://github.com/get-convex/convex-backend/blob/main/self-hosted/README.md?utm_source=coolify.io", "slogan": "Convex is the open-source reactive database for app developers.", - "compose": "c2VydmljZXM6CiAgYmFja2VuZDoKICAgIGltYWdlOiAnZ2hjci5pby9nZXQtY29udmV4L2NvbnZleC1iYWNrZW5kOmE5YTc2MGNhMTAzOTllZDQyZTFiNGJiODdjNzg1MzlhMjM1NDg4YzcnCiAgICB2b2x1bWVzOgogICAgICAtICdkYXRhOi9jb252ZXgvZGF0YScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0JBQ0tFTkRfMzIxMAogICAgICAtICdJTlNUQU5DRV9OQU1FPSR7SU5TVEFOQ0VfTkFNRTotc2VsZi1ob3N0ZWQtY29udmV4fScKICAgICAgLSAnSU5TVEFOQ0VfU0VDUkVUPSR7U0VSVklDRV9IRVhfMzJfU0VDUkVUfScKICAgICAgLSAnQ09OVkVYX1JFTEVBU0VfVkVSU0lPTl9ERVY9JHtDT05WRVhfUkVMRUFTRV9WRVJTSU9OX0RFVjotfScKICAgICAgLSAnQUNUSU9OU19VU0VSX1RJTUVPVVRfU0VDUz0ke0FDVElPTlNfVVNFUl9USU1FT1VUX1NFQ1M6LX0nCiAgICAgIC0gJ0NPTlZFWF9DTE9VRF9PUklHSU49JHtTRVJWSUNFX1VSTF9EQVNIQk9BUkR9JwogICAgICAtICdDT05WRVhfU0lURV9PUklHSU49JHtTRVJWSUNFX1VSTF9CQUNLRU5EfScKICAgICAgLSAnREFUQUJBU0VfVVJMPSR7REFUQUJBU0VfVVJMOi19JwogICAgICAtICdESVNBQkxFX0JFQUNPTj0ke0RJU0FCTEVfQkVBQ09OOj9mYWxzZX0nCiAgICAgIC0gJ1JFREFDVF9MT0dTX1RPX0NMSUVOVD0ke1JFREFDVF9MT0dTX1RPX0NMSUVOVDo/ZmFsc2V9JwogICAgICAtICdET19OT1RfUkVRVUlSRV9TU0w9JHtET19OT1RfUkVRVUlSRV9TU0w6P3RydWV9JwogICAgICAtICdQT1NUR1JFU19VUkw9JHtQT1NUR1JFU19VUkw6LX0nCiAgICAgIC0gJ01ZU1FMX1VSTD0ke01ZU1FMX1VSTDotfScKICAgICAgLSAnUlVTVF9MT0c9JHtSVVNUX0xPRzotaW5mb30nCiAgICAgIC0gJ1JVU1RfQkFDS1RSQUNFPSR7UlVTVF9CQUNLVFJBQ0U6LX0nCiAgICAgIC0gJ0FXU19SRUdJT049JHtBV1NfUkVHSU9OOi19JwogICAgICAtICdBV1NfQUNDRVNTX0tFWV9JRD0ke0FXU19BQ0NFU1NfS0VZX0lEOi19JwogICAgICAtICdBV1NfU0VDUkVUX0FDQ0VTU19LRVk9JHtBV1NfU0VDUkVUX0FDQ0VTU19LRVk6LX0nCiAgICAgIC0gJ0FXU19TRVNTSU9OX1RPS0VOPSR7QVdTX1NFU1NJT05fVE9LRU46LX0nCiAgICAgIC0gJ0FXU19TM19GT1JDRV9QQVRIX1NUWUxFPSR7QVdTX1MzX0ZPUkNFX1BBVEhfU1RZTEU6LX0nCiAgICAgIC0gJ0FXU19TM19ESVNBQkxFX1NTRT0ke0FXU19TM19ESVNBQkxFX1NTRTotfScKICAgICAgLSAnQVdTX1MzX0RJU0FCTEVfQ0hFQ0tTVU1TPSR7QVdTX1MzX0RJU0FCTEVfQ0hFQ0tTVU1TOi19JwogICAgICAtICdTM19TVE9SQUdFX0VYUE9SVFNfQlVDS0VUPSR7UzNfU1RPUkFHRV9FWFBPUlRTX0JVQ0tFVDotfScKICAgICAgLSAnUzNfU1RPUkFHRV9TTkFQU0hPVF9JTVBPUlRTX0JVQ0tFVD0ke1MzX1NUT1JBR0VfU05BUFNIT1RfSU1QT1JUU19CVUNLRVQ6LX0nCiAgICAgIC0gJ1MzX1NUT1JBR0VfTU9EVUxFU19CVUNLRVQ9JHtTM19TVE9SQUdFX01PRFVMRVNfQlVDS0VUOi19JwogICAgICAtICdTM19TVE9SQUdFX0ZJTEVTX0JVQ0tFVD0ke1MzX1NUT1JBR0VfRklMRVNfQlVDS0VUOi19JwogICAgICAtICdTM19TVE9SQUdFX1NFQVJDSF9CVUNLRVQ9JHtTM19TVE9SQUdFX1NFQVJDSF9CVUNLRVQ6LX0nCiAgICAgIC0gJ1MzX0VORFBPSU5UX1VSTD0ke1MzX0VORFBPSU5UX1VSTDotfScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OiAnY3VybCAtZiBodHRwOi8vMTI3LjAuMC4xOjMyMTAvdmVyc2lvbicKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHN0YXJ0X3BlcmlvZDogMTBzCiAgZGFzaGJvYXJkOgogICAgaW1hZ2U6ICdnaGNyLmlvL2dldC1jb252ZXgvY29udmV4LWRhc2hib2FyZDphOWE3NjBjYTEwMzk5ZWQ0MmUxYjRiYjg3Yzc4NTM5YTIzNTQ4OGM3JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfREFTSEJPQVJEXzY3OTEKICAgICAgLSAnTkVYVF9QVUJMSUNfREVQTE9ZTUVOVF9VUkw9JHtTRVJWSUNFX1VSTF9CQUNLRU5EfScKICAgIGRlcGVuZHNfb246CiAgICAgIGJhY2tlbmQ6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OiAnY3VybCAtZiBodHRwOi8vMTI3LjAuMC4xOjY3OTEvJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgc3RhcnRfcGVyaW9kOiA1cwo=", + "compose": "c2VydmljZXM6CiAgYmFja2VuZDoKICAgIGltYWdlOiAnZ2hjci5pby9nZXQtY29udmV4L2NvbnZleC1iYWNrZW5kOmE5YTc2MGNhMTAzOTllZDQyZTFiNGJiODdjNzg1MzlhMjM1NDg4YzcnCiAgICB2b2x1bWVzOgogICAgICAtICdkYXRhOi9jb252ZXgvZGF0YScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0JBQ0tFTkRfMzIxMAogICAgICAtICdJTlNUQU5DRV9OQU1FPSR7SU5TVEFOQ0VfTkFNRTotc2VsZi1ob3N0ZWQtY29udmV4fScKICAgICAgLSAnSU5TVEFOQ0VfU0VDUkVUPSR7U0VSVklDRV9IRVhfNjRfU0VDUkVUfScKICAgICAgLSAnQ09OVkVYX1JFTEVBU0VfVkVSU0lPTl9ERVY9JHtDT05WRVhfUkVMRUFTRV9WRVJTSU9OX0RFVjotfScKICAgICAgLSAnQUNUSU9OU19VU0VSX1RJTUVPVVRfU0VDUz0ke0FDVElPTlNfVVNFUl9USU1FT1VUX1NFQ1M6LX0nCiAgICAgIC0gJ0NPTlZFWF9DTE9VRF9PUklHSU49JHtTRVJWSUNFX1VSTF9EQVNIQk9BUkR9JwogICAgICAtICdDT05WRVhfU0lURV9PUklHSU49JHtTRVJWSUNFX1VSTF9CQUNLRU5EfScKICAgICAgLSAnREFUQUJBU0VfVVJMPSR7REFUQUJBU0VfVVJMOi19JwogICAgICAtICdESVNBQkxFX0JFQUNPTj0ke0RJU0FCTEVfQkVBQ09OOj9mYWxzZX0nCiAgICAgIC0gJ1JFREFDVF9MT0dTX1RPX0NMSUVOVD0ke1JFREFDVF9MT0dTX1RPX0NMSUVOVDo/ZmFsc2V9JwogICAgICAtICdET19OT1RfUkVRVUlSRV9TU0w9JHtET19OT1RfUkVRVUlSRV9TU0w6P3RydWV9JwogICAgICAtICdQT1NUR1JFU19VUkw9JHtQT1NUR1JFU19VUkw6LX0nCiAgICAgIC0gJ01ZU1FMX1VSTD0ke01ZU1FMX1VSTDotfScKICAgICAgLSAnUlVTVF9MT0c9JHtSVVNUX0xPRzotaW5mb30nCiAgICAgIC0gJ1JVU1RfQkFDS1RSQUNFPSR7UlVTVF9CQUNLVFJBQ0U6LX0nCiAgICAgIC0gJ0FXU19SRUdJT049JHtBV1NfUkVHSU9OOi19JwogICAgICAtICdBV1NfQUNDRVNTX0tFWV9JRD0ke0FXU19BQ0NFU1NfS0VZX0lEOi19JwogICAgICAtICdBV1NfU0VDUkVUX0FDQ0VTU19LRVk9JHtBV1NfU0VDUkVUX0FDQ0VTU19LRVk6LX0nCiAgICAgIC0gJ0FXU19TRVNTSU9OX1RPS0VOPSR7QVdTX1NFU1NJT05fVE9LRU46LX0nCiAgICAgIC0gJ0FXU19TM19GT1JDRV9QQVRIX1NUWUxFPSR7QVdTX1MzX0ZPUkNFX1BBVEhfU1RZTEU6LX0nCiAgICAgIC0gJ0FXU19TM19ESVNBQkxFX1NTRT0ke0FXU19TM19ESVNBQkxFX1NTRTotfScKICAgICAgLSAnQVdTX1MzX0RJU0FCTEVfQ0hFQ0tTVU1TPSR7QVdTX1MzX0RJU0FCTEVfQ0hFQ0tTVU1TOi19JwogICAgICAtICdTM19TVE9SQUdFX0VYUE9SVFNfQlVDS0VUPSR7UzNfU1RPUkFHRV9FWFBPUlRTX0JVQ0tFVDotfScKICAgICAgLSAnUzNfU1RPUkFHRV9TTkFQU0hPVF9JTVBPUlRTX0JVQ0tFVD0ke1MzX1NUT1JBR0VfU05BUFNIT1RfSU1QT1JUU19CVUNLRVQ6LX0nCiAgICAgIC0gJ1MzX1NUT1JBR0VfTU9EVUxFU19CVUNLRVQ9JHtTM19TVE9SQUdFX01PRFVMRVNfQlVDS0VUOi19JwogICAgICAtICdTM19TVE9SQUdFX0ZJTEVTX0JVQ0tFVD0ke1MzX1NUT1JBR0VfRklMRVNfQlVDS0VUOi19JwogICAgICAtICdTM19TVE9SQUdFX1NFQVJDSF9CVUNLRVQ9JHtTM19TVE9SQUdFX1NFQVJDSF9CVUNLRVQ6LX0nCiAgICAgIC0gJ1MzX0VORFBPSU5UX1VSTD0ke1MzX0VORFBPSU5UX1VSTDotfScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OiAnY3VybCAtZiBodHRwOi8vMTI3LjAuMC4xOjMyMTAvdmVyc2lvbicKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHN0YXJ0X3BlcmlvZDogMTBzCiAgZGFzaGJvYXJkOgogICAgaW1hZ2U6ICdnaGNyLmlvL2dldC1jb252ZXgvY29udmV4LWRhc2hib2FyZDphOWE3NjBjYTEwMzk5ZWQ0MmUxYjRiYjg3Yzc4NTM5YTIzNTQ4OGM3JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfREFTSEJPQVJEXzY3OTEKICAgICAgLSAnTkVYVF9QVUJMSUNfREVQTE9ZTUVOVF9VUkw9JHtTRVJWSUNFX1VSTF9CQUNLRU5EfScKICAgIGRlcGVuZHNfb246CiAgICAgIGJhY2tlbmQ6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OiAnY3VybCAtZiBodHRwOi8vMTI3LjAuMC4xOjY3OTEvJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgc3RhcnRfcGVyaW9kOiA1cwo=", "tags": [ "database", "reactive", @@ -1608,7 +1608,7 @@ "getoutline": { "documentation": "https://docs.getoutline.com/s/hosting/doc/hosting-outline-nipGaCRBDu?utm_source=coolify.io", "slogan": "Your team\u2019s knowledge base", - "compose": "c2VydmljZXM6CiAgb3V0bGluZToKICAgIGltYWdlOiAnZG9ja2VyLmdldG91dGxpbmUuY29tL291dGxpbmV3aWtpL291dGxpbmU6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAnc3RvcmFnZS1kYXRhOi92YXIvbGliL291dGxpbmUvZGF0YScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9PVVRMSU5FXzMwMDAKICAgICAgLSBOT0RFX0VOVj1wcm9kdWN0aW9uCiAgICAgIC0gJ1NFQ1JFVF9LRVk9JHtTRVJWSUNFX0hFWF8zMl9PVVRMSU5FfScKICAgICAgLSAnVVRJTFNfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF82NF9PVVRMSU5FfScKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF82NF9QT1NUR1JFU31AcG9zdGdyZXM6NTQzMi8ke1BPU1RHUkVTX0RBVEFCQVNFOi1vdXRsaW5lfScKICAgICAgLSAnUkVESVNfVVJMPXJlZGlzOi8vOiR7U0VSVklDRV9QQVNTV09SRF82NF9SRURJU31AcmVkaXM6NjM3OScKICAgICAgLSAnVVJMPSR7U0VSVklDRV9VUkxfT1VUTElORX0nCiAgICAgIC0gJ1BPUlQ9JHtPVVRMSU5FX1BPUlQ6LTMwMDB9JwogICAgICAtICdGSUxFX1NUT1JBR0U9JHtGSUxFX1NUT1JBR0U6LWxvY2FsfScKICAgICAgLSAnRklMRV9TVE9SQUdFX0xPQ0FMX1JPT1RfRElSPSR7RklMRV9TVE9SQUdFX0xPQ0FMX1JPT1RfRElSOi0vdmFyL2xpYi9vdXRsaW5lL2RhdGF9JwogICAgICAtICdGSUxFX1NUT1JBR0VfVVBMT0FEX01BWF9TSVpFPSR7RklMRV9TVE9SQUdFX1VQTE9BRF9NQVhfU0laRTotMjAwMH0nCiAgICAgIC0gJ0ZJTEVfU1RPUkFHRV9JTVBPUlRfTUFYX1NJWkU9JHtGSUxFX1NUT1JBR0VfSU1QT1JUX01BWF9TSVpFOi0xMDB9JwogICAgICAtICdGSUxFX1NUT1JBR0VfV09SS1NQQUNFX0lNUE9SVF9NQVhfU0laRT0ke0ZJTEVfU1RPUkFHRV9XT1JLU1BBQ0VfSU1QT1JUX01BWF9TSVpFfScKICAgICAgLSAnQVdTX0FDQ0VTU19LRVlfSUQ9JHtBV1NfQUNDRVNTX0tFWV9JRH0nCiAgICAgIC0gJ0FXU19TRUNSRVRfQUNDRVNTX0tFWT0ke0FXU19TRUNSRVRfQUNDRVNTX0tFWX0nCiAgICAgIC0gJ0FXU19SRUdJT049JHtBV1NfUkVHSU9OfScKICAgICAgLSAnQVdTX1MzX0FDQ0VMRVJBVEVfVVJMPSR7QVdTX1MzX0FDQ0VMRVJBVEVfVVJMfScKICAgICAgLSAnQVdTX1MzX1VQTE9BRF9CVUNLRVRfVVJMPSR7QVdTX1MzX1VQTE9BRF9CVUNLRVRfVVJMfScKICAgICAgLSAnQVdTX1MzX1VQTE9BRF9CVUNLRVRfTkFNRT0ke0FXU19TM19VUExPQURfQlVDS0VUX05BTUV9JwogICAgICAtICdBV1NfUzNfRk9SQ0VfUEFUSF9TVFlMRT0ke0FXU19TM19GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAgICAgLSAnQVdTX1MzX0FDTD0ke0FXU19TM19BQ0w6LXByaXZhdGV9JwogICAgICAtICdTTEFDS19DTElFTlRfSUQ9JHtTTEFDS19DTElFTlRfSUR9JwogICAgICAtICdTTEFDS19DTElFTlRfU0VDUkVUPSR7U0xBQ0tfQ0xJRU5UX1NFQ1JFVH0nCiAgICAgIC0gJ0dPT0dMRV9DTElFTlRfSUQ9JHtHT09HTEVfQ0xJRU5UX0lEfScKICAgICAgLSAnR09PR0xFX0NMSUVOVF9TRUNSRVQ9JHtHT09HTEVfQ0xJRU5UX1NFQ1JFVH0nCiAgICAgIC0gJ0FaVVJFX0NMSUVOVF9JRD0ke0FaVVJFX0NMSUVOVF9JRH0nCiAgICAgIC0gJ0FaVVJFX0NMSUVOVF9TRUNSRVQ9JHtBWlVSRV9DTElFTlRfU0VDUkVUfScKICAgICAgLSAnQVpVUkVfUkVTT1VSQ0VfQVBQX0lEPSR7QVpVUkVfUkVTT1VSQ0VfQVBQX0lEfScKICAgICAgLSAnT0lEQ19DTElFTlRfSUQ9JHtPSURDX0NMSUVOVF9JRH0nCiAgICAgIC0gJ09JRENfQ0xJRU5UX1NFQ1JFVD0ke09JRENfQ0xJRU5UX1NFQ1JFVH0nCiAgICAgIC0gJ09JRENfQVVUSF9VUkk9JHtPSURDX0FVVEhfVVJJfScKICAgICAgLSAnT0lEQ19UT0tFTl9VUkk9JHtPSURDX1RPS0VOX1VSSX0nCiAgICAgIC0gJ09JRENfVVNFUklORk9fVVJJPSR7T0lEQ19VU0VSSU5GT19VUkl9JwogICAgICAtICdPSURDX0xPR09VVF9VUkk9JHtPSURDX0xPR09VVF9VUkl9JwogICAgICAtICdPSURDX1VTRVJOQU1FX0NMQUlNPSR7T0lEQ19VU0VSTkFNRV9DTEFJTX0nCiAgICAgIC0gJ09JRENfRElTUExBWV9OQU1FPSR7T0lEQ19ESVNQTEFZX05BTUV9JwogICAgICAtICdPSURDX1NDT1BFUz0ke09JRENfU0NPUEVTfScKICAgICAgLSAnR0lUSFVCX0NMSUVOVF9JRD0ke0dJVEhVQl9DTElFTlRfSUR9JwogICAgICAtICdHSVRIVUJfQ0xJRU5UX1NFQ1JFVD0ke0dJVEhVQl9DTElFTlRfU0VDUkVUfScKICAgICAgLSAnR0lUSFVCX0FQUF9OQU1FPSR7R0lUSFVCX0FQUF9OQU1FfScKICAgICAgLSAnR0lUSFVCX0FQUF9JRD0ke0dJVEhVQl9BUFBfSUR9JwogICAgICAtICdHSVRIVUJfQVBQX1BSSVZBVEVfS0VZPSR7R0lUSFVCX0FQUF9QUklWQVRFX0tFWX0nCiAgICAgIC0gJ0RJU0NPUkRfQ0xJRU5UX0lEPSR7RElTQ09SRF9DTElFTlRfSUR9JwogICAgICAtICdESVNDT1JEX0NMSUVOVF9TRUNSRVQ9JHtESVNDT1JEX0NMSUVOVF9TRUNSRVR9JwogICAgICAtICdESVNDT1JEX1NFUlZFUl9JRD0ke0RJU0NPUkRfU0VSVkVSX0lEfScKICAgICAgLSAnRElTQ09SRF9TRVJWRVJfUk9MRVM9JHtESVNDT1JEX1NFUlZFUl9ST0xFU30nCiAgICAgIC0gJ1BHU1NMTU9ERT0ke1BHU1NMTU9ERTotZGlzYWJsZX0nCiAgICAgIC0gJ0ZPUkNFX0hUVFBTPSR7Rk9SQ0VfSFRUUFM6LXRydWV9JwogICAgICAtICdTTVRQX0hPU1Q9JHtTTVRQX0hPU1R9JwogICAgICAtICdTTVRQX1BPUlQ9JHtTTVRQX1BPUlR9JwogICAgICAtICdTTVRQX1VTRVJOQU1FPSR7U01UUF9VU0VSTkFNRX0nCiAgICAgIC0gJ1NNVFBfUEFTU1dPUkQ9JHtTTVRQX1BBU1NXT1JEfScKICAgICAgLSAnU01UUF9GUk9NX0VNQUlMPSR7U01UUF9GUk9NX0VNQUlMfScKICAgICAgLSAnU01UUF9SRVBMWV9FTUFJTD0ke1NNVFBfUkVQTFlfRU1BSUx9JwogICAgICAtICdTTVRQX1RMU19DSVBIRVJTPSR7U01UUF9UTFNfQ0lQSEVSU30nCiAgICAgIC0gJ1NNVFBfU0VDVVJFPSR7U01UUF9TRUNVUkV9JwogICAgICAtICdTTVRQX05BTUU9JHtTTVRQX05BTUV9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIGRpc2FibGU6IHRydWUKICByZWRpczoKICAgIGltYWdlOiAncmVkaXM6YWxwaW5lJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1JFRElTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF82NF9SRURJU30nCiAgICBjb21tYW5kOgogICAgICAtIHJlZGlzLXNlcnZlcgogICAgICAtICctLXJlcXVpcmVwYXNzJwogICAgICAtICcke1NFUlZJQ0VfUEFTU1dPUkRfNjRfUkVESVN9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gJy1hJwogICAgICAgIC0gJyR7U0VSVklDRV9QQVNTV09SRF82NF9SRURJU30nCiAgICAgICAgLSBQSU5HCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogMzBzCiAgICAgIHJldHJpZXM6IDMKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTItYWxwaW5lJwogICAgdm9sdW1lczoKICAgICAgLSAnZGF0YWJhc2UtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF82NF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREFUQUJBU0U6LW91dGxpbmV9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHBnX2lzcmVhZHkKICAgICAgICAtICctVScKICAgICAgICAtICcke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgICAgLSAnLWQnCiAgICAgICAgLSAnJHtQT1NUR1JFU19EQVRBQkFTRTotb3V0bGluZX0nCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDMK", + "compose": "c2VydmljZXM6CiAgb3V0bGluZToKICAgIGltYWdlOiAnZG9ja2VyLmdldG91dGxpbmUuY29tL291dGxpbmV3aWtpL291dGxpbmU6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAnc3RvcmFnZS1kYXRhOi92YXIvbGliL291dGxpbmUvZGF0YScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9PVVRMSU5FXzMwMDAKICAgICAgLSBOT0RFX0VOVj1wcm9kdWN0aW9uCiAgICAgIC0gJ1NFQ1JFVF9LRVk9JHtTRVJWSUNFX0hFWF82NF9PVVRMSU5FfScKICAgICAgLSAnVVRJTFNfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF82NF9PVVRMSU5FfScKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF82NF9QT1NUR1JFU31AcG9zdGdyZXM6NTQzMi8ke1BPU1RHUkVTX0RBVEFCQVNFOi1vdXRsaW5lfScKICAgICAgLSAnUkVESVNfVVJMPXJlZGlzOi8vOiR7U0VSVklDRV9QQVNTV09SRF82NF9SRURJU31AcmVkaXM6NjM3OScKICAgICAgLSAnVVJMPSR7U0VSVklDRV9VUkxfT1VUTElORX0nCiAgICAgIC0gJ1BPUlQ9JHtPVVRMSU5FX1BPUlQ6LTMwMDB9JwogICAgICAtICdGSUxFX1NUT1JBR0U9JHtGSUxFX1NUT1JBR0U6LWxvY2FsfScKICAgICAgLSAnRklMRV9TVE9SQUdFX0xPQ0FMX1JPT1RfRElSPSR7RklMRV9TVE9SQUdFX0xPQ0FMX1JPT1RfRElSOi0vdmFyL2xpYi9vdXRsaW5lL2RhdGF9JwogICAgICAtICdGSUxFX1NUT1JBR0VfVVBMT0FEX01BWF9TSVpFPSR7RklMRV9TVE9SQUdFX1VQTE9BRF9NQVhfU0laRTotMjAwMH0nCiAgICAgIC0gJ0ZJTEVfU1RPUkFHRV9JTVBPUlRfTUFYX1NJWkU9JHtGSUxFX1NUT1JBR0VfSU1QT1JUX01BWF9TSVpFOi0xMDB9JwogICAgICAtICdGSUxFX1NUT1JBR0VfV09SS1NQQUNFX0lNUE9SVF9NQVhfU0laRT0ke0ZJTEVfU1RPUkFHRV9XT1JLU1BBQ0VfSU1QT1JUX01BWF9TSVpFfScKICAgICAgLSAnQVdTX0FDQ0VTU19LRVlfSUQ9JHtBV1NfQUNDRVNTX0tFWV9JRH0nCiAgICAgIC0gJ0FXU19TRUNSRVRfQUNDRVNTX0tFWT0ke0FXU19TRUNSRVRfQUNDRVNTX0tFWX0nCiAgICAgIC0gJ0FXU19SRUdJT049JHtBV1NfUkVHSU9OfScKICAgICAgLSAnQVdTX1MzX0FDQ0VMRVJBVEVfVVJMPSR7QVdTX1MzX0FDQ0VMRVJBVEVfVVJMfScKICAgICAgLSAnQVdTX1MzX1VQTE9BRF9CVUNLRVRfVVJMPSR7QVdTX1MzX1VQTE9BRF9CVUNLRVRfVVJMfScKICAgICAgLSAnQVdTX1MzX1VQTE9BRF9CVUNLRVRfTkFNRT0ke0FXU19TM19VUExPQURfQlVDS0VUX05BTUV9JwogICAgICAtICdBV1NfUzNfRk9SQ0VfUEFUSF9TVFlMRT0ke0FXU19TM19GT1JDRV9QQVRIX1NUWUxFOi10cnVlfScKICAgICAgLSAnQVdTX1MzX0FDTD0ke0FXU19TM19BQ0w6LXByaXZhdGV9JwogICAgICAtICdTTEFDS19DTElFTlRfSUQ9JHtTTEFDS19DTElFTlRfSUR9JwogICAgICAtICdTTEFDS19DTElFTlRfU0VDUkVUPSR7U0xBQ0tfQ0xJRU5UX1NFQ1JFVH0nCiAgICAgIC0gJ0dPT0dMRV9DTElFTlRfSUQ9JHtHT09HTEVfQ0xJRU5UX0lEfScKICAgICAgLSAnR09PR0xFX0NMSUVOVF9TRUNSRVQ9JHtHT09HTEVfQ0xJRU5UX1NFQ1JFVH0nCiAgICAgIC0gJ0FaVVJFX0NMSUVOVF9JRD0ke0FaVVJFX0NMSUVOVF9JRH0nCiAgICAgIC0gJ0FaVVJFX0NMSUVOVF9TRUNSRVQ9JHtBWlVSRV9DTElFTlRfU0VDUkVUfScKICAgICAgLSAnQVpVUkVfUkVTT1VSQ0VfQVBQX0lEPSR7QVpVUkVfUkVTT1VSQ0VfQVBQX0lEfScKICAgICAgLSAnT0lEQ19DTElFTlRfSUQ9JHtPSURDX0NMSUVOVF9JRH0nCiAgICAgIC0gJ09JRENfQ0xJRU5UX1NFQ1JFVD0ke09JRENfQ0xJRU5UX1NFQ1JFVH0nCiAgICAgIC0gJ09JRENfQVVUSF9VUkk9JHtPSURDX0FVVEhfVVJJfScKICAgICAgLSAnT0lEQ19UT0tFTl9VUkk9JHtPSURDX1RPS0VOX1VSSX0nCiAgICAgIC0gJ09JRENfVVNFUklORk9fVVJJPSR7T0lEQ19VU0VSSU5GT19VUkl9JwogICAgICAtICdPSURDX0xPR09VVF9VUkk9JHtPSURDX0xPR09VVF9VUkl9JwogICAgICAtICdPSURDX1VTRVJOQU1FX0NMQUlNPSR7T0lEQ19VU0VSTkFNRV9DTEFJTX0nCiAgICAgIC0gJ09JRENfRElTUExBWV9OQU1FPSR7T0lEQ19ESVNQTEFZX05BTUV9JwogICAgICAtICdPSURDX1NDT1BFUz0ke09JRENfU0NPUEVTfScKICAgICAgLSAnR0lUSFVCX0NMSUVOVF9JRD0ke0dJVEhVQl9DTElFTlRfSUR9JwogICAgICAtICdHSVRIVUJfQ0xJRU5UX1NFQ1JFVD0ke0dJVEhVQl9DTElFTlRfU0VDUkVUfScKICAgICAgLSAnR0lUSFVCX0FQUF9OQU1FPSR7R0lUSFVCX0FQUF9OQU1FfScKICAgICAgLSAnR0lUSFVCX0FQUF9JRD0ke0dJVEhVQl9BUFBfSUR9JwogICAgICAtICdHSVRIVUJfQVBQX1BSSVZBVEVfS0VZPSR7R0lUSFVCX0FQUF9QUklWQVRFX0tFWX0nCiAgICAgIC0gJ0RJU0NPUkRfQ0xJRU5UX0lEPSR7RElTQ09SRF9DTElFTlRfSUR9JwogICAgICAtICdESVNDT1JEX0NMSUVOVF9TRUNSRVQ9JHtESVNDT1JEX0NMSUVOVF9TRUNSRVR9JwogICAgICAtICdESVNDT1JEX1NFUlZFUl9JRD0ke0RJU0NPUkRfU0VSVkVSX0lEfScKICAgICAgLSAnRElTQ09SRF9TRVJWRVJfUk9MRVM9JHtESVNDT1JEX1NFUlZFUl9ST0xFU30nCiAgICAgIC0gJ1BHU1NMTU9ERT0ke1BHU1NMTU9ERTotZGlzYWJsZX0nCiAgICAgIC0gJ0ZPUkNFX0hUVFBTPSR7Rk9SQ0VfSFRUUFM6LXRydWV9JwogICAgICAtICdTTVRQX0hPU1Q9JHtTTVRQX0hPU1R9JwogICAgICAtICdTTVRQX1BPUlQ9JHtTTVRQX1BPUlR9JwogICAgICAtICdTTVRQX1VTRVJOQU1FPSR7U01UUF9VU0VSTkFNRX0nCiAgICAgIC0gJ1NNVFBfUEFTU1dPUkQ9JHtTTVRQX1BBU1NXT1JEfScKICAgICAgLSAnU01UUF9GUk9NX0VNQUlMPSR7U01UUF9GUk9NX0VNQUlMfScKICAgICAgLSAnU01UUF9SRVBMWV9FTUFJTD0ke1NNVFBfUkVQTFlfRU1BSUx9JwogICAgICAtICdTTVRQX1RMU19DSVBIRVJTPSR7U01UUF9UTFNfQ0lQSEVSU30nCiAgICAgIC0gJ1NNVFBfU0VDVVJFPSR7U01UUF9TRUNVUkV9JwogICAgICAtICdTTVRQX05BTUU9JHtTTVRQX05BTUV9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIGRpc2FibGU6IHRydWUKICByZWRpczoKICAgIGltYWdlOiAncmVkaXM6YWxwaW5lJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1JFRElTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF82NF9SRURJU30nCiAgICBjb21tYW5kOgogICAgICAtIHJlZGlzLXNlcnZlcgogICAgICAtICctLXJlcXVpcmVwYXNzJwogICAgICAtICcke1NFUlZJQ0VfUEFTU1dPUkRfNjRfUkVESVN9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gJy1hJwogICAgICAgIC0gJyR7U0VSVklDRV9QQVNTV09SRF82NF9SRURJU30nCiAgICAgICAgLSBQSU5HCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogMzBzCiAgICAgIHJldHJpZXM6IDMKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTItYWxwaW5lJwogICAgdm9sdW1lczoKICAgICAgLSAnZGF0YWJhc2UtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF82NF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREFUQUJBU0U6LW91dGxpbmV9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHBnX2lzcmVhZHkKICAgICAgICAtICctVScKICAgICAgICAtICcke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgICAgLSAnLWQnCiAgICAgICAgLSAnJHtQT1NUR1JFU19EQVRBQkFTRTotb3V0bGluZX0nCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDMK", "tags": [ "knowledge base", "documentation" @@ -2000,7 +2000,7 @@ "homarr": { "documentation": "https://homarr.dev?utm_source=coolify.io", "slogan": "Homarr is a self-hosted homepage for your services.", - "compose": "c2VydmljZXM6CiAgaG9tYXJyOgogICAgaW1hZ2U6ICdnaGNyLmlvL2hvbWFyci1sYWJzL2hvbWFycjp2MS40MC4wJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfSE9NQVJSXzc1NzUKICAgICAgLSBTRVJWSUNFX0hFWF8zMl9IT01BUlIKICAgICAgLSAnU0VDUkVUX0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9IRVhfMzJfSE9NQVJSfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJy92YXIvcnVuL2RvY2tlci5zb2NrOi92YXIvcnVuL2RvY2tlci5zb2NrJwogICAgICAtICcuL2hvbWFyci9hcHBkYXRhOi9hcHBkYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHdnZXQKICAgICAgICAtICctcScKICAgICAgICAtICctLXNwaWRlcicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjc1NzUnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", + "compose": "c2VydmljZXM6CiAgaG9tYXJyOgogICAgaW1hZ2U6ICdnaGNyLmlvL2hvbWFyci1sYWJzL2hvbWFycjp2MS40MC4wJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfSE9NQVJSXzc1NzUKICAgICAgLSAnU0VDUkVUX0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9IRVhfNjRfSE9NQVJSfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJy92YXIvcnVuL2RvY2tlci5zb2NrOi92YXIvcnVuL2RvY2tlci5zb2NrJwogICAgICAtICcuL2hvbWFyci9hcHBkYXRhOi9hcHBkYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHdnZXQKICAgICAgICAtICctcScKICAgICAgICAtICctLXNwaWRlcicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjc1NzUnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", "tags": [ "homarr", "self-hosted", @@ -3408,7 +3408,7 @@ "open-archiver": { "documentation": "https://docs.openarchiver.com/?utm_source=coolify.io", "slogan": "A self-hosted, open-source email archiving solution with full-text search capability.", - "compose": "c2VydmljZXM6CiAgb3Blbi1hcmNoaXZlcjoKICAgIGltYWdlOiAnbG9naWNsYWJzaHEvb3Blbi1hcmNoaXZlcjpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9PUEVOQVJDSElWRVJfMzAwMAogICAgICAtICdFTkNSWVBUSU9OX0tFWT0ke1NFUlZJQ0VfSEVYXzMyX0VOQ1JZUFRJT05LRVl9JwogICAgICAtICdTVE9SQUdFX0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9IRVhfMzJfU1RPUkFHRUVOQ1JZUFRJT05LRVl9JwogICAgICAtICdQT1JUX0JBQ0tFTkQ9JHtQT1JUX0JBQ0tFTkQ6LTQwMDB9JwogICAgICAtICdQT1JUX0ZST05URU5EPSR7UE9SVF9GUk9OVEVORDotMzAwMH0nCiAgICAgIC0gJ05PREVfRU5WPSR7Tk9ERV9FTlY6LXByb2R1Y3Rpb259JwogICAgICAtICdTWU5DX0ZSRVFVRU5DWT0ke1NZTkNfRlJFUVVFTkNZOi0qICogKiAqICp9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1vcGVuX2FyY2hpdmV9JwogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzcWw6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNfREI6LW9wZW4tYXJjaGl2ZXItZGJ9JwogICAgICAtICdNRUlMSV9NQVNURVJfS0VZPSR7U0VSVklDRV9QQVNTV09SRF9NRUlMSVNFQVJDSH0nCiAgICAgIC0gJ01FSUxJX0hPU1Q9aHR0cDovL21laWxpc2VhcmNoOjc3MDAnCiAgICAgIC0gUkVESVNfSE9TVD12YWxrZXkKICAgICAgLSBSRURJU19QT1JUPTYzNzkKICAgICAgLSAnUkVESVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1ZBTEtFWX0nCiAgICAgIC0gUkVESVNfVExTX0VOQUJMRUQ9ZmFsc2UKICAgICAgLSAnU1RPUkFHRV9UWVBFPSR7U1RPUkFHRV9UWVBFOi1sb2NhbH0nCiAgICAgIC0gJ1NUT1JBR0VfTE9DQUxfUk9PVF9QQVRIPSR7U1RPUkFHRV9MT0NBTF9ST09UX1BBVEg6LS92YXIvZGF0YS9vcGVuLWFyY2hpdmVyfScKICAgICAgLSAnQk9EWV9TSVpFX0xJTUlUPSR7Qk9EWV9TSVpFX0xJTUlUOi0xMDBNfScKICAgICAgLSAnU1RPUkFHRV9TM19FTkRQT0lOVD0ke1NUT1JBR0VfUzNfRU5EUE9JTlR9JwogICAgICAtICdTVE9SQUdFX1MzX0JVQ0tFVD0ke1NUT1JBR0VfUzNfQlVDS0VUfScKICAgICAgLSAnU1RPUkFHRV9TM19BQ0NFU1NfS0VZX0lEPSR7U1RPUkFHRV9TM19BQ0NFU1NfS0VZX0lEfScKICAgICAgLSAnU1RPUkFHRV9TM19TRUNSRVRfQUNDRVNTX0tFWT0ke1NUT1JBR0VfUzNfU0VDUkVUX0FDQ0VTU19LRVl9JwogICAgICAtICdTVE9SQUdFX1MzX1JFR0lPTj0ke1NUT1JBR0VfUzNfUkVHSU9OfScKICAgICAgLSAnU1RPUkFHRV9TM19GT1JDRV9QQVRIX1NUWUxFPSR7U1RPUkFHRV9TM19GT1JDRV9QQVRIX1NUWUxFOi1mYWxzZX0nCiAgICAgIC0gJ0pXVF9TRUNSRVQ9JHtTRVJWSUNFX0JBU0U2NF8xMjhfSldUfScKICAgICAgLSAnSldUX0VYUElSRVNfSU49JHtKV1RfRVhQSVJFU19JTjotN2R9JwogICAgICAtICdSQVRFX0xJTUlUX1dJTkRPV19NUz0ke1JBVEVfTElNSVRfV0lORE9XX01TOi02MDAwMH0nCiAgICAgIC0gJ1JBVEVfTElNSVRfTUFYX1JFUVVFU1RTPSR7UkFURV9MSU1JVF9NQVhfUkVRVUVTVFM6LTEwMH0nCiAgICB2b2x1bWVzOgogICAgICAtICdhcmNoaXZlci1kYXRhOi92YXIvZGF0YS9vcGVuLWFyY2hpdmVyJwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgdmFsa2V5OgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIG1laWxpc2VhcmNoOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE3LWFscGluZScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1vcGVuLWFyY2hpdmVyLWRifScKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gTENfQUxMPUMKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3Bvc3RncmVzLWRhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICB2YWxrZXk6CiAgICBpbWFnZTogJ3ZhbGtleS92YWxrZXk6OC1hbHBpbmUnCiAgICBjb21tYW5kOiAndmFsa2V5LXNlcnZlciAtLXJlcXVpcmVwYXNzICR7U0VSVklDRV9QQVNTV09SRF9WQUxLRVl9JwogICAgdm9sdW1lczoKICAgICAgLSAndmFsa2V5LWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMwogIG1laWxpc2VhcmNoOgogICAgaW1hZ2U6ICdnZXRtZWlsaS9tZWlsaXNlYXJjaDp2MS4xNScKICAgIGVudmlyb25tZW50OgogICAgICAtICdNRUlMSV9NQVNURVJfS0VZPSR7U0VSVklDRV9QQVNTV09SRF9NRUlMSVNFQVJDSH0nCiAgICB2b2x1bWVzOgogICAgICAtICdtZWlsaXNlYXJjaC1kYXRhOi9tZWlsaV9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjc3MDAvaGVhbHRoJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1Cg==", + "compose": "c2VydmljZXM6CiAgb3Blbi1hcmNoaXZlcjoKICAgIGltYWdlOiAnbG9naWNsYWJzaHEvb3Blbi1hcmNoaXZlcjpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9PUEVOQVJDSElWRVJfMzAwMAogICAgICAtICdFTkNSWVBUSU9OX0tFWT0ke1NFUlZJQ0VfSEVYXzY0X0VOQ1JZUFRJT05LRVl9JwogICAgICAtICdTVE9SQUdFX0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9IRVhfNjRfU1RPUkFHRUVOQ1JZUFRJT05LRVl9JwogICAgICAtICdQT1JUX0JBQ0tFTkQ9JHtQT1JUX0JBQ0tFTkQ6LTQwMDB9JwogICAgICAtICdQT1JUX0ZST05URU5EPSR7UE9SVF9GUk9OVEVORDotMzAwMH0nCiAgICAgIC0gJ05PREVfRU5WPSR7Tk9ERV9FTlY6LXByb2R1Y3Rpb259JwogICAgICAtICdTWU5DX0ZSRVFVRU5DWT0ke1NZTkNfRlJFUVVFTkNZOi0qICogKiAqICp9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1vcGVuX2FyY2hpdmV9JwogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzcWw6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7UE9TVEdSRVNfREI6LW9wZW4tYXJjaGl2ZXItZGJ9JwogICAgICAtICdNRUlMSV9NQVNURVJfS0VZPSR7U0VSVklDRV9QQVNTV09SRF9NRUlMSVNFQVJDSH0nCiAgICAgIC0gJ01FSUxJX0hPU1Q9aHR0cDovL21laWxpc2VhcmNoOjc3MDAnCiAgICAgIC0gUkVESVNfSE9TVD12YWxrZXkKICAgICAgLSBSRURJU19QT1JUPTYzNzkKICAgICAgLSAnUkVESVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1ZBTEtFWX0nCiAgICAgIC0gUkVESVNfVExTX0VOQUJMRUQ9ZmFsc2UKICAgICAgLSAnU1RPUkFHRV9UWVBFPSR7U1RPUkFHRV9UWVBFOi1sb2NhbH0nCiAgICAgIC0gJ1NUT1JBR0VfTE9DQUxfUk9PVF9QQVRIPSR7U1RPUkFHRV9MT0NBTF9ST09UX1BBVEg6LS92YXIvZGF0YS9vcGVuLWFyY2hpdmVyfScKICAgICAgLSAnQk9EWV9TSVpFX0xJTUlUPSR7Qk9EWV9TSVpFX0xJTUlUOi0xMDBNfScKICAgICAgLSAnU1RPUkFHRV9TM19FTkRQT0lOVD0ke1NUT1JBR0VfUzNfRU5EUE9JTlR9JwogICAgICAtICdTVE9SQUdFX1MzX0JVQ0tFVD0ke1NUT1JBR0VfUzNfQlVDS0VUfScKICAgICAgLSAnU1RPUkFHRV9TM19BQ0NFU1NfS0VZX0lEPSR7U1RPUkFHRV9TM19BQ0NFU1NfS0VZX0lEfScKICAgICAgLSAnU1RPUkFHRV9TM19TRUNSRVRfQUNDRVNTX0tFWT0ke1NUT1JBR0VfUzNfU0VDUkVUX0FDQ0VTU19LRVl9JwogICAgICAtICdTVE9SQUdFX1MzX1JFR0lPTj0ke1NUT1JBR0VfUzNfUkVHSU9OfScKICAgICAgLSAnU1RPUkFHRV9TM19GT1JDRV9QQVRIX1NUWUxFPSR7U1RPUkFHRV9TM19GT1JDRV9QQVRIX1NUWUxFOi1mYWxzZX0nCiAgICAgIC0gJ0pXVF9TRUNSRVQ9JHtTRVJWSUNFX0JBU0U2NF8xMjhfSldUfScKICAgICAgLSAnSldUX0VYUElSRVNfSU49JHtKV1RfRVhQSVJFU19JTjotN2R9JwogICAgICAtICdSQVRFX0xJTUlUX1dJTkRPV19NUz0ke1JBVEVfTElNSVRfV0lORE9XX01TOi02MDAwMH0nCiAgICAgIC0gJ1JBVEVfTElNSVRfTUFYX1JFUVVFU1RTPSR7UkFURV9MSU1JVF9NQVhfUkVRVUVTVFM6LTEwMH0nCiAgICB2b2x1bWVzOgogICAgICAtICdhcmNoaXZlci1kYXRhOi92YXIvZGF0YS9vcGVuLWFyY2hpdmVyJwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgdmFsa2V5OgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIG1laWxpc2VhcmNoOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE3LWFscGluZScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1vcGVuLWFyY2hpdmVyLWRifScKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gTENfQUxMPUMKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3Bvc3RncmVzLWRhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICB2YWxrZXk6CiAgICBpbWFnZTogJ3ZhbGtleS92YWxrZXk6OC1hbHBpbmUnCiAgICBjb21tYW5kOiAndmFsa2V5LXNlcnZlciAtLXJlcXVpcmVwYXNzICR7U0VSVklDRV9QQVNTV09SRF9WQUxLRVl9JwogICAgdm9sdW1lczoKICAgICAgLSAndmFsa2V5LWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMwogIG1laWxpc2VhcmNoOgogICAgaW1hZ2U6ICdnZXRtZWlsaS9tZWlsaXNlYXJjaDp2MS4xNScKICAgIGVudmlyb25tZW50OgogICAgICAtICdNRUlMSV9NQVNURVJfS0VZPSR7U0VSVklDRV9QQVNTV09SRF9NRUlMSVNFQVJDSH0nCiAgICB2b2x1bWVzOgogICAgICAtICdtZWlsaXNlYXJjaC1kYXRhOi9tZWlsaV9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjc3MDAvaGVhbHRoJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1Cg==", "tags": [ "email archiving", "email", diff --git a/templates/service-templates.json b/templates/service-templates.json index cc909dc68..ea9fd145a 100644 --- a/templates/service-templates.json +++ b/templates/service-templates.json @@ -299,7 +299,7 @@ "bluesky-pds": { "documentation": "https://github.com/bluesky-social/pds?utm_source=coolify.io", "slogan": "Bluesky PDS (Personal Data Server)", - "compose": "c2VydmljZXM6CiAgcGRzOgogICAgaW1hZ2U6ICdnaGNyLmlvL2JsdWVza3ktc29jaWFsL3BkczowLjQuMTgyJwogICAgdm9sdW1lczoKICAgICAgLSAncGRzLWRhdGE6L3BkcycKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9QRFNfMzAwMAogICAgICAtICdQRFNfSE9TVE5BTUU9JHtTRVJWSUNFX0ZRRE5fUERTfScKICAgICAgLSAnUERTX0pXVF9TRUNSRVQ9JHtTRVJWSUNFX0hFWF8zMl9KV1RTRUNSRVR9JwogICAgICAtICdQRFNfQURNSU5fUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0FETUlOfScKICAgICAgLSAnUERTX0FETUlOX0VNQUlMPSR7UERTX0FETUlOX0VNQUlMfScKICAgICAgLSAnUERTX1BMQ19ST1RBVElPTl9LRVlfSzI1Nl9QUklWQVRFX0tFWV9IRVg9JHtTRVJWSUNFX0hFWF8zMl9ST1RBVElPTktFWX0nCiAgICAgIC0gJ1BEU19EQVRBX0RJUkVDVE9SWT0ke1BEU19EQVRBX0RJUkVDVE9SWTotL3Bkc30nCiAgICAgIC0gJ1BEU19CTE9CU1RPUkVfRElTS19MT0NBVElPTj0ke1BEU19EQVRBX0RJUkVDVE9SWTotL3Bkc30vYmxvY2tzJwogICAgICAtICdQRFNfQkxPQl9VUExPQURfTElNSVQ9JHtQRFNfQkxPQl9VUExPQURfTElNSVQ6LTEwNDg1NzYwMH0nCiAgICAgIC0gJ1BEU19ESURfUExDX1VSTD0ke1BEU19ESURfUExDX1VSTDotaHR0cHM6Ly9wbGMuZGlyZWN0b3J5fScKICAgICAgLSAnUERTX0VNQUlMX0ZST01fQUREUkVTUz0ke1BEU19FTUFJTF9GUk9NX0FERFJFU1N9JwogICAgICAtICdQRFNfRU1BSUxfU01UUF9VUkw9JHtQRFNfRU1BSUxfU01UUF9VUkx9JwogICAgICAtICdQRFNfQlNLWV9BUFBfVklFV19VUkw9JHtQRFNfQlNLWV9BUFBfVklFV19VUkw6LWh0dHBzOi8vYXBpLmJza3kuYXBwfScKICAgICAgLSAnUERTX0JTS1lfQVBQX1ZJRVdfRElEPSR7UERTX0JTS1lfQVBQX1ZJRVdfRElEOi1kaWQ6d2ViOmFwaS5ic2t5LmFwcH0nCiAgICAgIC0gJ1BEU19SRVBPUlRfU0VSVklDRV9GUUROPSR7UERTX1JFUE9SVF9TRVJWSUNFX0ZRRE46LWh0dHBzOi8vbW9kLmJza3kuYXBwL3hycGMvY29tLmF0cHJvdG8ubW9kZXJhdGlvbi5jcmVhdGVSZXBvcnR9JwogICAgICAtICdQRFNfUkVQT1JUX1NFUlZJQ0VfRElEPSR7UERTX1JFUE9SVF9TRVJWSUNFX0RJRDotZGlkOnBsYzphcjdjNGJ5NDZxamR5ZGhkZXZ2cm5kYWN9JwogICAgICAtICdQRFNfQ1JBV0xFUlM9JHtQRFNfQ1JBV0xFUlM6LWh0dHBzOi8vYnNreS5uZXR3b3JrfScKICAgICAgLSAnTE9HX0VOQUJMRUQ9JHtMT0dfRU5BQkxFRDotdHJ1ZX0nCiAgICBjb21tYW5kOiAic2ggLWMgJ1xuICBzZXQgLWV1byBwaXBlZmFpbFxuICBlY2hvIFwiSW5zdGFsbGluZyByZXF1aXJlZCBwYWNrYWdlcyBhbmQgcGRzYWRtaW4uLi5cIlxuICBhcGsgYWRkIC0tbm8tY2FjaGUgb3BlbnNzbCBjdXJsIGJhc2gganEgY29yZXV0aWxzIGdudXBnIHV0aWwtbGludXgtbWlzYyA+L2Rldi9udWxsXG4gIGN1cmwgLW8gL3Vzci9sb2NhbC9iaW4vcGRzYWRtaW4uc2ggaHR0cHM6Ly9yYXcuZ2l0aHVidXNlcmNvbnRlbnQuY29tL2JsdWVza3ktc29jaWFsL3Bkcy9tYWluL3Bkc2FkbWluLnNoXG4gIGNobW9kIDcwMCAvdXNyL2xvY2FsL2Jpbi9wZHNhZG1pbi5zaFxuICBsbiAtc2YgL3Vzci9sb2NhbC9iaW4vcGRzYWRtaW4uc2ggL3Vzci9sb2NhbC9iaW4vcGRzYWRtaW5cbiAgZWNobyBcIkNyZWF0aW5nIGFuIGVtcHR5IHBkcy5lbnYgZmlsZSBzbyBwZHNhZG1pbiB3b3Jrcy4uLlwiXG4gIHRvdWNoICR7UERTX0RBVEFfRElSRUNUT1JZfS9wZHMuZW52XG4gIGVjaG8gXCJMYXVuY2hpbmcgUERTLCBlbmpveSEuLi5cIlxuICBleGVjIG5vZGUgLS1lbmFibGUtc291cmNlLW1hcHMgaW5kZXguanNcbidcbiIKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSB3Z2V0CiAgICAgICAgLSAnLS1zcGlkZXInCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTozMDAwL3hycGMvX2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxMAo=", + "compose": "c2VydmljZXM6CiAgcGRzOgogICAgaW1hZ2U6ICdnaGNyLmlvL2JsdWVza3ktc29jaWFsL3BkczowLjQuMTgyJwogICAgdm9sdW1lczoKICAgICAgLSAncGRzLWRhdGE6L3BkcycKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9QRFNfMzAwMAogICAgICAtICdQRFNfSE9TVE5BTUU9JHtTRVJWSUNFX0ZRRE5fUERTfScKICAgICAgLSAnUERTX0pXVF9TRUNSRVQ9JHtTRVJWSUNFX0hFWF82NF9KV1RTRUNSRVR9JwogICAgICAtICdQRFNfQURNSU5fUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0FETUlOfScKICAgICAgLSAnUERTX0FETUlOX0VNQUlMPSR7UERTX0FETUlOX0VNQUlMfScKICAgICAgLSAnUERTX1BMQ19ST1RBVElPTl9LRVlfSzI1Nl9QUklWQVRFX0tFWV9IRVg9JHtTRVJWSUNFX0hFWF82NF9ST1RBVElPTktFWX0nCiAgICAgIC0gJ1BEU19EQVRBX0RJUkVDVE9SWT0ke1BEU19EQVRBX0RJUkVDVE9SWTotL3Bkc30nCiAgICAgIC0gJ1BEU19CTE9CU1RPUkVfRElTS19MT0NBVElPTj0ke1BEU19EQVRBX0RJUkVDVE9SWTotL3Bkc30vYmxvY2tzJwogICAgICAtICdQRFNfQkxPQl9VUExPQURfTElNSVQ9JHtQRFNfQkxPQl9VUExPQURfTElNSVQ6LTEwNDg1NzYwMH0nCiAgICAgIC0gJ1BEU19ESURfUExDX1VSTD0ke1BEU19ESURfUExDX1VSTDotaHR0cHM6Ly9wbGMuZGlyZWN0b3J5fScKICAgICAgLSAnUERTX0VNQUlMX0ZST01fQUREUkVTUz0ke1BEU19FTUFJTF9GUk9NX0FERFJFU1N9JwogICAgICAtICdQRFNfRU1BSUxfU01UUF9VUkw9JHtQRFNfRU1BSUxfU01UUF9VUkx9JwogICAgICAtICdQRFNfQlNLWV9BUFBfVklFV19VUkw9JHtQRFNfQlNLWV9BUFBfVklFV19VUkw6LWh0dHBzOi8vYXBpLmJza3kuYXBwfScKICAgICAgLSAnUERTX0JTS1lfQVBQX1ZJRVdfRElEPSR7UERTX0JTS1lfQVBQX1ZJRVdfRElEOi1kaWQ6d2ViOmFwaS5ic2t5LmFwcH0nCiAgICAgIC0gJ1BEU19SRVBPUlRfU0VSVklDRV9GUUROPSR7UERTX1JFUE9SVF9TRVJWSUNFX0ZRRE46LWh0dHBzOi8vbW9kLmJza3kuYXBwL3hycGMvY29tLmF0cHJvdG8ubW9kZXJhdGlvbi5jcmVhdGVSZXBvcnR9JwogICAgICAtICdQRFNfUkVQT1JUX1NFUlZJQ0VfRElEPSR7UERTX1JFUE9SVF9TRVJWSUNFX0RJRDotZGlkOnBsYzphcjdjNGJ5NDZxamR5ZGhkZXZ2cm5kYWN9JwogICAgICAtICdQRFNfQ1JBV0xFUlM9JHtQRFNfQ1JBV0xFUlM6LWh0dHBzOi8vYnNreS5uZXR3b3JrfScKICAgICAgLSAnTE9HX0VOQUJMRUQ9JHtMT0dfRU5BQkxFRDotdHJ1ZX0nCiAgICBjb21tYW5kOiAic2ggLWMgJ1xuICBzZXQgLWV1byBwaXBlZmFpbFxuICBlY2hvIFwiSW5zdGFsbGluZyByZXF1aXJlZCBwYWNrYWdlcyBhbmQgcGRzYWRtaW4uLi5cIlxuICBhcGsgYWRkIC0tbm8tY2FjaGUgb3BlbnNzbCBjdXJsIGJhc2gganEgY29yZXV0aWxzIGdudXBnIHV0aWwtbGludXgtbWlzYyA+L2Rldi9udWxsXG4gIGN1cmwgLW8gL3Vzci9sb2NhbC9iaW4vcGRzYWRtaW4uc2ggaHR0cHM6Ly9yYXcuZ2l0aHVidXNlcmNvbnRlbnQuY29tL2JsdWVza3ktc29jaWFsL3Bkcy9tYWluL3Bkc2FkbWluLnNoXG4gIGNobW9kIDcwMCAvdXNyL2xvY2FsL2Jpbi9wZHNhZG1pbi5zaFxuICBsbiAtc2YgL3Vzci9sb2NhbC9iaW4vcGRzYWRtaW4uc2ggL3Vzci9sb2NhbC9iaW4vcGRzYWRtaW5cbiAgZWNobyBcIkNyZWF0aW5nIGFuIGVtcHR5IHBkcy5lbnYgZmlsZSBzbyBwZHNhZG1pbiB3b3Jrcy4uLlwiXG4gIHRvdWNoICR7UERTX0RBVEFfRElSRUNUT1JZfS9wZHMuZW52XG4gIGVjaG8gXCJMYXVuY2hpbmcgUERTLCBlbmpveSEuLi5cIlxuICBleGVjIG5vZGUgLS1lbmFibGUtc291cmNlLW1hcHMgaW5kZXguanNcbidcbiIKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSB3Z2V0CiAgICAgICAgLSAnLS1zcGlkZXInCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTozMDAwL3hycGMvX2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxMAo=", "tags": [ "bluesky", "pds", @@ -730,7 +730,7 @@ "convex": { "documentation": "https://github.com/get-convex/convex-backend/blob/main/self-hosted/README.md?utm_source=coolify.io", "slogan": "Convex is the open-source reactive database for app developers.", - "compose": "c2VydmljZXM6CiAgYmFja2VuZDoKICAgIGltYWdlOiAnZ2hjci5pby9nZXQtY29udmV4L2NvbnZleC1iYWNrZW5kOmE5YTc2MGNhMTAzOTllZDQyZTFiNGJiODdjNzg1MzlhMjM1NDg4YzcnCiAgICB2b2x1bWVzOgogICAgICAtICdkYXRhOi9jb252ZXgvZGF0YScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9CQUNLRU5EXzMyMTAKICAgICAgLSAnSU5TVEFOQ0VfTkFNRT0ke0lOU1RBTkNFX05BTUU6LXNlbGYtaG9zdGVkLWNvbnZleH0nCiAgICAgIC0gJ0lOU1RBTkNFX1NFQ1JFVD0ke1NFUlZJQ0VfSEVYXzMyX1NFQ1JFVH0nCiAgICAgIC0gJ0NPTlZFWF9SRUxFQVNFX1ZFUlNJT05fREVWPSR7Q09OVkVYX1JFTEVBU0VfVkVSU0lPTl9ERVY6LX0nCiAgICAgIC0gJ0FDVElPTlNfVVNFUl9USU1FT1VUX1NFQ1M9JHtBQ1RJT05TX1VTRVJfVElNRU9VVF9TRUNTOi19JwogICAgICAtICdDT05WRVhfQ0xPVURfT1JJR0lOPSR7U0VSVklDRV9GUUROX0RBU0hCT0FSRH0nCiAgICAgIC0gJ0NPTlZFWF9TSVRFX09SSUdJTj0ke1NFUlZJQ0VfRlFETl9CQUNLRU5EfScKICAgICAgLSAnREFUQUJBU0VfVVJMPSR7REFUQUJBU0VfVVJMOi19JwogICAgICAtICdESVNBQkxFX0JFQUNPTj0ke0RJU0FCTEVfQkVBQ09OOj9mYWxzZX0nCiAgICAgIC0gJ1JFREFDVF9MT0dTX1RPX0NMSUVOVD0ke1JFREFDVF9MT0dTX1RPX0NMSUVOVDo/ZmFsc2V9JwogICAgICAtICdET19OT1RfUkVRVUlSRV9TU0w9JHtET19OT1RfUkVRVUlSRV9TU0w6P3RydWV9JwogICAgICAtICdQT1NUR1JFU19VUkw9JHtQT1NUR1JFU19VUkw6LX0nCiAgICAgIC0gJ01ZU1FMX1VSTD0ke01ZU1FMX1VSTDotfScKICAgICAgLSAnUlVTVF9MT0c9JHtSVVNUX0xPRzotaW5mb30nCiAgICAgIC0gJ1JVU1RfQkFDS1RSQUNFPSR7UlVTVF9CQUNLVFJBQ0U6LX0nCiAgICAgIC0gJ0FXU19SRUdJT049JHtBV1NfUkVHSU9OOi19JwogICAgICAtICdBV1NfQUNDRVNTX0tFWV9JRD0ke0FXU19BQ0NFU1NfS0VZX0lEOi19JwogICAgICAtICdBV1NfU0VDUkVUX0FDQ0VTU19LRVk9JHtBV1NfU0VDUkVUX0FDQ0VTU19LRVk6LX0nCiAgICAgIC0gJ0FXU19TRVNTSU9OX1RPS0VOPSR7QVdTX1NFU1NJT05fVE9LRU46LX0nCiAgICAgIC0gJ0FXU19TM19GT1JDRV9QQVRIX1NUWUxFPSR7QVdTX1MzX0ZPUkNFX1BBVEhfU1RZTEU6LX0nCiAgICAgIC0gJ0FXU19TM19ESVNBQkxFX1NTRT0ke0FXU19TM19ESVNBQkxFX1NTRTotfScKICAgICAgLSAnQVdTX1MzX0RJU0FCTEVfQ0hFQ0tTVU1TPSR7QVdTX1MzX0RJU0FCTEVfQ0hFQ0tTVU1TOi19JwogICAgICAtICdTM19TVE9SQUdFX0VYUE9SVFNfQlVDS0VUPSR7UzNfU1RPUkFHRV9FWFBPUlRTX0JVQ0tFVDotfScKICAgICAgLSAnUzNfU1RPUkFHRV9TTkFQU0hPVF9JTVBPUlRTX0JVQ0tFVD0ke1MzX1NUT1JBR0VfU05BUFNIT1RfSU1QT1JUU19CVUNLRVQ6LX0nCiAgICAgIC0gJ1MzX1NUT1JBR0VfTU9EVUxFU19CVUNLRVQ9JHtTM19TVE9SQUdFX01PRFVMRVNfQlVDS0VUOi19JwogICAgICAtICdTM19TVE9SQUdFX0ZJTEVTX0JVQ0tFVD0ke1MzX1NUT1JBR0VfRklMRVNfQlVDS0VUOi19JwogICAgICAtICdTM19TVE9SQUdFX1NFQVJDSF9CVUNLRVQ9JHtTM19TVE9SQUdFX1NFQVJDSF9CVUNLRVQ6LX0nCiAgICAgIC0gJ1MzX0VORFBPSU5UX1VSTD0ke1MzX0VORFBPSU5UX1VSTDotfScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OiAnY3VybCAtZiBodHRwOi8vMTI3LjAuMC4xOjMyMTAvdmVyc2lvbicKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHN0YXJ0X3BlcmlvZDogMTBzCiAgZGFzaGJvYXJkOgogICAgaW1hZ2U6ICdnaGNyLmlvL2dldC1jb252ZXgvY29udmV4LWRhc2hib2FyZDphOWE3NjBjYTEwMzk5ZWQ0MmUxYjRiYjg3Yzc4NTM5YTIzNTQ4OGM3JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0RBU0hCT0FSRF82NzkxCiAgICAgIC0gJ05FWFRfUFVCTElDX0RFUExPWU1FTlRfVVJMPSR7U0VSVklDRV9GUUROX0JBQ0tFTkR9JwogICAgZGVwZW5kc19vbjoKICAgICAgYmFja2VuZDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6ICdjdXJsIC1mIGh0dHA6Ly8xMjcuMC4wLjE6Njc5MS8nCiAgICAgIGludGVydmFsOiA1cwogICAgICBzdGFydF9wZXJpb2Q6IDVzCg==", + "compose": "c2VydmljZXM6CiAgYmFja2VuZDoKICAgIGltYWdlOiAnZ2hjci5pby9nZXQtY29udmV4L2NvbnZleC1iYWNrZW5kOmE5YTc2MGNhMTAzOTllZDQyZTFiNGJiODdjNzg1MzlhMjM1NDg4YzcnCiAgICB2b2x1bWVzOgogICAgICAtICdkYXRhOi9jb252ZXgvZGF0YScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9CQUNLRU5EXzMyMTAKICAgICAgLSAnSU5TVEFOQ0VfTkFNRT0ke0lOU1RBTkNFX05BTUU6LXNlbGYtaG9zdGVkLWNvbnZleH0nCiAgICAgIC0gJ0lOU1RBTkNFX1NFQ1JFVD0ke1NFUlZJQ0VfSEVYXzY0X1NFQ1JFVH0nCiAgICAgIC0gJ0NPTlZFWF9SRUxFQVNFX1ZFUlNJT05fREVWPSR7Q09OVkVYX1JFTEVBU0VfVkVSU0lPTl9ERVY6LX0nCiAgICAgIC0gJ0FDVElPTlNfVVNFUl9USU1FT1VUX1NFQ1M9JHtBQ1RJT05TX1VTRVJfVElNRU9VVF9TRUNTOi19JwogICAgICAtICdDT05WRVhfQ0xPVURfT1JJR0lOPSR7U0VSVklDRV9GUUROX0RBU0hCT0FSRH0nCiAgICAgIC0gJ0NPTlZFWF9TSVRFX09SSUdJTj0ke1NFUlZJQ0VfRlFETl9CQUNLRU5EfScKICAgICAgLSAnREFUQUJBU0VfVVJMPSR7REFUQUJBU0VfVVJMOi19JwogICAgICAtICdESVNBQkxFX0JFQUNPTj0ke0RJU0FCTEVfQkVBQ09OOj9mYWxzZX0nCiAgICAgIC0gJ1JFREFDVF9MT0dTX1RPX0NMSUVOVD0ke1JFREFDVF9MT0dTX1RPX0NMSUVOVDo/ZmFsc2V9JwogICAgICAtICdET19OT1RfUkVRVUlSRV9TU0w9JHtET19OT1RfUkVRVUlSRV9TU0w6P3RydWV9JwogICAgICAtICdQT1NUR1JFU19VUkw9JHtQT1NUR1JFU19VUkw6LX0nCiAgICAgIC0gJ01ZU1FMX1VSTD0ke01ZU1FMX1VSTDotfScKICAgICAgLSAnUlVTVF9MT0c9JHtSVVNUX0xPRzotaW5mb30nCiAgICAgIC0gJ1JVU1RfQkFDS1RSQUNFPSR7UlVTVF9CQUNLVFJBQ0U6LX0nCiAgICAgIC0gJ0FXU19SRUdJT049JHtBV1NfUkVHSU9OOi19JwogICAgICAtICdBV1NfQUNDRVNTX0tFWV9JRD0ke0FXU19BQ0NFU1NfS0VZX0lEOi19JwogICAgICAtICdBV1NfU0VDUkVUX0FDQ0VTU19LRVk9JHtBV1NfU0VDUkVUX0FDQ0VTU19LRVk6LX0nCiAgICAgIC0gJ0FXU19TRVNTSU9OX1RPS0VOPSR7QVdTX1NFU1NJT05fVE9LRU46LX0nCiAgICAgIC0gJ0FXU19TM19GT1JDRV9QQVRIX1NUWUxFPSR7QVdTX1MzX0ZPUkNFX1BBVEhfU1RZTEU6LX0nCiAgICAgIC0gJ0FXU19TM19ESVNBQkxFX1NTRT0ke0FXU19TM19ESVNBQkxFX1NTRTotfScKICAgICAgLSAnQVdTX1MzX0RJU0FCTEVfQ0hFQ0tTVU1TPSR7QVdTX1MzX0RJU0FCTEVfQ0hFQ0tTVU1TOi19JwogICAgICAtICdTM19TVE9SQUdFX0VYUE9SVFNfQlVDS0VUPSR7UzNfU1RPUkFHRV9FWFBPUlRTX0JVQ0tFVDotfScKICAgICAgLSAnUzNfU1RPUkFHRV9TTkFQU0hPVF9JTVBPUlRTX0JVQ0tFVD0ke1MzX1NUT1JBR0VfU05BUFNIT1RfSU1QT1JUU19CVUNLRVQ6LX0nCiAgICAgIC0gJ1MzX1NUT1JBR0VfTU9EVUxFU19CVUNLRVQ9JHtTM19TVE9SQUdFX01PRFVMRVNfQlVDS0VUOi19JwogICAgICAtICdTM19TVE9SQUdFX0ZJTEVTX0JVQ0tFVD0ke1MzX1NUT1JBR0VfRklMRVNfQlVDS0VUOi19JwogICAgICAtICdTM19TVE9SQUdFX1NFQVJDSF9CVUNLRVQ9JHtTM19TVE9SQUdFX1NFQVJDSF9CVUNLRVQ6LX0nCiAgICAgIC0gJ1MzX0VORFBPSU5UX1VSTD0ke1MzX0VORFBPSU5UX1VSTDotfScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OiAnY3VybCAtZiBodHRwOi8vMTI3LjAuMC4xOjMyMTAvdmVyc2lvbicKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHN0YXJ0X3BlcmlvZDogMTBzCiAgZGFzaGJvYXJkOgogICAgaW1hZ2U6ICdnaGNyLmlvL2dldC1jb252ZXgvY29udmV4LWRhc2hib2FyZDphOWE3NjBjYTEwMzk5ZWQ0MmUxYjRiYjg3Yzc4NTM5YTIzNTQ4OGM3JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0RBU0hCT0FSRF82NzkxCiAgICAgIC0gJ05FWFRfUFVCTElDX0RFUExPWU1FTlRfVVJMPSR7U0VSVklDRV9GUUROX0JBQ0tFTkR9JwogICAgZGVwZW5kc19vbjoKICAgICAgYmFja2VuZDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6ICdjdXJsIC1mIGh0dHA6Ly8xMjcuMC4wLjE6Njc5MS8nCiAgICAgIGludGVydmFsOiA1cwogICAgICBzdGFydF9wZXJpb2Q6IDVzCg==", "tags": [ "database", "reactive", @@ -1608,7 +1608,7 @@ "getoutline": { "documentation": "https://docs.getoutline.com/s/hosting/doc/hosting-outline-nipGaCRBDu?utm_source=coolify.io", "slogan": "Your team\u2019s knowledge base", - "compose": "c2VydmljZXM6CiAgb3V0bGluZToKICAgIGltYWdlOiAnZG9ja2VyLmdldG91dGxpbmUuY29tL291dGxpbmV3aWtpL291dGxpbmU6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAnc3RvcmFnZS1kYXRhOi92YXIvbGliL291dGxpbmUvZGF0YScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fT1VUTElORV8zMDAwCiAgICAgIC0gTk9ERV9FTlY9cHJvZHVjdGlvbgogICAgICAtICdTRUNSRVRfS0VZPSR7U0VSVklDRV9IRVhfMzJfT1VUTElORX0nCiAgICAgIC0gJ1VUSUxTX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfNjRfT1VUTElORX0nCiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfNjRfUE9TVEdSRVN9QHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU19EQVRBQkFTRTotb3V0bGluZX0nCiAgICAgIC0gJ1JFRElTX1VSTD1yZWRpczovLzoke1NFUlZJQ0VfUEFTU1dPUkRfNjRfUkVESVN9QHJlZGlzOjYzNzknCiAgICAgIC0gJ1VSTD0ke1NFUlZJQ0VfRlFETl9PVVRMSU5FfScKICAgICAgLSAnUE9SVD0ke09VVExJTkVfUE9SVDotMzAwMH0nCiAgICAgIC0gJ0ZJTEVfU1RPUkFHRT0ke0ZJTEVfU1RPUkFHRTotbG9jYWx9JwogICAgICAtICdGSUxFX1NUT1JBR0VfTE9DQUxfUk9PVF9ESVI9JHtGSUxFX1NUT1JBR0VfTE9DQUxfUk9PVF9ESVI6LS92YXIvbGliL291dGxpbmUvZGF0YX0nCiAgICAgIC0gJ0ZJTEVfU1RPUkFHRV9VUExPQURfTUFYX1NJWkU9JHtGSUxFX1NUT1JBR0VfVVBMT0FEX01BWF9TSVpFOi0yMDAwfScKICAgICAgLSAnRklMRV9TVE9SQUdFX0lNUE9SVF9NQVhfU0laRT0ke0ZJTEVfU1RPUkFHRV9JTVBPUlRfTUFYX1NJWkU6LTEwMH0nCiAgICAgIC0gJ0ZJTEVfU1RPUkFHRV9XT1JLU1BBQ0VfSU1QT1JUX01BWF9TSVpFPSR7RklMRV9TVE9SQUdFX1dPUktTUEFDRV9JTVBPUlRfTUFYX1NJWkV9JwogICAgICAtICdBV1NfQUNDRVNTX0tFWV9JRD0ke0FXU19BQ0NFU1NfS0VZX0lEfScKICAgICAgLSAnQVdTX1NFQ1JFVF9BQ0NFU1NfS0VZPSR7QVdTX1NFQ1JFVF9BQ0NFU1NfS0VZfScKICAgICAgLSAnQVdTX1JFR0lPTj0ke0FXU19SRUdJT059JwogICAgICAtICdBV1NfUzNfQUNDRUxFUkFURV9VUkw9JHtBV1NfUzNfQUNDRUxFUkFURV9VUkx9JwogICAgICAtICdBV1NfUzNfVVBMT0FEX0JVQ0tFVF9VUkw9JHtBV1NfUzNfVVBMT0FEX0JVQ0tFVF9VUkx9JwogICAgICAtICdBV1NfUzNfVVBMT0FEX0JVQ0tFVF9OQU1FPSR7QVdTX1MzX1VQTE9BRF9CVUNLRVRfTkFNRX0nCiAgICAgIC0gJ0FXU19TM19GT1JDRV9QQVRIX1NUWUxFPSR7QVdTX1MzX0ZPUkNFX1BBVEhfU1RZTEU6LXRydWV9JwogICAgICAtICdBV1NfUzNfQUNMPSR7QVdTX1MzX0FDTDotcHJpdmF0ZX0nCiAgICAgIC0gJ1NMQUNLX0NMSUVOVF9JRD0ke1NMQUNLX0NMSUVOVF9JRH0nCiAgICAgIC0gJ1NMQUNLX0NMSUVOVF9TRUNSRVQ9JHtTTEFDS19DTElFTlRfU0VDUkVUfScKICAgICAgLSAnR09PR0xFX0NMSUVOVF9JRD0ke0dPT0dMRV9DTElFTlRfSUR9JwogICAgICAtICdHT09HTEVfQ0xJRU5UX1NFQ1JFVD0ke0dPT0dMRV9DTElFTlRfU0VDUkVUfScKICAgICAgLSAnQVpVUkVfQ0xJRU5UX0lEPSR7QVpVUkVfQ0xJRU5UX0lEfScKICAgICAgLSAnQVpVUkVfQ0xJRU5UX1NFQ1JFVD0ke0FaVVJFX0NMSUVOVF9TRUNSRVR9JwogICAgICAtICdBWlVSRV9SRVNPVVJDRV9BUFBfSUQ9JHtBWlVSRV9SRVNPVVJDRV9BUFBfSUR9JwogICAgICAtICdPSURDX0NMSUVOVF9JRD0ke09JRENfQ0xJRU5UX0lEfScKICAgICAgLSAnT0lEQ19DTElFTlRfU0VDUkVUPSR7T0lEQ19DTElFTlRfU0VDUkVUfScKICAgICAgLSAnT0lEQ19BVVRIX1VSST0ke09JRENfQVVUSF9VUkl9JwogICAgICAtICdPSURDX1RPS0VOX1VSST0ke09JRENfVE9LRU5fVVJJfScKICAgICAgLSAnT0lEQ19VU0VSSU5GT19VUkk9JHtPSURDX1VTRVJJTkZPX1VSSX0nCiAgICAgIC0gJ09JRENfTE9HT1VUX1VSST0ke09JRENfTE9HT1VUX1VSSX0nCiAgICAgIC0gJ09JRENfVVNFUk5BTUVfQ0xBSU09JHtPSURDX1VTRVJOQU1FX0NMQUlNfScKICAgICAgLSAnT0lEQ19ESVNQTEFZX05BTUU9JHtPSURDX0RJU1BMQVlfTkFNRX0nCiAgICAgIC0gJ09JRENfU0NPUEVTPSR7T0lEQ19TQ09QRVN9JwogICAgICAtICdHSVRIVUJfQ0xJRU5UX0lEPSR7R0lUSFVCX0NMSUVOVF9JRH0nCiAgICAgIC0gJ0dJVEhVQl9DTElFTlRfU0VDUkVUPSR7R0lUSFVCX0NMSUVOVF9TRUNSRVR9JwogICAgICAtICdHSVRIVUJfQVBQX05BTUU9JHtHSVRIVUJfQVBQX05BTUV9JwogICAgICAtICdHSVRIVUJfQVBQX0lEPSR7R0lUSFVCX0FQUF9JRH0nCiAgICAgIC0gJ0dJVEhVQl9BUFBfUFJJVkFURV9LRVk9JHtHSVRIVUJfQVBQX1BSSVZBVEVfS0VZfScKICAgICAgLSAnRElTQ09SRF9DTElFTlRfSUQ9JHtESVNDT1JEX0NMSUVOVF9JRH0nCiAgICAgIC0gJ0RJU0NPUkRfQ0xJRU5UX1NFQ1JFVD0ke0RJU0NPUkRfQ0xJRU5UX1NFQ1JFVH0nCiAgICAgIC0gJ0RJU0NPUkRfU0VSVkVSX0lEPSR7RElTQ09SRF9TRVJWRVJfSUR9JwogICAgICAtICdESVNDT1JEX1NFUlZFUl9ST0xFUz0ke0RJU0NPUkRfU0VSVkVSX1JPTEVTfScKICAgICAgLSAnUEdTU0xNT0RFPSR7UEdTU0xNT0RFOi1kaXNhYmxlfScKICAgICAgLSAnRk9SQ0VfSFRUUFM9JHtGT1JDRV9IVFRQUzotdHJ1ZX0nCiAgICAgIC0gJ1NNVFBfSE9TVD0ke1NNVFBfSE9TVH0nCiAgICAgIC0gJ1NNVFBfUE9SVD0ke1NNVFBfUE9SVH0nCiAgICAgIC0gJ1NNVFBfVVNFUk5BTUU9JHtTTVRQX1VTRVJOQU1FfScKICAgICAgLSAnU01UUF9QQVNTV09SRD0ke1NNVFBfUEFTU1dPUkR9JwogICAgICAtICdTTVRQX0ZST01fRU1BSUw9JHtTTVRQX0ZST01fRU1BSUx9JwogICAgICAtICdTTVRQX1JFUExZX0VNQUlMPSR7U01UUF9SRVBMWV9FTUFJTH0nCiAgICAgIC0gJ1NNVFBfVExTX0NJUEhFUlM9JHtTTVRQX1RMU19DSVBIRVJTfScKICAgICAgLSAnU01UUF9TRUNVUkU9JHtTTVRQX1NFQ1VSRX0nCiAgICAgIC0gJ1NNVFBfTkFNRT0ke1NNVFBfTkFNRX0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgZGlzYWJsZTogdHJ1ZQogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczphbHBpbmUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUkVESVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEXzY0X1JFRElTfScKICAgIGNvbW1hbmQ6CiAgICAgIC0gcmVkaXMtc2VydmVyCiAgICAgIC0gJy0tcmVxdWlyZXBhc3MnCiAgICAgIC0gJyR7U0VSVklDRV9QQVNTV09SRF82NF9SRURJU30nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSAnLWEnCiAgICAgICAgLSAnJHtTRVJWSUNFX1BBU1NXT1JEXzY0X1JFRElTfScKICAgICAgICAtIFBJTkcKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiAzMHMKICAgICAgcmV0cmllczogMwogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxMi1hbHBpbmUnCiAgICB2b2x1bWVzOgogICAgICAtICdkYXRhYmFzZS1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEXzY0X1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQVRBQkFTRTotb3V0bGluZX0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcGdfaXNyZWFkeQogICAgICAgIC0gJy1VJwogICAgICAgIC0gJyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgICAtICctZCcKICAgICAgICAtICcke1BPU1RHUkVTX0RBVEFCQVNFOi1vdXRsaW5lfScKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMwo=", + "compose": "c2VydmljZXM6CiAgb3V0bGluZToKICAgIGltYWdlOiAnZG9ja2VyLmdldG91dGxpbmUuY29tL291dGxpbmV3aWtpL291dGxpbmU6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAnc3RvcmFnZS1kYXRhOi92YXIvbGliL291dGxpbmUvZGF0YScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fT1VUTElORV8zMDAwCiAgICAgIC0gTk9ERV9FTlY9cHJvZHVjdGlvbgogICAgICAtICdTRUNSRVRfS0VZPSR7U0VSVklDRV9IRVhfNjRfT1VUTElORX0nCiAgICAgIC0gJ1VUSUxTX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfNjRfT1VUTElORX0nCiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfNjRfUE9TVEdSRVN9QHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU19EQVRBQkFTRTotb3V0bGluZX0nCiAgICAgIC0gJ1JFRElTX1VSTD1yZWRpczovLzoke1NFUlZJQ0VfUEFTU1dPUkRfNjRfUkVESVN9QHJlZGlzOjYzNzknCiAgICAgIC0gJ1VSTD0ke1NFUlZJQ0VfRlFETl9PVVRMSU5FfScKICAgICAgLSAnUE9SVD0ke09VVExJTkVfUE9SVDotMzAwMH0nCiAgICAgIC0gJ0ZJTEVfU1RPUkFHRT0ke0ZJTEVfU1RPUkFHRTotbG9jYWx9JwogICAgICAtICdGSUxFX1NUT1JBR0VfTE9DQUxfUk9PVF9ESVI9JHtGSUxFX1NUT1JBR0VfTE9DQUxfUk9PVF9ESVI6LS92YXIvbGliL291dGxpbmUvZGF0YX0nCiAgICAgIC0gJ0ZJTEVfU1RPUkFHRV9VUExPQURfTUFYX1NJWkU9JHtGSUxFX1NUT1JBR0VfVVBMT0FEX01BWF9TSVpFOi0yMDAwfScKICAgICAgLSAnRklMRV9TVE9SQUdFX0lNUE9SVF9NQVhfU0laRT0ke0ZJTEVfU1RPUkFHRV9JTVBPUlRfTUFYX1NJWkU6LTEwMH0nCiAgICAgIC0gJ0ZJTEVfU1RPUkFHRV9XT1JLU1BBQ0VfSU1QT1JUX01BWF9TSVpFPSR7RklMRV9TVE9SQUdFX1dPUktTUEFDRV9JTVBPUlRfTUFYX1NJWkV9JwogICAgICAtICdBV1NfQUNDRVNTX0tFWV9JRD0ke0FXU19BQ0NFU1NfS0VZX0lEfScKICAgICAgLSAnQVdTX1NFQ1JFVF9BQ0NFU1NfS0VZPSR7QVdTX1NFQ1JFVF9BQ0NFU1NfS0VZfScKICAgICAgLSAnQVdTX1JFR0lPTj0ke0FXU19SRUdJT059JwogICAgICAtICdBV1NfUzNfQUNDRUxFUkFURV9VUkw9JHtBV1NfUzNfQUNDRUxFUkFURV9VUkx9JwogICAgICAtICdBV1NfUzNfVVBMT0FEX0JVQ0tFVF9VUkw9JHtBV1NfUzNfVVBMT0FEX0JVQ0tFVF9VUkx9JwogICAgICAtICdBV1NfUzNfVVBMT0FEX0JVQ0tFVF9OQU1FPSR7QVdTX1MzX1VQTE9BRF9CVUNLRVRfTkFNRX0nCiAgICAgIC0gJ0FXU19TM19GT1JDRV9QQVRIX1NUWUxFPSR7QVdTX1MzX0ZPUkNFX1BBVEhfU1RZTEU6LXRydWV9JwogICAgICAtICdBV1NfUzNfQUNMPSR7QVdTX1MzX0FDTDotcHJpdmF0ZX0nCiAgICAgIC0gJ1NMQUNLX0NMSUVOVF9JRD0ke1NMQUNLX0NMSUVOVF9JRH0nCiAgICAgIC0gJ1NMQUNLX0NMSUVOVF9TRUNSRVQ9JHtTTEFDS19DTElFTlRfU0VDUkVUfScKICAgICAgLSAnR09PR0xFX0NMSUVOVF9JRD0ke0dPT0dMRV9DTElFTlRfSUR9JwogICAgICAtICdHT09HTEVfQ0xJRU5UX1NFQ1JFVD0ke0dPT0dMRV9DTElFTlRfU0VDUkVUfScKICAgICAgLSAnQVpVUkVfQ0xJRU5UX0lEPSR7QVpVUkVfQ0xJRU5UX0lEfScKICAgICAgLSAnQVpVUkVfQ0xJRU5UX1NFQ1JFVD0ke0FaVVJFX0NMSUVOVF9TRUNSRVR9JwogICAgICAtICdBWlVSRV9SRVNPVVJDRV9BUFBfSUQ9JHtBWlVSRV9SRVNPVVJDRV9BUFBfSUR9JwogICAgICAtICdPSURDX0NMSUVOVF9JRD0ke09JRENfQ0xJRU5UX0lEfScKICAgICAgLSAnT0lEQ19DTElFTlRfU0VDUkVUPSR7T0lEQ19DTElFTlRfU0VDUkVUfScKICAgICAgLSAnT0lEQ19BVVRIX1VSST0ke09JRENfQVVUSF9VUkl9JwogICAgICAtICdPSURDX1RPS0VOX1VSST0ke09JRENfVE9LRU5fVVJJfScKICAgICAgLSAnT0lEQ19VU0VSSU5GT19VUkk9JHtPSURDX1VTRVJJTkZPX1VSSX0nCiAgICAgIC0gJ09JRENfTE9HT1VUX1VSST0ke09JRENfTE9HT1VUX1VSSX0nCiAgICAgIC0gJ09JRENfVVNFUk5BTUVfQ0xBSU09JHtPSURDX1VTRVJOQU1FX0NMQUlNfScKICAgICAgLSAnT0lEQ19ESVNQTEFZX05BTUU9JHtPSURDX0RJU1BMQVlfTkFNRX0nCiAgICAgIC0gJ09JRENfU0NPUEVTPSR7T0lEQ19TQ09QRVN9JwogICAgICAtICdHSVRIVUJfQ0xJRU5UX0lEPSR7R0lUSFVCX0NMSUVOVF9JRH0nCiAgICAgIC0gJ0dJVEhVQl9DTElFTlRfU0VDUkVUPSR7R0lUSFVCX0NMSUVOVF9TRUNSRVR9JwogICAgICAtICdHSVRIVUJfQVBQX05BTUU9JHtHSVRIVUJfQVBQX05BTUV9JwogICAgICAtICdHSVRIVUJfQVBQX0lEPSR7R0lUSFVCX0FQUF9JRH0nCiAgICAgIC0gJ0dJVEhVQl9BUFBfUFJJVkFURV9LRVk9JHtHSVRIVUJfQVBQX1BSSVZBVEVfS0VZfScKICAgICAgLSAnRElTQ09SRF9DTElFTlRfSUQ9JHtESVNDT1JEX0NMSUVOVF9JRH0nCiAgICAgIC0gJ0RJU0NPUkRfQ0xJRU5UX1NFQ1JFVD0ke0RJU0NPUkRfQ0xJRU5UX1NFQ1JFVH0nCiAgICAgIC0gJ0RJU0NPUkRfU0VSVkVSX0lEPSR7RElTQ09SRF9TRVJWRVJfSUR9JwogICAgICAtICdESVNDT1JEX1NFUlZFUl9ST0xFUz0ke0RJU0NPUkRfU0VSVkVSX1JPTEVTfScKICAgICAgLSAnUEdTU0xNT0RFPSR7UEdTU0xNT0RFOi1kaXNhYmxlfScKICAgICAgLSAnRk9SQ0VfSFRUUFM9JHtGT1JDRV9IVFRQUzotdHJ1ZX0nCiAgICAgIC0gJ1NNVFBfSE9TVD0ke1NNVFBfSE9TVH0nCiAgICAgIC0gJ1NNVFBfUE9SVD0ke1NNVFBfUE9SVH0nCiAgICAgIC0gJ1NNVFBfVVNFUk5BTUU9JHtTTVRQX1VTRVJOQU1FfScKICAgICAgLSAnU01UUF9QQVNTV09SRD0ke1NNVFBfUEFTU1dPUkR9JwogICAgICAtICdTTVRQX0ZST01fRU1BSUw9JHtTTVRQX0ZST01fRU1BSUx9JwogICAgICAtICdTTVRQX1JFUExZX0VNQUlMPSR7U01UUF9SRVBMWV9FTUFJTH0nCiAgICAgIC0gJ1NNVFBfVExTX0NJUEhFUlM9JHtTTVRQX1RMU19DSVBIRVJTfScKICAgICAgLSAnU01UUF9TRUNVUkU9JHtTTVRQX1NFQ1VSRX0nCiAgICAgIC0gJ1NNVFBfTkFNRT0ke1NNVFBfTkFNRX0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgZGlzYWJsZTogdHJ1ZQogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczphbHBpbmUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUkVESVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEXzY0X1JFRElTfScKICAgIGNvbW1hbmQ6CiAgICAgIC0gcmVkaXMtc2VydmVyCiAgICAgIC0gJy0tcmVxdWlyZXBhc3MnCiAgICAgIC0gJyR7U0VSVklDRV9QQVNTV09SRF82NF9SRURJU30nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSAnLWEnCiAgICAgICAgLSAnJHtTRVJWSUNFX1BBU1NXT1JEXzY0X1JFRElTfScKICAgICAgICAtIFBJTkcKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiAzMHMKICAgICAgcmV0cmllczogMwogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxMi1hbHBpbmUnCiAgICB2b2x1bWVzOgogICAgICAtICdkYXRhYmFzZS1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEXzY0X1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQVRBQkFTRTotb3V0bGluZX0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcGdfaXNyZWFkeQogICAgICAgIC0gJy1VJwogICAgICAgIC0gJyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgICAtICctZCcKICAgICAgICAtICcke1BPU1RHUkVTX0RBVEFCQVNFOi1vdXRsaW5lfScKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMwo=", "tags": [ "knowledge base", "documentation" @@ -2000,7 +2000,7 @@ "homarr": { "documentation": "https://homarr.dev?utm_source=coolify.io", "slogan": "Homarr is a self-hosted homepage for your services.", - "compose": "c2VydmljZXM6CiAgaG9tYXJyOgogICAgaW1hZ2U6ICdnaGNyLmlvL2hvbWFyci1sYWJzL2hvbWFycjp2MS40MC4wJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0hPTUFSUl83NTc1CiAgICAgIC0gU0VSVklDRV9IRVhfMzJfSE9NQVJSCiAgICAgIC0gJ1NFQ1JFVF9FTkNSWVBUSU9OX0tFWT0ke1NFUlZJQ0VfSEVYXzMyX0hPTUFSUn0nCiAgICB2b2x1bWVzOgogICAgICAtICcvdmFyL3J1bi9kb2NrZXIuc29jazovdmFyL3J1bi9kb2NrZXIuc29jaycKICAgICAgLSAnLi9ob21hcnIvYXBwZGF0YTovYXBwZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSB3Z2V0CiAgICAgICAgLSAnLXEnCiAgICAgICAgLSAnLS1zcGlkZXInCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo3NTc1JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==", + "compose": "c2VydmljZXM6CiAgaG9tYXJyOgogICAgaW1hZ2U6ICdnaGNyLmlvL2hvbWFyci1sYWJzL2hvbWFycjp2MS40MC4wJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0hPTUFSUl83NTc1CiAgICAgIC0gJ1NFQ1JFVF9FTkNSWVBUSU9OX0tFWT0ke1NFUlZJQ0VfSEVYXzY0X0hPTUFSUn0nCiAgICB2b2x1bWVzOgogICAgICAtICcvdmFyL3J1bi9kb2NrZXIuc29jazovdmFyL3J1bi9kb2NrZXIuc29jaycKICAgICAgLSAnLi9ob21hcnIvYXBwZGF0YTovYXBwZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSB3Z2V0CiAgICAgICAgLSAnLXEnCiAgICAgICAgLSAnLS1zcGlkZXInCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo3NTc1JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==", "tags": [ "homarr", "self-hosted", @@ -3408,7 +3408,7 @@ "open-archiver": { "documentation": "https://docs.openarchiver.com/?utm_source=coolify.io", "slogan": "A self-hosted, open-source email archiving solution with full-text search capability.", - "compose": "c2VydmljZXM6CiAgb3Blbi1hcmNoaXZlcjoKICAgIGltYWdlOiAnbG9naWNsYWJzaHEvb3Blbi1hcmNoaXZlcjpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fT1BFTkFSQ0hJVkVSXzMwMDAKICAgICAgLSAnRU5DUllQVElPTl9LRVk9JHtTRVJWSUNFX0hFWF8zMl9FTkNSWVBUSU9OS0VZfScKICAgICAgLSAnU1RPUkFHRV9FTkNSWVBUSU9OX0tFWT0ke1NFUlZJQ0VfSEVYXzMyX1NUT1JBR0VFTkNSWVBUSU9OS0VZfScKICAgICAgLSAnUE9SVF9CQUNLRU5EPSR7UE9SVF9CQUNLRU5EOi00MDAwfScKICAgICAgLSAnUE9SVF9GUk9OVEVORD0ke1BPUlRfRlJPTlRFTkQ6LTMwMDB9JwogICAgICAtICdOT0RFX0VOVj0ke05PREVfRU5WOi1wcm9kdWN0aW9ufScKICAgICAgLSAnU1lOQ19GUkVRVUVOQ1k9JHtTWU5DX0ZSRVFVRU5DWTotKiAqICogKiAqfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotb3Blbl9hcmNoaXZlfScKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3Jlc3FsOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AcG9zdGdyZXM6NTQzMi8ke1BPU1RHUkVTX0RCOi1vcGVuLWFyY2hpdmVyLWRifScKICAgICAgLSAnTUVJTElfTUFTVEVSX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfTUVJTElTRUFSQ0h9JwogICAgICAtICdNRUlMSV9IT1NUPWh0dHA6Ly9tZWlsaXNlYXJjaDo3NzAwJwogICAgICAtIFJFRElTX0hPU1Q9dmFsa2V5CiAgICAgIC0gUkVESVNfUE9SVD02Mzc5CiAgICAgIC0gJ1JFRElTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9WQUxLRVl9JwogICAgICAtIFJFRElTX1RMU19FTkFCTEVEPWZhbHNlCiAgICAgIC0gJ1NUT1JBR0VfVFlQRT0ke1NUT1JBR0VfVFlQRTotbG9jYWx9JwogICAgICAtICdTVE9SQUdFX0xPQ0FMX1JPT1RfUEFUSD0ke1NUT1JBR0VfTE9DQUxfUk9PVF9QQVRIOi0vdmFyL2RhdGEvb3Blbi1hcmNoaXZlcn0nCiAgICAgIC0gJ0JPRFlfU0laRV9MSU1JVD0ke0JPRFlfU0laRV9MSU1JVDotMTAwTX0nCiAgICAgIC0gJ1NUT1JBR0VfUzNfRU5EUE9JTlQ9JHtTVE9SQUdFX1MzX0VORFBPSU5UfScKICAgICAgLSAnU1RPUkFHRV9TM19CVUNLRVQ9JHtTVE9SQUdFX1MzX0JVQ0tFVH0nCiAgICAgIC0gJ1NUT1JBR0VfUzNfQUNDRVNTX0tFWV9JRD0ke1NUT1JBR0VfUzNfQUNDRVNTX0tFWV9JRH0nCiAgICAgIC0gJ1NUT1JBR0VfUzNfU0VDUkVUX0FDQ0VTU19LRVk9JHtTVE9SQUdFX1MzX1NFQ1JFVF9BQ0NFU1NfS0VZfScKICAgICAgLSAnU1RPUkFHRV9TM19SRUdJT049JHtTVE9SQUdFX1MzX1JFR0lPTn0nCiAgICAgIC0gJ1NUT1JBR0VfUzNfRk9SQ0VfUEFUSF9TVFlMRT0ke1NUT1JBR0VfUzNfRk9SQ0VfUEFUSF9TVFlMRTotZmFsc2V9JwogICAgICAtICdKV1RfU0VDUkVUPSR7U0VSVklDRV9CQVNFNjRfMTI4X0pXVH0nCiAgICAgIC0gJ0pXVF9FWFBJUkVTX0lOPSR7SldUX0VYUElSRVNfSU46LTdkfScKICAgICAgLSAnUkFURV9MSU1JVF9XSU5ET1dfTVM9JHtSQVRFX0xJTUlUX1dJTkRPV19NUzotNjAwMDB9JwogICAgICAtICdSQVRFX0xJTUlUX01BWF9SRVFVRVNUUz0ke1JBVEVfTElNSVRfTUFYX1JFUVVFU1RTOi0xMDB9JwogICAgdm9sdW1lczoKICAgICAgLSAnYXJjaGl2ZXItZGF0YTovdmFyL2RhdGEvb3Blbi1hcmNoaXZlcicKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHZhbGtleToKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBtZWlsaXNlYXJjaDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNy1hbHBpbmUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotb3Blbi1hcmNoaXZlci1kYn0nCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtIExDX0FMTD1DCiAgICB2b2x1bWVzOgogICAgICAtICdwb3N0Z3Jlcy1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgdmFsa2V5OgogICAgaW1hZ2U6ICd2YWxrZXkvdmFsa2V5OjgtYWxwaW5lJwogICAgY29tbWFuZDogJ3ZhbGtleS1zZXJ2ZXIgLS1yZXF1aXJlcGFzcyAke1NFUlZJQ0VfUEFTU1dPUkRfVkFMS0VZfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3ZhbGtleS1kYXRhOi9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gcGluZwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDMKICBtZWlsaXNlYXJjaDoKICAgIGltYWdlOiAnZ2V0bWVpbGkvbWVpbGlzZWFyY2g6djEuMTUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTUVJTElfTUFTVEVSX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfTUVJTElTRUFSQ0h9JwogICAgdm9sdW1lczoKICAgICAgLSAnbWVpbGlzZWFyY2gtZGF0YTovbWVpbGlfZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo3NzAwL2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQo=", + "compose": "c2VydmljZXM6CiAgb3Blbi1hcmNoaXZlcjoKICAgIGltYWdlOiAnbG9naWNsYWJzaHEvb3Blbi1hcmNoaXZlcjpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fT1BFTkFSQ0hJVkVSXzMwMDAKICAgICAgLSAnRU5DUllQVElPTl9LRVk9JHtTRVJWSUNFX0hFWF82NF9FTkNSWVBUSU9OS0VZfScKICAgICAgLSAnU1RPUkFHRV9FTkNSWVBUSU9OX0tFWT0ke1NFUlZJQ0VfSEVYXzY0X1NUT1JBR0VFTkNSWVBUSU9OS0VZfScKICAgICAgLSAnUE9SVF9CQUNLRU5EPSR7UE9SVF9CQUNLRU5EOi00MDAwfScKICAgICAgLSAnUE9SVF9GUk9OVEVORD0ke1BPUlRfRlJPTlRFTkQ6LTMwMDB9JwogICAgICAtICdOT0RFX0VOVj0ke05PREVfRU5WOi1wcm9kdWN0aW9ufScKICAgICAgLSAnU1lOQ19GUkVRVUVOQ1k9JHtTWU5DX0ZSRVFVRU5DWTotKiAqICogKiAqfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotb3Blbl9hcmNoaXZlfScKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3Jlc3FsOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AcG9zdGdyZXM6NTQzMi8ke1BPU1RHUkVTX0RCOi1vcGVuLWFyY2hpdmVyLWRifScKICAgICAgLSAnTUVJTElfTUFTVEVSX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfTUVJTElTRUFSQ0h9JwogICAgICAtICdNRUlMSV9IT1NUPWh0dHA6Ly9tZWlsaXNlYXJjaDo3NzAwJwogICAgICAtIFJFRElTX0hPU1Q9dmFsa2V5CiAgICAgIC0gUkVESVNfUE9SVD02Mzc5CiAgICAgIC0gJ1JFRElTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9WQUxLRVl9JwogICAgICAtIFJFRElTX1RMU19FTkFCTEVEPWZhbHNlCiAgICAgIC0gJ1NUT1JBR0VfVFlQRT0ke1NUT1JBR0VfVFlQRTotbG9jYWx9JwogICAgICAtICdTVE9SQUdFX0xPQ0FMX1JPT1RfUEFUSD0ke1NUT1JBR0VfTE9DQUxfUk9PVF9QQVRIOi0vdmFyL2RhdGEvb3Blbi1hcmNoaXZlcn0nCiAgICAgIC0gJ0JPRFlfU0laRV9MSU1JVD0ke0JPRFlfU0laRV9MSU1JVDotMTAwTX0nCiAgICAgIC0gJ1NUT1JBR0VfUzNfRU5EUE9JTlQ9JHtTVE9SQUdFX1MzX0VORFBPSU5UfScKICAgICAgLSAnU1RPUkFHRV9TM19CVUNLRVQ9JHtTVE9SQUdFX1MzX0JVQ0tFVH0nCiAgICAgIC0gJ1NUT1JBR0VfUzNfQUNDRVNTX0tFWV9JRD0ke1NUT1JBR0VfUzNfQUNDRVNTX0tFWV9JRH0nCiAgICAgIC0gJ1NUT1JBR0VfUzNfU0VDUkVUX0FDQ0VTU19LRVk9JHtTVE9SQUdFX1MzX1NFQ1JFVF9BQ0NFU1NfS0VZfScKICAgICAgLSAnU1RPUkFHRV9TM19SRUdJT049JHtTVE9SQUdFX1MzX1JFR0lPTn0nCiAgICAgIC0gJ1NUT1JBR0VfUzNfRk9SQ0VfUEFUSF9TVFlMRT0ke1NUT1JBR0VfUzNfRk9SQ0VfUEFUSF9TVFlMRTotZmFsc2V9JwogICAgICAtICdKV1RfU0VDUkVUPSR7U0VSVklDRV9CQVNFNjRfMTI4X0pXVH0nCiAgICAgIC0gJ0pXVF9FWFBJUkVTX0lOPSR7SldUX0VYUElSRVNfSU46LTdkfScKICAgICAgLSAnUkFURV9MSU1JVF9XSU5ET1dfTVM9JHtSQVRFX0xJTUlUX1dJTkRPV19NUzotNjAwMDB9JwogICAgICAtICdSQVRFX0xJTUlUX01BWF9SRVFVRVNUUz0ke1JBVEVfTElNSVRfTUFYX1JFUVVFU1RTOi0xMDB9JwogICAgdm9sdW1lczoKICAgICAgLSAnYXJjaGl2ZXItZGF0YTovdmFyL2RhdGEvb3Blbi1hcmNoaXZlcicKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHZhbGtleToKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBtZWlsaXNlYXJjaDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNy1hbHBpbmUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotb3Blbi1hcmNoaXZlci1kYn0nCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtIExDX0FMTD1DCiAgICB2b2x1bWVzOgogICAgICAtICdwb3N0Z3Jlcy1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgdmFsa2V5OgogICAgaW1hZ2U6ICd2YWxrZXkvdmFsa2V5OjgtYWxwaW5lJwogICAgY29tbWFuZDogJ3ZhbGtleS1zZXJ2ZXIgLS1yZXF1aXJlcGFzcyAke1NFUlZJQ0VfUEFTU1dPUkRfVkFMS0VZfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3ZhbGtleS1kYXRhOi9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gcGluZwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDMKICBtZWlsaXNlYXJjaDoKICAgIGltYWdlOiAnZ2V0bWVpbGkvbWVpbGlzZWFyY2g6djEuMTUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTUVJTElfTUFTVEVSX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfTUVJTElTRUFSQ0h9JwogICAgdm9sdW1lczoKICAgICAgLSAnbWVpbGlzZWFyY2gtZGF0YTovbWVpbGlfZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo3NzAwL2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQo=", "tags": [ "email archiving", "email", diff --git a/tests/Feature/Security/ApiSensitiveFieldsTest.php b/tests/Feature/Security/ApiSensitiveFieldsTest.php index 2275f7cc2..591f227cb 100644 --- a/tests/Feature/Security/ApiSensitiveFieldsTest.php +++ b/tests/Feature/Security/ApiSensitiveFieldsTest.php @@ -196,4 +196,14 @@ function makeTeamUser(): array expect($body)->toContain('"internal_db_url":'); expect($body)->toContain('"sentinel_token":'); }); + + test('project database list can eager load nested destination server settings', function () { + $databases = $this->project->databases(['destination.server.settings']); + $database = $databases->firstWhere('id', $this->database->id); + + expect($database)->not->toBeNull() + ->and($database->relationLoaded('destination'))->toBeTrue() + ->and($database->destination->relationLoaded('server'))->toBeTrue() + ->and($database->destination->server->relationLoaded('settings'))->toBeTrue(); + }); }); diff --git a/tests/Unit/Models/SensitiveFieldsHiddenTest.php b/tests/Unit/Models/SensitiveFieldsHiddenTest.php index 92e73c907..f95539d8a 100644 --- a/tests/Unit/Models/SensitiveFieldsHiddenTest.php +++ b/tests/Unit/Models/SensitiveFieldsHiddenTest.php @@ -48,7 +48,9 @@ 'manual_webhook_secret_gitea', 'dockerfile', 'docker_compose', + 'docker_compose_pr', 'docker_compose_raw', + 'docker_compose_pr_raw', 'custom_labels', ); }); From 12aba41d9a539cc7052ae69c57e6e63979fbedd6 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 11 May 2026 11:53:22 +0200 Subject: [PATCH 041/151] fix(api): gate service server secrets by sensitive scope Only eager load nested server settings for service responses when the API token has read:sensitive, preventing read-only tokens from exposing sentinel fields while preserving sensitive access. --- .../Controllers/Api/ServicesController.php | 18 ++++- .../Security/ApiSensitiveFieldsTest.php | 75 +++++++++++++++++++ 2 files changed, 89 insertions(+), 4 deletions(-) diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index c3ced0e31..773fadd78 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -14,6 +14,7 @@ use App\Models\Server; use App\Models\Service; use App\Support\ValidationPatterns; +use Illuminate\Database\Eloquent\Model; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Collection; @@ -50,9 +51,9 @@ private function removeSensitiveData($service) * Handles both single models and Eloquent Collections (the listing endpoint * passes a Collection of Services per project to removeSensitiveData()). */ - private function exposeNestedServerSecrets($model): void + private function exposeNestedServerSecrets(Model|Collection $model): void { - if ($model instanceof Collection || $model instanceof \Illuminate\Database\Eloquent\Collection) { + if ($model instanceof Collection) { foreach ($model as $item) { $this->exposeNestedServerSecrets($item); } @@ -215,8 +216,12 @@ public function services(Request $request) } $projects = Project::where('team_id', $teamId)->get(); $services = collect(); + $serviceRelations = $request->attributes->get('can_read_sensitive', false) === true + ? ['destination.server.settings'] + : []; + foreach ($projects as $project) { - $services->push($project->services()->get()); + $services->push($project->services()->with($serviceRelations)->get()); } foreach ($services as $service) { $service = $this->removeSensitiveData($service); @@ -771,7 +776,12 @@ public function service_by_uuid(Request $request) $this->authorize('view', $service); - $service = $service->load(['applications', 'databases']); + $serviceRelations = ['applications', 'databases']; + if ($request->attributes->get('can_read_sensitive', false) === true) { + $serviceRelations[] = 'destination.server.settings'; + } + + $service = $service->load($serviceRelations); return response()->json($this->removeSensitiveData($service)); } diff --git a/tests/Feature/Security/ApiSensitiveFieldsTest.php b/tests/Feature/Security/ApiSensitiveFieldsTest.php index 591f227cb..0036b8546 100644 --- a/tests/Feature/Security/ApiSensitiveFieldsTest.php +++ b/tests/Feature/Security/ApiSensitiveFieldsTest.php @@ -5,6 +5,7 @@ use App\Models\InstanceSettings; use App\Models\Project; use App\Models\Server; +use App\Models\Service; use App\Models\StandalonePostgresql; use App\Models\Team; use App\Models\User; @@ -207,3 +208,77 @@ function makeTeamUser(): array ->and($database->destination->server->relationLoaded('settings'))->toBeTrue(); }); }); + +describe('GET /api/v1/services sensitive field gating', function () { + beforeEach(function () { + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); + + $destination = $this->server->standaloneDockers()->firstOrFail(); + $this->service = Service::factory()->create([ + 'server_id' => $this->server->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'environment_id' => $this->environment->id, + ]); + }); + + test('read token does not leak service or nested server sensitive fields', function () { + $token = makeApiToken($this->user, $this->team, ['read']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->getJson('/api/v1/services'); + + $response->assertStatus(200); + + $body = $response->getContent(); + expect($body)->not->toContain('"docker_compose_raw":') + ->and($body)->not->toContain('"sentinel_token":') + ->and($body)->not->toContain('"sentinel_custom_url":'); + }); + + test('read sensitive token sees service and nested server sensitive fields', function () { + $token = makeApiToken($this->user, $this->team, ['read', 'read:sensitive']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->getJson('/api/v1/services'); + + $response->assertStatus(200); + + $body = $response->getContent(); + expect($body)->toContain('"docker_compose_raw":') + ->and($body)->toContain('"sentinel_token":') + ->and($body)->toContain('"sentinel_custom_url":'); + }); + + test('read sensitive service list eager loads nested server settings once', function () { + $secondServer = Server::factory()->create(['team_id' => $this->team->id]); + $secondDestination = $secondServer->standaloneDockers()->firstOrFail(); + + Service::factory()->create([ + 'server_id' => $secondServer->id, + 'destination_id' => $secondDestination->id, + 'destination_type' => $secondDestination->getMorphClass(), + 'environment_id' => $this->environment->id, + ]); + + $token = makeApiToken($this->user, $this->team, ['read', 'read:sensitive']); + $serverSettingsQueries = collect(); + + DB::listen(function ($query) use ($serverSettingsQueries) { + if (str_contains($query->sql, 'from "server_settings"')) { + $serverSettingsQueries->push($query->sql); + } + }); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->getJson('/api/v1/services'); + + $response->assertStatus(200); + + expect($serverSettingsQueries->contains(fn (string $sql) => str_contains($sql, '"server_settings"."server_id" in')))->toBeTrue(); + }); +}); From f40bb80f9decadce4c33ccf869bc5314e362359e Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 11 May 2026 20:52:29 +0200 Subject: [PATCH 042/151] style(api): tighten nested server secret visibility checks --- app/Http/Controllers/Api/DatabasesController.php | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 839e52cb3..0d22afbec 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -62,17 +62,18 @@ private function removeSensitiveData($database) */ private function exposeNestedServerSecrets(Model $model): void { - $server = $model->destination?->server ?? null; - if (! $server) { + $server = $model->destination?->server; + if ($server === null) { return; } + $server->makeVisible([ 'logdrain_axiom_api_key', 'logdrain_newrelic_license_key', ]); - $settings = $server->settings ?? null; - if ($settings) { - $settings->makeVisible([ + + if ($server->settings !== null) { + $server->settings->makeVisible([ 'sentinel_token', 'sentinel_custom_url', 'logdrain_newrelic_license_key', From 81a3bb0f0769e5a765e77649d002ea6acf9a667f Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 11 May 2026 21:18:07 +0200 Subject: [PATCH 043/151] fix(api): hide sensitive fields by default Add model-level hidden fields for secrets, tokens, keys, notification credentials, deployment logs, and environment values. Allow explicit read:sensitive API access to reveal gated private keys and deployment logs, and cover the behavior with feature and unit tests. --- app/Http/Controllers/Api/DeployController.php | 7 + .../Controllers/Api/SecurityController.php | 4 + app/Models/ApplicationDeploymentQueue.php | 4 + app/Models/CloudInitScript.php | 4 + app/Models/CloudProviderToken.php | 4 + app/Models/DiscordNotificationSettings.php | 4 + app/Models/EmailNotificationSettings.php | 10 ++ app/Models/InstanceSettings.php | 11 ++ app/Models/OauthSetting.php | 4 + app/Models/PrivateKey.php | 4 + app/Models/PushoverNotificationSettings.php | 5 + app/Models/S3Storage.php | 5 + app/Models/SharedEnvironmentVariable.php | 4 + app/Models/SlackNotificationSettings.php | 4 + app/Models/SslCertificate.php | 4 + app/Models/TelegramNotificationSettings.php | 22 ++- app/Models/WebhookNotificationSettings.php | 4 + .../components/notification/navbar.blade.php | 2 +- .../Security/ApiSensitiveFieldsTest.php | 95 ++++++++++++ .../Unit/Models/SensitiveFieldsHiddenTest.php | 136 ++++++++++++++++++ 20 files changed, 335 insertions(+), 2 deletions(-) diff --git a/app/Http/Controllers/Api/DeployController.php b/app/Http/Controllers/Api/DeployController.php index c93731d68..7a049ae2b 100644 --- a/app/Http/Controllers/Api/DeployController.php +++ b/app/Http/Controllers/Api/DeployController.php @@ -25,6 +25,10 @@ private function removeSensitiveData($deployment) $deployment->makeHidden([ 'logs', ]); + } else { + $deployment->makeVisible([ + 'logs', + ]); } return serializeApiResponse($deployment); @@ -699,6 +703,9 @@ public function get_application_deployments(Request $request) $this->authorize('view', $application); $deployments = $application->deployments($skip, $take); + if ($request->attributes->get('can_read_sensitive', false) === true) { + $deployments['deployments']->each->makeVisible(['logs']); + } return response()->json($deployments); } diff --git a/app/Http/Controllers/Api/SecurityController.php b/app/Http/Controllers/Api/SecurityController.php index e59c40866..759af5134 100644 --- a/app/Http/Controllers/Api/SecurityController.php +++ b/app/Http/Controllers/Api/SecurityController.php @@ -16,6 +16,10 @@ private function removeSensitiveData($team) $team->makeHidden([ 'private_key', ]); + } else { + $team->makeVisible([ + 'private_key', + ]); } return serializeApiResponse($team); diff --git a/app/Models/ApplicationDeploymentQueue.php b/app/Models/ApplicationDeploymentQueue.php index 67f28523c..f4c334bf3 100644 --- a/app/Models/ApplicationDeploymentQueue.php +++ b/app/Models/ApplicationDeploymentQueue.php @@ -68,6 +68,10 @@ class ApplicationDeploymentQueue extends Model 'finished_at', ]; + protected $hidden = [ + 'logs', + ]; + protected $casts = [ 'pull_request_id' => 'integer', 'finished_at' => 'datetime', diff --git a/app/Models/CloudInitScript.php b/app/Models/CloudInitScript.php index 2c78cc582..9d2676aca 100644 --- a/app/Models/CloudInitScript.php +++ b/app/Models/CloudInitScript.php @@ -12,6 +12,10 @@ class CloudInitScript extends Model 'script', ]; + protected $hidden = [ + 'script', + ]; + protected function casts(): array { return [ diff --git a/app/Models/CloudProviderToken.php b/app/Models/CloudProviderToken.php index 026d11fba..02c662d9d 100644 --- a/app/Models/CloudProviderToken.php +++ b/app/Models/CloudProviderToken.php @@ -11,6 +11,10 @@ class CloudProviderToken extends BaseModel 'name', ]; + protected $hidden = [ + 'token', + ]; + protected $casts = [ 'token' => 'encrypted', ]; diff --git a/app/Models/DiscordNotificationSettings.php b/app/Models/DiscordNotificationSettings.php index e86598126..135c921f6 100644 --- a/app/Models/DiscordNotificationSettings.php +++ b/app/Models/DiscordNotificationSettings.php @@ -34,6 +34,10 @@ class DiscordNotificationSettings extends Model 'discord_ping_enabled', ]; + protected $hidden = [ + 'discord_webhook_url', + ]; + protected $casts = [ 'discord_enabled' => 'boolean', 'discord_webhook_url' => 'encrypted', diff --git a/app/Models/EmailNotificationSettings.php b/app/Models/EmailNotificationSettings.php index 1277e45d9..7368bafbf 100644 --- a/app/Models/EmailNotificationSettings.php +++ b/app/Models/EmailNotificationSettings.php @@ -43,6 +43,16 @@ class EmailNotificationSettings extends Model 'traefik_outdated_email_notifications', ]; + protected $hidden = [ + 'smtp_from_address', + 'smtp_from_name', + 'smtp_recipients', + 'smtp_host', + 'smtp_username', + 'smtp_password', + 'resend_api_key', + ]; + protected $casts = [ 'smtp_enabled' => 'boolean', 'smtp_from_address' => 'encrypted', diff --git a/app/Models/InstanceSettings.php b/app/Models/InstanceSettings.php index d5c3bfa28..d63611fac 100644 --- a/app/Models/InstanceSettings.php +++ b/app/Models/InstanceSettings.php @@ -48,6 +48,17 @@ class InstanceSettings extends Model 'is_mcp_server_enabled', ]; + protected $hidden = [ + 'smtp_from_address', + 'smtp_from_name', + 'smtp_recipients', + 'smtp_host', + 'smtp_username', + 'smtp_password', + 'resend_api_key', + 'sentinel_token', + ]; + protected $casts = [ 'smtp_enabled' => 'boolean', 'smtp_from_address' => 'encrypted', diff --git a/app/Models/OauthSetting.php b/app/Models/OauthSetting.php index 08e08d85b..e7999134a 100644 --- a/app/Models/OauthSetting.php +++ b/app/Models/OauthSetting.php @@ -13,6 +13,10 @@ class OauthSetting extends Model protected $fillable = ['provider', 'client_id', 'client_secret', 'redirect_uri', 'tenant', 'base_url', 'enabled']; + protected $hidden = [ + 'client_secret', + ]; + protected function clientSecret(): Attribute { return Attribute::make( diff --git a/app/Models/PrivateKey.php b/app/Models/PrivateKey.php index 1521678f3..ee330aa19 100644 --- a/app/Models/PrivateKey.php +++ b/app/Models/PrivateKey.php @@ -41,6 +41,10 @@ class PrivateKey extends BaseModel 'fingerprint', ]; + protected $hidden = [ + 'private_key', + ]; + protected $casts = [ 'private_key' => 'encrypted', ]; diff --git a/app/Models/PushoverNotificationSettings.php b/app/Models/PushoverNotificationSettings.php index 5ad617ad6..dd0d81cc0 100644 --- a/app/Models/PushoverNotificationSettings.php +++ b/app/Models/PushoverNotificationSettings.php @@ -34,6 +34,11 @@ class PushoverNotificationSettings extends Model 'traefik_outdated_pushover_notifications', ]; + protected $hidden = [ + 'pushover_user_key', + 'pushover_api_token', + ]; + protected $casts = [ 'pushover_enabled' => 'boolean', 'pushover_user_key' => 'encrypted', diff --git a/app/Models/S3Storage.php b/app/Models/S3Storage.php index 3f6ee51cc..dbc895825 100644 --- a/app/Models/S3Storage.php +++ b/app/Models/S3Storage.php @@ -26,6 +26,11 @@ class S3Storage extends BaseModel 'unusable_email_sent', ]; + protected $hidden = [ + 'key', + 'secret', + ]; + protected $casts = [ 'is_usable' => 'boolean', 'key' => 'encrypted', diff --git a/app/Models/SharedEnvironmentVariable.php b/app/Models/SharedEnvironmentVariable.php index eadc33ec2..8bb241240 100644 --- a/app/Models/SharedEnvironmentVariable.php +++ b/app/Models/SharedEnvironmentVariable.php @@ -30,6 +30,10 @@ class SharedEnvironmentVariable extends Model 'version', ]; + protected $hidden = [ + 'value', + ]; + protected $casts = [ 'key' => 'string', 'value' => 'encrypted', diff --git a/app/Models/SlackNotificationSettings.php b/app/Models/SlackNotificationSettings.php index d4f125fb5..62603685e 100644 --- a/app/Models/SlackNotificationSettings.php +++ b/app/Models/SlackNotificationSettings.php @@ -33,6 +33,10 @@ class SlackNotificationSettings extends Model 'traefik_outdated_slack_notifications', ]; + protected $hidden = [ + 'slack_webhook_url', + ]; + protected $casts = [ 'slack_enabled' => 'boolean', 'slack_webhook_url' => 'encrypted', diff --git a/app/Models/SslCertificate.php b/app/Models/SslCertificate.php index eb2175d44..2311cea72 100644 --- a/app/Models/SslCertificate.php +++ b/app/Models/SslCertificate.php @@ -20,6 +20,10 @@ class SslCertificate extends Model 'is_ca_certificate', ]; + protected $hidden = [ + 'ssl_private_key', + ]; + protected $casts = [ 'ssl_certificate' => 'encrypted', 'ssl_private_key' => 'encrypted', diff --git a/app/Models/TelegramNotificationSettings.php b/app/Models/TelegramNotificationSettings.php index 4930f45d4..8c644f9bc 100644 --- a/app/Models/TelegramNotificationSettings.php +++ b/app/Models/TelegramNotificationSettings.php @@ -49,6 +49,25 @@ class TelegramNotificationSettings extends Model 'telegram_notifications_traefik_outdated_thread_id', ]; + protected $hidden = [ + 'telegram_token', + 'telegram_chat_id', + 'telegram_notifications_deployment_success_thread_id', + 'telegram_notifications_deployment_failure_thread_id', + 'telegram_notifications_status_change_thread_id', + 'telegram_notifications_backup_success_thread_id', + 'telegram_notifications_backup_failure_thread_id', + 'telegram_notifications_scheduled_task_success_thread_id', + 'telegram_notifications_scheduled_task_failure_thread_id', + 'telegram_notifications_docker_cleanup_success_thread_id', + 'telegram_notifications_docker_cleanup_failure_thread_id', + 'telegram_notifications_server_disk_usage_thread_id', + 'telegram_notifications_server_reachable_thread_id', + 'telegram_notifications_server_unreachable_thread_id', + 'telegram_notifications_server_patch_thread_id', + 'telegram_notifications_traefik_outdated_thread_id', + ]; + protected $casts = [ 'telegram_enabled' => 'boolean', 'telegram_token' => 'encrypted', @@ -75,7 +94,8 @@ class TelegramNotificationSettings extends Model 'telegram_notifications_backup_failure_thread_id' => 'encrypted', 'telegram_notifications_scheduled_task_success_thread_id' => 'encrypted', 'telegram_notifications_scheduled_task_failure_thread_id' => 'encrypted', - 'telegram_notifications_docker_cleanup_thread_id' => 'encrypted', + 'telegram_notifications_docker_cleanup_success_thread_id' => 'encrypted', + 'telegram_notifications_docker_cleanup_failure_thread_id' => 'encrypted', 'telegram_notifications_server_disk_usage_thread_id' => 'encrypted', 'telegram_notifications_server_reachable_thread_id' => 'encrypted', 'telegram_notifications_server_unreachable_thread_id' => 'encrypted', diff --git a/app/Models/WebhookNotificationSettings.php b/app/Models/WebhookNotificationSettings.php index 731006181..c6a81b50a 100644 --- a/app/Models/WebhookNotificationSettings.php +++ b/app/Models/WebhookNotificationSettings.php @@ -33,6 +33,10 @@ class WebhookNotificationSettings extends Model 'traefik_outdated_webhook_notifications', ]; + protected $hidden = [ + 'webhook_url', + ]; + protected function casts(): array { return [ diff --git a/resources/views/components/notification/navbar.blade.php b/resources/views/components/notification/navbar.blade.php index 0ee3b8ee4..256c4d528 100644 --- a/resources/views/components/notification/navbar.blade.php +++ b/resources/views/components/notification/navbar.blade.php @@ -2,7 +2,7 @@

Notifications

Get notified about your infrastructure.
-
+

Services

Reload List +
+ Last Updated on Service Templates: + +
The respective trademarks mentioned here are owned by the respective companies, and use of them @@ -154,7 +159,14 @@ class="w-full h-full p-2 transition-all duration-200 dark:bg-white/10 bg-black/1 @@ -237,6 +249,7 @@ function searchResources() { isSticky: false, selecting: false, services: [], + serviceTemplatesLastUpdated: null, gitBasedApplications: [], dockerBasedApplications: [], databases: [], @@ -251,12 +264,14 @@ function searchResources() { this.loading = true; const { services, + serviceTemplatesLastUpdated, categories, gitBasedApplications, dockerBasedApplications, databases } = await this.$wire.loadServices(); this.services = services; + this.serviceTemplatesLastUpdated = serviceTemplatesLastUpdated; this.categories = categories || []; this.gitBasedApplications = gitBasedApplications; this.dockerBasedApplications = dockerBasedApplications; diff --git a/tests/Feature/ServiceTemplatesLastUpdatedHintTest.php b/tests/Feature/ServiceTemplatesLastUpdatedHintTest.php new file mode 100644 index 000000000..99b9f7ad7 --- /dev/null +++ b/tests/Feature/ServiceTemplatesLastUpdatedHintTest.php @@ -0,0 +1,70 @@ +loadServices(); + + expect($resources) + ->toHaveKey('serviceTemplatesLastUpdated') + ->and($resources['serviceTemplatesLastUpdated']) + ->toBe(CarbonImmutable::createFromTimestamp(filemtime($templatePath))->timezone(config('app.timezone'))->format('M j, Y H:i')); +}); + +it('returns each service template last updated timestamp', function () { + $component = new Select; + $templatePath = base_path('templates/compose/activepieces.yaml'); + + $resources = $component->loadServices(); + + expect($resources['services']['activepieces']) + ->toHaveKey('templateLastUpdated') + ->and($resources['services']['activepieces']['templateLastUpdated']) + ->toBe(CarbonImmutable::createFromTimestamp(filemtime($templatePath))->timezone(config('app.timezone'))->format('M j, Y H:i')); +}); + +it('uses a service template timestamp cache keyed by bundle mtime', function () { + $bundleMtime = filemtime(base_path('templates/'.config('constants.services.file_name'))); + Cache::put("service-template-last-updated-map:{$bundleMtime}", [ + 'activepieces' => 'Cached timestamp', + ], now()->addDay()); + + $resources = (new Select)->loadServices(); + + expect($resources['services']['activepieces']['templateLastUpdated'])->toBe('Cached timestamp'); +}); + +it('does not use stale service template timestamp cache entries from another bundle mtime', function () { + $bundleMtime = filemtime(base_path('templates/'.config('constants.services.file_name'))); + Cache::put('service-template-last-updated-map:'.($bundleMtime - 1), [ + 'activepieces' => 'Stale cached timestamp', + ], now()->addDay()); + + $resources = (new Select)->loadServices(); + + expect($resources['services']['activepieces']['templateLastUpdated'])->not->toBe('Stale cached timestamp'); +}); + +it('renders the service templates last updated hint placeholder', function () { + View::share('errors', new ViewErrorBag); + + $view = $this->view('livewire.project.new.select', [ + 'current_step' => 'type', + 'environments' => collect(), + ]); + + $view->assertSee('Last Updated on Service Templates:'); + $view->assertSee('serviceTemplatesLastUpdated'); + $view->assertSee('service.templateLastUpdated'); +}); From 062ad5774041fb3be71abedcff33c4315613152c Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 4 Jun 2026 11:03:06 +0200 Subject: [PATCH 052/151] fix(security): enforce team access on mutable actions Authorize cloud provider token access, audit sensitive operations, and standardize public IDs across deployment and resource flows. --- app/Console/Commands/Emails.php | 1 + .../Api/ApplicationsController.php | 21 ++- .../Api/CloudProviderTokensController.php | 9 ++ app/Http/Controllers/Api/DeployController.php | 3 +- .../Controllers/Api/HetznerController.php | 5 + .../Controllers/Api/SentinelController.php | 10 +- app/Http/Controllers/Webhook/Bitbucket.php | 5 +- app/Http/Controllers/Webhook/Gitea.php | 5 +- app/Http/Controllers/Webhook/Github.php | 5 +- app/Http/Controllers/Webhook/Gitlab.php | 5 +- app/Jobs/ApplicationDeploymentJob.php | 3 +- app/Jobs/DatabaseBackupJob.php | 3 +- app/Jobs/ProcessGithubPullRequestWebhook.php | 3 +- app/Livewire/Boarding/Index.php | 3 +- app/Livewire/Destination/New/Docker.php | 5 +- app/Livewire/MonacoEditor.php | 3 +- app/Livewire/Project/AddEmpty.php | 3 +- app/Livewire/Project/Application/General.php | 3 +- app/Livewire/Project/Application/Heading.php | 3 +- app/Livewire/Project/Application/Previews.php | 3 +- .../Project/Application/PreviewsCompose.php | 5 +- app/Livewire/Project/Application/Rollback.php | 3 +- app/Livewire/Project/CloneMe.php | 17 ++- app/Livewire/Project/Database/InitScript.php | 9 ++ app/Livewire/Project/New/DockerImage.php | 3 +- app/Livewire/Project/New/EmptyProject.php | 3 +- app/Livewire/Project/New/SimpleDockerfile.php | 3 +- app/Livewire/Project/Shared/Danger.php | 3 +- app/Livewire/Project/Shared/Destination.php | 3 +- .../Shared/ExecuteContainerCommand.php | 12 ++ .../Project/Shared/ResourceOperations.php | 11 +- app/Livewire/Project/Shared/Terminal.php | 7 + app/Livewire/Project/Show.php | 3 +- app/Livewire/Security/CloudInitScriptForm.php | 17 ++- app/Livewire/Security/CloudInitScripts.php | 7 + .../Security/CloudProviderTokenForm.php | 8 +- app/Livewire/Security/CloudProviderTokens.php | 23 +++- .../Server/CloudProviderToken/Show.php | 27 +++- app/Livewire/Storage/Resources.php | 11 ++ app/Livewire/Team/InviteLink.php | 3 +- app/Mcp/Concerns/ResolvesTeam.php | 35 ++++- app/Mcp/Servers/CoolifyServer.php | 18 +-- app/Mcp/Tools/GetApplication.php | 20 +-- app/Mcp/Tools/GetDatabase.php | 20 +-- app/Mcp/Tools/GetInfrastructureOverview.php | 16 +-- app/Mcp/Tools/GetServer.php | 18 +-- app/Mcp/Tools/GetService.php | 20 +-- app/Mcp/Tools/ListApplications.php | 18 +-- app/Mcp/Tools/ListDatabases.php | 16 +-- app/Mcp/Tools/ListProjects.php | 16 +-- app/Mcp/Tools/ListServers.php | 16 +-- app/Mcp/Tools/ListServices.php | 16 +-- app/Models/Application.php | 3 +- app/Models/ApplicationPreview.php | 5 +- app/Models/BaseModel.php | 3 +- app/Models/CloudProviderToken.php | 4 + app/Models/PrivateKey.php | 3 +- app/Models/Project.php | 3 +- app/Models/Server.php | 3 +- app/Models/Service.php | 5 +- app/Providers/AuthServiceProvider.php | 122 +++++++++++++----- app/View/Components/Forms/Checkbox.php | 3 +- app/View/Components/Forms/Datalist.php | 5 +- app/View/Components/Forms/EnvVarInput.php | 5 +- app/View/Components/Forms/Input.php | 6 +- app/View/Components/Forms/Select.php | 5 +- app/View/Components/Forms/Textarea.php | 6 +- bootstrap/helpers/applications.php | 9 +- bootstrap/helpers/databases.php | 17 ++- bootstrap/helpers/docker.php | 3 +- bootstrap/helpers/parsers.php | 3 +- bootstrap/helpers/shared.php | 14 +- .../factories/CloudProviderTokenFactory.php | 25 ++++ database/factories/PrivateKeyFactory.php | 37 ++++++ .../database/postgresql/general.blade.php | 3 +- .../Feature/Api/CloudProviderTokenApiTest.php | 38 ++++++ tests/Feature/Api/HetznerApiTest.php | 29 +++++ .../CloudProviderAuthorizationTest.php | 8 ++ tests/Feature/Mcp/McpEndpointTest.php | 60 +++++++++ ...bleLivewireComponentsAuthorizationTest.php | 30 +++++ tests/Feature/Security/AuditLogTest.php | 100 ++++++++++++++ .../Feature/SentinelPushDeduplicationTest.php | 18 +++ tests/Unit/PublicIdTest.php | 19 +++ 83 files changed, 824 insertions(+), 278 deletions(-) create mode 100644 database/factories/CloudProviderTokenFactory.php create mode 100644 database/factories/PrivateKeyFactory.php create mode 100644 tests/Feature/MutableLivewireComponentsAuthorizationTest.php create mode 100644 tests/Unit/PublicIdTest.php diff --git a/app/Console/Commands/Emails.php b/app/Console/Commands/Emails.php index 43ba06804..02be98fc9 100644 --- a/app/Console/Commands/Emails.php +++ b/app/Console/Commands/Emails.php @@ -18,6 +18,7 @@ use Illuminate\Console\Command; use Illuminate\Mail\Message; use Illuminate\Notifications\Messages\MailMessage; +use Illuminate\Support\Str; use Mail; use function Laravel\Prompts\confirm; diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 79830ea41..824101be8 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -30,7 +30,6 @@ use OpenApi\Attributes as OA; use Spatie\Url\Url; use Symfony\Component\Yaml\Yaml; -use Visus\Cuid2\Cuid2; class ApplicationsController extends Controller { @@ -1197,7 +1196,7 @@ private function create_application(Request $request, $type) $application->isConfigurationChanged(true); if ($instantDeploy) { - $deployment_uuid = new Cuid2; + $deployment_uuid = new_public_id(); $result = queue_application_deployment( application: $application, @@ -1436,7 +1435,7 @@ private function create_application(Request $request, $type) $application->isConfigurationChanged(true); if ($instantDeploy) { - $deployment_uuid = new Cuid2; + $deployment_uuid = new_public_id(); $result = queue_application_deployment( application: $application, @@ -1645,7 +1644,7 @@ private function create_application(Request $request, $type) $application->isConfigurationChanged(true); if ($instantDeploy) { - $deployment_uuid = new Cuid2; + $deployment_uuid = new_public_id(); $result = queue_application_deployment( application: $application, @@ -1691,7 +1690,7 @@ private function create_application(Request $request, $type) ], 422); } if (! $request->has('name')) { - $request->offsetSet('name', 'dockerfile-'.new Cuid2); + $request->offsetSet('name', 'dockerfile-'.new_public_id()); } $return = $this->validateDataApplications($request, $server); @@ -1765,7 +1764,7 @@ private function create_application(Request $request, $type) $application->isConfigurationChanged(true); if ($instantDeploy) { - $deployment_uuid = new Cuid2; + $deployment_uuid = new_public_id(); $result = queue_application_deployment( application: $application, @@ -1809,7 +1808,7 @@ private function create_application(Request $request, $type) ], 422); } if (! $request->has('name')) { - $request->offsetSet('name', 'docker-image-'.new Cuid2); + $request->offsetSet('name', 'docker-image-'.new_public_id()); } $return = $this->validateDataApplications($request, $server); if ($return instanceof JsonResponse) { @@ -1884,7 +1883,7 @@ private function create_application(Request $request, $type) $application->isConfigurationChanged(true); if ($instantDeploy) { - $deployment_uuid = new Cuid2; + $deployment_uuid = new_public_id(); $result = queue_application_deployment( application: $application, @@ -2682,7 +2681,7 @@ public function update_by_uuid(Request $request) ]); if ($instantDeploy) { - $deployment_uuid = new Cuid2; + $deployment_uuid = new_public_id(); $result = queue_application_deployment( application: $application, @@ -3589,7 +3588,7 @@ public function action_deploy(Request $request) $this->authorize('deploy', $application); - $deployment_uuid = new Cuid2; + $deployment_uuid = new_public_id(); $result = queue_application_deployment( application: $application, @@ -3787,7 +3786,7 @@ public function action_restart(Request $request) $this->authorize('deploy', $application); - $deployment_uuid = new Cuid2; + $deployment_uuid = new_public_id(); $result = queue_application_deployment( application: $application, diff --git a/app/Http/Controllers/Api/CloudProviderTokensController.php b/app/Http/Controllers/Api/CloudProviderTokensController.php index 1dd2e0006..ad6eeb982 100644 --- a/app/Http/Controllers/Api/CloudProviderTokensController.php +++ b/app/Http/Controllers/Api/CloudProviderTokensController.php @@ -549,9 +549,18 @@ public function validateToken(Request $request) if (! $cloudToken) { return response()->json(['message' => 'Cloud provider token not found.'], 404); } + $this->authorize('view', $cloudToken); $validation = $this->validateProviderToken($cloudToken->provider, $cloudToken->token); + auditLog('api.cloud_token.validated', [ + 'team_id' => $teamId, + 'cloud_token_uuid' => $cloudToken->uuid, + 'cloud_token_name' => $cloudToken->name, + 'provider' => $cloudToken->provider, + 'valid' => $validation['valid'], + ]); + return response()->json([ 'valid' => $validation['valid'], 'message' => $validation['valid'] ? 'Token is valid.' : $validation['error'], diff --git a/app/Http/Controllers/Api/DeployController.php b/app/Http/Controllers/Api/DeployController.php index c93731d68..f0cf48efa 100644 --- a/app/Http/Controllers/Api/DeployController.php +++ b/app/Http/Controllers/Api/DeployController.php @@ -15,7 +15,6 @@ use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Http\Request; use OpenApi\Attributes as OA; -use Visus\Cuid2\Cuid2; class DeployController extends Controller { @@ -511,7 +510,7 @@ public function deploy_resource($resource, bool $force = false, int $pr = 0, ?st if ($dockerTag !== null && $resource->build_pack !== 'dockerimage') { return ['message' => 'docker_tag can only be used with Docker Image applications.', 'deployment_uuid' => null]; } - $deployment_uuid = new Cuid2; + $deployment_uuid = new_public_id(); $result = queue_application_deployment( application: $resource, deployment_uuid: $deployment_uuid, diff --git a/app/Http/Controllers/Api/HetznerController.php b/app/Http/Controllers/Api/HetznerController.php index ded23bb80..1c9d6f9ef 100644 --- a/app/Http/Controllers/Api/HetznerController.php +++ b/app/Http/Controllers/Api/HetznerController.php @@ -116,6 +116,7 @@ public function locations(Request $request) if (! $token) { return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404); } + $this->authorize('view', $token); try { $hetznerService = new HetznerService($token->token); @@ -237,6 +238,7 @@ public function serverTypes(Request $request) if (! $token) { return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404); } + $this->authorize('view', $token); try { $hetznerService = new HetznerService($token->token); @@ -336,6 +338,7 @@ public function images(Request $request) if (! $token) { return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404); } + $this->authorize('view', $token); try { $hetznerService = new HetznerService($token->token); @@ -445,6 +448,7 @@ public function sshKeys(Request $request) if (! $token) { return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404); } + $this->authorize('view', $token); try { $hetznerService = new HetznerService($token->token); @@ -621,6 +625,7 @@ public function createServer(Request $request) if (! $token) { return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404); } + $this->authorize('view', $token); // Validate private key $privateKey = PrivateKey::whereTeamId($teamId)->whereUuid($request->private_key_uuid)->first(); diff --git a/app/Http/Controllers/Api/SentinelController.php b/app/Http/Controllers/Api/SentinelController.php index df5c60d40..3af05f4fa 100644 --- a/app/Http/Controllers/Api/SentinelController.php +++ b/app/Http/Controllers/Api/SentinelController.php @@ -97,12 +97,12 @@ public function push(Request $request) if ($this->shouldDispatchUpdate($server, $data)) { PushServerUpdateJob::dispatch($server, $data); - } - auditLog('sentinel.metrics_pushed', [ - 'server_uuid' => $server->uuid, - 'team_id' => $server->team_id, - ]); + auditLog('sentinel.metrics_pushed', [ + 'server_uuid' => $server->uuid, + 'team_id' => $server->team_id, + ]); + } return response()->json(['message' => 'ok'], 200); } diff --git a/app/Http/Controllers/Webhook/Bitbucket.php b/app/Http/Controllers/Webhook/Bitbucket.php index d37ba7cee..435f5efab 100644 --- a/app/Http/Controllers/Webhook/Bitbucket.php +++ b/app/Http/Controllers/Webhook/Bitbucket.php @@ -10,7 +10,6 @@ use App\Models\ApplicationPreview; use Exception; use Illuminate\Http\Request; -use Visus\Cuid2\Cuid2; class Bitbucket extends Controller { @@ -141,7 +140,7 @@ public function manual(Request $request) continue; } - $deployment_uuid = new Cuid2; + $deployment_uuid = new_public_id(); $result = queue_application_deployment( application: $application, deployment_uuid: $deployment_uuid, @@ -192,7 +191,7 @@ public function manual(Request $request) continue; } - $deployment_uuid = new Cuid2; + $deployment_uuid = new_public_id(); $found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first(); if (! $found) { if ($application->build_pack === 'dockercompose') { diff --git a/app/Http/Controllers/Webhook/Gitea.php b/app/Http/Controllers/Webhook/Gitea.php index be064e380..82a8cc8af 100644 --- a/app/Http/Controllers/Webhook/Gitea.php +++ b/app/Http/Controllers/Webhook/Gitea.php @@ -11,7 +11,6 @@ use Exception; use Illuminate\Http\Request; use Illuminate\Support\Str; -use Visus\Cuid2\Cuid2; class Gitea extends Controller { @@ -127,7 +126,7 @@ public function manual(Request $request) continue; } - $deployment_uuid = new Cuid2; + $deployment_uuid = new_public_id(); $result = queue_application_deployment( application: $application, deployment_uuid: $deployment_uuid, @@ -194,7 +193,7 @@ public function manual(Request $request) continue; } - $deployment_uuid = new Cuid2; + $deployment_uuid = new_public_id(); $found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first(); if (! $found) { if ($application->build_pack === 'dockercompose') { diff --git a/app/Http/Controllers/Webhook/Github.php b/app/Http/Controllers/Webhook/Github.php index 40c5cbdf0..c9b0116fb 100644 --- a/app/Http/Controllers/Webhook/Github.php +++ b/app/Http/Controllers/Webhook/Github.php @@ -17,7 +17,6 @@ use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; use Illuminate\Support\Str; -use Visus\Cuid2\Cuid2; class Github extends Controller { @@ -144,7 +143,7 @@ public function manual(Request $request) continue; } - $deployment_uuid = new Cuid2; + $deployment_uuid = new_public_id(); $result = queue_application_deployment( application: $application, deployment_uuid: $deployment_uuid, @@ -362,7 +361,7 @@ public function normal(Request $request) continue; } - $deployment_uuid = new Cuid2; + $deployment_uuid = new_public_id(); $result = queue_application_deployment( application: $application, deployment_uuid: $deployment_uuid, diff --git a/app/Http/Controllers/Webhook/Gitlab.php b/app/Http/Controllers/Webhook/Gitlab.php index 231a0b6e5..c90f4ad40 100644 --- a/app/Http/Controllers/Webhook/Gitlab.php +++ b/app/Http/Controllers/Webhook/Gitlab.php @@ -11,7 +11,6 @@ use Exception; use Illuminate\Http\Request; use Illuminate\Support\Str; -use Visus\Cuid2\Cuid2; class Gitlab extends Controller { @@ -168,7 +167,7 @@ public function manual(Request $request) continue; } - $deployment_uuid = new Cuid2; + $deployment_uuid = new_public_id(); $result = queue_application_deployment( application: $application, deployment_uuid: $deployment_uuid, @@ -236,7 +235,7 @@ public function manual(Request $request) continue; } - $deployment_uuid = new Cuid2; + $deployment_uuid = new_public_id(); $found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first(); if (! $found) { if ($application->build_pack === 'dockercompose') { diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 1b8ef3fc4..20eae036b 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -37,7 +37,6 @@ use Spatie\Url\Url; use Symfony\Component\Yaml\Yaml; use Throwable; -use Visus\Cuid2\Cuid2; class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue { @@ -2207,7 +2206,7 @@ private function deploy_to_additional_destinations() continue; } - $deployment_uuid = new Cuid2; + $deployment_uuid = new_public_id(); queue_application_deployment( deployment_uuid: $deployment_uuid, application: $this->application, diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index 64e900b49..79bf929be 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -27,7 +27,6 @@ use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; use Throwable; -use Visus\Cuid2\Cuid2; class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue { @@ -309,7 +308,7 @@ public function handle(): void // Generate unique UUID for each database backup execution $attempts = 0; do { - $this->backup_log_uuid = (string) new Cuid2; + $this->backup_log_uuid = new_public_id(); $exists = ScheduledDatabaseBackupExecution::where('uuid', $this->backup_log_uuid)->exists(); $attempts++; if ($attempts >= 3 && $exists) { diff --git a/app/Jobs/ProcessGithubPullRequestWebhook.php b/app/Jobs/ProcessGithubPullRequestWebhook.php index 141351784..93aee0676 100644 --- a/app/Jobs/ProcessGithubPullRequestWebhook.php +++ b/app/Jobs/ProcessGithubPullRequestWebhook.php @@ -14,7 +14,6 @@ use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; -use Visus\Cuid2\Cuid2; class ProcessGithubPullRequestWebhook implements ShouldBeEncrypted, ShouldQueue { @@ -156,7 +155,7 @@ private function handleOpenAction(Application $application, ?GithubApp $githubAp } // Queue the deployment - $deployment_uuid = new Cuid2; + $deployment_uuid = new_public_id(); queue_application_deployment( application: $application, pull_request_id: $this->pullRequestId, diff --git a/app/Livewire/Boarding/Index.php b/app/Livewire/Boarding/Index.php index d57c06952..5582efbda 100644 --- a/app/Livewire/Boarding/Index.php +++ b/app/Livewire/Boarding/Index.php @@ -13,7 +13,6 @@ use Illuminate\Support\Collection; use Livewire\Attributes\Url; use Livewire\Component; -use Visus\Cuid2\Cuid2; class Index extends Component { @@ -470,7 +469,7 @@ public function createNewProject() $this->createdProject = Project::create([ 'name' => 'My first project', 'team_id' => currentTeam()->id, - 'uuid' => (string) new Cuid2, + 'uuid' => new_public_id(), ]); $this->currentState = 'create-resource'; } diff --git a/app/Livewire/Destination/New/Docker.php b/app/Livewire/Destination/New/Docker.php index 254823163..61e8bba34 100644 --- a/app/Livewire/Destination/New/Docker.php +++ b/app/Livewire/Destination/New/Docker.php @@ -9,7 +9,6 @@ use Livewire\Attributes\Locked; use Livewire\Attributes\Validate; use Livewire\Component; -use Visus\Cuid2\Cuid2; class Docker extends Component { @@ -35,7 +34,7 @@ class Docker extends Component public function mount(?string $server_id = null): void { - $this->network = (string) new Cuid2; + $this->network = new_public_id(); $this->servers = Server::isUsable()->get(); if (filled($server_id)) { @@ -68,7 +67,7 @@ public function updatedServerId(): void public function generateName(): void { - $name = data_get($this->selectedServer, 'name', new Cuid2); + $name = data_get($this->selectedServer, 'name', new_public_id()); $this->name = str("{$name}-{$this->network}")->kebab(); } diff --git a/app/Livewire/MonacoEditor.php b/app/Livewire/MonacoEditor.php index f660f9c13..cf476eb75 100644 --- a/app/Livewire/MonacoEditor.php +++ b/app/Livewire/MonacoEditor.php @@ -4,7 +4,6 @@ // use Livewire\Component; use Illuminate\View\Component; -use Visus\Cuid2\Cuid2; class MonacoEditor extends Component { @@ -40,7 +39,7 @@ public function __construct( public function render() { if (is_null($this->id)) { - $this->id = new Cuid2; + $this->id = new_public_id(); } if (is_null($this->name)) { diff --git a/app/Livewire/Project/AddEmpty.php b/app/Livewire/Project/AddEmpty.php index 3430c69bb..e004ac69e 100644 --- a/app/Livewire/Project/AddEmpty.php +++ b/app/Livewire/Project/AddEmpty.php @@ -6,7 +6,6 @@ use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; -use Visus\Cuid2\Cuid2; class AddEmpty extends Component { @@ -38,7 +37,7 @@ public function submit() 'name' => $this->name, 'description' => $this->description, 'team_id' => currentTeam()->id, - 'uuid' => (string) new Cuid2, + 'uuid' => new_public_id(), ]); $productionEnvironment = $project->environments()->where('name', 'production')->first(); diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index 89b1b4217..7af0a275d 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -13,7 +13,6 @@ use Livewire\Component; use Livewire\Features\SupportEvents\Event; use Spatie\Url\Url; -use Visus\Cuid2\Cuid2; class General extends Component { @@ -549,7 +548,7 @@ public function generateDomain(string $serviceName) try { $this->authorize('update', $this->application); - $uuid = new Cuid2; + $uuid = new_public_id(); $domain = generateUrl(server: $this->application->destination->server, random: $uuid); $sanitizedKey = str($serviceName)->replace('-', '_')->replace('.', '_')->toString(); $this->parsedServiceDomains[$sanitizedKey]['domain'] = $domain; diff --git a/app/Livewire/Project/Application/Heading.php b/app/Livewire/Project/Application/Heading.php index eb5b5f06c..b7750e087 100644 --- a/app/Livewire/Project/Application/Heading.php +++ b/app/Livewire/Project/Application/Heading.php @@ -7,7 +7,6 @@ use App\Models\Application; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; -use Visus\Cuid2\Cuid2; class Heading extends Component { @@ -129,7 +128,7 @@ public function deploy(bool $force_rebuild = false) protected function setDeploymentUuid() { - $this->deploymentUuid = new Cuid2; + $this->deploymentUuid = new_public_id(); $this->parameters['deployment_uuid'] = $this->deploymentUuid; } diff --git a/app/Livewire/Project/Application/Previews.php b/app/Livewire/Project/Application/Previews.php index dc611b5af..74b2ebce8 100644 --- a/app/Livewire/Project/Application/Previews.php +++ b/app/Livewire/Project/Application/Previews.php @@ -9,7 +9,6 @@ use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; use Livewire\Component; -use Visus\Cuid2\Cuid2; class Previews extends Component { @@ -312,7 +311,7 @@ public function deploy(int $pull_request_id, ?string $pull_request_html_url = nu protected function setDeploymentUuid() { - $this->deployment_uuid = new Cuid2; + $this->deployment_uuid = new_public_id(); $this->parameters['deployment_uuid'] = $this->deployment_uuid; } diff --git a/app/Livewire/Project/Application/PreviewsCompose.php b/app/Livewire/Project/Application/PreviewsCompose.php index 85ba2328e..e8da3b45c 100644 --- a/app/Livewire/Project/Application/PreviewsCompose.php +++ b/app/Livewire/Project/Application/PreviewsCompose.php @@ -6,7 +6,6 @@ use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; use Spatie\Url\Url; -use Visus\Cuid2\Cuid2; class PreviewsCompose extends Component { @@ -64,7 +63,7 @@ public function generate() if (empty($domain_string)) { $server = $this->preview->application->destination->server; $template = $this->preview->application->preview_url_template; - $random = new Cuid2; + $random = new_public_id(); // Generate a unique domain like main app services do $generated_fqdn = generateUrl(server: $server, random: $random); @@ -79,7 +78,7 @@ public function generate() $domain_list = explode(',', $domain_string); $preview_fqdns = []; $template = $this->preview->application->preview_url_template; - $random = new Cuid2; + $random = new_public_id(); foreach ($domain_list as $single_domain) { $single_domain = trim($single_domain); diff --git a/app/Livewire/Project/Application/Rollback.php b/app/Livewire/Project/Application/Rollback.php index 3edd77833..b070ae1cc 100644 --- a/app/Livewire/Project/Application/Rollback.php +++ b/app/Livewire/Project/Application/Rollback.php @@ -6,7 +6,6 @@ use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Validate; use Livewire\Component; -use Visus\Cuid2\Cuid2; class Rollback extends Component { @@ -52,7 +51,7 @@ public function rollbackImage($commit) $commit = validateGitRef($commit, 'rollback commit'); - $deployment_uuid = new Cuid2; + $deployment_uuid = new_public_id(); $result = queue_application_deployment( application: $this->application, diff --git a/app/Livewire/Project/CloneMe.php b/app/Livewire/Project/CloneMe.php index ac5d91ebe..0a6e3d8ec 100644 --- a/app/Livewire/Project/CloneMe.php +++ b/app/Livewire/Project/CloneMe.php @@ -13,7 +13,6 @@ use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; -use Visus\Cuid2\Cuid2; class CloneMe extends Component { @@ -64,7 +63,7 @@ public function mount($project_uuid) ->servers() ->get() ->reject(fn ($server) => $server->isBuildServer()); - $this->newName = str($this->project->name.'-clone-'.(string) new Cuid2)->slug(); + $this->newName = str($this->project->name.'-clone-'.new_public_id())->slug(); } public function toggleVolumeCloning(bool $value) @@ -112,7 +111,7 @@ public function clone(string $type) if ($this->environment->name !== 'production') { $project->environments()->create([ 'name' => $this->environment->name, - 'uuid' => (string) new Cuid2, + 'uuid' => new_public_id(), ]); } $environment = $project->environments->where('name', $this->environment->name)->first(); @@ -124,7 +123,7 @@ public function clone(string $type) $project = $this->project; $environment = $this->project->environments()->create([ 'name' => $this->newName, - 'uuid' => (string) new Cuid2, + 'uuid' => new_public_id(), ]); } $applications = $this->environment->applications; @@ -138,7 +137,7 @@ public function clone(string $type) } foreach ($databases as $database) { - $uuid = (string) new Cuid2; + $uuid = new_public_id(); $newDatabase = $database->replicate([ 'id', 'created_at', @@ -229,7 +228,7 @@ public function clone(string $type) $scheduledBackups = $database->scheduledBackups()->get(); foreach ($scheduledBackups as $backup) { - $uuid = (string) new Cuid2; + $uuid = new_public_id(); $newBackup = $backup->replicate([ 'id', 'created_at', @@ -258,7 +257,7 @@ public function clone(string $type) } foreach ($services as $service) { - $uuid = (string) new Cuid2; + $uuid = new_public_id(); $newService = $service->replicate([ 'id', 'created_at', @@ -282,7 +281,7 @@ public function clone(string $type) 'created_at', 'updated_at', ])->fill([ - 'uuid' => (string) new Cuid2, + 'uuid' => new_public_id(), 'service_id' => $newService->id, 'team_id' => currentTeam()->id, ]); @@ -413,7 +412,7 @@ public function clone(string $type) $scheduledBackups = $database->scheduledBackups()->get(); foreach ($scheduledBackups as $backup) { - $uuid = (string) new Cuid2; + $uuid = new_public_id(); $newBackup = $backup->replicate([ 'id', 'created_at', diff --git a/app/Livewire/Project/Database/InitScript.php b/app/Livewire/Project/Database/InitScript.php index e3baa1c8e..7074c235d 100644 --- a/app/Livewire/Project/Database/InitScript.php +++ b/app/Livewire/Project/Database/InitScript.php @@ -2,13 +2,20 @@ namespace App\Livewire\Project\Database; +use App\Models\StandalonePostgresql; use Exception; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\Locked; use Livewire\Attributes\Validate; use Livewire\Component; class InitScript extends Component { + use AuthorizesRequests; + + #[Locked] + public StandalonePostgresql $database; + #[Locked] public array $script; @@ -35,6 +42,7 @@ public function mount() public function submit() { try { + $this->authorize('update', $this->database); $this->validate(); $this->script['index'] = $this->index; $this->script['content'] = $this->content; @@ -48,6 +56,7 @@ public function submit() public function delete() { try { + $this->authorize('update', $this->database); $this->dispatch('delete_init_script', $this->script); } catch (Exception $e) { return handleError($e, $this); diff --git a/app/Livewire/Project/New/DockerImage.php b/app/Livewire/Project/New/DockerImage.php index 737806cb8..de86bea4a 100644 --- a/app/Livewire/Project/New/DockerImage.php +++ b/app/Livewire/Project/New/DockerImage.php @@ -7,7 +7,6 @@ use App\Services\DockerImageParser; use App\Support\ValidationPatterns; use Livewire\Component; -use Visus\Cuid2\Cuid2; class DockerImage extends Component { @@ -130,7 +129,7 @@ public function submit() $imageTag = $parser->isImageHash() ? 'sha256-'.$parser->getTag() : $parser->getTag(); $application = Application::create([ - 'name' => 'docker-image-'.new Cuid2, + 'name' => 'docker-image-'.new_public_id(), 'repository_project_id' => 0, 'git_repository' => 'coollabsio/coolify', 'git_branch' => 'main', diff --git a/app/Livewire/Project/New/EmptyProject.php b/app/Livewire/Project/New/EmptyProject.php index 0360365a9..7c92ce96b 100644 --- a/app/Livewire/Project/New/EmptyProject.php +++ b/app/Livewire/Project/New/EmptyProject.php @@ -4,7 +4,6 @@ use App\Models\Project; use Livewire\Component; -use Visus\Cuid2\Cuid2; class EmptyProject extends Component { @@ -13,7 +12,7 @@ public function createEmptyProject() $project = Project::create([ 'name' => generate_random_name(), 'team_id' => currentTeam()->id, - 'uuid' => (string) new Cuid2, + 'uuid' => new_public_id(), ]); return redirectRoute($this, 'project.show', ['project_uuid' => $project->uuid, 'environment_uuid' => $project->environments->first()->uuid]); diff --git a/app/Livewire/Project/New/SimpleDockerfile.php b/app/Livewire/Project/New/SimpleDockerfile.php index f07948dba..5a84343fd 100644 --- a/app/Livewire/Project/New/SimpleDockerfile.php +++ b/app/Livewire/Project/New/SimpleDockerfile.php @@ -6,7 +6,6 @@ use App\Models\GithubApp; use App\Models\Project; use Livewire\Component; -use Visus\Cuid2\Cuid2; class SimpleDockerfile extends Component { @@ -48,7 +47,7 @@ public function submit() $port = 80; } $application = Application::create([ - 'name' => 'dockerfile-'.new Cuid2, + 'name' => 'dockerfile-'.new_public_id(), 'repository_project_id' => 0, 'git_repository' => 'coollabsio/coolify', 'git_branch' => 'main', diff --git a/app/Livewire/Project/Shared/Danger.php b/app/Livewire/Project/Shared/Danger.php index caaabc494..7f0d3b173 100644 --- a/app/Livewire/Project/Shared/Danger.php +++ b/app/Livewire/Project/Shared/Danger.php @@ -8,7 +8,6 @@ use App\Models\ServiceDatabase; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; -use Visus\Cuid2\Cuid2; class Danger extends Component { @@ -39,7 +38,7 @@ class Danger extends Component public function mount() { $parameters = get_route_parameters(); - $this->modalId = new Cuid2; + $this->modalId = new_public_id(); $this->projectUuid = data_get($parameters, 'project_uuid'); $this->environmentUuid = data_get($parameters, 'environment_uuid'); diff --git a/app/Livewire/Project/Shared/Destination.php b/app/Livewire/Project/Shared/Destination.php index 51965e81f..4f3e659da 100644 --- a/app/Livewire/Project/Shared/Destination.php +++ b/app/Livewire/Project/Shared/Destination.php @@ -10,7 +10,6 @@ use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; use Livewire\Component; -use Visus\Cuid2\Cuid2; class Destination extends Component { @@ -80,7 +79,7 @@ public function redeploy(int $network_id, int $server_id) return; } - $deployment_uuid = new Cuid2; + $deployment_uuid = new_public_id(); $server = Server::ownedByCurrentTeam()->findOrFail($server_id); $destination = $server->standaloneDockers->where('id', $network_id)->firstOrFail(); $result = queue_application_deployment( diff --git a/app/Livewire/Project/Shared/ExecuteContainerCommand.php b/app/Livewire/Project/Shared/ExecuteContainerCommand.php index 4ea5e12db..3fa063298 100644 --- a/app/Livewire/Project/Shared/ExecuteContainerCommand.php +++ b/app/Livewire/Project/Shared/ExecuteContainerCommand.php @@ -6,12 +6,15 @@ use App\Models\Server; use App\Models\Service; use App\Support\ValidationPatterns; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; use Livewire\Attributes\On; use Livewire\Component; class ExecuteContainerCommand extends Component { + use AuthorizesRequests; + public $selected_container = 'default'; public Collection $containers; @@ -40,6 +43,7 @@ public function mount() if (data_get($this->parameters, 'application_uuid')) { $this->type = 'application'; $this->resource = Application::ownedByCurrentTeam()->where('uuid', $this->parameters['application_uuid'])->firstOrFail(); + $this->authorize('view', $this->resource); if ($this->resource->destination->server->isFunctional()) { $this->servers = $this->servers->push($this->resource->destination->server); } @@ -56,6 +60,7 @@ public function mount() abort(404); } $this->resource = $resource; + $this->authorize('view', $this->resource); if ($this->resource->destination->server->isFunctional()) { $this->servers = $this->servers->push($this->resource->destination->server); } @@ -63,6 +68,7 @@ public function mount() } elseif (data_get($this->parameters, 'service_uuid')) { $this->type = 'service'; $this->resource = Service::ownedByCurrentTeam()->where('uuid', $this->parameters['service_uuid'])->firstOrFail(); + $this->authorize('view', $this->resource); if ($this->resource->server->isFunctional()) { $this->servers = $this->servers->push($this->resource->server); } @@ -70,6 +76,7 @@ public function mount() } elseif (data_get($this->parameters, 'server_uuid')) { $this->type = 'server'; $this->resource = Server::ownedByCurrentTeam()->where('uuid', $this->parameters['server_uuid'])->firstOrFail(); + $this->authorize('view', $this->resource); $this->servers = $this->servers->push($this->resource); } $this->servers = $this->servers->sortByDesc(fn ($server) => $server->isTerminalEnabled()); @@ -152,7 +159,9 @@ public function updatedSelectedContainer() public function connectToServer() { try { + $this->authorize('canAccessTerminal'); $server = $this->servers->first(); + $this->authorize('view', $server); if ($server->isForceDisabled()) { throw new \RuntimeException('Server is disabled.'); } @@ -181,6 +190,7 @@ public function connectToContainer() return; } try { + $this->authorize('canAccessTerminal'); // Validate container name format if (! ValidationPatterns::isValidContainerName($this->selected_container)) { throw new \InvalidArgumentException('Invalid container name format'); @@ -198,6 +208,8 @@ public function connectToContainer() throw new \RuntimeException('Invalid server configuration.'); } + $this->authorize('view', $server); + if ($server->isForceDisabled()) { throw new \RuntimeException('Server is disabled.'); } diff --git a/app/Livewire/Project/Shared/ResourceOperations.php b/app/Livewire/Project/Shared/ResourceOperations.php index 9da666d8b..02171af8d 100644 --- a/app/Livewire/Project/Shared/ResourceOperations.php +++ b/app/Livewire/Project/Shared/ResourceOperations.php @@ -22,7 +22,6 @@ use App\Models\SwarmDocker; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; -use Visus\Cuid2\Cuid2; class ResourceOperations extends Component { @@ -66,7 +65,7 @@ public function cloneTo($destination_id) if (! $new_destination) { return $this->addError('destination_id', 'Destination not found.'); } - $uuid = (string) new Cuid2; + $uuid = new_public_id(); $server = $new_destination->server; if ($this->resource->getMorphClass() === Application::class) { @@ -89,7 +88,7 @@ public function cloneTo($destination_id) $this->resource->getMorphClass() === StandaloneDragonfly::class || $this->resource->getMorphClass() === StandaloneClickhouse::class ) { - $uuid = (string) new Cuid2; + $uuid = new_public_id(); $new_resource = $this->resource->replicate([ 'id', 'created_at', @@ -180,7 +179,7 @@ public function cloneTo($destination_id) $scheduledBackups = $this->resource->scheduledBackups()->get(); foreach ($scheduledBackups as $backup) { - $uuid = (string) new Cuid2; + $uuid = new_public_id(); $newBackup = $backup->replicate([ 'id', 'created_at', @@ -216,7 +215,7 @@ public function cloneTo($destination_id) return redirect()->to($route); } elseif ($this->resource->type() === 'service') { - $uuid = (string) new Cuid2; + $uuid = new_public_id(); $new_resource = $this->resource->replicate([ 'id', 'created_at', @@ -243,7 +242,7 @@ public function cloneTo($destination_id) 'created_at', 'updated_at', ])->fill([ - 'uuid' => (string) new Cuid2, + 'uuid' => new_public_id(), 'service_id' => $new_resource->id, 'team_id' => currentTeam()->id, ]); diff --git a/app/Livewire/Project/Shared/Terminal.php b/app/Livewire/Project/Shared/Terminal.php index db65cdaad..46c75e352 100644 --- a/app/Livewire/Project/Shared/Terminal.php +++ b/app/Livewire/Project/Shared/Terminal.php @@ -5,11 +5,14 @@ use App\Helpers\SshMultiplexingHelper; use App\Models\Server; use App\Support\ValidationPatterns; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\On; use Livewire\Component; class Terminal extends Component { + use AuthorizesRequests; + public bool $hasShell = true; public bool $isTerminalConnected = false; @@ -32,7 +35,11 @@ private function checkShellAvailability(Server $server, string $container): bool #[On('send-terminal-command')] public function sendTerminalCommand($isContainer, $identifier, $serverUuid) { + $this->authorize('canAccessTerminal'); + $server = Server::ownedByCurrentTeam()->whereUuid($serverUuid)->firstOrFail(); + $this->authorize('view', $server); + if (! $server->isTerminalEnabled() || $server->isForceDisabled()) { abort(403, 'Terminal access is disabled on this server.'); } diff --git a/app/Livewire/Project/Show.php b/app/Livewire/Project/Show.php index c86fa377d..fc84e4fbd 100644 --- a/app/Livewire/Project/Show.php +++ b/app/Livewire/Project/Show.php @@ -7,7 +7,6 @@ use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; -use Visus\Cuid2\Cuid2; class Show extends Component { @@ -49,7 +48,7 @@ public function submit() $environment = Environment::create([ 'name' => $this->name, 'project_id' => $this->project->id, - 'uuid' => (string) new Cuid2, + 'uuid' => new_public_id(), ]); return redirectRoute($this, 'project.resource.index', [ diff --git a/app/Livewire/Security/CloudInitScriptForm.php b/app/Livewire/Security/CloudInitScriptForm.php index 5e4ca9853..c7f933d39 100644 --- a/app/Livewire/Security/CloudInitScriptForm.php +++ b/app/Livewire/Security/CloudInitScriptForm.php @@ -3,6 +3,7 @@ namespace App\Livewire\Security; use App\Models\CloudInitScript; +use App\Rules\ValidCloudInitYaml; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; @@ -40,7 +41,7 @@ protected function rules(): array { return [ 'name' => 'required|string|max:255', - 'script' => ['required', 'string', new \App\Rules\ValidCloudInitYaml], + 'script' => ['required', 'string', new ValidCloudInitYaml], ]; } @@ -68,17 +69,29 @@ public function save() 'script' => $this->script, ]); + auditLog('ui.cloud_init_script.updated', [ + 'team_id' => currentTeam()->id, + 'cloud_init_script_id' => $cloudInitScript->id, + 'cloud_init_script_name' => $cloudInitScript->name, + ]); + $message = 'Cloud-init script updated successfully.'; } else { // Create new script $this->authorize('create', CloudInitScript::class); - CloudInitScript::create([ + $cloudInitScript = CloudInitScript::create([ 'team_id' => currentTeam()->id, 'name' => $this->name, 'script' => $this->script, ]); + auditLog('ui.cloud_init_script.created', [ + 'team_id' => currentTeam()->id, + 'cloud_init_script_id' => $cloudInitScript->id, + 'cloud_init_script_name' => $cloudInitScript->name, + ]); + $message = 'Cloud-init script created successfully.'; } diff --git a/app/Livewire/Security/CloudInitScripts.php b/app/Livewire/Security/CloudInitScripts.php index 13bcf2caa..57b7324d3 100644 --- a/app/Livewire/Security/CloudInitScripts.php +++ b/app/Livewire/Security/CloudInitScripts.php @@ -36,9 +36,16 @@ public function deleteScript(int $scriptId) $script = CloudInitScript::ownedByCurrentTeam()->findOrFail($scriptId); $this->authorize('delete', $script); + $scriptName = $script->name; $script->delete(); $this->loadScripts(); + auditLog('ui.cloud_init_script.deleted', [ + 'team_id' => currentTeam()->id, + 'cloud_init_script_id' => $scriptId, + 'cloud_init_script_name' => $scriptName, + ]); + $this->dispatch('success', 'Cloud-init script deleted successfully.'); } catch (\Throwable $e) { return handleError($e, $this); diff --git a/app/Livewire/Security/CloudProviderTokenForm.php b/app/Livewire/Security/CloudProviderTokenForm.php index ec4513ff3..6d0efa15f 100644 --- a/app/Livewire/Security/CloudProviderTokenForm.php +++ b/app/Livewire/Security/CloudProviderTokenForm.php @@ -54,7 +54,6 @@ private function validateToken(string $provider, string $token): bool $response = Http::withHeaders([ 'Authorization' => 'Bearer '.$token, ])->timeout(10)->get('https://api.hetzner.cloud/v1/servers'); - ray($response); return $response->successful(); } @@ -85,6 +84,13 @@ public function addToken() 'name' => $this->name, ]); + auditLog('ui.cloud_token.created', [ + 'team_id' => currentTeam()->id, + 'cloud_token_uuid' => $savedToken->uuid, + 'cloud_token_name' => $savedToken->name, + 'provider' => $savedToken->provider, + ]); + $this->reset(['token', 'name']); // Dispatch event with token ID so parent components can react diff --git a/app/Livewire/Security/CloudProviderTokens.php b/app/Livewire/Security/CloudProviderTokens.php index b7f389534..dabb199ed 100644 --- a/app/Livewire/Security/CloudProviderTokens.php +++ b/app/Livewire/Security/CloudProviderTokens.php @@ -4,6 +4,7 @@ use App\Models\CloudProviderToken; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Support\Facades\Http; use Livewire\Component; class CloudProviderTokens extends Component @@ -57,6 +58,14 @@ public function validateToken(int $tokenId) } else { $this->dispatch('error', 'Unknown provider.'); } + + auditLog('ui.cloud_token.validated', [ + 'team_id' => currentTeam()->id, + 'cloud_token_uuid' => $token->uuid, + 'cloud_token_name' => $token->name, + 'provider' => $token->provider, + 'valid' => $isValid ?? false, + ]); } catch (\Throwable $e) { return handleError($e, $this); } @@ -65,7 +74,7 @@ public function validateToken(int $tokenId) private function validateHetznerToken(string $token): bool { try { - $response = \Illuminate\Support\Facades\Http::withToken($token) + $response = Http::withToken($token) ->timeout(10) ->get('https://api.hetzner.cloud/v1/servers?per_page=1'); @@ -78,7 +87,7 @@ private function validateHetznerToken(string $token): bool private function validateDigitalOceanToken(string $token): bool { try { - $response = \Illuminate\Support\Facades\Http::withToken($token) + $response = Http::withToken($token) ->timeout(10) ->get('https://api.digitalocean.com/v2/account'); @@ -102,9 +111,19 @@ public function deleteToken(int $tokenId) return; } + $tokenUuid = $token->uuid; + $tokenName = $token->name; + $tokenProvider = $token->provider; $token->delete(); $this->loadTokens(); + auditLog('ui.cloud_token.deleted', [ + 'team_id' => currentTeam()->id, + 'cloud_token_uuid' => $tokenUuid, + 'cloud_token_name' => $tokenName, + 'provider' => $tokenProvider, + ]); + $this->dispatch('success', 'Cloud provider token deleted successfully.'); } catch (\Throwable $e) { return handleError($e, $this); diff --git a/app/Livewire/Server/CloudProviderToken/Show.php b/app/Livewire/Server/CloudProviderToken/Show.php index 6b22fddc6..e3232d3f3 100644 --- a/app/Livewire/Server/CloudProviderToken/Show.php +++ b/app/Livewire/Server/CloudProviderToken/Show.php @@ -5,6 +5,7 @@ use App\Models\CloudProviderToken; use App\Models\Server; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Support\Facades\Http; use Livewire\Component; class Show extends Component @@ -67,6 +68,16 @@ public function setCloudProviderToken($tokenId) $this->server->cloudProviderToken()->associate($ownedToken); $this->server->save(); + + auditLog('ui.server.cloud_token_assigned', [ + 'team_id' => currentTeam()->id, + 'server_uuid' => $this->server->uuid, + 'server_name' => $this->server->name, + 'cloud_token_uuid' => $ownedToken->uuid, + 'cloud_token_name' => $ownedToken->name, + 'provider' => $ownedToken->provider, + ]); + $this->dispatch('success', 'Hetzner token updated successfully.'); $this->dispatch('refreshServerShow'); } catch (\Exception $e) { @@ -79,7 +90,7 @@ private function validateTokenForServer(CloudProviderToken $token): array { try { // First, validate the token itself - $response = \Illuminate\Support\Facades\Http::withHeaders([ + $response = Http::withHeaders([ 'Authorization' => 'Bearer '.$token->token, ])->timeout(10)->get('https://api.hetzner.cloud/v1/servers'); @@ -92,7 +103,7 @@ private function validateTokenForServer(CloudProviderToken $token): array // Check if this token can access the specific Hetzner server if ($this->server->hetzner_server_id) { - $serverResponse = \Illuminate\Support\Facades\Http::withHeaders([ + $serverResponse = Http::withHeaders([ 'Authorization' => 'Bearer '.$token->token, ])->timeout(10)->get("https://api.hetzner.cloud/v1/servers/{$this->server->hetzner_server_id}"); @@ -123,7 +134,7 @@ public function validateToken() return; } - $response = \Illuminate\Support\Facades\Http::withHeaders([ + $response = Http::withHeaders([ 'Authorization' => 'Bearer '.$token->token, ])->timeout(10)->get('https://api.hetzner.cloud/v1/servers'); @@ -132,6 +143,16 @@ public function validateToken() } else { $this->dispatch('error', 'Hetzner token is invalid or has insufficient permissions.'); } + + auditLog('ui.server.cloud_token_validated', [ + 'team_id' => currentTeam()->id, + 'server_uuid' => $this->server->uuid, + 'server_name' => $this->server->name, + 'cloud_token_uuid' => $token->uuid, + 'cloud_token_name' => $token->name, + 'provider' => $token->provider, + 'valid' => $response->successful(), + ]); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Storage/Resources.php b/app/Livewire/Storage/Resources.php index 0dad2d548..4f39943e4 100644 --- a/app/Livewire/Storage/Resources.php +++ b/app/Livewire/Storage/Resources.php @@ -4,16 +4,21 @@ use App\Models\S3Storage; use App\Models\ScheduledDatabaseBackup; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; class Resources extends Component { + use AuthorizesRequests; + public S3Storage $storage; public array $selectedStorages = []; public function mount(): void { + $this->authorize('view', $this->storage); + $backups = ScheduledDatabaseBackup::where('s3_storage_id', $this->storage->id) ->where('save_s3', true) ->get(); @@ -25,6 +30,8 @@ public function mount(): void public function disableS3(int $backupId): void { + $this->authorize('update', $this->storage); + $backup = ScheduledDatabaseBackup::where('id', $backupId) ->where('s3_storage_id', $this->storage->id) ->firstOrFail(); @@ -41,6 +48,8 @@ public function disableS3(int $backupId): void public function moveBackup(int $backupId): void { + $this->authorize('update', $this->storage); + $backup = ScheduledDatabaseBackup::where('id', $backupId) ->where('s3_storage_id', $this->storage->id) ->firstOrFail(); @@ -62,6 +71,8 @@ public function moveBackup(int $backupId): void return; } + $this->authorize('update', $newStorage); + $backup->update(['s3_storage_id' => $newStorage->id]); unset($this->selectedStorages[$backupId]); diff --git a/app/Livewire/Team/InviteLink.php b/app/Livewire/Team/InviteLink.php index fb30961e9..5b040db71 100644 --- a/app/Livewire/Team/InviteLink.php +++ b/app/Livewire/Team/InviteLink.php @@ -10,7 +10,6 @@ use Illuminate\Support\Facades\Hash; use Illuminate\Support\Str; use Livewire\Component; -use Visus\Cuid2\Cuid2; class InviteLink extends Component { @@ -61,7 +60,7 @@ private function generateInviteLink(bool $sendEmail = false) if ($member_emails->contains($this->email)) { return handleError(livewire: $this, customErrorMessage: "$this->email is already a member of ".currentTeam()->name.'.'); } - $uuid = (string) new Cuid2(32); + $uuid = new_public_id(32); $link = url('/').config('constants.invitation.link.base_url').$uuid; $user = User::whereEmail($this->email)->first(); diff --git a/app/Mcp/Concerns/ResolvesTeam.php b/app/Mcp/Concerns/ResolvesTeam.php index f6d82453a..8e0ae0467 100644 --- a/app/Mcp/Concerns/ResolvesTeam.php +++ b/app/Mcp/Concerns/ResolvesTeam.php @@ -7,15 +7,19 @@ trait ResolvesTeam { - protected function ensureAbility(Request $request, string $ability = 'read'): ?Response + protected function ensureAbility(Request $request, string $ability = 'read', ?string $tool = null): ?Response { $user = $request->user(); if (! $user) { + $this->auditMcpTool($request, $tool, 'denied', ['reason' => 'unauthenticated']); + return Response::error('Unauthenticated.'); } $token = $user->currentAccessToken(); if (! $token) { + $this->auditMcpTool($request, $tool, 'denied', ['reason' => 'invalid_token']); + return Response::error('Invalid token.'); } @@ -23,6 +27,11 @@ protected function ensureAbility(Request $request, string $ability = 'read'): ?R return null; } + $this->auditMcpTool($request, $tool, 'denied', [ + 'reason' => 'missing_ability', + 'required_ability' => $ability, + ]); + return Response::error("Missing required permissions: {$ability}"); } @@ -38,4 +47,28 @@ protected function resolveTeamId(Request $request): ?int return (int) $teamId; } + + protected function mcpSuccess(Request $request, Response $response, array $context = []): Response + { + $this->auditMcpTool($request, $this->name ?? null, 'success', $context); + + return $response; + } + + protected function mcpError(Request $request, string $message, array $context = []): Response + { + $this->auditMcpTool($request, $this->name ?? null, 'error', $context + ['reason' => $message]); + + return Response::error($message); + } + + protected function auditMcpTool(Request $request, ?string $tool, string $outcome, array $context = []): void + { + auditLog('mcp.tool.called', [ + 'tool' => $tool ?: 'unknown', + 'team_id' => $this->resolveTeamId($request), + 'outcome' => $outcome, + ...$context, + ]); + } } diff --git a/app/Mcp/Servers/CoolifyServer.php b/app/Mcp/Servers/CoolifyServer.php index aff7e3f76..2b2d33d60 100644 --- a/app/Mcp/Servers/CoolifyServer.php +++ b/app/Mcp/Servers/CoolifyServer.php @@ -13,13 +13,14 @@ use App\Mcp\Tools\ListServers; use App\Mcp\Tools\ListServices; use Laravel\Mcp\Server; -use Laravel\Mcp\Server\Attributes\Instructions; -use Laravel\Mcp\Server\Attributes\Name; -use Laravel\Mcp\Server\Attributes\Version; -#[Name('Coolify')] -#[Version('0.1.0')] -#[Instructions(<<<'MD' +class CoolifyServer extends Server +{ + protected string $name = 'Coolify'; + + protected string $version = '0.1.0'; + + protected string $instructions = <<<'MD' Read-only MCP server for Coolify, scoped to the authenticated team token. Recommended workflow: @@ -28,9 +29,8 @@ 3. get_server / get_application / get_database / get_service — full details for a single UUID. Every response is `{ data, _actions?, _pagination? }`. `_actions` suggests the next tool + args; `_pagination.next` is the args to call again for the next page. -MD)] -class CoolifyServer extends Server -{ +MD; + protected array $tools = [ GetInfrastructureOverview::class, ListServers::class, diff --git a/app/Mcp/Tools/GetApplication.php b/app/Mcp/Tools/GetApplication.php index f7ac8db77..1d2f9f014 100644 --- a/app/Mcp/Tools/GetApplication.php +++ b/app/Mcp/Tools/GetApplication.php @@ -8,36 +8,36 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; -use Laravel\Mcp\Server\Attributes\Description; -use Laravel\Mcp\Server\Attributes\Name; use Laravel\Mcp\Server\Tool; -#[Name('get_application')] -#[Description('Get full details for a single application by UUID.')] class GetApplication extends Tool { + protected string $name = 'get_application'; + + protected string $description = 'Get full details for a single application by UUID.'; + use BuildsResponse; use ResolvesTeam; public function handle(Request $request): Response { - if ($error = $this->ensureAbility($request, 'read')) { + if ($error = $this->ensureAbility($request, 'read', $this->name)) { return $error; } $teamId = $this->resolveTeamId($request); if (is_null($teamId)) { - return Response::error('Invalid token.'); + return $this->mcpError($request, 'Invalid token.'); } $uuid = $request->get('uuid'); if (! is_string($uuid) || $uuid === '') { - return Response::error('uuid argument is required.'); + return $this->mcpError($request, 'uuid argument is required.'); } $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $uuid)->first(); if (! $application) { - return Response::error("Application [{$uuid}] not found."); + return $this->mcpError($request, "Application [{$uuid}] not found.", ['resource_uuid' => $uuid]); } // Drop relations that the server_status accessor lazy-loads — they @@ -45,10 +45,10 @@ public function handle(Request $request): Response $application->setRelations([]); $application->makeHidden(['destination', 'source', 'additional_servers', 'environment', 'tags', 'environmentVariables']); - return $this->respond( + return $this->mcpSuccess($request, $this->respond( $this->scrubSensitive($application->toArray()), $this->actionsForApplication($uuid, $application->status), - ); + ), ['resource_uuid' => $uuid]); } public function schema(JsonSchema $schema): array diff --git a/app/Mcp/Tools/GetDatabase.php b/app/Mcp/Tools/GetDatabase.php index 4eee9c961..c5d62e3a0 100644 --- a/app/Mcp/Tools/GetDatabase.php +++ b/app/Mcp/Tools/GetDatabase.php @@ -7,46 +7,46 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; -use Laravel\Mcp\Server\Attributes\Description; -use Laravel\Mcp\Server\Attributes\Name; use Laravel\Mcp\Server\Tool; -#[Name('get_database')] -#[Description('Get full details for a standalone database by UUID. Detects type across postgresql, mysql, mariadb, mongodb, redis, keydb, dragonfly, clickhouse.')] class GetDatabase extends Tool { + protected string $name = 'get_database'; + + protected string $description = 'Get full details for a standalone database by UUID. Detects type across postgresql, mysql, mariadb, mongodb, redis, keydb, dragonfly, clickhouse.'; + use BuildsResponse; use ResolvesTeam; public function handle(Request $request): Response { - if ($error = $this->ensureAbility($request, 'read')) { + if ($error = $this->ensureAbility($request, 'read', $this->name)) { return $error; } $teamId = $this->resolveTeamId($request); if (is_null($teamId)) { - return Response::error('Invalid token.'); + return $this->mcpError($request, 'Invalid token.'); } $uuid = $request->get('uuid'); if (! is_string($uuid) || $uuid === '') { - return Response::error('uuid argument is required.'); + return $this->mcpError($request, 'uuid argument is required.'); } $database = queryDatabaseByUuidWithinTeam($uuid, (string) $teamId); if (! $database) { - return Response::error("Database [{$uuid}] not found."); + return $this->mcpError($request, "Database [{$uuid}] not found.", ['resource_uuid' => $uuid]); } // Drop relations so deep server/destination data doesn't leak. $database->setRelations([]); $database->makeHidden(['destination', 'source', 'environment', 'environment_variables', 'environment_variables_preview']); - return $this->respond( + return $this->mcpSuccess($request, $this->respond( $this->scrubSensitive($database->toArray()), $this->actionsForDatabase($uuid, $database->status ?? null), - ); + ), ['resource_uuid' => $uuid]); } public function schema(JsonSchema $schema): array diff --git a/app/Mcp/Tools/GetInfrastructureOverview.php b/app/Mcp/Tools/GetInfrastructureOverview.php index 06e91ff57..6fcafa316 100644 --- a/app/Mcp/Tools/GetInfrastructureOverview.php +++ b/app/Mcp/Tools/GetInfrastructureOverview.php @@ -9,26 +9,26 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; -use Laravel\Mcp\Server\Attributes\Description; -use Laravel\Mcp\Server\Attributes\Name; use Laravel\Mcp\Server\Tool; -#[Name('get_infrastructure_overview')] -#[Description('High-level overview of the authenticated team: Coolify version, all servers, projects with resource counts, and aggregate counts. Start here to understand the setup.')] class GetInfrastructureOverview extends Tool { + protected string $name = 'get_infrastructure_overview'; + + protected string $description = 'High-level overview of the authenticated team: Coolify version, all servers, projects with resource counts, and aggregate counts. Start here to understand the setup.'; + use BuildsResponse; use ResolvesTeam; public function handle(Request $request): Response { - if ($error = $this->ensureAbility($request, 'read')) { + if ($error = $this->ensureAbility($request, 'read', $this->name)) { return $error; } $teamId = $this->resolveTeamId($request); if (is_null($teamId)) { - return Response::error('Invalid token.'); + return $this->mcpError($request, 'Invalid token.'); } $servers = Server::whereTeamId($teamId) @@ -72,7 +72,7 @@ public function handle(Request $request): Response ]; } - return $this->respond([ + return $this->mcpSuccess($request, $this->respond([ 'coolify_version' => config('constants.coolify.version'), 'servers' => $servers, 'projects' => $projectSummaries, @@ -83,7 +83,7 @@ public function handle(Request $request): Response 'services' => $serviceCount, 'databases' => $databaseCount, ], - ]); + ])); } public function schema(JsonSchema $schema): array diff --git a/app/Mcp/Tools/GetServer.php b/app/Mcp/Tools/GetServer.php index fc3e72f14..771aa7d36 100644 --- a/app/Mcp/Tools/GetServer.php +++ b/app/Mcp/Tools/GetServer.php @@ -8,36 +8,36 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; -use Laravel\Mcp\Server\Attributes\Description; -use Laravel\Mcp\Server\Attributes\Name; use Laravel\Mcp\Server\Tool; -#[Name('get_server')] -#[Description('Get full details for a single server by UUID.')] class GetServer extends Tool { + protected string $name = 'get_server'; + + protected string $description = 'Get full details for a single server by UUID.'; + use BuildsResponse; use ResolvesTeam; public function handle(Request $request): Response { - if ($error = $this->ensureAbility($request, 'read')) { + if ($error = $this->ensureAbility($request, 'read', $this->name)) { return $error; } $teamId = $this->resolveTeamId($request); if (is_null($teamId)) { - return Response::error('Invalid token.'); + return $this->mcpError($request, 'Invalid token.'); } $uuid = $request->get('uuid'); if (! is_string($uuid) || $uuid === '') { - return Response::error('uuid argument is required.'); + return $this->mcpError($request, 'uuid argument is required.'); } $server = Server::whereTeamId($teamId)->where('uuid', $uuid)->with('settings')->first(); if (! $server) { - return Response::error("Server [{$uuid}] not found."); + return $this->mcpError($request, "Server [{$uuid}] not found.", ['resource_uuid' => $uuid]); } $data = $this->scrubSensitive($server->toArray()); @@ -45,7 +45,7 @@ public function handle(Request $request): Response $data['is_usable'] = $server->settings?->is_usable; $data['connection_timeout'] = $server->settings?->connection_timeout; - return $this->respond($data, $this->actionsForServer($uuid)); + return $this->mcpSuccess($request, $this->respond($data, $this->actionsForServer($uuid)), ['resource_uuid' => $uuid]); } public function schema(JsonSchema $schema): array diff --git a/app/Mcp/Tools/GetService.php b/app/Mcp/Tools/GetService.php index 475958272..ad14ddb49 100644 --- a/app/Mcp/Tools/GetService.php +++ b/app/Mcp/Tools/GetService.php @@ -8,31 +8,31 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; -use Laravel\Mcp\Server\Attributes\Description; -use Laravel\Mcp\Server\Attributes\Name; use Laravel\Mcp\Server\Tool; -#[Name('get_service')] -#[Description('Get full details for a single service (multi-container stack) by UUID.')] class GetService extends Tool { + protected string $name = 'get_service'; + + protected string $description = 'Get full details for a single service (multi-container stack) by UUID.'; + use BuildsResponse; use ResolvesTeam; public function handle(Request $request): Response { - if ($error = $this->ensureAbility($request, 'read')) { + if ($error = $this->ensureAbility($request, 'read', $this->name)) { return $error; } $teamId = $this->resolveTeamId($request); if (is_null($teamId)) { - return Response::error('Invalid token.'); + return $this->mcpError($request, 'Invalid token.'); } $uuid = $request->get('uuid'); if (! is_string($uuid) || $uuid === '') { - return Response::error('uuid argument is required.'); + return $this->mcpError($request, 'uuid argument is required.'); } $service = Service::whereRelation('environment.project.team', 'id', $teamId) @@ -40,16 +40,16 @@ public function handle(Request $request): Response ->first(); if (! $service) { - return Response::error("Service [{$uuid}] not found."); + return $this->mcpError($request, "Service [{$uuid}] not found.", ['resource_uuid' => $uuid]); } $service->setRelations([]); $service->makeHidden(['destination', 'source', 'environment', 'applications', 'databases', 'serviceApplications', 'serviceDatabases']); - return $this->respond( + return $this->mcpSuccess($request, $this->respond( $this->scrubSensitive($service->toArray()), $this->actionsForService($uuid, $service->status ?? null), - ); + ), ['resource_uuid' => $uuid]); } public function schema(JsonSchema $schema): array diff --git a/app/Mcp/Tools/ListApplications.php b/app/Mcp/Tools/ListApplications.php index 815edd61a..bf31131b2 100644 --- a/app/Mcp/Tools/ListApplications.php +++ b/app/Mcp/Tools/ListApplications.php @@ -8,31 +8,31 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; -use Laravel\Mcp\Server\Attributes\Description; -use Laravel\Mcp\Server\Attributes\Name; use Laravel\Mcp\Server\Tool; -#[Name('list_applications')] -#[Description('List applications owned by the authenticated team. Returns summary (uuid, name, status, fqdn, git_repository). Optional "tag" argument filters by tag name. Use get_application for full details.')] class ListApplications extends Tool { + protected string $name = 'list_applications'; + + protected string $description = 'List applications owned by the authenticated team. Returns summary (uuid, name, status, fqdn, git_repository). Optional "tag" argument filters by tag name. Use get_application for full details.'; + use BuildsResponse; use ResolvesTeam; public function handle(Request $request): Response { - if ($error = $this->ensureAbility($request, 'read')) { + if ($error = $this->ensureAbility($request, 'read', $this->name)) { return $error; } $teamId = $this->resolveTeamId($request); if (is_null($teamId)) { - return Response::error('Invalid token.'); + return $this->mcpError($request, 'Invalid token.'); } $tagName = $request->get('tag'); if ($tagName !== null && (! is_string($tagName) || trim($tagName) === '')) { - return Response::error('tag argument must be a non-empty string.'); + return $this->mcpError($request, 'tag argument must be a non-empty string.'); } $args = $this->paginationArgs($request); @@ -59,11 +59,11 @@ public function handle(Request $request): Response $extra = $tagName ? ['tag' => $tagName] : []; - return $this->respond( + return $this->mcpSuccess($request, $this->respond( $summaries, [], $this->paginationMeta('list_applications', $args, $total, $extra), - ); + )); } public function schema(JsonSchema $schema): array diff --git a/app/Mcp/Tools/ListDatabases.php b/app/Mcp/Tools/ListDatabases.php index 7eb1fde00..98de6ecee 100644 --- a/app/Mcp/Tools/ListDatabases.php +++ b/app/Mcp/Tools/ListDatabases.php @@ -8,26 +8,26 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; -use Laravel\Mcp\Server\Attributes\Description; -use Laravel\Mcp\Server\Attributes\Name; use Laravel\Mcp\Server\Tool; -#[Name('list_databases')] -#[Description('List standalone databases owned by the authenticated team. Returns summary (uuid, name, status, type). Use get_database for full details.')] class ListDatabases extends Tool { + protected string $name = 'list_databases'; + + protected string $description = 'List standalone databases owned by the authenticated team. Returns summary (uuid, name, status, type). Use get_database for full details.'; + use BuildsResponse; use ResolvesTeam; public function handle(Request $request): Response { - if ($error = $this->ensureAbility($request, 'read')) { + if ($error = $this->ensureAbility($request, 'read', $this->name)) { return $error; } $teamId = $this->resolveTeamId($request); if (is_null($teamId)) { - return Response::error('Invalid token.'); + return $this->mcpError($request, 'Invalid token.'); } $args = $this->paginationArgs($request); @@ -52,11 +52,11 @@ public function handle(Request $request): Response ->values() ->all(); - return $this->respond( + return $this->mcpSuccess($request, $this->respond( $summaries, [], $this->paginationMeta('list_databases', $args, $total), - ); + )); } public function schema(JsonSchema $schema): array diff --git a/app/Mcp/Tools/ListProjects.php b/app/Mcp/Tools/ListProjects.php index 9ce1576b9..0a6de7f60 100644 --- a/app/Mcp/Tools/ListProjects.php +++ b/app/Mcp/Tools/ListProjects.php @@ -8,26 +8,26 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; -use Laravel\Mcp\Server\Attributes\Description; -use Laravel\Mcp\Server\Attributes\Name; use Laravel\Mcp\Server\Tool; -#[Name('list_projects')] -#[Description('List projects owned by the authenticated team. Returns summary (uuid, name, description).')] class ListProjects extends Tool { + protected string $name = 'list_projects'; + + protected string $description = 'List projects owned by the authenticated team. Returns summary (uuid, name, description).'; + use BuildsResponse; use ResolvesTeam; public function handle(Request $request): Response { - if ($error = $this->ensureAbility($request, 'read')) { + if ($error = $this->ensureAbility($request, 'read', $this->name)) { return $error; } $teamId = $this->resolveTeamId($request); if (is_null($teamId)) { - return Response::error('Invalid token.'); + return $this->mcpError($request, 'Invalid token.'); } $args = $this->paginationArgs($request); @@ -49,11 +49,11 @@ public function handle(Request $request): Response ->values() ->all(); - return $this->respond( + return $this->mcpSuccess($request, $this->respond( $summaries, [], $this->paginationMeta('list_projects', $args, $total), - ); + )); } public function schema(JsonSchema $schema): array diff --git a/app/Mcp/Tools/ListServers.php b/app/Mcp/Tools/ListServers.php index 20250c454..ed10afc93 100644 --- a/app/Mcp/Tools/ListServers.php +++ b/app/Mcp/Tools/ListServers.php @@ -8,26 +8,26 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; -use Laravel\Mcp\Server\Attributes\Description; -use Laravel\Mcp\Server\Attributes\Name; use Laravel\Mcp\Server\Tool; -#[Name('list_servers')] -#[Description('List servers visible to the authenticated team token. Returns summary (uuid, name, ip, reachability). Use get_server for full details.')] class ListServers extends Tool { + protected string $name = 'list_servers'; + + protected string $description = 'List servers visible to the authenticated team token. Returns summary (uuid, name, ip, reachability). Use get_server for full details.'; + use BuildsResponse; use ResolvesTeam; public function handle(Request $request): Response { - if ($error = $this->ensureAbility($request, 'read')) { + if ($error = $this->ensureAbility($request, 'read', $this->name)) { return $error; } $teamId = $this->resolveTeamId($request); if (is_null($teamId)) { - return Response::error('Invalid token.'); + return $this->mcpError($request, 'Invalid token.'); } $args = $this->paginationArgs($request); @@ -50,11 +50,11 @@ public function handle(Request $request): Response ->values() ->all(); - return $this->respond( + return $this->mcpSuccess($request, $this->respond( $summaries, [], $this->paginationMeta('list_servers', $args, $total), - ); + )); } public function schema(JsonSchema $schema): array diff --git a/app/Mcp/Tools/ListServices.php b/app/Mcp/Tools/ListServices.php index b0bff4fad..3a0ea158a 100644 --- a/app/Mcp/Tools/ListServices.php +++ b/app/Mcp/Tools/ListServices.php @@ -8,26 +8,26 @@ use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; -use Laravel\Mcp\Server\Attributes\Description; -use Laravel\Mcp\Server\Attributes\Name; use Laravel\Mcp\Server\Tool; -#[Name('list_services')] -#[Description('List services (multi-container stacks) owned by the authenticated team. Returns summary (uuid, name, status). Use get_service for full details.')] class ListServices extends Tool { + protected string $name = 'list_services'; + + protected string $description = 'List services (multi-container stacks) owned by the authenticated team. Returns summary (uuid, name, status). Use get_service for full details.'; + use BuildsResponse; use ResolvesTeam; public function handle(Request $request): Response { - if ($error = $this->ensureAbility($request, 'read')) { + if ($error = $this->ensureAbility($request, 'read', $this->name)) { return $error; } $teamId = $this->resolveTeamId($request); if (is_null($teamId)) { - return Response::error('Invalid token.'); + return $this->mcpError($request, 'Invalid token.'); } $args = $this->paginationArgs($request); @@ -49,11 +49,11 @@ public function handle(Request $request): Response ->values() ->all(); - return $this->respond( + return $this->mcpSuccess($request, $this->respond( $summaries, [], $this->paginationMeta('list_services', $args, $total), - ); + )); } public function schema(JsonSchema $schema): array diff --git a/app/Models/Application.php b/app/Models/Application.php index b2f852f15..4c53242ed 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -23,7 +23,6 @@ use Spatie\Activitylog\Models\Activity; use Spatie\Url\Url; use Symfony\Component\Yaml\Yaml; -use Visus\Cuid2\Cuid2; #[OA\Schema( description: 'Application model', @@ -1925,7 +1924,7 @@ public function loadComposeFile($isInit = false, ?string $restoreBaseDirectory = if ($isInit && $this->docker_compose_raw) { return; } - $uuid = new Cuid2; + $uuid = new_public_id(); ['commands' => $cloneCommand] = $this->generateGitImportCommands(deployment_uuid: $uuid, only_checkout: true, exec_in_docker: false, custom_base_dir: 'checkout'); $cloneCommand = str_replace(' clone ', ' clone --quiet ', $cloneCommand); $workdir = rtrim($this->base_directory, '/'); diff --git a/app/Models/ApplicationPreview.php b/app/Models/ApplicationPreview.php index 9159fd0d8..6e4b696d5 100644 --- a/app/Models/ApplicationPreview.php +++ b/app/Models/ApplicationPreview.php @@ -5,7 +5,6 @@ use App\Support\ValidationPatterns; use Illuminate\Database\Eloquent\SoftDeletes; use Spatie\Url\Url; -use Visus\Cuid2\Cuid2; class ApplicationPreview extends BaseModel { @@ -111,7 +110,7 @@ public function generate_preview_fqdn() $port = $portInt !== null ? ':'.$portInt : ''; $urlPath = $url->getPath(); $path = ($urlPath !== '' && $urlPath !== '/') ? $urlPath : ''; - $random = new Cuid2; + $random = new_public_id(); $preview_fqdn = str_replace('{{random}}', $random, $template); $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn); $preview_fqdn = str_replace('{{pr_id}}', $this->pull_request_id, $preview_fqdn); @@ -173,7 +172,7 @@ public function generate_preview_fqdn_compose() $port = $portInt !== null ? ':'.$portInt : ''; $urlPath = $url->getPath(); $path = ($urlPath !== '' && $urlPath !== '/') ? $urlPath : ''; - $random = new Cuid2; + $random = new_public_id(); $preview_fqdn = str_replace('{{random}}', $random, $template); $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn); $preview_fqdn = str_replace('{{pr_id}}', $this->pull_request_id, $preview_fqdn); diff --git a/app/Models/BaseModel.php b/app/Models/BaseModel.php index 727abed5f..d657fbec4 100644 --- a/app/Models/BaseModel.php +++ b/app/Models/BaseModel.php @@ -4,7 +4,6 @@ use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Model; -use Visus\Cuid2\Cuid2; abstract class BaseModel extends Model { @@ -15,7 +14,7 @@ protected static function boot() static::creating(function (Model $model) { // Generate a UUID if one isn't set if (! $model->uuid) { - $model->uuid = (string) new Cuid2; + $model->uuid = new_public_id(); } }); } diff --git a/app/Models/CloudProviderToken.php b/app/Models/CloudProviderToken.php index 026d11fba..35452553b 100644 --- a/app/Models/CloudProviderToken.php +++ b/app/Models/CloudProviderToken.php @@ -2,8 +2,12 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Factories\HasFactory; + class CloudProviderToken extends BaseModel { + use HasFactory; + protected $fillable = [ 'team_id', 'provider', diff --git a/app/Models/PrivateKey.php b/app/Models/PrivateKey.php index 1521678f3..bf42f21c7 100644 --- a/app/Models/PrivateKey.php +++ b/app/Models/PrivateKey.php @@ -4,6 +4,7 @@ use App\Traits\HasSafeStringAttribute; use DanHarrin\LivewireRateLimiting\WithRateLimiting; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Storage; @@ -30,7 +31,7 @@ )] class PrivateKey extends BaseModel { - use HasSafeStringAttribute, WithRateLimiting; + use HasFactory, HasSafeStringAttribute, WithRateLimiting; protected $fillable = [ 'name', diff --git a/app/Models/Project.php b/app/Models/Project.php index 632787a07..b47e7cf04 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -6,7 +6,6 @@ use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use OpenApi\Attributes as OA; -use Visus\Cuid2\Cuid2; #[OA\Schema( description: 'Project model', @@ -59,7 +58,7 @@ protected static function booted() Environment::create([ 'name' => 'production', 'project_id' => $project->id, - 'uuid' => (string) new Cuid2, + 'uuid' => new_public_id(), ]); }); static::deleting(function ($project) { diff --git a/app/Models/Server.php b/app/Models/Server.php index 2b7bbac55..0102b327e 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -37,7 +37,6 @@ use Spatie\Url\Url; use Stevebauman\Purify\Facades\Purify; use Symfony\Component\Yaml\Yaml; -use Visus\Cuid2\Cuid2; /** * @property array{ @@ -1041,7 +1040,7 @@ public function defaultStandaloneDockerAttributes(?int $id = null): array { $attributes = [ 'name' => 'coolify', - 'uuid' => (string) new Cuid2, + 'uuid' => new_public_id(), 'network' => 'coolify', 'server_id' => $this->id, ]; diff --git a/app/Models/Service.php b/app/Models/Service.php index cc8074b74..bf93bfd72 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -16,7 +16,6 @@ use Spatie\Activitylog\Models\Activity; use Spatie\Url\Url; use Symfony\Component\Yaml\Yaml; -use Visus\Cuid2\Cuid2; #[OA\Schema( description: 'Service model', @@ -71,7 +70,7 @@ protected static function booted() { static::creating(function ($service) { if (blank($service->name)) { - $service->name = 'service-'.(new Cuid2); + $service->name = 'service-'.new_public_id(); } }); static::created(function ($service) { @@ -1555,7 +1554,7 @@ public function saveComposeConfigs() "cd $workdir", ], $this->server); - $filename = new Cuid2.'-docker-compose.yml'; + $filename = new_public_id().'-docker-compose.yml'; Storage::disk('local')->put("tmp/{$filename}", $this->docker_compose); $path = Storage::path("tmp/{$filename}"); instant_scp($path, "{$workdir}/docker-compose.yml", $this->server); diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index e473d2875..1ae539174 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -3,9 +3,65 @@ namespace App\Providers; // use Illuminate\Support\Facades\Gate; +use App\Models\Application; +use App\Models\ApplicationPreview; +use App\Models\ApplicationSetting; +use App\Models\CloudInitScript; +use App\Models\CloudProviderToken; +use App\Models\DiscordNotificationSettings; +use App\Models\EmailNotificationSettings; +use App\Models\Environment; +use App\Models\EnvironmentVariable; +use App\Models\GithubApp; +use App\Models\InstanceSettings; +use App\Models\PrivateKey; +use App\Models\Project; +use App\Models\PushoverNotificationSettings; +use App\Models\Server; +use App\Models\Service; +use App\Models\ServiceApplication; +use App\Models\ServiceDatabase; +use App\Models\SharedEnvironmentVariable; +use App\Models\SlackNotificationSettings; +use App\Models\StandaloneClickhouse; +use App\Models\StandaloneDocker; +use App\Models\StandaloneDragonfly; +use App\Models\StandaloneKeydb; +use App\Models\StandaloneMariadb; +use App\Models\StandaloneMongodb; +use App\Models\StandaloneMysql; +use App\Models\StandalonePostgresql; +use App\Models\StandaloneRedis; +use App\Models\SwarmDocker; +use App\Models\Team; +use App\Models\TelegramNotificationSettings; +use App\Models\WebhookNotificationSettings; +use App\Policies\ApiTokenPolicy; +use App\Policies\ApplicationPolicy; +use App\Policies\ApplicationPreviewPolicy; +use App\Policies\ApplicationSettingPolicy; +use App\Policies\CloudInitScriptPolicy; +use App\Policies\CloudProviderTokenPolicy; +use App\Policies\DatabasePolicy; +use App\Policies\EnvironmentPolicy; +use App\Policies\EnvironmentVariablePolicy; +use App\Policies\GithubAppPolicy; +use App\Policies\InstanceSettingsPolicy; +use App\Policies\NotificationPolicy; +use App\Policies\PrivateKeyPolicy; +use App\Policies\ProjectPolicy; use App\Policies\ResourceCreatePolicy; +use App\Policies\ServerPolicy; +use App\Policies\ServiceApplicationPolicy; +use App\Policies\ServiceDatabasePolicy; +use App\Policies\ServicePolicy; +use App\Policies\SharedEnvironmentVariablePolicy; +use App\Policies\StandaloneDockerPolicy; +use App\Policies\SwarmDockerPolicy; +use App\Policies\TeamPolicy; use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider; use Illuminate\Support\Facades\Gate; +use Laravel\Sanctum\PersonalAccessToken; class AuthServiceProvider extends ServiceProvider { @@ -15,49 +71,51 @@ class AuthServiceProvider extends ServiceProvider * @var array */ protected $policies = [ - \App\Models\Server::class => \App\Policies\ServerPolicy::class, - \App\Models\PrivateKey::class => \App\Policies\PrivateKeyPolicy::class, - \App\Models\StandaloneDocker::class => \App\Policies\StandaloneDockerPolicy::class, - \App\Models\SwarmDocker::class => \App\Policies\SwarmDockerPolicy::class, - \App\Models\Application::class => \App\Policies\ApplicationPolicy::class, - \App\Models\ApplicationPreview::class => \App\Policies\ApplicationPreviewPolicy::class, - \App\Models\ApplicationSetting::class => \App\Policies\ApplicationSettingPolicy::class, - \App\Models\Service::class => \App\Policies\ServicePolicy::class, - \App\Models\ServiceApplication::class => \App\Policies\ServiceApplicationPolicy::class, - \App\Models\ServiceDatabase::class => \App\Policies\ServiceDatabasePolicy::class, - \App\Models\Project::class => \App\Policies\ProjectPolicy::class, - \App\Models\Environment::class => \App\Policies\EnvironmentPolicy::class, - \App\Models\EnvironmentVariable::class => \App\Policies\EnvironmentVariablePolicy::class, - \App\Models\SharedEnvironmentVariable::class => \App\Policies\SharedEnvironmentVariablePolicy::class, + Server::class => ServerPolicy::class, + PrivateKey::class => PrivateKeyPolicy::class, + StandaloneDocker::class => StandaloneDockerPolicy::class, + SwarmDocker::class => SwarmDockerPolicy::class, + Application::class => ApplicationPolicy::class, + ApplicationPreview::class => ApplicationPreviewPolicy::class, + ApplicationSetting::class => ApplicationSettingPolicy::class, + Service::class => ServicePolicy::class, + ServiceApplication::class => ServiceApplicationPolicy::class, + ServiceDatabase::class => ServiceDatabasePolicy::class, + Project::class => ProjectPolicy::class, + Environment::class => EnvironmentPolicy::class, + EnvironmentVariable::class => EnvironmentVariablePolicy::class, + SharedEnvironmentVariable::class => SharedEnvironmentVariablePolicy::class, // Database policies - all use the shared DatabasePolicy - \App\Models\StandalonePostgresql::class => \App\Policies\DatabasePolicy::class, - \App\Models\StandaloneMysql::class => \App\Policies\DatabasePolicy::class, - \App\Models\StandaloneMariadb::class => \App\Policies\DatabasePolicy::class, - \App\Models\StandaloneMongodb::class => \App\Policies\DatabasePolicy::class, - \App\Models\StandaloneRedis::class => \App\Policies\DatabasePolicy::class, - \App\Models\StandaloneKeydb::class => \App\Policies\DatabasePolicy::class, - \App\Models\StandaloneDragonfly::class => \App\Policies\DatabasePolicy::class, - \App\Models\StandaloneClickhouse::class => \App\Policies\DatabasePolicy::class, + StandalonePostgresql::class => DatabasePolicy::class, + StandaloneMysql::class => DatabasePolicy::class, + StandaloneMariadb::class => DatabasePolicy::class, + StandaloneMongodb::class => DatabasePolicy::class, + StandaloneRedis::class => DatabasePolicy::class, + StandaloneKeydb::class => DatabasePolicy::class, + StandaloneDragonfly::class => DatabasePolicy::class, + StandaloneClickhouse::class => DatabasePolicy::class, // Notification policies - all use the shared NotificationPolicy - \App\Models\EmailNotificationSettings::class => \App\Policies\NotificationPolicy::class, - \App\Models\DiscordNotificationSettings::class => \App\Policies\NotificationPolicy::class, - \App\Models\TelegramNotificationSettings::class => \App\Policies\NotificationPolicy::class, - \App\Models\SlackNotificationSettings::class => \App\Policies\NotificationPolicy::class, - \App\Models\PushoverNotificationSettings::class => \App\Policies\NotificationPolicy::class, - \App\Models\WebhookNotificationSettings::class => \App\Policies\NotificationPolicy::class, + EmailNotificationSettings::class => NotificationPolicy::class, + DiscordNotificationSettings::class => NotificationPolicy::class, + TelegramNotificationSettings::class => NotificationPolicy::class, + SlackNotificationSettings::class => NotificationPolicy::class, + PushoverNotificationSettings::class => NotificationPolicy::class, + WebhookNotificationSettings::class => NotificationPolicy::class, // API Token policy - \Laravel\Sanctum\PersonalAccessToken::class => \App\Policies\ApiTokenPolicy::class, + PersonalAccessToken::class => ApiTokenPolicy::class, // Instance settings policy - \App\Models\InstanceSettings::class => \App\Policies\InstanceSettingsPolicy::class, + InstanceSettings::class => InstanceSettingsPolicy::class, // Team policy - \App\Models\Team::class => \App\Policies\TeamPolicy::class, + Team::class => TeamPolicy::class, // Git source policies - \App\Models\GithubApp::class => \App\Policies\GithubAppPolicy::class, + GithubApp::class => GithubAppPolicy::class, + CloudProviderToken::class => CloudProviderTokenPolicy::class, + CloudInitScript::class => CloudInitScriptPolicy::class, ]; diff --git a/app/View/Components/Forms/Checkbox.php b/app/View/Components/Forms/Checkbox.php index eb38d84af..e33e4b919 100644 --- a/app/View/Components/Forms/Checkbox.php +++ b/app/View/Components/Forms/Checkbox.php @@ -6,7 +6,6 @@ use Illuminate\Contracts\View\View; use Illuminate\Support\Facades\Gate; use Illuminate\View\Component; -use Visus\Cuid2\Cuid2; class Checkbox extends Component { @@ -58,7 +57,7 @@ public function render(): View|Closure|string // Generate unique HTML ID by adding random suffix // This prevents duplicate IDs when multiple forms are on the same page if ($this->id) { - $uniqueSuffix = new Cuid2; + $uniqueSuffix = new_public_id(); $this->htmlId = $this->id.'-'.$uniqueSuffix; } else { $this->htmlId = $this->id; diff --git a/app/View/Components/Forms/Datalist.php b/app/View/Components/Forms/Datalist.php index 3b7a9ee34..b0f85c8cb 100644 --- a/app/View/Components/Forms/Datalist.php +++ b/app/View/Components/Forms/Datalist.php @@ -6,7 +6,6 @@ use Illuminate\Contracts\View\View; use Illuminate\Support\Facades\Gate; use Illuminate\View\Component; -use Visus\Cuid2\Cuid2; class Datalist extends Component { @@ -55,7 +54,7 @@ public function render(): View|Closure|string $this->modelBinding = $this->id; if (is_null($this->id)) { - $this->id = new Cuid2; + $this->id = new_public_id(); // Don't create wire:model binding for auto-generated IDs $this->modelBinding = 'null'; } @@ -64,7 +63,7 @@ public function render(): View|Closure|string // This prevents duplicate IDs when multiple forms are on the same page if ($this->modelBinding && $this->modelBinding !== 'null') { // Use original ID with random suffix for uniqueness - $uniqueSuffix = new Cuid2; + $uniqueSuffix = new_public_id(); $this->htmlId = $this->modelBinding.'-'.$uniqueSuffix; } else { $this->htmlId = (string) $this->id; diff --git a/app/View/Components/Forms/EnvVarInput.php b/app/View/Components/Forms/EnvVarInput.php index faef64a36..2f26e44cc 100644 --- a/app/View/Components/Forms/EnvVarInput.php +++ b/app/View/Components/Forms/EnvVarInput.php @@ -6,7 +6,6 @@ use Illuminate\Contracts\View\View; use Illuminate\Support\Facades\Gate; use Illuminate\View\Component; -use Visus\Cuid2\Cuid2; class EnvVarInput extends Component { @@ -56,7 +55,7 @@ public function render(): View|Closure|string $this->modelBinding = $this->id; if (is_null($this->id)) { - $this->id = new Cuid2; + $this->id = new_public_id(); // Don't create wire:model binding for auto-generated IDs $this->modelBinding = 'null'; } @@ -64,7 +63,7 @@ public function render(): View|Closure|string // This prevents duplicate IDs when multiple forms are on the same page if ($this->modelBinding && $this->modelBinding !== 'null') { // Use original ID with random suffix for uniqueness - $uniqueSuffix = new Cuid2; + $uniqueSuffix = new_public_id(); $this->htmlId = $this->modelBinding.'-'.$uniqueSuffix; } else { $this->htmlId = (string) $this->id; diff --git a/app/View/Components/Forms/Input.php b/app/View/Components/Forms/Input.php index 5ed347f42..303856926 100644 --- a/app/View/Components/Forms/Input.php +++ b/app/View/Components/Forms/Input.php @@ -5,8 +5,8 @@ use Closure; use Illuminate\Contracts\View\View; use Illuminate\Support\Facades\Gate; +use Illuminate\Support\Str; use Illuminate\View\Component; -use Visus\Cuid2\Cuid2; class Input extends Component { @@ -51,7 +51,7 @@ public function render(): View|Closure|string $this->modelBinding = $this->id; if (is_null($this->id)) { - $this->id = new Cuid2; + $this->id = new_public_id(); // Don't create wire:model binding for auto-generated IDs $this->modelBinding = 'null'; } @@ -59,7 +59,7 @@ public function render(): View|Closure|string // This prevents duplicate IDs when multiple forms are on the same page if ($this->modelBinding && $this->modelBinding !== 'null') { // Use original ID with random suffix for uniqueness - $uniqueSuffix = new Cuid2; + $uniqueSuffix = new_public_id(); $this->htmlId = $this->modelBinding.'-'.$uniqueSuffix; } else { $this->htmlId = (string) $this->id; diff --git a/app/View/Components/Forms/Select.php b/app/View/Components/Forms/Select.php index 026e3ba8c..327c33da6 100644 --- a/app/View/Components/Forms/Select.php +++ b/app/View/Components/Forms/Select.php @@ -6,7 +6,6 @@ use Illuminate\Contracts\View\View; use Illuminate\Support\Facades\Gate; use Illuminate\View\Component; -use Visus\Cuid2\Cuid2; class Select extends Component { @@ -48,7 +47,7 @@ public function render(): View|Closure|string $this->modelBinding = $this->id; if (is_null($this->id)) { - $this->id = new Cuid2; + $this->id = new_public_id(); // Don't create wire:model binding for auto-generated IDs $this->modelBinding = 'null'; } @@ -57,7 +56,7 @@ public function render(): View|Closure|string // This prevents duplicate IDs when multiple forms are on the same page if ($this->modelBinding && $this->modelBinding !== 'null') { // Use original ID with random suffix for uniqueness - $uniqueSuffix = new Cuid2; + $uniqueSuffix = new_public_id(); $this->htmlId = $this->modelBinding.'-'.$uniqueSuffix; } else { $this->htmlId = (string) $this->id; diff --git a/app/View/Components/Forms/Textarea.php b/app/View/Components/Forms/Textarea.php index 02a23a26a..5a5f975c6 100644 --- a/app/View/Components/Forms/Textarea.php +++ b/app/View/Components/Forms/Textarea.php @@ -5,8 +5,8 @@ use Closure; use Illuminate\Contracts\View\View; use Illuminate\Support\Facades\Gate; +use Illuminate\Support\Str; use Illuminate\View\Component; -use Visus\Cuid2\Cuid2; class Textarea extends Component { @@ -63,7 +63,7 @@ public function render(): View|Closure|string $this->modelBinding = $this->id; if (is_null($this->id)) { - $this->id = new Cuid2; + $this->id = new_public_id(); // Don't create wire:model binding for auto-generated IDs $this->modelBinding = 'null'; } @@ -72,7 +72,7 @@ public function render(): View|Closure|string // This prevents duplicate IDs when multiple forms are on the same page if ($this->modelBinding && $this->modelBinding !== 'null') { // Use original ID with random suffix for uniqueness - $uniqueSuffix = new Cuid2; + $uniqueSuffix = new_public_id(); $this->htmlId = $this->modelBinding.'-'.$uniqueSuffix; } else { $this->htmlId = (string) $this->id; diff --git a/bootstrap/helpers/applications.php b/bootstrap/helpers/applications.php index 4707b0a07..b7e4af7ab 100644 --- a/bootstrap/helpers/applications.php +++ b/bootstrap/helpers/applications.php @@ -10,7 +10,6 @@ use App\Models\Server; use App\Models\StandaloneDocker; use Spatie\Url\Url; -use Visus\Cuid2\Cuid2; function queue_application_deployment(Application $application, string $deployment_uuid, ?int $pull_request_id = 0, ?string $commit = null, bool $force_rebuild = false, bool $is_webhook = false, bool $is_api = false, bool $restart_only = false, ?string $git_type = null, bool $no_questions_asked = false, ?Server $server = null, ?StandaloneDocker $destination = null, bool $only_this_server = false, bool $rollback = false, ?string $docker_registry_image_tag = null) { @@ -192,7 +191,7 @@ function next_after_cancel(?Server $server = null) function clone_application(Application $source, $destination, array $overrides = [], bool $cloneVolumeData = false): Application { - $uuid = $overrides['uuid'] ?? (string) new Cuid2; + $uuid = $overrides['uuid'] ?? new_public_id(); $server = $destination->server; if ($server->team_id !== currentTeam()->id) { @@ -259,7 +258,7 @@ function clone_application(Application $source, $destination, array $overrides = 'created_at', 'updated_at', ])->fill([ - 'uuid' => (string) new Cuid2, + 'uuid' => new_public_id(), 'application_id' => $newApplication->id, 'team_id' => currentTeam()->id, ]); @@ -274,7 +273,7 @@ function clone_application(Application $source, $destination, array $overrides = 'created_at', 'updated_at', ])->fill([ - 'uuid' => (string) new Cuid2, + 'uuid' => new_public_id(), 'application_id' => $newApplication->id, 'status' => 'exited', 'fqdn' => null, @@ -322,7 +321,7 @@ function clone_application(Application $source, $destination, array $overrides = VolumeCloneJob::dispatch($sourceVolume, $targetVolume, $sourceServer, $targetServer, $newPersistentVolume); queue_application_deployment( - deployment_uuid: (string) new Cuid2, + deployment_uuid: new_public_id(), application: $source, server: $sourceServer, destination: $source->destination, diff --git a/bootstrap/helpers/databases.php b/bootstrap/helpers/databases.php index 4d5e085f3..5f0b2e690 100644 --- a/bootstrap/helpers/databases.php +++ b/bootstrap/helpers/databases.php @@ -17,12 +17,11 @@ use Illuminate\Support\Collection; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; -use Visus\Cuid2\Cuid2; function create_standalone_postgresql($environmentId, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null, string $databaseImage = 'postgres:16-alpine'): StandalonePostgresql { $database = new StandalonePostgresql; - $database->uuid = (new Cuid2); + $database->uuid = new_public_id(); $database->name = 'postgresql-database-'.$database->uuid; $database->image = $databaseImage; $database->postgres_password = Str::password(length: 64, symbols: false); @@ -40,7 +39,7 @@ function create_standalone_postgresql($environmentId, StandaloneDocker|SwarmDock function create_standalone_redis($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneRedis { $database = new StandaloneRedis; - $database->uuid = (new Cuid2); + $database->uuid = new_public_id(); $database->name = 'redis-database-'.$database->uuid; $redis_password = Str::password(length: 64, symbols: false); @@ -79,7 +78,7 @@ function create_standalone_redis($environment_id, StandaloneDocker|SwarmDocker $ function create_standalone_mongodb($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneMongodb { $database = new StandaloneMongodb; - $database->uuid = (new Cuid2); + $database->uuid = new_public_id(); $database->name = 'mongodb-database-'.$database->uuid; $database->mongo_initdb_root_password = Str::password(length: 64, symbols: false); $database->environment_id = $environment_id; @@ -96,7 +95,7 @@ function create_standalone_mongodb($environment_id, StandaloneDocker|SwarmDocker function create_standalone_mysql($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneMysql { $database = new StandaloneMysql; - $database->uuid = (new Cuid2); + $database->uuid = new_public_id(); $database->name = 'mysql-database-'.$database->uuid; $database->mysql_root_password = Str::password(length: 64, symbols: false); $database->mysql_password = Str::password(length: 64, symbols: false); @@ -114,7 +113,7 @@ function create_standalone_mysql($environment_id, StandaloneDocker|SwarmDocker $ function create_standalone_mariadb($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneMariadb { $database = new StandaloneMariadb; - $database->uuid = (new Cuid2); + $database->uuid = new_public_id(); $database->name = 'mariadb-database-'.$database->uuid; $database->mariadb_root_password = Str::password(length: 64, symbols: false); $database->mariadb_password = Str::password(length: 64, symbols: false); @@ -132,7 +131,7 @@ function create_standalone_mariadb($environment_id, StandaloneDocker|SwarmDocker function create_standalone_keydb($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneKeydb { $database = new StandaloneKeydb; - $database->uuid = (new Cuid2); + $database->uuid = new_public_id(); $database->name = 'keydb-database-'.$database->uuid; $database->keydb_password = Str::password(length: 64, symbols: false); $database->environment_id = $environment_id; @@ -149,7 +148,7 @@ function create_standalone_keydb($environment_id, StandaloneDocker|SwarmDocker $ function create_standalone_dragonfly($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneDragonfly { $database = new StandaloneDragonfly; - $database->uuid = (new Cuid2); + $database->uuid = new_public_id(); $database->name = 'dragonfly-database-'.$database->uuid; $database->dragonfly_password = Str::password(length: 64, symbols: false); $database->environment_id = $environment_id; @@ -166,7 +165,7 @@ function create_standalone_dragonfly($environment_id, StandaloneDocker|SwarmDock function create_standalone_clickhouse($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneClickhouse { $database = new StandaloneClickhouse; - $database->uuid = (new Cuid2); + $database->uuid = new_public_id(); $database->name = 'clickhouse-database-'.$database->uuid; $database->clickhouse_admin_password = Str::password(length: 64, symbols: false); $database->environment_id = $environment_id; diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index 2cf159bfd..1b389c77c 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -9,7 +9,6 @@ use Illuminate\Support\Str; use Spatie\Url\Url; use Symfony\Component\Yaml\Yaml; -use Visus\Cuid2\Cuid2; function getCurrentApplicationContainerStatus(Server $server, int $id, ?int $pullRequestId = null, ?bool $includePullrequests = false): Collection { @@ -459,7 +458,7 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_ foreach ($domains as $loop => $domain) { try { if ($generate_unique_uuid) { - $uuid = new Cuid2; + $uuid = new_public_id(); } $url = Url::fromString($domain); diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index 123cf906a..6632e1fd5 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -14,7 +14,6 @@ use Illuminate\Support\Str; use Spatie\Url\Url; use Symfony\Component\Yaml\Yaml; -use Visus\Cuid2\Cuid2; /** * Validates a Docker Compose YAML string for command injection vulnerabilities. @@ -1240,7 +1239,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $schema = $url->getScheme(); $portInt = $url->getPort(); $port = $portInt !== null ? ':'.$portInt : ''; - $random = new Cuid2; + $random = new_public_id(); $preview_fqdn = str_replace('{{random}}', $random, $template); $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn); $preview_fqdn = str_replace('{{pr_id}}', $pullRequestId, $preview_fqdn); diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index f2b672fef..c3f78ed9a 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -64,7 +64,6 @@ use PurplePixie\PhpDns\DNSTypes; use Spatie\Url\Url; use Symfony\Component\Yaml\Yaml; -use Visus\Cuid2\Cuid2; function base_configuration_dir(): string { @@ -115,6 +114,13 @@ function sanitize_string(?string $input = null): ?string return $sanitized; } +function new_public_id(int $length = 24): string +{ + $length = max(1, $length); + + return Str::lower(Str::random($length)); +} + /** * Validate that a path or identifier is safe for use in shell commands. * @@ -455,7 +461,7 @@ function generate_random_name(?string $cuid = null): string ] ); if (is_null($cuid)) { - $cuid = new Cuid2; + $cuid = new_public_id(); } return Str::kebab("{$generator->getName()}-$cuid"); @@ -491,7 +497,7 @@ function formatPrivateKey(string $privateKey) function generate_application_name(string $git_repository, string $git_branch, ?string $cuid = null): string { if (is_null($cuid)) { - $cuid = new Cuid2; + $cuid = new_public_id(); } $repo_name = str_contains($git_repository, '/') ? last(explode('/', $git_repository)) : $git_repository; @@ -3259,7 +3265,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal $template = $resource->preview_url_template; $host = $url->getHost(); $schema = $url->getScheme(); - $random = new Cuid2; + $random = new_public_id(); $preview_fqdn = str_replace('{{random}}', $random, $template); $preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn); $preview_fqdn = str_replace('{{pr_id}}', $pull_request_id, $preview_fqdn); diff --git a/database/factories/CloudProviderTokenFactory.php b/database/factories/CloudProviderTokenFactory.php new file mode 100644 index 000000000..4da7a2d08 --- /dev/null +++ b/database/factories/CloudProviderTokenFactory.php @@ -0,0 +1,25 @@ + + */ +class CloudProviderTokenFactory extends Factory +{ + protected $model = CloudProviderToken::class; + + public function definition(): array + { + return [ + 'team_id' => Team::factory(), + 'provider' => 'hetzner', + 'token' => 'test-cloud-provider-token', + 'name' => fake()->words(3, true), + ]; + } +} diff --git a/database/factories/PrivateKeyFactory.php b/database/factories/PrivateKeyFactory.php new file mode 100644 index 000000000..51cfdcaa2 --- /dev/null +++ b/database/factories/PrivateKeyFactory.php @@ -0,0 +1,37 @@ + + */ +class PrivateKeyFactory extends Factory +{ + protected $model = PrivateKey::class; + + public function definition(): array + { + return [ + 'name' => fake()->words(2, true), + 'description' => fake()->sentence(), + 'private_key' => $this->privateKey(), + 'team_id' => Team::factory(), + 'is_git_related' => false, + ]; + } + + private function privateKey(): string + { + return '-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk +hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA +AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV +uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== +-----END OPENSSH PRIVATE KEY-----'; + } +} diff --git a/resources/views/livewire/project/database/postgresql/general.blade.php b/resources/views/livewire/project/database/postgresql/general.blade.php index 2f0b14e84..f81fa63fd 100644 --- a/resources/views/livewire/project/database/postgresql/general.blade.php +++ b/resources/views/livewire/project/database/postgresql/general.blade.php @@ -142,7 +142,8 @@
@forelse($initScripts ?? [] as $script) - + @empty
No initialization scripts found.
@endforelse diff --git a/tests/Feature/Api/CloudProviderTokenApiTest.php b/tests/Feature/Api/CloudProviderTokenApiTest.php index da3acfd56..1a24b5eca 100644 --- a/tests/Feature/Api/CloudProviderTokenApiTest.php +++ b/tests/Feature/Api/CloudProviderTokenApiTest.php @@ -1,14 +1,23 @@ whereKey(0)->delete(); + $settings = new InstanceSettings(['is_api_enabled' => true]); + $settings->id = 0; + $settings->save(); + Once::flush(); + // Create a team with owner $this->team = Team::factory()->create(); $this->user = User::factory()->create(); @@ -410,4 +419,33 @@ $response->assertStatus(200); $response->assertJson(['valid' => true, 'message' => 'Token is valid.']); }); + + test('writes an audit log entry when validating a stored token', function () { + $token = CloudProviderToken::factory()->create([ + 'team_id' => $this->team->id, + 'provider' => 'hetzner', + 'name' => 'Audit Token', + ]); + + Http::fake([ + 'https://api.hetzner.cloud/v1/servers' => Http::response([], 200), + ]); + + $auditChannel = Mockery::mock(); + $auditChannel->shouldReceive('info') + ->once() + ->with('api.cloud_token.validated', Mockery::on(function (array $context) use ($token) { + return $context['cloud_token_uuid'] === $token->uuid + && $context['provider'] === 'hetzner' + && $context['valid'] === true; + })); + + Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel); + + $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson("/api/v1/cloud-tokens/{$token->uuid}/validate") + ->assertOk(); + }); }); diff --git a/tests/Feature/Api/HetznerApiTest.php b/tests/Feature/Api/HetznerApiTest.php index b5950f9fc..728b99d0e 100644 --- a/tests/Feature/Api/HetznerApiTest.php +++ b/tests/Feature/Api/HetznerApiTest.php @@ -1,15 +1,23 @@ whereKey(0)->delete(); + $settings = new InstanceSettings(['is_api_enabled' => true]); + $settings->id = 0; + $settings->save(); + Once::flush(); + // Create a team with owner $this->team = Team::factory()->create(); $this->user = User::factory()->create(); @@ -73,6 +81,27 @@ $response->assertStatus(404); }); + + test('member read token cannot use a stored cloud provider token', function () { + $member = User::factory()->create(); + $this->team->members()->attach($member->id, ['role' => 'member']); + session(['currentTeam' => $this->team]); + $memberToken = $member->createToken('member-read', ['read'])->plainTextToken; + + Http::fake([ + 'https://api.hetzner.cloud/v1/locations*' => Http::response([ + 'locations' => [['id' => 1, 'name' => 'nbg1']], + ], 200), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$memberToken, + 'Content-Type' => 'application/json', + ])->getJson('/api/v1/hetzner/locations?cloud_provider_token_id='.$this->hetznerToken->uuid); + + $response->assertForbidden(); + Http::assertNothingSent(); + }); }); describe('GET /api/v1/hetzner/server-types', function () { diff --git a/tests/Feature/Authorization/CloudProviderAuthorizationTest.php b/tests/Feature/Authorization/CloudProviderAuthorizationTest.php index 46185e9bb..04c8d030c 100644 --- a/tests/Feature/Authorization/CloudProviderAuthorizationTest.php +++ b/tests/Feature/Authorization/CloudProviderAuthorizationTest.php @@ -7,8 +7,11 @@ use App\Models\PrivateKey; use App\Models\Team; use App\Models\User; +use App\Policies\CloudInitScriptPolicy; +use App\Policies\CloudProviderTokenPolicy; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Gate; use Illuminate\Support\Str; uses(RefreshDatabase::class); @@ -131,6 +134,11 @@ expect(auth()->user()->can('viewAny', CloudInitScript::class))->toBeTrue(); }); +test('cloud provider and cloud init policies are explicitly registered', function () { + expect(Gate::getPolicyFor(CloudProviderToken::class))->toBeInstanceOf(CloudProviderTokenPolicy::class) + ->and(Gate::getPolicyFor(CloudInitScript::class))->toBeInstanceOf(CloudInitScriptPolicy::class); +}); + // --- Personal Access Token (API Token) Policy --- test('any user can create personal access token', function () { diff --git a/tests/Feature/Mcp/McpEndpointTest.php b/tests/Feature/Mcp/McpEndpointTest.php index ae0101547..b8511afef 100644 --- a/tests/Feature/Mcp/McpEndpointTest.php +++ b/tests/Feature/Mcp/McpEndpointTest.php @@ -6,6 +6,7 @@ use App\Models\Team; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Once; uses(RefreshDatabase::class); @@ -64,6 +65,23 @@ function mcpToolJson($response): array return json_decode($response->json('result.content.0.text'), true); } +function expectMcpAuditLog(array $expected): void +{ + $auditChannel = Mockery::mock(); + + Log::shouldReceive('channel') + ->with('audit') + ->once() + ->andReturn($auditChannel); + + $auditChannel + ->shouldReceive('info') + ->once() + ->with('mcp.tool.called', Mockery::on(fn (array $context) => collect($expected)->every( + fn ($value, $key) => data_get($context, $key) === $value, + ))); +} + test('MCP endpoint returns 404 when the instance setting is disabled', function () { InstanceSettings::query()->where('id', 0)->update(['is_mcp_server_enabled' => false]); Once::flush(); @@ -193,6 +211,48 @@ function mcpToolJson($response): array expect($response->json('result.content.0.text'))->toContain('Missing required permissions'); }); +test('MCP tools audit successful execution with the actual tool name', function () { + Project::create(['name' => 'Mine', 'team_id' => $this->team->id]); + $token = $this->user->createToken('mcp-read', ['read'])->plainTextToken; + + expectMcpAuditLog([ + 'tool' => 'list_projects', + 'team_id' => $this->team->id, + 'outcome' => 'success', + ]); + + mcpCallTool($token, 'list_projects')->assertOk(); +}); + +test('MCP tools audit denied execution after ability checks', function () { + $token = $this->user->createToken('mcp-no-abilities', [])->plainTextToken; + + expectMcpAuditLog([ + 'tool' => 'list_projects', + 'team_id' => $this->team->id, + 'outcome' => 'denied', + ]); + + $response = mcpCallTool($token, 'list_projects'); + $response->assertOk(); + expect($response->json('result.isError'))->toBeTrue(); +}); + +test('MCP tools audit execution errors after tool handling', function () { + $token = $this->user->createToken('mcp-read', ['read'])->plainTextToken; + + expectMcpAuditLog([ + 'tool' => 'get_server', + 'team_id' => $this->team->id, + 'outcome' => 'error', + 'resource_uuid' => 'missing-server', + ]); + + $response = mcpCallTool($token, 'get_server', ['uuid' => 'missing-server']); + $response->assertOk(); + expect($response->json('result.isError'))->toBeTrue(); +}); + test('MCP rejects token when user no longer belongs to token team', function () { Project::create(['name' => 'Hidden', 'team_id' => $this->team->id]); $token = $this->user->createToken('mcp-read', ['read'])->plainTextToken; diff --git a/tests/Feature/MutableLivewireComponentsAuthorizationTest.php b/tests/Feature/MutableLivewireComponentsAuthorizationTest.php new file mode 100644 index 000000000..962628298 --- /dev/null +++ b/tests/Feature/MutableLivewireComponentsAuthorizationTest.php @@ -0,0 +1,30 @@ +toContain($needle); + } +})->with([ + 'storage resources' => [ + 'app/Livewire/Storage/Resources.php', + ['AuthorizesRequests', "authorize('update'", "authorize('view'"], + ], + 'postgres init script editor' => [ + 'app/Livewire/Project/Database/InitScript.php', + ['AuthorizesRequests', "authorize('update'"], + ], + 'execute container command' => [ + 'app/Livewire/Project/Shared/ExecuteContainerCommand.php', + ['AuthorizesRequests', "authorize('view'", "authorize('canAccessTerminal'"], + ], + 'terminal' => [ + 'app/Livewire/Project/Shared/Terminal.php', + ['AuthorizesRequests', "authorize('view'", "authorize('canAccessTerminal'"], + ], +]); diff --git a/tests/Feature/Security/AuditLogTest.php b/tests/Feature/Security/AuditLogTest.php index 34e9168ec..ca1b7bd08 100644 --- a/tests/Feature/Security/AuditLogTest.php +++ b/tests/Feature/Security/AuditLogTest.php @@ -1,19 +1,36 @@ whereKey(0)->exists()) { + $settings = new InstanceSettings; + $settings->id = 0; + $settings->save(); + } + Once::flush(); + $team = Team::factory()->create(); $user = User::factory()->create(); $team->members()->attach($user->id, ['role' => 'owner']); @@ -122,6 +139,89 @@ function makeAuditApplication(string $repo = 'test-org/test-repo'): Application }); }); +describe('security UI audit logging', function () { + test('creating a cloud provider token from Livewire writes an audit entry', function () { + [$team] = makeAuditTeamUser(); + + Http::fake([ + 'https://api.hetzner.cloud/v1/servers' => Http::response([], 200), + ]); + + $auditChannel = Mockery::mock(); + $auditChannel->shouldReceive('info') + ->once() + ->with('ui.cloud_token.created', Mockery::on(function (array $context) use ($team) { + return $context['team_id'] === $team->id + && $context['provider'] === 'hetzner' + && $context['cloud_token_name'] === 'UI Token'; + })); + + Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel); + + Livewire::test(CloudProviderTokenForm::class) + ->set('provider', 'hetzner') + ->set('token', 'secret-token') + ->set('name', 'UI Token') + ->call('addToken'); + }); + + test('deleting a cloud provider token from Livewire writes an audit entry', function () { + [$team] = makeAuditTeamUser(); + $token = CloudProviderToken::factory()->create([ + 'team_id' => $team->id, + 'provider' => 'hetzner', + 'name' => 'Delete Me', + ]); + + $auditChannel = Mockery::mock(); + $auditChannel->shouldReceive('info') + ->once() + ->with('ui.cloud_token.deleted', Mockery::on(function (array $context) use ($token) { + return $context['cloud_token_uuid'] === $token->uuid + && $context['cloud_token_name'] === 'Delete Me'; + })); + + Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel); + + Livewire::test(CloudProviderTokens::class) + ->call('deleteToken', $token->id); + }); + + test('creating and deleting cloud-init scripts from Livewire write audit entries', function () { + [$team] = makeAuditTeamUser(); + + $auditChannel = Mockery::mock(); + $auditChannel->shouldReceive('info') + ->once() + ->with('ui.cloud_init_script.created', Mockery::on(function (array $context) use ($team) { + return $context['team_id'] === $team->id + && $context['cloud_init_script_name'] === 'Bootstrap'; + })); + $auditChannel->shouldReceive('info') + ->once() + ->with('ui.cloud_init_script.deleted', Mockery::on(function (array $context) { + return $context['cloud_init_script_name'] === 'Bootstrap'; + })); + + Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel); + + Livewire::test(CloudInitScriptForm::class) + ->set('name', 'Bootstrap') + ->set('script', "#cloud-config\npackages: []") + ->call('save'); + + $script = CloudInitScript::where('team_id', $team->id)->firstOrFail(); + + Livewire::test(CloudInitScripts::class) + ->call('deleteScript', $script->id); + }); + + test('cloud provider token form does not contain debug ray calls', function () { + expect(file_get_contents(app_path('Livewire/Security/CloudProviderTokenForm.php'))) + ->not->toContain('ray('); + }); +}); + describe('webhook signature failure logging', function () { test('GitHub manual webhook with bad signature logs to audit channel', function () { $app = makeAuditApplication(); diff --git a/tests/Feature/SentinelPushDeduplicationTest.php b/tests/Feature/SentinelPushDeduplicationTest.php index a7ecd5d4c..fb7b2df2e 100644 --- a/tests/Feature/SentinelPushDeduplicationTest.php +++ b/tests/Feature/SentinelPushDeduplicationTest.php @@ -8,6 +8,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Queue; uses(RefreshDatabase::class); @@ -81,6 +82,23 @@ function sentinelPayload(array $containers, ?float $diskPercentage = 42.0): arra Queue::assertPushed(PushServerUpdateJob::class, 1); }); +it('only audits sentinel pushes that dispatch a state update', function () use ($running) { + $auditChannel = Mockery::mock(); + $auditChannel->shouldReceive('info') + ->once() + ->with('sentinel.metrics_pushed', Mockery::on(function (array $context) { + return $context['server_uuid'] === $this->server->uuid + && $context['team_id'] === $this->team->id; + })); + + Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel); + + pushSentinel($this->token, sentinelPayload($running()))->assertOk(); + pushSentinel($this->token, sentinelPayload($running()))->assertOk(); + + Queue::assertPushed(PushServerUpdateJob::class, 1); +}); + it('updates the heartbeat even when the job is skipped', function () use ($running) { pushSentinel($this->token, sentinelPayload($running()))->assertOk(); diff --git a/tests/Unit/PublicIdTest.php b/tests/Unit/PublicIdTest.php new file mode 100644 index 000000000..027d46e57 --- /dev/null +++ b/tests/Unit/PublicIdTest.php @@ -0,0 +1,19 @@ +map(fn () => new_public_id()); + + expect($ids)->each + ->toBeString() + ->toHaveLength(24) + ->toMatch('/^[a-z0-9]+$/'); + + expect($ids->unique())->toHaveCount(100); +}); + +it('honors custom public id lengths', function () { + expect(new_public_id(32)) + ->toBeString() + ->toHaveLength(32) + ->toMatch('/^[a-z0-9]+$/'); +}); From 5973bb4d4f3c236d76ac25cb77c22e5317d5379f Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 4 Jun 2026 11:24:19 +0200 Subject: [PATCH 053/151] fix(security): hide notification secrets from non-admins Prevent users without update permission from reading notification credentials and manual webhook secrets in Livewire state or rendered forms. --- app/Livewire/Notifications/Discord.php | 4 +- app/Livewire/Notifications/Pushover.php | 9 ++- app/Livewire/Notifications/Slack.php | 4 +- app/Livewire/Notifications/Telegram.php | 9 ++- app/Livewire/Notifications/Webhook.php | 4 +- app/Livewire/Project/Shared/Webhooks.php | 19 +++-- .../livewire/notifications/discord.blade.php | 12 ++- .../livewire/notifications/pushover.blade.php | 21 ++++-- .../livewire/notifications/slack.blade.php | 12 ++- .../livewire/notifications/telegram.blade.php | 21 ++++-- .../livewire/notifications/webhook.blade.php | 12 ++- .../project/shared/webhooks.blade.php | 16 ++-- .../NotificationAuthorizationTest.php | 74 ++++++++++++++++++- .../SharedResourceAuthorizationTest.php | 47 +++++++++++- 14 files changed, 219 insertions(+), 45 deletions(-) diff --git a/app/Livewire/Notifications/Discord.php b/app/Livewire/Notifications/Discord.php index ab3884320..59350a3e1 100644 --- a/app/Livewire/Notifications/Discord.php +++ b/app/Livewire/Notifications/Discord.php @@ -110,7 +110,9 @@ public function syncData(bool $toModel = false) refreshSession(); } else { $this->discordEnabled = $this->settings->discord_enabled; - $this->discordWebhookUrl = $this->settings->discord_webhook_url; + $this->discordWebhookUrl = auth()->user()->can('update', $this->settings) + ? $this->settings->discord_webhook_url + : null; $this->deploymentSuccessDiscordNotifications = $this->settings->deployment_success_discord_notifications; $this->deploymentFailureDiscordNotifications = $this->settings->deployment_failure_discord_notifications; diff --git a/app/Livewire/Notifications/Pushover.php b/app/Livewire/Notifications/Pushover.php index d79eea87b..f894c5005 100644 --- a/app/Livewire/Notifications/Pushover.php +++ b/app/Livewire/Notifications/Pushover.php @@ -113,8 +113,13 @@ public function syncData(bool $toModel = false) refreshSession(); } else { $this->pushoverEnabled = $this->settings->pushover_enabled; - $this->pushoverUserKey = $this->settings->pushover_user_key; - $this->pushoverApiToken = $this->settings->pushover_api_token; + if (auth()->user()->can('update', $this->settings)) { + $this->pushoverUserKey = $this->settings->pushover_user_key; + $this->pushoverApiToken = $this->settings->pushover_api_token; + } else { + $this->pushoverUserKey = null; + $this->pushoverApiToken = null; + } $this->deploymentSuccessPushoverNotifications = $this->settings->deployment_success_pushover_notifications; $this->deploymentFailurePushoverNotifications = $this->settings->deployment_failure_pushover_notifications; diff --git a/app/Livewire/Notifications/Slack.php b/app/Livewire/Notifications/Slack.php index f870b3986..58cab5494 100644 --- a/app/Livewire/Notifications/Slack.php +++ b/app/Livewire/Notifications/Slack.php @@ -110,7 +110,9 @@ public function syncData(bool $toModel = false) refreshSession(); } else { $this->slackEnabled = $this->settings->slack_enabled; - $this->slackWebhookUrl = $this->settings->slack_webhook_url; + $this->slackWebhookUrl = auth()->user()->can('update', $this->settings) + ? $this->settings->slack_webhook_url + : null; $this->deploymentSuccessSlackNotifications = $this->settings->deployment_success_slack_notifications; $this->deploymentFailureSlackNotifications = $this->settings->deployment_failure_slack_notifications; diff --git a/app/Livewire/Notifications/Telegram.php b/app/Livewire/Notifications/Telegram.php index fc3966cf6..78eb7ef9f 100644 --- a/app/Livewire/Notifications/Telegram.php +++ b/app/Livewire/Notifications/Telegram.php @@ -169,8 +169,13 @@ public function syncData(bool $toModel = false) $this->settings->save(); } else { $this->telegramEnabled = $this->settings->telegram_enabled; - $this->telegramToken = $this->settings->telegram_token; - $this->telegramChatId = $this->settings->telegram_chat_id; + if (auth()->user()->can('update', $this->settings)) { + $this->telegramToken = $this->settings->telegram_token; + $this->telegramChatId = $this->settings->telegram_chat_id; + } else { + $this->telegramToken = null; + $this->telegramChatId = null; + } $this->deploymentSuccessTelegramNotifications = $this->settings->deployment_success_telegram_notifications; $this->deploymentFailureTelegramNotifications = $this->settings->deployment_failure_telegram_notifications; diff --git a/app/Livewire/Notifications/Webhook.php b/app/Livewire/Notifications/Webhook.php index 630d422a9..4a67180ff 100644 --- a/app/Livewire/Notifications/Webhook.php +++ b/app/Livewire/Notifications/Webhook.php @@ -105,7 +105,9 @@ public function syncData(bool $toModel = false) refreshSession(); } else { $this->webhookEnabled = $this->settings->webhook_enabled; - $this->webhookUrl = $this->settings->webhook_url; + $this->webhookUrl = auth()->user()->can('update', $this->settings) + ? $this->settings->webhook_url + : null; $this->deploymentSuccessWebhookNotifications = $this->settings->deployment_success_webhook_notifications; $this->deploymentFailureWebhookNotifications = $this->settings->deployment_failure_webhook_notifications; diff --git a/app/Livewire/Project/Shared/Webhooks.php b/app/Livewire/Project/Shared/Webhooks.php index eafc653d5..eb90262e9 100644 --- a/app/Livewire/Project/Shared/Webhooks.php +++ b/app/Livewire/Project/Shared/Webhooks.php @@ -34,19 +34,24 @@ public function mount() { $this->deploywebhook = generateDeployWebhook($this->resource); - $this->githubManualWebhookSecret = data_get($this->resource, 'manual_webhook_secret_github'); + if ($this->canViewSecrets()) { + $this->githubManualWebhookSecret = data_get($this->resource, 'manual_webhook_secret_github'); + $this->gitlabManualWebhookSecret = data_get($this->resource, 'manual_webhook_secret_gitlab'); + $this->bitbucketManualWebhookSecret = data_get($this->resource, 'manual_webhook_secret_bitbucket'); + $this->giteaManualWebhookSecret = data_get($this->resource, 'manual_webhook_secret_gitea'); + } + $this->githubManualWebhook = generateGitManualWebhook($this->resource, 'github'); - - $this->gitlabManualWebhookSecret = data_get($this->resource, 'manual_webhook_secret_gitlab'); $this->gitlabManualWebhook = generateGitManualWebhook($this->resource, 'gitlab'); - - $this->bitbucketManualWebhookSecret = data_get($this->resource, 'manual_webhook_secret_bitbucket'); $this->bitbucketManualWebhook = generateGitManualWebhook($this->resource, 'bitbucket'); - - $this->giteaManualWebhookSecret = data_get($this->resource, 'manual_webhook_secret_gitea'); $this->giteaManualWebhook = generateGitManualWebhook($this->resource, 'gitea'); } + public function canViewSecrets(): bool + { + return auth()->user()->can('update', $this->resource); + } + public function submit() { try { diff --git a/resources/views/livewire/notifications/discord.blade.php b/resources/views/livewire/notifications/discord.blade.php index 0e5406c78..64e445441 100644 --- a/resources/views/livewire/notifications/discord.blade.php +++ b/resources/views/livewire/notifications/discord.blade.php @@ -26,9 +26,15 @@ helper="If enabled, a ping (@here) will be sent to the notification when a critical event happens." label="Ping Enabled" />
- + @can('update', $settings) + + @else + + @endcan

Notification Settings

diff --git a/resources/views/livewire/notifications/pushover.blade.php b/resources/views/livewire/notifications/pushover.blade.php index 74cd9e8d2..4ea1a159c 100644 --- a/resources/views/livewire/notifications/pushover.blade.php +++ b/resources/views/livewire/notifications/pushover.blade.php @@ -24,12 +24,21 @@

- - + @can('update', $settings) + + + @else + + + @endcan

Notification Settings

diff --git a/resources/views/livewire/notifications/slack.blade.php b/resources/views/livewire/notifications/slack.blade.php index 14c7b3508..9ab752291 100644 --- a/resources/views/livewire/notifications/slack.blade.php +++ b/resources/views/livewire/notifications/slack.blade.php @@ -23,9 +23,15 @@
- + @can('update', $settings) + + @else + + @endcan

Notification Settings

diff --git a/resources/views/livewire/notifications/telegram.blade.php b/resources/views/livewire/notifications/telegram.blade.php index f87a13c37..98ec128d5 100644 --- a/resources/views/livewire/notifications/telegram.blade.php +++ b/resources/views/livewire/notifications/telegram.blade.php @@ -24,12 +24,21 @@

- - + @can('update', $settings) + + + @else + + + @endcan

Notification Settings

diff --git a/resources/views/livewire/notifications/webhook.blade.php b/resources/views/livewire/notifications/webhook.blade.php index 7c32311bf..4bacb6091 100644 --- a/resources/views/livewire/notifications/webhook.blade.php +++ b/resources/views/livewire/notifications/webhook.blade.php @@ -28,9 +28,15 @@ class="normal-case dark:text-white btn btn-xs no-animation btn-primary">
- + @can('update', $settings) + + @else + + @endcan

Notification Settings

diff --git a/resources/views/livewire/project/shared/webhooks.blade.php b/resources/views/livewire/project/shared/webhooks.blade.php index 24bba525a..bccc31383 100644 --- a/resources/views/livewire/project/shared/webhooks.blade.php +++ b/resources/views/livewire/project/shared/webhooks.blade.php @@ -22,9 +22,9 @@ helper="Need to set a secret to be able to use this webhook. It should match with the secret in GitHub." label="GitHub Webhook Secret" id="githubManualWebhookSecret">
@else - + label="GitHub Webhook Secret" value="Hidden (only admins can view)">
@endcan
@@ -39,9 +39,9 @@ helper="Need to set a secret to be able to use this webhook. It should match with the secret in GitLab." label="GitLab Webhook Secret" id="gitlabManualWebhookSecret"> @else - + label="GitLab Webhook Secret" value="Hidden (only admins can view)"> @endcan
@@ -51,9 +51,9 @@ helper="Need to set a secret to be able to use this webhook. It should match with the secret in Bitbucket." label="Bitbucket Webhook Secret" id="bitbucketManualWebhookSecret"> @else - + label="Bitbucket Webhook Secret" value="Hidden (only admins can view)"> @endcan
@@ -63,9 +63,9 @@ helper="Need to set a secret to be able to use this webhook. It should match with the secret in Gitea." label="Gitea Webhook Secret" id="giteaManualWebhookSecret"> @else - + label="Gitea Webhook Secret" value="Hidden (only admins can view)"> @endcan
@can('update', $resource) diff --git a/tests/Feature/Authorization/NotificationAuthorizationTest.php b/tests/Feature/Authorization/NotificationAuthorizationTest.php index 7a7ac94aa..aa9d1cbdc 100644 --- a/tests/Feature/Authorization/NotificationAuthorizationTest.php +++ b/tests/Feature/Authorization/NotificationAuthorizationTest.php @@ -15,7 +15,7 @@ uses(RefreshDatabase::class); beforeEach(function () { - InstanceSettings::updateOrCreate(['id' => 0]); + InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['id' => 0])); $this->team = Team::factory()->create(); @@ -275,3 +275,75 @@ expect($this->member->can('sendTest', $this->team->pushoverNotificationSettings))->toBeFalse(); expect($this->member->can('sendTest', $this->team->webhookNotificationSettings))->toBeFalse(); }); + +test('member cannot view notification secrets', function (string $component, string $settingsRelation, array $secrets) { + $settings = $this->team->{$settingsRelation}; + $settings->update($secrets); + + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + $componentTest = Livewire::test($component); + + foreach ($secrets as $column => $value) { + $property = str($column)->camel()->toString(); + + $componentTest + ->assertSet($property, null) + ->assertDontSee($value); + } + + $componentTest->assertSee('Hidden (only admins can view)'); +})->with([ + 'discord webhook' => [DiscordNotification::class, 'discordNotificationSettings', [ + 'discord_webhook_url' => 'https://discord.com/api/webhooks/secret-member', + ]], + 'slack webhook' => [SlackNotification::class, 'slackNotificationSettings', [ + 'slack_webhook_url' => 'https://hooks.slack.com/services/secret-member', + ]], + 'telegram token and chat id' => [TelegramNotification::class, 'telegramNotificationSettings', [ + 'telegram_token' => 'telegram-secret-token', + 'telegram_chat_id' => 'telegram-secret-chat', + ]], + 'pushover credentials' => [PushoverNotification::class, 'pushoverNotificationSettings', [ + 'pushover_user_key' => 'pushover-secret-user', + 'pushover_api_token' => 'pushover-secret-token', + ]], + 'generic webhook' => [WebhookNotification::class, 'webhookNotificationSettings', [ + 'webhook_url' => 'https://example.com/secret-webhook', + ]], +]); + +test('admin can view notification secrets', function (string $component, string $settingsRelation, array $secrets) { + $settings = $this->team->{$settingsRelation}; + $settings->update($secrets); + + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + $componentTest = Livewire::test($component); + + foreach ($secrets as $column => $value) { + $property = str($column)->camel()->toString(); + + $componentTest->assertSet($property, $value); + } +})->with([ + 'discord webhook' => [DiscordNotification::class, 'discordNotificationSettings', [ + 'discord_webhook_url' => 'https://discord.com/api/webhooks/secret-admin', + ]], + 'slack webhook' => [SlackNotification::class, 'slackNotificationSettings', [ + 'slack_webhook_url' => 'https://hooks.slack.com/services/secret-admin', + ]], + 'telegram token and chat id' => [TelegramNotification::class, 'telegramNotificationSettings', [ + 'telegram_token' => 'telegram-admin-token', + 'telegram_chat_id' => 'telegram-admin-chat', + ]], + 'pushover credentials' => [PushoverNotification::class, 'pushoverNotificationSettings', [ + 'pushover_user_key' => 'pushover-admin-user', + 'pushover_api_token' => 'pushover-admin-token', + ]], + 'generic webhook' => [WebhookNotification::class, 'webhookNotificationSettings', [ + 'webhook_url' => 'https://example.com/admin-webhook', + ]], +]); diff --git a/tests/Feature/Authorization/SharedResourceAuthorizationTest.php b/tests/Feature/Authorization/SharedResourceAuthorizationTest.php index 23f134458..57e46c418 100644 --- a/tests/Feature/Authorization/SharedResourceAuthorizationTest.php +++ b/tests/Feature/Authorization/SharedResourceAuthorizationTest.php @@ -20,7 +20,7 @@ uses(RefreshDatabase::class); beforeEach(function () { - InstanceSettings::updateOrCreate(['id' => 0]); + InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['id' => 0])); $this->team = Team::factory()->create(); @@ -167,6 +167,51 @@ expect($this->admin->can('update', $this->application))->toBeTrue(); }); +test('member cannot view application webhook secrets', function () { + $this->application->update([ + 'git_repository' => 'coollabsio/coolify', + 'git_branch' => 'main', + 'manual_webhook_secret_github' => 'github-secret-value', + 'manual_webhook_secret_gitlab' => 'gitlab-secret-value', + 'manual_webhook_secret_bitbucket' => 'bitbucket-secret-value', + 'manual_webhook_secret_gitea' => 'gitea-secret-value', + ]); + + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::test(Webhooks::class, ['resource' => $this->application->fresh()]) + ->assertSet('githubManualWebhookSecret', null) + ->assertSet('gitlabManualWebhookSecret', null) + ->assertSet('bitbucketManualWebhookSecret', null) + ->assertSet('giteaManualWebhookSecret', null) + ->assertSee('Hidden (only admins can view)') + ->assertDontSee('github-secret-value') + ->assertDontSee('gitlab-secret-value') + ->assertDontSee('bitbucket-secret-value') + ->assertDontSee('gitea-secret-value'); +}); + +test('admin can view application webhook secrets', function () { + $this->application->update([ + 'git_repository' => 'coollabsio/coolify', + 'git_branch' => 'main', + 'manual_webhook_secret_github' => 'github-secret-value', + 'manual_webhook_secret_gitlab' => 'gitlab-secret-value', + 'manual_webhook_secret_bitbucket' => 'bitbucket-secret-value', + 'manual_webhook_secret_gitea' => 'gitea-secret-value', + ]); + + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + Livewire::test(Webhooks::class, ['resource' => $this->application->fresh()]) + ->assertSet('githubManualWebhookSecret', 'github-secret-value') + ->assertSet('gitlabManualWebhookSecret', 'gitlab-secret-value') + ->assertSet('bitbucketManualWebhookSecret', 'bitbucket-secret-value') + ->assertSet('giteaManualWebhookSecret', 'gitea-secret-value'); +}); + // --- Resource Limits (policy checks, mount requires full resource data) --- test('member cannot update application resource limits', function () { From 8a2373e49f9017d507165e382d65a02099f5505b Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 4 Jun 2026 11:37:16 +0200 Subject: [PATCH 054/151] fix(ui): standardize permission denial callouts --- .../livewire/destination/new/docker.blade.php | 2 +- .../shared/resource-operations.blade.php | 15 +--- .../livewire/project/shared/tags.blade.php | 4 +- .../server/cloudflare-tunnel.blade.php | 13 ++- .../views/livewire/server/proxy.blade.php | 2 +- .../livewire/source/github/create.blade.php | 2 +- .../views/livewire/storage/create.blade.php | 2 +- .../livewire/subscription/index.blade.php | 2 +- .../Feature/ResourcePermissionCalloutTest.php | 82 +++++++++++++++++++ 9 files changed, 98 insertions(+), 26 deletions(-) create mode 100644 tests/Feature/ResourcePermissionCalloutTest.php diff --git a/resources/views/livewire/destination/new/docker.blade.php b/resources/views/livewire/destination/new/docker.blade.php index 953c4d42e..2be64d289 100644 --- a/resources/views/livewire/destination/new/docker.blade.php +++ b/resources/views/livewire/destination/new/docker.blade.php @@ -18,7 +18,7 @@ @else - + You don't have permission to create new destinations. Please contact your team administrator for access. @endcan diff --git a/resources/views/livewire/project/shared/resource-operations.blade.php b/resources/views/livewire/project/shared/resource-operations.blade.php index 658a7cbd3..0c3c8885c 100644 --- a/resources/views/livewire/project/shared/resource-operations.blade.php +++ b/resources/views/livewire/project/shared/resource-operations.blade.php @@ -101,16 +101,10 @@ - @else - - You don't have permission to clone resources. Contact your team administrator to request access. - - @endcan -

Move Resource

-
Transfer this resource between projects and environments.
+

Move Resource

+
Transfer this resource between projects and environments.
- @can('update', $resource) @if ($projects->count() > 0)
@@ -160,9 +154,8 @@
@endif @else - - You don't have permission to move resources between projects or environments. Contact your team - administrator to request access. + + You don't have permission to modify this resource. Contact your team administrator for access. @endcan
diff --git a/resources/views/livewire/project/shared/tags.blade.php b/resources/views/livewire/project/shared/tags.blade.php index 85208b75b..72b8214a9 100644 --- a/resources/views/livewire/project/shared/tags.blade.php +++ b/resources/views/livewire/project/shared/tags.blade.php @@ -10,8 +10,8 @@ Add @else - - You don't have permission to manage tags. Contact your team administrator to request access. + + You don't have permission to manage this resource. Contact your team administrator for access. @endcan @if (data_get($this->resource, 'tags') && count(data_get($this->resource, 'tags')) > 0) diff --git a/resources/views/livewire/server/cloudflare-tunnel.blade.php b/resources/views/livewire/server/cloudflare-tunnel.blade.php index 2ebac9d41..668fae8f1 100644 --- a/resources/views/livewire/server/cloudflare-tunnel.blade.php +++ b/resources/views/livewire/server/cloudflare-tunnel.blade.php @@ -71,6 +71,11 @@ class="underline">documentation
. @endif @if (!$isCloudflareTunnelsEnabled && $server->isFunctional()) + @cannot('update', $server) + + You don't have permission to configure Cloudflare Tunnel for this server. + + @endcannot

Automated

helper="The SSH domain you configured in Cloudflare. Make sure there is no protocol like http(s):// so you provide a FQDN not a URL. Documentation" /> Continue - @else - - You don't have permission to configure Cloudflare Tunnel for this server. - @endcan
@script @@ -121,10 +122,6 @@ class="flex flex-col gap-2 w-full"> ]" confirmationText="I manually configured Cloudflare Tunnel" confirmationLabel="Please type the confirmation text to confirm that you manually configured Cloudflare Tunnel." shortConfirmationLabel="Confirmation text" /> - @else - - You don't have permission to configure Cloudflare Tunnel for this server. - @endcan @endif diff --git a/resources/views/livewire/server/proxy.blade.php b/resources/views/livewire/server/proxy.blade.php index 127f583d6..143149abb 100644 --- a/resources/views/livewire/server/proxy.blade.php +++ b/resources/views/livewire/server/proxy.blade.php @@ -174,7 +174,7 @@ class="underline text-white">Traefik changelog to understand breaking change
--}} @else - + You don't have permission to configure proxy settings for this server. @endcan diff --git a/resources/views/livewire/source/github/create.blade.php b/resources/views/livewire/source/github/create.blade.php index 9d5189b43..eedb8809c 100644 --- a/resources/views/livewire/source/github/create.blade.php +++ b/resources/views/livewire/source/github/create.blade.php @@ -63,7 +63,7 @@ class="flex items-center justify-between w-full px-1 py-2 text-left select-none @else - + You don't have permission to create new GitHub Apps. Please contact your team administrator for access. @endcan \ No newline at end of file diff --git a/resources/views/livewire/storage/create.blade.php b/resources/views/livewire/storage/create.blade.php index 78ac717a1..32aeda095 100644 --- a/resources/views/livewire/storage/create.blade.php +++ b/resources/views/livewire/storage/create.blade.php @@ -24,7 +24,7 @@ @else - + You don't have permission to create new S3 storage configurations. Please contact your team administrator for access. diff --git a/resources/views/livewire/subscription/index.blade.php b/resources/views/livewire/subscription/index.blade.php index c78af77f9..dc18f54d4 100644 --- a/resources/views/livewire/subscription/index.blade.php +++ b/resources/views/livewire/subscription/index.blade.php @@ -54,7 +54,7 @@

Subscription

- + You are not an admin so you cannot manage your Team's subscription. If this does not make sense, please contact us. diff --git a/tests/Feature/ResourcePermissionCalloutTest.php b/tests/Feature/ResourcePermissionCalloutTest.php new file mode 100644 index 000000000..36e8100c7 --- /dev/null +++ b/tests/Feature/ResourcePermissionCalloutTest.php @@ -0,0 +1,82 @@ +create(); + $team = Team::factory()->create(); + $user->teams()->attach($team, ['role' => $role]); + + $server = Server::factory()->create(['team_id' => $team->id]); + $destination = $server->standaloneDockers()->firstOrFail(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + + test()->actingAs($user); + session(['currentTeam' => $team]); + + return Application::factory()->create([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); +} + +it('shows one red insufficient permissions callout for resource operations when update is denied', function () { + $application = resourcePermissionCalloutApplicationFor('member'); + + $component = Livewire::test(ResourceOperations::class, ['resource' => $application]) + ->assertSee('Insufficient Permissions') + ->assertSee('permission to modify this resource') + ->assertSee('team administrator for access') + ->assertDontSee('Access Restricted') + ->assertDontSee("You don't have permission to clone resources") + ->assertDontSee("You don't have permission to move resources"); + + expect(substr_count($component->html(), 'Insufficient Permissions'))->toBe(1) + ->and($component->html())->toContain('bg-red-50') + ->and($component->html())->not->toContain('bg-warning-50'); +}); + +it('shows the red insufficient permissions callout for tags when update is denied', function () { + $application = resourcePermissionCalloutApplicationFor('member'); + + $component = Livewire::test(Tags::class, ['resource' => $application]) + ->assertSee('Insufficient Permissions') + ->assertSee('permission to manage this resource') + ->assertSee('team administrator for access') + ->assertDontSee('Access Restricted') + ->assertDontSee("You don't have permission to manage tags"); + + expect(substr_count($component->html(), 'Insufficient Permissions'))->toBe(1) + ->and($component->html())->toContain('bg-red-50') + ->and($component->html())->not->toContain('bg-warning-50'); +}); + +it('does not use yellow permission callouts in blade views', function () { + $offendingFiles = collect(new RecursiveIteratorIterator(new RecursiveDirectoryIterator(resource_path('views')))) + ->filter(fn (SplFileInfo $file) => $file->isFile() && $file->getExtension() === 'php') + ->filter(function (SplFileInfo $file) { + $contents = file_get_contents($file->getPathname()); + + return str_contains($contents, 'type="warning" title="Permission Required"') + || str_contains($contents, 'title="Access Restricted"'); + }) + ->map(fn (SplFileInfo $file) => str_replace(base_path().'/', '', $file->getPathname())) + ->values() + ->all(); + + expect($offendingFiles)->toBeEmpty(); +}); From 22f9f96db68efb6009a3cd2085b317ab8d882992 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 4 Jun 2026 15:24:53 +0200 Subject: [PATCH 055/151] chore: inspect staged changes for commit message --- app/Console/Commands/Generate/Services.php | 24 ++ app/Livewire/GlobalSearch.php | 8 + app/Livewire/Project/New/Select.php | 37 +- bootstrap/helpers/shared.php | 16 +- public/svgs/clickhouse-icon.svg | 8 + public/svgs/clickhouse.svg | 9 +- public/svgs/dragonfly.svg | 1 + public/svgs/keydb.svg | 1 + .../views/livewire/global-search.blade.php | 12 +- .../views/livewire/project/index.blade.php | 71 ++-- templates/service-templates-latest.json | 401 ++++++++++++++++-- templates/service-templates.json | 401 ++++++++++++++++-- tests/Feature/ProjectIndexEmptyStateTest.php | 38 ++ .../ServiceTemplateGitTimestampTest.php | 29 ++ .../ServiceTemplatesLastUpdatedHintTest.php | 51 ++- .../GlobalSearchNewImageQuickActionTest.php | 106 ++++- 16 files changed, 1071 insertions(+), 142 deletions(-) create mode 100644 public/svgs/clickhouse-icon.svg create mode 100644 public/svgs/dragonfly.svg create mode 100644 public/svgs/keydb.svg create mode 100644 tests/Feature/ProjectIndexEmptyStateTest.php create mode 100644 tests/Feature/ServiceTemplateGitTimestampTest.php diff --git a/app/Console/Commands/Generate/Services.php b/app/Console/Commands/Generate/Services.php index e316fc391..2978feb3c 100644 --- a/app/Console/Commands/Generate/Services.php +++ b/app/Console/Commands/Generate/Services.php @@ -4,6 +4,7 @@ use Illuminate\Console\Command; use Illuminate\Support\Arr; +use Illuminate\Support\Facades\Process; use Symfony\Component\Yaml\Yaml; class Services extends Command @@ -77,6 +78,7 @@ private function processFile(string $file): false|array 'category' => $data->get('category'), 'logo' => $data->get('logo', 'svgs/default.webp'), 'minversion' => $data->get('minversion', '0.0.0'), + 'template_last_updated_at' => $this->templateLastUpdatedAt($file), ]; if ($port = $data->get('port')) { @@ -99,6 +101,26 @@ private function processFile(string $file): false|array return $payload; } + private function templateLastUpdatedAt(string $file): ?string + { + $process = Process::path(base_path())->run([ + 'git', + 'log', + '-1', + '--format=%cI', + '--', + "templates/compose/{$file}", + ]); + + if ($process->failed()) { + return null; + } + + $timestamp = trim($process->output()); + + return $timestamp === '' ? null : $timestamp; + } + private function generateServiceTemplatesWithFqdn(): void { $serviceTemplatesWithFqdn = collect(array_merge( @@ -155,6 +177,7 @@ private function processFileWithFqdn(string $file): false|array 'category' => $data->get('category'), 'logo' => $data->get('logo', 'svgs/default.webp'), 'minversion' => $data->get('minversion', '0.0.0'), + 'template_last_updated_at' => $this->templateLastUpdatedAt($file), ]; if ($port = $data->get('port')) { @@ -232,6 +255,7 @@ private function processFileWithFqdnRaw(string $file): false|array 'category' => $data->get('category'), 'logo' => $data->get('logo', 'svgs/default.webp'), 'minversion' => $data->get('minversion', '0.0.0'), + 'template_last_updated_at' => $this->templateLastUpdatedAt($file), ]; if ($port = $data->get('port')) { diff --git a/app/Livewire/GlobalSearch.php b/app/Livewire/GlobalSearch.php index df2adf22b..4148764de 100644 --- a/app/Livewire/GlobalSearch.php +++ b/app/Livewire/GlobalSearch.php @@ -1053,6 +1053,7 @@ private function loadCreatableItems() 'quickcommand' => '(type: new postgresql)', 'type' => 'postgresql', 'category' => 'Databases', + 'logo' => 'svgs/postgresql.svg', 'resourceType' => 'database', ]); @@ -1062,6 +1063,7 @@ private function loadCreatableItems() 'quickcommand' => '(type: new mysql)', 'type' => 'mysql', 'category' => 'Databases', + 'logo' => 'svgs/mysql.svg', 'resourceType' => 'database', ]); @@ -1071,6 +1073,7 @@ private function loadCreatableItems() 'quickcommand' => '(type: new mariadb)', 'type' => 'mariadb', 'category' => 'Databases', + 'logo' => 'svgs/mariadb.svg', 'resourceType' => 'database', ]); @@ -1080,6 +1083,7 @@ private function loadCreatableItems() 'quickcommand' => '(type: new redis)', 'type' => 'redis', 'category' => 'Databases', + 'logo' => 'svgs/redis.svg', 'resourceType' => 'database', ]); @@ -1089,6 +1093,7 @@ private function loadCreatableItems() 'quickcommand' => '(type: new keydb)', 'type' => 'keydb', 'category' => 'Databases', + 'logo' => 'svgs/keydb.svg', 'resourceType' => 'database', ]); @@ -1098,6 +1103,7 @@ private function loadCreatableItems() 'quickcommand' => '(type: new dragonfly)', 'type' => 'dragonfly', 'category' => 'Databases', + 'logo' => 'svgs/dragonfly.svg', 'resourceType' => 'database', ]); @@ -1107,6 +1113,7 @@ private function loadCreatableItems() 'quickcommand' => '(type: new mongodb)', 'type' => 'mongodb', 'category' => 'Databases', + 'logo' => 'svgs/mongodb.svg', 'resourceType' => 'database', ]); @@ -1116,6 +1123,7 @@ private function loadCreatableItems() 'quickcommand' => '(type: new clickhouse)', 'type' => 'clickhouse', 'category' => 'Databases', + 'logo' => 'svgs/clickhouse-icon.svg', 'resourceType' => 'database', ]); } diff --git a/app/Livewire/Project/New/Select.php b/app/Livewire/Project/New/Select.php index d6d234b18..cff886f98 100644 --- a/app/Livewire/Project/New/Select.php +++ b/app/Livewire/Project/New/Select.php @@ -6,7 +6,6 @@ use App\Models\Server; use Carbon\CarbonImmutable; use Illuminate\Support\Collection; -use Illuminate\Support\Facades\Cache; use Livewire\Component; class Select extends Component @@ -107,7 +106,7 @@ public function updatedSelectedEnvironment() public function loadServices() { $services = get_service_templates(); - $templateLastUpdatedMap = $this->serviceTemplateLastUpdatedMap($services->keys()); + $templateLastUpdatedMap = $this->serviceTemplateLastUpdatedMap($services); $services = collect($services)->map(function ($service, $key) use ($templateLastUpdatedMap) { $default_logo = 'images/default.webp'; @@ -279,19 +278,31 @@ private function serviceTemplatesLastUpdated(): ?string return $this->formatLastModified($this->serviceTemplatesPath()); } - private function serviceTemplateLastUpdatedMap(Collection $serviceNames): array + private function serviceTemplateLastUpdatedMap(Collection $services): array { - $bundleMtime = file_exists($this->serviceTemplatesPath()) ? filemtime($this->serviceTemplatesPath()) : 0; + return $services + ->mapWithKeys(fn ($service, $serviceName) => [ + (string) $serviceName => $this->serviceTemplateLastUpdatedFromPayload($service) + ?? $this->serviceTemplateLastUpdated((string) $serviceName), + ]) + ->all(); + } - return Cache::remember( - "service-template-last-updated-map:{$bundleMtime}", - now()->addDay(), - fn () => $serviceNames - ->mapWithKeys(fn ($serviceName) => [ - (string) $serviceName => $this->serviceTemplateLastUpdated((string) $serviceName), - ]) - ->all() - ); + private function serviceTemplateLastUpdatedFromPayload(mixed $service): ?string + { + $timestamp = data_get($service, 'template_last_updated_at'); + + if (! is_string($timestamp) || $timestamp === '') { + return null; + } + + try { + return CarbonImmutable::parse($timestamp) + ->timezone(config('app.timezone')) + ->format('M j, Y H:i'); + } catch (\Throwable) { + return null; + } } private function serviceTemplateLastUpdated(string $serviceName): ?string diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index f2b672fef..8fdfceaf1 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -1057,7 +1057,6 @@ function sslip(Server $server) function get_service_templates(bool $force = false): Collection { - if ($force) { try { $response = Http::retry(3, 1000)->get(config('constants.services.official')); @@ -1068,15 +1067,16 @@ function get_service_templates(bool $force = false): Collection return collect($services); } catch (Throwable) { - $services = File::get(base_path('templates/'.config('constants.services.file_name'))); - - return collect(json_decode($services))->sortKeys(); + return get_service_templates(); } - } else { - $services = File::get(base_path('templates/'.config('constants.services.file_name'))); - - return collect(json_decode($services))->sortKeys(); } + + $path = base_path('templates/'.config('constants.services.file_name')); + $mtime = filemtime($path) ?: 0; + + return Cache::remember("service-templates:{$mtime}", now()->addDay(), function () use ($path) { + return collect(json_decode(File::get($path)))->sortKeys(); + }); } function getResourceByUuid(string $uuid, ?int $teamId = null) diff --git a/public/svgs/clickhouse-icon.svg b/public/svgs/clickhouse-icon.svg new file mode 100644 index 000000000..e327a2a73 --- /dev/null +++ b/public/svgs/clickhouse-icon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/public/svgs/clickhouse.svg b/public/svgs/clickhouse.svg index d536536de..e327a2a73 100644 --- a/public/svgs/clickhouse.svg +++ b/public/svgs/clickhouse.svg @@ -1 +1,8 @@ - + + + + + + + + diff --git a/public/svgs/dragonfly.svg b/public/svgs/dragonfly.svg new file mode 100644 index 000000000..d762f3e03 --- /dev/null +++ b/public/svgs/dragonfly.svg @@ -0,0 +1 @@ + diff --git a/public/svgs/keydb.svg b/public/svgs/keydb.svg new file mode 100644 index 000000000..0abc24a81 --- /dev/null +++ b/public/svgs/keydb.svg @@ -0,0 +1 @@ + diff --git a/resources/views/livewire/global-search.blade.php b/resources/views/livewire/global-search.blade.php index 3316c110f..3169c7fc9 100644 --- a/resources/views/livewire/global-search.blade.php +++ b/resources/views/livewire/global-search.blade.php @@ -632,7 +632,7 @@ class="text-xs font-semibold text-neutral-500 dark:text-neutral-400 uppercase tr @foreach ($searchResults as $result) @if (!isset($result['is_creatable_suggestion'])) + class="search-result-item block px-4 py-3 hover:bg-neutral-100 dark:hover:bg-coolgray-200 transition-colors focus:outline-none focus:bg-neutral-100 dark:focus:bg-coolgray-200 focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-coollabs dark:focus-visible:ring-warning">