From 5902e7ab32dc2f274291264d27f3beff8290de9e Mon Sep 17 00:00:00 2001
From: Jens
Date: Sun, 20 Sep 2026 17:58:08 +0200
Subject: [PATCH] Report actual file storage and temporary upload capacity
separately
---
.../Http/Controllers/DashboardController.php | 10 ++--
app/Modules/Files/Uploads/LocalPartStore.php | 5 ++
.../Platform/Storage/StorageCapacity.php | 51 +++++++++++++++++++
.../dashboard-widgets/system-widget.tsx | 46 +++++++++++++----
tests/Feature/Audit/DashboardTest.php | 37 ++++++++++++++
5 files changed, 133 insertions(+), 16 deletions(-)
create mode 100644 app/Modules/Platform/Storage/StorageCapacity.php
diff --git a/app/Modules/Audit/Http/Controllers/DashboardController.php b/app/Modules/Audit/Http/Controllers/DashboardController.php
index 4bf14aed..c198a8a6 100644
--- a/app/Modules/Audit/Http/Controllers/DashboardController.php
+++ b/app/Modules/Audit/Http/Controllers/DashboardController.php
@@ -30,6 +30,7 @@ use App\Modules\Platform\News\NewsItems;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use App\Modules\Platform\Storage\StorageDurability;
+use App\Modules\Platform\Storage\StorageCapacity;
use App\Modules\Platform\System\SystemEnvironment;
use App\Modules\Platform\Updates\LatestReleaseInfo;
use Illuminate\Database\Eloquent\Builder;
@@ -56,6 +57,7 @@ class DashboardController extends Controller
private readonly Settings $settings,
private readonly ApiUsage $apiUsage,
private readonly StorageDurability $storageDurability,
+ private readonly StorageCapacity $storageCapacity,
private readonly FileDelivery $fileDelivery,
private readonly Installation $installation,
private readonly TimezoneRegistry $timezones,
@@ -98,7 +100,7 @@ class DashboardController extends Controller
: null,
'largest_files' => $canStatistics && $prefs->isEnabled($user, 'largest_files') ? $this->largestFiles($user) : null,
'recent' => $canActionsLog && $prefs->isEnabled($user, 'recent') ? $this->recentActivity($user) : null,
- 'system' => $canSystem && $prefs->isEnabled($user, 'system') ? $this->systemInfo() : null,
+ 'system' => $canSystem && $prefs->isEnabled($user, 'system') ? $this->systemInfo($user) : null,
// Both editions — informational content, not an update action,
// so no Capability check alongside the permission (unlike
// 'system' above).
@@ -485,10 +487,8 @@ class DashboardController extends Controller
/**
* @return array|bool|int|string|null>
*/
- private function systemInfo(): array
+ private function systemInfo(User $viewer): array
{
- $freeBytes = @disk_free_space(storage_path('app/files'));
-
// Cached by CheckForUpdatesCommand (daily) — never a live HTTP
// call from the request path. null means either no successful
// check yet, or the current version is already the latest.
@@ -497,7 +497,7 @@ class DashboardController extends Controller
return [
...$this->environment->toArray(),
'storage_used_bytes' => (int) File::query()->sum('size'),
- 'storage_free_bytes' => $freeBytes === false ? -1 : (int) $freeBytes,
+ ...$this->storageCapacity->inspect($viewer),
'update_available' => $release !== null,
'latest_version' => $release['version'] ?? null,
'release_url' => $release['url'] ?? null,
diff --git a/app/Modules/Files/Uploads/LocalPartStore.php b/app/Modules/Files/Uploads/LocalPartStore.php
index 10bde007..444374bb 100644
--- a/app/Modules/Files/Uploads/LocalPartStore.php
+++ b/app/Modules/Files/Uploads/LocalPartStore.php
@@ -353,6 +353,11 @@ class LocalPartStore
* deletes the whole tree, for everybody. Unset, which is every
* installation, the path is what it has always been.
*/
+ public function temporaryDirectory(): string
+ {
+ return $this->root();
+ }
+
private function root(): string
{
$configured = config('projectsend.uploads.parts_path');
diff --git a/app/Modules/Platform/Storage/StorageCapacity.php b/app/Modules/Platform/Storage/StorageCapacity.php
new file mode 100644
index 00000000..265b585e
--- /dev/null
+++ b/app/Modules/Platform/Storage/StorageCapacity.php
@@ -0,0 +1,51 @@
+disk.'.driver');
+
+ return [
+ 'storage_driver' => $driver,
+ // Object stores do not expose filesystem free space. Never report
+ // the VPS disk as the capacity of a remote storage provider.
+ 'storage_free_bytes' => $driver === 'local'
+ ? $this->freeBytes(Storage::disk($event->disk)->path(''))
+ : -1,
+ 'upload_temp_free_bytes' => $this->freeBytes($this->parts->temporaryDirectory()),
+ ];
+ }
+
+ protected function freeBytes(string $path): int
+ {
+ // Before the first upload, the directory may not exist yet. Its
+ // nearest existing ancestor is on the filesystem that will hold it.
+ while (! is_dir($path)) {
+ $parent = dirname($path);
+ if ($parent === $path) {
+ return -1;
+ }
+ $path = $parent;
+ }
+
+ $bytes = @disk_free_space($path);
+
+ return $bytes === false ? -1 : (int) $bytes;
+ }
+}
diff --git a/resources/js/components/dashboard-widgets/system-widget.tsx b/resources/js/components/dashboard-widgets/system-widget.tsx
index e72f5267..46f1683a 100644
--- a/resources/js/components/dashboard-widgets/system-widget.tsx
+++ b/resources/js/components/dashboard-widgets/system-widget.tsx
@@ -3,8 +3,8 @@ import { Link, usePage } from '@inertiajs/react';
import { AlertTriangle, ArrowUpCircle, HardDrive, ShieldAlert } from 'lucide-react';
import { useState } from 'react';
-import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { FileDeliveryDialog, type FileDelivery } from '@/components/file-delivery-dialog';
+import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { UpdateInstructions, type InstallKind } from '@/components/update-instructions';
import { useTranslation } from '@/hooks/use-translation';
import { formatBytes } from '@/lib/format-bytes';
@@ -23,6 +23,8 @@ export interface SystemInfo {
database: string;
storage_used_bytes: number;
storage_free_bytes: number;
+ storage_driver: string;
+ upload_temp_free_bytes: number;
update_available: boolean;
latest_version: string | null;
release_url: string | null;
@@ -109,7 +111,9 @@ function StorageDurabilityNotice({ durability }: { durability: StorageDurability
volume: durability.volume,
})
: t('They survive upgrades, but they live in a Docker-managed volume rather than a directory you chose.')}{' '}
- {t('That means docker compose down -v and docker volume prune both delete them, and a backup of your server can miss them entirely.')}
+ {t(
+ 'That means docker compose down -v and docker volume prune both delete them, and a backup of your server can miss them entirely.',
+ )}
@@ -207,9 +211,7 @@ export function SystemWidget({ system, onViewReleaseNotes }: { system: SystemInf
{t(':count files have been waiting to be checked for over an hour.', { count: system.scanning.pending })}
)}
{system.scanning.definitions_age_hours !== null && system.scanning.definitions_age_hours >= 72 && (
-
- {t('The virus definitions are :hours hours old.', { hours: system.scanning.definitions_age_hours })}
-
+ {t('The virus definitions are :hours hours old.', { hours: system.scanning.definitions_age_hours })}
)}
@@ -268,12 +270,30 @@ export function SystemWidget({ system, onViewReleaseNotes }: { system: SystemInf
{t('Storage used')}
{formatBytes(system.storage_used_bytes)}
- {system.storage_free_bytes >= 0 && (
-
-
{t('Storage free')}
- {formatBytes(system.storage_free_bytes)}
-
- )}
+
+
{t('File storage')}
+
+ {system.storage_driver === 'local'
+ ? t('Local disk')
+ : system.storage_driver === 's3'
+ ? t('S3-compatible storage')
+ : t('External storage')}
+
+
+
+
{t('Storage available')}
+
+ {system.storage_driver !== 'local'
+ ? t('Managed by provider')
+ : system.storage_free_bytes >= 0
+ ? formatBytes(system.storage_free_bytes)
+ : t('Unknown')}
+
+
+
+
{t('Temporary upload space')}
+ {system.upload_temp_free_bytes >= 0 ? formatBytes(system.upload_temp_free_bytes) : t('Unknown')}
+
{/* Stated always, flagged only when it is the slow one.
Both halves of the row open the explanation, and the
label is underlined, because the icon alone did not read
@@ -367,6 +387,10 @@ export function SystemWidget({ system, onViewReleaseNotes }: { system: SystemInf
)}
+
+ {t('Uploads use temporary space while being assembled, including when files are stored externally.')}
+
+
);
diff --git a/tests/Feature/Audit/DashboardTest.php b/tests/Feature/Audit/DashboardTest.php
index 5f549a68..2f713e87 100644
--- a/tests/Feature/Audit/DashboardTest.php
+++ b/tests/Feature/Audit/DashboardTest.php
@@ -590,3 +590,40 @@ test('the top-clients widget names only clients on the viewer roster', function
->where('top_clients_by_storage.0.name', 'My Own Client'),
);
});
+
+test('system capacity distinguishes local file storage from a configured temporary upload volume', function () {
+ $tempRoot = storage_path('app/separate-upload-volume');
+ config(['projectsend.uploads.parts_path' => $tempRoot]);
+ $capacity = new class(app(App\Modules\Files\Uploads\LocalPartStore::class)) extends App\Modules\Platform\Storage\StorageCapacity
+ {
+ protected function freeBytes(string $path): int
+ {
+ return $path === config('projectsend.uploads.parts_path') ? 99_000_000_000 : 39_000_000_000;
+ }
+ };
+ app()->instance(App\Modules\Platform\Storage\StorageCapacity::class, $capacity);
+
+ $this->actingAs($this->admin)->get('/dashboard')->assertInertia(
+ fn (AssertableInertia $page) => $page
+ ->where('system.storage_driver', 'local')
+ ->where('system.storage_free_bytes', 39_000_000_000)
+ ->where('system.upload_temp_free_bytes', 99_000_000_000),
+ );
+});
+
+test('system capacity never presents local disk space as available object storage', function () {
+ config(['filesystems.disks.remote_uploads.driver' => 's3']);
+ Illuminate\Support\Facades\Event::listen(
+ App\Modules\Files\Storage\ResolvingUploadDisk::class,
+ function (App\Modules\Files\Storage\ResolvingUploadDisk $event): void {
+ $event->disk = 'remote_uploads';
+ },
+ );
+ // No credentials or bucket: reading capacity must not contact S3.
+ $this->actingAs($this->admin)->get('/dashboard')->assertInertia(
+ fn (AssertableInertia $page) => $page
+ ->where('system.storage_driver', 's3')
+ ->where('system.storage_free_bytes', -1)
+ ->where('system.upload_temp_free_bytes', fn ($bytes): bool => is_int($bytes) && $bytes >= 0),
+ );
+});