validate($this->polling->rules() + [ 'search' => ['nullable', 'string', 'max:255'], 'visibility' => ['nullable', Rule::in(['public', 'private'])], ]); $viewer = $request->user(); assert($viewer !== null); // The API twin of the web listing's narrowing, and it has to be // here rather than only there: the same disclosure through a token // is the same disclosure (GHSA-r3hg-3fxw-rcmr). $query = $this->scope->groups($viewer)->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(Request $request, Group $group): GroupResource { $viewer = $request->user(); assert($viewer !== null); // The web edit screen's boundary, on its API twin: this is the read // half of the group that update() and destroy() below already refuse // to touch, and it hands back the membership with addresses. abort_unless($this->scope->allowsGroupChange($viewer, $group), 404); return new GroupResource($group->loadCount('members')->load([ 'members' => fn (BelongsToMany $members) => $members ->whereIn('users.id', $this->scope->clients($viewer)->select('id')), ])); } 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); } }