Files
projectsend/app/Modules/Files/Http/Controllers/Api/FilesController.php
T
ignacionelson 5f414c7a4a Give the API the same file filters the library screen has
The staff library grew filters for uploader, role, public/private, download
count and version. Two of those already existed on /api/v1/files
(`uploaded_by`, `public`); the other four did not, so an integration could
not ask what the screen asks.

Adds `role_id`, `downloads=none|any`, `version=current|outdated` and
`visibility=public|private`.

`visibility` rather than changing `public`, and that is the decision worth
explaining. `public` has always tested the file's own column, and callers
depend on that answer; changing what an existing filter means is breaking
for everyone already sending it, however much better the new meaning is. So
`public` is untouched and `visibility` is added beside it with the
application's own definition -- File::isEffectivelyPublic(), the flag or a
public folder anywhere above the file -- which is what the badge on a staff
row means. The guide says in a sentence which to reach for. Point the
visibility filter at the column instead and the test that separates them
fails, which is the whole point of having both.

That predicate now lives once, as File::scopeEffectivelyPublic(), beside the
isEffectivelyPublic() it has to agree with. It was a private helper on
FoldersController until a second surface wanted it.

`role_id` deliberately carries no identity guard, unlike `uploaded_by` beside
it. A role names nobody: the files in the result are ones the caller may
already read, and learning one came from somebody holding the Client role
narrows to a set they could have guessed. `uploaded_by` is different in kind
-- a non-empty answer confirms exactly the identity the response is
redacting -- which is why only it is guarded. The reasoning is in the code,
because an absent guard sitting next to a present one is the kind of thing a
reader should not have to re-derive.

Tests cover each filter, the public/visibility split, and the client-scoped
negative: every new filter still returns nothing outside the token's own
library, because a filter narrows a library and never widens one.
2026-09-16 14:11:06 -03:00

428 lines
18 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Modules\Files\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Modules\Api\Support\PollingQuery;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Clients\ClientStorageUsage;
use App\Modules\Files\Access\ClientIdentityScope;
use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Files\Access\ViewableFileScope;
use App\Modules\Files\DownloadLimitScope;
use App\Modules\Files\Editing\ApplyFileEdits;
use App\Modules\Files\Editing\FileExpiry;
use App\Modules\Files\Http\Resources\Api\FileResource;
use App\Modules\Files\Models\File;
use App\Modules\Files\Models\Folder;
use App\Modules\Files\Storage\ResolvingUploadDisk;
use App\Modules\Files\Uploads\StoreUploadedFile;
use App\Modules\Files\Uploads\UploadExtensionPolicy;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use App\Support\Rules;
use Closure;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
/**
* Read access to the file library.
*
* The listing is built from ViewableFileScope, never from File::query().
* That class is FilePolicy::view() expressed as SQL, so a client-scoped
* staff member's token returns exactly the files they see in the UI and
* the token ability check stays an *additional* gate rather than the only
* one. Reimplementing the visibility rules here would create a second
* definition of "may see", and the two would drift.
*/
class FilesController extends Controller
{
public function __construct(
private readonly ViewableFileScope $viewable,
private readonly PollingQuery $polling,
private readonly Settings $settings,
private readonly StoreUploadedFile $storeFile,
private readonly UploadExtensionPolicy $extensionPolicy,
private readonly ClientStorageUsage $storageUsage,
private readonly ActivityLogger $activity,
private readonly StaffLibraryScope $scope,
private readonly ClientIdentityScope $identity,
private readonly ApplyFileEdits $fileEdits,
private readonly FileExpiry $expiry,
) {}
/**
* Eager loads for the version counterparts, constrained to what this
* token's owner may see.
*
* Constrained here rather than in FileResource so the resource stays a
* pure allowlist with no visibility logic of its own — one definition of
* who may be told about a counterpart, in ViewableFileScope, exactly as
* on the web. Two extra queries for a whole page, not two per row.
*
* @return array<string, Closure(Relation<*, *, *>): 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', '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);
}
}