Files
projectsend/app/Modules/Groups/Http/Controllers/Api/GroupsController.php
T
ignacionelson eb2917f5ff Hold a group object to the same boundary its membership already has
This overturns something #1701 decided, so it should say so. That PR
closed the membership hole and left GroupsController::update and
destroy installation-wide on purpose, on the grounds that managing the
group object is a different question from managing who is in it.

What decides it is a measurement that was not in front of that decision.
An assignment to a group is how its members reach a file, so deleting a
group revokes that access for every member. Measured before this guard,
with a client-scoped role holding the group permissions:

  stranger client can read the shared file   true
  PATCH /groups/{stranger group}             302, renamed
  DELETE /groups/{stranger group}            302, group gone
  stranger client can read the shared file   false

So a staff member who may not add somebody to a group out of their reach
could delete it out from under the people already in it. That is not a
gentler version of the membership rule, it is a harder one, and the two
sitting on opposite sides of the same boundary was the odd part.

StaffLibraryScope::allowsGroupChange is the reach half of
allowsGroupMembership on its own, since no client appears in this
question -- one predicate, two callers, rather than a second statement of
it. Both surfaces take it, at 404, matching the membership guards.

A group that shares nothing beyond the actor's library still passes, so a
group they created or one holding their own clients stays theirs, and
unscoped staff are unaffected by construction.

The API document moves a 404 above a 422 on two paths. Both already
documented the 404 -- route model binding produced one -- and Scramble
orders responses by where they appear in the method, so the guard landing
before the validate() call is the whole of the change.
2026-08-26 18:04:41 -03:00

139 lines
4.9 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Modules\Groups\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Modules\Api\Support\PollingQuery;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Groups\Http\Resources\Api\GroupResource;
use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Groups\Models\Group;
use App\Support\Rules;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Validation\Rule;
/**
* Groups — collections of clients used as a sharing target.
*
* Distinct from a Category (a label on a file) and a Folder (hierarchy);
* the three are easy to conflate from outside, which is why the guide
* names them separately.
*/
class GroupsController extends Controller
{
public function __construct(
private readonly PollingQuery $polling,
private readonly ActivityLogger $activity,
private readonly StaffLibraryScope $scope,
) {}
public function index(Request $request): AnonymousResourceCollection
{
$filters = $request->validate($this->polling->rules() + [
'search' => ['nullable', 'string', 'max:255'],
'visibility' => ['nullable', Rule::in(['public', 'private'])],
]);
$query = Group::query()->withCount('members');
if (($filters['search'] ?? null) !== null) {
$search = $filters['search'];
$query->where(fn (Builder $inner) => $inner
->where('name', 'like', "%{$search}%")
->orWhere('description', 'like', "%{$search}%"));
}
if (($filters['visibility'] ?? null) !== null) {
$query->where('public', $filters['visibility'] === 'public');
}
return GroupResource::collection($this->polling->paginate($request, $query, 'groups'));
}
public function show(Group $group): GroupResource
{
return new GroupResource($group->loadCount('members')->load('members'));
}
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'slug' => Rules::slug('groups'),
'description' => ['nullable', 'string', 'max:2000'],
'public' => ['required', 'boolean'],
]);
$validated['slug'] = ($validated['slug'] ?? '') ?: Group::uniqueSlugFrom($validated['name']);
$group = Group::query()->create($validated);
$this->activity->log(Action::GroupCreated, subject: $group);
if ($group->public) {
$this->activity->log(Action::GroupMadePublic, subject: $group, context: ['slug' => $group->slug]);
}
return (new GroupResource($group->loadCount('members')))->response()->setStatusCode(201);
}
public function update(Request $request, Group $group): GroupResource
{
$viewer = $request->user();
assert($viewer !== null);
// Mirrors the web controller: a group reaching past this token
// owner's library is not theirs to change, and deleting one
// revokes its members' access to everything assigned to it.
abort_unless($this->scope->allowsGroupChange($viewer, $group), 404);
$validated = $request->validate([
'name' => ['sometimes', 'string', 'max:255'],
'slug' => Rules::slug('groups', $group->id),
'description' => ['sometimes', 'nullable', 'string', 'max:2000'],
'public' => ['sometimes', 'boolean'],
]);
// A slug must never change silently just because the name did.
$validated['slug'] = ($validated['slug'] ?? '') ?: ($group->slug ?: Group::uniqueSlugFrom($validated['name'] ?? $group->name, $group->id));
$wasPublic = $group->public;
$group->update($validated);
$this->activity->log(Action::GroupUpdated, subject: $group);
if (! $wasPublic && $group->public) {
$this->activity->log(Action::GroupMadePublic, subject: $group, context: ['slug' => $group->slug]);
} elseif ($wasPublic && ! $group->public) {
$this->activity->log(Action::GroupMadePrivate, subject: $group);
}
return new GroupResource($group->refresh()->loadCount('members'));
}
public function destroy(Request $request, Group $group): JsonResponse
{
$viewer = $request->user();
assert($viewer !== null);
// Mirrors the web controller: a group reaching past this token
// owner's library is not theirs to change, and deleting one
// revokes its members' access to everything assigned to it.
abort_unless($this->scope->allowsGroupChange($viewer, $group), 404);
$name = $group->name;
$group->delete();
$this->activity->log(Action::GroupDeleted, context: ['name' => $name]);
return response()->json(status: 204);
}
}