mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-17 17:15:08 +00:00
0a8b609e8b
#1698 moved the library boundary into FileCommentPolicy, where it belongs, and said plainly what that cost: the moderation screen went from 65 queries to 465 for a client-scoped moderator with five assigned clients. Measured here, those numbers are exactly right. The cost is not in asking. It is that StaffLibraryScope::files() rebuilds its query every time, and building one runs four immediate lookups per assigned client — the client's group ids, the same ids again inside Folder::sharedFolderIds(), that method's own assignment lookup, and the shared-folder get() in Folder::scopeVisibleToClient(). None of them depend on the query being built. Gate resolves a fresh policy for every check, so a listing paid for all of it once per row. The built query is now memoised per user and handed back as a clone, since every caller adds to it, and the scope is registered as `scoped` rather than transient so the memo survives a request. Scoped rather than a singleton on purpose: a long-lived queue worker keeps singletons between jobs, and a library query built from one job's data has no business answering the next one's question. That is 465 queries down to 60 on the same page — below the 65 it cost before #1698, because the memo also helps the callers that were already asking repeatedly. FileVersions::sharedAudience(), which runs the same helper twice per candidate while resolving notification recipients, gets it for free. So the answer to the question #1698 left open is neither of the two it offered. can_delete stays a real question asked of the policy; nothing restates the boundary; and the page is faster than it was before the fix. Three tests: one user's query never answers another's, one caller's constraints never follow the next, and the moderation screen does not ask once per row.
184 lines
5.5 KiB
PHP
184 lines
5.5 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Modules\Files\Access;
|
||
|
||
use App\Models\User;
|
||
use App\Modules\Files\Models\File;
|
||
use App\Modules\Files\Models\Folder;
|
||
use App\Modules\Groups\Models\Group;
|
||
use Illuminate\Database\Eloquent\Builder;
|
||
|
||
/**
|
||
* The single point that decides which library content a staff member
|
||
* sees. An unscoped staff member sees the whole shared library; a
|
||
* client-scoped one (see User::isClientScoped) sees only the files &
|
||
* folders they created, plus everything belonging to the clients
|
||
* assigned to them.
|
||
*
|
||
* Every staff listing goes through here, and the policies consult
|
||
* allowsFile()/allowsFolder() so direct access (download, details,
|
||
* edit…) respects the same boundary.
|
||
*
|
||
* @method Builder<File> files(User $user)
|
||
* @method Builder<Folder> folders(User $user)
|
||
*/
|
||
class StaffLibraryScope
|
||
{
|
||
/**
|
||
* Built queries, by user id. Building one is not free: it walks the
|
||
* assigned clients and File::scopeVisibleToClient runs four immediate
|
||
* lookups for each of them, none of which depend on the query being
|
||
* built. Callers ask over and over — the policies ask once per row on
|
||
* a listing, and Gate resolves a fresh policy for every check — so the
|
||
* same handful of lookups were being repeated per row.
|
||
*
|
||
* A clone goes back rather than the query itself, since every caller
|
||
* adds to it. Registered with the container as `scoped`, so the memo
|
||
* lasts a request and is dropped between queue jobs.
|
||
*
|
||
* @var array<int, Builder<File>>
|
||
*/
|
||
private array $files = [];
|
||
|
||
/** @var array<int, Builder<Folder>> */
|
||
private array $folders = [];
|
||
|
||
/**
|
||
* @return Builder<File>
|
||
*/
|
||
public function files(User $user): Builder
|
||
{
|
||
return clone ($this->files[$user->id] ??= $this->buildFiles($user));
|
||
}
|
||
|
||
/**
|
||
* @return Builder<File>
|
||
*/
|
||
private function buildFiles(User $user): Builder
|
||
{
|
||
$query = File::query();
|
||
|
||
if (! $user->isClientScoped()) {
|
||
return $query;
|
||
}
|
||
|
||
// Own uploads ∪ files visible to each assigned client. The
|
||
// per-client visibility is File::scopeVisibleToClient — the single
|
||
// source of truth for client file access — so no rule is duplicated.
|
||
return $query->where(function (Builder $outer) use ($user): void {
|
||
$outer->where('uploaded_by', $user->id);
|
||
|
||
foreach ($user->assignedClients as $client) {
|
||
$outer->orWhere(fn (Builder $scoped) => $scoped->visibleToClient($client));
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* @return Builder<Folder>
|
||
*/
|
||
public function folders(User $user): Builder
|
||
{
|
||
return clone ($this->folders[$user->id] ??= $this->buildFolders($user));
|
||
}
|
||
|
||
/**
|
||
* @return Builder<Folder>
|
||
*/
|
||
private function buildFolders(User $user): Builder
|
||
{
|
||
$query = Folder::query();
|
||
|
||
if (! $user->isClientScoped()) {
|
||
return $query;
|
||
}
|
||
|
||
return $query->where(function (Builder $outer) use ($user): void {
|
||
$outer->where('created_by', $user->id);
|
||
|
||
foreach ($user->assignedClients as $client) {
|
||
$outer->orWhere(fn (Builder $scoped) => $scoped->visibleToClient($client));
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Whether a scoped staff member may reach this specific file. Unscoped
|
||
* staff always may; the policies AND this into their permission checks
|
||
* so direct access respects the same boundary as the listings.
|
||
*/
|
||
public function allowsFile(User $user, File $file): bool
|
||
{
|
||
if (! $user->isClientScoped()) {
|
||
return true;
|
||
}
|
||
|
||
return $this->files($user)->whereKey($file->getKey())->exists();
|
||
}
|
||
|
||
public function allowsFolder(User $user, Folder $folder): bool
|
||
{
|
||
if (! $user->isClientScoped()) {
|
||
return true;
|
||
}
|
||
|
||
return $this->folders($user)->whereKey($folder->getKey())->exists();
|
||
}
|
||
|
||
/**
|
||
* Client ids a user may share with, or null when unrestricted (the
|
||
* whole roster). A scoped user may only share with their assigned
|
||
* clients.
|
||
*
|
||
* @return list<int>|null
|
||
*/
|
||
public function assignableClientIds(User $user): ?array
|
||
{
|
||
if (! $user->isClientScoped()) {
|
||
return null;
|
||
}
|
||
|
||
return array_values($user->assignedClients()->pluck('users.id')->map(fn ($id): int => (int) $id)->all());
|
||
}
|
||
|
||
/**
|
||
* Group ids a user may share with, or null when unrestricted. A scoped
|
||
* user may share with any group that contains at least one of their
|
||
* assigned clients.
|
||
*
|
||
* @return list<int>|null
|
||
*/
|
||
public function assignableGroupIds(User $user): ?array
|
||
{
|
||
if (! $user->isClientScoped()) {
|
||
return null;
|
||
}
|
||
|
||
$clientIds = $this->assignableClientIds($user) ?? [];
|
||
|
||
if ($clientIds === []) {
|
||
return [];
|
||
}
|
||
|
||
return array_values(Group::query()
|
||
->whereHas('members', fn (Builder $members) => $members->whereIn('users.id', $clientIds))
|
||
->pluck('id')->map(fn ($id): int => (int) $id)->all());
|
||
}
|
||
|
||
public function canAssignClient(User $user, User $client): bool
|
||
{
|
||
$ids = $this->assignableClientIds($user);
|
||
|
||
return $ids === null || in_array($client->id, $ids, true);
|
||
}
|
||
|
||
public function canAssignGroup(User $user, Group $group): bool
|
||
{
|
||
$ids = $this->assignableGroupIds($user);
|
||
|
||
return $ids === null || in_array($group->id, $ids, true);
|
||
}
|
||
}
|