mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-17 09:05:08 +00:00
922be7226c
A client could upload a file and then never touch it again. No rename, no description, no expiry, no categories, no delete — the portal has three file routes and all three are GET. Meanwhile the Roles screen happily grants the Client role edit_files, delete_files, set_file_categories, set_file_expiration_date and upload_public, and every one of them was inert, because the routes that honour them are `staff`-gated rather than permission-gated. That is what #1771 hit: a permission granted, saved, and silently doing nothing. A client owns what they uploaded. Ownership is now what lets them edit and delete it, subject to the same per-field keys staff are subject to. The obvious implementation is a trap, and it is worth writing down. Both policy methods began `if (! $user->isStaff()) return false;` and both end in StaffLibraryScope, whose allowsFile() reads `if (! isClientScoped()) return true` — and isClientScoped() is `isStaff() && role->client_scoped`, so it is false for every client. Delete the early return and a client falls into the branch meaning "this staff member is unrestricted" and is handed the whole library. Same for folders(), which returns an unfiltered query: a client could move their file into any folder on the installation. So clients get their own branch, reaching neither. The portal asks Folder::uploadableBy() instead — a file cannot be moved somewhere it could not have been uploaded. edit_others_files and delete_others_files stay inert for clients by construction. A client has no others' files, only files somebody showed them, and being shown a file is not being given it. Which fields an editor may write moved into ApplyFileEdits, shared by the staff editor, /api/v1 and the portal. There were two copies of the same eight permission checks and this would have been the third; the checks are easy, which is exactly why the drift would have been invisible. Callers normalise their own request shape, this gates and writes and logs. Expiry reading and writing came along too, as FileExpiry — three copies, of which only the API's could read a timestamp. Clients do not choose the public slug. It is derived from the name they already picked, because an installation-wide unique slug a client sets is a name to squat and an existence oracle to probe with. One consequence for later, written up in docs/api-todo.md: the policy now says yes to a client for file writes, so `staff-token` is the only thing holding the API boundary where there used to be two independent refusals. ActorBoundaryTest pins it, and asserts the policy passes first so the test cannot quietly stop testing the middleware. Also corrects a stale comment that claimed a deleted file's bytes stay on disk. They have not since File::booted() grew a `deleted` hook; nothing ever forceDelete()s a File row, so "until a purge lands" would have meant never — which is why a client's delete frees their quota by exactly what it frees on disk. The UI comes next; this is the authorization, the routes and the tests. Fixes #1771
371 lines
15 KiB
PHP
371 lines
15 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'],
|
|
'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']);
|
|
}
|
|
}
|
|
|
|
// `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);
|
|
}
|
|
}
|