Merge remote-tracking branch 'origin/next' into audit-policies

# Conflicts:
#	app/Http/Controllers/Api/SecurityController.php
#	app/Http/Controllers/Api/ServersController.php
#	app/Livewire/Admin/Index.php
#	app/Livewire/Destination/Show.php
#	app/Livewire/NavbarDeleteTeam.php
#	app/Livewire/Project/Application/Previews.php
#	app/Livewire/Project/DeleteProject.php
#	app/Livewire/Project/Shared/ResourceOperations.php
#	app/Livewire/Server/Resources.php
#	app/Livewire/Server/ValidateAndInstall.php
#	app/Livewire/Storage/Show.php
#	resources/views/livewire/dashboard.blade.php
#	resources/views/livewire/project/application/heading.blade.php
#	resources/views/livewire/project/database/heading.blade.php
#	resources/views/livewire/project/service/heading.blade.php
#	resources/views/livewire/project/shared/environment-variable/show.blade.php
#	resources/views/livewire/project/shared/scheduled-task/show.blade.php
#	tests/Feature/Security/TrustHostsMiddlewareTest.php
This commit is contained in:
Andras Bacsai
2026-04-19 15:19:37 +02:00
685 changed files with 41904 additions and 5674 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+66 -14
View File
@@ -4,12 +4,15 @@ namespace App\Http\Controllers\Api;
use App\Actions\Database\StartDatabase;
use App\Actions\Service\StartService;
use App\Enums\ApplicationDeploymentStatus;
use App\Http\Controllers\Controller;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\ApplicationPreview;
use App\Models\Server;
use App\Models\Service;
use App\Models\Tag;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\Request;
use OpenApi\Attributes as OA;
use Visus\Cuid2\Cuid2;
@@ -128,7 +131,7 @@ class DeployController extends Controller
return response()->json(['message' => 'Deployment not found.'], 404);
}
$application = $deployment->application;
if (! $application || data_get($application->team(), 'id') !== $teamId) {
if (! $application || data_get($application->team(), 'id') !== (int) $teamId) {
return response()->json(['message' => 'Deployment not found.'], 404);
}
@@ -228,8 +231,8 @@ class DeployController extends Controller
// Check if deployment can be cancelled (must be queued or in_progress)
$cancellableStatuses = [
\App\Enums\ApplicationDeploymentStatus::QUEUED->value,
\App\Enums\ApplicationDeploymentStatus::IN_PROGRESS->value,
ApplicationDeploymentStatus::QUEUED->value,
ApplicationDeploymentStatus::IN_PROGRESS->value,
];
if (! in_array($deployment->status, $cancellableStatuses)) {
@@ -246,11 +249,11 @@ class DeployController extends Controller
// Mark deployment as cancelled
$deployment->update([
'status' => \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value,
'status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value,
]);
// Get the server
$server = Server::find($build_server_id);
$server = Server::whereTeamId($teamId)->find($build_server_id);
if ($server) {
// Add cancellation log entry
@@ -304,6 +307,8 @@ class DeployController extends Controller
new OA\Parameter(name: 'uuid', in: 'query', description: 'Resource UUID(s). Comma separated list is also accepted.', schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'force', in: 'query', description: 'Force rebuild (without cache)', schema: new OA\Schema(type: 'boolean')),
new OA\Parameter(name: 'pr', in: 'query', description: 'Pull Request Id for deploying specific PR builds. Cannot be used with tag parameter.', schema: new OA\Schema(type: 'integer')),
new OA\Parameter(name: 'pull_request_id', in: 'query', description: 'Preview deployment identifier. Alias of pr.', schema: new OA\Schema(type: 'integer')),
new OA\Parameter(name: 'docker_tag', in: 'query', description: 'Docker image tag for Docker Image preview deployments. Requires pull_request_id.', schema: new OA\Schema(type: 'string')),
],
responses: [
@@ -354,7 +359,9 @@ class DeployController extends Controller
$uuids = $request->input('uuid');
$tags = $request->input('tag');
$force = $request->input('force') ?? false;
$pr = $request->input('pr') ? max((int) $request->input('pr'), 0) : 0;
$pullRequestId = $request->input('pull_request_id', $request->input('pr'));
$pr = $pullRequestId ? max((int) $pullRequestId, 0) : 0;
$dockerTag = $request->string('docker_tag')->trim()->value() ?: null;
if ($uuids && $tags) {
return response()->json(['message' => 'You can only use uuid or tag, not both.'], 400);
@@ -362,16 +369,22 @@ class DeployController extends Controller
if ($tags && $pr) {
return response()->json(['message' => 'You can only use tag or pr, not both.'], 400);
}
if ($dockerTag && $pr === 0) {
return response()->json(['message' => 'docker_tag requires pull_request_id.'], 400);
}
if ($dockerTag && $tags) {
return response()->json(['message' => 'You can only use tag or docker_tag, not both.'], 400);
}
if ($tags) {
return $this->by_tags($tags, $teamId, $force);
} elseif ($uuids) {
return $this->by_uuids($uuids, $teamId, $force, $pr);
return $this->by_uuids($uuids, $teamId, $force, $pr, $dockerTag);
}
return response()->json(['message' => 'You must provide uuid or tag.'], 400);
}
private function by_uuids(string $uuid, int $teamId, bool $force = false, int $pr = 0)
private function by_uuids(string $uuid, int $teamId, bool $force = false, int $pr = 0, ?string $dockerTag = null)
{
$uuids = explode(',', $uuid);
$uuids = collect(array_filter($uuids));
@@ -384,15 +397,22 @@ class DeployController extends Controller
foreach ($uuids as $uuid) {
$resource = getResourceByUuid($uuid, $teamId);
if ($resource) {
$dockerTagForResource = $dockerTag;
if ($pr !== 0) {
$preview = $resource->previews()->where('pull_request_id', $pr)->first();
$preview = null;
if ($resource instanceof Application && $resource->build_pack === 'dockerimage') {
$preview = $this->upsertDockerImagePreview($resource, $pr, $dockerTag);
$dockerTagForResource = $preview?->docker_registry_image_tag;
} else {
$preview = $resource->previews()->where('pull_request_id', $pr)->first();
}
if (! $preview) {
$deployments->push(['message' => "Pull request {$pr} not found for this resource.", 'resource_uuid' => $uuid]);
continue;
}
}
$result = $this->deploy_resource($resource, $force, $pr);
$result = $this->deploy_resource($resource, $force, $pr, $dockerTagForResource);
if (isset($result['status']) && $result['status'] === 429) {
return response()->json(['message' => $result['message']], 429)->header('Retry-After', 60);
}
@@ -465,7 +485,7 @@ class DeployController extends Controller
return response()->json(['message' => 'No resources found with this tag.'], 404);
}
public function deploy_resource($resource, bool $force = false, int $pr = 0): array
public function deploy_resource($resource, bool $force = false, int $pr = 0, ?string $dockerTag = null): array
{
$message = null;
$deployment_uuid = null;
@@ -477,9 +497,12 @@ class DeployController extends Controller
// Check authorization for application deployment
try {
$this->authorize('deploy', $resource);
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
} catch (AuthorizationException $e) {
return ['message' => 'Unauthorized to deploy this application.', 'deployment_uuid' => null];
}
if ($dockerTag !== null && $resource->build_pack !== 'dockerimage') {
return ['message' => 'docker_tag can only be used with Docker Image applications.', 'deployment_uuid' => null];
}
$deployment_uuid = new Cuid2;
$result = queue_application_deployment(
application: $resource,
@@ -487,6 +510,7 @@ class DeployController extends Controller
force_rebuild: $force,
pull_request_id: $pr,
is_api: true,
docker_registry_image_tag: $dockerTag,
);
if ($result['status'] === 'queue_full') {
return ['message' => $result['message'], 'deployment_uuid' => null, 'status' => 429];
@@ -500,7 +524,7 @@ class DeployController extends Controller
// Check authorization for service deployment
try {
$this->authorize('deploy', $resource);
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
} catch (AuthorizationException $e) {
return ['message' => 'Unauthorized to deploy this service.', 'deployment_uuid' => null];
}
StartService::run($resource);
@@ -510,7 +534,7 @@ class DeployController extends Controller
// Database resource - check authorization
try {
$this->authorize('manage', $resource);
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
} catch (AuthorizationException $e) {
return ['message' => 'Unauthorized to start this database.', 'deployment_uuid' => null];
}
StartDatabase::dispatch($resource);
@@ -525,6 +549,34 @@ class DeployController extends Controller
return ['message' => $message, 'deployment_uuid' => $deployment_uuid];
}
private function upsertDockerImagePreview(Application $application, int $pullRequestId, ?string $dockerTag): ?ApplicationPreview
{
$preview = $application->previews()->where('pull_request_id', $pullRequestId)->first();
if (! $preview && $dockerTag === null) {
return null;
}
if (! $preview) {
$preview = ApplicationPreview::create([
'application_id' => $application->id,
'pull_request_id' => $pullRequestId,
'pull_request_html_url' => '',
'docker_registry_image_tag' => $dockerTag,
]);
$preview->generate_preview_fqdn();
return $preview;
}
if ($dockerTag !== null && $preview->docker_registry_image_tag !== $dockerTag) {
$preview->docker_registry_image_tag = $dockerTag;
$preview->save();
}
return $preview;
}
#[OA\Get(
summary: 'List application deployments',
description: 'List application deployments by using the app uuid',
+12 -9
View File
@@ -5,6 +5,9 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\GithubApp;
use App\Models\PrivateKey;
use App\Rules\SafeExternalUrl;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
@@ -182,7 +185,7 @@ class GithubController extends Controller
}
$this->authorize('create', [GithubApp::class]);
$return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) {
if ($return instanceof JsonResponse) {
return $return;
}
@@ -205,8 +208,8 @@ class GithubController extends Controller
$validator = customApiValidator($request->all(), [
'name' => 'required|string|max:255',
'organization' => 'nullable|string|max:255',
'api_url' => 'required|string|url',
'html_url' => 'required|string|url',
'api_url' => ['required', 'string', 'url', new SafeExternalUrl],
'html_url' => ['required', 'string', 'url', new SafeExternalUrl],
'custom_user' => 'nullable|string|max:255',
'custom_port' => 'nullable|integer|min:1|max:65535',
'app_id' => 'required|integer',
@@ -371,7 +374,7 @@ class GithubController extends Controller
return response()->json([
'repositories' => $repositories->sortBy('name')->values(),
]);
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
} catch (ModelNotFoundException $e) {
return response()->json(['message' => 'GitHub app not found'], 404);
} catch (\Throwable $e) {
return handleError($e);
@@ -473,7 +476,7 @@ class GithubController extends Controller
return response()->json([
'branches' => $branches,
]);
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
} catch (ModelNotFoundException $e) {
return response()->json(['message' => 'GitHub app not found'], 404);
} catch (\Throwable $e) {
return handleError($e);
@@ -589,10 +592,10 @@ class GithubController extends Controller
$rules['organization'] = 'nullable|string';
}
if (isset($payload['api_url'])) {
$rules['api_url'] = 'url';
$rules['api_url'] = ['url', new SafeExternalUrl];
}
if (isset($payload['html_url'])) {
$rules['html_url'] = 'url';
$rules['html_url'] = ['url', new SafeExternalUrl];
}
if (isset($payload['custom_user'])) {
$rules['custom_user'] = 'string';
@@ -653,7 +656,7 @@ class GithubController extends Controller
'message' => 'GitHub app updated successfully',
'data' => $githubApp,
]);
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
} catch (ModelNotFoundException $e) {
return response()->json([
'message' => 'GitHub app not found',
], 404);
@@ -739,7 +742,7 @@ class GithubController extends Controller
return response()->json([
'message' => 'GitHub app deleted successfully',
]);
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
} catch (ModelNotFoundException $e) {
return response()->json([
'message' => 'GitHub app not found',
], 404);
@@ -587,7 +587,8 @@ class HetznerController extends Controller
}
// Check server limit
if (Team::serverLimitReached()) {
$team = Team::find($teamId);
if (Team::serverLimitReached($team)) {
return response()->json(['message' => 'Server limit reached for your subscription.'], 400);
}
+7 -3
View File
@@ -147,11 +147,15 @@ class OtherController extends Controller
public function feedback(Request $request)
{
$content = $request->input('content');
$data = $request->validate([
'content' => ['required', 'string', 'min:10', 'max:2000'],
]);
$webhook_url = config('constants.webhooks.feedback_discord_webhook');
if ($webhook_url) {
Http::post($webhook_url, [
'content' => $content,
Http::timeout(5)->post($webhook_url, [
'content' => $data['content'],
'allowed_mentions' => ['parse' => []],
]);
}
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Project;
use App\Support\ValidationPatterns;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use OpenApi\Attributes as OA;
@@ -236,7 +237,7 @@ class ProjectController extends Controller
$this->authorize('create', [Project::class]);
$return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) {
if ($return instanceof JsonResponse) {
return $return;
}
$validator = Validator::make($request->all(), [
@@ -349,7 +350,7 @@ class ProjectController extends Controller
}
$return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) {
if ($return instanceof JsonResponse) {
return $return;
}
$validator = Validator::make($request->all(), [
@@ -604,7 +605,7 @@ class ProjectController extends Controller
}
$return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) {
if ($return instanceof JsonResponse) {
return $return;
}
$validator = Validator::make($request->all(), [
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\PrivateKey;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use OpenApi\Attributes as OA;
@@ -178,7 +179,7 @@ class SecurityController extends Controller
}
$this->authorize('create', [PrivateKey::class]);
$return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) {
if ($return instanceof JsonResponse) {
return $return;
}
$validator = customApiValidator($request->all(), [
@@ -302,7 +303,7 @@ class SecurityController extends Controller
return invalidTokenResponse();
}
$return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) {
if ($return instanceof JsonResponse) {
return $return;
}
@@ -333,7 +334,7 @@ class SecurityController extends Controller
], 404);
}
$this->authorize('update', $foundKey);
$foundKey->update($request->all());
$foundKey->update($request->only($allowedFields));
return response()->json(serializeApiResponse([
'uuid' => $foundKey->uuid,
+52 -9
View File
@@ -7,6 +7,7 @@ use App\Actions\Server\ValidateServer;
use App\Enums\ProxyStatus;
use App\Enums\ProxyTypes;
use App\Http\Controllers\Controller;
use App\Jobs\DeleteResourceJob;
use App\Models\Application;
use App\Models\PrivateKey;
use App\Models\Project;
@@ -290,7 +291,11 @@ class ServersController extends Controller
if (is_null($teamId)) {
return invalidTokenResponse();
}
$uuid = $request->get('uuid');
$server = ModelsServer::whereTeamId($teamId)->whereUuid($request->uuid)->first();
if (is_null($server)) {
return response()->json(['message' => 'Server not found.'], 404);
}
$uuid = $request->query('uuid');
if ($uuid) {
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $uuid)->first();
if (! $application) {
@@ -301,7 +306,9 @@ class ServersController extends Controller
}
$projects = Project::where('team_id', $teamId)->get();
$domains = collect();
$applications = $projects->pluck('applications')->flatten();
$applications = $projects->pluck('applications')->flatten()->filter(function ($application) use ($server) {
return $application->destination?->server?->id === $server->id;
});
$settings = instanceSettings();
if ($applications->count() > 0) {
foreach ($applications as $application) {
@@ -341,7 +348,9 @@ class ServersController extends Controller
}
}
}
$services = $projects->pluck('services')->flatten();
$services = $projects->pluck('services')->flatten()->filter(function ($service) use ($server) {
return $service->server_id === $server->id;
});
if ($services->count() > 0) {
foreach ($services as $service) {
$service_applications = $service->applications;
@@ -354,7 +363,8 @@ class ServersController extends Controller
})->filter(function (Stringable $fqdn) {
return $fqdn->isNotEmpty();
});
if ($ip === 'host.docker.internal') {
$serviceIp = $server->ip;
if ($serviceIp === 'host.docker.internal') {
if ($settings->public_ipv4) {
$domains->push([
'domain' => $fqdn,
@@ -370,13 +380,13 @@ class ServersController extends Controller
if (! $settings->public_ipv4 && ! $settings->public_ipv6) {
$domains->push([
'domain' => $fqdn,
'ip' => $ip,
'ip' => $serviceIp,
]);
}
} else {
$domains->push([
'domain' => $fqdn,
'ip' => $ip,
'ip' => $serviceIp,
]);
}
}
@@ -590,6 +600,11 @@ class ServersController extends Controller
'is_build_server' => ['type' => 'boolean', 'description' => 'Is build server.'],
'instant_validate' => ['type' => 'boolean', 'description' => 'Instant validate.'],
'proxy_type' => ['type' => 'string', 'enum' => ['traefik', 'caddy', 'none'], 'description' => 'The proxy type.'],
'concurrent_builds' => ['type' => 'integer', 'description' => 'Number of concurrent builds.'],
'dynamic_timeout' => ['type' => 'integer', 'description' => 'Deployment timeout in seconds.'],
'deployment_queue_limit' => ['type' => 'integer', 'description' => 'Maximum number of queued deployments.'],
'server_disk_usage_notification_threshold' => ['type' => 'integer', 'description' => 'Server disk usage notification threshold (%).'],
'server_disk_usage_check_frequency' => ['type' => 'string', 'description' => 'Cron expression for disk usage check frequency.'],
],
),
),
@@ -626,7 +641,7 @@ class ServersController extends Controller
)]
public function update_server(Request $request)
{
$allowedFields = ['name', 'description', 'ip', 'port', 'user', 'private_key_uuid', 'is_build_server', 'instant_validate', 'proxy_type'];
$allowedFields = ['name', 'description', 'ip', 'port', 'user', 'private_key_uuid', 'is_build_server', 'instant_validate', 'proxy_type', 'concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency'];
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -647,6 +662,11 @@ class ServersController extends Controller
'is_build_server' => 'boolean|nullable',
'instant_validate' => 'boolean|nullable',
'proxy_type' => 'string|nullable',
'concurrent_builds' => 'integer|min:1',
'dynamic_timeout' => 'integer|min:1',
'deployment_queue_limit' => 'integer|min:1',
'server_disk_usage_notification_threshold' => 'integer|min:1|max:100',
'server_disk_usage_check_frequency' => 'string',
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
@@ -684,6 +704,19 @@ class ServersController extends Controller
'is_build_server' => $request->is_build_server,
]);
}
if ($request->has('server_disk_usage_check_frequency') && ! validate_cron_expression($request->server_disk_usage_check_frequency)) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['server_disk_usage_check_frequency' => ['Invalid Cron / Human expression for Disk Usage Check Frequency.']],
], 422);
}
$advancedSettings = $request->only(['concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency']);
if (! empty($advancedSettings)) {
$server->settings()->update(array_filter($advancedSettings, fn ($value) => ! is_null($value)));
}
if ($request->instant_validate) {
ValidateServer::dispatch($server);
}
@@ -762,12 +795,22 @@ class ServersController extends Controller
return response()->json(['message' => 'Server not found.'], 404);
}
$this->authorize('delete', $server);
if ($server->definedResources()->count() > 0) {
return response()->json(['message' => 'Server has resources, so you need to delete them before.'], 400);
$force = filter_var($request->query('force', false), FILTER_VALIDATE_BOOLEAN);
if ($server->definedResources()->count() > 0 && ! $force) {
return response()->json(['message' => 'Server has resources. Use ?force=true to delete all resources and the server, or delete resources manually first.'], 400);
}
if ($server->isLocalhost()) {
return response()->json(['message' => 'Local server cannot be deleted.'], 400);
}
if ($force) {
foreach ($server->definedResources() as $resource) {
DeleteResourceJob::dispatch($resource);
}
}
$server->delete();
DeleteServer::dispatch(
$server->id,
+648 -13
View File
@@ -8,9 +8,13 @@ use App\Actions\Service\StopService;
use App\Http\Controllers\Controller;
use App\Jobs\DeleteResourceJob;
use App\Models\EnvironmentVariable;
use App\Models\LocalFileVolume;
use App\Models\LocalPersistentVolume;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Support\ValidationPatterns;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use OpenApi\Attributes as OA;
@@ -221,11 +225,12 @@ class ServicesController extends Controller
type: 'object',
properties: [
'name' => ['type' => 'string', 'description' => 'The service name as defined in docker-compose.'],
'url' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "http://app.coolify.io,https://app2.coolify.io").'],
'url' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "https://app.coolify.io,https://app2.coolify.io").'],
],
),
],
'force_domain_override' => ['type' => 'boolean', 'default' => false, 'description' => 'Force domain override even if conflicts are detected.'],
'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off.'],
],
),
),
@@ -292,7 +297,7 @@ class ServicesController extends Controller
)]
public function create_service(Request $request)
{
$allowedFields = ['type', 'name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'urls', 'force_domain_override'];
$allowedFields = ['type', 'name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'urls', 'force_domain_override', 'is_container_label_escape_enabled'];
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -302,7 +307,7 @@ class ServicesController extends Controller
$this->authorize('create', Service::class);
$return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) {
if ($return instanceof JsonResponse) {
return $return;
}
$validationRules = [
@@ -321,6 +326,7 @@ class ServicesController extends Controller
'urls.*.name' => 'string|required',
'urls.*.url' => 'string|nullable',
'force_domain_override' => 'boolean',
'is_container_label_escape_enabled' => 'boolean',
];
$validationMessages = [
'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.',
@@ -433,6 +439,9 @@ class ServicesController extends Controller
$service = Service::create($servicePayload);
$service->name = $request->name ?? "$oneClickServiceName-".$service->uuid;
$service->description = $request->description;
if ($request->has('is_container_label_escape_enabled')) {
$service->is_container_label_escape_enabled = $request->boolean('is_container_label_escape_enabled');
}
$service->save();
if ($oneClickDotEnvs?->count() > 0) {
$oneClickDotEnvs->each(function ($value) use ($service) {
@@ -489,7 +498,7 @@ class ServicesController extends Controller
return response()->json(['message' => 'Service not found.', 'valid_service_types' => $serviceKeys], 404);
} elseif (filled($request->docker_compose_raw)) {
$allowedFields = ['name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override'];
$allowedFields = ['name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override', 'is_container_label_escape_enabled'];
$validationRules = [
'project_uuid' => 'string|required',
@@ -507,6 +516,7 @@ class ServicesController extends Controller
'urls.*.name' => 'string|required',
'urls.*.url' => 'string|nullable',
'force_domain_override' => 'boolean',
'is_container_label_escape_enabled' => 'boolean',
];
$validationMessages = [
'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.',
@@ -613,6 +623,9 @@ class ServicesController extends Controller
$service->destination_id = $destination->id;
$service->destination_type = $destination->getMorphClass();
$service->connect_to_docker_network = $connectToDockerNetwork;
if ($request->has('is_container_label_escape_enabled')) {
$service->is_container_label_escape_enabled = $request->boolean('is_container_label_escape_enabled');
}
$service->save();
$service->parse(isNew: true);
@@ -834,11 +847,12 @@ class ServicesController extends Controller
type: 'object',
properties: [
'name' => ['type' => 'string', 'description' => 'The service name as defined in docker-compose.'],
'url' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "http://app.coolify.io,https://app2.coolify.io").'],
'url' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "https://app.coolify.io,https://app2.coolify.io").'],
],
),
],
'force_domain_override' => ['type' => 'boolean', 'default' => false, 'description' => 'Force domain override even if conflicts are detected.'],
'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. If you want to use env variables inside the labels, turn this off.'],
],
)
),
@@ -916,7 +930,7 @@ class ServicesController extends Controller
}
$return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) {
if ($return instanceof JsonResponse) {
return $return;
}
@@ -927,7 +941,7 @@ class ServicesController extends Controller
$this->authorize('update', $service);
$allowedFields = ['name', 'description', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override'];
$allowedFields = ['name', 'description', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override', 'is_container_label_escape_enabled'];
$validationRules = [
'name' => 'string|max:255',
@@ -940,6 +954,7 @@ class ServicesController extends Controller
'urls.*.name' => 'string|required',
'urls.*.url' => 'string|nullable',
'force_domain_override' => 'boolean',
'is_container_label_escape_enabled' => 'boolean',
];
$validationMessages = [
'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.',
@@ -1005,6 +1020,9 @@ class ServicesController extends Controller
if ($request->has('connect_to_docker_network')) {
$service->connect_to_docker_network = $request->connect_to_docker_network;
}
if ($request->has('is_container_label_escape_enabled')) {
$service->is_container_label_escape_enabled = $request->boolean('is_container_label_escape_enabled');
}
$service->save();
$service->parse();
@@ -1197,7 +1215,7 @@ class ServicesController extends Controller
return invalidTokenResponse();
}
$service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first();
$service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->route('uuid'))->first();
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
@@ -1332,7 +1350,7 @@ class ServicesController extends Controller
return invalidTokenResponse();
}
$service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first();
$service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->route('uuid'))->first();
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
@@ -1352,6 +1370,7 @@ class ServicesController extends Controller
'is_literal' => 'boolean',
'is_multiline' => 'boolean',
'is_shown_once' => 'boolean',
'comment' => 'string|nullable|max:256',
]);
if ($validator->fails()) {
@@ -1451,7 +1470,7 @@ class ServicesController extends Controller
return invalidTokenResponse();
}
$service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first();
$service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->route('uuid'))->first();
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
@@ -1560,14 +1579,14 @@ class ServicesController extends Controller
return invalidTokenResponse();
}
$service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first();
$service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->route('uuid'))->first();
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
$this->authorize('manageEnvironment', $service);
$env = EnvironmentVariable::where('uuid', $request->env_uuid)
$env = EnvironmentVariable::where('uuid', $request->route('env_uuid'))
->where('resourceable_type', Service::class)
->where('resourceable_id', $service->id)
->first();
@@ -1680,6 +1699,15 @@ class ServicesController extends Controller
type: 'string',
)
),
new OA\Parameter(
name: 'docker_cleanup',
in: 'query',
description: 'Perform docker cleanup (prune networks, volumes, etc.).',
schema: new OA\Schema(
type: 'boolean',
default: true,
)
),
],
responses: [
new OA\Response(
@@ -1731,7 +1759,9 @@ class ServicesController extends Controller
if (str($service->status)->contains('stopped') || str($service->status)->contains('exited')) {
return response()->json(['message' => 'Service is already stopped.'], 400);
}
StopService::dispatch($service);
$dockerCleanup = $request->boolean('docker_cleanup', true);
StopService::dispatch($service, false, $dockerCleanup);
return response()->json(
[
@@ -1827,4 +1857,609 @@ class ServicesController extends Controller
200
);
}
#[OA\Get(
summary: 'List Storages',
description: 'List all persistent storages and file storages by service UUID.',
path: '/services/{uuid}/storages',
operationId: 'list-storages-by-service-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',
)
),
],
responses: [
new OA\Response(
response: 200,
description: 'All storages by service UUID.',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'persistent_storages', type: 'array', items: new OA\Items(type: 'object')),
new OA\Property(property: 'file_storages', type: 'array', items: new OA\Items(type: 'object')),
],
),
),
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 storages(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first();
if (! $service) {
return response()->json([
'message' => 'Service not found.',
], 404);
}
$this->authorize('view', $service);
$persistentStorages = collect();
$fileStorages = collect();
foreach ($service->applications as $app) {
$persistentStorages = $persistentStorages->merge(
$app->persistentStorages->map(fn ($s) => $s->setAttribute('resource_uuid', $app->uuid)->setAttribute('resource_type', 'application'))
);
$fileStorages = $fileStorages->merge(
$app->fileStorages->map(fn ($s) => $s->setAttribute('resource_uuid', $app->uuid)->setAttribute('resource_type', 'application'))
);
}
foreach ($service->databases as $db) {
$persistentStorages = $persistentStorages->merge(
$db->persistentStorages->map(fn ($s) => $s->setAttribute('resource_uuid', $db->uuid)->setAttribute('resource_type', 'database'))
);
$fileStorages = $fileStorages->merge(
$db->fileStorages->map(fn ($s) => $s->setAttribute('resource_uuid', $db->uuid)->setAttribute('resource_type', 'database'))
);
}
return response()->json([
'persistent_storages' => $persistentStorages->sortBy('id')->values(),
'file_storages' => $fileStorages->sortBy('id')->values(),
]);
}
#[OA\Post(
summary: 'Create Storage',
description: 'Create a persistent storage or file storage for a service sub-resource.',
path: '/services/{uuid}/storages',
operationId: 'create-storage-by-service-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')
),
],
requestBody: new OA\RequestBody(
required: true,
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
required: ['type', 'mount_path', 'resource_uuid'],
properties: [
'type' => ['type' => 'string', 'enum' => ['persistent', 'file'], 'description' => 'The type of storage.'],
'resource_uuid' => ['type' => 'string', 'description' => 'UUID of the service application or database sub-resource.'],
'name' => ['type' => 'string', 'description' => 'Volume name (persistent only, required for persistent).'],
'mount_path' => ['type' => 'string', 'description' => 'The container mount path.'],
'host_path' => ['type' => 'string', 'nullable' => true, 'description' => 'The host path (persistent only, optional).'],
'content' => ['type' => 'string', 'nullable' => true, 'description' => 'File content (file only, optional).'],
'is_directory' => ['type' => 'boolean', 'description' => 'Whether this is a directory mount (file only, default false).'],
'fs_path' => ['type' => 'string', 'description' => 'Host directory path (required when is_directory is true).'],
],
additionalProperties: false,
),
),
],
),
responses: [
new OA\Response(
response: 201,
description: 'Storage created.',
content: new OA\JsonContent(type: 'object'),
),
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'),
new OA\Response(response: 422, ref: '#/components/responses/422'),
]
)]
public function create_storage(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$return = validateIncomingRequest($request);
if ($return instanceof JsonResponse) {
return $return;
}
$service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first();
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
$this->authorize('update', $service);
$validator = customApiValidator($request->all(), [
'type' => 'required|string|in:persistent,file',
'resource_uuid' => 'required|string',
'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN],
'mount_path' => 'required|string',
'host_path' => 'string|nullable',
'content' => 'string|nullable',
'is_directory' => 'boolean',
'fs_path' => 'string',
]);
$allAllowedFields = ['type', 'resource_uuid', 'name', 'mount_path', 'host_path', 'content', 'is_directory', 'fs_path'];
$extraFields = array_diff(array_keys($request->all()), $allAllowedFields);
if ($validator->fails() || ! empty($extraFields)) {
$errors = $validator->errors();
if (! empty($extraFields)) {
foreach ($extraFields as $field) {
$errors->add($field, 'This field is not allowed.');
}
}
return response()->json([
'message' => 'Validation failed.',
'errors' => $errors,
], 422);
}
$subResource = $service->applications()->where('uuid', $request->resource_uuid)->first();
if (! $subResource) {
$subResource = $service->databases()->where('uuid', $request->resource_uuid)->first();
}
if (! $subResource) {
return response()->json(['message' => 'Service resource not found.'], 404);
}
if ($request->type === 'persistent') {
if (! $request->name) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['name' => 'The name field is required for persistent storages.'],
], 422);
}
$typeSpecificInvalidFields = array_intersect(['content', 'is_directory', 'fs_path'], array_keys($request->all()));
if (! empty($typeSpecificInvalidFields)) {
return response()->json([
'message' => 'Validation failed.',
'errors' => collect($typeSpecificInvalidFields)
->mapWithKeys(fn ($field) => [$field => "Field '{$field}' is not valid for type 'persistent'."]),
], 422);
}
$storage = LocalPersistentVolume::create([
'name' => $subResource->uuid.'-'.$request->name,
'mount_path' => $request->mount_path,
'host_path' => $request->host_path,
'resource_id' => $subResource->id,
'resource_type' => $subResource->getMorphClass(),
]);
return response()->json($storage, 201);
}
// File storage
$typeSpecificInvalidFields = array_intersect(['name', 'host_path'], array_keys($request->all()));
if (! empty($typeSpecificInvalidFields)) {
return response()->json([
'message' => 'Validation failed.',
'errors' => collect($typeSpecificInvalidFields)
->mapWithKeys(fn ($field) => [$field => "Field '{$field}' is not valid for type 'file'."]),
], 422);
}
$isDirectory = $request->boolean('is_directory', false);
if ($isDirectory) {
if (! $request->fs_path) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['fs_path' => 'The fs_path field is required for directory mounts.'],
], 422);
}
$fsPath = str($request->fs_path)->trim()->start('/')->value();
$mountPath = str($request->mount_path)->trim()->start('/')->value();
validateShellSafePath($fsPath, 'storage source path');
validateShellSafePath($mountPath, 'storage destination path');
$storage = LocalFileVolume::create([
'fs_path' => $fsPath,
'mount_path' => $mountPath,
'is_directory' => true,
'resource_id' => $subResource->id,
'resource_type' => get_class($subResource),
]);
} else {
$mountPath = str($request->mount_path)->trim()->start('/')->value();
validateShellSafePath($mountPath, 'file storage path');
$fsPath = service_configuration_dir().'/'.$service->uuid.$mountPath;
$storage = LocalFileVolume::create([
'fs_path' => $fsPath,
'mount_path' => $mountPath,
'content' => $request->content,
'is_directory' => false,
'resource_id' => $subResource->id,
'resource_type' => get_class($subResource),
]);
}
return response()->json($storage, 201);
}
#[OA\Patch(
summary: 'Update Storage',
description: 'Update a persistent storage or file storage by service UUID.',
path: '/services/{uuid}/storages',
operationId: 'update-storage-by-service-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',
)
),
],
requestBody: new OA\RequestBody(
description: 'Storage updated. For read-only storages (from docker-compose or services), only is_preview_suffix_enabled can be updated.',
required: true,
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
required: ['type'],
properties: [
'uuid' => ['type' => 'string', 'description' => 'The UUID of the storage (preferred).'],
'id' => ['type' => 'integer', 'description' => 'The ID of the storage (deprecated, use uuid instead).'],
'type' => ['type' => 'string', 'enum' => ['persistent', 'file'], 'description' => 'The type of storage: persistent or file.'],
'is_preview_suffix_enabled' => ['type' => 'boolean', 'description' => 'Whether to add -pr-N suffix for preview deployments.'],
'name' => ['type' => 'string', 'description' => 'The volume name (persistent only, not allowed for read-only storages).'],
'mount_path' => ['type' => 'string', 'description' => 'The container mount path (not allowed for read-only storages).'],
'host_path' => ['type' => 'string', 'nullable' => true, 'description' => 'The host path (persistent only, not allowed for read-only storages).'],
'content' => ['type' => 'string', 'nullable' => true, 'description' => 'The file content (file only, not allowed for read-only storages).'],
],
additionalProperties: false,
),
),
],
),
responses: [
new OA\Response(
response: 200,
description: 'Storage updated.',
content: new OA\JsonContent(type: 'object'),
),
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',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function update_storage(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$return = validateIncomingRequest($request);
if ($return instanceof JsonResponse) {
return $return;
}
$service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->route('uuid'))->first();
if (! $service) {
return response()->json([
'message' => 'Service not found.',
], 404);
}
$this->authorize('update', $service);
$validator = customApiValidator($request->all(), [
'uuid' => 'string',
'id' => 'integer',
'type' => 'required|string|in:persistent,file',
'is_preview_suffix_enabled' => 'boolean',
'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN],
'mount_path' => 'string',
'host_path' => 'string|nullable',
'content' => 'string|nullable',
]);
$allAllowedFields = ['uuid', 'id', 'type', 'is_preview_suffix_enabled', 'name', 'mount_path', 'host_path', 'content'];
$extraFields = array_diff(array_keys($request->all()), $allAllowedFields);
if ($validator->fails() || ! empty($extraFields)) {
$errors = $validator->errors();
if (! empty($extraFields)) {
foreach ($extraFields as $field) {
$errors->add($field, 'This field is not allowed.');
}
}
return response()->json([
'message' => 'Validation failed.',
'errors' => $errors,
], 422);
}
$storageUuid = $request->input('uuid');
$storageId = $request->input('id');
if (! $storageUuid && ! $storageId) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['uuid' => 'Either uuid or id is required.'],
], 422);
}
$lookupField = $storageUuid ? 'uuid' : 'id';
$lookupValue = $storageUuid ?? $storageId;
$storage = null;
if ($request->type === 'persistent') {
foreach ($service->applications as $app) {
$storage = $app->persistentStorages->where($lookupField, $lookupValue)->first();
if ($storage) {
break;
}
}
if (! $storage) {
foreach ($service->databases as $db) {
$storage = $db->persistentStorages->where($lookupField, $lookupValue)->first();
if ($storage) {
break;
}
}
}
} else {
foreach ($service->applications as $app) {
$storage = $app->fileStorages->where($lookupField, $lookupValue)->first();
if ($storage) {
break;
}
}
if (! $storage) {
foreach ($service->databases as $db) {
$storage = $db->fileStorages->where($lookupField, $lookupValue)->first();
if ($storage) {
break;
}
}
}
}
if (! $storage) {
return response()->json([
'message' => 'Storage not found.',
], 404);
}
$isReadOnly = $storage->shouldBeReadOnlyInUI();
$editableOnlyFields = ['name', 'mount_path', 'host_path', 'content'];
$requestedEditableFields = array_intersect($editableOnlyFields, array_keys($request->all()));
if ($isReadOnly && ! empty($requestedEditableFields)) {
return response()->json([
'message' => 'This storage is read-only (managed by docker-compose or service definition). Only is_preview_suffix_enabled can be updated.',
'read_only_fields' => array_values($requestedEditableFields),
], 422);
}
// Reject fields that don't apply to the given storage type
if (! $isReadOnly) {
$typeSpecificInvalidFields = $request->type === 'persistent'
? array_intersect(['content'], array_keys($request->all()))
: array_intersect(['name', 'host_path'], array_keys($request->all()));
if (! empty($typeSpecificInvalidFields)) {
return response()->json([
'message' => 'Validation failed.',
'errors' => collect($typeSpecificInvalidFields)
->mapWithKeys(fn ($field) => [$field => "Field '{$field}' is not valid for type '{$request->type}'."]),
], 422);
}
}
// Always allowed
if ($request->has('is_preview_suffix_enabled')) {
$storage->is_preview_suffix_enabled = $request->is_preview_suffix_enabled;
}
// Only for editable storages
if (! $isReadOnly) {
if ($request->type === 'persistent') {
if ($request->has('name')) {
$storage->name = $request->name;
}
if ($request->has('mount_path')) {
$storage->mount_path = $request->mount_path;
}
if ($request->has('host_path')) {
$storage->host_path = $request->host_path;
}
} else {
if ($request->has('mount_path')) {
$storage->mount_path = $request->mount_path;
}
if ($request->has('content')) {
$storage->content = $request->content;
}
}
}
$storage->save();
return response()->json($storage);
}
#[OA\Delete(
summary: 'Delete Storage',
description: 'Delete a persistent storage or file storage by service UUID.',
path: '/services/{uuid}/storages/{storage_uuid}',
operationId: 'delete-storage-by-service-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')
),
new OA\Parameter(
name: 'storage_uuid',
in: 'path',
description: 'UUID of the storage.',
required: true,
schema: new OA\Schema(type: 'string')
),
],
responses: [
new OA\Response(response: 200, description: 'Storage deleted.', content: new OA\JsonContent(
properties: [new OA\Property(property: 'message', 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'),
new OA\Response(response: 422, ref: '#/components/responses/422'),
]
)]
public function delete_storage(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first();
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
$this->authorize('update', $service);
$storageUuid = $request->route('storage_uuid');
$storage = null;
foreach ($service->applications as $app) {
$storage = $app->persistentStorages->where('uuid', $storageUuid)->first();
if ($storage) {
break;
}
}
if (! $storage) {
foreach ($service->databases as $db) {
$storage = $db->persistentStorages->where('uuid', $storageUuid)->first();
if ($storage) {
break;
}
}
}
if (! $storage) {
foreach ($service->applications as $app) {
$storage = $app->fileStorages->where('uuid', $storageUuid)->first();
if ($storage) {
break;
}
}
}
if (! $storage) {
foreach ($service->databases as $db) {
$storage = $db->fileStorages->where('uuid', $storageUuid)->first();
if ($storage) {
break;
}
}
}
if (! $storage) {
return response()->json(['message' => 'Storage not found.'], 404);
}
if ($storage->shouldBeReadOnlyInUI()) {
return response()->json([
'message' => 'This storage is read-only (managed by docker-compose or service definition) and cannot be deleted.',
], 422);
}
if ($storage instanceof LocalFileVolume) {
$storage->deleteStorageOnServer();
}
$storage->delete();
return response()->json(['message' => 'Storage deleted.']);
}
}
@@ -14,14 +14,6 @@ class TeamController extends Controller
'custom_server_limit',
'pivot',
]);
if (request()->attributes->get('can_read_sensitive', false) === false) {
$team->makeHidden([
'smtp_username',
'smtp_password',
'resend_api_key',
'telegram_token',
]);
}
return serializeApiResponse($team);
}
+31 -31
View File
@@ -108,9 +108,31 @@ class Controller extends BaseController
return redirect()->route('login')->with('error', 'Invalid credentials.');
}
public function showInvitation()
{
$invitationUuid = request()->route('uuid');
$invitation = TeamInvitation::whereUuid($invitationUuid)->firstOrFail();
$user = User::whereEmail($invitation->email)->firstOrFail();
if (Auth::id() !== $user->id) {
abort(400, 'You are not allowed to accept this invitation.');
}
if (! $invitation->isValid()) {
abort(400, 'Invitation expired.');
}
$alreadyMember = $user->teams()->where('team_id', $invitation->team->id)->exists();
return view('invitation.accept', [
'invitation' => $invitation,
'team' => $invitation->team,
'alreadyMember' => $alreadyMember,
]);
}
public function acceptInvitation()
{
$resetPassword = request()->query('reset-password');
$invitationUuid = request()->route('uuid');
$invitation = TeamInvitation::whereUuid($invitationUuid)->firstOrFail();
@@ -119,43 +141,21 @@ class Controller extends BaseController
if (Auth::id() !== $user->id) {
abort(400, 'You are not allowed to accept this invitation.');
}
$invitationValid = $invitation->isValid();
if ($invitationValid) {
if ($resetPassword) {
$user->update([
'password' => Hash::make($invitationUuid),
'force_password_reset' => true,
]);
}
if ($user->teams()->where('team_id', $invitation->team->id)->exists()) {
$invitation->delete();
return redirect()->route('team.index');
}
$user->teams()->attach($invitation->team->id, ['role' => $invitation->role]);
$invitation->delete();
refreshSession($invitation->team);
return redirect()->route('team.index');
} else {
if (! $invitation->isValid()) {
abort(400, 'Invitation expired.');
}
}
public function revokeInvitation()
{
$invitation = TeamInvitation::whereUuid(request()->route('uuid'))->firstOrFail();
$user = User::whereEmail($invitation->email)->firstOrFail();
if (is_null(Auth::user())) {
return redirect()->route('login');
}
if (Auth::id() !== $user->id) {
abort(401);
if ($user->teams()->where('team_id', $invitation->team->id)->exists()) {
$invitation->delete();
return redirect()->route('team.index');
}
$user->teams()->attach($invitation->team->id, ['role' => $invitation->role]);
$invitation->delete();
refreshSession($invitation->team);
return redirect()->route('team.index');
}
}
+21 -2
View File
@@ -57,10 +57,29 @@ class Bitbucket extends Controller
}
foreach ($applications as $application) {
$webhook_secret = data_get($application, 'manual_webhook_secret_bitbucket');
if (empty($webhook_secret)) {
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
'message' => 'Webhook secret not configured.',
]);
continue;
}
$payload = $request->getContent();
[$algo, $hash] = explode('=', $x_bitbucket_token, 2);
$payloadHash = hash_hmac($algo, $payload, $webhook_secret);
$parts = explode('=', $x_bitbucket_token, 2);
if (count($parts) !== 2 || $parts[0] !== 'sha256') {
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
'message' => 'Invalid signature.',
]);
continue;
}
$hash = $parts[1];
$payloadHash = hash_hmac('sha256', $payload, $webhook_secret);
if (! hash_equals($hash, $payloadHash) && ! isDev()) {
$return_payloads->push([
'application' => $application->name,
+9
View File
@@ -67,6 +67,15 @@ class Gitea extends Controller
}
foreach ($applications as $application) {
$webhook_secret = data_get($application, 'manual_webhook_secret_gitea');
if (empty($webhook_secret)) {
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
'message' => 'Webhook secret not configured.',
]);
continue;
}
$hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret);
if (! hash_equals($x_hub_signature_256, $hmac) && ! isDev()) {
$return_payloads->push([
+15
View File
@@ -55,6 +55,9 @@ class Github extends Controller
$after_sha = data_get($payload, 'after', data_get($payload, 'pull_request.head.sha'));
$author_association = data_get($payload, 'pull_request.author_association');
}
if (! in_array($x_github_event, ['push', 'pull_request'])) {
return response("Nothing to do. Event '$x_github_event' is not supported.");
}
if (! $branch) {
return response('Nothing to do. No branch found in the request.');
}
@@ -78,6 +81,15 @@ class Github extends Controller
foreach ($applicationsByServer as $serverId => $serverApplications) {
foreach ($serverApplications as $application) {
$webhook_secret = data_get($application, 'manual_webhook_secret_github');
if (empty($webhook_secret)) {
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
'message' => 'Webhook secret not configured.',
]);
continue;
}
$hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret);
if (! hash_equals($x_hub_signature_256, $hmac) && ! isDev()) {
$return_payloads->push([
@@ -246,6 +258,9 @@ class Github extends Controller
$after_sha = data_get($payload, 'after', data_get($payload, 'pull_request.head.sha'));
$author_association = data_get($payload, 'pull_request.author_association');
}
if (! in_array($x_github_event, ['push', 'pull_request'])) {
return response("Nothing to do. Event '$x_github_event' is not supported.");
}
if (! $id || ! $branch) {
return response('Nothing to do. No id or branch found.');
}
+10 -1
View File
@@ -100,7 +100,16 @@ class Gitlab extends Controller
}
foreach ($applications as $application) {
$webhook_secret = data_get($application, 'manual_webhook_secret_gitlab');
if (! hash_equals($webhook_secret ?? '', $x_gitlab_token ?? '')) {
if (empty($webhook_secret)) {
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
'message' => 'Webhook secret not configured.',
]);
continue;
}
if (! hash_equals($webhook_secret, $x_gitlab_token ?? '')) {
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',