mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-19 18:15:08 +00:00
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:
@@ -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.'));
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user