Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions database/factories/ComponentGroupFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

namespace Cachet\Database\Factories;

use Cachet\Enums\ComponentGroupVisibilityEnum;
use Cachet\Enums\ResourceOrderColumnEnum;
use Cachet\Enums\ResourceOrderDirectionEnum;
use Cachet\Enums\ResourceVisibilityEnum;
use Cachet\Models\ComponentGroup;
use Illuminate\Database\Eloquent\Factories\Factory;

Expand All @@ -27,7 +27,7 @@ public function definition(): array
'order' => 0,
'order_column' => ResourceOrderColumnEnum::Manual,
'order_direction' => null,
'visible' => ComponentGroupVisibilityEnum::expanded->value,
'visible' => ResourceVisibilityEnum::guest->value,
];
}

Expand Down
20 changes: 20 additions & 0 deletions src/Concerns/ChecksApiAuthentication.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

declare(strict_types=1);

namespace Cachet\Concerns;

trait ChecksApiAuthentication
{
/**
* Determine whether the API caller is authenticated.
*
* The API's read routes carry no auth middleware, so the application's
* default guard never sees bearer tokens. The Sanctum guard resolves
* both API tokens and first-party sessions.
*/
protected function isAuthenticated(): bool
{
return auth('sanctum')->check();
}
}
66 changes: 51 additions & 15 deletions src/Http/Controllers/Api/ComponentController.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,32 +5,32 @@
use Cachet\Actions\Component\CreateComponent;
use Cachet\Actions\Component\DeleteComponent;
use Cachet\Actions\Component\UpdateComponent;
use Cachet\Concerns\ChecksApiAuthentication;
use Cachet\Concerns\GuardsApiAbilities;
use Cachet\Data\Requests\Component\CreateComponentRequestData;
use Cachet\Data\Requests\Component\UpdateComponentRequestData;
use Cachet\Enums\ComponentStatusEnum;
use Cachet\Http\Resources\Component as ComponentResource;
use Cachet\Models\Component;
use Cachet\Models\ComponentGroup;
use Cachet\Models\Incident;
use Dedoc\Scramble\Attributes\Group;
use Dedoc\Scramble\Attributes\QueryParameter;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Http\Response;
use Illuminate\Routing\Controller;
use Illuminate\Support\Collection;
use Spatie\QueryBuilder\AllowedFilter;
use Spatie\QueryBuilder\AllowedInclude;
use Spatie\QueryBuilder\QueryBuilder;

#[Group('Components', weight: 1)]
class ComponentController extends Controller
{
use ChecksApiAuthentication;
use GuardsApiAbilities;

/**
* The list of allowed includes.
*/
public const ALLOWED_INCLUDES = [
'group',
'incidents',
];

/**
* List Components
*/
Expand All @@ -41,19 +41,56 @@ class ComponentController extends Controller
#[QueryParameter('page', 'Which page to show.', type: 'int', example: 2)]
public function index()
{
$components = QueryBuilder::for(Component::class)
->allowedIncludes(self::ALLOWED_INCLUDES)
$components = QueryBuilder::for($this->visibleComponents())
->allowedIncludes($this->allowedIncludes())
->allowedFilters([
'name',
AllowedFilter::exact('status'),
AllowedFilter::exact('enabled'),
AllowedFilter::exact('enabled')->default(true),
])
->allowedSorts(['name', 'order', 'id'])
->simplePaginate(request('per_page', 15));

return ComponentResource::collection($components);
}

/**
* The list of allowed includes, scoped to the current caller.
*
* @return array<int, string|Collection<int, AllowedInclude>>
*/
protected function allowedIncludes(): array
{
return [
'group',
AllowedInclude::callback('incidents', function (BelongsToMany $query): void {
/** @var BelongsToMany<Incident, Component> $query */
$query->visible($this->isAuthenticated());
}),
];
}

/**
* Base query scoping components to those visible to the current caller.
*
* Components have no visibility of their own; they inherit it from their
* group. Ungrouped components are always public and disabled components
* are hidden from guests, matching the status page.
*
* @return Builder<Component>
*/
protected function visibleComponents(): Builder
{
$visibleGroups = ComponentGroup::query()->visible($this->isAuthenticated())->select('id');

return Component::query()
->unless($this->isAuthenticated(), fn (Builder $query) => $query->enabled())
->where(function ($query) use ($visibleGroups): void {
$query->whereNull('component_group_id')
->orWhereIn('component_group_id', $visibleGroups);
});
}

/**
* Create Component
*/
Expand All @@ -73,10 +110,9 @@ public function store(CreateComponentRequestData $data, CreateComponent $createC
*/
public function show(Component $component)
{

$componentQuery = QueryBuilder::for(Component::class)
->allowedIncludes(self::ALLOWED_INCLUDES)
->find($component->id);
$componentQuery = QueryBuilder::for($this->visibleComponents())
->allowedIncludes($this->allowedIncludes())
->findOrFail($component->id);

return ComponentResource::make($componentQuery)
->response()
Expand Down
36 changes: 30 additions & 6 deletions src/Http/Controllers/Api/ComponentGroupController.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,31 +5,56 @@
use Cachet\Actions\ComponentGroup\CreateComponentGroup;
use Cachet\Actions\ComponentGroup\DeleteComponentGroup;
use Cachet\Actions\ComponentGroup\UpdateComponentGroup;
use Cachet\Concerns\ChecksApiAuthentication;
use Cachet\Concerns\GuardsApiAbilities;
use Cachet\Data\Requests\ComponentGroup\CreateComponentGroupRequestData;
use Cachet\Data\Requests\ComponentGroup\UpdateComponentGroupRequestData;
use Cachet\Http\Resources\ComponentGroup as ComponentGroupResource;
use Cachet\Models\Component;
use Cachet\Models\ComponentGroup;
use Dedoc\Scramble\Attributes\Group;
use Dedoc\Scramble\Attributes\QueryParameter;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Http\Response;
use Illuminate\Routing\Controller;
use Illuminate\Support\Collection;
use Spatie\QueryBuilder\AllowedInclude;
use Spatie\QueryBuilder\QueryBuilder;

#[Group('Component Groups', weight: 2)]
class ComponentGroupController extends Controller
{
use ChecksApiAuthentication;
use GuardsApiAbilities;

/**
* The list of allowed includes, scoped to the current caller.
*
* Disabled components are hidden from guests, matching the status page.
*
* @return array<int, Collection<int, AllowedInclude>>
*/
protected function allowedIncludes(): array
{
return [
AllowedInclude::callback('components', function (HasMany $query): void {
/** @var HasMany<Component, ComponentGroup> $query */
if (! $this->isAuthenticated()) {
$query->enabled();
}
}),
];
}

/**
* List Component Groups
*/
#[QueryParameter('per_page', 'How many items to show per page.', type: 'int', default: 15, example: 20)]
#[QueryParameter('page', 'Which page to show.', type: 'int', example: 2)]
public function index()
{
$componentGroups = QueryBuilder::for(ComponentGroup::class)
->allowedIncludes(['components'])
$componentGroups = QueryBuilder::for(ComponentGroup::query()->visible($this->isAuthenticated()))
->allowedIncludes($this->allowedIncludes())
->allowedSorts(['name', 'id'])
->simplePaginate(request('per_page', 15));

Expand All @@ -53,10 +78,9 @@ public function store(CreateComponentGroupRequestData $data, CreateComponentGrou
*/
public function show(ComponentGroup $componentGroup)
{

$componentQuery = QueryBuilder::for(ComponentGroup::class)
->allowedIncludes(['components'])
->find($componentGroup->id);
$componentQuery = QueryBuilder::for(ComponentGroup::query()->visible($this->isAuthenticated()))
->allowedIncludes($this->allowedIncludes())
->findOrFail($componentGroup->id);

return ComponentGroupResource::make($componentQuery)
->response()
Expand Down
43 changes: 30 additions & 13 deletions src/Http/Controllers/Api/IncidentController.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,33 +5,51 @@
use Cachet\Actions\Incident\CreateIncident;
use Cachet\Actions\Incident\DeleteIncident;
use Cachet\Actions\Incident\UpdateIncident;
use Cachet\Concerns\ChecksApiAuthentication;
use Cachet\Concerns\GuardsApiAbilities;
use Cachet\Data\Requests\Incident\CreateIncidentRequestData;
use Cachet\Data\Requests\Incident\UpdateIncidentRequestData;
use Cachet\Http\Resources\Incident as IncidentResource;
use Cachet\Models\Component;
use Cachet\Models\ComponentGroup;
use Cachet\Models\Incident;
use Dedoc\Scramble\Attributes\Group;
use Dedoc\Scramble\Attributes\QueryParameter;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Routing\Controller;
use Illuminate\Support\Collection;
use Spatie\QueryBuilder\AllowedFilter;
use Spatie\QueryBuilder\AllowedInclude;
use Spatie\QueryBuilder\QueryBuilder;

#[Group('Incidents', weight: 3)]
class IncidentController extends Controller
{
use ChecksApiAuthentication;
use GuardsApiAbilities;

/**
* The list of allowed includes.
* The list of allowed includes, scoped to the current caller.
*
* Component groups hidden from the caller are excluded from the nested
* include so a visible incident cannot reveal them.
*
* @return array<int, string|Collection<int, AllowedInclude>>
*/
public const ALLOWED_INCLUDES = [
'components',
'components.group',
'updates',
'user',
];
protected function allowedIncludes(): array
{
return [
'components',
AllowedInclude::callback('components.group', function (BelongsTo $query): void {
/** @var BelongsTo<ComponentGroup, Component> $query */
$query->visible($this->isAuthenticated());
}),
'updates',
'user',
];
}

/**
* List Incidents
Expand All @@ -40,8 +58,8 @@ class IncidentController extends Controller
#[QueryParameter('page', 'Which page to show.', type: 'int', example: 2)]
public function index(Request $request)
{
$incidents = QueryBuilder::for(Incident::query())
->allowedIncludes(self::ALLOWED_INCLUDES)
$incidents = QueryBuilder::for(Incident::query()->visible($this->isAuthenticated()))
->allowedIncludes($this->allowedIncludes())
->allowedFilters([
'name',
AllowedFilter::exact('status'),
Expand Down Expand Up @@ -73,10 +91,9 @@ public function store(CreateIncidentRequestData $data, CreateIncident $createInc
*/
public function show(Incident $incident)
{

$incidentQuery = QueryBuilder::for(Incident::class)
->allowedIncludes(self::ALLOWED_INCLUDES)
->find($incident->id);
$incidentQuery = QueryBuilder::for(Incident::query()->visible($this->isAuthenticated()))
->allowedIncludes($this->allowedIncludes())
->findOrFail($incident->id);

return IncidentResource::make($incidentQuery)
->response()
Expand Down
17 changes: 17 additions & 0 deletions src/Http/Controllers/Api/IncidentUpdateController.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use Cachet\Actions\Update\CreateUpdate;
use Cachet\Actions\Update\DeleteUpdate;
use Cachet\Actions\Update\EditUpdate;
use Cachet\Concerns\ChecksApiAuthentication;
use Cachet\Concerns\GuardsApiAbilities;
use Cachet\Data\Requests\IncidentUpdate\CreateIncidentUpdateRequestData;
use Cachet\Data\Requests\IncidentUpdate\EditIncidentUpdateRequestData;
Expand All @@ -22,6 +23,7 @@
#[Group('Incident Updates', weight: 4)]
class IncidentUpdateController extends Controller
{
use ChecksApiAuthentication;
use GuardsApiAbilities;

/**
Expand All @@ -31,6 +33,8 @@ class IncidentUpdateController extends Controller
#[QueryParameter('page', 'Which page to show.', type: 'int', example: 2)]
public function index(Incident $incident)
{
$this->ensureIncidentVisible($incident);

$query = Update::query()
->where('updateable_id', $incident->id)
->where('updateable_type', 'incident');
Expand Down Expand Up @@ -61,6 +65,8 @@ public function store(CreateIncidentUpdateRequestData $data, Incident $incident,
*/
public function show(Incident $incident, Update $update)
{
$this->ensureIncidentVisible($incident);

$updateQuery = QueryBuilder::for(Update::class)
->allowedIncludes([
AllowedInclude::relationship('incident', 'updateable'),
Expand All @@ -72,6 +78,17 @@ public function show(Incident $incident, Update $update)
->setStatusCode(Response::HTTP_OK);
}

/**
* Abort with a 404 when the parent incident is not visible to the caller.
*/
protected function ensureIncidentVisible(Incident $incident): void
{
abort_unless(
Incident::query()->visible($this->isAuthenticated())->whereKey($incident->getKey())->exists(),
Response::HTTP_NOT_FOUND,
);
}

/**
* Update Incident Update
*/
Expand Down
Loading
Loading