Files
projectsend/app/Modules/Files/Models/Folder.php
T
ignacionelson 616a355d54 Give each client a folder of their own, standing in for the root
A client who may create folders creates them at the top of the library,
beside the ones staff made, and their uploads land at the root too. An
administrator opening /files gets one flat pile with nothing saying which
parts belong to whom.

With the new "Give each client a folder of their own" setting, every new
client gets a folder named after them and it acts as their root: what they
upload and any folder they create goes inside it. /files becomes a list of
clients rather than a pile.

The sentence this feature has to keep true: **the home is a default
location, not a boundary.** Folder::scopeVisibleToClient is untouched, so a
folder staff shared with a client still reaches them and sits beside their
own. Making the home a jail would have silently revoked every share that
already exists -- a data-access change wearing the clothes of a tidying-up
feature. There is a test named after that rule.

What the client sees is the *inside* of their folder, not a folder wearing
their own name, which is not information to them. The breadcrumb is trimmed
of it for the same reason: "Invoices", not "Acme Ltd / Invoices".

Some decisions worth naming:

- **A column, not a convention.** `folders.home_for_user_id`, unique.
  Matching on the name breaks the moment two clients share one, and
  `created_by` plus a null parent catches every root folder a client ever
  made themselves. The question is asked on each upload and each portal
  listing and the answer has to be exact.
- **created_by is the client**, because that is how scopeVisibleToClient
  already grants somebody their own folder -- no assignment row to keep in
  step with it. That is also why this writes the row rather than calling
  FolderService::create(), which takes created_by from auth()->id().
- **On model events**, not in the services that make and rename clients.
  There are nine of those (ClientAccounts, ClientProvisioning, the profile
  screen, two update endpoints, AccountConversion, invitations, LDAP,
  social) and a rule repeated in nine places is missing from the tenth.
- **Turning the setting on creates nothing.** Existing clients get a folder
  when an administrator presses a button that says how many are waiting,
  and it reports created/total/already-had afterwards. Somebody should be
  able to switch this on, look, and switch it off without having
  reorganised a library. It moves no files either.
- **Nobody deletes a home from a folder screen**, staff included, and the
  client cannot rename theirs -- they own it, so ownership alone would have
  let them, and its name follows the account anyway.
- **The name always follows the client**, over a hand-typed one. A folder
  still called "Acme Ltd" under an account now called something else
  misleads the administrator the feature exists for.

Verified in a real browser as well as in tests: the screen mounts, the
panel reads "24 of your existing clients have no folder yet", and pressing
the button answers "24 of 24 clients got a folder. 0 already had one."
2026-09-17 00:22:29 -03:00

329 lines
11 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Modules\Files\Models;
use App\Models\User;
use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Groups\Models\Group;
use App\Support\Concerns\HasUniqueSlug;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* A library folder. Staff see one shared tree; a folder becomes visible
* to a client either when it — or an ancestor — is explicitly shared
* (granting live access to the whole subtree), or when the client created
* it themselves.
*
* @property int $id
* @property string $name
* @property int|null $parent_id
* @property int|null $created_by
* @property string $path
* @property string $slug
* @property bool $public
* @property bool $allow_client_uploads
*/
class Folder extends Model
{
use HasUniqueSlug;
use SoftDeletes;
public const MAX_DEPTH = 10;
protected $guarded = [];
protected function casts(): array
{
return [
'public' => 'boolean',
'allow_client_uploads' => 'boolean',
];
}
protected static function slugFallback(): string
{
return 'folder';
}
/**
* @return BelongsTo<Folder, $this>
*/
public function parent(): BelongsTo
{
return $this->belongsTo(Folder::class, 'parent_id');
}
/**
* @return HasMany<Folder, $this>
*/
public function children(): HasMany
{
return $this->hasMany(Folder::class, 'parent_id');
}
/**
* @return HasMany<File, $this>
*/
public function files(): HasMany
{
return $this->hasMany(File::class);
}
/**
* @return HasMany<FolderAssignment, $this>
*/
public function assignments(): HasMany
{
return $this->hasMany(FolderAssignment::class);
}
/**
* @return BelongsTo<User, $this>
*/
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
/**
* Ancestor ids parsed from the materialized path (nearest first is
* not guaranteed; order is root→self).
*
* @return list<int>
*/
public function ancestorIds(): array
{
return array_values(array_filter(array_map('intval', explode('/', trim($this->path, '/')))));
}
public function depth(): int
{
return count($this->ancestorIds());
}
public function isOwnedBy(User $user): bool
{
return $this->created_by === $user->id;
}
/** Whether this folder stands in as some client's root. */
public function isHome(): bool
{
return $this->home_for_user_id !== null;
}
/**
* Self or any ancestor is public — the inheritance every file in this
* folder's subtree relies on (File::isEffectivelyPublic()), and what
* the file editor shows the user when a file's own public checkbox is
* grayed out.
*/
public function isEffectivelyPublic(): bool
{
return $this->publicSourceName() !== null;
}
/**
* Self's name if public, else the name of the nearest public ancestor
* (not necessarily the topmost one), else null. What the file editor
* names in the message under a grayed-out, inherited-public checkbox.
*/
public function publicSourceName(): ?string
{
if ($this->public) {
return $this->name;
}
$ancestorIds = $this->ancestorIds();
if ($ancestorIds === []) {
return null;
}
$publicAncestorNames = self::query()->whereIn('id', $ancestorIds)->where('public', true)->pluck('name', 'id');
foreach (array_reverse($ancestorIds) as $id) {
if (isset($publicAncestorNames[$id])) {
return $publicAncestorNames[$id];
}
}
return null;
}
/**
* Whether $user may put content into $folder (null = loose at the
* root, always allowed).
*
* **Read the name as "may place into", not "may upload into".** Every
* way a file arrives in a folder has to come through here, and the
* name cost us one advisory already: the publication rule below was
* written for GHSA-237r-jx85-j3hr and wired into the upload paths
* alone, because those are what the name suggested. Moving a file in,
* bulk-moving a selection in, reparenting one through the edit form,
* and dragging a whole folder into a public parent all put content
* somewhere too, and none of them asked (GHSA-rxf8-wh8v-jm9j). They
* ask now. Anything new that writes a `folder_id` or a `parent_id`
* belongs on this list.
*
* Staff are held to the library boundary they are held to everywhere
* else: an unscoped staff member may use any folder, a client-scoped
* one only the folders StaffLibraryScope already shows them. Callers
* that have already resolved the destination through
* StaffLibraryScope::folders() have answered that half — the two are
* the same query — and call this for the publication half.
*
* For a client this is unchanged, and is still the whole of the
* check: they own the folder, or it is a public folder that opts into
* client uploads and their role permits uploading into public folders
* at all.
*/
public static function uploadableBy(User $user, ?self $folder): bool
{
if ($folder === null) {
return true;
}
if ($user->isStaff()) {
if (! app(StaffLibraryScope::class)->allowsFolder($user, $folder)) {
return false;
}
// Being allowed to reach the folder is not the same as being
// allowed to publish, and putting a file in a public folder
// publishes it: isEffectivelyPublic() is "my own flag, or my
// folder's". So the destination reaches the property that
// `upload_public` guards, without ever touching the switch
// (GHSA-237r-jx85-j3hr).
//
// The keys already say this. The client branch below has always
// asked for `upload_to_public_folders` here, and
// MyFilesController's picker calls that the established meaning
// of the two — it was simply never asked on a staff role, which
// left that permission doing nothing at all for staff.
//
// Effectively public, not `public`: the flag is inherited down
// a subtree, so a private folder inside a public one publishes
// just the same and a check on the folder's own flag would walk
// straight past it.
return ! $folder->isEffectivelyPublic()
|| $user->can('upload_public')
|| $user->can('upload_to_public_folders');
}
return $folder->isOwnedBy($user)
|| ($folder->public && $folder->allow_client_uploads && $user->can('upload_to_public_folders'));
}
/**
* The path prefix matching this folder's whole subtree (self + all
* descendants share this prefix in their path).
*/
public function subtreePathPrefix(): string
{
return $this->path.$this->id.'/';
}
/**
* This folder's id plus every descendant's — the live subtree, used
* anywhere "every file inside this folder, recursively" is needed
* (e.g. zipping a folder).
*
* @return list<int>
*/
public function subtreeFolderIds(): array
{
$descendantIds = self::query()
->where('path', 'like', $this->subtreePathPrefix().'%')
->pluck('id')
->map(fn ($id): int => (int) $id)
->all();
return array_values([$this->id, ...$descendantIds]);
}
/**
* Folders visible to a client: any folder shared with them or their
* groups (plus every descendant of such a folder, live subtree), or
* any folder they created themselves, anywhere in that visible tree
* (see MyFoldersController::store's parent_id validation, which only
* ever lets a client nest a new folder inside this same set).
*
* @param Builder<Folder> $query
*/
public function scopeVisibleToClient(Builder $query, User $client): void
{
$sharedIds = self::sharedFolderIds($client);
$query->where(function (Builder $inner) use ($sharedIds, $client): void {
$inner->where('created_by', $client->id);
if ($sharedIds !== []) {
$inner->orWhereIn('id', $sharedIds);
foreach (self::query()->whereIn('id', $sharedIds)->get() as $shared) {
$inner->orWhere('path', 'like', $shared->subtreePathPrefix().'%');
}
}
});
}
/**
* Folders publicly reachable on the public listing site: any folder
* marked public, plus every descendant in its live subtree (mirrors
* scopeVisibleToClient's shared-subtree shape). No Gate/auth involved
* — same reasoning as File::scopeStandalonePublic.
*
* @param Builder<Folder> $query
*/
public function scopePubliclyVisible(Builder $query): void
{
$publicIds = self::query()->where('public', true)->pluck('id')->map(fn ($id): int => (int) $id)->all();
if ($publicIds === []) {
$query->whereRaw('1 = 0');
return;
}
$query->where(function (Builder $inner) use ($publicIds): void {
$inner->whereIn('id', $publicIds);
foreach (self::query()->whereIn('id', $publicIds)->get() as $public) {
$inner->orWhere('path', 'like', $public->subtreePathPrefix().'%');
}
});
}
/**
* Ids of folders shared directly with the client or via a group.
*
* @return list<int>
*/
public static function sharedFolderIds(User $client): array
{
$groupIds = $client->memberOfGroups()->pluck('groups.id')->all();
$ids = FolderAssignment::query()
->where(function (Builder $query) use ($client, $groupIds): void {
$query->where(function (Builder $direct) use ($client): void {
$direct->where('assignable_type', (new User)->getMorphClass())
->where('assignable_id', $client->id);
})->orWhere(function (Builder $viaGroup) use ($groupIds): void {
$viaGroup->where('assignable_type', (new Group)->getMorphClass())
->whereIn('assignable_id', $groupIds);
});
})
->pluck('folder_id')
->all();
return array_values(array_map('intval', $ids));
}
}