Tell a self-hosted installation when nothing is checking its uploads

The dashboard's System card now says so when no scanner is configured
at all, not only when a configured one is failing: "Anything uploaded
here — by staff, by clients, or through an upload link — is passed on
unchecked", with a link to set it up.

Said only where somebody can act on it. Connecting a scanner is a new
capability, scanning.connect, community only — on a hosted installation
the scanner is infrastructure the platform runs, so its address is not a
tenant's to set and its absence is not a tenant's to fix. The two
policies stay on both editions, because what to do with a file nobody
could scan is a decision about somebody's own files. An edition
difference through the registry, never an edition check.

Also: PROJECTSEND_SCANNER_DEFAULT_ADDRESS, seeded into the settings on
first boot by the command that already does this for two-factor
enforcement. It is the opposite of PROJECTSEND_SCANNER_ADDRESS — a
starting value rather than a policy, so a Docker install that brings up
the optional scanner container arrives configured while the address and
the switch stay on the settings screen. Both are seeded together or
neither: an address with scanning off would look configured and check
nothing.

Nothing changes for an existing installation on upgrade: scanning stays
off, existing files are marked "never scanned", and the scanner
container is still opt-in.
This commit is contained in:
ignacionelson
2026-09-16 15:34:49 -03:00
parent f2a7bbb182
commit 85b1650ef0
11 changed files with 241 additions and 8 deletions
+7
View File
@@ -27,6 +27,13 @@ PROJECTSEND_EDITION=community
# configure scanning in Settings instead, which is the ordinary way.
# PROJECTSEND_SCANNER_ADDRESS=tcp://clamav:3310
# Optional: the scanner a fresh installation starts out pointed at, written
# into the settings on first boot and ignored on every later one. Unlike the
# variable above it leaves both the address and the switch on the settings
# screen, which is what a self-hosted install wants: configured out of the
# box, and still yours.
# PROJECTSEND_SCANNER_DEFAULT_ADDRESS=tcp://clamav:3310
# Optional: uid/gid the app/web containers' internal user runs as, so the
# bind-mounted repo needs no permission fixes. Defaults to 1000; override
# if your host user's `id -u`/`id -g` differ.
+4 -1
View File
@@ -372,7 +372,10 @@ docker compose --profile scanner up -d
```
Then go to **System → Settings → Virus scanning**, switch it on, and use `tcp://clamav:3310` as the
address. **Test scanner** sends a harmless standard test file and tells you whether it was actually
address. On a brand-new installation you can skip that step: uncomment
`PROJECTSEND_SCANNER_DEFAULT_ADDRESS` in the compose file before the first start and the site comes
up already pointed at the scanner. It is a starting value, not a lock — the address and the switch
stay on that screen. **Test scanner** sends a harmless standard test file and tells you whether it was actually
detected.
Two things to know before you turn it on. It needs about **11.5 GB of memory**, because the virus
@@ -530,14 +530,30 @@ class DashboardController extends Controller
* healthy state; the dashboard exists here to interrupt somebody who
* was not looking for it.
*
* @return array{reachable: bool, engine: string|null, definitions_age_hours: int|null, let_through_24h: int, pending: int}|null
* @return array{configured: bool, reachable: bool, engine: string|null, definitions_age_hours: int|null, let_through_24h: int, pending: int}|null
*/
private function scanningWarning(): ?array
{
$config = app(ScanningConfig::class);
if (! $config->enabled()) {
return null;
// Nothing is checking what this installation accepts. Said
// only where somebody could act on it: an installation that
// connects its own scanner (community — see
// Capability::VirusScanningConnect). On a hosted one the
// scanner is the platform's to run, and a tenant reading
// "not configured" would be reading about somebody else's
// job.
return $this->capabilities->has(Capability::VirusScanningConnect)
? [
'configured' => false,
'reachable' => false,
'engine' => null,
'definitions_age_hours' => null,
'let_through_24h' => 0,
'pending' => 0,
]
: null;
}
$scanner = app(VirusScanner::class)->status();
@@ -561,6 +577,7 @@ class DashboardController extends Controller
}
return [
'configured' => true,
'reachable' => $scanner->reachable,
'engine' => $scanner->engine,
'definitions_age_hours' => $stale,
@@ -13,6 +13,8 @@ use App\Modules\Files\Scanning\ScanningConfig;
use App\Modules\Files\Scanning\ScanOutcome;
use App\Modules\Files\Scanning\ScanStatus;
use App\Modules\Files\Scanning\VirusScanner;
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\RedirectResponse;
@@ -43,6 +45,7 @@ class VirusScanningSettingsController extends Controller
private readonly Settings $settings,
private readonly ScanningConfig $config,
private readonly ActivityLogger $activity,
private readonly CapabilityRegistry $capabilities,
) {}
public function edit(Request $request): Response
@@ -60,7 +63,12 @@ class VirusScanningSettingsController extends Controller
// nothing at all. Same shape the CAPTCHA screen uses.
'test_result' => $request->session()->get('scanner_test_result'),
'enabled' => $this->config->enabled(),
'managed' => $this->config->isManaged(),
// Two different reasons the connection is not this screen's to
// change: a managed configuration names the scanner, or this
// edition does not connect scanners at all. The screen says
// the same thing for both, since to the person reading it
// they are the same fact.
'managed' => $this->config->isManaged() || ! $this->canConnect(),
'address' => $this->config->isManaged() ? '' : $this->settings->get(Setting::VirusScannerAddress),
'max_size_mb' => $this->settings->get(Setting::VirusScanMaxSizeMb),
'unscannable_policy' => $this->settings->get(Setting::VirusUnscannablePolicy),
@@ -86,7 +94,7 @@ class VirusScanningSettingsController extends Controller
// A managed installation may still choose its policies. The
// connection and the switch are not on the screen there, and a
// request that sends them anyway changes nothing.
if (! $this->config->isManaged()) {
if (! $this->config->isManaged() && $this->canConnect()) {
$address = trim((string) ($validated['address'] ?? ''));
// Refused rather than saved and quietly inert: switching this
@@ -123,6 +131,10 @@ class VirusScanningSettingsController extends Controller
*/
public function test(VirusScanner $scanner): RedirectResponse
{
// Nothing to test where the connection is not this installation's
// to make.
abort_unless($this->canConnect(), 403);
$status = $scanner->status();
if (! $status->reachable) {
@@ -177,6 +189,17 @@ class VirusScanningSettingsController extends Controller
return back()->with('success', __('Scanning existing files has started. It runs in the background.'));
}
/**
* Whether this installation connects its own scanner.
*
* Community only, through the registry rather than an edition check
* see Capability::VirusScanningConnect for the division.
*/
private function canConnect(): bool
{
return $this->capabilities->has(Capability::VirusScanningConnect);
}
/**
* @return array<string, int>
*/
@@ -29,6 +29,16 @@ enum Capability: string
// the bucket is provisioned, what goes in it is not.
case UsersManage = 'users.manage';
case StorageConfigure = 'storage.configure';
// Connecting this installation to a virus scanner, and being told on
// the dashboard when it has none. Community only, and the division is
// the one managed storage already draws: on a hosted installation the
// scanner is infrastructure the platform runs, so its address is not a
// tenant's to set and its absence is not a tenant's to fix. What stays
// on both editions is what to *do* with a file nobody could scan —
// that is a decision about somebody's own files, not about
// infrastructure. See docs/feature-virus-scanning.md.
case VirusScanningConnect = 'scanning.connect';
case EmailTransportConfigure = 'email.transport.configure';
case SystemUpdates = 'system.updates';
@@ -166,6 +176,7 @@ enum Capability: string
{
return match ($this) {
self::StorageConfigure,
self::VirusScanningConnect,
self::EmailTransportConfigure,
self::SystemUpdates,
self::NewsConfigure,
@@ -60,9 +60,43 @@ class SeedSettingsCommand extends Command
$this->seedTwoFactorEnforcement($settings, $enforcement);
}
$scanner = config('projectsend.scanning.default_address');
if (is_string($scanner) && trim($scanner) !== '') {
$this->seedScanner($settings, trim($scanner));
}
return self::SUCCESS;
}
/**
* Point a fresh installation at its scanner, and switch scanning on.
*
* For the operator who brings up the optional scanner container beside
* the application: without this they would have to find the settings
* screen and type an address the compose file already knows. Unlike
* PROJECTSEND_SCANNER_ADDRESS this leaves both the address and the
* switch editable afterwards it is a starting value, not a policy.
*
* Both are seeded together or neither: an address with scanning off
* would look configured and check nothing, and scanning on with no
* address would hold every upload.
*/
private function seedScanner(Settings $settings, string $address): void
{
// The address, asked of the table for the reason given below: its
// default is the empty string, so get() cannot tell "never set"
// from "deliberately cleared".
if (StoredSetting::query()->where('key', Setting::VirusScannerAddress->value)->exists()) {
return;
}
$settings->set(Setting::VirusScannerAddress, $address);
$settings->set(Setting::VirusScanningEnabled, true);
$this->info("Virus scanning seeded to '{$address}' and switched on (first boot).");
}
private function seedTwoFactorEnforcement(Settings $settings, string $value): void
{
if (TwoFactorEnforcement::tryFrom($value) === null) {
+10
View File
@@ -201,6 +201,16 @@ return [
'scanning' => [
'address' => env('PROJECTSEND_SCANNER_ADDRESS'),
// The other way to point an installation at a scanner, and the
// opposite of the one above: written into the settings table on
// first boot and then owned by whoever administers the
// installation, who can change it or switch scanning off like any
// other setting. It is what a self-hosted operator who brings up
// the optional scanner container wants — the site arrives
// configured, without the platform taking the switch away. Ignored
// on any boot where the setting already has a value.
'default_address' => env('PROJECTSEND_SCANNER_DEFAULT_ADDRESS'),
// How long to wait for the socket, and then for each reply. A scan
// streams the whole file before the reply comes, so the second one
// has to allow for the largest file this installation accepts.
+7
View File
@@ -56,6 +56,13 @@ services:
SESSION_DRIVER: redis
QUEUE_CONNECTION: redis
# Uncomment together with the clamav service at the bottom of this
# file. It is written into the settings once, on first boot, so the
# site arrives configured — and it stays yours afterwards: the
# address and the switch are both on System → Settings → Virus
# scanning, and this line is ignored on every later boot.
# PROJECTSEND_SCANNER_DEFAULT_ADDRESS: tcp://clamav:3310
# Mail is easier to configure from System → Settings → Email once you
# are logged in — it has a "send test" button. These are the fallback
# until then.
@@ -41,6 +41,8 @@ export interface SystemInfo {
* the configured behaviour. This is where that gets said out loud.
*/
scanning: {
/** False means no scanner is configured at all. */
configured: boolean;
reachable: boolean;
engine: string | null;
definitions_age_hours: number | null;
@@ -133,11 +135,20 @@ export function SystemWidget({ system, onViewReleaseNotes }: { system: SystemInf
<Alert variant="warning" className="mb-3">
<ShieldAlert className="size-4" />
<AlertTitle>
{system.scanning.reachable ? t('Files are going out unscanned') : t('The virus scanner is not answering')}
{!system.scanning.configured
? t('Uploads are not being checked for viruses')
: system.scanning.reachable
? t('Files are going out unscanned')
: t('The virus scanner is not answering')}
</AlertTitle>
<AlertDescription>
<ul className="list-inside list-disc">
{!system.scanning.reachable && <li>{t('Uploads cannot be checked until it is back.')}</li>}
{!system.scanning.configured && (
<li>{t('Anything uploaded here — by staff, by clients, or through an upload link — is passed on unchecked.')}</li>
)}
{system.scanning.configured && !system.scanning.reachable && (
<li>{t('Uploads cannot be checked until it is back.')}</li>
)}
{system.scanning.let_through_24h > 0 && (
<li>
{t(':count files were allowed through without being scanned in the last 24 hours.', {
@@ -155,7 +166,7 @@ export function SystemWidget({ system, onViewReleaseNotes }: { system: SystemInf
)}
</ul>
<Link href="/system/settings/virus-scanning" className="mt-1 inline-block underline hover:no-underline">
{t('Virus scanning settings')}
{system.scanning.configured ? t('Virus scanning settings') : t('Set up virus scanning')}
</Link>
</AlertDescription>
</Alert>
@@ -243,3 +243,66 @@ test('the dashboard says nothing while scanning is healthy, and speaks up when i
fn (AssertableInertia $page) => $page->where('system.scanning.reachable', false),
);
});
/*
|--------------------------------------------------------------------------
| Nothing is checking what this installation accepts
|--------------------------------------------------------------------------
*/
test('an installation with no scanner is told so on the dashboard', function () {
$this->actingAs($this->admin)->get('/dashboard')->assertInertia(
fn (AssertableInertia $page) => $page
->where('system.scanning.configured', false)
->where('system.scanning.reachable', false),
);
});
test('a hosted installation is not told: the scanner is not its job', function () {
// The capability, not an edition check — see
// Capability::VirusScanningConnect. The System card is community-only
// in its own right, so on a hosted installation the whole card is
// absent and the notice with it; the assertion below is about the
// card, and the one on the settings screen (further down) is what
// pins the capability itself.
config(['projectsend.edition' => App\Modules\Platform\Capabilities\Edition::Cloud]);
forgetRequestState();
$this->actingAs($this->admin)->get('/dashboard')->assertInertia(
fn (AssertableInertia $page) => $page->where('system', null),
);
});
test('the notice goes away once a scanner is configured', function () {
app(Settings::class)->set(Setting::VirusScanningEnabled, true);
app(Settings::class)->set(Setting::VirusScannerAddress, 'tcp://scanner.test:3310');
app()->instance(VirusScanner::class, new FakeVirusScanner);
$this->actingAs($this->admin)->get('/dashboard')->assertInertia(
fn (AssertableInertia $page) => $page->where('system.scanning', null),
);
});
test('a hosted installation cannot connect a scanner of its own, but keeps its policies', function () {
config(['projectsend.edition' => App\Modules\Platform\Capabilities\Edition::Cloud]);
forgetRequestState();
$this->actingAs($this->admin)->get('/system/settings/virus-scanning')->assertInertia(
fn (AssertableInertia $page) => $page->where('managed', true),
);
$this->actingAs($this->admin)->post('/system/settings/virus-scanning/test')->assertForbidden();
$this->actingAs($this->admin)->patch('/system/settings/virus-scanning', [
'enabled' => true,
'address' => 'tcp://mine:3310',
'max_size_mb' => 256,
'unscannable_policy' => 'block',
'scanner_down_policy' => 'hold',
'wait_minutes' => 10,
'existing_rate_per_minute' => 60,
])->assertSessionHasNoErrors();
expect(app(Settings::class)->get(Setting::VirusScannerAddress))->toBe('')
->and(app(Settings::class)->get(Setting::VirusUnscannablePolicy))->toBe('block');
});
@@ -95,3 +95,50 @@ test('the seeded policy is in force for the first account the same boot creates'
expect(app(Settings::class)->get(Setting::TwoFactorEnforcement))->toBe('staff')
->and($admin->hasTwoFactorEnabled())->toBeFalse();
});
/*
|--------------------------------------------------------------------------
| The virus scanner
|--------------------------------------------------------------------------
|
| The opposite of PROJECTSEND_SCANNER_ADDRESS, which is a policy the
| platform keeps. This is a starting value for an operator who brought up
| the optional scanner container beside the application: it arrives
| configured, and stays theirs to change.
|
*/
test('a first boot points the installation at the scanner named in its environment', function () {
config(['projectsend.scanning.default_address' => 'tcp://clamav:3310']);
$this->artisan('projectsend:seed-settings')->assertSuccessful();
$settings = app(App\Modules\Platform\Settings\Settings::class);
expect($settings->get(App\Modules\Platform\Settings\Setting::VirusScannerAddress))->toBe('tcp://clamav:3310')
// Both together: an address with scanning off would look
// configured and check nothing.
->and($settings->get(App\Modules\Platform\Settings\Setting::VirusScanningEnabled))->toBeTrue();
});
test('it never argues with an administrator who has already chosen', function () {
$settings = app(App\Modules\Platform\Settings\Settings::class);
$settings->set(App\Modules\Platform\Settings\Setting::VirusScannerAddress, '');
$settings->set(App\Modules\Platform\Settings\Setting::VirusScanningEnabled, false);
config(['projectsend.scanning.default_address' => 'tcp://clamav:3310']);
$this->artisan('projectsend:seed-settings')->assertSuccessful();
// Cleared on purpose is a decision, and a restart must not undo it.
expect($settings->get(App\Modules\Platform\Settings\Setting::VirusScannerAddress))->toBe('')
->and($settings->get(App\Modules\Platform\Settings\Setting::VirusScanningEnabled))->toBeFalse();
});
test('an installation with no scanner named in its environment is left alone', function () {
config(['projectsend.scanning.default_address' => null]);
$this->artisan('projectsend:seed-settings')->assertSuccessful();
expect(app(App\Modules\Platform\Settings\Settings::class)->get(App\Modules\Platform\Settings\Setting::VirusScanningEnabled))->toBeFalse();
});