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), + ); +});