): mixed> */ private function versionRelations(?User $user): array { if ($user === null) { return []; } // clone: the same builder is compiled into two separate subqueries, // and a Builder is not reusable once bound. $visible = $this->viewable->for($user)->select('files.id'); return [ 'previousVersion' => fn (Relation $query) => $query->whereIn('files.id', (clone $visible)->getQuery()), 'nextVersion' => fn (Relation $query) => $query->whereIn('files.id', (clone $visible)->getQuery()), ]; } public function index(Request $request): AnonymousResourceCollection { $user = $request->user(); assert($user !== null); $filters = $request->validate($this->polling->rules() + [ 'folder_id' => ['nullable', 'integer'], 'category_id' => ['nullable', 'integer'], 'uploaded_by' => ['nullable', 'integer'], 'search' => ['nullable', 'string', 'max:255'], 'public' => ['nullable', 'boolean'], 'visibility' => ['nullable', 'in:public,private'], 'role_id' => ['nullable', 'integer'], 'downloads' => ['nullable', 'in:none,any'], 'version' => ['nullable', 'in:current,outdated'], 'expired' => ['nullable', 'boolean'], ]); $query = $this->viewable->for($user) ->with(['folder', 'uploader', 'categories'] + $this->versionRelations($user)); if (array_key_exists('folder_id', $filters) && $filters['folder_id'] !== null) { $query->where('files.folder_id', $filters['folder_id']); } if (array_key_exists('uploaded_by', $filters) && $filters['uploaded_by'] !== null) { // A filter is a question, and this one asks "did client N put // anything into my library". Answered plainly it is an oracle: // a client-scoped caller could walk the id space and learn // which clients off their roster share files with clients on // it, without ever reading a name. So an id this caller may // not identify matches nothing — indistinguishable from a // client who has uploaded nothing, which is the point. if (! $this->identity->permitsClientId($user, (int) $filters['uploaded_by'])) { $query->whereRaw('1 = 0'); } $query->where('files.uploaded_by', $filters['uploaded_by']); } if (array_key_exists('category_id', $filters) && $filters['category_id'] !== null) { $query->whereHas('categories', fn (Builder $categories) => $categories->whereKey($filters['category_id'])); } if (($filters['search'] ?? null) !== null) { $search = $filters['search']; $query->where(fn (Builder $inner) => $inner ->where('files.name', 'like', "%{$search}%") ->orWhere('files.description', 'like', "%{$search}%") ->orWhere('files.original_name', 'like', "%{$search}%")); } // Two overlapping questions, kept apart on purpose. // // `public` has always tested the column, and callers depend on that, // so its meaning is left exactly as it was -- changing what an // existing filter answers is a breaking change for everybody already // asking it, whatever the new answer is. // // `visibility` is the question the staff library's own filter asks: // File::isEffectivelyPublic(), the flag *or* a public folder anywhere // above the file. That is what the badge on a row means, so it is // what an integration comparing itself to the screen will expect. // Prefer it; `public` remains for compatibility. if ($request->has('public') && ($filters['public'] ?? null) !== null) { $query->where('files.public', $request->boolean('public')); } if (($filters['visibility'] ?? null) !== null) { $query->effectivelyPublic($filters['visibility'] === 'public'); } // No identity guard here, unlike `uploaded_by` directly above, and // the difference is what the answer discloses. `uploaded_by` names a // person: a non-empty result confirms *which* client uploaded a file // whose uploader the response is redacting, which is the redaction // undone. A role names nobody. The files in the result are ones this // caller may already read, and learning that one of them came from // somebody holding the Client role narrows to a set the caller could // have guessed. Same reasoning, and same absence of a guard, as the // staff library's own role filter -- the two surfaces must not // disagree about what a role reveals. if (($filters['role_id'] ?? null) !== null) { $query->whereHas('uploader', fn (Builder $uploader) => $uploader->where('role_id', (int) $filters['role_id'])); } // has/doesn't-have rather than a comparison on a count: an aggregate // cannot be filtered in a WHERE, and a HAVING would be applied after // the page has already been sliced. if (($filters['downloads'] ?? null) !== null) { $filters['downloads'] === 'none' ? $query->whereDoesntHave('downloads') : $query->whereHas('downloads'); } // "current" includes a file that was never versioned at all -- it is // the current version of itself. "outdated" is the word the version // badge uses, so the filter and the row agree. if (($filters['version'] ?? null) !== null) { $filters['version'] === 'current' ? $query->whereDoesntHave('nextVersion') : $query->whereHas('nextVersion'); } // Expiry is a filter, not a default: staff see expired files in the // UI too (that is how they notice and act on them). Dropping them // is the client branch's rule, applied inside the visibility scopes // where it belongs — which is also why a client-scoped caller does // not get their clients' expired files back here whatever this // filter says: their library is built on that same branch. See // File::isExpired. if ($request->has('expired') && ($filters['expired'] ?? null) !== null) { $request->boolean('expired') ? $query->expired() : $query->notExpired(); } return FileResource::collection($this->polling->paginate($request, $query, 'files')); } public function show(Request $request, File $file): FileResource { Gate::authorize('view', $file); $file->load(['folder', 'uploader', 'categories', 'assignments.assignable'] + $this->versionRelations($request->user())); return new FileResource($file); } /** * Upload a file in a single request. * * Send the file as multipart form data. The maximum accepted size is * this installation's configured upload limit; larger or unreliable * uploads should use the resumable `/uploads` endpoints instead. * * The stored content type is detected from the uploaded bytes, not from * the declared `Content-Type`. */ public function store(Request $request): JsonResponse { // NOTE: docblocks on the methods in this namespace are published as // the API reference (Scramble reads them), so implementation notes // belong here rather than above. // // This is deliberately not a copy of the web FilesController::store(): // that one exists to seed fixtures for the test suite and hard-caps // at 100 MB in validation instead of reading Setting::MaxFileSizeMb. // The checks below mirror the chunked flow's, which are the real ones. $user = $request->user(); assert($user !== null); $validated = $request->validate([ 'file' => ['required', 'file'], 'name' => ['nullable', 'string', 'max:255'], 'description' => ['nullable', 'string', 'max:2000'], 'folder_id' => Rules::folderId(), ]); /** @var UploadedFile $upload */ $upload = $validated['file']; $size = (int) $upload->getSize(); $maxMb = (int) $this->settings->get(Setting::MaxFileSizeMb); if ($maxMb > 0 && $size > $maxMb * 1024 * 1024) { throw ValidationException::withMessages([ 'file' => __('This file exceeds the maximum allowed size of :max MB.', ['max' => (string) $maxMb]), ]); } $folder = isset($validated['folder_id']) ? Folder::query()->whereKey($validated['folder_id'])->first() : null; abort_unless(Folder::uploadableBy($user, $folder), 403); // Inert for a staff token — the quota is a client-portal concept — // but the check belongs here rather than being added later when // client tokens land and this path silently becomes a way around it. if ($user->isClient()) { $quotaBytes = $this->storageUsage->quotaBytes($user); if ($quotaBytes > 0 && $this->storageUsage->usedBytes($user) + $size > $quotaBytes) { throw ValidationException::withMessages([ 'file' => __('This upload would exceed your storage quota of :quota MB.', [ 'quota' => (string) $this->storageUsage->quotaMb($user), ]), ]); } } if (! $this->extensionPolicy->isAllowed($user, $upload->getClientOriginalName())) { throw ValidationException::withMessages([ 'file' => __('This file type is not allowed for upload.'), ]); } $diskEvent = new ResolvingUploadDisk($user); Event::dispatch($diskEvent); $disk = $diskEvent->disk; $path = $upload->storeAs( now()->format('Y/m'), Str::uuid()->toString().'.'.strtolower($upload->getClientOriginalExtension()), $disk, ); abort_unless(is_string($path), 500); $file = $this->storeFile->create( uploader: $user, originalName: $upload->getClientOriginalName(), path: $path, // From the bytes, never from the request. A caller controls the // Content-Type it declares, and the mime type decides how this // file is later served and previewed. mimeType: $upload->getMimeType() ?? 'application/octet-stream', size: $size, checksum: hash_file('sha256', $upload->getRealPath()) ?: '', name: $validated['name'] ?? null, description: $validated['description'] ?? null, folderId: $validated['folder_id'] ?? null, disk: $disk, ); return (new FileResource($file->load(['folder', 'uploader', 'categories']))) ->response() ->setStatusCode(201); } /** * Update a file's metadata. * * Only the fields present in the request are changed; omitting one * leaves it as it was. * * Some fields need a permission of their own — `expires_at` needs * `set_file_expiration_date`, `public` needs `upload_public`, and * `categories` needs `set_file_categories`. Sending one of those * without the matching permission leaves that field untouched rather * than failing the whole request, which mirrors the web interface. * * `expires_at` accepts either a calendar day (`2026-09-12`) or a full * timestamp. A day means the end of that day in the caller's timezone, * which is what the same value means on the web and what the file's * own `expires_at` reads back as; a timestamp is taken as the instant * it names. * * `commentable` only has an effect while the installation's comment * setting is "only files marked as commentable"; under any other * setting it is ignored, again rather than failing. */ public function update(Request $request, File $file): FileResource { Gate::authorize('update', $file); $user = $request->user(); assert($user !== null); $validated = $request->validate([ 'name' => ['sometimes', 'string', 'max:255'], 'description' => ['sometimes', 'nullable', 'string', 'max:2000'], 'folder_id' => ['sometimes', ...Rules::folderId()], 'public' => ['sometimes', 'boolean'], 'commentable' => ['sometimes', 'boolean'], 'slug' => Rules::slug('files', $file->id), 'categories' => ['sometimes', 'array'], 'categories.*' => ['integer', 'exists:categories,id'], 'expires_at' => ['sometimes', 'nullable', 'string', 'date'], 'download_limit' => ['sometimes', 'nullable', 'integer', 'min:1'], 'download_limit_scope' => ['sometimes', Rule::enum(DownloadLimitScope::class)], ]); // Reparenting through update() must respect the same two rules as // the web move()/bulkUpdate() paths: the destination folder must be // one this user can see, and one they may put content into. A public // destination publishes what lands in it, so the second question is // the one `upload_public` exists to ask and store() above already // asks (GHSA-rxf8-wh8v-jm9j). Only enforced when folder_id actually // changes, so re-saving a file that already sits in an out-of-scope // folder (reachable via a direct client share) still works. The // integer rule admits numeric strings, so cast before the strict // change comparison. if (array_key_exists('folder_id', $validated) && $validated['folder_id'] !== null) { $validated['folder_id'] = (int) $validated['folder_id']; if ($validated['folder_id'] !== $file->folder_id) { $destination = $this->scope->folders($user)->whereKey($validated['folder_id'])->firstOrFail(); abort_unless(Folder::uploadableBy($user, $destination), 403); } } // `sometimes` throughout the rules above means $validated already // holds exactly the fields the caller sent, which is the same // array_key_exists contract ApplyFileEdits reads — so the payload // passes through almost untouched. Which of them this token's user // may actually write is that class's decision, shared with the // staff editor and the client portal. $changes = array_intersect_key($validated, array_flip([ 'name', 'description', 'folder_id', 'commentable', 'download_limit', 'download_limit_scope', 'public', 'slug', 'categories', ])); // The one field that needs converting rather than passing along: a // caller may send a calendar day or a full timestamp, and a day // means the end of that day where the caller is. if (array_key_exists('expires_at', $validated)) { $changes['expires_at'] = $this->expiry->instant($validated['expires_at'], $user); } $this->fileEdits->apply($user, $file, $changes); return new FileResource($file->fresh()?->load(['folder', 'uploader', 'categories']) ?? $file); } public function destroy(File $file): JsonResponse { Gate::authorize('delete', $file); $name = $file->name; $file->delete(); $this->activity->log(Action::FileDeleted, context: ['name' => $name]); return response()->json(status: 204); } }