Files
projectsend/app/Modules/Files/Http/Controllers/FoldersController.php
T
ignacionelson 5493955bea Show staff where a file stands, and say the same through the API
Staff keep seeing every file they always saw — withholding is about
recipients, not about the library — so the library now carries the state
on the row: Checking, Quarantined, Released, or Not scanned with the
reason behind it. Nothing at all for a clean file, which is the common
case.

The API says the same in a `scan` object on every file, with an
`available` flag so a caller need not learn which of six states mean
"you can have it", and `scan_status` is a filter, so an integration can
wait for the file it just uploaded or collect what is in quarantine.
The download endpoint answers 423 for a file that is not available,
which it already did through the shared controller.

Re-exported the OpenAPI document.
2026-09-16 14:45:13 -03:00

550 lines
24 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Modules\Files\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Comments\Access\VisibleCommentScope;
use App\Modules\Comments\CommentingRules;
use App\Modules\Files\Access\ClientIdentityScope;
use App\Modules\Files\Access\DownloadAllowance;
use App\Modules\Files\Access\ShareTargets;
use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Files\Folders\BreadcrumbBuilder;
use App\Modules\Files\Folders\FolderService;
use App\Modules\Files\Models\Category;
use App\Modules\Files\Models\File;
use App\Modules\Files\Scanning\NotScannedReason;
use App\Modules\Files\Scanning\ScanningConfig;
use App\Modules\Files\Scanning\ScanStatus;
use App\Modules\Files\Models\Folder;
use App\Modules\Files\Versions\FileVersionLinks;
use App\Modules\Groups\Models\Group;
use App\Support\ConcatenatedPagination;
use App\Support\Pagination;
use App\Support\PublicUrl;
use App\Support\Rules;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Gate;
use Inertia\Inertia;
use Inertia\Response;
/**
* Staff folder library (staff-only, permission-refined). Folders are
* never assigned to staff — visibility comes from StaffLibraryScope.
*/
class FoldersController extends Controller
{
/**
* Folders and files share one page window (folders first, files
* filling whatever room is left) rather than folders being an
* unbounded, always-fully-shown block above a separately paginated
* file list — see index()'s own docblock for why.
*/
private const PER_PAGE = 25;
public function __construct(
private readonly FolderService $folders,
private readonly StaffLibraryScope $scope,
private readonly ActivityLogger $activity,
private readonly PublicUrl $publicUrl,
private readonly ShareTargets $shareTargets,
private readonly ClientIdentityScope $identity,
private readonly BreadcrumbBuilder $breadcrumbs,
private readonly CommentingRules $commenting,
private readonly VisibleCommentScope $comments,
private readonly FileVersionLinks $versionLinks,
private readonly DownloadAllowance $allowance,
) {}
/**
* Folders and files are two independently-ordered sequences
* (folders by name, files by whatever the current mode sorts by)
* concatenated into one flat sequence and sliced into fixed
* PER_PAGE-sized pages — folders first, files filling whatever room
* is left once folders run out. This keeps every folder and file
* reachable via Next/Previous with a bounded per-page query cost,
* regardless of how many folders or files exist — the previous
* "fetch every folder, unpaginated, on every file page" approach
* both repeated the same folder rows on every page and had no cap
* at all for a directory with hundreds/thousands of subfolders.
*/
public function index(Request $request): Response|RedirectResponse
{
$user = $request->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<string, Builder<Model>> $sequences */
$sequences = ['folders' => $folders, 'files' => $files];
$sliced = ConcatenatedPagination::slice(
$sequences,
$page,
self::PER_PAGE,
['path' => $request->url(), 'query' => $request->query()],
);
/** @var Collection<int, Folder> $folderRows */
$folderRows = $sliced['items']['folders'];
/** @var Collection<int, File> $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<string, mixed>
*/
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<int, int> $commentCounts
* @param array<int, int> $pendingCounts
* @param array<int, array{previous: array{id: int, name: string, url: string|null}|null, next: array{id: int, name: string, url: string|null}|null}> $versions
* @return array<string, mixed>
*/
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);
}
}