in_array($request->query('tab'), ['options', 'activity'], true) ? (string) $request->query('tab') : 'scanner', // Read from the session here rather than shared as a flash // prop: HandleInertiaRequests shares `success` and `error` and // nothing else, which is why the Test button appeared to do // nothing at all. Same shape the CAPTCHA screen uses. 'test_result' => $request->session()->get('scanner_test_result'), 'enabled' => $this->config->enabled(), // Two different reasons the connection is not this screen's to // change: a managed configuration names the scanner, or this // edition does not connect scanners at all. The screen says // the same thing for both, since to the person reading it // they are the same fact. 'managed' => $this->config->isManaged() || ! $this->canConnect(), // Distinct from `managed`, which covers two different reasons // the address is not editable. A managed installation still // has a scanner worth testing; one that does not connect // scanners at all has nothing to test, and the endpoint says // so with a 403. 'can_test' => $this->canConnect(), 'address' => $this->config->isManaged() ? '' : $this->settings->get(Setting::VirusScannerAddress), 'max_size_mb' => $this->settings->get(Setting::VirusScanMaxSizeMb), 'unscannable_policy' => $this->settings->get(Setting::VirusUnscannablePolicy), 'scanner_down_policy' => $this->settings->get(Setting::VirusScannerDownPolicy), 'wait_minutes' => $this->settings->get(Setting::VirusScannerWaitMinutes), 'existing_rate_per_minute' => $this->settings->get(Setting::VirusScanExistingRatePerMinute), 'counts' => $this->counts(), ]); } public function update(Request $request): RedirectResponse { $validated = $request->validate([ 'enabled' => ['required', 'boolean'], 'address' => ['nullable', 'string', 'max:255'], 'max_size_mb' => ['required', 'integer', 'min:0', 'max:4096'], 'unscannable_policy' => ['required', Rule::in(['allow', 'block'])], 'scanner_down_policy' => ['required', Rule::in(['allow', 'hold'])], 'wait_minutes' => ['required', 'integer', 'min:1', 'max:1440'], 'existing_rate_per_minute' => ['required', 'integer', 'min:1', 'max:6000'], ]); // A managed installation may still choose its policies. The // connection and the switch are not on the screen there, and a // request that sends them anyway changes nothing. if (! $this->config->isManaged() && $this->canConnect()) { $address = trim((string) ($validated['address'] ?? '')); // Refused rather than saved and quietly inert: switching this // on with nowhere to send files would leave every upload // waiting for a scanner that does not exist. if ($request->boolean('enabled') && $address === '') { return back()->withErrors(['address' => __('Enter the address of your scanner first.')]); } // Checked here rather than left to the socket, which accepts // more than it should — see ScannerAddress. if ($address !== '' && ! ScannerAddress::isValid($address)) { return back()->withErrors(['address' => __(ScannerAddress::message())]); } $this->settings->set(Setting::VirusScannerAddress, $address); $this->settings->set(Setting::VirusScanningEnabled, $request->boolean('enabled')); } $this->settings->set(Setting::VirusScanMaxSizeMb, (int) $validated['max_size_mb']); $this->settings->set(Setting::VirusUnscannablePolicy, $validated['unscannable_policy']); $this->settings->set(Setting::VirusScannerDownPolicy, $validated['scanner_down_policy']); $this->settings->set(Setting::VirusScannerWaitMinutes, (int) $validated['wait_minutes']); $this->settings->set(Setting::VirusScanExistingRatePerMinute, (int) $validated['existing_rate_per_minute']); $this->activity->log(Action::SettingsUpdated, context: ['section' => 'virus_scanning']); return back(); } /** * Prove the scanner is there, and that it is actually detecting. * * Three steps, reported separately, because "cannot connect" and * "connects and finds nothing" are different problems and the second * is the one that looks fine from the outside. The third sends the * EICAR test string — a harmless sequence every engine recognises by * agreement — so the answer is "it detected something" rather than * "it did not complain". */ public function test(Request $request, VirusScanner $scanner): RedirectResponse { // Nothing to test where the connection is not this installation's // to make. abort_unless($this->canConnect(), 403); $typed = trim((string) $request->input('address', '')); // What the button is for: the address on screen, which on a first // attempt has never been saved. Falls back to the stored one when // the field is empty, so the button still answers on a screen // somebody has not touched. if ($typed !== '') { if (! ScannerAddress::isValid($typed)) { // Answered as a test result rather than as a field error: // the person pressed Test, and this is what the test // found. Nothing is dialled. return back()->with('scanner_test_result', [ 'ok' => false, 'message' => __(ScannerAddress::message()), ]); } $this->config->preview($typed); } $status = $scanner->status(); if (! $status->reachable) { return back()->with('scanner_test_result', [ 'ok' => false, 'message' => $status->error ?? __('The scanner could not be reached.'), ]); } $stream = fopen('php://temp', 'r+'); assert($stream !== false); fwrite($stream, $this->eicar()); rewind($stream); $verdict = $scanner->scan($stream, strlen($this->eicar())); fclose($stream); if ($verdict->outcome === ScanOutcome::Infected) { return back()->with('scanner_test_result', [ 'ok' => true, 'message' => __('Working. :engine detected the test file as ":threat".', [ 'engine' => $status->engine ?? __('The scanner'), 'threat' => $verdict->detail ?? '', ]), ]); } // Reachable, and did not recognise a file every engine is supposed // to. Almost always empty or broken virus definitions, which is // exactly the failure nothing else would show. return back()->with('scanner_test_result', [ 'ok' => false, 'message' => __(':engine answered but did not detect the standard test file. Check that its virus definitions are installed and up to date.', [ 'engine' => $status->engine ?? __('The scanner'), ]), ]); } /** * Queue every file that has never been scanned. * * The work itself is the hourly command's, so this button does not * hold a request open for a library of any size, and the pace is the * setting above rather than "as fast as the queue will go". */ public function scanExisting(): RedirectResponse { abort_unless($this->config->enabled(), 422); Artisan::queue('projectsend:scan-files', ['--existing' => true]); // Onto the tab that shows it happening rather than back where they // were: somebody who just started a scan wants to watch it, and a // screen that looks unchanged reads as a button that did nothing. return redirect() ->route('system-settings.virus-scanning.edit', ['tab' => 'activity']) ->with('success', __('The scan has started.')); } /** * What the scanner is doing right now, and what it last decided. * * Polled by the Activity tab rather than rendered with the page: a * backfill takes minutes to hours, and a screen that only tells you * where things stood when you opened it is the screen somebody * reloads repeatedly instead of watching. * * JSON rather than an Inertia partial, the way the notification bell * and the zip builder already poll — see use-notification-poll.ts. */ public function activity(): JsonResponse { $recent = File::query() ->whereNotNull('scanned_at') ->orderByDesc('scanned_at') ->limit(20) ->get(['id', 'name', 'scan_status', 'scan_note', 'scanned_at', 'scan_engine']); $waiting = File::query()->where('scan_status', ScanStatus::Pending)->count(); // Counted as well as the files above, and this is the half that // makes a backfill visible: re-scanning a file that already went // out unchecked deliberately leaves it available, so it is not // "pending" and a screen watching only that count says nothing is // happening while the queue works through a whole library. $queued = Queue::size('scans'); return response()->json([ // "Something is happening" is the one thing a person watching // this screen wants to know, and it is worth being explicit // about rather than left to be inferred from a count. 'running' => $waiting > 0 || $queued > 0, 'waiting' => $waiting, 'queued' => $queued, 'checked_last_hour' => File::query()->where('scanned_at', '>=', now()->subHour())->count(), 'last_scanned_at' => $recent->first()?->scanned_at?->toIso8601String(), 'never_scanned' => File::query()->neverScanned()->count(), 'quarantined' => File::query()->whereIn('scan_status', [ ScanStatus::Infected->value, ScanStatus::UnscannableBlocked->value, ])->count(), 'recent' => $recent->map(fn (File $file): array => [ 'id' => $file->id, 'name' => $file->name, 'status' => $file->scan_status->value, // A reason is a key and is translated; a threat name is // the scanner's own words and is passed through. 'note' => $this->noteFor($file), 'scanned_at' => $file->scanned_at?->toIso8601String(), 'engine' => $file->scan_engine, ])->all(), ]); } /** * Whether this installation connects its own scanner. * * Community only, through the registry rather than an edition check — * see Capability::VirusScanningConnect for the division. */ private function canConnect(): bool { return $this->capabilities->has(Capability::VirusScanningConnect); } private function noteFor(File $file): ?string { $note = $file->scan_note; if ($note === null) { return $file->scan_status === ScanStatus::NotScanned ? (string) __(NotScannedReason::BeforeScanning->label()) : null; } $reason = NotScannedReason::tryFrom($note); return $reason === null ? $note : (string) __($reason->label()); } /** * @return array */ private function counts(): array { return [ 'pending' => File::query()->where('scan_status', ScanStatus::Pending)->count(), 'quarantined' => File::query()->whereIn('scan_status', [ ScanStatus::Infected->value, ScanStatus::UnscannableBlocked->value, ])->count(), 'never_scanned' => File::query()->neverScanned()->count(), 'let_through' => File::query() ->where('scan_status', ScanStatus::NotScanned) ->whereIn('scan_note', [ NotScannedReason::ScannerUnavailable->value, NotScannedReason::TooLarge->value, NotScannedReason::Encrypted->value, ]) ->count(), // What a New scan would actually check: everything except a // file already waiting for its first verdict, and one whose // bytes are gone. 'scannable' => File::query() ->whereNotIn('scan_status', [ScanStatus::Pending->value, ScanStatus::Missing->value]) ->count(), // So the New scan button can refuse a second scan while one is // still working through the queue. 'queued' => Queue::size('scans'), ]; } private function eicar(): string { // Assembled rather than written out, so the repository itself // never contains the literal string: antivirus software on a // developer's machine quarantines files that do, and a checkout // that deletes its own test fixtures is a bad afternoon. return 'X5O!P%@AP[4\\PZX54(P^)7CC)7}$'.'EICAR-STANDARD-'.'ANTIVIRUS-TEST-FILE!'.'$H+H*'; } }