mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-18 01:25:09 +00:00
Add an Activity tab that shows a scan as it happens
A backfill runs for minutes or hours inside a queue worker, where none of it is visible. The third tab polls every four seconds and says what is happening: whether anything is running, how many uploads are held, how deep the queue is, how many files were checked in the last hour, and the last twenty verdicts with what each one was. When nothing is running, that same list is the record of the last run, which is what somebody opening the tab after the fact came for. Two things the live screen found that the tests had not: **A backfill read as "nothing is being scanned."** Re-scanning a file that already went out unchecked deliberately leaves it available, so it is never "pending" — and the screen counted only pending files. It counts the scans queue too, and the two are shown separately, because "an upload nobody can download yet" and "work the scanner has not reached" are different facts. **A file whose bytes are missing was recorded as "the scanner could not be reached."** Wrong on screen, and worse than wrong in behaviour: that is the one reason the hourly sweep re-queues, so every orphaned row would have been rescanned every hour forever. It has its own reason now, and goes through the same policy as a file the scanner could not open. Both tabs also gained the header shortcut to Quarantine, and Quarantine one back to the settings, each shown only to somebody the destination will actually let in.
This commit is contained in:
@@ -17,8 +17,10 @@ use App\Modules\Platform\Capabilities\Capability;
|
||||
use App\Modules\Platform\Capabilities\CapabilityRegistry;
|
||||
use App\Modules\Platform\Settings\Setting;
|
||||
use App\Modules\Platform\Settings\Settings;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
@@ -56,7 +58,9 @@ class VirusScanningSettingsController extends Controller
|
||||
// scanner is set up, the other is revisited — and a single
|
||||
// column of fields with two Save buttons reads as one form
|
||||
// that saves half of itself.
|
||||
'tab' => $request->query('tab') === 'options' ? 'options' : 'scanner',
|
||||
'tab' => 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
|
||||
@@ -189,6 +193,61 @@ class VirusScanningSettingsController extends Controller
|
||||
return back()->with('success', __('Scanning existing files has started. It runs in the background.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
@@ -200,6 +259,21 @@ class VirusScanningSettingsController extends Controller
|
||||
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<string, int>
|
||||
*/
|
||||
|
||||
@@ -160,11 +160,12 @@ class ScanFileJob implements ShouldQueue
|
||||
}
|
||||
|
||||
if ($stream === null) {
|
||||
// Not the scanner's fault and not a verdict about the file:
|
||||
// treated as "could not be checked", so the installation's
|
||||
// own policy decides, rather than calling a file nobody read
|
||||
// clean.
|
||||
return ScanVerdict::unavailable(__('The file could not be read from storage.'));
|
||||
// Not the scanner's fault, and not something waiting will fix
|
||||
// — an orphaned row, or storage that moved. It goes through
|
||||
// the same policy as a file the scanner could not open, and
|
||||
// deliberately not through the scanner-unavailable path,
|
||||
// which is retried hourly and would retry this forever.
|
||||
return ScanVerdict::unreadable(__('The file could not be read from storage.'));
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -25,6 +25,15 @@ enum NotScannedReason: string
|
||||
/** Uploaded before scanning was switched on, or while it is off. */
|
||||
case BeforeScanning = 'before_scanning';
|
||||
|
||||
/**
|
||||
* The bytes were not there to read — an orphaned row, or storage that
|
||||
* has moved. Its own reason rather than "the scanner could not be
|
||||
* reached", which is what it used to say: that reading is both wrong
|
||||
* on screen and wrong in behaviour, because the hourly sweep retries
|
||||
* an unreachable scanner and would have retried these forever.
|
||||
*/
|
||||
case Unreadable = 'unreadable';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
@@ -32,6 +41,7 @@ enum NotScannedReason: string
|
||||
self::Encrypted => 'Encrypted, so it could not be scanned',
|
||||
self::ScannerUnavailable => 'The scanner could not be reached',
|
||||
self::BeforeScanning => 'Uploaded before virus scanning was switched on',
|
||||
self::Unreadable => 'The file itself could not be read from storage',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,5 +10,8 @@ enum ScanOutcome
|
||||
case Infected;
|
||||
case TooLarge;
|
||||
case Encrypted;
|
||||
/** The file's own bytes could not be read. Nothing to do with the scanner. */
|
||||
case Unreadable;
|
||||
|
||||
case Unavailable;
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ class ScanPolicy
|
||||
ScanOutcome::Infected => $this->quarantine($file, $verdict->detail ?? 'unknown', $verdict->engine),
|
||||
ScanOutcome::TooLarge => $this->unscannable($file, NotScannedReason::TooLarge, $verdict->engine),
|
||||
ScanOutcome::Encrypted => $this->unscannable($file, NotScannedReason::Encrypted, $verdict->engine),
|
||||
ScanOutcome::Unreadable => $this->unscannable($file, NotScannedReason::Unreadable, $verdict->engine),
|
||||
ScanOutcome::Unavailable => $this->unavailable($file, $verdict->detail),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -43,6 +43,12 @@ final class ScanVerdict
|
||||
return new self(ScanOutcome::Encrypted, null, $engine);
|
||||
}
|
||||
|
||||
/** The file could not be read, so nothing was scanned. */
|
||||
public static function unreadable(string $reason): self
|
||||
{
|
||||
return new self(ScanOutcome::Unreadable, $reason);
|
||||
}
|
||||
|
||||
/** The scanner could not be reached, or did not answer in time. */
|
||||
public static function unavailable(string $reason): self
|
||||
{
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import { router } from '@inertiajs/react';
|
||||
import { CheckCircle2, Loader2, ShieldAlert, ShieldQuestion } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import HeadingSmall from '@/components/heading-small';
|
||||
import { TableShell } from '@/components/table-shell';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useFormatDate } from '@/hooks/use-format-date';
|
||||
import { useTranslation } from '@/hooks/use-translation';
|
||||
|
||||
/** Often enough to feel live, rarely enough to be a read every few seconds. */
|
||||
const POLL_INTERVAL_MS = 4000;
|
||||
|
||||
interface ScannedFile {
|
||||
id: number;
|
||||
name: string;
|
||||
status: string;
|
||||
note: string | null;
|
||||
scanned_at: string | null;
|
||||
engine: string | null;
|
||||
}
|
||||
|
||||
interface Activity {
|
||||
running: boolean;
|
||||
/** New uploads withheld until they are checked. */
|
||||
waiting: number;
|
||||
/** Jobs on the scans queue — a backfill lives here, not in `waiting`. */
|
||||
queued: number;
|
||||
checked_last_hour: number;
|
||||
last_scanned_at: string | null;
|
||||
never_scanned: number;
|
||||
quarantined: number;
|
||||
recent: ScannedFile[];
|
||||
}
|
||||
|
||||
/**
|
||||
* What the scanner is doing, refreshed while somebody is watching.
|
||||
*
|
||||
* A backfill runs for minutes or hours in a queue worker, where nothing
|
||||
* about it is visible: this is the only place it can be watched. When
|
||||
* nothing is running the same list is the record of what was decided
|
||||
* last, which is what a person opening this tab after the fact is
|
||||
* looking for.
|
||||
*/
|
||||
export function VirusScanActivity() {
|
||||
const { t } = useTranslation();
|
||||
const { dateTime } = useFormatDate();
|
||||
const [activity, setActivity] = useState<Activity | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let stopped = false;
|
||||
|
||||
const poll = () => {
|
||||
fetch(route('system-settings.virus-scanning.activity'), {
|
||||
credentials: 'same-origin',
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((body: Activity) => {
|
||||
if (!stopped) {
|
||||
setActivity(body);
|
||||
setFailed(false);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// A missed poll tries again on the next tick. Only say
|
||||
// so once nothing has ever arrived, or a blip would
|
||||
// replace a screen full of real numbers with an error.
|
||||
if (!stopped) setFailed(true);
|
||||
});
|
||||
};
|
||||
|
||||
poll();
|
||||
const intervalId = window.setInterval(poll, POLL_INTERVAL_MS);
|
||||
// A scan started from the Options tab lands here as a redirect
|
||||
// back; refresh then rather than waiting out the interval.
|
||||
const stopOnSuccess = router.on('success', poll);
|
||||
|
||||
return () => {
|
||||
stopped = true;
|
||||
window.clearInterval(intervalId);
|
||||
stopOnSuccess();
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (activity === null) {
|
||||
return (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{failed ? t('Could not read what the scanner is doing.') : t('Loading…')}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const badge = (file: ScannedFile) => {
|
||||
if (file.status === 'clean') {
|
||||
return (
|
||||
<Badge variant="secondary" className="gap-1 font-normal">
|
||||
<CheckCircle2 className="size-3" /> {t('Clean')}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
if (file.status === 'infected' || file.status === 'unscannable_blocked') {
|
||||
return (
|
||||
<Badge variant="destructive" className="gap-1 font-normal">
|
||||
<ShieldAlert className="size-3" /> {file.note ?? t('Quarantined')}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
if (file.status === 'released') {
|
||||
return (
|
||||
<Badge variant="secondary" className="font-normal">
|
||||
{t('Released')}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge variant="outline" className="gap-1 font-normal">
|
||||
<ShieldQuestion className="size-3" /> {file.note ?? t('Not scanned')}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{activity.running && <Loader2 className="text-muted-foreground size-4 animate-spin" />}
|
||||
<HeadingSmall
|
||||
title={activity.running ? t('Scanning now') : t('Nothing is being scanned')}
|
||||
description={
|
||||
activity.running
|
||||
? t(':count files still to check.', { count: Math.max(activity.waiting, activity.queued) })
|
||||
: activity.last_scanned_at
|
||||
? t('Last checked :when.', { when: dateTime(activity.last_scanned_at) })
|
||||
: t('Nothing has been checked yet.')
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<dl className="mt-4 grid grid-cols-2 gap-x-6 gap-y-2 text-sm sm:grid-cols-5">
|
||||
<div>
|
||||
{/* Two different facts, and the difference matters:
|
||||
an upload nobody can download yet, and work the
|
||||
scanner has not reached. A backfill shows up in
|
||||
the second and never in the first. */}
|
||||
<dt className="text-muted-foreground">{t('Uploads held')}</dt>
|
||||
<dd className="font-medium">{activity.waiting}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">{t('In the queue')}</dt>
|
||||
<dd className="font-medium">{activity.queued}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">{t('Checked in the last hour')}</dt>
|
||||
<dd className="font-medium">{activity.checked_last_hour}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">{t('In quarantine')}</dt>
|
||||
<dd className="font-medium">{activity.quarantined}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">{t('Never scanned')}</dt>
|
||||
<dd className="font-medium">{activity.never_scanned}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<TableShell
|
||||
columns={[t('File'), t('Result'), t('Checked')]}
|
||||
isEmpty={activity.recent.length === 0}
|
||||
emptyMessage={<>{t('No file has been checked yet.')}</>}
|
||||
>
|
||||
{activity.recent.map((file) => (
|
||||
<tr key={file.id} className="border-b last:border-0">
|
||||
<td className="px-4 py-2.5 font-medium">{file.name}</td>
|
||||
<td className="px-4 py-2.5">{badge(file)}</td>
|
||||
<td className="text-muted-foreground px-4 py-2.5">{dateTime(file.scanned_at)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</TableShell>
|
||||
|
||||
{failed && <p className="text-muted-foreground text-xs">{t('The last refresh did not go through. Still trying.')}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type BreadcrumbItem } from '@/types';
|
||||
import { Head, useForm } from '@inertiajs/react';
|
||||
import { type BreadcrumbItem, type SharedData } from '@/types';
|
||||
import { Head, Link, useForm, usePage } from '@inertiajs/react';
|
||||
import { ShieldAlert } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
@@ -105,6 +105,7 @@ function ReleaseDialog({ file }: { file: QuarantinedFile }) {
|
||||
export default function Quarantine({ files, pagination }: QuarantineProps) {
|
||||
const { t } = useTranslation();
|
||||
const { dateTime } = useFormatDate();
|
||||
const { auth } = usePage<SharedData>().props;
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{ title: t('All files'), href: '/files' },
|
||||
@@ -116,7 +117,17 @@ export default function Quarantine({ files, pagination }: QuarantineProps) {
|
||||
<Head title={t('Quarantine')} />
|
||||
|
||||
<div className="px-4 py-6">
|
||||
<Heading title={t('Quarantine')} description={t('Files the virus scanner refused. Nobody can download these.')} />
|
||||
<div className="flex items-start justify-between">
|
||||
<Heading title={t('Quarantine')} description={t('Files the virus scanner refused. Nobody can download these.')} />
|
||||
|
||||
{/* The way back to the screen that decides what gets
|
||||
refused, for whoever may change it. */}
|
||||
{auth.permissions.includes('edit_settings') && (
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={route('system-settings.virus-scanning.edit')}>{t('Virus scanning settings')}</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{files.some((file) => file.was_available) && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type BreadcrumbItem } from '@/types';
|
||||
import { Head, Link, router, useForm } from '@inertiajs/react';
|
||||
import { type BreadcrumbItem, type SharedData } from '@/types';
|
||||
import { Head, Link, router, useForm, usePage } from '@inertiajs/react';
|
||||
import { CheckCircle2, ShieldAlert, TriangleAlert } from 'lucide-react';
|
||||
import { FormEventHandler } from 'react';
|
||||
|
||||
@@ -13,10 +13,11 @@ import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { VirusScanActivity } from '@/components/virus-scan-activity';
|
||||
import { useTranslation } from '@/hooks/use-translation';
|
||||
import AppLayout from '@/layouts/app-layout';
|
||||
|
||||
type Tab = 'scanner' | 'options';
|
||||
type Tab = 'scanner' | 'options' | 'activity';
|
||||
|
||||
interface VirusScanningProps {
|
||||
tab: Tab;
|
||||
@@ -53,6 +54,7 @@ export default function VirusScanningSettings({
|
||||
counts,
|
||||
}: VirusScanningProps) {
|
||||
const { t } = useTranslation();
|
||||
const { auth } = usePage<SharedData>().props;
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{ title: t('Settings'), href: '/system/settings' },
|
||||
@@ -80,6 +82,7 @@ export default function VirusScanningSettings({
|
||||
const tabs: { key: Tab; label: string }[] = [
|
||||
{ key: 'scanner', label: t('Scanner') },
|
||||
{ key: 'options', label: t('Options') },
|
||||
{ key: 'activity', label: t('Activity') },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -87,7 +90,24 @@ export default function VirusScanningSettings({
|
||||
<Head title={t('Virus scanning')} />
|
||||
|
||||
<div className="space-y-6 px-4 py-6">
|
||||
<Heading title={t('Virus scanning')} description={t('Uploaded files are checked before anyone can download them')} />
|
||||
<div className="flex items-start justify-between">
|
||||
<Heading title={t('Virus scanning')} description={t('Uploaded files are checked before anyone can download them')} />
|
||||
|
||||
{/* The screen this one leads to: whatever the scanner
|
||||
actually refused. Only for somebody who may act on
|
||||
it — the quarantine screen answers 403 otherwise,
|
||||
and a button that leads to a refusal is worse than
|
||||
no button. */}
|
||||
{auth.permissions.includes('release_quarantined_files') && (
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={route('files.quarantine')}>
|
||||
{counts.quarantined > 0
|
||||
? t('Quarantine (:count)', { count: counts.quarantined })
|
||||
: t('Quarantine')}
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{counts.let_through > 0 && (
|
||||
<Alert variant="destructive" className="max-w-xl">
|
||||
@@ -105,7 +125,7 @@ export default function VirusScanningSettings({
|
||||
{tabs.map(({ key, label }) => (
|
||||
<Link
|
||||
key={key}
|
||||
href={route('system-settings.virus-scanning.edit', key === 'options' ? { tab: 'options' } : {})}
|
||||
href={route('system-settings.virus-scanning.edit', key === 'scanner' ? {} : { tab: key })}
|
||||
preserveScroll
|
||||
className={`-mb-px border-b-2 px-3 py-2 text-sm ${
|
||||
tab === key ? 'border-primary text-foreground font-medium' : 'text-muted-foreground border-transparent'
|
||||
@@ -314,6 +334,9 @@ export default function VirusScanningSettings({
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
{/* Mounted only while the tab is open, which is also what
|
||||
starts and stops its polling. */}
|
||||
{tab === 'activity' && <VirusScanActivity />}
|
||||
</div>
|
||||
</AppLayout>
|
||||
);
|
||||
|
||||
@@ -193,6 +193,11 @@ Route::middleware('auth')->group(function () {
|
||||
// be a button somebody can sit on.
|
||||
Route::post('system/settings/virus-scanning/test', [VirusScanningSettingsController::class, 'test'])
|
||||
->middleware('throttle:12,1,scanner-test')->name('system-settings.virus-scanning.test');
|
||||
// Polled every few seconds while the Activity tab is open, so it
|
||||
// is a plain JSON read with its own generous bucket rather than
|
||||
// an Inertia render.
|
||||
Route::get('system/settings/virus-scanning/activity', [VirusScanningSettingsController::class, 'activity'])
|
||||
->middleware('throttle:120,1,scanner-activity')->name('system-settings.virus-scanning.activity');
|
||||
Route::post('system/settings/virus-scanning/scan-existing', [VirusScanningSettingsController::class, 'scanExisting'])
|
||||
->middleware('throttle:6,1,scanner-backfill')->name('system-settings.virus-scanning.scan-existing');
|
||||
Route::get('system/settings/comments', [CommentSettingsController::class, 'edit'])->name('system-settings.comments.edit');
|
||||
|
||||
@@ -318,3 +318,74 @@ test('a hosted installation cannot connect a scanner of its own, but keeps its p
|
||||
expect(app(Settings::class)->get(Setting::VirusScannerAddress))->toBe('')
|
||||
->and(app(Settings::class)->get(Setting::VirusUnscannablePolicy))->toBe('block');
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Watching a scan happen
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
test('the activity endpoint says what is running and what was decided', function () {
|
||||
$waiting = File::factory()->create(['scan_status' => ScanStatus::Pending]);
|
||||
$done = File::factory()->create([
|
||||
'name' => 'Contrato',
|
||||
'scan_status' => ScanStatus::Infected,
|
||||
'scan_note' => 'Eicar-Test-Signature',
|
||||
'scanned_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$body = $this->actingAs($this->admin)->getJson('/system/settings/virus-scanning/activity')->assertOk()->json();
|
||||
|
||||
expect($body['running'])->toBeTrue()
|
||||
->and($body['waiting'])->toBe(1)
|
||||
->and($body['checked_last_hour'])->toBe(1)
|
||||
->and($body['quarantined'])->toBe(1)
|
||||
->and($body['recent'][0]['name'])->toBe('Contrato')
|
||||
->and($body['recent'][0]['note'])->toBe('Eicar-Test-Signature');
|
||||
|
||||
expect($waiting->fresh()->scan_status)->toBe(ScanStatus::Pending)
|
||||
->and($done->fresh()->scan_status)->toBe(ScanStatus::Infected);
|
||||
});
|
||||
|
||||
test('with nothing waiting it reports the last run rather than nothing at all', function () {
|
||||
File::factory()->create([
|
||||
'scan_status' => ScanStatus::Clean,
|
||||
'scanned_at' => now()->subDays(2),
|
||||
]);
|
||||
|
||||
$body = $this->actingAs($this->admin)->getJson('/system/settings/virus-scanning/activity')->assertOk()->json();
|
||||
|
||||
expect($body['running'])->toBeFalse()
|
||||
->and($body['last_scanned_at'])->not->toBeNull()
|
||||
->and($body['recent'])->toHaveCount(1);
|
||||
});
|
||||
|
||||
test('a file that was never scanned reads as such rather than as a bare "not scanned"', function () {
|
||||
File::factory()->create(['scan_status' => ScanStatus::NotScanned, 'scan_note' => null, 'scanned_at' => now()]);
|
||||
|
||||
$body = $this->actingAs($this->admin)->getJson('/system/settings/virus-scanning/activity')->assertOk()->json();
|
||||
|
||||
expect($body['recent'][0]['note'])->toContain('before virus scanning');
|
||||
});
|
||||
|
||||
test('watching a scan needs the same permission as changing its settings', function () {
|
||||
$staff = User::factory()->role(App\Modules\Identity\Permissions\SystemRole::Uploader)->create();
|
||||
|
||||
$this->actingAs($staff)->getJson('/system/settings/virus-scanning/activity')->assertForbidden();
|
||||
});
|
||||
|
||||
test('a backfill counts as running even though it holds nothing back', function () {
|
||||
// The case the first version of this screen got wrong: re-scanning
|
||||
// files that already went out deliberately leaves them available, so
|
||||
// nothing is "pending" and a screen watching only that said nothing
|
||||
// was happening while the queue worked through a whole library.
|
||||
Illuminate\Support\Facades\Queue::fake();
|
||||
|
||||
App\Modules\Files\Jobs\ScanFileJob::dispatch(1, true);
|
||||
|
||||
$body = $this->actingAs($this->admin)->getJson('/system/settings/virus-scanning/activity')->assertOk()->json();
|
||||
|
||||
expect($body['waiting'])->toBe(0)
|
||||
->and($body['queued'])->toBe(1)
|
||||
->and($body['running'])->toBeTrue();
|
||||
});
|
||||
|
||||
@@ -476,3 +476,36 @@ test('the backfill queues those files', function () {
|
||||
|
||||
Illuminate\Support\Facades\Queue::assertPushed(ScanFileJob::class, fn (ScanFileJob $job): bool => $job->fileId === $old->id);
|
||||
});
|
||||
|
||||
test('a file whose bytes are gone says so, and is not retried forever', function () {
|
||||
// An orphaned row, or storage that moved. It used to be recorded as
|
||||
// "the scanner could not be reached" — wrong on screen, and wrong in
|
||||
// behaviour: that is the one reason the hourly sweep re-queues, so
|
||||
// every missing file would have been rescanned every hour for good.
|
||||
$scanner = fakeScanner(ScanVerdict::clean());
|
||||
$file = scannableFile();
|
||||
Storage::disk('files')->delete($file->path);
|
||||
|
||||
runScan($file);
|
||||
|
||||
$file->refresh();
|
||||
expect($file->scan_status)->toBe(ScanStatus::NotScanned)
|
||||
->and($file->scan_note)->toBe(NotScannedReason::Unreadable->value)
|
||||
// Never offered to the scanner: there was nothing to offer.
|
||||
->and($scanner->scans)->toBe(0);
|
||||
|
||||
Illuminate\Support\Facades\Queue::fake();
|
||||
$this->artisan('projectsend:scan-files')->assertSuccessful();
|
||||
Illuminate\Support\Facades\Queue::assertNothingPushed();
|
||||
});
|
||||
|
||||
test('an unreadable file is blocked where this installation blocks what it cannot scan', function () {
|
||||
app(App\Modules\Platform\Settings\Settings::class)->set(Setting::VirusUnscannablePolicy, 'block');
|
||||
fakeScanner(ScanVerdict::clean());
|
||||
$file = scannableFile();
|
||||
Storage::disk('files')->delete($file->path);
|
||||
|
||||
runScan($file);
|
||||
|
||||
expect($file->refresh()->scan_status)->toBe(ScanStatus::UnscannableBlocked);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user