Files
projectsend/app/Modules/Files/Http/Controllers/MyFilesController.php
T
denkfabrik-li 862765643b Don't name a subfolder to a client who cannot open it
MyFilesController::index() builds the portal's folder list in two
branches. At the root it narrows to $visibleIds; one level in it listed
every direct child of the folder being browsed, visible or not.

Opening one was still refused — $current is resolved through
visibleToClient() and 404s otherwise — so what escaped was the name, not
the contents. A name is worth protecting here for the same reason
VisibleCommentScope gives about its own boundary: it is what stops one
customer learning that another exists.

$visibleIds is computed once and wanted in three places. The root branch
narrows by it, the breadcrumb narrows by it, and the nested branch did
not. That is a gap rather than a distinction, and the class docblock had
already promised the opposite: "Group and internal folder names never
leak."

Reachable only where a folder is visible for the created_by reason
rather than by sharing. Inside a shared subtree every child matches by
path prefix anyway, so nothing leaks there. A client with
create_own_folders makes such a folder through POST /my-folders, and
staff can file anything inside it — FoldersController::store resolves its
parent through StaffLibraryScope, which is unfiltered for unscoped staff.

Files were never affected: that branch's query already starts from
File::query()->visibleToClient(). The breadcrumb already trims to the
first visible ancestor. Neither is touched.
2026-08-26 02:02:19 +02:00

343 lines
17 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Modules\Files\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Clients\ClientStorageUsage;
use App\Modules\Comments\Access\VisibleCommentScope;
use App\Modules\Comments\CommentingRules;
use App\Modules\Files\Access\DownloadAllowance;
use App\Modules\Files\Folders\BreadcrumbBuilder;
use App\Modules\Files\Models\Category;
use App\Modules\Files\Models\File;
use App\Modules\Files\Models\Folder;
use App\Modules\Files\Uploads\UploadExtensionPolicy;
use App\Modules\Files\Versions\FileVersionLinks;
use App\Modules\Files\Versions\FileVersions;
use App\Modules\Platform\Capabilities\CapabilityRegistry;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use App\Modules\Platform\Theming\PublicThemeRegistry;
use App\Support\ConcatenatedPagination;
use App\Support\Pagination;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Gate;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;
/**
* The client's browsable file view. Root shows loose directly-assigned
* files plus top-level shared folders; entering a shared folder shows
* its subfolders and files. Group and internal folder names never leak:
* a file assigned directly inside an unshared folder appears loose.
*
* index()'s props are consumed by every portal/themes/{key}/my-files.tsx
* — see docs/theming-files-checklist.md before changing this method's
* Inertia props or the behavior every theme is expected to preserve.
*/
class MyFilesController extends Controller
{
/**
* Folders and files share one page window — see FoldersController's
* equivalent constant/docblock for why (folders were previously
* fetched fully unbounded and repeated identically on every file
* page; this is the client-portal twin of that same fix).
*/
private const PER_PAGE = 25;
public function __construct(
private readonly Settings $settings,
private readonly ClientStorageUsage $storageUsage,
private readonly UploadExtensionPolicy $extensionPolicy,
private readonly PublicThemeRegistry $themes,
private readonly CapabilityRegistry $capabilities,
private readonly BreadcrumbBuilder $breadcrumbs,
private readonly CommentingRules $commenting,
private readonly VisibleCommentScope $comments,
private readonly DownloadAllowance $allowance,
private readonly FileVersions $versions,
private readonly FileVersionLinks $versionLinks,
) {}
public function index(Request $request): Response|RedirectResponse
{
$client = $request->user();
abort_unless($client !== null && $client->isClient(), 404);
$validated = $request->validate([
'search' => ['nullable', 'string', 'max:255'],
'folder' => ['nullable', 'integer'],
'category' => ['nullable', 'integer', 'exists:categories,id'],
'owner' => ['nullable', Rule::in(['mine', 'shared'])],
'sort' => ['nullable', Rule::in(['name', 'size', 'date'])],
'direction' => ['nullable', Rule::in(['asc', 'desc'])],
]);
$search = trim($validated['search'] ?? '');
$searching = $search !== '';
$categoryId = isset($validated['category']) ? (int) $validated['category'] : null;
$owner = $validated['owner'] ?? null;
$sort = $validated['sort'] ?? 'date';
$direction = $validated['direction'] ?? 'desc';
// Every folder the client may see: staff-shared subtrees plus any
// folder they created themselves, anywhere in that visible tree.
$visibleIds = array_values(Folder::query()->visibleToClient($client)->pluck('id')->map(fn ($id): int => (int) $id)->all());
// A search term, a category filter, or an owner filter all switch to
// a flat, global view across everything the client may see — same
// convention as the staff library (FoldersController) uses for
// search/category. Sorting never does: it only changes the order of
// whichever set (flat or folder-scoped) is already being shown.
$flat = $searching || $categoryId !== null || $owner !== null;
if ($flat) {
// `visibleToClient` is the single source of truth, so the
// privacy rule (never surfacing an unshared folder's name) holds:
// only shared folders match, and files are shown without folder
// context in the portal rows.
$current = null;
$folders = $searching
? Folder::query()->visibleToClient($client)->where('name', 'like', "%{$search}%")->orderBy('name')
: Folder::query()->whereRaw('1 = 0');
$filesQuery = File::query()->visibleToClient($client)
->when($searching, fn (Builder $q) => $q
->where(fn (Builder $w) => $w->where('name', 'like', "%{$search}%")->orWhere('original_name', 'like', "%{$search}%")));
} else {
// The folder being browsed must itself be visible to the client.
$current = null;
if ($request->integer('folder') > 0) {
$current = Folder::query()->visibleToClient($client)->find($request->integer('folder'));
abort_if($current === null, 404);
}
// Subfolders: at root, every visible folder whose parent isn't
// itself visible (top of each shared subtree, or a client-owned
// folder with no visible parent); inside a folder, the visible
// ones among its direct children.
//
// Both branches narrow to $visibleIds. Being handed a folder is
// not permission to read the names of everything filed inside
// it: a client-created folder is visible through created_by,
// which says nothing about a subfolder staff later put there.
if ($current === null) {
$folders = Folder::query()
->whereIn('id', $visibleIds)
->where(fn ($q) => $q->whereNull('parent_id')->orWhereNotIn('parent_id', $visibleIds))
->orderBy('name');
} else {
$folders = Folder::query()
->whereIn('id', $visibleIds)
->where('parent_id', $current->id)
->orderBy('name');
}
// Files: inside a folder, that folder's files; at root, only
// loosely (directly/group) assigned files with no folder — the
// ones in an unshared folder (or a visible one, browsed via its
// own listing) show here with no folder context.
$filesQuery = File::query()->visibleToClient($client);
if ($current === null) {
$filesQuery->where(fn (Builder $q) => $q->whereNull('folder_id')->orWhereNotIn('folder_id', $visibleIds));
} else {
$filesQuery->where('folder_id', $current->id);
}
}
$filesQuery
->when($categoryId !== null, fn (Builder $q) => $q
->whereHas('categories', fn (Builder $c) => $c->where('categories.id', $categoryId)))
->when($owner === 'mine', fn (Builder $q) => $q->where('uploaded_by', $client->id))
->when($owner === 'shared', fn (Builder $q) => $q->where('uploaded_by', '!=', $client->id));
$column = match ($sort) {
'name' => 'name',
'size' => 'size',
default => 'created_at',
};
// The download counts a spent-limit badge needs, attached only
// when this installation uses limits at all — otherwise every
// portal listing would pay two correlated subqueries per row for
// a feature nobody here has turned on.
$files = $this->allowance->withCounts($filesQuery, $client)
->with('categories', 'folder')
->orderBy($column, $direction);
$page = Paginator::resolveCurrentPage();
// ConcatenatedPagination is model-agnostic — Larastan's Builder
// generic is invariant, so a heterogeneous array of builders
// needs an explicit widen/narrow at the call site (sound at
// runtime since each key's builder only ever queries its own
// model). Same pattern as FoldersController::index().
/** @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()],
);
if (Pagination::isPastLastPage($sliced['paginator'], $page)) {
return redirect()->route('my-files.index', array_filter([
'search' => $search !== '' ? $search : null,
'folder' => $current?->id,
'category' => $categoryId,
'owner' => $owner,
'sort' => $sort !== 'date' ? $sort : null,
'direction' => $direction !== 'desc' ? $direction : null,
'page' => Pagination::redirectPage($sliced['paginator']),
]));
}
/** @var Collection<int, Folder> $folderRows */
$folderRows = $sliced['items']['folders'];
/** @var Collection<int, File> $fileRows */
$fileRows = $sliced['items']['files'];
$commentCounts = $this->comments->countsFor($client, $fileRows);
// Two queries for the page, not two per row. No URL resolver: the
// portal has no per-file page to link to, so a counterpart is named
// and not linked (see docs/theming-files-checklist.md).
$versions = $this->versionLinks->forMany($fileRows, $client);
$unreadComments = $this->comments->unreadCountsFor($client, array_values(array_map(intval(...), $fileRows->pluck('id')->all())));
return Inertia::render("portal/themes/{$this->themeKey()}/my-files", [
'folder' => $current === null ? null : ['id' => $current->id, 'name' => $current->name],
'breadcrumb' => $flat ? [] : $this->breadcrumbs->visible($current, $visibleIds),
'folders' => $folderRows->map(fn (Folder $folder): array => [
'id' => $folder->id,
'name' => $folder->name,
'is_mine' => $folder->created_by === $client->id,
'public' => $folder->isEffectivelyPublic(),
'can_update' => Gate::forUser($client)->allows('update', $folder),
'can_delete' => Gate::forUser($client)->allows('delete', $folder),
])->values()->all(),
// Comment counts for the page's rows, resolved in two queries
// for the whole page rather than one per row — see
// VisibleCommentScope::countsFor.
'comments_enabled' => $this->commenting->enabled(),
'files' => $fileRows->map(fn (File $file): array => [
'id' => $file->id,
'name' => $file->name,
'description' => $file->description,
'original_name' => $file->original_name,
'mime_type' => $file->mime_type,
'size' => $file->size,
'created_at' => $file->created_at?->toIso8601String(),
'is_mine' => $file->uploaded_by === $client->id,
// Effective status (own flag or inherited from a public
// folder) — same "will visitors on the public site see
// this" badge as the staff library shows.
'public' => $file->isEffectivelyPublic(),
'comments_count' => $commentCounts[$file->id] ?? 0,
'unread_comments_count' => $unreadComments[$file->id] ?? 0,
// Already narrowed to what this client may be told: a
// counterpart they were not given is null, not hidden by
// the theme. A theme must never filter this itself.
'version' => $versions[$file->id] ?? ['previous' => null, 'next' => null],
'categories' => $file->categories->map(fn (Category $category): array => [
'id' => $category->id, 'name' => $category->name, 'color' => $category->color,
])->values()->all(),
// Already decided — see DownloadAllowance::summaryFor.
// `blocked` means this client may no longer take a copy;
// the row still shows, which is the whole difference from
// an expired file.
'download_limit' => $this->allowance->summaryFor($file, $client),
])->values()->all(),
'pagination' => Pagination::meta($sliced['paginator']),
'search' => $search,
'searching' => $flat,
'category' => $categoryId,
'categories' => Category::query()->orderBy('name')->get(['id', 'name', 'color'])
->map(fn (Category $category): array => ['id' => $category->id, 'name' => $category->name, 'color' => $category->color])->all(),
'owner' => $owner,
'sort' => $sort,
'direction' => $direction,
'can_upload' => $client->can('upload'),
'can_upload_here' => Folder::uploadableBy($client, $current),
'can_create_folders' => $client->can('create_own_folders'),
// Whether a row is clickable to look at rather than only to
// take. Per page rather than per file: the mime type decides
// which files can be previewed and every theme already knows
// how to read one, so all this has to carry is whether the
// installation offers it here at all. See
// FileThumbnailController::preview, which re-checks it.
'preview_enabled' => $this->settings->get(Setting::ClientsCanPreviewFiles),
]);
}
public function upload(Request $request): Response
{
$client = $request->user();
abort_unless($client !== null && $client->isClient(), 404);
abort_unless($client->can('upload'), 403);
$folder = null;
if ($request->integer('folder') > 0) {
$folder = Folder::query()->visibleToClient($client)->find($request->integer('folder'));
abort_if($folder === null, 404);
abort_unless(Folder::uploadableBy($client, $folder), 403);
}
return Inertia::render('portal/upload', [
'allowed_extensions' => $this->extensionPolicy->hintFor($client),
'max_file_size_mb' => (int) $this->settings->get(Setting::MaxFileSizeMb),
'part_size_mb' => (int) config('projectsend.upload_part_size_mb'),
'remaining_bytes' => $this->storageUsage->remainingBytes($client),
'quota_bytes' => $this->storageUsage->quotaBytes($client) ?: null,
'used_bytes' => $this->storageUsage->usedBytes($client),
'theme' => $this->themeKey(),
'folder' => $folder === null ? null : ['id' => $folder->id, 'name' => $folder->name],
// Upload time is the only place a client can set this: there is
// no per-file editor in the portal, and none is being added.
'version_candidates_url' => route('my-files.version-candidates', [], false),
]);
}
/**
* Files this client may name as the previous version of what they are
* uploading — THEIR OWN UPLOADS ONLY.
*
* Not the files visible to them: a revision inherits the recipients of
* the file it revises, so offering a file staff shared with them would
* be offering a way to publish their upload to a recipient list they do
* not own. FilePolicy::setVersion enforces the same rule server-side
* when the upload completes; this only keeps the picker honest.
*/
public function versionCandidates(Request $request): JsonResponse
{
$client = $request->user();
abort_unless($client !== null && $client->isClient(), 404);
abort_unless($client->can('upload'), 403);
$candidates = $this->versions
->candidates(null, $client, trim((string) $request->query('search', '')))
->map(fn (File $file): array => [
'id' => $file->id,
'name' => $file->name,
'original_name' => $file->original_name,
])->values();
return response()->json(['files' => $candidates]);
}
private function themeKey(): string
{
$value = $this->settings->get(Setting::Theme);
return $this->themes->resolve(is_string($value) ? $value : 'default', $this->capabilities);
}
}