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/211] 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/211] 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/211] 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/211] 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/211] 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/211] 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)
-
-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)
+
+
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.
+ Link this server to a Hetzner Cloud instance to enable power controls and status monitoring. +
+{{ $hetznerSearchError }}
++ @if ($manualHetznerServerId) + No Hetzner server found with ID: {{ $manualHetznerServerId }} + @else + No Hetzner server found matching IP: {{ $server->ip }} + @endif +
++ Try a different token, enter the Server ID manually, or verify the details are correct. +
++ Link this server to a DigitalOcean droplet to enable power controls and status monitoring. +
+{{ $digitalOceanSearchError }}
++ @if ($manualDigitalOceanDropletId) + No DigitalOcean droplet found with ID: {{ $manualDigitalOceanDropletId }} + @else + No DigitalOcean droplet found matching IP: {{ $server->ip }} + @endif +
++ Try a different token, enter the Droplet ID manually, or verify the details are correct. +
++ Link this server to a Vultr instance to enable power controls and status monitoring. +
+{{ $vultrSearchError }}
++ @if ($manualVultrInstanceId) + No Vultr instance found with ID: {{ $manualVultrInstanceId }} + @else + No Vultr instance found matching IP: {{ $server->ip }} + @endif +
++ Try a different token, enter the Instance ID manually, or verify the details are correct. +
+- Link this server to a Hetzner Cloud instance to enable power controls and status monitoring. -
- -{{ $hetznerSearchError }}
-- @if ($manualHetznerServerId) - No Hetzner server found with ID: {{ $manualHetznerServerId }} - @else - No Hetzner server found matching IP: {{ $server->ip }} - @endif -
-- Try a different token, enter the Server ID manually, or verify the details are correct. -
-- Link this server to a Vultr instance to enable power controls and status monitoring. -
- -{{ $vultrSearchError }}
-- @if ($manualVultrInstanceId) - No Vultr instance found with ID: {{ $manualVultrInstanceId }} - @else - No Vultr instance found matching IP: {{ $server->ip }} - @endif -
-- Try a different token, enter the Instance ID manually, or verify the details are correct. -
-- Link this server to a DigitalOcean droplet to enable power controls and status monitoring. -
- -{{ $digitalOceanSearchError }}
-- @if ($manualDigitalOceanDropletId) - No DigitalOcean droplet found with ID: {{ $manualDigitalOceanDropletId }} - @else - No DigitalOcean droplet found matching IP: {{ $server->ip }} - @endif -
-Body
'))->toBeTrue() + ->and($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['first@example.com'], 'Subject', 'Body
'))->toBeFalse(); +}); + +it('allows different recipients and content through the default fingerprint', function () { + $notification = new DedupeTestNotification; + + expect($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['first@example.com'], 'Subject', 'Body
'))->toBeTrue() + ->and($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['second@example.com'], 'Subject', 'Body
'))->toBeTrue() + ->and($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['first@example.com'], 'Other subject', 'Body
'))->toBeTrue() + ->and($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['first@example.com'], 'Subject', 'Other body
'))->toBeTrue(); +}); + +it('uses semantic keys instead of rendered content when provided', function () { + $notification = new DedupeTestNotification(semanticKey: 'event:123'); + + expect($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['first@example.com'], 'Subject', 'Body
'))->toBeTrue() + ->and($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['first@example.com'], 'Other subject', 'Other body
'))->toBeFalse() + ->and($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['second@example.com'], 'Other subject', 'Other body
'))->toBeTrue(); +}); + +it('allows notifications to opt out of deduplication', function () { + $notification = new DedupeTestNotification(deduplicate: false); + + expect($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['first@example.com'], 'Subject', 'Body
'))->toBeTrue() + ->and($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['first@example.com'], 'Subject', 'Body
'))->toBeTrue(); +}); From 2a0183bfad2ab7252144e5d73eb8cf7b598a0625 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:19:48 +0200 Subject: [PATCH 191/211] Revert "feat(notifications): deduplicate repeated email alerts" This reverts commit 8c1405e1689c7f26e27f062bb35025f76df02b05. --- .../ApiTokenExpiringNotification.php | 10 -- .../Application/DeploymentFailed.php | 10 -- .../Application/DeploymentSuccess.php | 10 -- .../Application/RestartLimitReached.php | 10 -- .../Application/StatusChanged.php | 11 --- app/Notifications/Channels/EmailChannel.php | 38 +++----- .../Channels/TransactionalEmailChannel.php | 11 +-- .../Container/ContainerRestarted.php | 10 -- .../Container/ContainerStopped.php | 10 -- app/Notifications/CustomEmailNotification.php | 15 --- app/Notifications/Database/BackupFailed.php | 13 --- app/Notifications/Database/BackupSuccess.php | 13 --- .../Database/BackupSuccessWithS3Warning.php | 13 --- .../ScheduledTask/TaskFailed.php | 10 -- .../ScheduledTask/TaskSuccess.php | 10 -- .../Server/DockerCleanupFailed.php | 10 -- .../Server/DockerCleanupSuccess.php | 10 -- app/Notifications/Server/ForceDisabled.php | 10 -- app/Notifications/Server/ForceEnabled.php | 10 -- .../Server/HetznerDeletionFailed.php | 10 -- app/Notifications/Server/HighDiskUsage.php | 10 -- app/Notifications/Server/Reachable.php | 10 -- app/Notifications/Server/ServerPatchCheck.php | 10 -- .../Server/TraefikVersionOutdated.php | 12 --- app/Notifications/Server/Unreachable.php | 10 -- .../SslExpirationNotification.php | 16 ---- app/Notifications/Test.php | 5 - .../EmailChangeVerification.php | 10 -- .../TransactionalEmails/InvitationLink.php | 10 -- .../TransactionalEmails/Test.php | 5 - app/Services/NotificationDeduplicator.php | 95 ------------------- ...pplicationStoppedAfterRestartLimitTest.php | 18 ---- .../Feature/NotificationDeduplicationTest.php | 75 --------------- 33 files changed, 14 insertions(+), 516 deletions(-) delete mode 100644 app/Services/NotificationDeduplicator.php delete mode 100644 tests/Feature/NotificationDeduplicationTest.php diff --git a/app/Notifications/ApiTokenExpiringNotification.php b/app/Notifications/ApiTokenExpiringNotification.php index c00ac2d12..451dd312a 100644 --- a/app/Notifications/ApiTokenExpiringNotification.php +++ b/app/Notifications/ApiTokenExpiringNotification.php @@ -29,16 +29,6 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('api_token_expiring'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - return "api-token-expiring:{$this->token->id}"; - } - - public function deduplicateFor(): int - { - return 172800; - } - public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Application/DeploymentFailed.php b/app/Notifications/Application/DeploymentFailed.php index 0ed705edd..8fff7f03b 100644 --- a/app/Notifications/Application/DeploymentFailed.php +++ b/app/Notifications/Application/DeploymentFailed.php @@ -52,16 +52,6 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('deployment_failure'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - return "deployment-failed:{$this->deployment_uuid}"; - } - - public function deduplicateFor(): int - { - return 86400; - } - public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Application/DeploymentSuccess.php b/app/Notifications/Application/DeploymentSuccess.php index 56b692cda..415df5831 100644 --- a/app/Notifications/Application/DeploymentSuccess.php +++ b/app/Notifications/Application/DeploymentSuccess.php @@ -52,16 +52,6 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('deployment_success'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - return "deployment-success:{$this->deployment_uuid}"; - } - - public function deduplicateFor(): int - { - return 86400; - } - public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Application/RestartLimitReached.php b/app/Notifications/Application/RestartLimitReached.php index 507bba28d..635dfdbdc 100644 --- a/app/Notifications/Application/RestartLimitReached.php +++ b/app/Notifications/Application/RestartLimitReached.php @@ -49,16 +49,6 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('status_change'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - return "restart-limit-reached:application:{$this->resource->uuid}:count:{$this->restart_count}"; - } - - public function deduplicateFor(): int - { - return 86400; - } - public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Application/StatusChanged.php b/app/Notifications/Application/StatusChanged.php index 87986435d..ef61b7e6a 100644 --- a/app/Notifications/Application/StatusChanged.php +++ b/app/Notifications/Application/StatusChanged.php @@ -42,16 +42,6 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('status_change'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - return "application-status-changed:application:{$this->resource->uuid}:stopped"; - } - - public function deduplicateFor(): int - { - return 3600; - } - public function toMail(): MailMessage { $mail = new MailMessage; @@ -60,7 +50,6 @@ public function toMail(): MailMessage $mail->view('emails.application-status-changes', [ 'name' => $this->resource_name, 'fqdn' => $fqdn, - 'application_url' => $this->resource_url, 'resource_url' => $this->resource_url, ]); diff --git a/app/Notifications/Channels/EmailChannel.php b/app/Notifications/Channels/EmailChannel.php index 45c6cb2d6..abd115550 100644 --- a/app/Notifications/Channels/EmailChannel.php +++ b/app/Notifications/Channels/EmailChannel.php @@ -4,20 +4,13 @@ use App\Exceptions\NonReportableException; use App\Models\Team; -use App\Services\NotificationDeduplicator; use Exception; use Illuminate\Notifications\Notification; use Resend; -use Resend\Exceptions\ErrorException; -use Resend\Exceptions\TransporterException; -use Symfony\Component\Mailer\Mailer; -use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport; -use Symfony\Component\Mime\Address; -use Symfony\Component\Mime\Email; class EmailChannel { - public function __construct(private NotificationDeduplicator $deduplicator) {} + public function __construct() {} public function send(SendsEmail $notifiable, Notification $notification): void { @@ -74,11 +67,6 @@ public function send(SendsEmail $notifiable, Notification $notification): void } $mailMessage = $notification->toMail($notifiable); - $renderedMail = (string) $mailMessage->render(); - - if (! $this->deduplicator->shouldSend($notifiable, $notification, self::class, $recipients, $mailMessage->subject, $renderedMail)) { - return; - } if ($isResendEnabled) { $resend = Resend::client($settings->resend_api_key); @@ -87,17 +75,17 @@ public function send(SendsEmail $notifiable, Notification $notification): void 'from' => $from, 'to' => $recipients, 'subject' => $mailMessage->subject, - 'html' => $renderedMail, + 'html' => (string) $mailMessage->render(), ]); } elseif ($isSmtpEnabled) { - $encryption = match (strtolower($settings->smtp_encryption ?? '')) { + $encryption = match (strtolower($settings->smtp_encryption)) { 'starttls' => null, 'tls' => 'tls', 'none' => null, default => null, }; - $transport = new EsmtpTransport( + $transport = new \Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport( $settings->smtp_host, $settings->smtp_port, $encryption @@ -105,20 +93,20 @@ public function send(SendsEmail $notifiable, Notification $notification): void $transport->setUsername($settings->smtp_username ?? ''); $transport->setPassword($settings->smtp_password ?? ''); - $mailer = new Mailer($transport); + $mailer = new \Symfony\Component\Mailer\Mailer($transport); $fromEmail = $settings->smtp_from_address ?? 'noreply@localhost'; $fromName = $settings->smtp_from_name ?? 'System'; - $from = new Address($fromEmail, $fromName); - $email = (new Email) + $from = new \Symfony\Component\Mime\Address($fromEmail, $fromName); + $email = (new \Symfony\Component\Mime\Email) ->from($from) ->to(...$recipients) ->subject($mailMessage->subject) - ->html($renderedMail); + ->html((string) $mailMessage->render()); $mailer->send($email); } - } catch (ErrorException $e) { + } catch (\Resend\Exceptions\ErrorException $e) { // Map HTTP status codes to user-friendly messages $userMessage = match ($e->getErrorCode()) { 403 => 'Invalid Resend API key. Please verify your API key in the Resend dashboard and update it in settings.', @@ -143,13 +131,13 @@ public function send(SendsEmail $notifiable, Notification $notification): void // Don't report expected errors (invalid keys, validation) to Sentry if (in_array($e->getErrorCode(), [403, 401, 400])) { - throw NonReportableException::fromException(new Exception($userMessage, $e->getCode(), $e)); + throw NonReportableException::fromException(new \Exception($userMessage, $e->getCode(), $e)); } - throw new Exception($userMessage, $e->getCode(), $e); - } catch (TransporterException $e) { + throw new \Exception($userMessage, $e->getCode(), $e); + } catch (\Resend\Exceptions\TransporterException $e) { send_internal_notification("Resend Transport Error: {$e->getMessage()}"); - throw new Exception('Unable to connect to Resend API. Please check your internet connection and try again.'); + throw new \Exception('Unable to connect to Resend API. Please check your internet connection and try again.'); } catch (\Throwable $e) { // Check if this is a Resend domain verification error on cloud instances if (isCloud() && str_contains($e->getMessage(), 'domain is not verified')) { diff --git a/app/Notifications/Channels/TransactionalEmailChannel.php b/app/Notifications/Channels/TransactionalEmailChannel.php index 803db57f3..8ab74a60b 100644 --- a/app/Notifications/Channels/TransactionalEmailChannel.php +++ b/app/Notifications/Channels/TransactionalEmailChannel.php @@ -3,7 +3,6 @@ namespace App\Notifications\Channels; use App\Models\User; -use App\Services\NotificationDeduplicator; use Exception; use Illuminate\Mail\Message; use Illuminate\Notifications\Notification; @@ -11,8 +10,6 @@ class TransactionalEmailChannel { - public function __construct(private NotificationDeduplicator $deduplicator) {} - public function send(User $notifiable, Notification $notification): void { $settings = instanceSettings(); @@ -30,19 +27,13 @@ public function send(User $notifiable, Notification $notification): void } $this->bootConfigs(); $mailMessage = $notification->toMail($notifiable); - $renderedMail = (string) $mailMessage->render(); - - if (! $this->deduplicator->shouldSend($notifiable, $notification, self::class, [$email], $mailMessage->subject, $renderedMail)) { - return; - } - Mail::send( [], [], fn (Message $message) => $message ->to($email) ->subject($mailMessage->subject) - ->html($renderedMail) + ->html((string) $mailMessage->render()) ); } diff --git a/app/Notifications/Container/ContainerRestarted.php b/app/Notifications/Container/ContainerRestarted.php index d51c77cb3..2d7eb58b5 100644 --- a/app/Notifications/Container/ContainerRestarted.php +++ b/app/Notifications/Container/ContainerRestarted.php @@ -21,16 +21,6 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('status_change'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - return "container-restarted:server:{$this->server->uuid}:container:{$this->name}"; - } - - public function deduplicateFor(): int - { - return 3600; - } - public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Container/ContainerStopped.php b/app/Notifications/Container/ContainerStopped.php index 7daba04ca..f518cd2fd 100644 --- a/app/Notifications/Container/ContainerStopped.php +++ b/app/Notifications/Container/ContainerStopped.php @@ -21,16 +21,6 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('status_change'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - return "container-stopped:server:{$this->server->uuid}:container:{$this->name}"; - } - - public function deduplicateFor(): int - { - return 3600; - } - public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/CustomEmailNotification.php b/app/Notifications/CustomEmailNotification.php index e3f62e22a..c3c89b30f 100644 --- a/app/Notifications/CustomEmailNotification.php +++ b/app/Notifications/CustomEmailNotification.php @@ -15,19 +15,4 @@ class CustomEmailNotification extends Notification implements ShouldQueue public $tries = 5; public $maxExceptions = 5; - - public function shouldDeduplicate(): bool - { - return true; - } - - public function deduplicateFor(): int - { - return 900; - } - - public function deduplicationKey(object $notifiable, string $channel): ?string - { - return null; - } } diff --git a/app/Notifications/Database/BackupFailed.php b/app/Notifications/Database/BackupFailed.php index 8d9c99603..c2b21b1d5 100644 --- a/app/Notifications/Database/BackupFailed.php +++ b/app/Notifications/Database/BackupFailed.php @@ -11,8 +11,6 @@ class BackupFailed extends CustomEmailNotification { - public int|string|null $backupId = null; - public string $name; public string $frequency; @@ -20,7 +18,6 @@ class BackupFailed extends CustomEmailNotification public function __construct(ScheduledDatabaseBackup $backup, public $database, public $output, public $database_name) { $this->onQueue('high'); - $this->backupId = data_get($backup, 'uuid') ?? data_get($backup, 'id'); $this->name = $database->name; $this->frequency = $backup->frequency; } @@ -30,16 +27,6 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('backup_failure'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - return "backup-failed:backup:{$this->backupId}:database:{$this->database->uuid}:output:".hash('sha256', (string) $this->output); - } - - public function deduplicateFor(): int - { - return 21600; - } - public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Database/BackupSuccess.php b/app/Notifications/Database/BackupSuccess.php index 166a48496..3d2d8ece3 100644 --- a/app/Notifications/Database/BackupSuccess.php +++ b/app/Notifications/Database/BackupSuccess.php @@ -11,8 +11,6 @@ class BackupSuccess extends CustomEmailNotification { - public int|string|null $backupId = null; - public string $name; public string $frequency; @@ -20,7 +18,6 @@ class BackupSuccess extends CustomEmailNotification public function __construct(ScheduledDatabaseBackup $backup, public $database, public $database_name) { $this->onQueue('high'); - $this->backupId = data_get($backup, 'uuid') ?? data_get($backup, 'id'); $this->name = $database->name; $this->frequency = $backup->frequency; @@ -31,16 +28,6 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('backup_success'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - return "backup-success:backup:{$this->backupId}:database:{$this->database->uuid}:name:{$this->database_name}:frequency:{$this->frequency}"; - } - - public function deduplicateFor(): int - { - return 86400; - } - public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Database/BackupSuccessWithS3Warning.php b/app/Notifications/Database/BackupSuccessWithS3Warning.php index 0da619448..ee24ef17d 100644 --- a/app/Notifications/Database/BackupSuccessWithS3Warning.php +++ b/app/Notifications/Database/BackupSuccessWithS3Warning.php @@ -11,8 +11,6 @@ class BackupSuccessWithS3Warning extends CustomEmailNotification { - public int|string|null $backupId = null; - public string $name; public string $frequency; @@ -22,7 +20,6 @@ class BackupSuccessWithS3Warning extends CustomEmailNotification public function __construct(ScheduledDatabaseBackup $backup, public $database, public $database_name, public $s3_error) { $this->onQueue('high'); - $this->backupId = data_get($backup, 'uuid') ?? data_get($backup, 'id'); $this->name = $database->name; $this->frequency = $backup->frequency; @@ -37,16 +34,6 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('backup_failure'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - return "backup-s3-warning:backup:{$this->backupId}:database:{$this->database->uuid}:error:".hash('sha256', (string) $this->s3_error); - } - - public function deduplicateFor(): int - { - return 21600; - } - public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/ScheduledTask/TaskFailed.php b/app/Notifications/ScheduledTask/TaskFailed.php index 5078ca8e9..bd060112a 100644 --- a/app/Notifications/ScheduledTask/TaskFailed.php +++ b/app/Notifications/ScheduledTask/TaskFailed.php @@ -28,16 +28,6 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('scheduled_task_failure'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - return "scheduled-task-failed:task:{$this->task->uuid}:output:".hash('sha256', $this->output); - } - - public function deduplicateFor(): int - { - return 3600; - } - public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/ScheduledTask/TaskSuccess.php b/app/Notifications/ScheduledTask/TaskSuccess.php index 0231ecf3d..58c959bd8 100644 --- a/app/Notifications/ScheduledTask/TaskSuccess.php +++ b/app/Notifications/ScheduledTask/TaskSuccess.php @@ -28,16 +28,6 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('scheduled_task_success'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - return "scheduled-task-success:task:{$this->task->uuid}:output:".hash('sha256', $this->output); - } - - public function deduplicateFor(): int - { - return 3600; - } - public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Server/DockerCleanupFailed.php b/app/Notifications/Server/DockerCleanupFailed.php index ac0eea17d..9cbdeb488 100644 --- a/app/Notifications/Server/DockerCleanupFailed.php +++ b/app/Notifications/Server/DockerCleanupFailed.php @@ -21,16 +21,6 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('docker_cleanup_failure'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - return "docker-cleanup-failed:server:{$this->server->uuid}:message:".hash('sha256', $this->message); - } - - public function deduplicateFor(): int - { - return 21600; - } - public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Server/DockerCleanupSuccess.php b/app/Notifications/Server/DockerCleanupSuccess.php index 7e5ec0bcf..d28f25c6c 100644 --- a/app/Notifications/Server/DockerCleanupSuccess.php +++ b/app/Notifications/Server/DockerCleanupSuccess.php @@ -21,16 +21,6 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('docker_cleanup_success'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - return "docker-cleanup-success:server:{$this->server->uuid}:message:".hash('sha256', $this->message); - } - - public function deduplicateFor(): int - { - return 21600; - } - public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Server/ForceDisabled.php b/app/Notifications/Server/ForceDisabled.php index 8d1817026..4b56f5860 100644 --- a/app/Notifications/Server/ForceDisabled.php +++ b/app/Notifications/Server/ForceDisabled.php @@ -21,16 +21,6 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('server_force_disabled'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - return "server-force-disabled:{$this->server->uuid}"; - } - - public function deduplicateFor(): int - { - return 86400; - } - public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Server/ForceEnabled.php b/app/Notifications/Server/ForceEnabled.php index 3db96f995..36dad3c60 100644 --- a/app/Notifications/Server/ForceEnabled.php +++ b/app/Notifications/Server/ForceEnabled.php @@ -21,16 +21,6 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('server_force_enabled'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - return "server-force-enabled:{$this->server->uuid}"; - } - - public function deduplicateFor(): int - { - return 86400; - } - public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Server/HetznerDeletionFailed.php b/app/Notifications/Server/HetznerDeletionFailed.php index 866d2eb07..bb452b054 100644 --- a/app/Notifications/Server/HetznerDeletionFailed.php +++ b/app/Notifications/Server/HetznerDeletionFailed.php @@ -21,16 +21,6 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('hetzner_deletion_failed'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - return "hetzner-deletion-failed:{$this->hetznerServerId}:error:".hash('sha256', $this->errorMessage); - } - - public function deduplicateFor(): int - { - return 86400; - } - public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Server/HighDiskUsage.php b/app/Notifications/Server/HighDiskUsage.php index 4007ca805..149d1bbc8 100644 --- a/app/Notifications/Server/HighDiskUsage.php +++ b/app/Notifications/Server/HighDiskUsage.php @@ -21,16 +21,6 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('server_disk_usage'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - return "high-disk-usage:server:{$this->server->uuid}:threshold:{$this->server_disk_usage_notification_threshold}"; - } - - public function deduplicateFor(): int - { - return 21600; - } - public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Server/Reachable.php b/app/Notifications/Server/Reachable.php index b297b7d3d..e64b0af2a 100644 --- a/app/Notifications/Server/Reachable.php +++ b/app/Notifications/Server/Reachable.php @@ -30,16 +30,6 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('server_reachable'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - return "server-reachable:{$this->server->uuid}"; - } - - public function deduplicateFor(): int - { - return 1800; - } - public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Server/ServerPatchCheck.php b/app/Notifications/Server/ServerPatchCheck.php index d0d5f4875..ba6cd4982 100644 --- a/app/Notifications/Server/ServerPatchCheck.php +++ b/app/Notifications/Server/ServerPatchCheck.php @@ -24,16 +24,6 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('server_patch'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - return "server-patch-check:server:{$this->server->uuid}:state:".hash('sha256', json_encode($this->patchData)); - } - - public function deduplicateFor(): int - { - return 86400; - } - public function toMail($notifiable = null): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Server/TraefikVersionOutdated.php b/app/Notifications/Server/TraefikVersionOutdated.php index d6e5ae8aa..c94cc1732 100644 --- a/app/Notifications/Server/TraefikVersionOutdated.php +++ b/app/Notifications/Server/TraefikVersionOutdated.php @@ -38,18 +38,6 @@ private function getUpgradeTarget(array $info): string return $this->formatVersion($info['latest'] ?? 'unknown'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - $serverUuids = $this->servers->pluck('uuid')->sort()->values()->join('|'); - - return 'traefik-version-outdated:servers:'.hash('sha256', $serverUuids); - } - - public function deduplicateFor(): int - { - return 86400; - } - public function toMail($notifiable = null): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Server/Unreachable.php b/app/Notifications/Server/Unreachable.php index cd6fd63b6..99742f3b7 100644 --- a/app/Notifications/Server/Unreachable.php +++ b/app/Notifications/Server/Unreachable.php @@ -30,16 +30,6 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('server_unreachable'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - return "server-unreachable:{$this->server->uuid}"; - } - - public function deduplicateFor(): int - { - return 3600; - } - public function toMail(): ?MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/SslExpirationNotification.php b/app/Notifications/SslExpirationNotification.php index 72ce136bd..78e1e8be9 100644 --- a/app/Notifications/SslExpirationNotification.php +++ b/app/Notifications/SslExpirationNotification.php @@ -59,22 +59,6 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('ssl_certificate_renewal'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - $resourceKeys = $this->resources - ->map(fn ($resource) => data_get($resource, 'uuid') ?? data_get($resource, 'name')) - ->sort() - ->values() - ->join('|'); - - return 'ssl-certificate-renewed:resources:'.hash('sha256', $resourceKeys); - } - - public function deduplicateFor(): int - { - return 86400; - } - public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Test.php b/app/Notifications/Test.php index ea3dfe9c1..bbed22777 100644 --- a/app/Notifications/Test.php +++ b/app/Notifications/Test.php @@ -30,11 +30,6 @@ public function __construct(public ?string $emails = null, public ?string $chann $this->onQueue('high'); } - public function shouldDeduplicate(): bool - { - return false; - } - public function via(object $notifiable): array { if ($this->channel) { diff --git a/app/Notifications/TransactionalEmails/EmailChangeVerification.php b/app/Notifications/TransactionalEmails/EmailChangeVerification.php index bb5e7f870..ea8462366 100644 --- a/app/Notifications/TransactionalEmails/EmailChangeVerification.php +++ b/app/Notifications/TransactionalEmails/EmailChangeVerification.php @@ -25,16 +25,6 @@ public function __construct( $this->onQueue('high'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - return "email-change-verification:user:{$this->user->id}:email:{$this->newEmail}:code:{$this->verificationCode}"; - } - - public function deduplicateFor(): int - { - return (int) max(1, now()->diffInSeconds($this->expiresAt, false)); - } - public function toMail(): MailMessage { // Use the configured expiry minutes value diff --git a/app/Notifications/TransactionalEmails/InvitationLink.php b/app/Notifications/TransactionalEmails/InvitationLink.php index f3b1e6d67..9bfb54798 100644 --- a/app/Notifications/TransactionalEmails/InvitationLink.php +++ b/app/Notifications/TransactionalEmails/InvitationLink.php @@ -21,16 +21,6 @@ public function __construct(public User $user, public bool $isTransactionalEmail $this->onQueue('high'); } - public function deduplicationKey(object $notifiable, string $channel): ?string - { - return "invitation-link:user:{$this->user->id}:email:{$this->user->email}"; - } - - public function deduplicateFor(): int - { - return 3600; - } - public function toMail(): MailMessage { $invitation = TeamInvitation::whereEmail($this->user->email)->first(); diff --git a/app/Notifications/TransactionalEmails/Test.php b/app/Notifications/TransactionalEmails/Test.php index dc8c0dac7..2f7d70bbf 100644 --- a/app/Notifications/TransactionalEmails/Test.php +++ b/app/Notifications/TransactionalEmails/Test.php @@ -15,11 +15,6 @@ public function __construct(public string $emails, public bool $isTransactionalE $this->onQueue('high'); } - public function shouldDeduplicate(): bool - { - return false; - } - public function via(): array { return [EmailChannel::class]; diff --git a/app/Services/NotificationDeduplicator.php b/app/Services/NotificationDeduplicator.php deleted file mode 100644 index d018dd0a5..000000000 --- a/app/Services/NotificationDeduplicator.php +++ /dev/null @@ -1,95 +0,0 @@ - $recipients - */ - public function shouldSend(object $notifiable, Notification $notification, string $channel, array $recipients, ?string $subject = null, ?string $body = null): bool - { - if (method_exists($notification, 'shouldDeduplicate') && ! $notification->shouldDeduplicate()) { - return true; - } - - $ttl = method_exists($notification, 'deduplicateFor') - ? $notification->deduplicateFor() - : self::DEFAULT_TTL; - - if ($ttl <= 0) { - return true; - } - - return Cache::add( - $this->cacheKey($notifiable, $notification, $channel, $recipients, $subject, $body), - true, - $ttl, - ); - } - - /** - * @param arrayBody
'))->toBeTrue() - ->and($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['first@example.com'], 'Subject', 'Body
'))->toBeFalse(); -}); - -it('allows different recipients and content through the default fingerprint', function () { - $notification = new DedupeTestNotification; - - expect($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['first@example.com'], 'Subject', 'Body
'))->toBeTrue() - ->and($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['second@example.com'], 'Subject', 'Body
'))->toBeTrue() - ->and($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['first@example.com'], 'Other subject', 'Body
'))->toBeTrue() - ->and($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['first@example.com'], 'Subject', 'Other body
'))->toBeTrue(); -}); - -it('uses semantic keys instead of rendered content when provided', function () { - $notification = new DedupeTestNotification(semanticKey: 'event:123'); - - expect($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['first@example.com'], 'Subject', 'Body
'))->toBeTrue() - ->and($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['first@example.com'], 'Other subject', 'Other body
'))->toBeFalse() - ->and($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['second@example.com'], 'Other subject', 'Other body
'))->toBeTrue(); -}); - -it('allows notifications to opt out of deduplication', function () { - $notification = new DedupeTestNotification(deduplicate: false); - - expect($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['first@example.com'], 'Subject', 'Body
'))->toBeTrue() - ->and($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['first@example.com'], 'Subject', 'Body
'))->toBeTrue(); -}); From bcadcc920083e5383869ce9bc3ca78ec927e3dcd Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 10 Jul 2026 10:47:38 +0200 Subject: [PATCH 192/211] docs(readme): serve sponsor images from Coollabs CDN --- README.md | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 91458b703..ee4028d6a 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,7 @@ ### Big Sponsors ### Small Sponsors -
+
@@ -113,39 +113,39 @@ ### Small Sponsors
-
-
+
+
-
-
+
-
+
-
-
-
-
-
+
+
+
-
-
+
-
+
...and many more at [GitHub Sponsors](https://github.com/sponsors/coollabsio)
From dd10a90d8c82d2eee20a772bea28df7d60310ed4 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Fri, 10 Jul 2026 10:48:06 +0200
Subject: [PATCH 193/211] fix(meta): update social preview image URL
---
resources/views/layouts/base.blade.php | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/resources/views/layouts/base.blade.php b/resources/views/layouts/base.blade.php
index be7b928ab..553248b60 100644
--- a/resources/views/layouts/base.blade.php
+++ b/resources/views/layouts/base.blade.php
@@ -22,13 +22,13 @@
-
+
-
+
@use('App\Models\InstanceSettings')
@php
From e4f925ebbfc52d75d9921d22781054576c7783ea Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Fri, 10 Jul 2026 10:49:59 +0200
Subject: [PATCH 194/211] fix(meta): serve releases metadata from Coollabs CDN
---
config/constants.php | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/config/constants.php b/config/constants.php
index b9e3d600f..bf053fde3 100644
--- a/config/constants.php
+++ b/config/constants.php
@@ -16,7 +16,7 @@
'cdn_url' => env('CDN_URL', 'https://cdn.coollabs.io'),
'versions_url' => env('VERSIONS_URL', env('CDN_URL', 'https://cdn.coollabs.io').'/coolify/versions.json'),
'upgrade_script_url' => env('UPGRADE_SCRIPT_URL', env('CDN_URL', 'https://cdn.coollabs.io').'/coolify/upgrade.sh'),
- 'releases_url' => env('RELEASES_URL', 'https://raw.githubusercontent.com/coollabsio/coolify-cdn/main/json/releases.json'),
+ 'releases_url' => env('RELEASES_URL', 'https://cdn.coollabs.io/coolify/releases.json'),
],
'urls' => [
From d3fbb32c527bf880244330c3ce655ad8a672e935 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Fri, 10 Jul 2026 14:29:11 +0200
Subject: [PATCH 195/211] feat(cdn): sync release metadata through BunnyCDN
Replace the legacy sync:bunny flags with an interactive CDN sync flow for service templates and release metadata. Serve official service templates from the Coollabs CDN, update version metadata, and remove obsolete helper scripts.
---
app/Console/Commands/SyncBunny.php | 321 +++++++++++++++---
.../Concerns/SummarizesDiffText.php | 2 +-
config/constants.php | 4 +-
other/nightly/versions.json | 4 +-
scripts/conductor-setup.sh | 97 ------
scripts/sync_volume.sh | 57 ----
tests/Feature/PullChangelogTest.php | 2 +-
tests/Feature/SyncBunnyTest.php | 232 ++++++++++---
.../ApplicationConfigurationSnapshotTest.php | 8 +-
versions.json | 4 +-
10 files changed, 475 insertions(+), 256 deletions(-)
delete mode 100755 scripts/conductor-setup.sh
delete mode 100644 scripts/sync_volume.sh
diff --git a/app/Console/Commands/SyncBunny.php b/app/Console/Commands/SyncBunny.php
index 3f3e213fd..55acf3828 100644
--- a/app/Console/Commands/SyncBunny.php
+++ b/app/Console/Commands/SyncBunny.php
@@ -5,9 +5,12 @@
use Illuminate\Console\Command;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Pool;
+use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http;
use function Laravel\Prompts\confirm;
+use function Laravel\Prompts\multiselect;
+use function Laravel\Prompts\select;
class SyncBunny extends Command
{
@@ -16,7 +19,7 @@ class SyncBunny extends Command
*
* @var string
*/
- protected $signature = 'sync:bunny {--templates} {--release} {--nightly}';
+ protected $signature = 'sync:bunny {--bunny}';
/**
* The console command description.
@@ -25,15 +28,234 @@ class SyncBunny extends Command
*/
protected $description = 'Sync files to BunnyCDN';
+ protected function removeTemporaryDirectory(string $tmpDir): void
+ {
+ $temporaryRoot = realpath(sys_get_temp_dir());
+ $temporaryDirectory = realpath($tmpDir);
+
+ if ($temporaryRoot === false || $temporaryDirectory === false) {
+ return;
+ }
+
+ $expectedPrefix = rtrim($temporaryRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.'coollabs-cdn-';
+ if (! str_starts_with($temporaryDirectory, $expectedPrefix)) {
+ return;
+ }
+
+ File::deleteDirectory($temporaryDirectory);
+ }
+
+ /**
+ * Fetch GitHub releases and sync to GitHub repository
+ */
+ private function syncReleasesToGitHubRepo(array $files, bool $nightly = false): bool
+ {
+ $this->info('Fetching releases from GitHub...');
+ try {
+ $response = Http::timeout(30)
+ ->get('https://api.github.com/repos/coollabsio/coolify/releases', [
+ 'per_page' => 30, // Fetch more releases for better changelog
+ ]);
+
+ if (! $response->successful()) {
+ $this->error('Failed to fetch releases from GitHub: '.$response->status());
+
+ return false;
+ }
+
+ $releasesFile = tempnam(sys_get_temp_dir(), 'coolify-releases-');
+ if ($releasesFile === false || file_put_contents($releasesFile, json_encode($response->json(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)) === false) {
+ $this->error('Failed to create temporary releases.json.');
+
+ return false;
+ }
+
+ $files[$releasesFile] = $nightly ? 'json/coolify/nightly/releases.json' : 'json/coolify/releases.json';
+
+ try {
+ return $this->syncFilesToGitHubRepo($files, $nightly);
+ } finally {
+ @unlink($releasesFile);
+ }
+ } catch (\Throwable $e) {
+ $this->error('Error syncing releases: '.$e->getMessage());
+
+ return false;
+ }
+ }
+
+ /**
+ * Sync install.sh, docker-compose, and env files to GitHub repository via PR
+ */
+ private function syncFilesToGitHubRepo(array $files, bool $nightly = false): bool
+ {
+ $envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION';
+ $this->info("Syncing $envLabel files to GitHub repository...");
+ try {
+ $timestamp = time();
+ $tmpDir = sys_get_temp_dir().'/coollabs-cdn-files-'.$timestamp;
+ $branchName = 'update-files-'.$timestamp;
+
+ // Clone the repository
+ $this->info('Cloning coollabs-cdn repository...');
+ $output = [];
+ exec('gh repo clone coollabsio/coollabs-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode);
+ if ($returnCode !== 0) {
+ $this->error('Failed to clone repository: '.implode("\n", $output));
+
+ return false;
+ }
+
+ // Create feature branch
+ $this->info('Creating feature branch...');
+ $output = [];
+ exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
+ if ($returnCode !== 0) {
+ $this->error('Failed to create branch: '.implode("\n", $output));
+ $this->removeTemporaryDirectory($tmpDir);
+
+ return false;
+ }
+
+ // Copy each file to its target path in the CDN repo
+ $copiedFiles = [];
+ foreach ($files as $sourceFile => $targetPath) {
+ if (! file_exists($sourceFile)) {
+ $this->warn("Source file not found, skipping: $sourceFile");
+
+ continue;
+ }
+
+ $destPath = "$tmpDir/$targetPath";
+ $destDir = dirname($destPath);
+
+ if (! is_dir($destDir)) {
+ if (! mkdir($destDir, 0755, true)) {
+ $this->error("Failed to create directory: $destDir");
+ $this->removeTemporaryDirectory($tmpDir);
+
+ return false;
+ }
+ }
+
+ if (copy($sourceFile, $destPath) === false) {
+ $this->error("Failed to copy $sourceFile to $destPath");
+ $this->removeTemporaryDirectory($tmpDir);
+
+ return false;
+ }
+
+ $copiedFiles[] = $targetPath;
+ $this->info("Copied: $targetPath");
+ }
+
+ if (empty($copiedFiles)) {
+ $this->warn('No files were copied. Nothing to commit.');
+ $this->removeTemporaryDirectory($tmpDir);
+
+ return true;
+ }
+
+ // Stage all copied files
+ $this->info('Staging changes...');
+ $output = [];
+ $stageCmd = 'cd '.escapeshellarg($tmpDir).' && git add '.implode(' ', array_map('escapeshellarg', $copiedFiles)).' 2>&1';
+ exec($stageCmd, $output, $returnCode);
+ if ($returnCode !== 0) {
+ $this->error('Failed to stage changes: '.implode("\n", $output));
+ $this->removeTemporaryDirectory($tmpDir);
+
+ return false;
+ }
+
+ // Check for changes
+ $this->info('Checking for changes...');
+ $changedFiles = [];
+ exec('cd '.escapeshellarg($tmpDir).' && git diff --cached --name-only 2>&1', $changedFiles, $returnCode);
+ if ($returnCode !== 0) {
+ $this->error('Failed to check changed files: '.implode("\n", $changedFiles));
+ $this->removeTemporaryDirectory($tmpDir);
+
+ return false;
+ }
+
+ $changedFiles = array_values(array_filter($changedFiles));
+ if (empty($changedFiles)) {
+ $this->info('All files are already up to date. No changes to commit.');
+ $this->removeTemporaryDirectory($tmpDir);
+
+ return true;
+ }
+
+ // Commit changes
+ $commitMessage = "Update $envLabel files (install.sh, docker-compose, env) - ".date('Y-m-d H:i:s');
+ $output = [];
+ exec('cd '.escapeshellarg($tmpDir).' && git commit -m '.escapeshellarg($commitMessage).' 2>&1', $output, $returnCode);
+ if ($returnCode !== 0) {
+ $this->error('Failed to commit changes: '.implode("\n", $output));
+ $this->removeTemporaryDirectory($tmpDir);
+
+ return false;
+ }
+
+ // Push to remote
+ $this->info('Pushing branch to remote...');
+ $output = [];
+ exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
+ if ($returnCode !== 0) {
+ $this->error('Failed to push branch: '.implode("\n", $output));
+ $this->removeTemporaryDirectory($tmpDir);
+
+ return false;
+ }
+
+ // Create pull request
+ $this->info('Creating pull request...');
+ $prTitle = "Update $envLabel files - ".date('Y-m-d H:i:s');
+ $fileList = implode("\n- ", $changedFiles);
+ $prBody = "Automated update of $envLabel files:\n- $fileList";
+ $prCommand = 'gh pr create --repo coollabsio/coollabs-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1';
+ $output = [];
+ exec($prCommand, $output, $returnCode);
+
+ // Clean up
+ $this->removeTemporaryDirectory($tmpDir);
+
+ if ($returnCode !== 0) {
+ $this->error('Failed to create PR: '.implode("\n", $output));
+
+ return false;
+ }
+
+ $this->info('Pull request created successfully!');
+ if (! empty($output)) {
+ $this->info('PR URL: '.implode("\n", $output));
+ }
+ $this->info('Files synced: '.count($changedFiles));
+
+ return true;
+ } catch (\Throwable $e) {
+ $this->error('Error syncing files to GitHub: '.$e->getMessage());
+
+ return false;
+ }
+ }
+
/**
* Execute the console command.
*/
public function handle()
{
$that = $this;
- $only_template = $this->option('templates');
- $only_version = $this->option('release');
- $nightly = $this->option('nightly');
+ $only_bunny = $this->option('bunny');
+ $nightly = select(
+ label: 'Which environment would you like to sync?',
+ options: [
+ 'production' => 'Production',
+ 'nightly' => 'Nightly',
+ ],
+ default: 'production',
+ ) === 'nightly';
$bunny_cdn = 'https://cdn.coollabs.io';
$bunny_cdn_path = 'coolify';
$bunny_cdn_storage_name = 'coolcdn';
@@ -55,6 +277,7 @@ public function handle()
$upgrade_script_location = "$parent_dir/scripts/upgrade.sh";
$upgrade_postgres_script_location = "$parent_dir/scripts/upgrade-postgres.sh";
$production_env_location = "$parent_dir/.env.production";
+ $service_template_location = "$parent_dir/templates/$service_template";
$versions_location = "$parent_dir/$versions";
PendingRequest::macro('storage', function ($fileName) use ($that) {
@@ -93,7 +316,7 @@ public function handle()
$install_script_location = "$parent_dir/other/nightly/$install_script";
$versions_location = "$parent_dir/other/nightly/$versions";
}
- if (! $only_template && ! $only_version) {
+ if ($only_bunny) {
$envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION';
$this->info("About to sync $envLabel files to BunnyCDN.");
$this->newLine();
@@ -108,7 +331,7 @@ public function handle()
$install_script_location => "$bunny_cdn/$bunny_cdn_path/$install_script",
];
- $diffTmpDir = sys_get_temp_dir().'/coolify-cdn-diff-'.time();
+ $diffTmpDir = sys_get_temp_dir().'/coollabs-cdn-diff-'.time();
@mkdir($diffTmpDir, 0755, true);
$hasChanges = false;
@@ -151,7 +374,7 @@ public function handle()
}
}
- exec('rm -rf '.escapeshellarg($diffTmpDir));
+ $this->removeTemporaryDirectory($diffTmpDir);
if (! $hasChanges) {
$this->newLine();
@@ -167,49 +390,55 @@ public function handle()
return;
}
}
- if ($only_template) {
- $this->info('About to sync '.config('constants.services.file_name').' to BunnyCDN.');
- $confirmed = confirm('Are you sure you want to sync?');
- if (! $confirmed) {
- return;
- }
- Http::pool(fn (Pool $pool) => [
- $pool->storage(fileName: "$parent_dir/templates/$service_template")->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$service_template"),
- $pool->purge("$bunny_cdn/$bunny_cdn_path/$service_template"),
- ]);
- $this->info('Service template uploaded & purged...');
+ if (! $only_bunny) {
+ $envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION';
+ $this->info("About to sync $envLabel releases, versions, compose, and environment files to GitHub repository.");
- return;
- } elseif ($only_version) {
if ($nightly) {
- $this->info('About to sync NIGHTLY versions.json to BunnyCDN.');
+ $files = [
+ $versions_location => 'json/coolify/nightly/versions.json',
+ $compose_file_location => 'json/coolify/nightly/docker-compose.yml',
+ $compose_file_prod_location => 'json/coolify/nightly/docker-compose.prod.yml',
+ $production_env_location => 'json/coolify/nightly/.env.production',
+ $install_script_location => 'json/coolify/nightly/install.sh',
+ $upgrade_script_location => 'json/coolify/nightly/upgrade.sh',
+ $upgrade_postgres_script_location => 'json/coolify/nightly/upgrade-postgres.sh',
+ $service_template_location => 'json/coolify/nightly/service-templates-latest.json',
+ ];
} else {
- $this->info('About to sync PRODUCTION versions.json to BunnyCDN.');
- }
- $file = file_get_contents($versions_location);
- $json = json_decode($file, true);
- $actual_version = data_get($json, 'coolify.v4.version');
-
- $this->info("Version: {$actual_version}");
- $this->info('This will:');
- $this->info(' 1. Sync versions.json to BunnyCDN');
- $this->newLine();
-
- $confirmed = confirm('Are you sure you want to proceed?');
- if (! $confirmed) {
- return;
+ $files = [
+ $versions_location => 'json/coolify/versions.json',
+ $compose_file_location => 'json/coolify/docker-compose.yml',
+ $compose_file_prod_location => 'json/coolify/docker-compose.prod.yml',
+ $production_env_location => 'json/coolify/.env.production',
+ $install_script_location => 'json/coolify/install.sh',
+ $upgrade_script_location => 'json/coolify/upgrade.sh',
+ $upgrade_postgres_script_location => 'json/coolify/upgrade-postgres.sh',
+ $service_template_location => 'json/coolify/service-templates-latest.json',
+ ];
}
- $this->info('Syncing versions.json to BunnyCDN...');
- Http::pool(fn (Pool $pool) => [
- $pool->storage(fileName: $versions_location)->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$versions"),
- $pool->purge("$bunny_cdn/$bunny_cdn_path/$versions"),
- ]);
- $this->info('✓ versions.json uploaded & purged to BunnyCDN');
- $this->newLine();
+ $releasesTarget = $nightly ? 'json/coolify/nightly/releases.json' : 'json/coolify/releases.json';
+ $options = [$releasesTarget, ...array_values($files)];
+ $selectedFiles = multiselect(
+ label: 'Which files would you like to sync?',
+ options: $options,
+ default: $options,
+ required: true,
+ scroll: count($options),
+ );
- $this->info('=== Summary ===');
- $this->info('BunnyCDN sync: ✓ Complete');
+ $includeReleases = in_array($releasesTarget, $selectedFiles, true);
+ $files = array_filter(
+ $files,
+ fn (string $targetPath) => in_array($targetPath, $selectedFiles, true),
+ );
+
+ if ($includeReleases) {
+ $this->syncReleasesToGitHubRepo($files, $nightly);
+ } else {
+ $this->syncFilesToGitHubRepo($files, $nightly);
+ }
return;
}
@@ -231,10 +460,6 @@ public function handle()
$pool->purge("$bunny_cdn/$bunny_cdn_path/$install_script"),
]);
$this->info('All files uploaded & purged to BunnyCDN.');
- $this->newLine();
-
- $this->info('=== Summary ===');
- $this->info('BunnyCDN sync: Complete');
} catch (\Throwable $e) {
$this->error('Error: '.$e->getMessage());
}
diff --git a/app/Services/DeploymentConfiguration/Concerns/SummarizesDiffText.php b/app/Services/DeploymentConfiguration/Concerns/SummarizesDiffText.php
index 6960a8f1b..8eedf0920 100644
--- a/app/Services/DeploymentConfiguration/Concerns/SummarizesDiffText.php
+++ b/app/Services/DeploymentConfiguration/Concerns/SummarizesDiffText.php
@@ -9,7 +9,7 @@ trait SummarizesDiffText
* worth expanding. Kept as one constant so the snapshot summary and the
* differ's expand decision never drift apart.
*/
- private const SINGLE_LINE_LIMIT = 120;
+ private const SINGLE_LINE_LIMIT = 40;
/**
* Returns the value only when it is worth expanding (multi-line or longer
diff --git a/config/constants.php b/config/constants.php
index bf053fde3..290ce3f95 100644
--- a/config/constants.php
+++ b/config/constants.php
@@ -25,9 +25,7 @@
],
'services' => [
- // Temporary disabled until cache is implemented
- // 'official' => 'https://cdn.coollabs.io/coolify/service-templates.json',
- 'official' => 'https://raw.githubusercontent.com/coollabsio/coolify/v4.x/templates/service-templates-latest.json',
+ 'official' => 'https://cdn.coollabs.io/coolify/service-templates-latest.json',
'file_name' => 'service-templates-latest.json',
],
diff --git a/other/nightly/versions.json b/other/nightly/versions.json
index 751db0754..9c9a405aa 100644
--- a/other/nightly/versions.json
+++ b/other/nightly/versions.json
@@ -1,10 +1,10 @@
{
"coolify": {
"v4": {
- "version": "4.2.0"
+ "version": "4.1.2"
},
"nightly": {
- "version": "4.2.1"
+ "version": "4.2.0"
},
"helper": {
"version": "1.0.14"
diff --git a/scripts/conductor-setup.sh b/scripts/conductor-setup.sh
deleted file mode 100755
index a88b457fb..000000000
--- a/scripts/conductor-setup.sh
+++ /dev/null
@@ -1,97 +0,0 @@
-#!/bin/bash
-set -e
-
-# Validate CONDUCTOR_ROOT_PATH is set and valid before any operations
-if [ -z "$CONDUCTOR_ROOT_PATH" ]; then
- echo "ERROR: CONDUCTOR_ROOT_PATH environment variable is not set"
- echo "This script must be run by Conductor with CONDUCTOR_ROOT_PATH set to the main repository path"
- exit 1
-fi
-
-if [ ! -d "$CONDUCTOR_ROOT_PATH" ]; then
- echo "ERROR: CONDUCTOR_ROOT_PATH ($CONDUCTOR_ROOT_PATH) is not a valid directory"
- exit 1
-fi
-
-# Copy .env file
-cp "$CONDUCTOR_ROOT_PATH/.env" .env
-
-# Setup shared dependencies via symlinks to main repo
-echo "Setting up shared node_modules and vendor directories..."
-
-# Ensure main repo has the directories
-mkdir -p "$CONDUCTOR_ROOT_PATH/node_modules"
-mkdir -p "$CONDUCTOR_ROOT_PATH/vendor"
-
-# Get current worktree path
-WORKTREE_PATH=$(pwd)
-
-# Safety check 1: ensure WORKTREE_PATH is valid
-if [ -z "$WORKTREE_PATH" ]; then
- echo "ERROR: WORKTREE_PATH is empty"
- exit 1
-fi
-
-# Safety check 2: CRITICAL FIRST - blacklist system directories
-# This check runs BEFORE the positive check to prevent dangerous operations
-# even if someone misconfigures CONDUCTOR_ROOT_PATH
-case "$WORKTREE_PATH" in
- /|/bin|/sbin|/usr|/usr/*|/etc|/etc/*|/var|/var/*|/System|/System/*|/Library|/Library/*|/Applications|/Applications/*|"$HOME")
- echo "ERROR: WORKTREE_PATH ($WORKTREE_PATH) is in a dangerous system location"
- exit 1
- ;;
-esac
-
-# Safety check 3: positive check - verify we're under CONDUCTOR_ROOT_PATH
-case "$WORKTREE_PATH" in
- "$CONDUCTOR_ROOT_PATH"|"$CONDUCTOR_ROOT_PATH"/.conductor/*)
- # Valid: either main repo or under .conductor/
- ;;
- *)
- echo "ERROR: WORKTREE_PATH ($WORKTREE_PATH) is not under CONDUCTOR_ROOT_PATH ($CONDUCTOR_ROOT_PATH)"
- exit 1
- ;;
-esac
-
-# Safety check 4: verify we're in a git repository
-if [ ! -f ".git" ] && [ ! -d ".git" ]; then
- echo "ERROR: Not in a git repository"
- exit 1
-fi
-
-# Remove existing directories/symlinks if they exist
-# For symlinks: use 'rm' without -r to remove the symlink itself (not following it)
-# For directories: use 'rm -rf' to remove the directory and contents
-if [ -L "node_modules" ]; then
- # It's a symlink - remove it without following (no -r flag)
- rm "$WORKTREE_PATH/node_modules"
-elif [ -e "node_modules" ]; then
- # It's a regular directory or file - safe to use -rf
- rm -rf "$WORKTREE_PATH/node_modules"
-fi
-
-if [ -L "vendor" ]; then
- # It's a symlink - remove it without following (no -r flag)
- rm "$WORKTREE_PATH/vendor"
-elif [ -e "vendor" ]; then
- # It's a regular directory or file - safe to use -rf
- rm -rf "$WORKTREE_PATH/vendor"
-fi
-
-# Calculate relative path from worktree to main repo
-# Use bash-native approach: try realpath first (GNU coreutils), fallback to perl
-if command -v realpath &> /dev/null && realpath --relative-to / / &> /dev/null 2>&1; then
- # GNU coreutils realpath with --relative-to support
- RELATIVE_PATH=$(realpath --relative-to="$WORKTREE_PATH" "$CONDUCTOR_ROOT_PATH")
-else
- # Fallback: use perl which is standard on macOS and most Unix systems
- RELATIVE_PATH=$(perl -e 'use File::Spec; print File::Spec->abs2rel($ARGV[0], $ARGV[1])' "$CONDUCTOR_ROOT_PATH" "$WORKTREE_PATH")
-fi
-
-# Create symlinks to main repo's node_modules and vendor
-ln -sf "$RELATIVE_PATH/node_modules" node_modules
-ln -sf "$RELATIVE_PATH/vendor" vendor
-
-echo "✓ Shared dependencies linked successfully"
-echo " node_modules -> $RELATIVE_PATH/node_modules"
-echo " vendor -> $RELATIVE_PATH/vendor"
\ No newline at end of file
diff --git a/scripts/sync_volume.sh b/scripts/sync_volume.sh
deleted file mode 100644
index 43631fdf7..000000000
--- a/scripts/sync_volume.sh
+++ /dev/null
@@ -1,57 +0,0 @@
-#!/bin/bash
-# Sync docker volumes between two servers
-
-VERSION="1.0.0"
-SOURCE=$1
-DESTINATION=$2
-set -e
-if [ -z "$SOURCE" ]; then
- echo "Source server is not specified."
- exit 1
-fi
-if [ -z "$DESTINATION" ]; then
- echo "Destination server is not specified."
- exit 1
-fi
-
-SOURCE_USER=$(echo $SOURCE | cut -d@ -f1)
-SOURCE_SERVER=$(echo $SOURCE | cut -d: -f1 | cut -d@ -f2)
-SOURCE_PORT=$(echo $SOURCE | cut -d: -f2 | cut -d/ -f1)
-SOURCE_VOLUME_NAME=$(echo $SOURCE | cut -d/ -f2)
-
-if ! [[ "$SOURCE_PORT" =~ ^[0-9]+$ ]]; then
- echo "Invalid source port: $SOURCE_PORT"
- exit 1
-fi
-
-DESTINATION_USER=$(echo $DESTINATION | cut -d@ -f1)
-DESTINATION_SERVER=$(echo $DESTINATION | cut -d: -f1 | cut -d@ -f2)
-DESTINATION_PORT=$(echo $DESTINATION | cut -d: -f2 | cut -d/ -f1)
-DESTINATION_VOLUME_NAME=$(echo $DESTINATION | cut -d/ -f2)
-
-if ! [[ "$DESTINATION_PORT" =~ ^[0-9]+$ ]]; then
- echo "Invalid destination port: $DESTINATION_PORT"
- exit 1
-fi
-
-echo "Generating backup file to ./$SOURCE_VOLUME_NAME.tgz"
-ssh -p $SOURCE_PORT $SOURCE_USER@$SOURCE_SERVER "docker run -v $SOURCE_VOLUME_NAME:/volume --rm --log-driver none loomchild/volume-backup backup -c pigz -v" >./$SOURCE_VOLUME_NAME.tgz
-echo ""
-if [ -f "./$SOURCE_VOLUME_NAME.tgz" ]; then
- echo "Uploading backup file to $DESTINATION_SERVER:~/$DESTINATION_VOLUME_NAME.tgz"
- scp -P $DESTINATION_PORT ./$SOURCE_VOLUME_NAME.tgz $DESTINATION_USER@$DESTINATION_SERVER:~/$DESTINATION_VOLUME_NAME.tgz
- echo ""
- echo "Restoring backup file on remote ($DESTINATION_SERVER:/~/$DESTINATION_VOLUME_NAME.tgz)"
- ssh -p $DESTINATION_PORT $DESTINATION_USER@$DESTINATION_SERVER "docker run -i -v $DESTINATION_VOLUME_NAME:/volume --log-driver none --rm loomchild/volume-backup restore -c pigz -vf < ~/$DESTINATION_VOLUME_NAME.tgz"
- echo ""
- echo "Deleting backup file on remote ($DESTINATION_SERVER:/~/$DESTINATION_VOLUME_NAME.tgz)"
- ssh -p $DESTINATION_PORT $DESTINATION_USER@$DESTINATION_SERVER "rm ~/$DESTINATION_VOLUME_NAME.tgz"
-
- echo ""
- echo "Local file ./$SOURCE_VOLUME_NAME.tgz is not deleted."
-
- echo ""
- echo "WARNING: If you are copying a database volume, you need to set the right users/passwords on the destination service's environment variables."
- echo "Why? Because we are copying the volume as-is, so the database credentials will bethe same as on the source volume."
-fi
-
diff --git a/tests/Feature/PullChangelogTest.php b/tests/Feature/PullChangelogTest.php
index 145638812..7793b0b77 100644
--- a/tests/Feature/PullChangelogTest.php
+++ b/tests/Feature/PullChangelogTest.php
@@ -34,7 +34,7 @@ function fakeReleasesPayload(): array
test('releases_url config defaults to the GitHub raw source', function () {
expect(config('constants.coolify.releases_url'))
- ->toBe('https://raw.githubusercontent.com/coollabsio/coolify-cdn/main/json/releases.json');
+ ->toBe('https://cdn.coollabs.io/coolify/service-templates-latest.json');
});
test('PullChangelog fetches from the configured releases_url and writes the changelog', function () {
diff --git a/tests/Feature/SyncBunnyTest.php b/tests/Feature/SyncBunnyTest.php
index ca3091841..9f4badad3 100644
--- a/tests/Feature/SyncBunnyTest.php
+++ b/tests/Feature/SyncBunnyTest.php
@@ -1,63 +1,107 @@
> "$SYNC_BUNNY_TEST_LOG"
-exit 1
-SH);
+ file_put_contents("{$binDir}/{$name}", $contents);
chmod("{$binDir}/{$name}", 0755);
}
-it('syncs nightly versions to BunnyCDN without creating a GitHub PR', function () {
- Http::fake([
- 'storage.bunnycdn.com/*' => Http::response([], 201),
- 'api.bunny.net/purge*' => Http::response([], 200),
- ]);
+it('only exposes the BunnyCDN legacy sync option', function () {
+ $definition = Artisan::all()['sync:bunny']->getDefinition();
- $binDir = sys_get_temp_dir().'/sync-bunny-bin-'.uniqid();
- $logFile = sys_get_temp_dir().'/sync-bunny-'.uniqid().'.log';
-
- mkdir($binDir, 0755, true);
- createSyncBunnyFailingBinary($binDir, 'gh');
- createSyncBunnyFailingBinary($binDir, 'git');
-
- $originalPath = getenv('PATH') ?: '';
- putenv("PATH={$binDir}:{$originalPath}");
- putenv("SYNC_BUNNY_TEST_LOG={$logFile}");
-
- try {
- $this->artisan('sync:bunny --release --nightly')
- ->expectsConfirmation('Are you sure you want to proceed?', 'yes')
- ->expectsOutputToContain('BunnyCDN sync: ✓ Complete')
- ->doesntExpectOutputToContain('GitHub PR')
- ->assertExitCode(0);
- } finally {
- putenv("PATH={$originalPath}");
- putenv('SYNC_BUNNY_TEST_LOG');
- }
-
- expect(file_exists($logFile))->toBeFalse();
-
- Http::assertSent(fn ($request) => $request->url() === 'https://storage.bunnycdn.com/coolcdn/coolify-nightly/versions.json');
- Http::assertSent(fn ($request) => str_starts_with($request->url(), 'https://api.bunny.net/purge')
- && $request['url'] === 'https://cdn.coollabs.io/coolify-nightly/versions.json');
+ expect($definition->hasOption('bunny'))->toBeTrue()
+ ->and($definition->hasOption('github-releases'))->toBeFalse()
+ ->and($definition->hasOption('release'))->toBeFalse()
+ ->and($definition->hasOption('nightly'))->toBeFalse()
+ ->and($definition->hasOption('templates'))->toBeFalse();
});
-it('syncs postgres upgrade script to BunnyCDN during full sync', function () {
+it('loads service templates from the Coollabs CDN', function () {
+ expect(config('constants.services.official'))
+ ->toBe('https://cdn.coollabs.io/coolify/service-templates-latest.json');
+});
+
+it('only removes validated Coolify CDN temporary directories', function () {
+ $command = new class extends SyncBunny
+ {
+ public function removeDirectory(string $path): void
+ {
+ $this->removeTemporaryDirectory($path);
+ }
+ };
+
+ $invalidDirectory = sys_get_temp_dir().'/unrelated-directory-'.uniqid();
+ $validDirectory = sys_get_temp_dir().'/coollabs-cdn-files-'.uniqid();
+ mkdir($invalidDirectory);
+ mkdir($validDirectory);
+
+ $command->removeDirectory('');
+ $command->removeDirectory($invalidDirectory);
+ $command->removeDirectory($validDirectory);
+
+ expect($invalidDirectory)->toBeDirectory()
+ ->and($validDirectory)->not->toBeDirectory();
+
+ rmdir($invalidDirectory);
+});
+
+it('syncs full files to BunnyCDN only when explicitly requested', function () {
Http::fake([
'https://cdn.coollabs.io/coolify/*' => Http::response('', 404),
'https://storage.bunnycdn.com/*' => Http::response([], 201),
'https://api.bunny.net/purge*' => Http::response([], 200),
]);
- $this->artisan('sync:bunny')
- ->expectsConfirmation('Are you sure you want to sync?', 'yes')
- ->expectsOutputToContain('BunnyCDN sync: Complete')
- ->assertExitCode(0);
+ $binDir = sys_get_temp_dir().'/sync-bunny-bin-'.uniqid();
+ $logFile = sys_get_temp_dir().'/sync-bunny-'.uniqid().'.log';
+
+ mkdir($binDir, 0755, true);
+
+ createFakeSyncBunnyBinary($binDir, 'gh', <<<'SH'
+#!/bin/sh
+printf 'gh %s\n' "$*" >> "$SYNC_BUNNY_TEST_LOG"
+if [ "$1" = "repo" ] && [ "$2" = "clone" ]; then
+ mkdir -p "$4/scripts"
+fi
+exit 0
+SH);
+
+ createFakeSyncBunnyBinary($binDir, 'git', <<<'SH'
+#!/bin/sh
+printf 'git %s\n' "$*" >> "$SYNC_BUNNY_TEST_LOG"
+if [ "$1" = "status" ]; then
+ printf 'M scripts/upgrade-postgres.sh\n'
+fi
+exit 0
+SH);
+
+ $originalPath = getenv('PATH') ?: '';
+ putenv("PATH={$binDir}:{$originalPath}");
+ putenv("SYNC_BUNNY_TEST_LOG={$logFile}");
+
+ try {
+ $this->artisan('sync:bunny --bunny')
+ ->expectsChoice('Which environment would you like to sync?', 'production', [
+ 'production' => 'Production',
+ 'nightly' => 'Nightly',
+ ])
+ ->expectsConfirmation('Are you sure you want to sync?', 'yes')
+ ->assertExitCode(0);
+ } finally {
+ putenv("PATH={$originalPath}");
+ putenv('SYNC_BUNNY_TEST_LOG');
+ }
+
+ $log = file_exists($logFile) ? file_get_contents($logFile) : '';
+
+ expect($log)
+ ->not->toContain('gh repo clone')
+ ->not->toContain('gh pr create')
+ ->not->toContain('coollabsio/coolify-cdn');
Http::assertSent(fn ($request) => $request->method() === 'PUT'
&& $request->url() === 'https://storage.bunnycdn.com/coolcdn/coolify/upgrade-postgres.sh');
@@ -65,3 +109,105 @@ function createSyncBunnyFailingBinary(string $binDir, string $name): void
Http::assertSent(fn ($request) => str_starts_with($request->url(), 'https://api.bunny.net/purge')
&& $request['url'] === 'https://cdn.coollabs.io/coolify/upgrade-postgres.sh');
});
+
+it('selects the environment and release files to sync to GitHub', function (string $targetDirectory, string $environment, array $selectedBasenames) {
+ Http::fake([
+ 'api.github.com/repos/coollabsio/coolify/releases*' => Http::response([], 200),
+ ]);
+
+ $binDir = sys_get_temp_dir().'/sync-bunny-bin-'.uniqid();
+ $logFile = sys_get_temp_dir().'/sync-bunny-'.uniqid().'.log';
+
+ mkdir($binDir, 0755, true);
+
+ createFakeSyncBunnyBinary($binDir, 'gh', <<<'SH'
+#!/bin/sh
+printf 'gh %s\n' "$*" >> "$SYNC_BUNNY_TEST_LOG"
+if [ "$1" = "repo" ] && [ "$2" = "clone" ]; then
+ mkdir -p "$4"
+fi
+exit 0
+SH);
+
+ createFakeSyncBunnyBinary($binDir, 'git', <<<'SH'
+#!/bin/sh
+printf 'git %s\n' "$*" >> "$SYNC_BUNNY_TEST_LOG"
+if [ "$1" = "status" ]; then
+ printf 'M json/releases.json\n'
+fi
+if [ "$1" = "diff" ]; then
+ if [ -f json/coolify/nightly/releases.json ]; then
+ printf 'json/coolify/nightly/releases.json\n'
+ else
+ printf 'json/coolify/releases.json\n'
+ fi
+fi
+exit 0
+SH);
+
+ $originalPath = getenv('PATH') ?: '';
+ putenv("PATH={$binDir}:{$originalPath}");
+ putenv("SYNC_BUNNY_TEST_LOG={$logFile}");
+
+ $allBasenames = [
+ 'releases.json',
+ 'versions.json',
+ 'docker-compose.yml',
+ 'docker-compose.prod.yml',
+ '.env.production',
+ 'install.sh',
+ 'upgrade.sh',
+ 'upgrade-postgres.sh',
+ 'service-templates-latest.json',
+ ];
+ $allTargets = array_map(fn (string $file) => "$targetDirectory/$file", $allBasenames);
+ $selectedTargets = array_map(fn (string $file) => "$targetDirectory/$file", $selectedBasenames);
+
+ try {
+ $this->artisan('sync:bunny')
+ ->expectsChoice('Which environment would you like to sync?', $environment, [
+ 'production' => 'Production',
+ 'nightly' => 'Nightly',
+ ])
+ ->expectsChoice('Which files would you like to sync?', $selectedTargets, $allTargets)
+ ->assertExitCode(0);
+ } finally {
+ putenv("PATH={$originalPath}");
+ putenv('SYNC_BUNNY_TEST_LOG');
+ }
+
+ $log = file_get_contents($logFile);
+
+ expect($log)
+ ->toContain('gh pr create --repo coollabsio/coollabs-cdn')
+ ->not->toContain('coollabsio/coolify-cdn');
+
+ foreach ($selectedTargets as $selectedTarget) {
+ expect($log)->toContain($selectedTarget);
+ }
+
+ foreach (array_diff($allTargets, $selectedTargets) as $unselectedTarget) {
+ expect($log)->not->toContain($unselectedTarget);
+ }
+
+ $pullRequestCommand = substr($log, strrpos($log, 'gh pr create'));
+
+ expect($pullRequestCommand)
+ ->toContain("$targetDirectory/releases.json")
+ ->not->toContain("$targetDirectory/versions.json");
+
+ Http::assertSentCount(1);
+})->with([
+ 'select production files' => ['json/coolify', 'production', ['releases.json', 'versions.json']],
+ 'select nightly with all files selected by default' => ['json/coolify/nightly', 'nightly', [
+ 'releases.json',
+ 'versions.json',
+ 'docker-compose.yml',
+ 'docker-compose.prod.yml',
+ '.env.production',
+ 'install.sh',
+ 'upgrade.sh',
+ 'upgrade-postgres.sh',
+ 'service-templates-latest.json',
+ ]],
+]);
diff --git a/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php b/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php
index 20b7c0adc..c6c823633 100644
--- a/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php
+++ b/tests/Unit/DeploymentConfiguration/ApplicationConfigurationSnapshotTest.php
@@ -66,12 +66,16 @@ function markSnapshotTestApplicationDeployed(Application $application): Applicat
$application = snapshotTestApplication();
markSnapshotTestApplicationDeployed($application);
- $application->update(['fqdn' => 'https://new.example.com']);
+ $domains = 'https://new.example.com,https://another.example.com';
+ $application->update(['fqdn' => $domains]);
$diff = $application->refresh()->pendingDeploymentConfigurationDiff();
+ $change = collect($diff->changes())->firstWhere('label', 'Domains');
expect($diff->isChanged())->toBeTrue()
->and($diff->requiresBuild())->toBeFalse()
- ->and(collect($diff->changes())->pluck('label'))->toContain('Domains');
+ ->and($change)->not->toBeNull()
+ ->and($change['expandable'])->toBeTrue()
+ ->and($change['new_full_value'])->toBe($domains);
});
it('detects environment variable value changes without exposing secret values', function () {
diff --git a/versions.json b/versions.json
index 751db0754..9c9a405aa 100644
--- a/versions.json
+++ b/versions.json
@@ -1,10 +1,10 @@
{
"coolify": {
"v4": {
- "version": "4.2.0"
+ "version": "4.1.2"
},
"nightly": {
- "version": "4.2.1"
+ "version": "4.2.0"
},
"helper": {
"version": "1.0.14"
From bd82b4c1cd977712f67c9efe2dd5457852872678 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Fri, 10 Jul 2026 18:35:42 +0000
Subject: [PATCH 196/211] chore(deps): bump web-auth/webauthn-lib from 5.3.3 to
5.3.5
Bumps [web-auth/webauthn-lib](https://github.com/web-auth/webauthn-lib) from 5.3.3 to 5.3.5.
- [Commits](https://github.com/web-auth/webauthn-lib/compare/5.3.3...5.3.5)
---
updated-dependencies:
- dependency-name: web-auth/webauthn-lib
dependency-version: 5.3.5
dependency-type: indirect
...
Signed-off-by: dependabot[bot]