mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-17 17:15:08 +00:00
7da4635f13
A staff member limited to their own assigned clients could read the names
and ids of clients on nobody's roster but their own, out of ordinary file
metadata.
The file boundary was never wrong. Sharing means a file can legitimately
reach a scoped viewer through client A while client B uploaded it, or
while B also receives it -- StaffLibraryScope::buildFiles is right to
permit that, and a B-only file is still a 403. What was wrong is that
every response then went on to name B. FileResource serialised the loaded
uploader and each assignment unfiltered; ShareTargets::assigned took no
viewer at all, so the details panel published the recipient list as it
stands and forSubject narrowed available_clients while handing
assigned_clients straight through. FoldersController::fileRow,
FilesController::edit, FileDetailsController and ClientFilesController
each named the uploader the same way. The API's uploaded_by filter asked
the question without any name attached: it answered "does this client of
yours put files in front of a client of mine" for any id a caller cared
to try.
12a8ebe3 said the rule out loud while fixing topClientsByStorage -- "the
file was theirs to read and the uploader's name was not theirs to see" --
and then the rule stayed in that widget. So it is a class now.
ClientIdentityScope is the one decision, asked by every surface that
names a client, and it deliberately answers about clients only: a
colleague's name is not a client identity, and hiding it would hide who
uploaded most of the library from the people who work in it. Groups go
through it too, on the same argument -- a group is a list of clients
wearing one name -- which the report did not cover but is the same leak.
Two judgement calls worth naming. assigned() keeps returning the whole
truth and gains a warning, because VisibleCommentScope resolves
notification recipients from it and a recipient filtered out of that list
is one who never hears about a message addressed to them; assignedFor()
is the display half. And FileResource asks at serialisation rather than
in its callers' eager loads, which is the opposite of how the version
counterparts next door are narrowed: that one is set-shaped and folds
into a query, this one is a per-row roster check across eight call sites
in four controllers, two of them re-loading assignments after a write.
The tests assert on whole response bodies rather than on named keys. The
leak was never in one field -- the same name arrived through the
uploader, through the recipient list and through four screens -- so a
body that does not contain the name anywhere is the only assertion that
would have caught all of it. Ten of the eighteen fail without this
change; the rest are the negative controls, including that an unscoped
administrator still sees every name and that the uploaded_by filter still
works for a client on the roster and for staff.
Reported by @Noorkhalel, GHSA-whmp-p9hv-r7j7. Their write-up named every
affected surface and the root cause in each, which is most of why this
took one pass.
411 lines
17 KiB
PHP
411 lines
17 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\Comments\CommentingRules;
|
|
use App\Modules\Comments\CommentScope;
|
|
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\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\Localization\LocalDay;
|
|
use App\Modules\Platform\Localization\TimezoneRegistry;
|
|
use App\Modules\Platform\Settings\Setting;
|
|
use App\Modules\Platform\Settings\Settings;
|
|
use App\Support\Rules;
|
|
use Carbon\Carbon;
|
|
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 CommentingRules $commenting,
|
|
private readonly StaffLibraryScope $scope,
|
|
private readonly ClientIdentityScope $identity,
|
|
private readonly TimezoneRegistry $timezones,
|
|
) {}
|
|
|
|
/**
|
|
* 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'],
|
|
'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}%"));
|
|
}
|
|
|
|
if ($request->has('public') && ($filters['public'] ?? null) !== null) {
|
|
$query->where('files.public', $request->boolean('public'));
|
|
}
|
|
|
|
// 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 library scope as
|
|
// the web move()/bulkUpdate() paths: the destination folder must be
|
|
// one this user can see. 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) {
|
|
$this->scope->folders($user)->findOrFail($validated['folder_id']);
|
|
}
|
|
}
|
|
|
|
$attributes = array_intersect_key($validated, array_flip(['name', 'description', 'folder_id']));
|
|
|
|
if (array_key_exists('expires_at', $validated) && $user->can('set_file_expiration_date')) {
|
|
$attributes['expires_at'] = $this->expiryInstant($validated['expires_at'], $user);
|
|
}
|
|
|
|
if (array_key_exists('download_limit', $validated) && $user->can('limit_downloads')) {
|
|
$attributes['download_limit'] = $validated['download_limit'];
|
|
}
|
|
|
|
if (array_key_exists('download_limit_scope', $validated) && $user->can('limit_downloads')) {
|
|
$attributes['download_limit_scope'] = $validated['download_limit_scope'];
|
|
}
|
|
|
|
if (array_key_exists('commentable', $validated) && $this->commenting->scope() === CommentScope::SelectedFiles) {
|
|
$attributes['commentable'] = $validated['commentable'];
|
|
}
|
|
|
|
$wasPublic = $file->public;
|
|
|
|
if (array_key_exists('public', $validated) && $user->can('upload_public')) {
|
|
$attributes['public'] = $validated['public'];
|
|
$attributes['slug'] = ($validated['slug'] ?? '') ?: ($file->slug ?: File::uniqueSlugFrom($validated['name'] ?? $file->name, $file->id));
|
|
}
|
|
|
|
$file->update($attributes);
|
|
|
|
if (array_key_exists('categories', $validated) && $user->can('set_file_categories')) {
|
|
$file->categories()->sync($validated['categories']);
|
|
}
|
|
|
|
$this->activity->log(Action::FileUpdated, subject: $file);
|
|
|
|
if (! $wasPublic && $file->public) {
|
|
$this->activity->log(Action::FileMadePublic, subject: $file, context: ['slug' => $file->slug]);
|
|
} elseif ($wasPublic && ! $file->public) {
|
|
$this->activity->log(Action::FileMadePrivate, subject: $file);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* What an `expires_at` value means.
|
|
*
|
|
* A bare `YYYY-MM-DD` is a calendar day, and a calendar day ends where
|
|
* the person naming it lives — the same rule the web form's date input
|
|
* gets from FilesController::expiryInstant. Stored as it arrives it
|
|
* would be midnight UTC instead, so a file asked to expire on the 12th
|
|
* would die at the *start* of the 12th, and for a caller west of
|
|
* Greenwich partway through the 11th.
|
|
*
|
|
* Anything carrying a time is an instant the caller named on purpose
|
|
* and is stored as it arrives, unchanged from before: the API can
|
|
* express a moment, and a date input cannot.
|
|
*/
|
|
private function expiryInstant(?string $value, User $setter): ?Carbon
|
|
{
|
|
if ($value === null) {
|
|
return null;
|
|
}
|
|
|
|
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) === 1
|
|
? LocalDay::end($value, $this->timezones->resolve($setter))
|
|
: Carbon::parse($value);
|
|
}
|
|
}
|