Files
projectsend/app/Modules/Files/Uploads/StoreUploadedFile.php
T
ignacionelson 757fba19ca Give uploads a seam, and link-minting one home
Two pieces of groundwork, no behaviour change.

FileWasStored is dispatched from StoreUploadedFile, which every upload
path converges on — the chunked flow staff and clients share, and the
synchronous POST beside it. A listener therefore sees each upload once
without knowing which route produced it, which is the property that makes
it usable from outside this repository. A notification, not a filter:
nothing on it is mutable, and anything that needs to influence an upload
has to do so before the bytes land, which is what ResolvingUploadDisk is
already for.

CreateShareLink is the other half. Minting a link was a ShareLinksController
private concern, and the controller is an HTTP handler behind `staff`
middleware — so a link now needs making from outside a request as well.
Two copies of "make a token, write the row, log it" would drift, and the
half most likely to drift is the token, which is the entire authorization
for /s/{token}: there is no session behind it and no second factor, so
being unguessable is its only defence. Anything minted through the action
gets Str::random(32) — about 190 bits, more than a UUID's 122 — and never
a chosen value. The chosen-token path stays in the controller, where a
person is typing one into a form and its minimum length can be argued
about in a validation rule.

The permission questions stay in the controller too. Whether somebody may
set an expiry or a download cap is a fact about them, and the action has
no viewer to ask; it takes both already resolved, including the expiry,
because "the end of the 12th" depends on whose timezone you are in.

Five tests, including that the file a listener receives is complete and
readable rather than half-built, and that the staff form still refuses an
expiry to somebody without the permission after the extraction.
2026-09-08 15:39:55 -03:00

85 lines
2.8 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Modules\Files\Uploads;
use App\Models\User;
use App\Modules\Files\Events\FileWasStored;
use Illuminate\Support\Facades\Event;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Files\Models\File;
/**
* The single place a stored payload becomes a File record — shared by
* the plain intake and the chunked-upload completion, so metadata
* persistence and auditing never diverge.
*/
class StoreUploadedFile
{
public function __construct(
private readonly ActivityLogger $activity,
) {}
public function create(
User $uploader,
string $originalName,
string $path,
string $mimeType,
int $size,
string $checksum,
?string $name = null,
?string $description = null,
?int $folderId = null,
string $disk = 'files',
Action $action = Action::FileUploaded,
): File {
$originalName = self::sanitizeFilename($originalName);
$file = File::query()->create([
'uploaded_by' => $uploader->id,
'folder_id' => $folderId,
'name' => $name !== null && $name !== ''
? $name
: pathinfo($originalName, PATHINFO_FILENAME),
'description' => $description,
'original_name' => $originalName,
'path' => $path,
'disk' => $disk,
'mime_type' => $mimeType,
'size' => $size,
'checksum' => $checksum,
]);
$this->activity->log($action, $uploader, $file);
// Every upload path converges here — the chunked flow staff and
// clients share, and the synchronous POST beside it — so a
// listener sees each upload once without knowing which route
// produced it. Dispatched after the row exists, so what it
// receives is a complete File.
Event::dispatch(new FileWasStored($file, $uploader));
return $file;
}
/**
* An uploader picks original_name freely — it is validated for length
* and nothing else — and it is echoed back in the Content-Disposition
* header of every download, preview and thumbnail response. A CR or LF
* in a header value is header injection; PHP's header() refuses to
* emit one, so today that fails closed as a 500 rather than a split
* response, but a filename should not be able to break the response at
* all. Strip the whole C0/C1 control range once, here, rather than
* hoping each of the five emission sites remembers to.
*/
public static function sanitizeFilename(string $name): string
{
$clean = preg_replace('/[\x00-\x1F\x7F]/u', '', $name) ?? $name;
$clean = trim($clean);
return $clean === '' ? 'file' : $clean;
}
}