mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-17 09:05:08 +00:00
bab90c0ad8
Every upload now starts as "being checked" and is not served to anyone until a scanner has looked at it. Infected files are quarantined: kept on disk, unreachable, waiting for an administrator. The scanner is ClamAV, reached over a socket, streaming the file wherever it is stored — no temporary copy for an S3 or GCS disk. What the scanner answers is a fact; what it means for the file is this installation's setting, so ClamAvScanner knows nothing about settings and ScanPolicy knows nothing about sockets. Three of clamd's own alert options are what make a file it could not open come back as an answer rather than as "OK"; the client maps those to "too large" and "encrypted" instead of to a threat. Both policies default to letting files through, marked "not scanned", which is the product owner's decision: a scanner that cannot answer must not stop people working. Every such file is logged, and the screens that say so come with the rest of this work. Withholding is two rules. A file that is not available drops out of the scopes that answer "what may this person see" — recipients and the public listings, never the uploader's own copy. And every route that puts bytes on the wire asks FileAvailability first: download, thumbnail, preview, share link, the four public routes and both ends of a zip build. A share link minted before the scan finishes says the file is still being checked rather than 404ing. Not yet here, and coming next: the quarantine screen and its permission, the notifications, the settings screen, the hourly retry, the backfill for existing libraries, and the Docker service.
98 lines
3.5 KiB
PHP
98 lines
3.5 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;
|
|
use App\Modules\Files\Scanning\NotScannedReason;
|
|
use App\Modules\Files\Scanning\ScanStatus;
|
|
use App\Modules\Files\Scanning\ScanningConfig;
|
|
|
|
/**
|
|
* 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,
|
|
private readonly ScanningConfig $scanning,
|
|
) {}
|
|
|
|
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);
|
|
|
|
$scanning = $this->scanning->enabled();
|
|
|
|
$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,
|
|
// Decided in the same insert as the row rather than a moment
|
|
// later: a file is unavailable from the instant it exists, or
|
|
// there is a window in which it is neither scanned nor
|
|
// withheld. Every upload path arrives here, so this is the
|
|
// only place that has to be right.
|
|
'scan_status' => $scanning ? ScanStatus::Pending : ScanStatus::NotScanned,
|
|
'scan_note' => $scanning ? null : NotScannedReason::BeforeScanning->value,
|
|
]);
|
|
|
|
$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;
|
|
}
|
|
}
|