'boolean', 'allow_client_uploads' => 'boolean', ]; } protected static function slugFallback(): string { return 'folder'; } /** * @return BelongsTo */ public function parent(): BelongsTo { return $this->belongsTo(Folder::class, 'parent_id'); } /** * @return HasMany */ public function children(): HasMany { return $this->hasMany(Folder::class, 'parent_id'); } /** * @return HasMany */ public function files(): HasMany { return $this->hasMany(File::class); } /** * @return HasMany */ public function assignments(): HasMany { return $this->hasMany(FolderAssignment::class); } /** * @return BelongsTo */ 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 */ 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; } /** * 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 */ 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 $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 $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 */ 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)); } }