Merge remote-tracking branch 'origin/next' into env-var-descriptions

This commit is contained in:
Andras Bacsai committed 2026-02-28 00:09:54 +01:00
commit d9e39ba211
404 files changed
+29406 -26356

No files matched your search

File diff suppressed because it is too large. Load diff
@@ -411,7 +411,6 @@ class CloudProviderTokensController extends Controller
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
],
@@ -117,7 +117,6 @@ class DatabasesController extends Controller
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
],
@@ -182,7 +181,6 @@ class DatabasesController extends Controller
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
],
@@ -245,7 +243,6 @@ class DatabasesController extends Controller
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
],
@@ -392,7 +389,7 @@ class DatabasesController extends Controller
], 422);
}
$postgresConf = base64_decode($request->postgres_conf);
if (mb_detect_encoding($postgresConf, 'ASCII', true) === false) {
if (mb_detect_encoding($postgresConf, 'UTF-8', true) === false) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
@@ -432,7 +429,7 @@ class DatabasesController extends Controller
], 422);
}
$redisConf = base64_decode($request->redis_conf);
if (mb_detect_encoding($redisConf, 'ASCII', true) === false) {
if (mb_detect_encoding($redisConf, 'UTF-8', true) === false) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
@@ -459,7 +456,7 @@ class DatabasesController extends Controller
], 422);
}
$keydbConf = base64_decode($request->keydb_conf);
if (mb_detect_encoding($keydbConf, 'ASCII', true) === false) {
if (mb_detect_encoding($keydbConf, 'UTF-8', true) === false) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
@@ -489,7 +486,7 @@ class DatabasesController extends Controller
], 422);
}
$mariadbConf = base64_decode($request->mariadb_conf);
if (mb_detect_encoding($mariadbConf, 'ASCII', true) === false) {
if (mb_detect_encoding($mariadbConf, 'UTF-8', true) === false) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
@@ -518,7 +515,7 @@ class DatabasesController extends Controller
], 422);
}
$mongoConf = base64_decode($request->mongo_conf);
if (mb_detect_encoding($mongoConf, 'ASCII', true) === false) {
if (mb_detect_encoding($mongoConf, 'UTF-8', true) === false) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
@@ -549,7 +546,7 @@ class DatabasesController extends Controller
], 422);
}
$mysqlConf = base64_decode($request->mysql_conf);
if (mb_detect_encoding($mysqlConf, 'ASCII', true) === false) {
if (mb_detect_encoding($mysqlConf, 'UTF-8', true) === false) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
@@ -614,7 +611,6 @@ class DatabasesController extends Controller
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
],
@@ -832,7 +828,6 @@ class DatabasesController extends Controller
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
new OA\Parameter(
@@ -842,7 +837,6 @@ class DatabasesController extends Controller
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
],
@@ -1707,7 +1701,7 @@ class DatabasesController extends Controller
], 422);
}
$postgresConf = base64_decode($request->postgres_conf);
if (mb_detect_encoding($postgresConf, 'ASCII', true) === false) {
if (mb_detect_encoding($postgresConf, 'UTF-8', true) === false) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
@@ -1762,7 +1756,7 @@ class DatabasesController extends Controller
], 422);
}
$mariadbConf = base64_decode($request->mariadb_conf);
if (mb_detect_encoding($mariadbConf, 'ASCII', true) === false) {
if (mb_detect_encoding($mariadbConf, 'UTF-8', true) === false) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
@@ -1821,7 +1815,7 @@ class DatabasesController extends Controller
], 422);
}
$mysqlConf = base64_decode($request->mysql_conf);
if (mb_detect_encoding($mysqlConf, 'ASCII', true) === false) {
if (mb_detect_encoding($mysqlConf, 'UTF-8', true) === false) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
@@ -1877,7 +1871,7 @@ class DatabasesController extends Controller
], 422);
}
$redisConf = base64_decode($request->redis_conf);
if (mb_detect_encoding($redisConf, 'ASCII', true) === false) {
if (mb_detect_encoding($redisConf, 'UTF-8', true) === false) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
@@ -1963,7 +1957,7 @@ class DatabasesController extends Controller
], 422);
}
$keydbConf = base64_decode($request->keydb_conf);
if (mb_detect_encoding($keydbConf, 'ASCII', true) === false) {
if (mb_detect_encoding($keydbConf, 'UTF-8', true) === false) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
@@ -2057,7 +2051,7 @@ class DatabasesController extends Controller
], 422);
}
$mongoConf = base64_decode($request->mongo_conf);
if (mb_detect_encoding($mongoConf, 'ASCII', true) === false) {
if (mb_detect_encoding($mongoConf, 'UTF-8', true) === false) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
@@ -2104,7 +2098,6 @@ class DatabasesController extends Controller
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
new OA\Parameter(name: 'delete_configurations', in: 'query', required: false, description: 'Delete configurations.', schema: new OA\Schema(type: 'boolean', default: true)),
@@ -2193,7 +2186,7 @@ class DatabasesController extends Controller
in: 'path',
required: true,
description: 'UUID of the backup configuration to delete',
schema: new OA\Schema(type: 'string', format: 'uuid')
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'delete_s3',
@@ -2310,14 +2303,14 @@ class DatabasesController extends Controller
in: 'path',
required: true,
description: 'UUID of the backup configuration',
schema: new OA\Schema(type: 'string', format: 'uuid')
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'execution_uuid',
in: 'path',
required: true,
description: 'UUID of the backup execution to delete',
schema: new OA\Schema(type: 'string', format: 'uuid')
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'delete_s3',
@@ -2430,7 +2423,7 @@ class DatabasesController extends Controller
in: 'path',
required: true,
description: 'UUID of the backup configuration',
schema: new OA\Schema(type: 'string', format: 'uuid')
schema: new OA\Schema(type: 'string')
),
],
responses: [
@@ -2527,7 +2520,6 @@ class DatabasesController extends Controller
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
],
@@ -2608,7 +2600,6 @@ class DatabasesController extends Controller
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
],
@@ -2689,7 +2680,6 @@ class DatabasesController extends Controller
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
],
@@ -127,6 +127,10 @@ class DeployController extends Controller
if (! $deployment) {
return response()->json(['message' => 'Deployment not found.'], 404);
}
$application = $deployment->application;
if (! $application || data_get($application->team(), 'id') !== $teamId) {
return response()->json(['message' => 'Deployment not found.'], 404);
}
return response()->json($this->removeSensitiveData($deployment));
}
@@ -538,7 +542,6 @@ class DeployController extends Controller
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
new OA\Parameter(
@@ -285,7 +285,6 @@ class ProjectController extends Controller
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
],
@@ -406,7 +405,6 @@ class ProjectController extends Controller
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
],
@@ -0,0 +1,922 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Application;
use App\Models\ScheduledTask;
use App\Models\Service;
use Illuminate\Http\Request;
use OpenApi\Attributes as OA;
class ScheduledTasksController extends Controller
{
private function removeSensitiveData($task)
{
$task->makeHidden([
'id',
'team_id',
'application_id',
'service_id',
]);
return serializeApiResponse($task);
}
private function resolveApplication(Request $request, int $teamId): ?Application
{
return Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
}
private function resolveService(Request $request, int $teamId): ?Service
{
return Service::whereRelation('environment.project.team', 'id', $teamId)->where('uuid', $request->uuid)->first();
}
private function listTasks(Application|Service $resource): \Illuminate\Http\JsonResponse
{
$this->authorize('view', $resource);
$tasks = $resource->scheduled_tasks->map(function ($task) {
return $this->removeSensitiveData($task);
});
return response()->json($tasks);
}
private function createTask(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse
{
$this->authorize('update', $resource);
$return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) {
return $return;
}
$allowedFields = ['name', 'command', 'frequency', 'container', 'timeout', 'enabled'];
$validator = customApiValidator($request->all(), [
'name' => 'required|string|max:255',
'command' => 'required|string',
'frequency' => 'required|string',
'container' => 'string|nullable',
'timeout' => 'integer|min:1',
'enabled' => 'boolean',
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
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);
}
if (! validate_cron_expression($request->frequency)) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['frequency' => ['Invalid cron expression or frequency format.']],
], 422);
}
$teamId = getTeamIdFromToken();
$task = new ScheduledTask;
$task->name = $request->name;
$task->command = $request->command;
$task->frequency = $request->frequency;
$task->container = $request->container;
$task->timeout = $request->has('timeout') ? $request->timeout : 300;
$task->enabled = $request->has('enabled') ? $request->enabled : true;
$task->team_id = $teamId;
if ($resource instanceof Application) {
$task->application_id = $resource->id;
} elseif ($resource instanceof Service) {
$task->service_id = $resource->id;
}
$task->save();
return response()->json($this->removeSensitiveData($task), 201);
}
private function updateTask(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse
{
$this->authorize('update', $resource);
$return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) {
return $return;
}
if ($request->all() === []) {
return response()->json(['message' => 'At least one field must be provided.'], 422);
}
$allowedFields = ['name', 'command', 'frequency', 'container', 'timeout', 'enabled'];
$validator = customApiValidator($request->all(), [
'name' => 'string|max:255',
'command' => 'string',
'frequency' => 'string',
'container' => 'string|nullable',
'timeout' => 'integer|min:1',
'enabled' => 'boolean',
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
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);
}
if ($request->has('frequency') && ! validate_cron_expression($request->frequency)) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['frequency' => ['Invalid cron expression or frequency format.']],
], 422);
}
$task = $resource->scheduled_tasks()->where('uuid', $request->task_uuid)->first();
if (! $task) {
return response()->json(['message' => 'Scheduled task not found.'], 404);
}
$task->update($request->only($allowedFields));
return response()->json($this->removeSensitiveData($task), 200);
}
private function deleteTask(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse
{
$this->authorize('update', $resource);
$deleted = $resource->scheduled_tasks()->where('uuid', $request->task_uuid)->delete();
if (! $deleted) {
return response()->json(['message' => 'Scheduled task not found.'], 404);
}
return response()->json(['message' => 'Scheduled task deleted.']);
}
private function getExecutions(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse
{
$this->authorize('view', $resource);
$task = $resource->scheduled_tasks()->where('uuid', $request->task_uuid)->first();
if (! $task) {
return response()->json(['message' => 'Scheduled task not found.'], 404);
}
$executions = $task->executions()->get()->map(function ($execution) {
$execution->makeHidden(['id', 'scheduled_task_id']);
return serializeApiResponse($execution);
});
return response()->json($executions);
}
#[OA\Get(
summary: 'List Tasks',
description: 'List all scheduled tasks for an application.',
path: '/applications/{uuid}/scheduled-tasks',
operationId: 'list-scheduled-tasks-by-application-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Scheduled Tasks'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the application.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
],
responses: [
new OA\Response(
response: 200,
description: 'Get all scheduled tasks for an application.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'array',
items: new OA\Items(ref: '#/components/schemas/ScheduledTask')
)
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
]
)]
public function scheduled_tasks_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$application = $this->resolveApplication($request, $teamId);
if (! $application) {
return response()->json(['message' => 'Application not found.'], 404);
}
return $this->listTasks($application);
}
#[OA\Post(
summary: 'Create Task',
description: 'Create a new scheduled task for an application.',
path: '/applications/{uuid}/scheduled-tasks',
operationId: 'create-scheduled-task-by-application-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Scheduled Tasks'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the application.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
],
requestBody: new OA\RequestBody(
description: 'Scheduled task data',
required: true,
content: new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
required: ['name', 'command', 'frequency'],
properties: [
'name' => ['type' => 'string', 'description' => 'The name of the scheduled task.'],
'command' => ['type' => 'string', 'description' => 'The command to execute.'],
'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'],
'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'],
'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300],
'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.', 'default' => true],
],
),
)
),
responses: [
new OA\Response(
response: 201,
description: 'Scheduled task created.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(ref: '#/components/schemas/ScheduledTask')
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function create_scheduled_task_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$application = $this->resolveApplication($request, $teamId);
if (! $application) {
return response()->json(['message' => 'Application not found.'], 404);
}
return $this->createTask($request, $application);
}
#[OA\Patch(
summary: 'Update Task',
description: 'Update a scheduled task for an application.',
path: '/applications/{uuid}/scheduled-tasks/{task_uuid}',
operationId: 'update-scheduled-task-by-application-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Scheduled Tasks'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the application.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
new OA\Parameter(
name: 'task_uuid',
in: 'path',
description: 'UUID of the scheduled task.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
],
requestBody: new OA\RequestBody(
description: 'Scheduled task data',
required: true,
content: new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'name' => ['type' => 'string', 'description' => 'The name of the scheduled task.'],
'command' => ['type' => 'string', 'description' => 'The command to execute.'],
'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'],
'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'],
'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300],
'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.', 'default' => true],
],
),
)
),
responses: [
new OA\Response(
response: 200,
description: 'Scheduled task updated.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(ref: '#/components/schemas/ScheduledTask')
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function update_scheduled_task_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$application = $this->resolveApplication($request, $teamId);
if (! $application) {
return response()->json(['message' => 'Application not found.'], 404);
}
return $this->updateTask($request, $application);
}
#[OA\Delete(
summary: 'Delete Task',
description: 'Delete a scheduled task for an application.',
path: '/applications/{uuid}/scheduled-tasks/{task_uuid}',
operationId: 'delete-scheduled-task-by-application-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Scheduled Tasks'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the application.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
new OA\Parameter(
name: 'task_uuid',
in: 'path',
description: 'UUID of the scheduled task.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
],
responses: [
new OA\Response(
response: 200,
description: 'Scheduled task deleted.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'message' => ['type' => 'string', 'example' => 'Scheduled task deleted.'],
]
)
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
]
)]
public function delete_scheduled_task_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$application = $this->resolveApplication($request, $teamId);
if (! $application) {
return response()->json(['message' => 'Application not found.'], 404);
}
return $this->deleteTask($request, $application);
}
#[OA\Get(
summary: 'List Executions',
description: 'List all executions for a scheduled task on an application.',
path: '/applications/{uuid}/scheduled-tasks/{task_uuid}/executions',
operationId: 'list-scheduled-task-executions-by-application-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Scheduled Tasks'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the application.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
new OA\Parameter(
name: 'task_uuid',
in: 'path',
description: 'UUID of the scheduled task.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
],
responses: [
new OA\Response(
response: 200,
description: 'Get all executions for a scheduled task.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'array',
items: new OA\Items(ref: '#/components/schemas/ScheduledTaskExecution')
)
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
]
)]
public function executions_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$application = $this->resolveApplication($request, $teamId);
if (! $application) {
return response()->json(['message' => 'Application not found.'], 404);
}
return $this->getExecutions($request, $application);
}
#[OA\Get(
summary: 'List Tasks',
description: 'List all scheduled tasks for a service.',
path: '/services/{uuid}/scheduled-tasks',
operationId: 'list-scheduled-tasks-by-service-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Scheduled Tasks'],
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: 'Get all scheduled tasks for a service.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'array',
items: new OA\Items(ref: '#/components/schemas/ScheduledTask')
)
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
]
)]
public function scheduled_tasks_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$service = $this->resolveService($request, $teamId);
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
return $this->listTasks($service);
}
#[OA\Post(
summary: 'Create Task',
description: 'Create a new scheduled task for a service.',
path: '/services/{uuid}/scheduled-tasks',
operationId: 'create-scheduled-task-by-service-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Scheduled Tasks'],
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: 'Scheduled task data',
required: true,
content: new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
required: ['name', 'command', 'frequency'],
properties: [
'name' => ['type' => 'string', 'description' => 'The name of the scheduled task.'],
'command' => ['type' => 'string', 'description' => 'The command to execute.'],
'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'],
'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'],
'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300],
'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.', 'default' => true],
],
),
)
),
responses: [
new OA\Response(
response: 201,
description: 'Scheduled task created.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(ref: '#/components/schemas/ScheduledTask')
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function create_scheduled_task_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$service = $this->resolveService($request, $teamId);
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
return $this->createTask($request, $service);
}
#[OA\Patch(
summary: 'Update Task',
description: 'Update a scheduled task for a service.',
path: '/services/{uuid}/scheduled-tasks/{task_uuid}',
operationId: 'update-scheduled-task-by-service-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Scheduled Tasks'],
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: 'task_uuid',
in: 'path',
description: 'UUID of the scheduled task.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
],
requestBody: new OA\RequestBody(
description: 'Scheduled task data',
required: true,
content: new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'name' => ['type' => 'string', 'description' => 'The name of the scheduled task.'],
'command' => ['type' => 'string', 'description' => 'The command to execute.'],
'frequency' => ['type' => 'string', 'description' => 'The frequency of the scheduled task.'],
'container' => ['type' => 'string', 'nullable' => true, 'description' => 'The container where the command should be executed.'],
'timeout' => ['type' => 'integer', 'description' => 'The timeout of the scheduled task in seconds.', 'default' => 300],
'enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if the scheduled task is enabled.', 'default' => true],
],
),
)
),
responses: [
new OA\Response(
response: 200,
description: 'Scheduled task updated.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(ref: '#/components/schemas/ScheduledTask')
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function update_scheduled_task_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$service = $this->resolveService($request, $teamId);
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
return $this->updateTask($request, $service);
}
#[OA\Delete(
summary: 'Delete Task',
description: 'Delete a scheduled task for a service.',
path: '/services/{uuid}/scheduled-tasks/{task_uuid}',
operationId: 'delete-scheduled-task-by-service-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Scheduled Tasks'],
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: 'task_uuid',
in: 'path',
description: 'UUID of the scheduled task.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
],
responses: [
new OA\Response(
response: 200,
description: 'Scheduled task deleted.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'message' => ['type' => 'string', 'example' => 'Scheduled task deleted.'],
]
)
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
]
)]
public function delete_scheduled_task_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$service = $this->resolveService($request, $teamId);
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
return $this->deleteTask($request, $service);
}
#[OA\Get(
summary: 'List Executions',
description: 'List all executions for a scheduled task on a service.',
path: '/services/{uuid}/scheduled-tasks/{task_uuid}/executions',
operationId: 'list-scheduled-task-executions-by-service-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Scheduled Tasks'],
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: 'task_uuid',
in: 'path',
description: 'UUID of the scheduled task.',
required: true,
schema: new OA\Schema(
type: 'string',
)
),
],
responses: [
new OA\Response(
response: 200,
description: 'Get all executions for a scheduled task.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'array',
items: new OA\Items(ref: '#/components/schemas/ScheduledTaskExecution')
)
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
]
)]
public function executions_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$service = $this->resolveService($request, $teamId);
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
return $this->getExecutions($request, $service);
}
}
+12 -6
View File
@@ -290,9 +290,12 @@ class ServersController extends Controller
}
$uuid = $request->get('uuid');
if ($uuid) {
$domains = Application::getDomainsByUuid($uuid);
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $uuid)->first();
if (! $application) {
return response()->json(['message' => 'Application not found.'], 404);
}
return response()->json(serializeApiResponse($domains));
return response()->json(serializeApiResponse($application->fqdns));
}
$projects = Project::where('team_id', $teamId)->get();
$domains = collect();
@@ -519,9 +522,13 @@ class ServersController extends Controller
if (! $privateKey) {
return response()->json(['message' => 'Private key not found.'], 404);
}
$allServers = ModelsServer::whereIp($request->ip)->get();
if ($allServers->count() > 0) {
return response()->json(['message' => 'Server with this IP already exists.'], 400);
$foundServer = ModelsServer::whereIp($request->ip)->first();
if ($foundServer) {
if ($foundServer->team_id === $teamId) {
return response()->json(['message' => 'A server with this IP/Domain already exists in your team.'], 400);
}
return response()->json(['message' => 'A server with this IP/Domain is already in use by another team.'], 400);
}
$proxyType = $request->proxy_type ? str($request->proxy_type)->upper() : ProxyTypes::TRAEFIK->value;
@@ -699,7 +706,6 @@ class ServersController extends Controller
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
],
+300 -156
View File
@@ -12,6 +12,7 @@ use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use OpenApi\Attributes as OA;
use Symfony\Component\Yaml\Yaml;
@@ -37,6 +38,100 @@ class ServicesController extends Controller
return serializeApiResponse($service);
}
private function applyServiceUrls(Service $service, array $urlsArray, string $teamId, bool $forceDomainOverride = false): ?array
{
$errors = [];
$conflicts = [];
$urls = collect($urlsArray)->flatMap(function ($item) {
$urlValue = data_get($item, 'url');
if (blank($urlValue)) {
return [];
}
return str($urlValue)->replaceStart(',', '')->replaceEnd(',', '')->trim()->explode(',')->map(fn ($url) => trim($url))->filter();
});
$urls = $urls->map(function ($url) use (&$errors) {
if (! filter_var($url, FILTER_VALIDATE_URL)) {
$errors[] = "Invalid URL: {$url}";
return $url;
}
$scheme = parse_url($url, PHP_URL_SCHEME) ?? '';
if (! in_array(strtolower($scheme), ['http', 'https'])) {
$errors[] = "Invalid URL scheme: {$scheme} for URL: {$url}. Only http and https are supported.";
}
return $url;
});
$duplicates = $urls->duplicates()->unique()->values();
if ($duplicates->isNotEmpty() && ! $forceDomainOverride) {
$errors[] = 'The current request contains conflicting URLs across containers: '.implode(', ', $duplicates->toArray()).'. Use force_domain_override=true to proceed.';
}
if (count($errors) > 0) {
return ['errors' => $errors];
}
collect($urlsArray)->each(function ($item) use ($service, $teamId, $forceDomainOverride, &$errors, &$conflicts) {
$name = data_get($item, 'name');
$containerUrls = data_get($item, 'url');
if (blank($name)) {
$errors[] = 'Service container name is required to apply URLs.';
return;
}
$application = $service->applications()->where('name', $name)->first();
if (! $application) {
$errors[] = "Service container with '{$name}' not found.";
return;
}
if (filled($containerUrls)) {
$containerUrls = str($containerUrls)->replaceStart(',', '')->replaceEnd(',', '')->trim();
$containerUrls = str($containerUrls)->explode(',')->map(fn ($url) => str(trim($url))->lower());
$result = checkIfDomainIsAlreadyUsedViaAPI($containerUrls, $teamId, $application->uuid);
if (isset($result['error'])) {
$errors[] = $result['error'];
return;
}
if ($result['hasConflicts'] && ! $forceDomainOverride) {
$conflicts = array_merge($conflicts, $result['conflicts']);
return;
}
$containerUrls = $containerUrls->filter(fn ($u) => filled($u))->unique()->implode(',');
} else {
$containerUrls = null;
}
$application->fqdn = $containerUrls;
$application->save();
});
if (! empty($errors)) {
return ['errors' => $errors];
}
if (! empty($conflicts)) {
return [
'conflicts' => $conflicts,
'warning' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.',
];
}
return null;
}
#[OA\Get(
summary: 'List',
description: 'List all services.',
@@ -105,98 +200,7 @@ class ServicesController extends Controller
type: 'object',
required: ['server_uuid', 'project_uuid', 'environment_name', 'environment_uuid'],
properties: [
'type' => [
'description' => 'The one-click service type',
'type' => 'string',
'enum' => [
'activepieces',
'appsmith',
'appwrite',
'authentik',
'babybuddy',
'budge',
'changedetection',
'chatwoot',
'classicpress-with-mariadb',
'classicpress-with-mysql',
'classicpress-without-database',
'cloudflared',
'code-server',
'dashboard',
'directus',
'directus-with-postgresql',
'docker-registry',
'docuseal',
'docuseal-with-postgres',
'dokuwiki',
'duplicati',
'emby',
'embystat',
'fider',
'filebrowser',
'firefly',
'formbricks',
'ghost',
'gitea',
'gitea-with-mariadb',
'gitea-with-mysql',
'gitea-with-postgresql',
'glance',
'glances',
'glitchtip',
'grafana',
'grafana-with-postgresql',
'grocy',
'heimdall',
'homepage',
'jellyfin',
'kuzzle',
'listmonk',
'logto',
'mediawiki',
'meilisearch',
'metabase',
'metube',
'minio',
'moodle',
'n8n',
'n8n-with-postgresql',
'next-image-transformation',
'nextcloud',
'nocodb',
'odoo',
'openblocks',
'pairdrop',
'penpot',
'phpmyadmin',
'pocketbase',
'posthog',
'reactive-resume',
'rocketchat',
'shlink',
'slash',
'snapdrop',
'statusnook',
'stirling-pdf',
'supabase',
'syncthing',
'tolgee',
'trigger',
'trigger-with-external-database',
'twenty',
'umami',
'unleash-with-postgresql',
'unleash-without-database',
'uptime-kuma',
'vaultwarden',
'vikunja',
'weblate',
'whoogle',
'wordpress-with-mariadb',
'wordpress-with-mysql',
'wordpress-without-database',
],
],
'type' => ['description' => 'The one-click service type (e.g. "actualbudget", "calibre-web", "gitea-with-mysql" ...)', 'type' => 'string'],
'name' => ['type' => 'string', 'maxLength' => 255, 'description' => 'Name of the service.'],
'description' => ['type' => 'string', 'nullable' => true, 'description' => 'Description of the service.'],
'project_uuid' => ['type' => 'string', 'description' => 'Project UUID.'],
@@ -205,7 +209,19 @@ class ServicesController extends Controller
'server_uuid' => ['type' => 'string', 'description' => 'Server UUID.'],
'destination_uuid' => ['type' => 'string', 'description' => 'Destination UUID. Required if server has multiple destinations.'],
'instant_deploy' => ['type' => 'boolean', 'default' => false, 'description' => 'Start the service immediately after creation.'],
'docker_compose_raw' => ['type' => 'string', 'description' => 'The Docker Compose raw content.'],
'docker_compose_raw' => ['type' => 'string', 'description' => 'The base64 encoded Docker Compose content.'],
'urls' => [
'type' => 'array',
'description' => 'Array of URLs to be applied to containers of a service.',
'items' => new OA\Schema(
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").'],
],
),
],
'force_domain_override' => ['type' => 'boolean', 'default' => false, 'description' => 'Force domain override even if conflicts are detected.'],
],
),
),
@@ -235,6 +251,35 @@ class ServicesController extends Controller
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 409,
description: 'Domain conflicts detected.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'message' => ['type' => 'string', 'example' => 'Domain conflicts detected. Use force_domain_override=true to proceed.'],
'warning' => ['type' => 'string', 'example' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.'],
'conflicts' => [
'type' => 'array',
'items' => new OA\Schema(
type: 'object',
properties: [
'domain' => ['type' => 'string', 'example' => 'example.com'],
'resource_name' => ['type' => 'string', 'example' => 'My Application'],
'resource_uuid' => ['type' => 'string', 'nullable' => true, 'example' => 'abc123-def456'],
'resource_type' => ['type' => 'string', 'enum' => ['application', 'service', 'instance'], 'example' => 'application'],
'message' => ['type' => 'string', 'example' => 'Domain example.com is already in use by application \'My Application\''],
]
),
],
]
)
),
]
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
@@ -243,7 +288,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'];
$allowedFields = ['type', 'name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'urls', 'force_domain_override'];
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -256,7 +301,7 @@ class ServicesController extends Controller
if ($return instanceof \Illuminate\Http\JsonResponse) {
return $return;
}
$validator = customApiValidator($request->all(), [
$validationRules = [
'type' => 'string|required_without:docker_compose_raw',
'docker_compose_raw' => 'string|required_without:type',
'project_uuid' => 'string|required',
@@ -267,7 +312,16 @@ class ServicesController extends Controller
'name' => 'string|max:255',
'description' => 'string|nullable',
'instant_deploy' => 'boolean',
]);
'urls' => 'array|nullable',
'urls.*' => 'array:name,url',
'urls.*.name' => 'string|required',
'urls.*.url' => 'string|nullable',
'force_domain_override' => 'boolean',
];
$validationMessages = [
'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.',
];
$validator = Validator::make($request->all(), $validationRules, $validationMessages);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
if ($validator->fails() || ! empty($extraFields)) {
@@ -283,6 +337,13 @@ class ServicesController extends Controller
'errors' => $errors,
], 422);
}
if (filled($request->type) && filled($request->docker_compose_raw)) {
return response()->json([
'message' => 'You cannot provide both service type and docker_compose_raw. Use one or the other.',
], 422);
}
$environmentUuid = $request->environment_uuid;
$environmentName = $request->environment_name;
if (blank($environmentUuid) && blank($environmentName)) {
@@ -355,7 +416,8 @@ class ServicesController extends Controller
data_set($servicePayload, 'connect_to_docker_network', true);
}
$service = Service::create($servicePayload);
$service->name = "$oneClickServiceName-".$service->uuid;
$service->name = $request->name ?? "$oneClickServiceName-".$service->uuid;
$service->description = $request->description;
$service->save();
if ($oneClickDotEnvs?->count() > 0) {
$oneClickDotEnvs->each(function ($value) use ($service) {
@@ -380,29 +442,41 @@ class ServicesController extends Controller
// Apply service-specific application prerequisites
applyServiceApplicationPrerequisites($service);
if ($request->has('urls') && is_array($request->urls)) {
$urlResult = $this->applyServiceUrls($service, $request->urls, $teamId, $request->boolean('force_domain_override'));
if ($urlResult !== null) {
$service->delete();
if (isset($urlResult['errors'])) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $urlResult['errors'],
], 422);
}
if (isset($urlResult['conflicts'])) {
return response()->json([
'message' => 'Domain conflicts detected. Use force_domain_override=true to proceed.',
'conflicts' => $urlResult['conflicts'],
'warning' => $urlResult['warning'],
], 409);
}
}
}
if ($instantDeploy) {
StartService::dispatch($service);
}
$domains = $service->applications()->get()->pluck('fqdn')->sort();
$domains = $domains->map(function ($domain) {
if (count(explode(':', $domain)) > 2) {
return str($domain)->beforeLast(':')->value();
}
return $domain;
});
return response()->json([
'uuid' => $service->uuid,
'domains' => $domains,
]);
'domains' => $service->applications()->pluck('fqdn')->filter()->sort()->values(),
])->setStatusCode(201);
}
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'];
$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'];
$validator = customApiValidator($request->all(), [
$validationRules = [
'project_uuid' => 'string|required',
'environment_name' => 'string|nullable',
'environment_uuid' => 'string|nullable',
@@ -413,7 +487,16 @@ class ServicesController extends Controller
'instant_deploy' => 'boolean',
'connect_to_docker_network' => 'boolean',
'docker_compose_raw' => 'string|required',
]);
'urls' => 'array|nullable',
'urls.*' => 'array:name,url',
'urls.*.name' => 'string|required',
'urls.*.url' => 'string|nullable',
'force_domain_override' => 'boolean',
];
$validationMessages = [
'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.',
];
$validator = Validator::make($request->all(), $validationRules, $validationMessages);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
if ($validator->fails() || ! empty($extraFields)) {
@@ -469,7 +552,7 @@ class ServicesController extends Controller
], 422);
}
$dockerComposeRaw = base64_decode($request->docker_compose_raw);
if (mb_detect_encoding($dockerComposeRaw, 'ASCII', true) === false) {
if (mb_detect_encoding($dockerComposeRaw, 'UTF-8', true) === false) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
@@ -507,25 +590,40 @@ class ServicesController extends Controller
$service->save();
$service->parse(isNew: true);
if ($request->has('urls') && is_array($request->urls)) {
$urlResult = $this->applyServiceUrls($service, $request->urls, $teamId, $request->boolean('force_domain_override'));
if ($urlResult !== null) {
$service->delete();
if (isset($urlResult['errors'])) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $urlResult['errors'],
], 422);
}
if (isset($urlResult['conflicts'])) {
return response()->json([
'message' => 'Domain conflicts detected. Use force_domain_override=true to proceed.',
'conflicts' => $urlResult['conflicts'],
'warning' => $urlResult['warning'],
], 409);
}
}
}
if ($instantDeploy) {
StartService::dispatch($service);
}
$domains = $service->applications()->get()->pluck('fqdn')->sort();
$domains = $domains->map(function ($domain) {
if (count(explode(':', $domain)) > 2) {
return str($domain)->beforeLast(':')->value();
}
return $domain;
})->values();
return response()->json([
'uuid' => $service->uuid,
'domains' => $domains,
'domains' => $service->applications()->pluck('fqdn')->filter()->sort()->values(),
])->setStatusCode(201);
} else {
return response()->json(['message' => 'No service type or docker_compose_raw provided.'], 400);
} elseif (filled($request->type)) {
return response()->json([
'message' => 'Invalid service type.',
'valid_service_types' => $serviceKeys,
], 404);
}
}
@@ -681,7 +779,6 @@ class ServicesController extends Controller
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
],
@@ -703,7 +800,19 @@ class ServicesController extends Controller
'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'],
'instant_deploy' => ['type' => 'boolean', 'description' => 'The flag to indicate if the service should be deployed instantly.'],
'connect_to_docker_network' => ['type' => 'boolean', 'default' => false, 'description' => 'Connect the service to the predefined docker network.'],
'docker_compose_raw' => ['type' => 'string', 'description' => 'The Docker Compose raw content.'],
'docker_compose_raw' => ['type' => 'string', 'description' => 'The base64 encoded Docker Compose content.'],
'urls' => [
'type' => 'array',
'description' => 'Array of URLs to be applied to containers of a service.',
'items' => new OA\Schema(
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").'],
],
),
],
'force_domain_override' => ['type' => 'boolean', 'default' => false, 'description' => 'Force domain override even if conflicts are detected.'],
],
)
),
@@ -738,6 +847,35 @@ class ServicesController extends Controller
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 409,
description: 'Domain conflicts detected.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'message' => ['type' => 'string', 'example' => 'Domain conflicts detected. Use force_domain_override=true to proceed.'],
'warning' => ['type' => 'string', 'example' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.'],
'conflicts' => [
'type' => 'array',
'items' => new OA\Schema(
type: 'object',
properties: [
'domain' => ['type' => 'string', 'example' => 'example.com'],
'resource_name' => ['type' => 'string', 'example' => 'My Application'],
'resource_uuid' => ['type' => 'string', 'nullable' => true, 'example' => 'abc123-def456'],
'resource_type' => ['type' => 'string', 'enum' => ['application', 'service', 'instance'], 'example' => 'application'],
'message' => ['type' => 'string', 'example' => 'Domain example.com is already in use by application \'My Application\''],
]
),
],
]
)
),
]
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
@@ -763,15 +901,24 @@ class ServicesController extends Controller
$this->authorize('update', $service);
$allowedFields = ['name', 'description', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network'];
$allowedFields = ['name', 'description', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override'];
$validator = customApiValidator($request->all(), [
$validationRules = [
'name' => 'string|max:255',
'description' => 'string|nullable',
'instant_deploy' => 'boolean',
'connect_to_docker_network' => 'boolean',
'docker_compose_raw' => 'string|nullable',
]);
'urls' => 'array|nullable',
'urls.*' => 'array:name,url',
'urls.*.name' => 'string|required',
'urls.*.url' => 'string|nullable',
'force_domain_override' => 'boolean',
];
$validationMessages = [
'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.',
];
$validator = Validator::make($request->all(), $validationRules, $validationMessages);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
if ($validator->fails() || ! empty($extraFields)) {
@@ -797,7 +944,7 @@ class ServicesController extends Controller
], 422);
}
$dockerComposeRaw = base64_decode($request->docker_compose_raw);
if (mb_detect_encoding($dockerComposeRaw, 'ASCII', true) === false) {
if (mb_detect_encoding($dockerComposeRaw, 'UTF-8', true) === false) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
@@ -835,22 +982,33 @@ class ServicesController extends Controller
$service->save();
$service->parse();
if ($request->has('urls') && is_array($request->urls)) {
$urlResult = $this->applyServiceUrls($service, $request->urls, $teamId, $request->boolean('force_domain_override'));
if ($urlResult !== null) {
if (isset($urlResult['errors'])) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $urlResult['errors'],
], 422);
}
if (isset($urlResult['conflicts'])) {
return response()->json([
'message' => 'Domain conflicts detected. Use force_domain_override=true to proceed.',
'conflicts' => $urlResult['conflicts'],
'warning' => $urlResult['warning'],
], 409);
}
}
}
if ($request->instant_deploy) {
StartService::dispatch($service);
}
$domains = $service->applications()->get()->pluck('fqdn')->sort();
$domains = $domains->map(function ($domain) {
if (count(explode(':', $domain)) > 2) {
return str($domain)->beforeLast(':')->value();
}
return $domain;
})->values();
return response()->json([
'uuid' => $service->uuid,
'domains' => $domains,
'domains' => $service->applications()->pluck('fqdn')->filter()->sort()->values(),
])->setStatusCode(200);
}
@@ -871,7 +1029,6 @@ class ServicesController extends Controller
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
],
@@ -952,7 +1109,6 @@ class ServicesController extends Controller
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
],
@@ -985,10 +1141,7 @@ class ServicesController extends Controller
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'message' => ['type' => 'string', 'example' => 'Environment variable updated.'],
]
ref: '#/components/schemas/EnvironmentVariable'
)
),
]
@@ -1082,7 +1235,6 @@ class ServicesController extends Controller
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
],
@@ -1123,10 +1275,8 @@ class ServicesController extends Controller
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'message' => ['type' => 'string', 'example' => 'Environment variables updated.'],
]
type: 'array',
items: new OA\Items(ref: '#/components/schemas/EnvironmentVariable')
)
),
]
@@ -1213,7 +1363,6 @@ class ServicesController extends Controller
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
],
@@ -1336,7 +1485,6 @@ class ServicesController extends Controller
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
new OA\Parameter(
@@ -1346,7 +1494,6 @@ class ServicesController extends Controller
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
],
@@ -1425,7 +1572,6 @@ class ServicesController extends Controller
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
],
@@ -1506,7 +1652,6 @@ class ServicesController extends Controller
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
],
@@ -1587,7 +1732,6 @@ class ServicesController extends Controller
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
new OA\Parameter(
+8 -2
View File
@@ -218,7 +218,10 @@ class TeamController extends Controller
if (is_null($teamId)) {
return invalidTokenResponse();
}
$team = auth()->user()->currentTeam();
$team = auth()->user()->teams->where('id', $teamId)->first();
if (is_null($team)) {
return response()->json(['message' => 'Team not found.'], 404);
}
return response()->json(
$this->removeSensitiveData($team),
@@ -263,7 +266,10 @@ class TeamController extends Controller
if (is_null($teamId)) {
return invalidTokenResponse();
}
$team = auth()->user()->currentTeam();
$team = auth()->user()->teams->where('id', $teamId)->first();
if (is_null($team)) {
return response()->json(['message' => 'Team not found.'], 404);
}
$team->members->makeHidden([
'pivot',
'email_change_code',
+8 -4
View File
@@ -13,7 +13,8 @@ class UploadController extends BaseController
{
public function upload(Request $request)
{
$resource = getResourceByUuid(request()->route('databaseUuid'), data_get(auth()->user()->currentTeam(), 'id'));
$databaseIdentifier = request()->route('databaseUuid');
$resource = getResourceByUuid($databaseIdentifier, data_get(auth()->user()->currentTeam(), 'id'));
if (is_null($resource)) {
return response()->json(['error' => 'You do not have permission for this database'], 500);
}
@@ -26,7 +27,10 @@ class UploadController extends BaseController
$save = $receiver->receive();
if ($save->isFinished()) {
return $this->saveFile($save->getFile(), $resource);
// Use the original identifier from the route to maintain path consistency
// For ServiceDatabase: {name}-{service_uuid}
// For standalone databases: {uuid}
return $this->saveFile($save->getFile(), $databaseIdentifier);
}
$handler = $save->handler();
@@ -57,10 +61,10 @@ class UploadController extends BaseController
// 'mime_type' => $mime
// ]);
// }
protected function saveFile(UploadedFile $file, $resource)
protected function saveFile(UploadedFile $file, string $resourceIdentifier)
{
$mime = str_replace('/', '-', $file->getMimeType());
$filePath = "upload/{$resource->uuid}";
$filePath = "upload/{$resourceIdentifier}";
$finalPath = storage_path('app/'.$filePath);
$file->move($finalPath, 'restore');
+1 -1
View File
@@ -90,7 +90,7 @@ class Gitea extends Controller
if ($x_gitea_event === 'push') {
if ($application->isDeployable()) {
$is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files);
if ($is_watch_path_triggered || is_null($application->watch_paths)) {
if ($is_watch_path_triggered || blank($application->watch_paths)) {
$deployment_uuid = new Cuid2;
$result = queue_application_deployment(
application: $application,
+61 -170
View File
@@ -2,13 +2,10 @@
namespace App\Http\Controllers\Webhook;
use App\Actions\Application\CleanupPreviewDeployment;
use App\Enums\ProcessStatus;
use App\Http\Controllers\Controller;
use App\Jobs\ApplicationPullRequestUpdateJob;
use App\Jobs\GithubAppPermissionJob;
use App\Jobs\ProcessGithubPullRequestWebhook;
use App\Models\Application;
use App\Models\ApplicationPreview;
use App\Models\GithubApp;
use App\Models\PrivateKey;
use Exception;
@@ -54,6 +51,8 @@ class Github extends Controller
$pull_request_html_url = data_get($payload, 'pull_request.html_url');
$branch = data_get($payload, 'pull_request.head.ref');
$base_branch = data_get($payload, 'pull_request.base.ref');
$before_sha = data_get($payload, 'before');
$after_sha = data_get($payload, 'after', data_get($payload, 'pull_request.head.sha'));
$author_association = data_get($payload, 'pull_request.author_association');
}
if (! $branch) {
@@ -69,7 +68,7 @@ class Github extends Controller
if ($x_github_event === 'pull_request') {
$applications = $applications->where('git_branch', $base_branch)->get();
if ($applications->isEmpty()) {
return response("Nothing to do. No applications found with branch '$base_branch'.");
return response("Nothing to do. No applications found for repo $full_name and branch '$base_branch'.");
}
}
$applicationsByServer = $applications->groupBy(function ($app) {
@@ -102,7 +101,7 @@ class Github extends Controller
if ($x_github_event === 'push') {
if ($application->isDeployable()) {
$is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files);
if ($is_watch_path_triggered || is_null($application->watch_paths)) {
if ($is_watch_path_triggered || blank($application->watch_paths)) {
$deployment_uuid = new Cuid2;
$result = queue_application_deployment(
application: $application,
@@ -152,96 +151,35 @@ class Github extends Controller
}
}
if ($x_github_event === 'pull_request') {
if ($action === 'opened' || $action === 'synchronize' || $action === 'reopened') {
if ($application->isPRDeployable()) {
// Check if PR deployments from public contributors are restricted
if (! $application->settings->is_pr_deployments_public_enabled) {
$trustedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR', 'CONTRIBUTOR'];
if (! in_array($author_association, $trustedAssociations)) {
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
'message' => 'PR deployments are restricted to repository members and contributors. Author association: '.$author_association,
]);
// Check if PR deployments are enabled (but allow 'closed' action to cleanup)
if (! $application->isPRDeployable() && $action !== 'closed') {
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
'message' => 'Preview deployments disabled.',
]);
continue;
}
}
$deployment_uuid = new Cuid2;
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
if (! $found) {
if ($application->build_pack === 'dockercompose') {
$pr_app = ApplicationPreview::create([
'git_type' => 'github',
'application_id' => $application->id,
'pull_request_id' => $pull_request_id,
'pull_request_html_url' => $pull_request_html_url,
'docker_compose_domains' => $application->docker_compose_domains,
]);
$pr_app->generate_preview_fqdn_compose();
} else {
$pr_app = ApplicationPreview::create([
'git_type' => 'github',
'application_id' => $application->id,
'pull_request_id' => $pull_request_id,
'pull_request_html_url' => $pull_request_html_url,
]);
$pr_app->generate_preview_fqdn();
}
}
$result = queue_application_deployment(
application: $application,
pull_request_id: $pull_request_id,
deployment_uuid: $deployment_uuid,
force_rebuild: false,
commit: data_get($payload, 'head.sha', 'HEAD'),
is_webhook: true,
git_type: 'github'
);
if ($result['status'] === 'queue_full') {
return response($result['message'], 429)->header('Retry-After', 60);
} elseif ($result['status'] === 'skipped') {
$return_payloads->push([
'application' => $application->name,
'status' => 'skipped',
'message' => $result['message'],
]);
} else {
$return_payloads->push([
'application' => $application->name,
'status' => 'success',
'message' => 'Preview deployment queued.',
]);
}
} else {
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
'message' => 'Preview deployments disabled.',
]);
}
continue;
}
if ($action === 'closed') {
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
if ($found) {
// Use comprehensive cleanup that cancels active deployments,
// kills helper containers, and removes all PR containers
CleanupPreviewDeployment::run($application, $pull_request_id, $found);
$return_payloads->push([
'application' => $application->name,
'status' => 'success',
'message' => 'Preview deployment closed.',
]);
} else {
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
'message' => 'No preview deployment found.',
]);
}
}
ProcessGithubPullRequestWebhook::dispatch(
applicationId: $application->id,
githubAppId: null,
action: $action,
pullRequestId: $pull_request_id,
pullRequestHtmlUrl: $pull_request_html_url,
beforeSha: $before_sha,
afterSha: $after_sha,
commitSha: data_get($payload, 'pull_request.head.sha', 'HEAD'),
authorAssociation: $author_association,
fullName: $full_name,
);
$return_payloads->push([
'application' => $application->name,
'status' => 'queued',
'message' => 'PR webhook received, processing queued.',
]);
}
}
}
@@ -304,6 +242,8 @@ class Github extends Controller
$pull_request_html_url = data_get($payload, 'pull_request.html_url');
$branch = data_get($payload, 'pull_request.head.ref');
$base_branch = data_get($payload, 'pull_request.base.ref');
$before_sha = data_get($payload, 'before');
$after_sha = data_get($payload, 'after', data_get($payload, 'pull_request.head.sha'));
$author_association = data_get($payload, 'pull_request.author_association');
}
if (! $id || ! $branch) {
@@ -344,7 +284,7 @@ class Github extends Controller
if ($x_github_event === 'push') {
if ($application->isDeployable()) {
$is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files);
if ($is_watch_path_triggered || is_null($application->watch_paths)) {
if ($is_watch_path_triggered || blank($application->watch_paths)) {
$deployment_uuid = new Cuid2;
$result = queue_application_deployment(
application: $application,
@@ -386,86 +326,37 @@ class Github extends Controller
}
}
if ($x_github_event === 'pull_request') {
if ($action === 'opened' || $action === 'synchronize' || $action === 'reopened') {
if ($application->isPRDeployable()) {
// Check if PR deployments from public contributors are restricted
if (! $application->settings->is_pr_deployments_public_enabled) {
$trustedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR', 'CONTRIBUTOR'];
if (! in_array($author_association, $trustedAssociations)) {
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
'message' => 'PR deployments are restricted to repository members and contributors. Author association: '.$author_association,
]);
// Check if PR deployments are enabled (but allow 'closed' action to cleanup)
if (! $application->isPRDeployable() && $action !== 'closed') {
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
'message' => 'Preview deployments disabled.',
]);
continue;
}
}
$deployment_uuid = new Cuid2;
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
if (! $found) {
ApplicationPreview::create([
'git_type' => 'github',
'application_id' => $application->id,
'pull_request_id' => $pull_request_id,
'pull_request_html_url' => $pull_request_html_url,
]);
}
$result = queue_application_deployment(
application: $application,
pull_request_id: $pull_request_id,
deployment_uuid: $deployment_uuid,
force_rebuild: false,
commit: data_get($payload, 'head.sha', 'HEAD'),
is_webhook: true,
git_type: 'github'
);
if ($result['status'] === 'queue_full') {
return response($result['message'], 429)->header('Retry-After', 60);
} elseif ($result['status'] === 'skipped') {
$return_payloads->push([
'application' => $application->name,
'status' => 'skipped',
'message' => $result['message'],
]);
} else {
$return_payloads->push([
'application' => $application->name,
'status' => 'success',
'message' => 'Preview deployment queued.',
]);
}
} else {
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
'message' => 'Preview deployments disabled.',
]);
}
continue;
}
if ($action === 'closed' || $action === 'close') {
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
if ($found) {
// Delete the PR comment on GitHub (GitHub-specific feature)
ApplicationPullRequestUpdateJob::dispatchSync(application: $application, preview: $found, status: ProcessStatus::CLOSED);
// Use comprehensive cleanup that cancels active deployments,
// kills helper containers, and removes all PR containers
CleanupPreviewDeployment::run($application, $pull_request_id, $found);
$full_name = data_get($payload, 'repository.full_name');
$return_payloads->push([
'application' => $application->name,
'status' => 'success',
'message' => 'Preview deployment closed.',
]);
} else {
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
'message' => 'No preview deployment found.',
]);
}
}
ProcessGithubPullRequestWebhook::dispatch(
applicationId: $application->id,
githubAppId: $github_app->id,
action: $action,
pullRequestId: $pull_request_id,
pullRequestHtmlUrl: $pull_request_html_url,
beforeSha: $before_sha,
afterSha: $after_sha,
commitSha: data_get($payload, 'pull_request.head.sha', 'HEAD'),
authorAssociation: $author_association,
fullName: $full_name,
);
$return_payloads->push([
'application' => $application->name,
'status' => 'queued',
'message' => 'PR webhook received, processing queued.',
]);
}
}
}
+2 -2
View File
@@ -100,7 +100,7 @@ class Gitlab extends Controller
}
foreach ($applications as $application) {
$webhook_secret = data_get($application, 'manual_webhook_secret_gitlab');
if ($webhook_secret !== $x_gitlab_token) {
if (! hash_equals($webhook_secret ?? '', $x_gitlab_token ?? '')) {
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
@@ -122,7 +122,7 @@ class Gitlab extends Controller
if ($x_gitlab_event === 'push') {
if ($application->isDeployable()) {
$is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files);
if ($is_watch_path_triggered || is_null($application->watch_paths)) {
if ($is_watch_path_triggered || blank($application->watch_paths)) {
$deployment_uuid = new Cuid2;
$result = queue_application_deployment(
application: $application,