Files
projectsend/app/Modules/Files/Access/ShareTargets.php
ignacionelson 6e47d76ba6 ProjectSend 2.0.0
Client file sharing, rebuilt from the ground up: a private area per
client, resumable uploads, folders, groups and categories, sharing with
expiry dates and download limits, comments, file versions, an activity
log, a REST API, and sixteen languages.

This repository begins here. ProjectSend 2 was developed privately, and
that development history is not published — the previous generation
remains available, with its own history, at projectsend/legacy.

Free software under the GNU General Public License v2, or (at your
option) any later version.
2026-08-14 01:38:12 -03:00

148 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\FileAssignment;
use App\Modules\Files\Models\Folder;
use App\Modules\Groups\Models\Group;
use App\Modules\Identity\UserType;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
/**
* Who a file or folder is shared with, and who it could still be shared
* with — the lists behind every sharing panel.
*
* Splitting a subject's assignments into clients and groups was written out
* at four call sites and had already started to drift: one compared
* assignable_type against User::class while the others used the morph class.
* Those agree only for as long as no morph map is registered, which is
* exactly the kind of difference that goes unnoticed until it doesn't.
*
* Scoping is not re-derived here. Which clients and groups a staff member
* may share with belongs to StaffLibraryScope, and this asks it.
*/
class ShareTargets
{
public function __construct(private readonly StaffLibraryScope $scope) {}
/**
* The clients and groups a subject is already shared with, as id/name
* pairs. Neutral keys, so callers can nest it ('shares' on the details
* panel) or flatten it (the edit pages' assigned_* props).
*
* @return array{clients: list<array{id: int, name: string}>, groups: list<array{id: int, name: string}>}
*/
public function assigned(File|Folder $subject): array
{
[$clientIds, $groupIds] = $this->assignedIds($subject);
return [
'clients' => $this->clients($clientIds),
'groups' => $this->groups($groupIds),
];
}
/**
* The assigned lists plus everything still available to share with,
* narrowed to what this viewer is allowed to reach.
*
* @return array{assigned_clients: list<array{id: int, name: string}>, assigned_groups: list<array{id: int, name: string}>, available_clients: list<array{id: int, name: string}>, available_groups: list<array{id: int, name: string}>}
*/
public function forSubject(File|Folder $subject, User $viewer): array
{
[$clientIds, $groupIds] = $this->assignedIds($subject);
// Resolved once each: these hit the database, and the call sites this
// replaces evaluated them twice apiece — once to decide whether to
// filter, once to supply the ids.
$assignableClientIds = $this->scope->assignableClientIds($viewer);
$assignableGroupIds = $this->scope->assignableGroupIds($viewer);
$availableClients = User::query()
->where('type', UserType::Client)
->whereNotIn('id', $clientIds)
->when($assignableClientIds !== null, fn (Builder $query) => $query->whereIn('id', $assignableClientIds))
->orderBy('name')
->get();
$availableGroups = Group::query()
->whereNotIn('id', $groupIds)
->when($assignableGroupIds !== null, fn (Builder $query) => $query->whereIn('id', $assignableGroupIds))
->orderBy('name')
->get();
$assigned = $this->assigned($subject);
return [
'assigned_clients' => $assigned['clients'],
'assigned_groups' => $assigned['groups'],
'available_clients' => $this->pairs($availableClients),
'available_groups' => $this->pairs($availableGroups),
];
}
/**
* @return array{0: Collection<int, mixed>, 1: Collection<int, mixed>}
*/
private function assignedIds(File|Folder $subject): array
{
// A file revision owns no assignment rows — it inherits the
// recipients of its version chain's root (File::sharingOwnerId).
// Reading its own rows would show every revision as shared with
// nobody, which is the opposite of what is true.
$assigned = $subject instanceof File
? FileAssignment::query()->where('file_id', $subject->sharingOwnerId())->get()
: $subject->assignments()->get();
// getMorphClass() rather than ::class, so this keeps agreeing with
// what was written to assignable_type even if a morph map is added.
return [
$assigned->where('assignable_type', (new User)->getMorphClass())->pluck('assignable_id'),
$assigned->where('assignable_type', (new Group)->getMorphClass())->pluck('assignable_id'),
];
}
/**
* @param Collection<int, mixed> $ids
* @return list<array{id: int, name: string}>
*/
private function clients(Collection $ids): array
{
return $this->pairs(User::query()->whereIn('id', $ids)->orderBy('name')->get());
}
/**
* @param Collection<int, mixed> $ids
* @return list<array{id: int, name: string}>
*/
private function groups(Collection $ids): array
{
return $this->pairs(Group::query()->whereIn('id', $ids)->orderBy('name')->get());
}
/**
* Built by iteration rather than map()->values(): Collection's value
* template is invariant, so passing a Collection<Group> where
* Collection<User|Group> is declared does not type-check, and the
* result of map() is not provably a list either.
*
* @param iterable<User|Group> $models
* @return list<array{id: int, name: string}>
*/
private function pairs(iterable $models): array
{
$pairs = [];
foreach ($models as $model) {
$pairs[] = ['id' => (int) $model->getKey(), 'name' => $model->name];
}
return $pairs;
}
}