mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-17 09:05:08 +00:00
616a355d54
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."
329 lines
11 KiB
PHP
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));
|
|
}
|
|
}
|