mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
feat(canvas): add interactive resource views and team avatars (#12160)
This commit is contained in:
@@ -121,8 +121,8 @@ trait HandlesTagsApi
|
||||
return response()->json(['message' => 'Tag not found on resource.'], 404);
|
||||
}
|
||||
|
||||
// The tag stays, also without resources.
|
||||
$resource->tags()->detach($tag->id);
|
||||
$tag->deleteIfOrphaned();
|
||||
|
||||
return response()->json(['message' => 'Tag removed.']);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\ResourceLink;
|
||||
use App\Services\EnvironmentCanvas;
|
||||
use Illuminate\Contracts\Validation\Validator as ValidatorContract;
|
||||
use Illuminate\Database\UniqueConstraintViolationException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
/**
|
||||
* Environment canvas: a view of one environment's resources with saved card positions and visual-only links.
|
||||
*/
|
||||
class EnvironmentCanvasController extends Controller
|
||||
{
|
||||
private const MAX_POSITIONS = 1000;
|
||||
|
||||
public function __construct(private EnvironmentCanvas $canvas) {}
|
||||
|
||||
public function show(Request $request): JsonResponse
|
||||
{
|
||||
$environment = $this->findEnvironment($request);
|
||||
if ($environment instanceof JsonResponse) {
|
||||
return $environment;
|
||||
}
|
||||
$this->authorize('view', $environment);
|
||||
|
||||
$user = $request->user();
|
||||
$token = $user->currentAccessToken();
|
||||
$canEdit = $user->isAdminOfTeam((int) $environment->project->team_id)
|
||||
&& ($token->can('root') || $token->can('write'));
|
||||
|
||||
return response()->json($this->canvas->payload(
|
||||
$environment,
|
||||
canReadSensitive: (bool) $request->attributes->get('can_read_sensitive', false),
|
||||
canEdit: $canEdit,
|
||||
));
|
||||
}
|
||||
|
||||
public function updatePositions(Request $request): JsonResponse
|
||||
{
|
||||
$environment = $this->findEnvironment($request);
|
||||
if ($environment instanceof JsonResponse) {
|
||||
return $environment;
|
||||
}
|
||||
$this->authorize('update', $environment);
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
$validator = Validator::make($request->all(), [
|
||||
'positions' => ['present', 'array', 'max:'.self::MAX_POSITIONS],
|
||||
]);
|
||||
if ($response = $this->validationFailedResponse($request, $validator, ['positions'])) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$nodeUuids = $this->canvas->resourceIndex($environment)->keys()->all();
|
||||
$positions = collect($request->input('positions'))->only($nodeUuids)->all();
|
||||
|
||||
$coordinateRule = ['required', 'integer', 'between:-'.EnvironmentCanvas::MAX_COORDINATE.','.EnvironmentCanvas::MAX_COORDINATE];
|
||||
$validator = Validator::make(['positions' => $positions], [
|
||||
'positions.*' => ['required', 'array'],
|
||||
'positions.*.x' => $coordinateRule,
|
||||
'positions.*.y' => $coordinateRule,
|
||||
]);
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$saved = DB::transaction(function () use ($environment, $positions, $nodeUuids) {
|
||||
$locked = Environment::query()->lockForUpdate()->findOrFail($environment->id);
|
||||
$merged = $this->canvas->filterPositions(
|
||||
array_merge($locked->canvas_positions ?? [], $positions),
|
||||
$nodeUuids,
|
||||
);
|
||||
$locked->canvas_positions = $merged;
|
||||
$locked->save();
|
||||
|
||||
return $merged;
|
||||
});
|
||||
|
||||
return response()->json(['positions' => (object) $saved]);
|
||||
}
|
||||
|
||||
public function storeLink(Request $request): JsonResponse
|
||||
{
|
||||
$environment = $this->findEnvironment($request);
|
||||
if ($environment instanceof JsonResponse) {
|
||||
return $environment;
|
||||
}
|
||||
$this->authorize('update', $environment);
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
$validator = Validator::make($request->all(), [
|
||||
'source' => ['required', 'string', 'max:255'],
|
||||
'target' => ['required', 'string', 'max:255', 'different:source'],
|
||||
'label' => ['nullable', 'string', 'max:64'],
|
||||
]);
|
||||
if ($response = $this->validationFailedResponse($request, $validator, ['source', 'target', 'label'])) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$resources = $this->canvas->resourceIndex($environment);
|
||||
$source = $resources->get($request->input('source'));
|
||||
$target = $resources->get($request->input('target'));
|
||||
$errors = [];
|
||||
if (! $source) {
|
||||
$errors['source'] = ['The source must be a resource of this environment.'];
|
||||
}
|
||||
if (! $target) {
|
||||
$errors['target'] = ['The target must be a resource of this environment.'];
|
||||
}
|
||||
if ($errors !== []) {
|
||||
return response()->json(['message' => 'Validation failed.', 'errors' => $errors], 422);
|
||||
}
|
||||
|
||||
$attributes = [
|
||||
'environment_id' => $environment->id,
|
||||
'source_type' => $source['type'],
|
||||
'source_id' => $source['id'],
|
||||
'target_type' => $target['type'],
|
||||
'target_id' => $target['id'],
|
||||
];
|
||||
$duplicate = ['message' => 'Validation failed.', 'errors' => ['target' => ['These resources are already linked.']]];
|
||||
if (ResourceLink::query()->where($attributes)->exists()) {
|
||||
return response()->json($duplicate, 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$link = ResourceLink::create($attributes + [
|
||||
'label' => $this->normalizeLabel($request->input('label')),
|
||||
'created_by' => $request->user()->id,
|
||||
]);
|
||||
} catch (UniqueConstraintViolationException) {
|
||||
return response()->json($duplicate, 422);
|
||||
}
|
||||
|
||||
return response()->json(
|
||||
$this->canvas->linkPayload($link, $request->input('source'), $request->input('target')),
|
||||
201,
|
||||
);
|
||||
}
|
||||
|
||||
public function updateLink(Request $request): JsonResponse
|
||||
{
|
||||
$environment = $this->findEnvironment($request);
|
||||
if ($environment instanceof JsonResponse) {
|
||||
return $environment;
|
||||
}
|
||||
$this->authorize('update', $environment);
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
$validator = Validator::make($request->all(), [
|
||||
'label' => ['present', 'nullable', 'string', 'max:64'],
|
||||
]);
|
||||
if ($response = $this->validationFailedResponse($request, $validator, ['label'])) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$link = $environment->resourceLinks()->whereUuid($request->route('link_uuid'))->first();
|
||||
if (! $link) {
|
||||
return response()->json(['message' => 'Link not found.'], 404);
|
||||
}
|
||||
$resources = $this->canvas->resourceIndex($environment);
|
||||
$uuidOf = fn (string $type, int $id) => $resources->search(fn (array $resource) => $resource['type'] === $type && $resource['id'] === $id);
|
||||
$sourceUuid = $uuidOf($link->source_type, (int) $link->source_id);
|
||||
$targetUuid = $uuidOf($link->target_type, (int) $link->target_id);
|
||||
if ($sourceUuid === false || $targetUuid === false) {
|
||||
return response()->json(['message' => 'Link not found.'], 404);
|
||||
}
|
||||
|
||||
$link->update(['label' => $this->normalizeLabel($request->input('label'))]);
|
||||
|
||||
return response()->json($this->canvas->linkPayload($link, $sourceUuid, $targetUuid));
|
||||
}
|
||||
|
||||
public function destroyLink(Request $request): JsonResponse
|
||||
{
|
||||
$environment = $this->findEnvironment($request);
|
||||
if ($environment instanceof JsonResponse) {
|
||||
return $environment;
|
||||
}
|
||||
$this->authorize('update', $environment);
|
||||
|
||||
$link = $environment->resourceLinks()->whereUuid($request->route('link_uuid'))->first();
|
||||
if (! $link) {
|
||||
return response()->json(['message' => 'Link not found.'], 404);
|
||||
}
|
||||
$link->delete();
|
||||
|
||||
return response()->json(['message' => 'Link deleted.']);
|
||||
}
|
||||
|
||||
/**
|
||||
* The environment of the request, within a project of the token's team.
|
||||
*/
|
||||
private function findEnvironment(Request $request): Environment|JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$project = Project::whereTeamId($teamId)->whereUuid($request->route('uuid'))->first();
|
||||
if (! $project) {
|
||||
return response()->json(['message' => 'Project not found.'], 404);
|
||||
}
|
||||
|
||||
$environmentNameOrUuid = $request->route('environment_name_or_uuid');
|
||||
$environment = $project->environments()->whereName($environmentNameOrUuid)->first()
|
||||
?? $project->environments()->whereUuid($environmentNameOrUuid)->first();
|
||||
if (! $environment) {
|
||||
return response()->json(['message' => 'Environment not found.'], 404);
|
||||
}
|
||||
$environment->setRelation('project', $project);
|
||||
|
||||
return $environment;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $allowedFields
|
||||
*/
|
||||
private function validationFailedResponse(Request $request, ValidatorContract $validator, array $allowedFields): ?JsonResponse
|
||||
{
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
if (! $validator->fails() && empty($extraFields)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$errors = $validator->errors();
|
||||
foreach ($extraFields as $field) {
|
||||
$errors->add($field, 'This field is not allowed.');
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $errors,
|
||||
], 422);
|
||||
}
|
||||
|
||||
private function normalizeLabel(mixed $label): ?string
|
||||
{
|
||||
$label = trim((string) $label);
|
||||
|
||||
return $label === '' ? null : $label;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Project;
|
||||
use App\Services\ProjectCanvas;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Project canvas: the resources of every environment of one project (or of every project).
|
||||
* Moves and links are saved through EnvironmentCanvasController. Called by resources/js/environment-canvas.js through the /canvas/api session bridge.
|
||||
*/
|
||||
class ProjectCanvasController extends Controller
|
||||
{
|
||||
public function __construct(private ProjectCanvas $canvas) {}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
return response()->json($this->canvas->teamPayload((int) $teamId, (bool) $request->attributes->get('can_read_sensitive', false), $this->canEdit($request, (int) $teamId)));
|
||||
}
|
||||
|
||||
public function show(Request $request, string $uuid): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
$project = Project::query()->whereTeamId($teamId)->whereUuid($uuid)->first();
|
||||
if (! $project) {
|
||||
return response()->json(['message' => 'Project not found.'], 404);
|
||||
}
|
||||
$this->authorize('view', $project);
|
||||
|
||||
return response()->json($this->canvas->payload($project, (bool) $request->attributes->get('can_read_sensitive', false), $this->canEdit($request, (int) $project->team_id)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Same rule as the environment canvas: team admins with a write token move cards and draw links.
|
||||
*/
|
||||
private function canEdit(Request $request, int $teamId): bool
|
||||
{
|
||||
$user = $request->user();
|
||||
$token = $user->currentAccessToken();
|
||||
|
||||
return $user->isAdminOfTeam($teamId) && ($token->can('root') || $token->can('write'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Server;
|
||||
use App\Services\ServerCanvas;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Server canvas: every server of the team (or one server) with the resources on it.
|
||||
* Moves and links are saved through EnvironmentCanvasController. Called by resources/js/environment-canvas.js through the /canvas/api session bridge.
|
||||
*/
|
||||
class ServerCanvasController extends Controller
|
||||
{
|
||||
public function __construct(private ServerCanvas $canvas) {}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
$this->authorize('viewAny', Server::class);
|
||||
|
||||
$servers = Server::query()->whereTeamId($teamId)->with('settings')->orderBy('name')->get();
|
||||
|
||||
return response()->json($this->canvas->payload($teamId, $servers, null, $this->canReadSensitive($request), $this->canEdit($request, $teamId)));
|
||||
}
|
||||
|
||||
public function show(Request $request, string $uuid): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
$server = Server::query()->whereTeamId($teamId)->whereUuid($uuid)->with('settings')->first();
|
||||
if (! $server) {
|
||||
return response()->json(['message' => 'Server not found.'], 404);
|
||||
}
|
||||
$this->authorize('view', $server);
|
||||
|
||||
return response()->json($this->canvas->payload($teamId, collect([$server]), $server, $this->canReadSensitive($request), $this->canEdit($request, $teamId)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Same rule as the environment canvas: team admins with a write token move cards and draw links.
|
||||
*/
|
||||
private function canEdit(Request $request, int $teamId): bool
|
||||
{
|
||||
$user = $request->user();
|
||||
$token = $user->currentAccessToken();
|
||||
|
||||
return $user->isAdminOfTeam($teamId) && ($token->can('root') || $token->can('write'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Unmanaged Docker containers of one server, as read-only cards for its canvas.
|
||||
*/
|
||||
public function containers(string $uuid): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
$server = Server::query()->whereTeamId($teamId)->whereUuid($uuid)->with('settings')->first();
|
||||
if (! $server) {
|
||||
return response()->json(['message' => 'Server not found.'], 404);
|
||||
}
|
||||
$this->authorize('view', $server);
|
||||
|
||||
return response()->json(['nodes' => $this->canvas->unmanagedContainerNodes($server)->all()]);
|
||||
}
|
||||
|
||||
private function canReadSensitive(Request $request): bool
|
||||
{
|
||||
return (bool) $request->attributes->get('can_read_sensitive', false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\SharedEnvironmentVariable;
|
||||
use App\Models\Team;
|
||||
use App\Services\SharedVariableCanvas;
|
||||
use App\Traits\EnvironmentVariableProtection;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
/**
|
||||
* Shared variables canvas: shows which resources use the shared variables of a scope, and adds
|
||||
* a shared variable to a resource (a resource variable that holds {{scope.KEY}}) or removes it.
|
||||
* Called by resources/js/environment-canvas.js through the /canvas/api session bridge.
|
||||
*/
|
||||
class SharedVariableCanvasController extends Controller
|
||||
{
|
||||
use EnvironmentVariableProtection;
|
||||
|
||||
public function __construct(private SharedVariableCanvas $canvas) {}
|
||||
|
||||
public function show(Request $request): JsonResponse
|
||||
{
|
||||
$scope = $this->scope($request);
|
||||
$owner = $this->findOwner($scope, $request->route('uuid'));
|
||||
if ($owner instanceof JsonResponse) {
|
||||
return $owner;
|
||||
}
|
||||
$this->authorize('view', $owner);
|
||||
|
||||
return response()->json($this->canvas->payload($scope, $owner, $this->canEdit($request, $scope, $owner)));
|
||||
}
|
||||
|
||||
public function storeLink(Request $request): JsonResponse
|
||||
{
|
||||
$scope = $this->scope($request);
|
||||
$owner = $this->findOwner($scope, $request->route('uuid'));
|
||||
if ($owner instanceof JsonResponse) {
|
||||
return $owner;
|
||||
}
|
||||
$this->authorize('view', $owner);
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
$validator = Validator::make($request->all(), [
|
||||
'source' => ['required', 'string', 'max:255'],
|
||||
'target' => ['required', 'string', 'max:255'],
|
||||
]);
|
||||
if ($validator->fails()) {
|
||||
return response()->json(['message' => 'Validation failed.', 'errors' => $validator->errors()], 422);
|
||||
}
|
||||
|
||||
$variable = $this->findVariable($owner, $request->input('source'));
|
||||
$resource = $this->canvas->resources($scope, $owner)->get($request->input('target'));
|
||||
$errors = [];
|
||||
if (! $variable) {
|
||||
$errors['source'] = ['The source must be a shared variable of this scope.'];
|
||||
}
|
||||
if (! $resource) {
|
||||
$errors['target'] = ['The target must be a resource that can use this shared variable.'];
|
||||
}
|
||||
if ($errors !== []) {
|
||||
return response()->json(['message' => 'Validation failed.', 'errors' => $errors], 422);
|
||||
}
|
||||
$this->authorize('manageEnvironment', $resource);
|
||||
|
||||
$existing = $this->canvas->resourceVariables(collect([$resource]))->firstWhere('key', $variable->key);
|
||||
if ($existing) {
|
||||
$message = in_array($variable->key, $this->canvas->referencedKeys($scope, $existing->value), true)
|
||||
? "{$resource->name} already uses {$variable->key}."
|
||||
: "{$resource->name} already has a variable named {$variable->key}. Rename or remove it first.";
|
||||
|
||||
return response()->json(['message' => $message, 'errors' => ['target' => [$message]]], 422);
|
||||
}
|
||||
|
||||
$environmentVariable = new EnvironmentVariable;
|
||||
$environmentVariable->key = $variable->key;
|
||||
$environmentVariable->value = $this->canvas->reference($scope, $variable->key);
|
||||
$environmentVariable->is_multiline = (bool) $variable->is_multiline;
|
||||
$environmentVariable->is_literal = (bool) $variable->is_literal;
|
||||
$environmentVariable->resourceable_id = $resource->getKey();
|
||||
$environmentVariable->resourceable_type = $resource->getMorphClass();
|
||||
$environmentVariable->save();
|
||||
|
||||
$link = collect($this->canvas->links($scope, collect([$variable]), collect([$resource->uuid => $resource])))
|
||||
->firstWhere('source', $this->canvas->variableNodeId($variable));
|
||||
|
||||
return response()->json($link, 201);
|
||||
}
|
||||
|
||||
public function destroyLink(Request $request): JsonResponse
|
||||
{
|
||||
$scope = $this->scope($request);
|
||||
$owner = $this->findOwner($scope, $request->route('uuid'));
|
||||
if ($owner instanceof JsonResponse) {
|
||||
return $owner;
|
||||
}
|
||||
$this->authorize('view', $owner);
|
||||
|
||||
[$source, $target] = array_pad(explode('|', (string) $request->route('link_id'), 2), 2, '');
|
||||
$resource = $this->canvas->resources($scope, $owner)->get($target);
|
||||
$variable = $this->findVariable($owner, $source);
|
||||
$key = $variable?->key ?? (str_starts_with($source, 'missing:') ? substr($source, strlen('missing:')) : null);
|
||||
if (! $resource || blank($key)) {
|
||||
return response()->json(['message' => 'Link not found.'], 404);
|
||||
}
|
||||
$this->authorize('manageEnvironment', $resource);
|
||||
|
||||
// Preview copies of application variables are removed together with the variable.
|
||||
$references = EnvironmentVariable::query()
|
||||
->where('resourceable_type', $resource->getMorphClass())
|
||||
->where('resourceable_id', $resource->getKey())
|
||||
->get()
|
||||
->filter(fn (EnvironmentVariable $environmentVariable) => in_array($key, $this->canvas->referencedKeys($scope, $environmentVariable->value), true));
|
||||
if ($references->where('is_preview', false)->isEmpty()) {
|
||||
return response()->json(['message' => 'Link not found.'], 404);
|
||||
}
|
||||
$embedded = $references->reject(fn (EnvironmentVariable $environmentVariable) => $this->canvas->isExactReference($scope, $key, $environmentVariable->value));
|
||||
if ($embedded->isNotEmpty()) {
|
||||
$message = "{$embedded->pluck('key')->unique()->join(', ')} on {$resource->name} uses {$this->canvas->reference($scope, $key)} inside a longer value. Edit it on the resource.";
|
||||
|
||||
return response()->json(['message' => $message], 422);
|
||||
}
|
||||
if ($this->usesDockerCompose($resource)) {
|
||||
foreach ($references->pluck('key')->unique() as $environmentKey) {
|
||||
[$isUsed, $reason] = $this->isEnvironmentVariableUsedInDockerCompose($environmentKey, $resource->docker_compose);
|
||||
if ($isUsed) {
|
||||
return response()->json(['message' => "Cannot remove {$environmentKey} from {$resource->name}. {$reason}"], 422);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DB::transaction(fn () => $references->each->delete());
|
||||
|
||||
return response()->json(['message' => 'Link removed.']);
|
||||
}
|
||||
|
||||
/**
|
||||
* The team routes have no {scope} segment.
|
||||
*/
|
||||
private function scope(Request $request): string
|
||||
{
|
||||
return $request->route('scope') ?? 'team';
|
||||
}
|
||||
|
||||
private function findOwner(string $scope, ?string $uuid): Model|JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
$owner = match ($scope) {
|
||||
'team' => Team::query()->find($teamId),
|
||||
'project' => Project::query()->where('team_id', $teamId)->where('uuid', $uuid)->first(),
|
||||
'environment' => Environment::query()->where('uuid', $uuid)->whereHas('project', fn ($query) => $query->where('team_id', $teamId))->with('project')->first(),
|
||||
'server' => Server::query()->where('team_id', $teamId)->where('uuid', $uuid)->first(),
|
||||
default => null,
|
||||
};
|
||||
|
||||
return $owner ?? response()->json(['message' => str($scope)->headline().' not found.'], 404);
|
||||
}
|
||||
|
||||
private function findVariable(Model $owner, ?string $nodeId): ?SharedEnvironmentVariable
|
||||
{
|
||||
if (! str_starts_with((string) $nodeId, 'variable:')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $owner->environment_variables()->whereKey((int) substr($nodeId, strlen('variable:')))->first();
|
||||
}
|
||||
|
||||
private function canEdit(Request $request, string $scope, Model $owner): bool
|
||||
{
|
||||
$user = $request->user();
|
||||
$token = $user->currentAccessToken();
|
||||
|
||||
return $user->isAdminOfTeam($this->canvas->teamId($scope, $owner))
|
||||
&& ($token->can('root') || $token->can('write'));
|
||||
}
|
||||
|
||||
private function usesDockerCompose(Model $resource): bool
|
||||
{
|
||||
return $resource instanceof Service
|
||||
|| ($resource instanceof Application && $resource->build_pack === 'dockercompose');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Tag;
|
||||
use App\Services\TagCanvas;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
/**
|
||||
* Tags canvas: shows which resources have which tag, and tags or untags a resource.
|
||||
* Called by resources/js/environment-canvas.js through the /canvas/api session bridge.
|
||||
*/
|
||||
class TagCanvasController extends Controller
|
||||
{
|
||||
public function __construct(private TagCanvas $canvas) {}
|
||||
|
||||
public function show(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
$this->authorize('viewAny', Tag::class);
|
||||
|
||||
$user = $request->user();
|
||||
$token = $user->currentAccessToken();
|
||||
$canEdit = $user->isAdminOfTeam((int) $teamId) && ($token->can('root') || $token->can('write'));
|
||||
|
||||
return response()->json($this->canvas->payload((int) $teamId, $canEdit));
|
||||
}
|
||||
|
||||
public function storeLink(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
$validator = Validator::make($request->all(), [
|
||||
'source' => ['required', 'string', 'max:255'],
|
||||
'target' => ['required', 'string', 'max:255'],
|
||||
]);
|
||||
if ($validator->fails()) {
|
||||
return response()->json(['message' => 'Validation failed.', 'errors' => $validator->errors()], 422);
|
||||
}
|
||||
|
||||
$tag = $this->findTag((int) $teamId, $request->input('source'));
|
||||
$resource = $this->canvas->resources((int) $teamId)->get($request->input('target'));
|
||||
$errors = [];
|
||||
if (! $tag) {
|
||||
$errors['source'] = ['The source must be a tag of this team.'];
|
||||
}
|
||||
if (! $resource) {
|
||||
$errors['target'] = ['The target must be a resource of this team.'];
|
||||
}
|
||||
if ($errors !== []) {
|
||||
return response()->json(['message' => 'Validation failed.', 'errors' => $errors], 422);
|
||||
}
|
||||
$this->authorize('update', $resource);
|
||||
|
||||
if ($resource->tags->contains('id', $tag->id)) {
|
||||
$message = "{$resource->name} already has the {$tag->name} tag.";
|
||||
|
||||
return response()->json(['message' => $message, 'errors' => ['target' => [$message]]], 422);
|
||||
}
|
||||
$resource->tags()->attach($tag->id);
|
||||
|
||||
return response()->json($this->canvas->link($tag, $resource), 201);
|
||||
}
|
||||
|
||||
public function destroyLink(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
[$source, $target] = array_pad(explode('|', (string) $request->route('link_id'), 2), 2, '');
|
||||
$tag = $this->findTag((int) $teamId, $source);
|
||||
$resource = $this->canvas->resources((int) $teamId)->get($target);
|
||||
if (! $tag || ! $resource || ! $resource->tags->contains('id', $tag->id)) {
|
||||
return response()->json(['message' => 'Link not found.'], 404);
|
||||
}
|
||||
$this->authorize('update', $resource);
|
||||
|
||||
// The tag stays, also without resources. Delete it on the Tags page.
|
||||
$resource->tags()->detach($tag->id);
|
||||
|
||||
return response()->json(['message' => 'Link removed.']);
|
||||
}
|
||||
|
||||
private function findTag(int $teamId, ?string $nodeId): ?Tag
|
||||
{
|
||||
if (! str_starts_with((string) $nodeId, 'tag:')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Tag::query()->where('team_id', $teamId)->where('uuid', substr($nodeId, strlen('tag:')))->first();
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,24 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\AvatarStorageService;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class ProfileAvatarController extends Controller
|
||||
{
|
||||
public function __invoke(AvatarStorageService $avatarStorage): Response
|
||||
public function __invoke(AvatarStorageService $avatarStorage, ?int $user_id = null): Response
|
||||
{
|
||||
$contents = $avatarStorage->contents(auth()->user());
|
||||
$user = auth()->user();
|
||||
|
||||
if ($user_id !== null && $user_id !== $user->id) {
|
||||
$user = User::query()
|
||||
->whereKey($user_id)
|
||||
->whereHas('teams', fn ($query) => $query->whereIn('teams.id', auth()->user()->teams()->select('teams.id')))
|
||||
->firstOrFail();
|
||||
}
|
||||
|
||||
$contents = $avatarStorage->contents($user);
|
||||
abort_if($contents === null, 404);
|
||||
|
||||
return response($contents, 200, [
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\TeamAvatarStorageService;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class TeamAvatarController extends Controller
|
||||
{
|
||||
public function __invoke(int $team_id, TeamAvatarStorageService $avatarStorage): Response
|
||||
{
|
||||
$team = auth()->user()->teams()->whereKey($team_id)->firstOrFail();
|
||||
$contents = $avatarStorage->teamContents($team);
|
||||
|
||||
abort_if($contents === null, 404);
|
||||
|
||||
return response($contents, 200, [
|
||||
'Content-Type' => 'image/jpeg',
|
||||
'Cache-Control' => 'private, max-age=31536000, immutable',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Laravel\Sanctum\PersonalAccessToken;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Lets the deploy canvas call API controllers with the browser session.
|
||||
*
|
||||
* Gives the session user an in-memory (never saved) API token for the team of
|
||||
* the canvas page (the X-Canvas-Team header, set when the page was opened), not
|
||||
* the session team, so switching teams in another tab does not change an open
|
||||
* canvas. The token has the abilities an API token of that user may have:
|
||||
* admins and owners get read, write, deploy and read:sensitive, members get
|
||||
* read only. The usual API middleware (api.token.team rejects teams the user is
|
||||
* not a member of, api.ability, api.sensitive) and controllers then enforce
|
||||
* team scope and permissions as for a real token.
|
||||
*/
|
||||
class UseSessionTeamApiToken
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
$teamId = $request->header('X-Canvas-Team');
|
||||
|
||||
if (! $user) {
|
||||
return response()->json(['message' => 'Unauthenticated.'], 401);
|
||||
}
|
||||
if (! is_string($teamId) || ! ctype_digit($teamId)) {
|
||||
return response()->json(['message' => 'Missing canvas team.'], 400);
|
||||
}
|
||||
$teamId = (int) $teamId;
|
||||
|
||||
$abilities = $user->isAdminOfTeam($teamId)
|
||||
? ['read', 'write', 'deploy', 'read:sensitive']
|
||||
: ['read'];
|
||||
|
||||
$token = new PersonalAccessToken;
|
||||
$token->forceFill([
|
||||
'name' => 'deploy-canvas-session',
|
||||
'team_id' => $teamId,
|
||||
'abilities' => $abilities,
|
||||
]);
|
||||
$user->withAccessToken($token);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user