Files
projectsend/app/Modules/Files/Scanning/ScanStatus.php
T
ignacionelson bab90c0ad8 Scan uploaded files for viruses, and withhold them until they are checked
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.
2026-09-16 14:23:56 -03:00

81 lines
2.4 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Modules\Files\Scanning;
/**
* Where a file stands with the virus scanner.
*
* Availability is not a case here on purpose: three of these mean the
* file may be served and three mean it may not, and asking
* FileAvailability rather than comparing cases is what keeps that rule in
* one place. See docs/feature-virus-scanning.md.
*/
enum ScanStatus: string
{
/** Waiting to be scanned, or being scanned right now. */
case Pending = 'pending';
/** Scanned, nothing found. */
case Clean = 'clean';
/** A threat was found. Quarantined; `scan_note` is the threat name. */
case Infected = 'infected';
/** Was infected, and an administrator decided to allow it anyway. */
case Released = 'released';
/** Not checked, and allowed through. `scan_note` is a NotScannedReason. */
case NotScanned = 'not_scanned';
/** Could not be checked, and this installation blocks those. Quarantined. */
case UnscannableBlocked = 'unscannable_blocked';
/**
* Whether a file in this state may be seen and downloaded by people
* other than staff and its uploader.
*/
public function isAvailable(): bool
{
return match ($this) {
self::Clean, self::Released, self::NotScanned => true,
self::Pending, self::Infected, self::UnscannableBlocked => false,
};
}
/**
* The states a query may hand to somebody other than staff.
*
* @return list<string>
*/
public static function availableValues(): array
{
return array_values(array_map(
fn (self $status): string => $status->value,
array_filter(self::cases(), fn (self $status): bool => $status->isAvailable()),
));
}
/** Whether this state is waiting on an administrator's decision. */
public function isQuarantined(): bool
{
return $this === self::Infected || $this === self::UnscannableBlocked;
}
/**
* English, and the translation key — what staff see on the file.
*/
public function label(): string
{
return match ($this) {
self::Pending => 'Checking for viruses',
self::Clean => 'Checked',
self::Infected => 'Quarantined',
self::Released => 'Released by an administrator',
self::NotScanned => 'Not scanned',
self::UnscannableBlocked => 'Blocked: could not be scanned',
};
}
}