mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-17 00:55:07 +00:00
7727ad7616
Folder uses SoftDeletes. The `exists` rule runs against the table, so a folder in the trash passes it -- while every resolution that follows goes through Folder::query(), which honours the soft delete and finds nothing. Ten rules across five controllers rely on that check, and each one reads it as "this folder exists". Two of them then wrote the id anyway. Api\FilesController::store() resolves the folder, hands the null to Folder::uploadableBy(), is told yes -- correctly, that is the rule for a root upload -- and passes $validated['folder_id'] to the write. FilesController::store() is the same shape once #1694 gives it the guard. FilesController::update() and its API twin write it straight through with nothing in between. The result is a live file inside a deleted folder, which is a state nothing else in the application produces: FolderService::delete() deletes every file in the subtree along with it. The row is reachable by id, in search and over the API, and missing from the listing its uploader would look in. Rules::folderId() makes the check mean what its readers assume, once, where the reasoning can be written down -- the same argument slug() makes for itself one method above. Every site takes it, so the file cannot end up with two spellings of the same rule and no way to tell which is the safe one. What changes, path by path: - POST /files, POST /api/v1/files, PATCH /files/{file} and PATCH /api/v1/files/{file} refuse a folder in the trash instead of writing its id. This is the fix. - POST /uploads used to accept it and quietly file the upload at the root -- its guard and its write already agreed, on null. It now says so instead, which is what the other upload paths do. - files/{file}/move, files/bulk-edit, folders, folders/{folder}/move and the portal's my-folders already refused, through StaffLibraryScope::folders() or Folder::scopeVisibleToClient(), both of which drop trashed rows. They still refuse; the answer is now 422 naming folder_id rather than a bare 404. Those two guards are asking a different question -- "is this folder yours" -- and they keep asking it. No live folder id behaves differently anywhere, and the root (a null folder_id) is untouched. The published API document is unchanged: `exists` renders the same either way. Regenerated with php artisan scramble:export and byte-identical.
412 lines
17 KiB
PHP
412 lines
17 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Modules\Files\Http\Controllers;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\User;
|
|
use App\Modules\Audit\Action;
|
|
use App\Modules\Audit\ActivityLogger;
|
|
use App\Modules\Clients\ClientStorageUsage;
|
|
use App\Modules\Files\Models\File;
|
|
use App\Modules\Files\Models\Folder;
|
|
use App\Modules\Files\Notifications\AdminClientUploadedNotification;
|
|
use App\Modules\Files\Uploads\LocalPartStore;
|
|
use App\Modules\Files\Uploads\PartTooLargeException;
|
|
use App\Modules\Files\Uploads\StoreUploadedFile;
|
|
use App\Modules\Files\Uploads\UploadExtensionPolicy;
|
|
use App\Modules\Files\Uploads\UploadSession;
|
|
use App\Modules\Files\Versions\FileVersions;
|
|
use App\Modules\Identity\Permissions\Permission;
|
|
use App\Modules\Identity\Permissions\PermissionChecker;
|
|
use App\Modules\Identity\UserType;
|
|
use App\Modules\Notifications\Notifier;
|
|
use App\Modules\Platform\Settings\Setting;
|
|
use App\Modules\Platform\Settings\Settings;
|
|
use App\Support\Rules;
|
|
use Illuminate\Auth\Access\AuthorizationException;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\RedirectResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Http\Response;
|
|
use Illuminate\Support\Facades\Notification;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Support\Str;
|
|
use Illuminate\Validation\ValidationException;
|
|
use Symfony\Component\HttpKernel\Exception\HttpException;
|
|
|
|
/**
|
|
* The resumable upload contract (Uppy's aws-s3 multipart protocol
|
|
* shape): create session, sign part URLs, receive parts, list parts
|
|
* for resume, complete (assemble + File record), abort. On installs
|
|
* without object storage the part URLs point back at this app; the
|
|
* cloud edition hands out real presigned S3 URLs behind the same
|
|
* contract.
|
|
*/
|
|
class ChunkedUploadsController extends Controller
|
|
{
|
|
public function __construct(
|
|
private readonly LocalPartStore $parts,
|
|
private readonly Settings $settings,
|
|
private readonly StoreUploadedFile $storeFile,
|
|
private readonly UploadExtensionPolicy $extensionPolicy,
|
|
private readonly ClientStorageUsage $storageUsage,
|
|
private readonly Notifier $notifier,
|
|
private readonly PermissionChecker $permissions,
|
|
private readonly ActivityLogger $activity,
|
|
private readonly FileVersions $versions,
|
|
) {}
|
|
|
|
/**
|
|
* Begin a resumable upload.
|
|
*
|
|
* Declare the filename and size; the response returns an `uploadId`
|
|
* used by the remaining steps. The declared size is re-checked against
|
|
* the assembled bytes when the upload completes.
|
|
*/
|
|
public function store(Request $request): JsonResponse
|
|
{
|
|
$validated = $request->validate([
|
|
'filename' => ['required', 'string', 'max:255'],
|
|
'size' => ['required', 'integer', 'min:1'],
|
|
'type' => ['nullable', 'string', 'max:255'],
|
|
'description' => ['nullable', 'string', 'max:2000'],
|
|
'folder_id' => Rules::folderId(),
|
|
'previous_file_id' => ['nullable', 'integer'],
|
|
]);
|
|
|
|
$maxMb = (int) $this->settings->get(Setting::MaxFileSizeMb);
|
|
|
|
if ($maxMb > 0 && (int) $validated['size'] > $maxMb * 1024 * 1024) {
|
|
throw ValidationException::withMessages([
|
|
'size' => __('This file exceeds the maximum allowed size of :max MB.', ['max' => (string) $maxMb]),
|
|
]);
|
|
}
|
|
|
|
$user = $request->user();
|
|
assert($user !== null);
|
|
|
|
$folder = isset($validated['folder_id']) ? Folder::query()->whereKey($validated['folder_id'])->first() : null;
|
|
abort_unless(Folder::uploadableBy($user, $folder), 403);
|
|
|
|
// The declared size here is client-supplied and unverified until
|
|
// complete()'s real assembled byte count — re-checked there too.
|
|
if ($user->isClient()) {
|
|
$quotaBytes = $this->storageUsage->quotaBytes($user);
|
|
|
|
if ($quotaBytes > 0 && $this->storageUsage->usedBytes($user) + (int) $validated['size'] > $quotaBytes) {
|
|
throw ValidationException::withMessages([
|
|
'size' => __('This upload would exceed your storage quota of :quota MB.', ['quota' => (string) $user->storage_quota_mb]),
|
|
]);
|
|
}
|
|
}
|
|
|
|
if (! $this->extensionPolicy->isAllowed($user, $validated['filename'])) {
|
|
throw ValidationException::withMessages([
|
|
'filename' => __('This file type is not allowed for upload.'),
|
|
]);
|
|
}
|
|
|
|
// Checked here, before a single byte is sent, so a rejected pick
|
|
// costs nothing. FileVersions::link() re-checks it at complete()
|
|
// through FilePolicy::setVersion — that is the boundary; this is
|
|
// the courtesy.
|
|
$previousFileId = null;
|
|
if (($validated['previous_file_id'] ?? null) !== null) {
|
|
$previousFileId = $this->resolvableOriginal($user, (int) $validated['previous_file_id'])?->id;
|
|
|
|
if ($previousFileId === null) {
|
|
throw ValidationException::withMessages([
|
|
'previous_file_id' => __('That file can\'t be used as the previous version.'),
|
|
]);
|
|
}
|
|
}
|
|
|
|
$session = UploadSession::query()->create([
|
|
'user_id' => $user->id,
|
|
'folder_id' => $folder?->id,
|
|
'previous_file_id' => $previousFileId,
|
|
'original_name' => $validated['filename'],
|
|
'size' => (int) $validated['size'],
|
|
'mime_type' => $validated['type'] ?? null,
|
|
'description' => $validated['description'] ?? null,
|
|
]);
|
|
|
|
return response()->json([
|
|
'uploadId' => $session->id,
|
|
'key' => $session->id,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Get a short-lived signed URL for one part.
|
|
*/
|
|
public function signPart(Request $request, UploadSession $session, int $part): JsonResponse
|
|
{
|
|
$this->authorizeSession($request, $session);
|
|
|
|
abort_unless($part >= 1 && $part <= 10000, 422);
|
|
|
|
return response()->json([
|
|
'url' => $this->parts->signPartUrl($session, $part, $this->partRouteName($request)),
|
|
'method' => 'PUT',
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* This controller is mounted twice — on the session-authenticated web
|
|
* routes and on the token-authenticated API twins — and a signed URL
|
|
* must name the route the caller can actually reach. A browser handed
|
|
* an /api/v1 URL would have no bearer token; an API client handed the
|
|
* web URL would have no session.
|
|
*/
|
|
private function partRouteName(Request $request): string
|
|
{
|
|
return $request->is('api/*') ? 'api.uploads.parts.put' : 'uploads.parts.put';
|
|
}
|
|
|
|
/**
|
|
* Upload one part.
|
|
*
|
|
* PUT the part's raw bytes to the signed URL returned by the sign
|
|
* endpoint. Parts may be sent in any order, and the response's `ETag`
|
|
* identifies the stored part.
|
|
*/
|
|
public function putPart(Request $request, UploadSession $session, int $part): Response
|
|
{
|
|
// The signature is the grant here (presigned semantics), but
|
|
// ownership of the session is still enforced below.
|
|
$this->authorizeSession($request, $session);
|
|
|
|
// signPart() bounds the part number; bound the part body too, or a
|
|
// session can absorb unlimited bytes. The quota is only enforceable
|
|
// at complete(), against the assembled size — until then nothing
|
|
// stops a client declaring a 1-byte upload and streaming gigabytes
|
|
// of parts, which never becomes a File row and so never counts
|
|
// against anything. Stale sessions are purged daily, so without a
|
|
// cap here the exposure is a day's worth of disk.
|
|
abort_unless($part >= 1 && $part <= 10000, 422);
|
|
|
|
$maxPartBytes = max(1, (int) config('projectsend.upload_part_size_mb')) * 1024 * 1024;
|
|
// Allow a little slack over the advertised part size: the client
|
|
// chooses its own chunking and only the last part is short.
|
|
$limit = $maxPartBytes * 2;
|
|
|
|
if ($request->header('Content-Length') !== null && (int) $request->header('Content-Length') > $limit) {
|
|
abort(413);
|
|
}
|
|
|
|
$stream = $request->getContent(true);
|
|
|
|
try {
|
|
$etag = $this->parts->storePart($session, $part, $stream, $limit);
|
|
} catch (PartTooLargeException) {
|
|
abort(413);
|
|
} finally {
|
|
if (is_resource($stream)) {
|
|
fclose($stream);
|
|
}
|
|
}
|
|
|
|
return response('', 200, [
|
|
'ETag' => '"'.$etag.'"',
|
|
// Stated rather than left to Symfony, whose default for an
|
|
// empty body is text/html. A CDN that rewrites HTML — as
|
|
// Cloudflare's Email Obfuscation and Automatic HTTPS Rewrites
|
|
// do — drops the origin's ETag from an HTML response, since a
|
|
// rewritten body would no longer match it. Nothing on this side
|
|
// would notice (LocalPartStore keeps its own record of every
|
|
// part), but the client never learns the part landed, so the
|
|
// upload stalls at 100% with no error anywhere.
|
|
'Content-Type' => 'application/octet-stream',
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* List the parts already received, so an interrupted upload can resume
|
|
* rather than start again.
|
|
*/
|
|
public function listParts(Request $request, UploadSession $session): JsonResponse
|
|
{
|
|
$this->authorizeSession($request, $session);
|
|
|
|
return response()->json($this->parts->listParts($session));
|
|
}
|
|
|
|
public function complete(Request $request, UploadSession $session): JsonResponse
|
|
{
|
|
$this->authorizeSession($request, $session);
|
|
|
|
$user = $request->user();
|
|
assert($user !== null);
|
|
|
|
$extension = strtolower(pathinfo($session->original_name, PATHINFO_EXTENSION));
|
|
$targetPath = now()->format('Y/m').'/'.Str::uuid()->toString().($extension !== '' ? '.'.$extension : '');
|
|
|
|
try {
|
|
$assembled = $this->parts->assemble($session, $targetPath);
|
|
} catch (\RuntimeException $exception) {
|
|
throw ValidationException::withMessages(['parts' => $exception->getMessage()]);
|
|
}
|
|
|
|
// store()'s quota check used a client-declared, unverified size —
|
|
// re-check against the real assembled byte count before this
|
|
// becomes a File row. No File row exists yet at this point, so
|
|
// cleanup only needs to undo what assemble() just wrote.
|
|
if ($user->isClient()) {
|
|
$quotaBytes = $this->storageUsage->quotaBytes($user);
|
|
|
|
if ($quotaBytes > 0 && $this->storageUsage->usedBytes($user) + $assembled['size'] > $quotaBytes) {
|
|
Storage::disk($assembled['disk'])->delete($assembled['path']);
|
|
$session->delete();
|
|
|
|
throw ValidationException::withMessages([
|
|
'size' => __('This upload would exceed your storage quota of :quota MB.', ['quota' => (string) $user->storage_quota_mb]),
|
|
]);
|
|
}
|
|
}
|
|
|
|
// Never trust the client-supplied `type` (store()'s $session->mime_type) for
|
|
// what gets served back — FileThumbnailController::preview() serves files
|
|
// inline using this value, so a spoofed "text/html" would execute script in
|
|
// the previewer's browser. Detect the real mime type from the assembled bytes.
|
|
$mimeType = Storage::disk($assembled['disk'])->mimeType($assembled['path']) ?: 'application/octet-stream';
|
|
|
|
$file = $this->storeFile->create(
|
|
uploader: $user,
|
|
originalName: $session->original_name,
|
|
path: $assembled['path'],
|
|
mimeType: $mimeType,
|
|
size: $assembled['size'],
|
|
checksum: $assembled['checksum'],
|
|
description: $session->description,
|
|
folderId: $session->folder_id,
|
|
disk: $assembled['disk'],
|
|
);
|
|
|
|
$previousFileId = $session->previous_file_id;
|
|
|
|
$session->delete();
|
|
|
|
// Linked after the File row exists, through the same seam the file
|
|
// editor and the API use — so the ownership rule that stops a
|
|
// client inheriting a stranger's recipients is enforced here too,
|
|
// not just in the picker.
|
|
//
|
|
// A failure here does not fail the upload: the bytes are stored and
|
|
// the row is real, so losing the file over a lost link would be the
|
|
// wrong way round. The response says the link did not happen.
|
|
//
|
|
// Its own method so the declared ?string return type reaches the
|
|
// response array — inline, the generator narrows the property to the
|
|
// one literal string the failure branch happens to produce, and the
|
|
// published spec then promises consumers a constant instead of a
|
|
// nullable message.
|
|
$versionError = $this->linkUploadedVersion($file, $user, $previousFileId);
|
|
|
|
// The staff Dashboard has no equivalent of these — a staff
|
|
// member uploading their own file isn't "someone to notify
|
|
// about", only a client uploading is.
|
|
if ($user->isClient()) {
|
|
if ($this->settings->get(Setting::EmailNotificationsEnabled) === true) {
|
|
$adminEmails = $this->settings->get(Setting::AdminNotificationEmails);
|
|
foreach (is_array($adminEmails) ? $adminEmails : [] as $email) {
|
|
Notification::route('mail', $email)->notify(new AdminClientUploadedNotification($user->name, $file->name, $file->id));
|
|
}
|
|
}
|
|
|
|
$this->notifier->send('client_uploaded', $this->staffWhoCanSeeUploads(), subject: $file, data: [
|
|
'clientName' => $user->name,
|
|
'itemName' => $file->name,
|
|
'fileId' => $file->id,
|
|
]);
|
|
}
|
|
|
|
return response()->json([
|
|
'location' => route('files.download', $file, false),
|
|
'file_id' => $file->id,
|
|
'version_error' => $versionError,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* The file this user may legitimately name as an original, or null.
|
|
*
|
|
* Goes through FileVersions::candidates() rather than a plain lookup so
|
|
* the same rule applies here as in the picker — and in particular so a
|
|
* client gets their own uploads only. A client can see plenty of files
|
|
* they do not own, and since a revision inherits the original's
|
|
* recipients, resolving one of those would hand this upload a recipient
|
|
* list belonging to someone else.
|
|
*/
|
|
/**
|
|
* Mark the freshly uploaded file as a revision, if one was declared.
|
|
*
|
|
* @return string|null why it did not happen, or null when it did (or
|
|
* when nothing was declared)
|
|
*/
|
|
private function linkUploadedVersion(File $file, User $user, ?int $previousFileId): ?string
|
|
{
|
|
if ($previousFileId === null) {
|
|
return null;
|
|
}
|
|
|
|
$previous = $this->resolvableOriginal($user, $previousFileId);
|
|
|
|
try {
|
|
abort_if($previous === null, 404);
|
|
$this->versions->link($file, $previous, $user);
|
|
} catch (ValidationException $exception) {
|
|
return $exception->getMessage();
|
|
} catch (AuthorizationException|HttpException) {
|
|
return __('That file can\'t be used as the previous version.');
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function resolvableOriginal(User $user, int $previousFileId): ?File
|
|
{
|
|
return $this->versions->resolveCandidate(null, $user, $previousFileId);
|
|
}
|
|
|
|
/**
|
|
* Staff who could already open this file via the activity log's own
|
|
* link-resolution rule (upload, edit_files, or edit_others_files) —
|
|
* the same permission set, just evaluated per-candidate instead of
|
|
* per-viewer.
|
|
*
|
|
* @return list<User>
|
|
*/
|
|
private function staffWhoCanSeeUploads(): array
|
|
{
|
|
return array_values(User::query()->where('type', UserType::Staff)->get()
|
|
->filter(fn (User $staff): bool => $this->permissions->allows($staff, Permission::Upload)
|
|
|| $this->permissions->allows($staff, Permission::EditFiles)
|
|
|| $this->permissions->allows($staff, Permission::EditOthersFiles))
|
|
->all());
|
|
}
|
|
|
|
public function destroy(Request $request, UploadSession $session): RedirectResponse|Response
|
|
{
|
|
$this->authorizeSession($request, $session);
|
|
|
|
$originalName = $session->original_name;
|
|
|
|
$this->parts->abort($session);
|
|
$session->delete();
|
|
|
|
$this->activity->log(Action::UploadAborted, context: ['name' => $originalName]);
|
|
|
|
return response()->noContent();
|
|
}
|
|
|
|
private function authorizeSession(Request $request, UploadSession $session): void
|
|
{
|
|
$user = $request->user();
|
|
|
|
abort_unless($user !== null && $session->isOwnedBy($user), 404);
|
|
}
|
|
}
|