mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-17 00:55:07 +00:00
cd8da6a117
Both chunked-upload quota checks resolve the limit through ClientStorageUsage::quotaBytes(), which falls back to the site default when a client has no quota of their own -- and then print `$user->storage_quota_mb` in the rejection. For every client who was never given an explicit quota that column is 0, so the message reads "This upload would exceed your storage quota of 0 MB." at the one moment somebody is trying to find out what their limit is. The API's single-request upload already prints `$this->storageUsage->quotaMb($user)` for the same sentence (Api/FilesController.php:208). The two chunked copies now do the same. Three tests: the inherited default is named at session creation and again at completion, and a client with a quota of their own still sees their own number. Without the fix the first two go red, the third stays green.
482 lines
20 KiB
PHP
482 lines
20 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\Cache;
|
|
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.', [
|
|
// The resolved quota, not the column: a client who
|
|
// was never given one of their own carries 0 there
|
|
// and inherits the site default, so printing the
|
|
// column reads "your storage quota of 0 MB" at the
|
|
// moment somebody is asking what their limit is.
|
|
'quota' => (string) $this->storageUsage->quotaMb($user),
|
|
]),
|
|
]);
|
|
}
|
|
}
|
|
|
|
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);
|
|
|
|
// Serialise completion per session: two concurrent completes (an Uppy
|
|
// retry, a double submit, a lost-connection resend) would otherwise
|
|
// both assemble into the one target file and create two File rows.
|
|
// The lock's TTL releases the claim if a completion dies mid-flight,
|
|
// so a later retry still works.
|
|
$lock = Cache::lock('upload-complete:'.$session->id, 120);
|
|
|
|
if (! $lock->get()) {
|
|
throw ValidationException::withMessages([
|
|
'parts' => __('This upload is already being finalised.'),
|
|
]);
|
|
}
|
|
|
|
try {
|
|
return $this->finalise($session, $user);
|
|
} finally {
|
|
$lock->release();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Assemble a session's received parts into a stored File. Runs under
|
|
* complete()'s per-session lock, so it is the single writer to the
|
|
* session's target path and the only creator of its File row.
|
|
*/
|
|
private function finalise(UploadSession $session, User $user): JsonResponse
|
|
{
|
|
$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 size check ran against the client-declared, unverified
|
|
// size, so a small declared size would otherwise let an upload of any
|
|
// size through here. Re-check the real assembled byte count against
|
|
// the same limit store() applies to everyone. No File row exists yet
|
|
// at this point, so cleanup only needs to undo what assemble() wrote.
|
|
$maxMb = (int) $this->settings->get(Setting::MaxFileSizeMb);
|
|
|
|
if ($maxMb > 0 && $assembled['size'] > $maxMb * 1024 * 1024) {
|
|
Storage::disk($assembled['disk'])->delete($assembled['path']);
|
|
$session->delete();
|
|
|
|
throw ValidationException::withMessages([
|
|
'size' => __('This file exceeds the maximum allowed size of :max MB.', ['max' => (string) $maxMb]),
|
|
]);
|
|
}
|
|
|
|
// 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) $this->storageUsage->quotaMb($user),
|
|
]),
|
|
]);
|
|
}
|
|
}
|
|
|
|
// 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';
|
|
|
|
// Re-resolved rather than taken from the session. A chunked
|
|
// upload is two requests, and store()'s rule only ever sees the
|
|
// first: delete the folder while the bytes are in flight and the
|
|
// recorded id names a folder whose own deletion already removed
|
|
// every file in it. Filing into it would recreate exactly the
|
|
// state Rules::folderId() exists to prevent.
|
|
//
|
|
// The root, rather than a refusal, because the two moments cost
|
|
// different things. At store() nothing has been sent, so refusing
|
|
// is free and honest. Here the bytes are already uploaded, and
|
|
// throwing away somebody's finished transfer over a folder that
|
|
// vanished underneath them is the harsher of the two surprises —
|
|
// the file lands somewhere they can see it and move it.
|
|
$folderId = $session->folder_id !== null && Folder::query()->whereKey($session->folder_id)->exists()
|
|
? $session->folder_id
|
|
: null;
|
|
|
|
$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: $folderId,
|
|
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);
|
|
}
|
|
}
|