mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-14 00:16:42 +00:00
Merge remote-tracking branch 'origin/next' into 9249-pr-investigation
This commit is contained in:
commit
95a3c453d8
1200 files changed
+75614
-19321
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\CloudProviderToken;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@@ -15,9 +16,14 @@ class CloudProviderTokensController extends Controller
|
||||
{
|
||||
$token->makeHidden([
|
||||
'id',
|
||||
'token',
|
||||
]);
|
||||
|
||||
if (request()->attributes->get('can_read_sensitive', false) === true) {
|
||||
$token->makeVisible([
|
||||
'token',
|
||||
]);
|
||||
}
|
||||
|
||||
return serializeApiResponse($token);
|
||||
}
|
||||
|
||||
@@ -176,6 +182,7 @@ class CloudProviderTokensController extends Controller
|
||||
if (is_null($token)) {
|
||||
return response()->json(['message' => 'Cloud provider token not found.'], 404);
|
||||
}
|
||||
$this->authorize('view', $token);
|
||||
|
||||
return response()->json($this->removeSensitiveData($token));
|
||||
}
|
||||
@@ -242,9 +249,10 @@ class CloudProviderTokensController extends Controller
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
$this->authorize('create', [CloudProviderToken::class]);
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
@@ -286,6 +294,13 @@ class CloudProviderTokensController extends Controller
|
||||
'name' => $body['name'],
|
||||
]);
|
||||
|
||||
auditLog('api.cloud_token.created', [
|
||||
'team_id' => $teamId,
|
||||
'cloud_token_uuid' => $cloudProviderToken->uuid,
|
||||
'cloud_token_name' => $cloudProviderToken->name,
|
||||
'provider' => $cloudProviderToken->provider,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'uuid' => $cloudProviderToken->uuid,
|
||||
])->setStatusCode(201);
|
||||
@@ -355,7 +370,7 @@ class CloudProviderTokensController extends Controller
|
||||
}
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
@@ -386,9 +401,18 @@ class CloudProviderTokensController extends Controller
|
||||
if (! $token) {
|
||||
return response()->json(['message' => 'Cloud provider token not found.'], 404);
|
||||
}
|
||||
$this->authorize('update', $token);
|
||||
|
||||
$token->update(array_intersect_key($body, array_flip($allowedFields)));
|
||||
|
||||
auditLog('api.cloud_token.updated', [
|
||||
'team_id' => $teamId,
|
||||
'cloud_token_uuid' => $token->uuid,
|
||||
'cloud_token_name' => $token->name,
|
||||
'provider' => $token->provider,
|
||||
'changed_fields' => array_values(array_intersect($allowedFields, array_keys($body))),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'uuid' => $token->uuid,
|
||||
]);
|
||||
@@ -459,13 +483,24 @@ class CloudProviderTokensController extends Controller
|
||||
if (! $token) {
|
||||
return response()->json(['message' => 'Cloud provider token not found.'], 404);
|
||||
}
|
||||
$this->authorize('delete', $token);
|
||||
|
||||
if ($token->hasServers()) {
|
||||
return response()->json(['message' => 'Cannot delete token that is used by servers.'], 400);
|
||||
}
|
||||
|
||||
$tokenUuid = $token->uuid;
|
||||
$tokenName = $token->name;
|
||||
$tokenProvider = $token->provider;
|
||||
$token->delete();
|
||||
|
||||
auditLog('api.cloud_token.deleted', [
|
||||
'team_id' => $teamId,
|
||||
'cloud_token_uuid' => $tokenUuid,
|
||||
'cloud_token_name' => $tokenName,
|
||||
'provider' => $tokenProvider,
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Cloud provider token deleted.']);
|
||||
}
|
||||
|
||||
@@ -519,9 +554,18 @@ class CloudProviderTokensController extends Controller
|
||||
if (! $cloudToken) {
|
||||
return response()->json(['message' => 'Cloud provider token not found.'], 404);
|
||||
}
|
||||
$this->authorize('view', $cloudToken);
|
||||
|
||||
$validation = $this->validateProviderToken($cloudToken->provider, $cloudToken->token);
|
||||
|
||||
auditLog('api.cloud_token.validated', [
|
||||
'team_id' => $teamId,
|
||||
'cloud_token_uuid' => $cloudToken->uuid,
|
||||
'cloud_token_name' => $cloudToken->name,
|
||||
'provider' => $cloudToken->provider,
|
||||
'valid' => $validation['valid'],
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'valid' => $validation['valid'],
|
||||
'message' => $validation['valid'] ? 'Token is valid.' : $validation['error'],
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Concerns;
|
||||
|
||||
use App\Http\Controllers\Api\TagsController;
|
||||
use App\Models\Tag;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
trait HandlesTagsApi
|
||||
{
|
||||
/**
|
||||
* Find the taggable resource by UUID within the team.
|
||||
*/
|
||||
abstract protected function findTaggableResource(string $uuid, int|string $teamId): mixed;
|
||||
|
||||
/**
|
||||
* Get the 404 message for the taggable resource.
|
||||
*/
|
||||
abstract protected function tagResourceNotFoundMessage(): string;
|
||||
|
||||
public function listTags(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$resource = $this->findTaggableResource($request->route('uuid'), $teamId);
|
||||
if (! $resource) {
|
||||
return response()->json(['message' => $this->tagResourceNotFoundMessage()], 404);
|
||||
}
|
||||
|
||||
$this->authorize('view', $resource);
|
||||
|
||||
return response()->json($resource->tags->map(TagsController::serializeTag(...)));
|
||||
}
|
||||
|
||||
public function createTag(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
$resource = $this->findTaggableResource($request->route('uuid'), $teamId);
|
||||
if (! $resource) {
|
||||
return response()->json(['message' => $this->tagResourceNotFoundMessage()], 404);
|
||||
}
|
||||
|
||||
$this->authorize('update', $resource);
|
||||
|
||||
if ($request->has('tag_name') && $request->has('tag_names')) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['tag_name' => ['Provide either tag_name or tag_names, not both.']],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$validator = Validator::make($request->all(), [
|
||||
'tag_name' => 'required_without:tag_names|string',
|
||||
'tag_names' => 'required_without:tag_name|array|min:1',
|
||||
'tag_names.*' => 'string',
|
||||
]);
|
||||
|
||||
$extraFields = array_diff(array_keys($request->all()), ['tag_name', 'tag_names']);
|
||||
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);
|
||||
}
|
||||
|
||||
$tagNames = $this->normalizeTagNames($request->has('tag_names') ? $request->tag_names : [$request->tag_name]);
|
||||
$invalidTags = array_filter($tagNames, fn (string $tagName): bool => mb_strlen($tagName) < 2);
|
||||
if (! empty($invalidTags)) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['tag_name' => ['Each tag name must be at least 2 characters after sanitization.']],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$this->attachTagsToResource($resource, $tagNames, $teamId);
|
||||
|
||||
return response()->json($resource->refresh()->tags->map(TagsController::serializeTag(...)))->setStatusCode(201);
|
||||
}
|
||||
|
||||
public function deleteTag(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$resource = $this->findTaggableResource($request->route('uuid'), $teamId);
|
||||
if (! $resource) {
|
||||
return response()->json(['message' => $this->tagResourceNotFoundMessage()], 404);
|
||||
}
|
||||
|
||||
$this->authorize('update', $resource);
|
||||
|
||||
$tag = Tag::where('team_id', $teamId)->where('uuid', $request->route('tag_uuid'))->first();
|
||||
if (! $tag) {
|
||||
return response()->json(['message' => 'Tag not found.'], 404);
|
||||
}
|
||||
|
||||
if (! $resource->tags()->whereKey($tag->id)->exists()) {
|
||||
return response()->json(['message' => 'Tag not found on resource.'], 404);
|
||||
}
|
||||
|
||||
$resource->tags()->detach($tag->id);
|
||||
$tag->deleteIfOrphaned();
|
||||
|
||||
return response()->json(['message' => 'Tag removed.']);
|
||||
}
|
||||
|
||||
protected function attachTagsToResource($resource, array $tagNames, int|string $teamId): void
|
||||
{
|
||||
foreach ($this->normalizeTagNames($tagNames) as $tagName) {
|
||||
if (mb_strlen($tagName) < 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$tag = Tag::query()->createOrFirst([
|
||||
'team_id' => $teamId,
|
||||
'name' => $tagName,
|
||||
]);
|
||||
|
||||
$resource->tags()->syncWithoutDetaching([$tag->id]);
|
||||
}
|
||||
}
|
||||
|
||||
protected function validateTagsParameter(Request $request): ?JsonResponse
|
||||
{
|
||||
if (! $request->has('tags')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tagNames = $this->normalizeTagNames($request->input('tags', []));
|
||||
$invalidTags = array_filter($tagNames, fn (string $tagName): bool => mb_strlen($tagName) < 2);
|
||||
if (! empty($invalidTags)) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['tags' => ['Each tag name must be at least 2 characters after sanitization.']],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$request->merge(['tags' => $tagNames]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function normalizeTagNames(array $tagNames): array
|
||||
{
|
||||
return collect($tagNames)
|
||||
->map(fn ($tagName): string => strtolower(trim(strip_tags((string) $tagName))))
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -15,7 +15,6 @@ use App\Models\Tag;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Http\Request;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
class DeployController extends Controller
|
||||
{
|
||||
@@ -25,6 +24,10 @@ class DeployController extends Controller
|
||||
$deployment->makeHidden([
|
||||
'logs',
|
||||
]);
|
||||
} else {
|
||||
$deployment->makeVisible([
|
||||
'logs',
|
||||
]);
|
||||
}
|
||||
|
||||
return serializeApiResponse($deployment);
|
||||
@@ -281,6 +284,14 @@ class DeployController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
auditLog('api.deployment.cancelled', [
|
||||
'team_id' => $teamId,
|
||||
'deployment_uuid' => $deployment->deployment_uuid,
|
||||
'application_id' => $application?->id,
|
||||
'application_uuid' => $application?->uuid,
|
||||
'server_id' => $deployment->server_id,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Deployment cancelled successfully.',
|
||||
'deployment_uuid' => $deployment->deployment_uuid,
|
||||
@@ -358,7 +369,7 @@ class DeployController extends Controller
|
||||
|
||||
$uuids = $request->input('uuid');
|
||||
$tags = $request->input('tag');
|
||||
$force = $request->input('force') ?? false;
|
||||
$force = $request->boolean('force');
|
||||
$pullRequestId = $request->input('pull_request_id', $request->input('pr'));
|
||||
$pr = $pullRequestId ? max((int) $pullRequestId, 0) : 0;
|
||||
$dockerTag = $request->string('docker_tag')->trim()->value() ?: null;
|
||||
@@ -418,7 +429,7 @@ class DeployController extends Controller
|
||||
}
|
||||
['message' => $return_message, 'deployment_uuid' => $deployment_uuid] = $result;
|
||||
if ($deployment_uuid) {
|
||||
$deployments->push(['message' => $return_message, 'resource_uuid' => $uuid, 'deployment_uuid' => $deployment_uuid->toString()]);
|
||||
$deployments->push(['message' => $return_message, 'resource_uuid' => $uuid, 'deployment_uuid' => $deployment_uuid]);
|
||||
} else {
|
||||
$deployments->push(['message' => $return_message, 'resource_uuid' => $uuid]);
|
||||
}
|
||||
@@ -464,7 +475,7 @@ class DeployController extends Controller
|
||||
}
|
||||
['message' => $return_message, 'deployment_uuid' => $deployment_uuid] = $result;
|
||||
if ($deployment_uuid) {
|
||||
$deployments->push(['resource_uuid' => $resource->uuid, 'deployment_uuid' => $deployment_uuid->toString()]);
|
||||
$deployments->push(['resource_uuid' => $resource->uuid, 'deployment_uuid' => $deployment_uuid]);
|
||||
}
|
||||
$message = $message->merge($return_message);
|
||||
}
|
||||
@@ -503,7 +514,7 @@ class DeployController extends Controller
|
||||
if ($dockerTag !== null && $resource->build_pack !== 'dockerimage') {
|
||||
return ['message' => 'docker_tag can only be used with Docker Image applications.', 'deployment_uuid' => null];
|
||||
}
|
||||
$deployment_uuid = new Cuid2;
|
||||
$deployment_uuid = new_public_id();
|
||||
$result = queue_application_deployment(
|
||||
application: $resource,
|
||||
deployment_uuid: $deployment_uuid,
|
||||
@@ -518,6 +529,14 @@ class DeployController extends Controller
|
||||
$message = $result['message'];
|
||||
} else {
|
||||
$message = "Application {$resource->name} deployment queued.";
|
||||
auditLog('api.deployment.triggered', [
|
||||
'resource_type' => 'application',
|
||||
'application_uuid' => $resource->uuid,
|
||||
'application_name' => $resource->name,
|
||||
'deployment_uuid' => $deployment_uuid,
|
||||
'force_rebuild' => $force,
|
||||
'pull_request_id' => $pr,
|
||||
]);
|
||||
}
|
||||
break;
|
||||
case Service::class:
|
||||
@@ -529,6 +548,10 @@ class DeployController extends Controller
|
||||
}
|
||||
StartService::run($resource);
|
||||
$message = "Service {$resource->name} started. It could take a while, be patient.";
|
||||
auditLog('api.service.deployed', [
|
||||
'service_uuid' => $resource->uuid,
|
||||
'service_name' => $resource->name,
|
||||
]);
|
||||
break;
|
||||
default:
|
||||
// Database resource - check authorization
|
||||
@@ -543,6 +566,11 @@ class DeployController extends Controller
|
||||
$resource->save();
|
||||
|
||||
$message = "Database {$resource->name} started.";
|
||||
auditLog('api.database.started', [
|
||||
'database_uuid' => $resource->uuid,
|
||||
'database_name' => $resource->name,
|
||||
'database_type' => $resource->getMorphClass(),
|
||||
]);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -674,6 +702,9 @@ class DeployController extends Controller
|
||||
$this->authorize('view', $application);
|
||||
|
||||
$deployments = $application->deployments($skip, $take);
|
||||
if ($request->attributes->get('can_read_sensitive', false) === true) {
|
||||
$deployments['deployments']->each->makeVisible(['logs']);
|
||||
}
|
||||
|
||||
return response()->json($deployments);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Actions\Destination\RemoveStandaloneDockerNetwork;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\SwarmDocker;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class DestinationsController extends Controller
|
||||
{
|
||||
private function transform(StandaloneDocker|SwarmDocker $destination): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $destination->uuid,
|
||||
'name' => $destination->name,
|
||||
'network' => $destination->network,
|
||||
'type' => $destination instanceof SwarmDocker ? 'swarm' : 'standalone',
|
||||
'server_uuid' => $destination->server?->uuid,
|
||||
'created_at' => $destination->created_at,
|
||||
'updated_at' => $destination->updated_at,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the calling token's team id, or return an invalid-token response.
|
||||
*/
|
||||
private function teamIdOrAbort(): int|JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
return $teamId;
|
||||
}
|
||||
|
||||
/**
|
||||
* StandaloneDocker / SwarmDocker scoped to a team via their parent server.
|
||||
* Uses whereHas instead of the model's ownedByCurrentTeamAPI() scope so the
|
||||
* controller works on Coolify versions that pre-date that scope being added
|
||||
* to the destination models (e.g. 4.0.0-beta.470).
|
||||
*/
|
||||
private function teamScopedDockers(int $teamId): array
|
||||
{
|
||||
return [
|
||||
'standalone' => StandaloneDocker::with('server:id,uuid')->whereHas('server', fn ($query) => $query->whereTeamId($teamId))->get(),
|
||||
'swarm' => SwarmDocker::with('server:id,uuid')->whereHas('server', fn ($query) => $query->whereTeamId($teamId))->get(),
|
||||
];
|
||||
}
|
||||
|
||||
private function findDestinationForTeam(int $teamId, string $uuid): StandaloneDocker|SwarmDocker
|
||||
{
|
||||
return StandaloneDocker::with('server:id,uuid,team_id,ip,user,port,private_key_id')->whereHas('server', fn ($query) => $query->whereTeamId($teamId))->whereUuid($uuid)->first()
|
||||
?? SwarmDocker::with('server:id,uuid,team_id')->whereHas('server', fn ($query) => $query->whereTeamId($teamId))->whereUuid($uuid)->firstOrFail();
|
||||
}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = $this->teamIdOrAbort();
|
||||
if (! is_int($teamId)) {
|
||||
return $teamId;
|
||||
}
|
||||
$sets = $this->teamScopedDockers($teamId);
|
||||
|
||||
return response()->json(
|
||||
$sets['standalone']->concat($sets['swarm'])
|
||||
->map(fn ($destination) => $this->transform($destination))
|
||||
->values()
|
||||
);
|
||||
}
|
||||
|
||||
public function index_by_server(Request $request, string $server_uuid): JsonResponse
|
||||
{
|
||||
$teamId = $this->teamIdOrAbort();
|
||||
if (! is_int($teamId)) {
|
||||
return $teamId;
|
||||
}
|
||||
$server = Server::with(['standaloneDockers.server:id,uuid', 'swarmDockers.server:id,uuid'])
|
||||
->whereTeamId($teamId)
|
||||
->whereUuid($server_uuid)
|
||||
->firstOrFail();
|
||||
$list = $server->standaloneDockers->concat($server->swarmDockers);
|
||||
|
||||
return response()->json($list->map(fn ($destination) => $this->transform($destination))->values());
|
||||
}
|
||||
|
||||
public function show(Request $request, string $uuid): JsonResponse
|
||||
{
|
||||
$teamId = $this->teamIdOrAbort();
|
||||
if (! is_int($teamId)) {
|
||||
return $teamId;
|
||||
}
|
||||
$destination = $this->findDestinationForTeam($teamId, $uuid);
|
||||
|
||||
return response()->json($this->transform($destination));
|
||||
}
|
||||
|
||||
public function create(Request $request, string $server_uuid): JsonResponse
|
||||
{
|
||||
$teamId = $this->teamIdOrAbort();
|
||||
if (! is_int($teamId)) {
|
||||
return $teamId;
|
||||
}
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
$server = Server::whereTeamId($teamId)->whereUuid($server_uuid)->firstOrFail();
|
||||
|
||||
$allowed = ['name', 'network', 'type'];
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'name' => 'nullable|string|max:255',
|
||||
'network' => ['required', 'string', 'max:255', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/'],
|
||||
'type' => 'nullable|in:standalone,swarm',
|
||||
]);
|
||||
$extra = array_diff(array_keys($request->all()), $allowed);
|
||||
if ($validator->fails() || ! empty($extra)) {
|
||||
$errors = $validator->errors();
|
||||
if (! empty($extra)) {
|
||||
foreach ($extra as $field) {
|
||||
$errors->add($field, 'This field is not allowed.');
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json(['message' => 'Validation failed.', 'errors' => $errors], 422);
|
||||
}
|
||||
|
||||
$expectedType = $server->isSwarm() ? 'swarm' : 'standalone';
|
||||
$type = $request->input('type', $expectedType);
|
||||
if ($type !== $expectedType) {
|
||||
return response()->json(['message' => "Destination type must be {$expectedType} for this server."], 422);
|
||||
}
|
||||
|
||||
$name = $request->input('name') ?: ($server->name.'-'.$request->input('network'));
|
||||
$class = $type === 'swarm' ? SwarmDocker::class : StandaloneDocker::class;
|
||||
|
||||
$this->authorize('create', $class);
|
||||
|
||||
$exists = $class::where('server_id', $server->id)->where('network', $request->input('network'))->exists();
|
||||
if ($exists) {
|
||||
return response()->json(['message' => 'A destination with this network already exists on the server.'], 409);
|
||||
}
|
||||
|
||||
try {
|
||||
$destination = $class::create([
|
||||
'name' => $name,
|
||||
'network' => $request->input('network'),
|
||||
'server_id' => $server->id,
|
||||
]);
|
||||
} catch (QueryException $exception) {
|
||||
if ($this->isUniqueConstraintViolation($exception)) {
|
||||
return response()->json(['message' => 'A destination with this network already exists on the server.'], 409);
|
||||
}
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
auditLog('api.destination.created', [
|
||||
'team_id' => $teamId,
|
||||
'destination_uuid' => $destination->uuid,
|
||||
'destination_name' => $destination->name,
|
||||
'destination_type' => $type,
|
||||
'server_uuid' => $server->uuid,
|
||||
]);
|
||||
|
||||
return response()->json($this->transform($destination->load('server:id,uuid')), 201);
|
||||
}
|
||||
|
||||
private function isUniqueConstraintViolation(QueryException $exception): bool
|
||||
{
|
||||
$sqlState = $exception->errorInfo[0] ?? null;
|
||||
$driverCode = (string) ($exception->errorInfo[1] ?? $exception->getCode());
|
||||
|
||||
return in_array($sqlState, ['23000', '23505'], true)
|
||||
|| in_array($driverCode, ['19', '1062', '2067'], true);
|
||||
}
|
||||
|
||||
public function delete(Request $request, string $uuid): JsonResponse
|
||||
{
|
||||
$teamId = $this->teamIdOrAbort();
|
||||
if (! is_int($teamId)) {
|
||||
return $teamId;
|
||||
}
|
||||
$destination = $this->findDestinationForTeam($teamId, $uuid);
|
||||
|
||||
$this->authorize('delete', $destination);
|
||||
|
||||
// Guard against deleting destinations with attached resources. attachedTo()
|
||||
// is recent on the destination models; fall back to a manual check for
|
||||
// older Coolify versions (e.g. 4.0.0-beta.470).
|
||||
if (method_exists($destination, 'attachedTo')) {
|
||||
if ($destination->attachedTo()) {
|
||||
return response()->json(['message' => 'Destination has attached resources, detach first.'], 409);
|
||||
}
|
||||
} else {
|
||||
$hasAttached = $destination->applications()->exists()
|
||||
|| $destination->postgresqls()->exists()
|
||||
|| (method_exists($destination, 'mysqls') && $destination->mysqls()->exists())
|
||||
|| (method_exists($destination, 'mariadbs') && $destination->mariadbs()->exists())
|
||||
|| (method_exists($destination, 'mongodbs') && $destination->mongodbs()->exists())
|
||||
|| (method_exists($destination, 'redis') && $destination->redis()->exists())
|
||||
|| (method_exists($destination, 'keydbs') && $destination->keydbs()->exists())
|
||||
|| (method_exists($destination, 'dragonflies') && $destination->dragonflies()->exists())
|
||||
|| (method_exists($destination, 'clickhouses') && $destination->clickhouses()->exists())
|
||||
|| (method_exists($destination, 'services') && $destination->services()->exists());
|
||||
if ($hasAttached) {
|
||||
return response()->json(['message' => 'Destination has attached resources, detach first.'], 409);
|
||||
}
|
||||
}
|
||||
if ($destination instanceof StandaloneDocker) {
|
||||
app(RemoveStandaloneDockerNetwork::class)->handle($destination);
|
||||
}
|
||||
|
||||
$destinationUuid = $destination->uuid;
|
||||
$destinationName = $destination->name;
|
||||
$destinationType = $destination instanceof SwarmDocker ? 'swarm' : 'standalone';
|
||||
$serverUuid = $destination->server?->uuid;
|
||||
|
||||
$destination->delete();
|
||||
|
||||
auditLog('api.destination.deleted', [
|
||||
'team_id' => $teamId,
|
||||
'destination_uuid' => $destinationUuid,
|
||||
'destination_name' => $destinationName,
|
||||
'destination_type' => $destinationType,
|
||||
'server_uuid' => $serverUuid,
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Deleted.']);
|
||||
}
|
||||
}
|
||||
@@ -17,10 +17,17 @@ class GithubController extends Controller
|
||||
{
|
||||
private function removeSensitiveData($githubApp)
|
||||
{
|
||||
$githubApp->makeHidden([
|
||||
'client_secret',
|
||||
'webhook_secret',
|
||||
]);
|
||||
if (request()->attributes->get('can_read_sensitive', false) === true) {
|
||||
$githubApp->makeVisible([
|
||||
'client_secret',
|
||||
'webhook_secret',
|
||||
]);
|
||||
} else {
|
||||
$githubApp->makeHidden([
|
||||
'client_secret',
|
||||
'webhook_secret',
|
||||
]);
|
||||
}
|
||||
|
||||
return serializeApiResponse($githubApp);
|
||||
}
|
||||
@@ -129,7 +136,7 @@ class GithubController extends Controller
|
||||
'private_key_uuid' => ['type' => 'string', 'description' => 'UUID of an existing private key for GitHub App authentication.'],
|
||||
'is_system_wide' => ['type' => 'boolean', 'description' => 'Is this app system-wide (cloud only).'],
|
||||
],
|
||||
required: ['name', 'api_url', 'html_url', 'app_id', 'installation_id', 'client_id', 'client_secret', 'private_key_uuid'],
|
||||
required: ['name', 'html_url', 'app_id', 'installation_id', 'client_id', 'client_secret', 'private_key_uuid'],
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -183,6 +190,7 @@ class GithubController extends Controller
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
$this->authorize('create', [GithubApp::class]);
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
@@ -204,10 +212,14 @@ class GithubController extends Controller
|
||||
'is_system_wide',
|
||||
];
|
||||
|
||||
$request->merge([
|
||||
'organization' => normalizeGithubOrganization($request->input('organization')),
|
||||
]);
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'name' => 'required|string|max:255',
|
||||
'organization' => 'nullable|string|max:255',
|
||||
'api_url' => ['required', 'string', 'url', new SafeExternalUrl],
|
||||
'organization' => ['nullable', 'string', 'max:255', 'regex:/\A[^\s\/?#]+\z/'],
|
||||
'api_url' => ['nullable', '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',
|
||||
@@ -251,7 +263,9 @@ class GithubController extends Controller
|
||||
'uuid' => Str::uuid(),
|
||||
'name' => $request->input('name'),
|
||||
'organization' => $request->input('organization'),
|
||||
'api_url' => $request->input('api_url'),
|
||||
'api_url' => filled($request->input('api_url'))
|
||||
? $request->input('api_url')
|
||||
: githubApiUrlFromHtmlUrl($request->input('html_url')),
|
||||
'html_url' => $request->input('html_url'),
|
||||
'custom_user' => $request->input('custom_user', 'git'),
|
||||
'custom_port' => $request->input('custom_port', 22),
|
||||
@@ -271,6 +285,12 @@ class GithubController extends Controller
|
||||
|
||||
$githubApp = GithubApp::create($payload);
|
||||
|
||||
auditLog('api.github_app.created', [
|
||||
'team_id' => $teamId,
|
||||
'github_app_uuid' => $githubApp->uuid,
|
||||
'github_app_name' => $githubApp->name,
|
||||
]);
|
||||
|
||||
return response()->json($githubApp, 201);
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e);
|
||||
@@ -558,6 +578,7 @@ class GithubController extends Controller
|
||||
$githubApp = GithubApp::where('id', $github_app_id)
|
||||
->where('team_id', $teamId)
|
||||
->firstOrFail();
|
||||
$this->authorize('update', $githubApp);
|
||||
|
||||
// Define allowed fields for update
|
||||
$allowedFields = [
|
||||
@@ -581,13 +602,17 @@ class GithubController extends Controller
|
||||
|
||||
$payload = $request->only($allowedFields);
|
||||
|
||||
if (array_key_exists('organization', $payload)) {
|
||||
$payload['organization'] = normalizeGithubOrganization($payload['organization']);
|
||||
}
|
||||
|
||||
// Validate the request
|
||||
$rules = [];
|
||||
if (isset($payload['name'])) {
|
||||
$rules['name'] = 'string';
|
||||
}
|
||||
if (isset($payload['organization'])) {
|
||||
$rules['organization'] = 'nullable|string';
|
||||
$rules['organization'] = ['nullable', 'string', 'regex:/\A[^\s\/?#]+\z/'];
|
||||
}
|
||||
if (isset($payload['api_url'])) {
|
||||
$rules['api_url'] = ['url', new SafeExternalUrl];
|
||||
@@ -631,6 +656,13 @@ class GithubController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
if (array_key_exists('organization', $payload)) {
|
||||
$payload['organization'] = normalizeGithubOrganization($payload['organization']);
|
||||
}
|
||||
if (isset($payload['html_url']) && ! filled($payload['api_url'] ?? null)) {
|
||||
$payload['api_url'] = githubApiUrlFromHtmlUrl($payload['html_url']);
|
||||
}
|
||||
|
||||
// Handle private_key_uuid -> private_key_id conversion
|
||||
if (isset($payload['private_key_uuid'])) {
|
||||
$privateKey = PrivateKey::where('team_id', $teamId)
|
||||
@@ -650,6 +682,13 @@ class GithubController extends Controller
|
||||
// Update the GitHub app
|
||||
$githubApp->update($payload);
|
||||
|
||||
auditLog('api.github_app.updated', [
|
||||
'team_id' => $teamId,
|
||||
'github_app_uuid' => $githubApp->uuid,
|
||||
'github_app_name' => $githubApp->name,
|
||||
'changed_fields' => array_values(array_diff($allowedFields, ['client_secret', 'webhook_secret', 'private_key_uuid'])),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'GitHub app updated successfully',
|
||||
'data' => $githubApp,
|
||||
@@ -724,6 +763,7 @@ class GithubController extends Controller
|
||||
$githubApp = GithubApp::where('id', $github_app_id)
|
||||
->where('team_id', $teamId)
|
||||
->firstOrFail();
|
||||
$this->authorize('delete', $githubApp);
|
||||
|
||||
// Check if the GitHub app is being used by any applications
|
||||
if ($githubApp->applications->isNotEmpty()) {
|
||||
@@ -734,8 +774,16 @@ class GithubController extends Controller
|
||||
], 409);
|
||||
}
|
||||
|
||||
$deletedUuid = $githubApp->uuid;
|
||||
$deletedName = $githubApp->name;
|
||||
$githubApp->delete();
|
||||
|
||||
auditLog('api.github_app.deleted', [
|
||||
'team_id' => $teamId,
|
||||
'github_app_uuid' => $deletedUuid,
|
||||
'github_app_name' => $deletedName,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'GitHub app deleted successfully',
|
||||
]);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Actions\Server\ValidateServer;
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Exceptions\RateLimitException;
|
||||
use App\Http\Controllers\Controller;
|
||||
@@ -12,6 +13,7 @@ use App\Models\Team;
|
||||
use App\Rules\ValidCloudInitYaml;
|
||||
use App\Rules\ValidHostname;
|
||||
use App\Services\HetznerService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
@@ -114,6 +116,7 @@ class HetznerController extends Controller
|
||||
if (! $token) {
|
||||
return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404);
|
||||
}
|
||||
$this->authorize('view', $token);
|
||||
|
||||
try {
|
||||
$hetznerService = new HetznerService($token->token);
|
||||
@@ -121,7 +124,7 @@ class HetznerController extends Controller
|
||||
|
||||
return response()->json($locations);
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json(['message' => 'Failed to fetch locations: '.$e->getMessage()], 500);
|
||||
return response()->json(['message' => 'Failed to fetch Hetzner locations.'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,6 +238,7 @@ class HetznerController extends Controller
|
||||
if (! $token) {
|
||||
return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404);
|
||||
}
|
||||
$this->authorize('view', $token);
|
||||
|
||||
try {
|
||||
$hetznerService = new HetznerService($token->token);
|
||||
@@ -242,7 +246,7 @@ class HetznerController extends Controller
|
||||
|
||||
return response()->json($serverTypes);
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json(['message' => 'Failed to fetch server types: '.$e->getMessage()], 500);
|
||||
return response()->json(['message' => 'Failed to fetch Hetzner server types.'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -334,6 +338,7 @@ class HetznerController extends Controller
|
||||
if (! $token) {
|
||||
return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404);
|
||||
}
|
||||
$this->authorize('view', $token);
|
||||
|
||||
try {
|
||||
$hetznerService = new HetznerService($token->token);
|
||||
@@ -354,7 +359,7 @@ class HetznerController extends Controller
|
||||
|
||||
return response()->json(array_values($filtered));
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json(['message' => 'Failed to fetch images: '.$e->getMessage()], 500);
|
||||
return response()->json(['message' => 'Failed to fetch Hetzner images.'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -443,6 +448,7 @@ class HetznerController extends Controller
|
||||
if (! $token) {
|
||||
return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404);
|
||||
}
|
||||
$this->authorize('view', $token);
|
||||
|
||||
try {
|
||||
$hetznerService = new HetznerService($token->token);
|
||||
@@ -450,7 +456,196 @@ class HetznerController extends Controller
|
||||
|
||||
return response()->json($sshKeys);
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json(['message' => 'Failed to fetch SSH keys: '.$e->getMessage()], 500);
|
||||
return response()->json(['message' => 'Failed to fetch Hetzner SSH keys.'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Get Hetzner Firewalls',
|
||||
description: 'Get all existing Hetzner firewalls for the current project.',
|
||||
path: '/hetzner/firewalls',
|
||||
operationId: 'get-hetzner-firewalls',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Hetzner'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'cloud_provider_token_uuid',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.',
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'cloud_provider_token_id',
|
||||
in: 'query',
|
||||
required: false,
|
||||
deprecated: true,
|
||||
description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.',
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'List of Hetzner firewalls.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'array',
|
||||
items: new OA\Items(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'id' => ['type' => 'integer'],
|
||||
'name' => ['type' => 'string'],
|
||||
]
|
||||
)
|
||||
)
|
||||
),
|
||||
]),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function firewalls(Request $request)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string',
|
||||
'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$tokenUuid = $this->getCloudProviderTokenUuid($request);
|
||||
$token = CloudProviderToken::whereTeamId($teamId)
|
||||
->whereUuid($tokenUuid)
|
||||
->where('provider', 'hetzner')
|
||||
->first();
|
||||
|
||||
if (! $token) {
|
||||
return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404);
|
||||
}
|
||||
$this->authorize('view', $token);
|
||||
|
||||
try {
|
||||
$hetznerService = new HetznerService($token->token);
|
||||
|
||||
return response()->json($hetznerService->getFirewalls());
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json(['message' => 'Failed to fetch Hetzner firewalls.'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Get Hetzner Networks',
|
||||
description: 'Get all existing Hetzner private networks for the current project.',
|
||||
path: '/hetzner/networks',
|
||||
operationId: 'get-hetzner-networks',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Hetzner'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'cloud_provider_token_uuid',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.',
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'cloud_provider_token_id',
|
||||
in: 'query',
|
||||
required: false,
|
||||
deprecated: true,
|
||||
description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.',
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'List of Hetzner networks.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'array',
|
||||
items: new OA\Items(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'id' => ['type' => 'integer'],
|
||||
'name' => ['type' => 'string'],
|
||||
'ip_range' => ['type' => 'string'],
|
||||
]
|
||||
)
|
||||
)
|
||||
),
|
||||
]),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function networks(Request $request)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string',
|
||||
'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$tokenUuid = $this->getCloudProviderTokenUuid($request);
|
||||
$token = CloudProviderToken::whereTeamId($teamId)
|
||||
->whereUuid($tokenUuid)
|
||||
->where('provider', 'hetzner')
|
||||
->first();
|
||||
|
||||
if (! $token) {
|
||||
return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404);
|
||||
}
|
||||
$this->authorize('view', $token);
|
||||
|
||||
try {
|
||||
$hetznerService = new HetznerService($token->token);
|
||||
|
||||
return response()->json($hetznerService->getNetworks());
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json(['message' => 'Failed to fetch Hetzner networks.'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -481,7 +676,10 @@ class HetznerController extends Controller
|
||||
'private_key_uuid' => ['type' => 'string', 'example' => 'xyz789', 'description' => 'Private key UUID'],
|
||||
'enable_ipv4' => ['type' => 'boolean', 'example' => true, 'description' => 'Enable IPv4 (default: true)'],
|
||||
'enable_ipv6' => ['type' => 'boolean', 'example' => true, 'description' => 'Enable IPv6 (default: true)'],
|
||||
'enable_backups' => ['type' => 'boolean', 'example' => false, 'description' => 'Enable Hetzner server backups after creation (adds 20% to the monthly server fee)'],
|
||||
'hetzner_ssh_key_ids' => ['type' => 'array', 'items' => ['type' => 'integer'], 'description' => 'Additional Hetzner SSH key IDs'],
|
||||
'hetzner_firewall_ids' => ['type' => 'array', 'items' => ['type' => 'integer'], 'description' => 'Existing Hetzner firewall IDs to apply during server creation'],
|
||||
'hetzner_network_ids' => ['type' => 'array', 'items' => ['type' => 'integer'], 'description' => 'Existing Hetzner network IDs to attach during server creation'],
|
||||
'cloud_init_script' => ['type' => 'string', 'description' => 'Cloud-init YAML script (optional)'],
|
||||
'instant_validate' => ['type' => 'boolean', 'example' => false, 'description' => 'Validate server immediately after creation'],
|
||||
],
|
||||
@@ -539,7 +737,10 @@ class HetznerController extends Controller
|
||||
'private_key_uuid',
|
||||
'enable_ipv4',
|
||||
'enable_ipv6',
|
||||
'enable_backups',
|
||||
'hetzner_ssh_key_ids',
|
||||
'hetzner_firewall_ids',
|
||||
'hetzner_network_ids',
|
||||
'cloud_init_script',
|
||||
'instant_validate',
|
||||
];
|
||||
@@ -548,9 +749,10 @@ class HetznerController extends Controller
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
$this->authorize('create', [Server::class]);
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
@@ -564,8 +766,13 @@ class HetznerController extends Controller
|
||||
'private_key_uuid' => 'required|string',
|
||||
'enable_ipv4' => 'nullable|boolean',
|
||||
'enable_ipv6' => 'nullable|boolean',
|
||||
'enable_backups' => 'nullable|boolean',
|
||||
'hetzner_ssh_key_ids' => 'nullable|array',
|
||||
'hetzner_ssh_key_ids.*' => 'integer',
|
||||
'hetzner_firewall_ids' => 'nullable|array',
|
||||
'hetzner_firewall_ids.*' => 'integer',
|
||||
'hetzner_network_ids' => 'nullable|array',
|
||||
'hetzner_network_ids.*' => 'integer',
|
||||
'cloud_init_script' => ['nullable', 'string', new ValidCloudInitYaml],
|
||||
'instant_validate' => 'nullable|boolean',
|
||||
]);
|
||||
@@ -601,13 +808,32 @@ class HetznerController extends Controller
|
||||
if (is_null($request->enable_ipv6)) {
|
||||
$request->offsetSet('enable_ipv6', true);
|
||||
}
|
||||
if (is_null($request->enable_backups)) {
|
||||
$request->offsetSet('enable_backups', false);
|
||||
}
|
||||
if (is_null($request->hetzner_ssh_key_ids)) {
|
||||
$request->offsetSet('hetzner_ssh_key_ids', []);
|
||||
}
|
||||
if (is_null($request->hetzner_firewall_ids)) {
|
||||
$request->offsetSet('hetzner_firewall_ids', []);
|
||||
}
|
||||
if (is_null($request->hetzner_network_ids)) {
|
||||
$request->offsetSet('hetzner_network_ids', []);
|
||||
}
|
||||
if (is_null($request->instant_validate)) {
|
||||
$request->offsetSet('instant_validate', false);
|
||||
}
|
||||
|
||||
if (! $request->boolean('enable_ipv4') && ! $request->boolean('enable_ipv6')) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => [
|
||||
'enable_ipv4' => ['Enable at least one public IP protocol.'],
|
||||
'enable_ipv6' => ['Enable at least one public IP protocol.'],
|
||||
],
|
||||
], 422);
|
||||
}
|
||||
|
||||
// Validate cloud provider token
|
||||
$tokenUuid = $this->getCloudProviderTokenUuid($request);
|
||||
$token = CloudProviderToken::whereTeamId($teamId)
|
||||
@@ -618,6 +844,7 @@ class HetznerController extends Controller
|
||||
if (! $token) {
|
||||
return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404);
|
||||
}
|
||||
$this->authorize('view', $token);
|
||||
|
||||
// Validate private key
|
||||
$privateKey = PrivateKey::whereTeamId($teamId)->whereUuid($request->private_key_uuid)->first();
|
||||
@@ -679,6 +906,18 @@ class HetznerController extends Controller
|
||||
],
|
||||
];
|
||||
|
||||
$firewallIds = array_values(array_unique($request->hetzner_firewall_ids));
|
||||
if ($firewallIds !== []) {
|
||||
$params['firewalls'] = array_map(function (int $firewallId): array {
|
||||
return ['firewall' => $firewallId];
|
||||
}, $firewallIds);
|
||||
}
|
||||
|
||||
$networkIds = array_values(array_unique($request->hetzner_network_ids));
|
||||
if ($networkIds !== []) {
|
||||
$params['networks'] = $networkIds;
|
||||
}
|
||||
|
||||
// Add cloud-init script if provided
|
||||
if (! empty($request->cloud_init_script)) {
|
||||
$params['user_data'] = $request->cloud_init_script;
|
||||
@@ -715,11 +954,27 @@ class HetznerController extends Controller
|
||||
$server->proxy->set('type', ProxyTypes::TRAEFIK->value);
|
||||
$server->save();
|
||||
|
||||
if ($request->enable_backups) {
|
||||
try {
|
||||
$hetznerService->enableServerBackup((int) $hetznerServer['id']);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate server if requested
|
||||
if ($request->instant_validate) {
|
||||
\App\Actions\Server\ValidateServer::dispatch($server);
|
||||
ValidateServer::dispatch($server);
|
||||
}
|
||||
|
||||
auditLog('api.hetzner_server.created', [
|
||||
'team_id' => $teamId,
|
||||
'server_uuid' => $server->uuid,
|
||||
'server_name' => $server->name,
|
||||
'hetzner_server_id' => $hetznerServer['id'],
|
||||
'ip' => $ipAddress,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'uuid' => $server->uuid,
|
||||
'hetzner_server_id' => $hetznerServer['id'],
|
||||
@@ -733,7 +988,7 @@ class HetznerController extends Controller
|
||||
|
||||
return $response;
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json(['message' => 'Failed to create server: '.$e->getMessage()], 500);
|
||||
return response()->json(['message' => 'Failed to create Hetzner server.'], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,11 +85,15 @@ class OtherController extends Controller
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
if ($teamId !== '0') {
|
||||
auditLog('api.instance.enable_denied', ['team_id' => $teamId], 'warning');
|
||||
|
||||
return response()->json(['message' => 'You are not allowed to enable the API.'], 403);
|
||||
}
|
||||
$settings = instanceSettings();
|
||||
$settings->update(['is_api_enabled' => true]);
|
||||
|
||||
auditLog('api.instance.enabled', ['team_id' => $teamId]);
|
||||
|
||||
return response()->json(['message' => 'API enabled.'], 200);
|
||||
}
|
||||
|
||||
@@ -137,21 +141,141 @@ class OtherController extends Controller
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
if ($teamId !== '0') {
|
||||
auditLog('api.instance.disable_denied', ['team_id' => $teamId], 'warning');
|
||||
|
||||
return response()->json(['message' => 'You are not allowed to disable the API.'], 403);
|
||||
}
|
||||
$settings = instanceSettings();
|
||||
$settings->update(['is_api_enabled' => false]);
|
||||
|
||||
auditLog('api.instance.disabled', ['team_id' => $teamId]);
|
||||
|
||||
return response()->json(['message' => 'API disabled.'], 200);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Enable MCP Server',
|
||||
description: 'Enable the MCP server endpoint at /mcp (only with root permissions).',
|
||||
path: '/mcp/enable',
|
||||
operationId: 'enable-mcp',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'MCP server enabled.',
|
||||
content: new OA\JsonContent(
|
||||
type: 'object',
|
||||
properties: [
|
||||
new OA\Property(property: 'message', type: 'string', example: 'MCP server enabled.'),
|
||||
]
|
||||
)),
|
||||
new OA\Response(
|
||||
response: 403,
|
||||
description: 'You are not allowed to enable the MCP server.',
|
||||
content: new OA\JsonContent(
|
||||
type: 'object',
|
||||
properties: [
|
||||
new OA\Property(property: 'message', type: 'string', example: 'You are not allowed to enable the MCP server.'),
|
||||
]
|
||||
)),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function enable_mcp(Request $request)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
if ($teamId !== '0') {
|
||||
auditLog('api.mcp.enable_denied', ['team_id' => $teamId], 'warning');
|
||||
|
||||
return response()->json(['message' => 'You are not allowed to enable the MCP server.'], 403);
|
||||
}
|
||||
$settings = instanceSettings();
|
||||
$settings->update(['is_mcp_server_enabled' => true]);
|
||||
|
||||
auditLog('api.mcp.enabled', ['team_id' => $teamId]);
|
||||
|
||||
return response()->json(['message' => 'MCP server enabled.'], 200);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Disable MCP Server',
|
||||
description: 'Disable the MCP server endpoint at /mcp (only with root permissions).',
|
||||
path: '/mcp/disable',
|
||||
operationId: 'disable-mcp',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'MCP server disabled.',
|
||||
content: new OA\JsonContent(
|
||||
type: 'object',
|
||||
properties: [
|
||||
new OA\Property(property: 'message', type: 'string', example: 'MCP server disabled.'),
|
||||
]
|
||||
)),
|
||||
new OA\Response(
|
||||
response: 403,
|
||||
description: 'You are not allowed to disable the MCP server.',
|
||||
content: new OA\JsonContent(
|
||||
type: 'object',
|
||||
properties: [
|
||||
new OA\Property(property: 'message', type: 'string', example: 'You are not allowed to disable the MCP server.'),
|
||||
]
|
||||
)),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function disable_mcp(Request $request)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
if ($teamId !== '0') {
|
||||
auditLog('api.mcp.disable_denied', ['team_id' => $teamId], 'warning');
|
||||
|
||||
return response()->json(['message' => 'You are not allowed to disable the MCP server.'], 403);
|
||||
}
|
||||
$settings = instanceSettings();
|
||||
$settings->update(['is_mcp_server_enabled' => false]);
|
||||
|
||||
auditLog('api.mcp.disabled', ['team_id' => $teamId]);
|
||||
|
||||
return response()->json(['message' => 'MCP server disabled.'], 200);
|
||||
}
|
||||
|
||||
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' => []],
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -97,6 +97,7 @@ class ProjectController extends Controller
|
||||
if (! $project) {
|
||||
return response()->json(['message' => 'Project not found.'], 404);
|
||||
}
|
||||
$this->authorize('view', $project);
|
||||
|
||||
$project->load(['environments']);
|
||||
|
||||
@@ -165,6 +166,9 @@ class ProjectController extends Controller
|
||||
return response()->json(['message' => 'Environment not found.'], 404);
|
||||
}
|
||||
$environment = $environment->load(['applications', 'postgresqls', 'redis', 'mongodbs', 'mysqls', 'mariadbs', 'services']);
|
||||
collect(['applications', 'postgresqls', 'redis', 'mongodbs', 'mysqls', 'mariadbs', 'services'])
|
||||
->flatMap(fn (string $relation) => $environment->{$relation})
|
||||
->each(fn ($resource) => exposeSensitiveFields($resource));
|
||||
|
||||
return response()->json(serializeApiResponse($environment));
|
||||
}
|
||||
@@ -233,6 +237,7 @@ class ProjectController extends Controller
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
$this->authorize('create', [Project::class]);
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof JsonResponse) {
|
||||
@@ -258,12 +263,18 @@ class ProjectController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
$project = Project::forceCreate([
|
||||
$project = Project::create([
|
||||
'name' => $request->name,
|
||||
'description' => $request->description,
|
||||
'team_id' => $teamId,
|
||||
]);
|
||||
|
||||
auditLog('api.project.created', [
|
||||
'team_id' => $teamId,
|
||||
'project_uuid' => $project->uuid,
|
||||
'project_name' => $project->name,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'uuid' => $project->uuid,
|
||||
])->setStatusCode(201);
|
||||
@@ -379,9 +390,17 @@ class ProjectController extends Controller
|
||||
if (! $project) {
|
||||
return response()->json(['message' => 'Project not found.'], 404);
|
||||
}
|
||||
$this->authorize('update', $project);
|
||||
|
||||
$project->update($request->only($allowedFields));
|
||||
|
||||
auditLog('api.project.updated', [
|
||||
'team_id' => $teamId,
|
||||
'project_uuid' => $project->uuid,
|
||||
'project_name' => $project->name,
|
||||
'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'uuid' => $project->uuid,
|
||||
'name' => $project->name,
|
||||
@@ -456,12 +475,21 @@ class ProjectController extends Controller
|
||||
if (! $project) {
|
||||
return response()->json(['message' => 'Project not found.'], 404);
|
||||
}
|
||||
$this->authorize('delete', $project);
|
||||
if (! $project->isEmpty()) {
|
||||
return response()->json(['message' => 'Project has resources, so it cannot be deleted.'], 400);
|
||||
}
|
||||
|
||||
$projectUuid = $project->uuid;
|
||||
$projectName = $project->name;
|
||||
$project->delete();
|
||||
|
||||
auditLog('api.project.deleted', [
|
||||
'team_id' => $teamId,
|
||||
'project_uuid' => $projectUuid,
|
||||
'project_name' => $projectName,
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Project deleted.']);
|
||||
}
|
||||
|
||||
@@ -631,6 +659,7 @@ class ProjectController extends Controller
|
||||
if (! $project) {
|
||||
return response()->json(['message' => 'Project not found.'], 404);
|
||||
}
|
||||
$this->authorize('update', $project);
|
||||
|
||||
$existingEnvironment = $project->environments()->where('name', $request->name)->first();
|
||||
if ($existingEnvironment) {
|
||||
@@ -641,6 +670,13 @@ class ProjectController extends Controller
|
||||
'name' => $request->name,
|
||||
]);
|
||||
|
||||
auditLog('api.project.environment_created', [
|
||||
'team_id' => $teamId,
|
||||
'project_uuid' => $project->uuid,
|
||||
'environment_uuid' => $environment->uuid,
|
||||
'environment_name' => $environment->name,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'uuid' => $environment->uuid,
|
||||
])->setStatusCode(201);
|
||||
@@ -718,13 +754,23 @@ class ProjectController extends Controller
|
||||
if (! $environment) {
|
||||
return response()->json(['message' => 'Environment not found.'], 404);
|
||||
}
|
||||
$this->authorize('delete', $environment);
|
||||
|
||||
if (! $environment->isEmpty()) {
|
||||
return response()->json(['message' => 'Environment has resources, so it cannot be deleted.'], 400);
|
||||
}
|
||||
|
||||
$envUuid = $environment->uuid;
|
||||
$envName = $environment->name;
|
||||
$environment->delete();
|
||||
|
||||
auditLog('api.project.environment_deleted', [
|
||||
'team_id' => $teamId,
|
||||
'project_uuid' => $project->uuid,
|
||||
'environment_uuid' => $envUuid,
|
||||
'environment_name' => $envName,
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Environment deleted.']);
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,7 @@ class ResourcesController extends Controller
|
||||
}
|
||||
$resources = $resources->flatten();
|
||||
$resources = $resources->map(function ($resource) {
|
||||
exposeSensitiveFields($resource);
|
||||
$payload = $resource->toArray();
|
||||
$payload['status'] = $resource->status;
|
||||
$payload['type'] = $resource->type();
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
|
||||
use App\Models\Application;
|
||||
use App\Models\ScheduledTask;
|
||||
use App\Models\Service;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
@@ -33,7 +34,7 @@ class ScheduledTasksController extends Controller
|
||||
return Service::whereRelation('environment.project.team', 'id', $teamId)->where('uuid', $request->uuid)->first();
|
||||
}
|
||||
|
||||
private function listTasks(Application|Service $resource): \Illuminate\Http\JsonResponse
|
||||
private function listTasks(Application|Service $resource): JsonResponse
|
||||
{
|
||||
$this->authorize('view', $resource);
|
||||
|
||||
@@ -44,12 +45,12 @@ class ScheduledTasksController extends Controller
|
||||
return response()->json($tasks);
|
||||
}
|
||||
|
||||
private function createTask(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse
|
||||
private function createTask(Request $request, Application|Service $resource): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $resource);
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
@@ -105,15 +106,23 @@ class ScheduledTasksController extends Controller
|
||||
|
||||
$task->save();
|
||||
|
||||
auditLog('api.scheduled_task.created', [
|
||||
'team_id' => $teamId,
|
||||
'task_uuid' => $task->uuid,
|
||||
'task_name' => $task->name,
|
||||
'resource_type' => $resource instanceof Application ? 'application' : 'service',
|
||||
'resource_uuid' => $resource->uuid,
|
||||
]);
|
||||
|
||||
return response()->json($this->removeSensitiveData($task), 201);
|
||||
}
|
||||
|
||||
private function updateTask(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse
|
||||
private function updateTask(Request $request, Application|Service $resource): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $resource);
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
@@ -161,22 +170,43 @@ class ScheduledTasksController extends Controller
|
||||
|
||||
$task->update($request->only($allowedFields));
|
||||
|
||||
auditLog('api.scheduled_task.updated', [
|
||||
'team_id' => getTeamIdFromToken(),
|
||||
'task_uuid' => $task->uuid,
|
||||
'task_name' => $task->name,
|
||||
'resource_type' => $resource instanceof Application ? 'application' : 'service',
|
||||
'resource_uuid' => $resource->uuid,
|
||||
'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
|
||||
]);
|
||||
|
||||
return response()->json($this->removeSensitiveData($task), 200);
|
||||
}
|
||||
|
||||
private function deleteTask(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse
|
||||
private function deleteTask(Request $request, Application|Service $resource): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $resource);
|
||||
|
||||
$deleted = $resource->scheduled_tasks()->where('uuid', $request->task_uuid)->delete();
|
||||
if (! $deleted) {
|
||||
$task = $resource->scheduled_tasks()->where('uuid', $request->task_uuid)->first();
|
||||
if (! $task) {
|
||||
return response()->json(['message' => 'Scheduled task not found.'], 404);
|
||||
}
|
||||
|
||||
$taskUuid = $task->uuid;
|
||||
$taskName = $task->name;
|
||||
$task->delete();
|
||||
|
||||
auditLog('api.scheduled_task.deleted', [
|
||||
'team_id' => getTeamIdFromToken(),
|
||||
'task_uuid' => $taskUuid,
|
||||
'task_name' => $taskName,
|
||||
'resource_type' => $resource instanceof Application ? 'application' : 'service',
|
||||
'resource_uuid' => $resource->uuid,
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Scheduled task deleted.']);
|
||||
}
|
||||
|
||||
private function getExecutions(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse
|
||||
private function getExecutions(Request $request, Application|Service $resource): JsonResponse
|
||||
{
|
||||
$this->authorize('view', $resource);
|
||||
|
||||
@@ -238,7 +268,7 @@ class ScheduledTasksController extends Controller
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function scheduled_tasks_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
|
||||
public function scheduled_tasks_by_application_uuid(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
@@ -317,7 +347,7 @@ class ScheduledTasksController extends Controller
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function create_scheduled_task_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
|
||||
public function create_scheduled_task_by_application_uuid(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
@@ -404,7 +434,7 @@ class ScheduledTasksController extends Controller
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function update_scheduled_task_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
|
||||
public function update_scheduled_task_by_application_uuid(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
@@ -474,7 +504,7 @@ class ScheduledTasksController extends Controller
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function delete_scheduled_task_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
|
||||
public function delete_scheduled_task_by_application_uuid(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
@@ -542,7 +572,7 @@ class ScheduledTasksController extends Controller
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function executions_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
|
||||
public function executions_by_application_uuid(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
@@ -601,7 +631,7 @@ class ScheduledTasksController extends Controller
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function scheduled_tasks_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
|
||||
public function scheduled_tasks_by_service_uuid(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
@@ -680,7 +710,7 @@ class ScheduledTasksController extends Controller
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function create_scheduled_task_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
|
||||
public function create_scheduled_task_by_service_uuid(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
@@ -767,7 +797,7 @@ class ScheduledTasksController extends Controller
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function update_scheduled_task_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
|
||||
public function update_scheduled_task_by_service_uuid(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
@@ -837,7 +867,7 @@ class ScheduledTasksController extends Controller
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function delete_scheduled_task_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
|
||||
public function delete_scheduled_task_by_service_uuid(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
@@ -905,7 +935,7 @@ class ScheduledTasksController extends Controller
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function executions_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
|
||||
public function executions_by_service_uuid(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
|
||||
@@ -16,6 +16,10 @@ class SecurityController extends Controller
|
||||
$team->makeHidden([
|
||||
'private_key',
|
||||
]);
|
||||
} else {
|
||||
$team->makeVisible([
|
||||
'private_key',
|
||||
]);
|
||||
}
|
||||
|
||||
return serializeApiResponse($team);
|
||||
@@ -110,6 +114,7 @@ class SecurityController extends Controller
|
||||
'message' => 'Private Key not found.',
|
||||
], 404);
|
||||
}
|
||||
$this->authorize('view', $key);
|
||||
|
||||
return response()->json($this->removeSensitiveData($key));
|
||||
}
|
||||
@@ -176,6 +181,7 @@ class SecurityController extends Controller
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
$this->authorize('create', [PrivateKey::class]);
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
@@ -232,6 +238,13 @@ class SecurityController extends Controller
|
||||
'private_key' => $request->private_key,
|
||||
]);
|
||||
|
||||
auditLog('api.private_key.created', [
|
||||
'team_id' => $teamId,
|
||||
'private_key_uuid' => $key->uuid,
|
||||
'private_key_name' => $key->name,
|
||||
'fingerprint' => $fingerPrint,
|
||||
]);
|
||||
|
||||
return response()->json(serializeApiResponse([
|
||||
'uuid' => $key->uuid,
|
||||
]))->setStatusCode(201);
|
||||
@@ -331,8 +344,16 @@ class SecurityController extends Controller
|
||||
'message' => 'Private Key not found.',
|
||||
], 404);
|
||||
}
|
||||
$this->authorize('update', $foundKey);
|
||||
$foundKey->update($request->only($allowedFields));
|
||||
|
||||
auditLog('api.private_key.updated', [
|
||||
'team_id' => $teamId,
|
||||
'private_key_uuid' => $foundKey->uuid,
|
||||
'private_key_name' => $foundKey->name,
|
||||
'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
|
||||
]);
|
||||
|
||||
return response()->json(serializeApiResponse([
|
||||
'uuid' => $foundKey->uuid,
|
||||
]))->setStatusCode(201);
|
||||
@@ -407,6 +428,7 @@ class SecurityController extends Controller
|
||||
if (is_null($key)) {
|
||||
return response()->json(['message' => 'Private Key not found.'], 404);
|
||||
}
|
||||
$this->authorize('delete', $key);
|
||||
|
||||
if ($key->isInUse()) {
|
||||
return response()->json([
|
||||
@@ -415,8 +437,16 @@ class SecurityController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
$keyUuid = $key->uuid;
|
||||
$keyName = $key->name;
|
||||
$key->forceDelete();
|
||||
|
||||
auditLog('api.private_key.deleted', [
|
||||
'team_id' => $teamId,
|
||||
'private_key_uuid' => $keyUuid,
|
||||
'private_key_name' => $keyName,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Private Key deleted.',
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\PushServerUpdateJob;
|
||||
use App\Models\Server;
|
||||
use Exception;
|
||||
use Illuminate\Contracts\Cache\LockTimeoutException;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
class SentinelController extends Controller
|
||||
{
|
||||
/**
|
||||
* Handle a Sentinel agent metrics push.
|
||||
*
|
||||
* Sentinel pushes its full container list on a fixed interval (default 60s),
|
||||
* even when nothing changed. To avoid dispatching one PushServerUpdateJob per
|
||||
* server per minute, the job is only dispatched when the container state hash
|
||||
* changes, or when the force window has elapsed.
|
||||
*/
|
||||
public function push(Request $request)
|
||||
{
|
||||
$token = $request->header('Authorization');
|
||||
if (! $token) {
|
||||
auditLogWebhookFailure('sentinel', 'token_missing');
|
||||
|
||||
return response()->json(['message' => 'Unauthorized'], 401);
|
||||
}
|
||||
$naked_token = str_replace('Bearer ', '', $token);
|
||||
try {
|
||||
$decrypted = decrypt($naked_token);
|
||||
$decrypted_token = json_decode($decrypted, true);
|
||||
} catch (Exception $e) {
|
||||
auditLogWebhookFailure('sentinel', 'decrypt_failed');
|
||||
|
||||
return response()->json(['message' => 'Invalid token'], 401);
|
||||
}
|
||||
$server_uuid = data_get($decrypted_token, 'server_uuid');
|
||||
if (! $server_uuid) {
|
||||
auditLogWebhookFailure('sentinel', 'invalid_token_payload');
|
||||
|
||||
return response()->json(['message' => 'Invalid token'], 401);
|
||||
}
|
||||
$server = Server::where('uuid', $server_uuid)->first();
|
||||
if (! $server) {
|
||||
auditLogWebhookFailure('sentinel', 'server_not_found', [
|
||||
'server_uuid' => $server_uuid,
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Server not found'], 404);
|
||||
}
|
||||
|
||||
if (isCloud() && data_get($server->team->subscription, 'stripe_invoice_paid', false) === false && $server->team->id !== 0) {
|
||||
auditLogWebhookFailure('sentinel', 'subscription_unpaid', [
|
||||
'server_uuid' => $server->uuid,
|
||||
'team_id' => $server->team_id,
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Unauthorized'], 401);
|
||||
}
|
||||
|
||||
if ($server->isFunctional() === false) {
|
||||
auditLogWebhookFailure('sentinel', 'server_not_functional', [
|
||||
'server_uuid' => $server->uuid,
|
||||
'team_id' => $server->team_id,
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Server is not functional'], 401);
|
||||
}
|
||||
|
||||
if ($server->settings->sentinel_token !== $naked_token) {
|
||||
auditLogWebhookFailure('sentinel', 'token_mismatch', [
|
||||
'server_uuid' => $server->uuid,
|
||||
'team_id' => $server->team_id,
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Unauthorized'], 401);
|
||||
}
|
||||
$validator = Validator::make($request->all(), [
|
||||
'containers' => ['present', 'array'],
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json(serializeApiResponse([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $validator->errors(),
|
||||
]), 422);
|
||||
}
|
||||
|
||||
$data = $request->all();
|
||||
|
||||
// Heartbeat MUST update on every push — drives isSentinelLive() and SSH-check skipping.
|
||||
$server->sentinelHeartbeat();
|
||||
|
||||
if ($this->shouldDispatchUpdate($server, $data)) {
|
||||
PushServerUpdateJob::dispatch($server, $data);
|
||||
|
||||
auditLog('sentinel.metrics_pushed', [
|
||||
'server_uuid' => $server->uuid,
|
||||
'team_id' => $server->team_id,
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json(['message' => 'ok'], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether PushServerUpdateJob should be dispatched for this push.
|
||||
*
|
||||
* Dispatches when: first push (no cached hash), the container state changed,
|
||||
* or the force window elapsed.
|
||||
*/
|
||||
private function shouldDispatchUpdate(Server $server, array $data): bool
|
||||
{
|
||||
$hash = $this->containerStateHash($data);
|
||||
$hashKey = "sentinel:push-hash:{$server->id}";
|
||||
$forceKey = "sentinel:push-force:{$server->id}";
|
||||
$lockKey = "sentinel:push-lock:{$server->id}";
|
||||
|
||||
try {
|
||||
return Cache::lock($lockKey, 10)->block(5, function () use ($hashKey, $forceKey, $hash): bool {
|
||||
$cachedHash = Cache::get($hashKey);
|
||||
$forceActive = Cache::has($forceKey);
|
||||
|
||||
$shouldDispatch = $cachedHash === null || $cachedHash !== $hash || ! $forceActive;
|
||||
|
||||
if ($shouldDispatch) {
|
||||
// Day-long TTL bounds memory if a server stops pushing entirely.
|
||||
Cache::put($hashKey, $hash, now()->addDay());
|
||||
Cache::put($forceKey, true, config('constants.sentinel.push_force_interval_seconds', 300));
|
||||
}
|
||||
|
||||
return $shouldDispatch;
|
||||
});
|
||||
} catch (LockTimeoutException) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a stable hash of container state.
|
||||
*
|
||||
* Covers [name, state] only — metrics, filesystem_usage_root, and
|
||||
* health_status are excluded on purpose. Disk % churns constantly, and
|
||||
* health checks can flap between starting/healthy/unhealthy while the
|
||||
* container lifecycle state remains unchanged. Both would otherwise defeat
|
||||
* the hash and dispatch DB-heavy PushServerUpdateJob instances too often.
|
||||
* The force window still refreshes full state periodically. Sorted by name
|
||||
* so container ordering from Sentinel does not affect the hash.
|
||||
*/
|
||||
private function containerStateHash(array $data): string
|
||||
{
|
||||
$containers = collect(data_get($data, 'containers', []))
|
||||
->map(fn ($c) => [
|
||||
'name' => data_get($c, 'name'),
|
||||
'state' => data_get($c, 'state'),
|
||||
])
|
||||
->sortBy('name')
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return hash('xxh128', json_encode($containers));
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@ use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server as ModelsServer;
|
||||
use App\Rules\ValidServerIp;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Stringable;
|
||||
@@ -21,9 +23,14 @@ class ServersController extends Controller
|
||||
{
|
||||
private function removeSensitiveDataFromSettings($settings)
|
||||
{
|
||||
if (request()->attributes->get('can_read_sensitive', false) === false) {
|
||||
$settings = $settings->makeHidden([
|
||||
if (request()->attributes->get('can_read_sensitive', false) === true) {
|
||||
$settings = $settings->makeVisible([
|
||||
'sentinel_token',
|
||||
'sentinel_custom_url',
|
||||
'logdrain_newrelic_license_key',
|
||||
'logdrain_axiom_api_key',
|
||||
'logdrain_custom_config',
|
||||
'logdrain_custom_config_parser',
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -35,8 +42,11 @@ class ServersController extends Controller
|
||||
$server->makeHidden([
|
||||
'id',
|
||||
]);
|
||||
if (request()->attributes->get('can_read_sensitive', false) === false) {
|
||||
// Do nothing
|
||||
if (request()->attributes->get('can_read_sensitive', false) === true) {
|
||||
$server->makeVisible([
|
||||
'logdrain_axiom_api_key',
|
||||
'logdrain_newrelic_license_key',
|
||||
]);
|
||||
}
|
||||
|
||||
return serializeApiResponse($server);
|
||||
@@ -146,6 +156,7 @@ class ServersController extends Controller
|
||||
if (is_null($server)) {
|
||||
return response()->json(['message' => 'Server not found.'], 404);
|
||||
}
|
||||
$this->authorize('view', $server);
|
||||
if ($with_resources) {
|
||||
$server['resources'] = $server->definedResources()->map(function ($resource) {
|
||||
$payload = [
|
||||
@@ -475,9 +486,10 @@ class ServersController extends Controller
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
$this->authorize('create', [ModelsServer::class]);
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
$validator = customApiValidator($request->all(), [
|
||||
@@ -486,10 +498,12 @@ class ServersController extends Controller
|
||||
'ip' => ['string', 'required', new ValidServerIp],
|
||||
'port' => 'integer|nullable|between:1,65535',
|
||||
'private_key_uuid' => 'string|required',
|
||||
'user' => ['string', 'nullable', 'regex:/^[a-zA-Z0-9_-]+$/'],
|
||||
'user' => ValidationPatterns::serverUsernameRules(required: false),
|
||||
'is_build_server' => 'boolean|nullable',
|
||||
'instant_validate' => 'boolean|nullable',
|
||||
'proxy_type' => 'string|nullable',
|
||||
], [
|
||||
...ValidationPatterns::serverUsernameMessages(),
|
||||
]);
|
||||
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
@@ -564,6 +578,14 @@ class ServersController extends Controller
|
||||
ValidateServer::dispatch($server);
|
||||
}
|
||||
|
||||
auditLog('api.server.created', [
|
||||
'team_id' => $teamId,
|
||||
'server_uuid' => $server->uuid,
|
||||
'server_name' => $server->name,
|
||||
'ip' => $server->ip,
|
||||
'is_build_server' => (bool) $request->is_build_server,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'uuid' => $server->uuid,
|
||||
])->setStatusCode(201);
|
||||
@@ -603,6 +625,7 @@ class ServersController extends Controller
|
||||
'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.'],
|
||||
'connection_timeout' => ['type' => 'integer', 'description' => 'SSH connection timeout in seconds (1-300). Default: 10.'],
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -639,7 +662,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', 'concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency'];
|
||||
$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', 'connection_timeout'];
|
||||
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
@@ -647,7 +670,7 @@ class ServersController extends Controller
|
||||
}
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
$validator = customApiValidator($request->all(), [
|
||||
@@ -656,7 +679,7 @@ class ServersController extends Controller
|
||||
'ip' => ['string', 'nullable', new ValidServerIp],
|
||||
'port' => 'integer|nullable|between:1,65535',
|
||||
'private_key_uuid' => 'string|nullable',
|
||||
'user' => ['string', 'nullable', 'regex:/^[a-zA-Z0-9_-]+$/'],
|
||||
'user' => ValidationPatterns::serverUsernameRules(required: false),
|
||||
'is_build_server' => 'boolean|nullable',
|
||||
'instant_validate' => 'boolean|nullable',
|
||||
'proxy_type' => 'string|nullable',
|
||||
@@ -665,6 +688,9 @@ class ServersController extends Controller
|
||||
'deployment_queue_limit' => 'integer|min:1',
|
||||
'server_disk_usage_notification_threshold' => 'integer|min:1|max:100',
|
||||
'server_disk_usage_check_frequency' => 'string',
|
||||
'connection_timeout' => 'integer|min:1|max:300',
|
||||
], [
|
||||
...ValidationPatterns::serverUsernameMessages(),
|
||||
]);
|
||||
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
@@ -685,21 +711,22 @@ class ServersController extends Controller
|
||||
if (! $server) {
|
||||
return response()->json(['message' => 'Server not found.'], 404);
|
||||
}
|
||||
$this->authorize('update', $server);
|
||||
if ($request->proxy_type) {
|
||||
$validProxyTypes = collect(ProxyTypes::cases())->map(function ($proxyType) {
|
||||
return str($proxyType->value)->lower();
|
||||
});
|
||||
if ($validProxyTypes->contains(str($request->proxy_type)->lower())) {
|
||||
$server->changeProxy($request->proxy_type, async: true);
|
||||
} else {
|
||||
if (! $validProxyTypes->contains(str($request->proxy_type)->lower())) {
|
||||
return response()->json(['message' => 'Invalid proxy type.'], 422);
|
||||
}
|
||||
}
|
||||
$server->update($request->only(['name', 'description', 'ip', 'port', 'user']));
|
||||
if ($request->is_build_server) {
|
||||
$server->settings()->update([
|
||||
'is_build_server' => $request->is_build_server,
|
||||
]);
|
||||
$updateFields = $request->only(['name', 'description', 'ip', 'port', 'user']);
|
||||
if ($request->filled('private_key_uuid')) {
|
||||
$privateKey = PrivateKey::whereTeamId($teamId)->whereUuid($request->private_key_uuid)->first();
|
||||
if (! $privateKey) {
|
||||
return response()->json(['message' => 'Private key not found.'], 404);
|
||||
}
|
||||
$updateFields['private_key_id'] = $privateKey->id;
|
||||
}
|
||||
|
||||
if ($request->has('server_disk_usage_check_frequency') && ! validate_cron_expression($request->server_disk_usage_check_frequency)) {
|
||||
@@ -709,15 +736,33 @@ class ServersController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
$advancedSettings = $request->only(['concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency']);
|
||||
$server->update($updateFields);
|
||||
if ($request->has('is_build_server')) {
|
||||
$server->settings()->update([
|
||||
'is_build_server' => $request->boolean('is_build_server'),
|
||||
]);
|
||||
}
|
||||
|
||||
$advancedSettings = $request->only(['concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency', 'connection_timeout']);
|
||||
if (! empty($advancedSettings)) {
|
||||
$server->settings()->update(array_filter($advancedSettings, fn ($value) => ! is_null($value)));
|
||||
}
|
||||
|
||||
if ($request->proxy_type) {
|
||||
$server->changeProxy($request->proxy_type, async: true);
|
||||
}
|
||||
|
||||
if ($request->instant_validate) {
|
||||
ValidateServer::dispatch($server);
|
||||
}
|
||||
|
||||
auditLog('api.server.updated', [
|
||||
'team_id' => $teamId,
|
||||
'server_uuid' => $server->uuid,
|
||||
'server_name' => $server->name,
|
||||
'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'uuid' => $server->uuid,
|
||||
])->setStatusCode(201);
|
||||
@@ -791,6 +836,7 @@ class ServersController extends Controller
|
||||
if (! $server) {
|
||||
return response()->json(['message' => 'Server not found.'], 404);
|
||||
}
|
||||
$this->authorize('delete', $server);
|
||||
|
||||
$force = filter_var($request->query('force', false), FILTER_VALIDATE_BOOLEAN);
|
||||
|
||||
@@ -807,6 +853,9 @@ class ServersController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
$deletedUuid = $server->uuid;
|
||||
$deletedName = $server->name;
|
||||
$deletedIp = $server->ip;
|
||||
$server->delete();
|
||||
DeleteServer::dispatch(
|
||||
$server->id,
|
||||
@@ -816,6 +865,14 @@ class ServersController extends Controller
|
||||
$server->team_id
|
||||
);
|
||||
|
||||
auditLog('api.server.deleted', [
|
||||
'team_id' => $teamId,
|
||||
'server_uuid' => $deletedUuid,
|
||||
'server_name' => $deletedName,
|
||||
'ip' => $deletedIp,
|
||||
'force' => $force,
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Server deleted.']);
|
||||
}
|
||||
|
||||
@@ -879,8 +936,15 @@ class ServersController extends Controller
|
||||
if (! $server) {
|
||||
return response()->json(['message' => 'Server not found.'], 404);
|
||||
}
|
||||
$this->authorize('update', $server);
|
||||
ValidateServer::dispatch($server);
|
||||
|
||||
auditLog('api.server.validated', [
|
||||
'team_id' => $teamId,
|
||||
'server_uuid' => $server->uuid,
|
||||
'server_name' => $server->name,
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Validation started.'], 201);
|
||||
}
|
||||
}
|
||||
@@ -14,34 +14,102 @@ use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
class ServicesController extends Controller
|
||||
{
|
||||
use Concerns\HandlesTagsApi;
|
||||
|
||||
protected function findTaggableResource(string $uuid, int|string $teamId): mixed
|
||||
{
|
||||
return Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($uuid)->first();
|
||||
}
|
||||
|
||||
protected function tagResourceNotFoundMessage(): string
|
||||
{
|
||||
return 'Service not found.';
|
||||
}
|
||||
|
||||
private function exposeFileStorageContentIfAllowed(LocalFileVolume|LocalPersistentVolume $storage): LocalFileVolume|LocalPersistentVolume
|
||||
{
|
||||
if (request()->attributes->get('can_read_sensitive', false) === true) {
|
||||
$storage->makeVisible(['content']);
|
||||
}
|
||||
|
||||
return $storage;
|
||||
}
|
||||
|
||||
private function removeSensitiveData($service)
|
||||
{
|
||||
if ($service instanceof Collection) {
|
||||
return $service->map(fn (Service $item) => $this->removeSensitiveData($item));
|
||||
}
|
||||
|
||||
$service->makeHidden([
|
||||
'id',
|
||||
'resourceable',
|
||||
'resourceable_id',
|
||||
'resourceable_type',
|
||||
]);
|
||||
if (request()->attributes->get('can_read_sensitive', false) === false) {
|
||||
$service->makeHidden([
|
||||
if (request()->attributes->get('can_read_sensitive', false) === true) {
|
||||
$service->makeVisible([
|
||||
'docker_compose_raw',
|
||||
'docker_compose',
|
||||
'value',
|
||||
'real_value',
|
||||
]);
|
||||
$this->exposeNestedServerSecrets($service);
|
||||
}
|
||||
|
||||
if ($service->is_shown_once ?? false) {
|
||||
$service->makeHidden(['value', 'real_value']);
|
||||
}
|
||||
|
||||
return serializeApiResponse($service);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expose sensitive fields on eager-loaded nested Server + ServerSetting
|
||||
* relations for callers with the `read:sensitive` or `root` token ability.
|
||||
* Handles both single models and Eloquent Collections (the listing endpoint
|
||||
* passes a Collection of Services per project to removeSensitiveData()).
|
||||
*/
|
||||
private function exposeNestedServerSecrets(Model|Collection $model): void
|
||||
{
|
||||
if ($model instanceof Collection) {
|
||||
foreach ($model as $item) {
|
||||
$this->exposeNestedServerSecrets($item);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
$server = $model->destination?->server ?? $model->server ?? null;
|
||||
if (! $server) {
|
||||
return;
|
||||
}
|
||||
$server->makeVisible([
|
||||
'logdrain_axiom_api_key',
|
||||
'logdrain_newrelic_license_key',
|
||||
]);
|
||||
$settings = $server->settings ?? null;
|
||||
if ($settings) {
|
||||
$settings->makeVisible([
|
||||
'sentinel_token',
|
||||
'sentinel_custom_url',
|
||||
'logdrain_newrelic_license_key',
|
||||
'logdrain_axiom_api_key',
|
||||
'logdrain_custom_config',
|
||||
'logdrain_custom_config_parser',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function applyServiceUrls(Service $service, array $urlsArray, string $teamId, bool $forceDomainOverride = false): ?array
|
||||
{
|
||||
$errors = [];
|
||||
@@ -56,19 +124,10 @@ class ServicesController extends Controller
|
||||
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;
|
||||
});
|
||||
$errors = ValidationPatterns::validateApplicationDomains($urls->implode(','));
|
||||
$urls = collect(ValidationPatterns::applicationDomainList(
|
||||
ValidationPatterns::normalizeApplicationDomains($urls->implode(','))
|
||||
));
|
||||
|
||||
$duplicates = $urls->duplicates()->unique()->values();
|
||||
if ($duplicates->isNotEmpty() && ! $forceDomainOverride) {
|
||||
@@ -97,10 +156,10 @@ class ServicesController extends Controller
|
||||
}
|
||||
|
||||
if (filled($containerUrls)) {
|
||||
$containerUrls = str($containerUrls)->replaceStart(',', '')->replaceEnd(',', '')->trim();
|
||||
$containerUrls = str($containerUrls)->explode(',')->map(fn ($url) => str(trim($url))->lower());
|
||||
$containerUrls = ValidationPatterns::normalizeApplicationDomains($containerUrls);
|
||||
$containerUrlCollection = collect(ValidationPatterns::applicationDomainList($containerUrls));
|
||||
|
||||
$result = checkIfDomainIsAlreadyUsedViaAPI($containerUrls, $teamId, $application->uuid);
|
||||
$result = checkIfDomainIsAlreadyUsedViaAPI($containerUrlCollection, $teamId, $application->uuid);
|
||||
if (isset($result['error'])) {
|
||||
$errors[] = $result['error'];
|
||||
|
||||
@@ -112,8 +171,6 @@ class ServicesController extends Controller
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$containerUrls = $containerUrls->filter(fn ($u) => filled($u))->unique()->implode(',');
|
||||
} else {
|
||||
$containerUrls = null;
|
||||
}
|
||||
@@ -177,8 +234,12 @@ class ServicesController extends Controller
|
||||
}
|
||||
$projects = Project::where('team_id', $teamId)->get();
|
||||
$services = collect();
|
||||
$serviceRelations = $request->attributes->get('can_read_sensitive', false) === true
|
||||
? ['destination.server.settings']
|
||||
: [];
|
||||
|
||||
foreach ($projects as $project) {
|
||||
$services->push($project->services()->get());
|
||||
$services->push($project->services()->with($serviceRelations)->get());
|
||||
}
|
||||
foreach ($services as $service) {
|
||||
$service = $this->removeSensitiveData($service);
|
||||
@@ -221,12 +282,13 @@ 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.'],
|
||||
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the service.'],
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -293,7 +355,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', 'is_container_label_escape_enabled'];
|
||||
$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', 'tags'];
|
||||
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
@@ -323,6 +385,8 @@ class ServicesController extends Controller
|
||||
'urls.*.url' => 'string|nullable',
|
||||
'force_domain_override' => 'boolean',
|
||||
'is_container_label_escape_enabled' => 'boolean',
|
||||
'tags' => 'array|nullable',
|
||||
'tags.*' => 'string|min:2',
|
||||
];
|
||||
$validationMessages = [
|
||||
'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.',
|
||||
@@ -344,6 +408,11 @@ class ServicesController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
$return = $this->validateTagsParameter($request);
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
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.',
|
||||
@@ -432,7 +501,7 @@ class ServicesController extends Controller
|
||||
if (in_array($oneClickServiceName, NEEDS_TO_CONNECT_TO_PREDEFINED_NETWORK)) {
|
||||
data_set($servicePayload, 'connect_to_docker_network', true);
|
||||
}
|
||||
$service = Service::forceCreate($servicePayload);
|
||||
$service = Service::create($servicePayload);
|
||||
$service->name = $request->name ?? "$oneClickServiceName-".$service->uuid;
|
||||
$service->description = $request->description;
|
||||
if ($request->has('is_container_label_escape_enabled')) {
|
||||
@@ -482,10 +551,22 @@ class ServicesController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
if ($request->has('tags')) {
|
||||
$this->attachTagsToResource($service, $request->tags, $teamId);
|
||||
}
|
||||
|
||||
if ($instantDeploy) {
|
||||
StartService::dispatch($service);
|
||||
}
|
||||
|
||||
auditLog('api.service.created', [
|
||||
'team_id' => $teamId,
|
||||
'service_uuid' => $service->uuid,
|
||||
'service_name' => $service->name,
|
||||
'service_type' => $oneClickServiceName ?? null,
|
||||
'instant_deploy' => (bool) $instantDeploy,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'uuid' => $service->uuid,
|
||||
'domains' => $service->applications()->pluck('fqdn')->filter()->sort()->values(),
|
||||
@@ -494,7 +575,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', 'is_container_label_escape_enabled'];
|
||||
$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', 'tags'];
|
||||
|
||||
$validationRules = [
|
||||
'project_uuid' => 'string|required',
|
||||
@@ -513,6 +594,8 @@ class ServicesController extends Controller
|
||||
'urls.*.url' => 'string|nullable',
|
||||
'force_domain_override' => 'boolean',
|
||||
'is_container_label_escape_enabled' => 'boolean',
|
||||
'tags' => 'array|nullable',
|
||||
'tags.*' => 'string|min:2',
|
||||
];
|
||||
$validationMessages = [
|
||||
'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.',
|
||||
@@ -646,10 +729,22 @@ class ServicesController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
if ($request->has('tags')) {
|
||||
$this->attachTagsToResource($service, $request->tags, $teamId);
|
||||
}
|
||||
|
||||
if ($instantDeploy) {
|
||||
StartService::dispatch($service);
|
||||
}
|
||||
|
||||
auditLog('api.service.created', [
|
||||
'team_id' => $teamId,
|
||||
'service_uuid' => $service->uuid,
|
||||
'service_name' => $service->name,
|
||||
'service_type' => 'docker_compose',
|
||||
'instant_deploy' => (bool) $instantDeploy,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'uuid' => $service->uuid,
|
||||
'domains' => $service->applications()->pluck('fqdn')->filter()->sort()->values(),
|
||||
@@ -717,11 +812,135 @@ class ServicesController extends Controller
|
||||
|
||||
$this->authorize('view', $service);
|
||||
|
||||
$service = $service->load(['applications', 'databases']);
|
||||
$serviceRelations = ['applications', 'databases'];
|
||||
if ($request->attributes->get('can_read_sensitive', false) === true) {
|
||||
$serviceRelations[] = 'destination.server.settings';
|
||||
}
|
||||
|
||||
$service = $service->load($serviceRelations);
|
||||
|
||||
return response()->json($this->removeSensitiveData($service));
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Get service logs.',
|
||||
description: 'Get logs for a specific service sub-resource by service UUID. The `sub_service_name` query parameter must match the `name` field of one of the service applications or databases returned by `GET /services/{uuid}`.',
|
||||
path: '/services/{uuid}/logs',
|
||||
operationId: 'get-service-logs-by-uuid',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Services'],
|
||||
parameters: [
|
||||
new OA\Parameter(
|
||||
name: 'uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the service.',
|
||||
required: true,
|
||||
schema: new OA\Schema(
|
||||
type: 'string',
|
||||
format: 'uuid',
|
||||
)
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'sub_service_name',
|
||||
in: 'query',
|
||||
description: 'Sub-service name from `GET /services/{uuid}` under `applications[].name` or `databases[].name`. Do not use `human_name` or the Docker container name with the service UUID suffix.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string', example: 'appwrite-console'),
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'lines',
|
||||
in: 'query',
|
||||
description: 'Number of lines to show from the end of the logs.',
|
||||
required: false,
|
||||
schema: new OA\Schema(
|
||||
type: 'integer',
|
||||
format: 'int32',
|
||||
default: 100,
|
||||
)
|
||||
),
|
||||
new OA\Parameter(
|
||||
name: 'show_timestamps',
|
||||
in: 'query',
|
||||
description: 'Show timestamps in the logs.',
|
||||
required: false,
|
||||
schema: new OA\Schema(type: 'boolean', default: false),
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Get service logs by UUID.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'object',
|
||||
properties: [
|
||||
'logs' => ['type' => 'string'],
|
||||
]
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(
|
||||
response: 401,
|
||||
ref: '#/components/responses/401',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 400,
|
||||
ref: '#/components/responses/400',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
]
|
||||
)]
|
||||
public function logs_by_uuid(Request $request)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
$uuid = $request->route('uuid');
|
||||
if (! $uuid) {
|
||||
return response()->json(['message' => 'UUID is required.'], 400);
|
||||
}
|
||||
$subServiceName = $request->query->get('sub_service_name');
|
||||
if (! $subServiceName) {
|
||||
return response()->json(['message' => 'Sub service name is required.'], 400);
|
||||
}
|
||||
$service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first();
|
||||
if (! $service) {
|
||||
return response()->json(['message' => 'Service not found.'], 404);
|
||||
}
|
||||
|
||||
$name = "{$subServiceName}-{$service->uuid}";
|
||||
$containers = getCurrentServiceSubContainerStatus($service->destination->server, $service->id, $name);
|
||||
$container = $containers->first();
|
||||
|
||||
if (! $container) {
|
||||
return response()->json(['message' => 'Container not found.'], 404);
|
||||
}
|
||||
|
||||
$status = getContainerStatus($service->destination->server, $container['Names']);
|
||||
if ($status !== 'running') {
|
||||
return response()->json([
|
||||
'message' => 'Container is not running.',
|
||||
], 400);
|
||||
}
|
||||
|
||||
$lines = normalizeLogLines($request->query('lines'));
|
||||
$showTimestamps = parseLogTimestampFlag($request->query('show_timestamps'));
|
||||
$logs = getContainerLogs($service->destination->server, $container['ID'], $lines, $showTimestamps);
|
||||
|
||||
return response()->json([
|
||||
'logs' => $logs,
|
||||
]);
|
||||
}
|
||||
|
||||
#[OA\Delete(
|
||||
summary: 'Delete',
|
||||
description: 'Delete service by UUID.',
|
||||
@@ -792,6 +1011,12 @@ class ServicesController extends Controller
|
||||
dockerCleanup: $request->boolean('docker_cleanup', true)
|
||||
);
|
||||
|
||||
auditLog('api.service.deleted', [
|
||||
'team_id' => $teamId,
|
||||
'service_uuid' => $service->uuid,
|
||||
'service_name' => $service->name,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Service deletion request queued.',
|
||||
]);
|
||||
@@ -843,7 +1068,7 @@ 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").'],
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -1046,6 +1271,13 @@ class ServicesController extends Controller
|
||||
StartService::dispatch($service);
|
||||
}
|
||||
|
||||
auditLog('api.service.updated', [
|
||||
'team_id' => $teamId,
|
||||
'service_uuid' => $service->uuid,
|
||||
'service_name' => $service->name,
|
||||
'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'uuid' => $service->uuid,
|
||||
'domains' => $service->applications()->pluck('fqdn')->filter()->sort()->values(),
|
||||
@@ -1218,8 +1450,12 @@ class ServicesController extends Controller
|
||||
|
||||
$this->authorize('manageEnvironment', $service);
|
||||
|
||||
if ($request->has('key')) {
|
||||
$request->merge(['key' => ValidationPatterns::normalizeEnvironmentVariableKey((string) $request->key)]);
|
||||
}
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'key' => 'string|required',
|
||||
'key' => ValidationPatterns::environmentVariableKeyRules(),
|
||||
'value' => 'string|nullable',
|
||||
'is_literal' => 'boolean',
|
||||
'is_multiline' => 'boolean',
|
||||
@@ -1255,6 +1491,13 @@ class ServicesController extends Controller
|
||||
}
|
||||
$env->save();
|
||||
|
||||
auditLog('api.service.env_updated', [
|
||||
'team_id' => $teamId,
|
||||
'service_uuid' => $service->uuid,
|
||||
'env_uuid' => $env->uuid,
|
||||
'env_key' => $env->key,
|
||||
]);
|
||||
|
||||
return response()->json($this->removeSensitiveData($env))->setStatusCode(201);
|
||||
}
|
||||
|
||||
@@ -1360,8 +1603,12 @@ class ServicesController extends Controller
|
||||
|
||||
$updatedEnvs = collect();
|
||||
foreach ($bulk_data as $item) {
|
||||
if (array_key_exists('key', $item)) {
|
||||
$item['key'] = ValidationPatterns::normalizeEnvironmentVariableKey((string) $item['key']);
|
||||
}
|
||||
|
||||
$validator = customApiValidator($item, [
|
||||
'key' => 'string|required',
|
||||
'key' => ValidationPatterns::environmentVariableKeyRules(),
|
||||
'value' => 'string|nullable',
|
||||
'is_literal' => 'boolean',
|
||||
'is_multiline' => 'boolean',
|
||||
@@ -1384,6 +1631,12 @@ class ServicesController extends Controller
|
||||
$updatedEnvs->push($this->removeSensitiveData($env));
|
||||
}
|
||||
|
||||
auditLog('api.service.env_bulk_upserted', [
|
||||
'team_id' => $teamId,
|
||||
'service_uuid' => $service->uuid,
|
||||
'env_count' => $updatedEnvs->count(),
|
||||
]);
|
||||
|
||||
return response()->json($updatedEnvs)->setStatusCode(201);
|
||||
}
|
||||
|
||||
@@ -1473,8 +1726,12 @@ class ServicesController extends Controller
|
||||
|
||||
$this->authorize('manageEnvironment', $service);
|
||||
|
||||
if ($request->has('key')) {
|
||||
$request->merge(['key' => ValidationPatterns::normalizeEnvironmentVariableKey((string) $request->key)]);
|
||||
}
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'key' => 'string|required',
|
||||
'key' => ValidationPatterns::environmentVariableKeyRules(),
|
||||
'value' => 'string|nullable',
|
||||
'is_literal' => 'boolean',
|
||||
'is_multiline' => 'boolean',
|
||||
@@ -1506,6 +1763,13 @@ class ServicesController extends Controller
|
||||
'comment' => $request->comment ?? null,
|
||||
]);
|
||||
|
||||
auditLog('api.service.env_created', [
|
||||
'team_id' => $teamId,
|
||||
'service_uuid' => $service->uuid,
|
||||
'env_uuid' => $env->uuid,
|
||||
'env_key' => $env->key,
|
||||
]);
|
||||
|
||||
return response()->json($this->removeSensitiveData($env))->setStatusCode(201);
|
||||
}
|
||||
|
||||
@@ -1591,8 +1855,17 @@ class ServicesController extends Controller
|
||||
return response()->json(['message' => 'Environment variable not found.'], 404);
|
||||
}
|
||||
|
||||
$envKey = $env->key;
|
||||
$envUuid = $env->uuid;
|
||||
$env->forceDelete();
|
||||
|
||||
auditLog('api.service.env_deleted', [
|
||||
'team_id' => $teamId,
|
||||
'service_uuid' => $service->uuid,
|
||||
'env_uuid' => $envUuid,
|
||||
'env_key' => $envKey,
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Environment variable deleted.']);
|
||||
}
|
||||
|
||||
@@ -1668,6 +1941,12 @@ class ServicesController extends Controller
|
||||
}
|
||||
StartService::dispatch($service);
|
||||
|
||||
auditLog('api.service.deployed', [
|
||||
'team_id' => $teamId,
|
||||
'service_uuid' => $service->uuid,
|
||||
'service_name' => $service->name,
|
||||
]);
|
||||
|
||||
return response()->json(
|
||||
[
|
||||
'message' => 'Service starting request queued.',
|
||||
@@ -1759,6 +2038,13 @@ class ServicesController extends Controller
|
||||
$dockerCleanup = $request->boolean('docker_cleanup', true);
|
||||
StopService::dispatch($service, false, $dockerCleanup);
|
||||
|
||||
auditLog('api.service.stopped', [
|
||||
'team_id' => $teamId,
|
||||
'service_uuid' => $service->uuid,
|
||||
'service_name' => $service->name,
|
||||
'docker_cleanup' => $dockerCleanup,
|
||||
]);
|
||||
|
||||
return response()->json(
|
||||
[
|
||||
'message' => 'Service stopping request queued.',
|
||||
@@ -1846,6 +2132,13 @@ class ServicesController extends Controller
|
||||
$pullLatest = $request->boolean('latest');
|
||||
RestartService::dispatch($service, $pullLatest);
|
||||
|
||||
auditLog('api.service.restarted', [
|
||||
'team_id' => $teamId,
|
||||
'service_uuid' => $service->uuid,
|
||||
'service_name' => $service->name,
|
||||
'pull_latest' => $pullLatest,
|
||||
]);
|
||||
|
||||
return response()->json(
|
||||
[
|
||||
'message' => 'Service restarting request queued.',
|
||||
@@ -1935,6 +2228,8 @@ class ServicesController extends Controller
|
||||
);
|
||||
}
|
||||
|
||||
$fileStorages->each(fn (LocalFileVolume $storage) => $this->exposeFileStorageContentIfAllowed($storage));
|
||||
|
||||
return response()->json([
|
||||
'persistent_storages' => $persistentStorages->sortBy('id')->values(),
|
||||
'file_storages' => $fileStorages->sortBy('id')->values(),
|
||||
@@ -2018,13 +2313,14 @@ class ServicesController extends Controller
|
||||
'resource_uuid' => 'required|string',
|
||||
'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN],
|
||||
'mount_path' => 'required|string',
|
||||
'host_path' => 'string|nullable',
|
||||
'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
|
||||
'content' => 'string|nullable',
|
||||
'is_directory' => 'boolean',
|
||||
'is_host_file' => 'boolean',
|
||||
'fs_path' => 'string',
|
||||
]);
|
||||
|
||||
$allAllowedFields = ['type', 'resource_uuid', 'name', 'mount_path', 'host_path', 'content', 'is_directory', 'fs_path'];
|
||||
$allAllowedFields = ['type', 'resource_uuid', 'name', 'mount_path', 'host_path', 'content', 'is_directory', 'is_host_file', 'fs_path'];
|
||||
$extraFields = array_diff(array_keys($request->all()), $allAllowedFields);
|
||||
if ($validator->fails() || ! empty($extraFields)) {
|
||||
$errors = $validator->errors();
|
||||
@@ -2056,7 +2352,7 @@ class ServicesController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
$typeSpecificInvalidFields = array_intersect(['content', 'is_directory', 'fs_path'], array_keys($request->all()));
|
||||
$typeSpecificInvalidFields = array_intersect(['content', 'is_directory', 'is_host_file', 'fs_path'], array_keys($request->all()));
|
||||
if (! empty($typeSpecificInvalidFields)) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
@@ -2087,6 +2383,14 @@ class ServicesController extends Controller
|
||||
}
|
||||
|
||||
$isDirectory = $request->boolean('is_directory', false);
|
||||
$isHostFile = $request->boolean('is_host_file', false);
|
||||
|
||||
if ($isDirectory && $isHostFile) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['is_host_file' => 'Host file mounts cannot also be directory mounts.'],
|
||||
], 422);
|
||||
}
|
||||
|
||||
if ($isDirectory) {
|
||||
if (! $request->fs_path) {
|
||||
@@ -2109,12 +2413,50 @@ class ServicesController extends Controller
|
||||
'resource_id' => $subResource->id,
|
||||
'resource_type' => get_class($subResource),
|
||||
]);
|
||||
} elseif ($isHostFile) {
|
||||
if (! $request->fs_path) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['fs_path' => 'The fs_path field is required for host file mounts.'],
|
||||
], 422);
|
||||
}
|
||||
|
||||
if ($request->filled('content')) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['content' => 'Content is not valid for host file mounts.'],
|
||||
], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$fsPath = validateHostFileMountPath($request->fs_path, 'host file source path');
|
||||
$mountPath = validateFileMountPath($request->mount_path, 'host file destination path');
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['mount_path' => $e->getMessage()],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$storage = LocalFileVolume::create([
|
||||
'fs_path' => $fsPath,
|
||||
'mount_path' => $mountPath,
|
||||
'content' => null,
|
||||
'is_directory' => false,
|
||||
'is_host_file' => 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;
|
||||
try {
|
||||
$mountPath = validateFileMountPath($request->mount_path, 'file storage path');
|
||||
$fsPath = confineFileMountPath(service_configuration_dir().'/'.$service->uuid, $mountPath, 'file storage path');
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['mount_path' => $e->getMessage()],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$storage = LocalFileVolume::create([
|
||||
'fs_path' => $fsPath,
|
||||
@@ -2126,7 +2468,16 @@ class ServicesController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json($storage, 201);
|
||||
auditLog('api.service.storage_created', [
|
||||
'team_id' => $teamId,
|
||||
'service_uuid' => $service->uuid,
|
||||
'storage_uuid' => $storage->uuid ?? null,
|
||||
'storage_id' => $storage->id,
|
||||
'storage_type' => $request->type,
|
||||
'mount_path' => $storage->mount_path,
|
||||
]);
|
||||
|
||||
return response()->json($this->exposeFileStorageContentIfAllowed($storage), 201);
|
||||
}
|
||||
|
||||
#[OA\Patch(
|
||||
@@ -2227,7 +2578,7 @@ class ServicesController extends Controller
|
||||
'is_preview_suffix_enabled' => 'boolean',
|
||||
'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN],
|
||||
'mount_path' => 'string',
|
||||
'host_path' => 'string|nullable',
|
||||
'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
|
||||
'content' => 'string|nullable',
|
||||
]);
|
||||
|
||||
@@ -2354,7 +2705,16 @@ class ServicesController extends Controller
|
||||
|
||||
$storage->save();
|
||||
|
||||
return response()->json($storage);
|
||||
auditLog('api.service.storage_updated', [
|
||||
'team_id' => $teamId,
|
||||
'service_uuid' => $service->uuid,
|
||||
'storage_uuid' => $storage->uuid ?? null,
|
||||
'storage_id' => $storage->id,
|
||||
'storage_type' => $request->type,
|
||||
'mount_path' => $storage->mount_path ?? null,
|
||||
]);
|
||||
|
||||
return response()->json($this->exposeFileStorageContentIfAllowed($storage));
|
||||
}
|
||||
|
||||
#[OA\Delete(
|
||||
@@ -2454,8 +2814,162 @@ class ServicesController extends Controller
|
||||
$storage->deleteStorageOnServer();
|
||||
}
|
||||
|
||||
$storageType = $storage instanceof LocalFileVolume ? 'file' : 'persistent';
|
||||
$storageMountPath = $storage->mount_path ?? null;
|
||||
$storage->delete();
|
||||
|
||||
auditLog('api.service.storage_deleted', [
|
||||
'team_id' => $teamId,
|
||||
'service_uuid' => $service->uuid,
|
||||
'storage_uuid' => $storageUuid,
|
||||
'storage_type' => $storageType,
|
||||
'mount_path' => $storageMountPath,
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Storage deleted.']);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'List Tags',
|
||||
description: 'List tags for a service by UUID.',
|
||||
path: '/services/{uuid}/tags',
|
||||
operationId: 'list-tags-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: 'List of tags.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'array',
|
||||
items: new OA\Items(ref: '#/components/schemas/Tag')
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
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 tags(Request $request): JsonResponse
|
||||
{
|
||||
return $this->listTags($request);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Create Tag',
|
||||
description: 'Add tag(s) to a service by UUID.',
|
||||
path: '/services/{uuid}/tags',
|
||||
operationId: 'create-tag-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',
|
||||
properties: [
|
||||
'tag_name' => ['type' => 'string', 'description' => 'The tag name (min 2 characters). Required if tag_names is not provided.'],
|
||||
'tag_names' => [
|
||||
'type' => 'array',
|
||||
'items' => new OA\Items(type: 'string'),
|
||||
'description' => 'Array of tag names (each min 2 characters). Required if tag_name is not provided.',
|
||||
],
|
||||
],
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 201,
|
||||
description: 'Tags added successfully.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'array',
|
||||
items: new OA\Items(ref: '#/components/schemas/Tag')
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
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_tag(Request $request): JsonResponse
|
||||
{
|
||||
return $this->createTag($request);
|
||||
}
|
||||
|
||||
#[OA\Delete(
|
||||
summary: 'Delete Tag',
|
||||
description: 'Remove a tag from a service by UUID.',
|
||||
path: '/services/{uuid}/tags/{tag_uuid}',
|
||||
operationId: 'delete-tag-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: 'tag_uuid',
|
||||
in: 'path',
|
||||
description: 'UUID of the tag.',
|
||||
required: true,
|
||||
schema: new OA\Schema(type: 'string')
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'Tag removed.',
|
||||
),
|
||||
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 delete_tag(Request $request): JsonResponse
|
||||
{
|
||||
return $this->deleteTag($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Tag;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class TagsController extends Controller
|
||||
{
|
||||
public static function serializeTag(Tag $tag): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $tag->uuid,
|
||||
'name' => $tag->name,
|
||||
'created_at' => $tag->created_at,
|
||||
'updated_at' => $tag->updated_at,
|
||||
];
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'List',
|
||||
description: 'List all tags for the current team.',
|
||||
path: '/tags',
|
||||
operationId: 'list-tags',
|
||||
security: [
|
||||
['bearerAuth' => []],
|
||||
],
|
||||
tags: ['Tags'],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 200,
|
||||
description: 'All tags for the current team.',
|
||||
content: [
|
||||
new OA\MediaType(
|
||||
mediaType: 'application/json',
|
||||
schema: new OA\Schema(
|
||||
type: 'array',
|
||||
items: new OA\Items(ref: '#/components/schemas/Tag')
|
||||
)
|
||||
),
|
||||
]
|
||||
),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
]
|
||||
)]
|
||||
public function tags(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$tags = Tag::where('team_id', $teamId)->orderBy('name')->get();
|
||||
|
||||
return response()->json($tags->map(self::serializeTag(...)));
|
||||
}
|
||||
}
|
||||
@@ -110,6 +110,7 @@ class TeamController extends Controller
|
||||
if (is_null($team)) {
|
||||
return response()->json(['message' => 'Team not found.'], 404);
|
||||
}
|
||||
$this->authorize('view', $team);
|
||||
$team = $this->removeSensitiveData($team);
|
||||
|
||||
return response()->json(
|
||||
@@ -168,6 +169,7 @@ class TeamController extends Controller
|
||||
if (is_null($team)) {
|
||||
return response()->json(['message' => 'Team not found.'], 404);
|
||||
}
|
||||
$this->authorize('view', $team);
|
||||
$members = $team->members;
|
||||
$members->makeHidden([
|
||||
'pivot',
|
||||
|
||||
Reference in new issue
Block a user