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.
This commit is contained in:
ignacionelson
2026-09-16 14:29:41 -03:00
parent bab90c0ad8
commit e9496dc357
15 changed files with 687 additions and 26 deletions
@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace App\Modules\Files\Console;
use App\Modules\Files\Jobs\ScanFileJob;
use App\Modules\Files\Models\File;
use App\Modules\Files\Scanning\NotScannedReason;
use App\Modules\Files\Scanning\ScanningConfig;
use App\Modules\Files\Scanning\ScanStatus;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
/**
* Sends files back to the scanner: the ones still waiting, the ones that
* went through unscanned because it was down, and when asked the
* library that was already here before any of this existed.
*
* Hourly rather than daily. A file stuck pending is a file nobody can
* download, and an installation set to hold has no other way forward
* once its worker restarted and the job with it.
*/
class ScanFilesCommand extends Command
{
protected $signature = 'projectsend:scan-files
{--existing : also work through files that were never scanned because scanning was off}';
protected $description = 'Scan files that are waiting, were missed, or were never checked (runs hourly)';
public function handle(ScanningConfig $config): int
{
if (! $config->enabled()) {
$this->info('Virus scanning is switched off.');
return self::SUCCESS;
}
$waiting = $this->dispatchFor(File::query()->where('scan_status', ScanStatus::Pending));
// Allowed through while the scanner was unreachable. Now that it
// may be back, they are asked again — a file found infected at
// this point is quarantined like any other, and its quarantine
// notice says it was available in the meantime.
$missed = $this->dispatchFor(
File::query()
->where('scan_status', ScanStatus::NotScanned)
->where('scan_note', NotScannedReason::ScannerUnavailable->value)
);
$this->info("Re-queued {$waiting} waiting file(s) and {$missed} that were missed while the scanner was down.");
if ($this->option('existing')) {
// Paced, because this can be a whole library at once and the
// scanner is also serving today's uploads. An hour's worth per
// run, since that is how often this command runs.
$limit = $config->existingScanRatePerMinute() * 60;
$old = $this->dispatchFor(
File::query()
->where('scan_status', ScanStatus::NotScanned)
->where('scan_note', NotScannedReason::BeforeScanning->value),
$limit,
);
$this->info("Queued {$old} file(s) that had never been scanned.");
}
return self::SUCCESS;
}
/**
* @param Builder<File> $query
*/
private function dispatchFor(Builder $query, ?int $limit = null): int
{
if ($limit !== null) {
$query->limit($limit);
}
$ids = $query->orderBy('id')->pluck('id');
foreach ($ids as $id) {
// Back to pending first: the job only acts on a pending file,
// which is what stops two runs of this command from scanning
// the same file twice.
File::query()->whereKey($id)->update(['scan_status' => ScanStatus::Pending->value, 'scan_note' => null]);
ScanFileJob::dispatch((int) $id);
}
return $ids->count();
}
}
@@ -108,6 +108,27 @@ class FilesServiceProvider extends ServiceProvider
url: fn (array $data): string => route('my-files.index'),
));
// Two audiences, two types, because they need different words and
// different links. Staff get a queue to act on; the person who
// uploaded gets told their file did not go through.
$registry = $this->app->make(NotificationTypeRegistry::class);
$registry->register(new NotificationTypeDefinition(
key: 'file_quarantined',
label: 'A file was quarantined by the virus scanner',
template: 'A virus was found in ":itemName", uploaded by :uploaderName',
url: fn (array $data): string => route('files.quarantine'),
));
$registry->register(new NotificationTypeDefinition(
key: 'upload_blocked',
label: 'One of your uploads was blocked',
template: 'Your file ":itemName" was blocked: :threat',
// Their own files list. Deliberately not the quarantine
// screen, which they cannot open.
url: fn (array $data): string => route('my-files.index'),
));
// Every upload path converges on FileWasStored, so this is the
// one place a scan is started from. Dispatched rather than run
// inline: a 5 GB file takes minutes to read, and an upload must
@@ -121,6 +142,7 @@ class FilesServiceProvider extends ServiceProvider
if ($this->app->runningInConsole()) {
$this->commands([
Console\ScanFilesCommand::class,
Console\PurgeStaleUploadsCommand::class,
Console\PurgeZipDownloadsCommand::class,
Console\PurgeExpiredFilesCommand::class,
@@ -0,0 +1,114 @@
<?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.'));
}
}
+2
View File
@@ -46,6 +46,7 @@ use Illuminate\Support\Carbon;
* @property Carbon|null $scanned_at
* @property string|null $scan_engine
* @property int $scan_attempts
* @property bool $scan_was_available
* @property int|null $released_by
* @property Carbon|null $released_at
* @property bool $public
@@ -89,6 +90,7 @@ class File extends Model
'scanned_at' => 'datetime',
'released_at' => 'datetime',
'scan_attempts' => 'integer',
'scan_was_available' => 'boolean',
'commentable' => 'boolean',
'expires_at' => 'datetime',
'download_limit' => 'integer',
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Modules\Files\Scanning;
use App\Models\User;
use App\Modules\Files\Models\File;
use App\Modules\Identity\Permissions\Permission;
use App\Modules\Identity\Permissions\PermissionChecker;
use App\Modules\Identity\UserType;
use App\Modules\Notifications\Notifier;
/**
* Who hears about a quarantined file.
*
* Two audiences, deliberately not three. Staff who can do something about
* it are told, because a file sitting in quarantine that nobody looks at
* is the same as a file silently lost. The person who uploaded it is
* told, because on an honest account this is how they find out their own
* machine has something on it and because otherwise their file simply
* never arrives and they have no idea why.
*
* The people the file was shared with are **not** told. They never
* received it, and a message about a virus in a file they never saw
* would alarm without informing.
*
* Recipients are resolved here rather than inside Notifier, which
* authorizes nothing by design see its security contract.
*/
class QuarantineNotifier
{
public function __construct(
private readonly Notifier $notifier,
private readonly PermissionChecker $permissions,
) {}
public function quarantined(File $file, string $threat): void
{
$uploader = $file->uploader;
$this->notifier->send('file_quarantined', $this->staff(), subject: $file, data: [
'itemName' => $file->name,
'uploaderName' => $uploader->name ?? __('a deleted account'),
'threat' => $threat,
]);
// The uploader hears it once. Without this check a staff member
// who uploaded an infected file would get both messages, which
// read as two different files.
if ($uploader !== null && ! $this->staff()->contains(fn (User $staff): bool => $staff->is($uploader))) {
$this->notifier->send('upload_blocked', [$uploader], subject: $file, data: [
'itemName' => $file->name,
'threat' => $threat,
]);
}
}
/**
* @return \Illuminate\Support\Collection<int, User>
*/
private function staff(): \Illuminate\Support\Collection
{
return User::query()
->where('type', UserType::Staff)
->where('active', true)
->get()
->filter(fn (User $staff): bool => $this->permissions->allows($staff, Permission::ReleaseQuarantinedFiles))
->values();
}
}
@@ -30,6 +30,7 @@ class ScanPolicy
private readonly ScanningConfig $config,
private readonly FileAvailability $availability,
private readonly ActivityLogger $activity,
private readonly QuarantineNotifier $notifier,
) {}
/**
@@ -73,6 +74,8 @@ class ScanPolicy
{
$wasAvailable = $this->availability->isAvailable($file);
$file->forceFill(['scan_was_available' => $wasAvailable])->save();
$this->settle($file, ScanStatus::Infected, $threat, $engine);
$this->purgeRenditions($file);
@@ -87,6 +90,8 @@ class ScanPolicy
'was_available' => $wasAvailable,
]);
$this->notifier->quarantined($file, $threat);
return ScanStatus::Infected;
}
@@ -104,6 +109,8 @@ class ScanPolicy
'was_available' => false,
]);
$this->notifier->quarantined($file, $reason->label());
return ScanStatus::UnscannableBlocked;
}
@@ -35,6 +35,13 @@ enum Permission: string
// rather than a key nobody can reach.
case ModerateComments = 'moderate_comments';
// Overrule the virus scanner: let a quarantined file out. Its own key
// rather than riding on delete_files, because deciding that a threat
// report is wrong is a different judgement from deciding a file is no
// longer needed — and only the administrator role holds it by
// default. See docs/feature-virus-scanning.md.
case ReleaseQuarantinedFiles = 'release_quarantined_files';
// Categories
case CreateCategories = 'create_categories';
case EditCategories = 'edit_categories';
@@ -95,6 +102,7 @@ enum Permission: string
self::ImportOrphans => 'Import orphan files',
self::LimitDownloads => 'Limit download counts',
self::ModerateComments => 'Moderate comments',
self::ReleaseQuarantinedFiles => 'Release quarantined files',
self::CreateCategories => 'Create categories',
self::EditCategories => 'Edit categories',
self::DeleteCategories => 'Delete categories',
@@ -186,6 +194,7 @@ enum Permission: string
self::ImportOrphans,
self::LimitDownloads,
self::ModerateComments => PermissionCategory::Files,
self::ReleaseQuarantinedFiles => PermissionCategory::Files,
self::CreateCategories,
self::EditCategories,
@@ -56,6 +56,7 @@ class SchedulerMonitoringController extends Controller
'projectsend:purge-zip-downloads' => (string) __('Purge zip downloads'),
'projectsend:check-for-updates' => (string) __('Check for updates'),
'projectsend:fetch-news' => (string) __('Fetch dashboard news'),
'projectsend:scan-files' => (string) __('Scan files for viruses'),
'projectsend:purge-expired-files' => (string) __('Purge expired files'),
'projectsend:purge-orphan-files' => (string) __('Purge orphan files'),
'projectsend:purge-api-request-logs' => (string) __('Purge API request logs'),
@@ -31,6 +31,14 @@ return new class extends Migration
$table->string('scan_engine')->nullable()->after('scanned_at');
$table->unsignedInteger('scan_attempts')->default(0)->after('scan_engine');
// Whether this file could be downloaded before it was
// quarantined — true only for one that went out unscanned
// while the scanner was unreachable and was caught later.
// Recorded on the file because it changes what an
// administrator has to do, and because reconstructing it from
// the activity log afterwards means reading every entry.
$table->boolean('scan_was_available')->default(false)->after('scan_attempts');
// Who overruled a quarantine, and when. The reason they gave
// is in the activity log; this is what the file itself shows.
$table->foreignId('released_by')->nullable()->after('scan_attempts')->constrained('users')->nullOnDelete();
@@ -43,7 +51,7 @@ return new class extends Migration
Schema::table('files', function (Blueprint $table) {
$table->dropConstrainedForeignId('released_by');
$table->dropIndex(['scan_status']);
$table->dropColumn(['scan_status', 'scan_note', 'scanned_at', 'scan_engine', 'scan_attempts', 'released_at']);
$table->dropColumn(['scan_status', 'scan_note', 'scanned_at', 'scan_engine', 'scan_attempts', 'scan_was_available', 'released_at']);
});
}
};
+4 -24
View File
@@ -5,30 +5,7 @@ import { Sidebar, SidebarContent, SidebarFooter, SidebarHeader, SidebarMenu, Sid
import { useTranslation } from '@/hooks/use-translation';
import { type NavGroup, type SharedData } from '@/types';
import { Link, usePage } from '@inertiajs/react';
import {
Activity,
ArrowLeftRight,
BookOpen,
Boxes,
Contact,
Download,
FileCode,
FileText,
FileWarning,
History,
KeyRound,
LayoutGrid,
ListChecks,
MailPlus,
MessageSquare,
Settings,
ShieldCheck,
Tags,
Upload,
UserCheck,
UserPlus,
Users,
} from 'lucide-react';
import { Activity, ArrowLeftRight, BookOpen, Boxes, Contact, Download, FileCode, FileText, FileWarning, History, KeyRound, LayoutGrid, ListChecks, MailPlus, MessageSquare, Settings, ShieldAlert, ShieldCheck, Tags, Upload, UserCheck, UserPlus, Users } from 'lucide-react';
import AppLogo from './app-logo';
export function AppSidebar() {
@@ -87,6 +64,9 @@ export function AppSidebar() {
if (can('import_orphans')) {
fileItems.push({ title: t('Import orphan files'), url: '/files/orphans', icon: FileWarning });
}
if (can('release_quarantined_files')) {
fileItems.push({ title: t('Quarantine'), url: '/files/quarantine', icon: ShieldAlert });
}
if (can('moderate_comments')) {
// Just "Comments" — the old "Comments awaiting approval" wrapped
// and pushed its own count badge out of the sidebar, and the screen
+166
View File
@@ -0,0 +1,166 @@
import { type BreadcrumbItem } from '@/types';
import { Head, useForm } from '@inertiajs/react';
import { ShieldAlert } from 'lucide-react';
import { useState } from 'react';
import Heading from '@/components/heading';
import InputError from '@/components/input-error';
import { Pagination, PaginationMeta } from '@/components/pagination';
import { TableShell } from '@/components/table-shell';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { useFormatDate } from '@/hooks/use-format-date';
import { useTranslation } from '@/hooks/use-translation';
import AppLayout from '@/layouts/app-layout';
import { formatBytes } from '@/lib/format-bytes';
interface QuarantinedFile {
id: number;
name: string;
original_name: string;
size: number;
uploader: string | null;
threat: string | null;
status: string;
scanned_at: string | null;
/** It could be downloaded before it was flagged — so somebody may already have it. */
was_available: boolean;
downloads_count: number;
}
interface QuarantineProps {
files: QuarantinedFile[];
pagination: PaginationMeta;
}
function ReleaseDialog({ file }: { file: QuarantinedFile }) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const { data, setData, post, processing, errors, reset } = useForm({ reason: '' });
return (
<Dialog
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) reset();
}}
>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
{t('Release')}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('Release ":name"?', { name: file.name })}</DialogTitle>
<DialogDescription>
{t(
'The scanner reported a threat in this file. Releasing it makes it downloadable again for everyone it was shared with. Only do this if you are sure the report is wrong.',
)}
</DialogDescription>
</DialogHeader>
<div className="grid gap-2">
<Label htmlFor="reason">{t('Why are you releasing it?')}</Label>
<Textarea id="reason" value={data.reason} onChange={(e) => setData('reason', e.target.value)} required />
<p className="text-muted-foreground text-xs">{t('This is recorded in the activity log, with your name.')}</p>
<InputError message={errors.reason} />
</div>
<DialogFooter>
<Button variant="ghost" type="button" onClick={() => setOpen(false)}>
{t('Cancel')}
</Button>
<Button
type="button"
disabled={processing || data.reason.trim() === ''}
onClick={() =>
post(route('files.release', file.id), {
preserveScroll: true,
onSuccess: () => setOpen(false),
})
}
>
{t('Release file')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default function Quarantine({ files, pagination }: QuarantineProps) {
const { t } = useTranslation();
const { dateTime } = useFormatDate();
const breadcrumbs: BreadcrumbItem[] = [
{ title: t('All files'), href: '/files' },
{ title: t('Quarantine'), href: '/files/quarantine' },
];
return (
<AppLayout breadcrumbs={breadcrumbs}>
<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.')} />
{files.some((file) => file.was_available) && (
<Alert variant="destructive" className="mb-4">
<ShieldAlert className="size-4" />
<AlertDescription>
{t(
'Some of these were downloadable before they were checked, because the scanner was unreachable at the time. Their download history shows whether anybody took a copy.',
)}
</AlertDescription>
</Alert>
)}
<TableShell
columns={[t('File'), t('Uploaded by'), t('Found'), t('Detected'), null]}
isEmpty={files.length === 0}
emptyMessage={<>{t('Nothing is in quarantine.')}</>}
>
{files.map((file) => (
<tr key={file.id} className="border-b last:border-0">
<td className="px-4 py-2.5">
<div className="font-medium">{file.name}</div>
<div className="text-muted-foreground text-xs">
{file.original_name} · {formatBytes(file.size)}
</div>
</td>
<td className="text-muted-foreground px-4 py-2.5">{file.uploader ?? t('(deleted account)')}</td>
<td className="px-4 py-2.5">
<Badge variant="destructive">{file.threat ?? t('Unknown')}</Badge>
{file.was_available && (
<div className="text-muted-foreground mt-1 text-xs">
{t('Was downloadable before this. Downloads so far: :count', { count: file.downloads_count })}
</div>
)}
</td>
<td className="text-muted-foreground px-4 py-2.5">{dateTime(file.scanned_at)}</td>
<td className="px-4 py-2.5 text-right">
<ReleaseDialog file={file} />
</td>
</tr>
))}
</TableShell>
<Pagination meta={pagination} />
</div>
</AppLayout>
);
}
+3
View File
@@ -17,6 +17,9 @@ Schedule::command('projectsend:purge-stale-uploads')->hourly();
Schedule::command('projectsend:purge-zip-downloads')->daily();
Schedule::command('projectsend:check-for-updates')->daily();
Schedule::command('projectsend:fetch-news')->daily();
// Hourly, not daily: a file stuck waiting for a scanner is a file nobody
// can download, and an installation set to hold has no other way forward.
Schedule::command('projectsend:scan-files')->hourly();
Schedule::command('projectsend:purge-expired-files')->daily();
Schedule::command('projectsend:purge-orphan-files')->daily();
Schedule::command('projectsend:purge-api-request-logs')->daily();
+11
View File
@@ -29,6 +29,7 @@ use App\Modules\Files\Http\Controllers\FoldersController;
use App\Modules\Files\Http\Controllers\MyFilesController;
use App\Modules\Files\Http\Controllers\MyFoldersController;
use App\Modules\Files\Http\Controllers\OrphanFilesController;
use App\Modules\Files\Http\Controllers\QuarantineController;
use App\Modules\Files\Http\Controllers\PublicShareController;
use App\Modules\Files\Http\Controllers\ShareLinksController;
use App\Modules\Files\Http\Controllers\ZipDownloadsController;
@@ -122,6 +123,16 @@ Route::middleware(['auth'])->group(function () {
Route::post('files/orphans/delete', [OrphanFilesController::class, 'destroy'])->name('orphan-files.delete');
});
// Before files/{file}, or "quarantine" is swallowed as a file key —
// the same ordering the orphans block above depends on.
Route::get('files/quarantine', [QuarantineController::class, 'index'])
->middleware(['staff', 'can:release_quarantined_files'])->name('files.quarantine');
// Password confirmation on top of the permission: this is the one
// action that deliberately hands out a file the scanner called
// malicious, and it is the same bar minting an API token has to clear.
Route::post('files/{file}/release', [QuarantineController::class, 'release'])
->middleware(['staff', 'can:release_quarantined_files', 'password.confirm'])->name('files.release');
Route::get('files/{file}', [FilesController::class, 'edit'])->middleware('staff')->name('files.edit');
Route::get('files/{file}/details', [FileDetailsController::class, 'show'])->middleware('staff')->name('files.details');
Route::get('files/{file}/activity', [FileDetailsController::class, 'activity'])->middleware('staff')->name('files.activity');
+173
View File
@@ -0,0 +1,173 @@
<?php
declare(strict_types=1);
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLog;
use App\Modules\Files\Models\File;
use App\Modules\Files\Scanning\ScanStatus;
use App\Modules\Files\Scanning\ScanVerdict;
use App\Modules\Identity\Models\Role;
use App\Modules\Identity\Models\RolePermission;
use App\Modules\Identity\Permissions\Permission;
use App\Modules\Notifications\InAppNotification;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Inertia\Testing\AssertableInertia;
beforeEach(function () {
Storage::fake('files');
$this->admin = User::factory()->create();
app(Settings::class)->set(Setting::VirusScanningEnabled, true);
app(Settings::class)->set(Setting::VirusScannerAddress, 'tcp://scanner.test:3310');
app(Settings::class)->set(Setting::VirusUnscannablePolicy, 'allow');
app(Settings::class)->set(Setting::VirusScannerDownPolicy, 'allow');
app(Settings::class)->set(Setting::EmailNotificationsEnabled, false);
});
function quarantined(array $overrides = []): File
{
$path = 'uploads/'.Str::uuid()->toString().'.pdf';
Storage::disk('files')->put($path, 'some bytes');
return File::factory()->create(array_merge([
'path' => $path,
'disk' => 'files',
'size' => 10,
'scan_status' => ScanStatus::Infected,
'scan_note' => 'Eicar-Test-Signature',
'scanned_at' => now(),
], $overrides));
}
/*
|--------------------------------------------------------------------------
| Who may open it
|--------------------------------------------------------------------------
*/
test('the quarantine screen needs its own permission', function () {
$role = Role::query()->create(['name' => 'No release '.Str::random(4)]);
RolePermission::query()->create(['role_id' => $role->id, 'permission' => Permission::DeleteOthersFiles->value]);
$staff = User::factory()->create(['role_id' => $role->id]);
$this->actingAs($staff)->get('/files/quarantine')->assertForbidden();
// Deleting a file is not the same judgement as deciding the scanner
// was wrong, which is why this permission exists separately.
$file = quarantined();
$this->actingAs($staff)->post("/files/{$file->id}/release", ['reason' => 'looks fine'])->assertForbidden();
expect($file->refresh()->scan_status)->toBe(ScanStatus::Infected);
});
test('a client cannot reach it at all', function () {
$client = User::factory()->client()->create();
// EnsureStaff sends a client to their own dashboard rather than
// answering 403 — what matters here is that the screen is not served.
$this->actingAs($client)->get('/files/quarantine')->assertRedirect(route('dashboard'));
});
test('an administrator sees what is quarantined, and what got out first', function () {
$uploader = User::factory()->client()->create(['name' => 'Cliente Uno']);
$file = quarantined(['name' => 'Factura', 'uploaded_by' => $uploader->id, 'scan_was_available' => true]);
$this->actingAs($this->admin)->get('/files/quarantine')->assertInertia(
fn (AssertableInertia $page) => $page
->component('files/quarantine')
->where('files.0.name', 'Factura')
->where('files.0.uploader', 'Cliente Uno')
->where('files.0.threat', 'Eicar-Test-Signature')
->where('files.0.was_available', true),
);
});
/*
|--------------------------------------------------------------------------
| Releasing
|--------------------------------------------------------------------------
*/
test('releasing needs a reason, and records who gave it', function () {
$file = quarantined();
confirmPassword($this->admin);
$this->actingAs($this->admin)->post("/files/{$file->id}/release", ['reason' => ''])
->assertSessionHasErrors('reason');
expect($file->refresh()->scan_status)->toBe(ScanStatus::Infected);
$this->actingAs($this->admin)->post("/files/{$file->id}/release", ['reason' => 'False positive, reported upstream'])
->assertSessionHasNoErrors();
$file->refresh();
expect($file->scan_status)->toBe(ScanStatus::Released)
->and($file->released_by)->toBe($this->admin->id)
->and($file->released_at)->not->toBeNull();
$entry = ActivityLog::query()->where('action', Action::FileReleased)->sole();
expect($entry->actor_id)->toBe($this->admin->id)
->and($entry->context['reason'])->toBe('False positive, reported upstream');
});
test('a released file downloads again', function () {
$file = quarantined(['uploaded_by' => $this->admin->id]);
$this->actingAs($this->admin)->get("/files/{$file->id}/download")->assertStatus(423);
confirmPassword($this->admin);
$this->actingAs($this->admin)->post("/files/{$file->id}/release", ['reason' => 'Known false positive']);
$this->actingAs($this->admin)->get("/files/{$file->id}/download")->assertOk();
});
test('releasing asks for the password first', function () {
$file = quarantined();
// No confirmPassword() here: the middleware should send the request
// to the confirmation screen rather than release the file.
$this->actingAs($this->admin)->post("/files/{$file->id}/release", ['reason' => 'sure'])
->assertRedirect(route('password.confirm'));
expect($file->refresh()->scan_status)->toBe(ScanStatus::Infected);
});
test('a file that is not quarantined cannot be released', function () {
$file = quarantined(['scan_status' => ScanStatus::Clean, 'scan_note' => null]);
confirmPassword($this->admin);
$this->actingAs($this->admin)->post("/files/{$file->id}/release", ['reason' => 'why not'])->assertNotFound();
});
/*
|--------------------------------------------------------------------------
| Who is told
|--------------------------------------------------------------------------
*/
test('a quarantined file tells the administrators and the uploader, and nobody else', function () {
$uploader = User::factory()->client()->create();
$bystander = User::factory()->client()->create();
$file = quarantined(['scan_status' => ScanStatus::Pending, 'scan_note' => null, 'uploaded_by' => $uploader->id]);
app(App\Modules\Files\Scanning\ScanPolicy::class)->record($file, ScanVerdict::infected('Eicar-Test-Signature'));
$told = InAppNotification::query()->pluck('type', 'user_id');
expect($told[$this->admin->id] ?? null)->toBe('file_quarantined')
->and($told[$uploader->id] ?? null)->toBe('upload_blocked')
->and($told->has($bystander->id))->toBeFalse();
});
test('a staff member who uploaded it is told once, as staff', function () {
$file = quarantined(['scan_status' => ScanStatus::Pending, 'scan_note' => null, 'uploaded_by' => $this->admin->id]);
app(App\Modules\Files\Scanning\ScanPolicy::class)->record($file, ScanVerdict::infected('Eicar-Test-Signature'));
expect(InAppNotification::query()->where('user_id', $this->admin->id)->count())->toBe(1);
});
@@ -45,7 +45,7 @@ test('the scheduler page lists every known command, flagging ones that have neve
]);
$response = $this->actingAs($this->admin)->get('/system/settings/scheduler');
$response->assertInertia(fn (AssertableInertia $page) => $page->component('system/settings/scheduler')->has('tasks', 11));
$response->assertInertia(fn (AssertableInertia $page) => $page->component('system/settings/scheduler')->has('tasks', 12));
$tasks = collect(schedulerPageProps($response)['tasks'])->keyBy('command');
expect($tasks->get('projectsend:purge-expired-files')['status'])->toBe('success')