user(); assert($user !== null); $validated = $request->validate([ 'search' => ['nullable', 'string', 'max:255'], 'folder' => ['nullable', 'integer'], 'category' => ['nullable', 'integer', 'exists:categories,id'], // Not a 'boolean' rule: that only accepts true/false/0/1/'0'/'1', // rejecting the literal "true"/"" the frontend checkbox sends. // $request->boolean() below coerces any of those safely, so // there's nothing for a validation rule to add here. ]); $search = trim($validated['search'] ?? ''); $searching = $search !== ''; $categoryId = $validated['category'] ?? null; $expired = $request->boolean('expired'); // A search term, a category filter, or the expired-only filter all // switch to a flat view across the whole visible library; // otherwise it's folder browsing. $flat = $searching || $categoryId !== null || $expired; $folderQuery = $this->scope->folders($user)->withCount(['children', 'files']); // `downloads` unconditionally — the library has always shown a // downloads column. The viewer's own count is only added when // something on this install is actually limited. $fileQuery = $this->allowance->withOwnCount( $this->scope->files($user)->with('uploader.role', 'categories', 'folder') ->withCount(['assignments', 'downloads']), $user, ); if ($flat) { $current = null; $folders = $searching ? $folderQuery->where('name', 'like', "%{$search}%")->orderBy('name') : $folderQuery->whereRaw('1 = 0'); $files = $fileQuery ->when($searching, fn (Builder $q) => $q->where(fn (Builder $w) => $w ->where('name', 'like', "%{$search}%")->orWhere('original_name', 'like', "%{$search}%"))) ->when($categoryId !== null, fn (Builder $q) => $q ->whereHas('categories', fn (Builder $c) => $c->where('categories.id', $categoryId))) ->when($expired, fn (Builder $q) => $q->expired()) ->orderBy('name'); } else { $current = $request->integer('folder') > 0 ? $this->scope->folders($user)->find($request->integer('folder')) : null; $folders = $folderQuery ->where('parent_id', $current?->id) ->orderBy('name'); $files = $fileQuery ->where('folder_id', $current?->id) ->orderByDesc('created_at'); } $page = Paginator::resolveCurrentPage(); // ConcatenatedPagination is intentionally model-agnostic (Folder // here, File/Group elsewhere) — Larastan's Builder generic is // invariant, so a heterogeneous array of builders needs an // explicit widen/narrow at each call site; sound at runtime since // each key's builder only ever queries its own model. /** @var array> $sequences */ $sequences = ['folders' => $folders, 'files' => $files]; $sliced = ConcatenatedPagination::slice( $sequences, $page, self::PER_PAGE, ['path' => $request->url(), 'query' => $request->query()], ); /** @var Collection $folderRows */ $folderRows = $sliced['items']['folders']; /** @var Collection $fileRows */ $fileRows = $sliced['items']['files']; // A stale/guessed ?page= beyond what actually exists (same // protection OrphanFilesController::index() already has) would // otherwise silently render an empty page instead of the real one. if (Pagination::isPastLastPage($sliced['paginator'], $page)) { return redirect()->route('files.index', array_filter([ 'search' => $search !== '' ? $search : null, 'folder' => $current?->id, 'category' => $categoryId, 'expired' => $expired ? 'true' : null, 'page' => Pagination::redirectPage($sliced['paginator']), ])); } // Resolved once for the whole page rather than per row — see // VisibleCommentScope::countsFor. $fileIds = array_values(array_map(intval(...), $fileRows->pluck('id')->all())); $commentCounts = $this->comments->countsFor($user, $fileRows); $pendingCounts = $this->comments->pendingCountsFor($user, $fileIds); // Two queries for the page, not two per row — same batching shape // as the comment counts above. $versions = $this->versionLinks->forMany($fileRows, $user, fn (File $other): string => route('files.edit', $other, false)); return Inertia::render('files/index', [ 'folder' => $current === null ? null : ['id' => $current->id, 'name' => $current->name], 'breadcrumb' => $flat ? [] : $this->breadcrumbs->for($current), 'folders' => $folderRows->map(fn (Folder $folder): array => $this->folderRow($user, $folder))->all(), 'files' => $fileRows->map(fn (File $file): array => $this->fileRow($user, $file, $commentCounts, $pendingCounts, $versions))->all(), 'pagination' => Pagination::meta($sliced['paginator']), 'search' => $search, 'searching' => $flat, 'category' => $categoryId, 'expired' => $expired, 'categories' => Category::query()->orderBy('name')->get(['id', 'name', 'color']) ->map(fn (Category $category): array => ['id' => $category->id, 'name' => $category->name, 'color' => $category->color])->all(), // Narrowed like every other folder listing on this screen: an // unscoped staff member gets the whole tree, a client-scoped // one only their own. Unfiltered this handed a scoped staffer // every folder name and id on the installation. 'folder_options' => $this->scope->folders($user)->orderBy('path')->orderBy('name')->get() ->map(fn (Folder $folder): array => ['id' => $folder->id, 'name' => $folder->name])->all(), 'can_create_folders' => $user->can('create_own_folders'), 'can_upload' => $user->can('upload'), 'can_manage_public' => $user->can('upload_public'), 'comments_enabled' => $this->commenting->enabled(), ]); } /** * What the scanner made of a file, for a staff member's list. * * Staff see every file they always saw, with its state on it — * withholding applies to recipients, not to the library. Null while * scanning is off so nothing is decorated on an installation that does * not use it. * * @return array{status: string, note: string|null}|null */ private function scanState(File $file): ?array { if (! app(ScanningConfig::class)->enabled() && $file->scan_status === ScanStatus::NotScanned) { return null; } $note = $file->scan_note; return [ 'status' => $file->scan_status->value, // A reason is a key and is translated here; a threat name is // the scanner's own words and is passed through. 'note' => $note === null ? null : (NotScannedReason::tryFrom($note)?->label() !== null ? (string) __(NotScannedReason::from($note)->label()) : $note), ]; } /** * @return array */ private function folderRow(User $user, Folder $folder): array { return [ 'id' => $folder->id, 'name' => $folder->name, // Effective status (self or inherited from a public ancestor), // not just the folder's own flag — this is the "will visitors // on the public site see this" badge, same as fileRow() below. 'public' => $folder->isEffectivelyPublic(), // Unlike the badge above, a working public page only exists for // a folder that is *itself* flagged public — PublicFoldersController // looks up strictly on `public = true`, so a folder that's only // effectively public via an ancestor has no page of its own. 'public_url' => $folder->public ? $this->publicUrl->for($folder) : null, 'children_count' => $folder->children_count, 'files_count' => $folder->files_count, 'shared_count' => $folder->assignments()->count(), 'can_update' => Gate::forUser($user)->allows('update', $folder), 'can_delete' => Gate::forUser($user)->allows('delete', $folder), ]; } /** * @param array $commentCounts * @param array $pendingCounts * @param array $versions * @return array */ private function fileRow(User $user, File $file, array $commentCounts = [], array $pendingCounts = [], array $versions = []): array { return [ 'id' => $file->id, 'name' => $file->name, 'original_name' => $file->original_name, 'mime_type' => $file->mime_type, 'size' => $file->size, // The whole block goes, not just the name: type and role // describe the same person, and "a client uploaded this" on a // row whose uploader is off this viewer's roster narrows who // it could be just as effectively as naming them. 'uploader' => ($file->uploader !== null && $this->identity->permits($user, $file->uploader)) ? [ 'name' => $file->uploader->name, 'type' => $file->uploader->type->value, 'role' => $file->uploader->role?->name, ] : null, 'public' => $file->isEffectivelyPublic(), 'expired' => $file->isExpired(), // Null while scanning is off, so a library that does not use // it carries no badge. 'scan' => $this->scanState($file), // No link at all once expired — the public route 404s past // expiry too (see File::scopeNotExpired's callers), so there's // no point offering a button that leads to a dead page. 'public_url' => ($file->isEffectivelyPublic() && ! $file->isExpired()) ? $this->publicUrl->for($file) : null, 'assignments_count' => $file->assignments_count, 'downloads_count' => $file->downloads_count, // Staff are subject to a limit like anyone else — only the // file's own uploader is exempt — so the library shows the // same spent state the portal does. 'download_limit' => $this->allowance->summaryFor($file, $user), 'comments_count' => $commentCounts[$file->id] ?? 0, // Only ever non-zero for a moderator: a staff member who // cannot approve should not be shown a badge asking them to. 'pending_comments_count' => $pendingCounts[$file->id] ?? 0, 'created_at' => $file->created_at?->toIso8601String(), // Already narrowed to what this viewer may be told about; a // null end is "no such link, or not yours to know". 'version' => $versions[$file->id] ?? ['previous' => null, 'next' => null], 'can_update' => Gate::forUser($user)->allows('update', $file), 'can_delete' => Gate::forUser($user)->allows('delete', $file), 'categories' => $file->categories->map(fn (Category $category): array => [ 'id' => $category->id, 'name' => $category->name, 'color' => $category->color, ])->values()->all(), ]; } public function edit(Request $request, Folder $folder): Response { $user = $request->user(); assert($user !== null); Gate::forUser($user)->authorize('view', $folder); return Inertia::render('files/folder', [ 'folder' => [ 'id' => $folder->id, 'name' => $folder->name, 'parent_id' => $folder->parent_id, 'public' => $folder->public, 'allow_client_uploads' => $folder->allow_client_uploads, 'slug' => $folder->slug, ], 'public_url' => $folder->public ? $this->publicUrl->for($folder) : null, 'breadcrumb' => $this->breadcrumbs->for($folder), 'can_update' => Gate::forUser($user)->allows('update', $folder), 'can_manage_public' => $user->can('upload_public'), ...$this->shareTargets->forSubject($folder, $user), ]); } public function store(Request $request): RedirectResponse { $user = $request->user(); // Requires upload too, same as the client portal's MyFoldersController::store() // — a folder nobody can put anything in isn't useful on its own. abort_unless($user !== null && $user->can('create_own_folders') && $user->can('upload'), 403); $validated = $request->validate([ 'name' => ['required', 'string', 'max:255'], 'parent_id' => Rules::folderId(), 'public' => ['sometimes', 'boolean'], 'slug' => Rules::slug('folders'), 'allow_client_uploads' => ['sometimes', 'boolean'], ]); $parent = $this->resolveParent($user, $validated['parent_id'] ?? null); $folder = $this->folders->create($validated['name'], $parent); // Only a user who can manage public state may set it on create — // create_own_folders alone doesn't imply upload_public. if ($user->can('upload_public')) { $public = $validated['public'] ?? false; $folder->update([ 'public' => $public, 'slug' => $public ? (($validated['slug'] ?? '') ?: $folder->slug) : $folder->slug, 'allow_client_uploads' => $public && ($validated['allow_client_uploads'] ?? false), ]); } $this->activity->log(Action::FolderCreated, subject: $folder); if ($folder->public) { $this->activity->log(Action::FolderMadePublic, subject: $folder, context: [ 'allow_client_uploads' => $folder->allow_client_uploads, 'slug' => $folder->slug, ]); } return back()->with('success', __('Folder created.')); } public function update(Request $request, Folder $folder): RedirectResponse { $user = $request->user(); Gate::authorize('update', $folder); $validated = $request->validate([ 'name' => ['required', 'string', 'max:255'], 'public' => ['sometimes', 'boolean'], // Omitting the field on an update leaves the current slug // alone — it must not silently change just because the name // did, same rule as FilesController::update. 'slug' => Rules::slug('folders', $folder->id), 'allow_client_uploads' => ['sometimes', 'boolean'], ]); $attributes = ['name' => $validated['name']]; $wasPublic = $folder->public; $wasAllowClientUploads = $folder->allow_client_uploads; // Only a user who can manage public state may change it — a user // who can rename/share a folder but lacks upload_public leaves its // public state exactly as it was. if ($user?->can('upload_public') === true) { $public = $validated['public'] ?? $folder->public; $attributes['public'] = $public; $attributes['slug'] = $public ? (($validated['slug'] ?? '') ?: ($folder->slug ?: Folder::uniqueSlugFrom($validated['name'], $folder->id))) : $folder->slug; $attributes['allow_client_uploads'] = $public && ($validated['allow_client_uploads'] ?? false); } $folder->update($attributes); $this->activity->log(Action::FolderRenamed, subject: $folder); if ($folder->public && (! $wasPublic || $folder->allow_client_uploads !== $wasAllowClientUploads)) { $this->activity->log(Action::FolderMadePublic, subject: $folder, context: [ 'allow_client_uploads' => $folder->allow_client_uploads, 'slug' => $folder->slug, ]); } elseif ($wasPublic && ! $folder->public) { $this->activity->log(Action::FolderMadePrivate, subject: $folder); } return back(); } public function move(Request $request, Folder $folder): RedirectResponse { Gate::authorize('update', $folder); $validated = $request->validate([ 'parent_id' => Rules::folderId(), ]); $user = $request->user(); $newParent = $this->resolveParent($user, $validated['parent_id'] ?? null); // A folder carries its contents with it, and a folder inside a // public one is public — isEffectivelyPublic() reads the whole // ancestry. So dropping a private folder into a public parent // publishes every file in its subtree at once, which is the same // act the upload path refuses without `upload_public`. The flag on // this screen is already guarded (update() above leaves public // state alone without the permission); the placement was not // (GHSA-rxf8-wh8v-jm9j). if ($user !== null) { abort_unless(Folder::uploadableBy($user, $newParent), 403); } $this->folders->move($folder, $newParent); $this->activity->log(Action::FolderMoved, subject: $folder); return back(); } public function destroy(Request $request, Folder $folder): RedirectResponse { Gate::authorize('delete', $folder); $viewer = $request->user(); assert($viewer !== null); // Deleting a folder cascades to every file in its subtree, and a // File's `deleted` hook removes the bytes from disk — there is no // restore. Authorizing the folder is not authorizing its contents: // FilePolicy::delete asks for `delete_others_files` on somebody // else's upload, and for the library boundary on top of that, and // neither question is asked anywhere on this path. // // MyFoldersController::destroy already refuses for the client half // of the same cascade, in the same words. This is the staff half. $blocked = $this->undeletableFileCount($viewer, $folder); if ($blocked > 0) { return back()->with('error', trans_choice( 'This folder cannot be deleted: it holds :count file you may not delete.|This folder cannot be deleted: it holds :count files you may not delete.', $blocked, ['count' => (string) $blocked], )); } $name = $folder->name; $parentId = $folder->parent_id; $this->folders->delete($folder); $this->activity->log(Action::FolderDeleted, context: ['name' => $name]); return redirect()->route('files.index', $parentId !== null ? ['folder' => $parentId] : [])->with('success', __('Folder deleted.')); } /** * How many files in this folder's subtree the viewer may not delete. * * Asked as one count rather than FilePolicy::delete per file: a folder * can hold thousands, Gate resolves a fresh policy for every check, and * a per-row policy check on a listing is the cost 0a8b609e went to * some trouble to remove. The two halves of FilePolicy::delete are * expressible in SQL — the permission half is constant for this * viewer, and the library half is the query StaffLibraryScope already * memoises per request. * * Somebody holding both delete permissions and no library scope can * delete anything in the subtree by construction, so they never pay for * the query at all. */ private function undeletableFileCount(User $viewer, Folder $folder): int { $mayDeleteOwn = $viewer->can('delete_files'); $mayDeleteOthers = $viewer->can('delete_others_files'); $scoped = $viewer->isClientScoped(); if ($mayDeleteOwn && $mayDeleteOthers && ! $scoped) { return 0; } return File::query() ->whereIn('folder_id', $folder->subtreeFolderIds()) ->where(function (Builder $outer) use ($viewer, $mayDeleteOwn, $mayDeleteOthers, $scoped): void { if (! $mayDeleteOwn) { $outer->orWhere('uploaded_by', $viewer->id); } if (! $mayDeleteOthers) { $outer->orWhere(fn (Builder $others): Builder => $others ->whereNull('uploaded_by')->orWhere('uploaded_by', '!=', $viewer->id)); } if ($scoped) { $outer->orWhereNotIn('id', $this->scope->files($viewer)->select('id')); } }) ->count(); } private function resolveParent(?User $user, ?int $parentId): ?Folder { if ($user === null || $parentId === null) { return null; } return $this->scope->folders($user)->findOrFail($parentId); } }