mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-22 19:43:24 +00:00
Report actual file storage and temporary upload capacity separately
This commit is contained in:
@@ -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<string, array<string, bool|int|string|null>|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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Platform\Storage;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Files\Storage\ResolvingUploadDisk;
|
||||
use App\Modules\Files\Uploads\LocalPartStore;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class StorageCapacity
|
||||
{
|
||||
public function __construct(private readonly LocalPartStore $parts) {}
|
||||
|
||||
/** @return array{storage_driver: string, storage_free_bytes: int, upload_temp_free_bytes: int} */
|
||||
public function inspect(User $uploader): array
|
||||
{
|
||||
$event = new ResolvingUploadDisk($uploader);
|
||||
Event::dispatch($event);
|
||||
$driver = (string) config('filesystems.disks.'.$event->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;
|
||||
}
|
||||
}
|
||||
@@ -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.',
|
||||
)}
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
@@ -207,9 +211,7 @@ export function SystemWidget({ system, onViewReleaseNotes }: { system: SystemInf
|
||||
<li>{t(':count files have been waiting to be checked for over an hour.', { count: system.scanning.pending })}</li>
|
||||
)}
|
||||
{system.scanning.definitions_age_hours !== null && system.scanning.definitions_age_hours >= 72 && (
|
||||
<li>
|
||||
{t('The virus definitions are :hours hours old.', { hours: system.scanning.definitions_age_hours })}
|
||||
</li>
|
||||
<li>{t('The virus definitions are :hours hours old.', { hours: system.scanning.definitions_age_hours })}</li>
|
||||
)}
|
||||
</ul>
|
||||
<Link href="/system/settings/virus-scanning" className="mt-1 inline-block underline hover:no-underline">
|
||||
@@ -268,12 +270,30 @@ export function SystemWidget({ system, onViewReleaseNotes }: { system: SystemInf
|
||||
<dt className="text-muted-foreground">{t('Storage used')}</dt>
|
||||
<dd>{formatBytes(system.storage_used_bytes)}</dd>
|
||||
</div>
|
||||
{system.storage_free_bytes >= 0 && (
|
||||
<div className="flex justify-between gap-2">
|
||||
<dt className="text-muted-foreground">{t('Storage free')}</dt>
|
||||
<dd>{formatBytes(system.storage_free_bytes)}</dd>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between gap-2">
|
||||
<dt className="text-muted-foreground">{t('File storage')}</dt>
|
||||
<dd>
|
||||
{system.storage_driver === 'local'
|
||||
? t('Local disk')
|
||||
: system.storage_driver === 's3'
|
||||
? t('S3-compatible storage')
|
||||
: t('External storage')}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-2">
|
||||
<dt className="text-muted-foreground">{t('Storage available')}</dt>
|
||||
<dd>
|
||||
{system.storage_driver !== 'local'
|
||||
? t('Managed by provider')
|
||||
: system.storage_free_bytes >= 0
|
||||
? formatBytes(system.storage_free_bytes)
|
||||
: t('Unknown')}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-2">
|
||||
<dt className="text-muted-foreground">{t('Temporary upload space')}</dt>
|
||||
<dd>{system.upload_temp_free_bytes >= 0 ? formatBytes(system.upload_temp_free_bytes) : t('Unknown')}</dd>
|
||||
</div>
|
||||
{/* 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
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('Uploads use temporary space while being assembled, including when files are stored externally.')}
|
||||
</p>
|
||||
|
||||
<FileDeliveryDialog delivery={system.file_delivery} open={deliveryOpen} onOpenChange={setDeliveryOpen} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user