Files
projectsend/app/Modules/Files/Http/Controllers/QuarantineController.php
T
ignacionelson e9496dc357 Give quarantined files a screen, an owner, and somebody to tell
An infected file now goes somewhere rather than nowhere. Staff holding
the new release_quarantined_files permission get a Quarantine screen
listing what was refused, who uploaded it, and what the scanner called
it. They can delete it as they always could, or release it — which
needs a written reason, a password confirmation on top of the
permission, and lands in the activity log under their name.

Only the administrator role holds that permission by default. Deciding
a threat report is wrong is a different judgement from deciding a file
is no longer needed, which is why it is not delete_files.

Two notifications, two audiences: staff who can act on it, and the
person who uploaded it — for whom this is how they learn their own
machine has something on it. The people the file was shared with are
deliberately not told about a file they never received.

`projectsend:scan-files` runs hourly: it re-queues files still waiting,
and re-scans the ones that went out unscanned while the scanner was
unreachable, since it may be back. With --existing it also works
through a library uploaded before scanning was switched on, paced by a
setting so it does not starve today's uploads.

A file that was downloadable before it was caught says so on the
screen, with its download count, because that is the case where
somebody may already have a copy.
2026-09-16 14:29:41 -03:00

115 lines
4.2 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Modules\Files\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Files\Models\File;
use App\Modules\Files\Scanning\FileAvailability;
use App\Modules\Files\Scanning\NotScannedReason;
use App\Modules\Files\Scanning\ScanStatus;
use App\Support\Pagination;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
/**
* The files the virus scanner refused, and the one decision a person can
* make about them.
*
* Nothing is deleted here automatically and nothing expires out of this
* list: a quarantined file waits for somebody. Deleting one is the
* ordinary file deletion, with its ordinary permission — this screen only
* adds the other answer, which is that the scanner was wrong.
*
* Releasing is gated by a permission of its own that only the
* administrator role holds by default, and by password confirmation on
* top of it, because it is the one action in the application that
* deliberately hands out a file something reported as malicious.
*/
class QuarantineController extends Controller
{
public function __construct(
private readonly ActivityLogger $activity,
private readonly FileAvailability $availability,
) {}
public function index(Request $request): Response
{
$files = File::query()
->whereIn('scan_status', [ScanStatus::Infected->value, ScanStatus::UnscannableBlocked->value])
->with('uploader')
->orderByDesc('scanned_at')
->paginate(25)
->withQueryString();
$files->through(fn (File $file): array => [
'id' => $file->id,
'name' => $file->name,
'original_name' => $file->original_name,
'size' => $file->size,
'uploader' => $file->uploader?->name,
// The threat name, or — for a file nothing could open — what
// stopped it being read.
'threat' => $file->scan_status === ScanStatus::UnscannableBlocked
? __(NotScannedReason::tryFrom((string) $file->scan_note)?->label() ?? 'Could not be scanned')
: $file->scan_note,
'status' => $file->scan_status->value,
'scanned_at' => $file->scanned_at?->toIso8601String(),
// True only for a file that went out unscanned while the
// scanner was unreachable and was caught later — which is the
// one case where somebody may already have a copy.
'was_available' => $file->scan_was_available,
'downloads_count' => $file->downloads()->count(),
]);
return Inertia::render('files/quarantine', [
'files' => $files->items(),
'pagination' => Pagination::meta($files),
]);
}
/**
* Overrule the scanner for one file.
*
* The reason is required and is recorded against the person who gave
* it. A release is not undone by a later scan: the file stays
* released until somebody deletes it, which is the point — an
* administrator who has decided a detection is wrong should not have
* to decide it again every hour.
*/
public function release(Request $request, File $file): RedirectResponse
{
abort_unless($file->scan_status->isQuarantined(), 404);
$validated = $request->validate([
'reason' => ['required', 'string', 'max:500'],
]);
$actor = $request->user();
assert($actor !== null);
$file->forceFill([
'scan_status' => ScanStatus::Released,
'released_by' => $actor->id,
'released_at' => now(),
])->save();
$this->activity->log(Action::FileReleased, subject: $file, context: [
'reason' => $validated['reason'],
'threat' => $file->scan_note,
]);
// Everything that was waiting on this file — a share email, a new
// version notice — goes out now, exactly as it would have if the
// scan had passed.
$this->availability->markAvailable($file);
return back()->with('success', __('The file has been released.'));
}
}