diff --git a/app/Exceptions/Service/Backup/TooManyBackupsException.php b/app/Exceptions/Service/Backup/TooManyBackupsException.php index 8ccbaf76b..ddcba8256 100644 --- a/app/Exceptions/Service/Backup/TooManyBackupsException.php +++ b/app/Exceptions/Service/Backup/TooManyBackupsException.php @@ -9,10 +9,9 @@ class TooManyBackupsException extends DisplayException /** * TooManyBackupsException constructor. */ - public function __construct(int $backupLimit) + public function __construct(int $backupLimit, ?string $customMessage = null) { - parent::__construct( - sprintf('Cannot create a new backup, this server has reached its limit of %d backups.', $backupLimit) - ); + $message = $customMessage ?? sprintf('Cannot create a new backup, this server has reached its limit of %d backups.', $backupLimit); + parent::__construct($message); } } diff --git a/app/Extensions/Backups/BackupManager.php b/app/Extensions/Backups/BackupManager.php index 60f394fb9..4bbd044dd 100644 --- a/app/Extensions/Backups/BackupManager.php +++ b/app/Extensions/Backups/BackupManager.php @@ -122,6 +122,26 @@ class BackupManager return new S3Filesystem($client, $config['bucket'], $config['prefix'] ?? '', $config['options'] ?? []); } + /** + * Creates a new Rustic Local adapter. + * Rustic adapters don't use traditional filesystem operations - they are handled by Wings directly. + */ + public function createRusticLocalAdapter(array $config): FilesystemAdapter + { + // Return a minimal adapter since rustic operations are handled by Wings + return new InMemoryFilesystemAdapter(); + } + + /** + * Creates a new Rustic S3 adapter. + * Rustic adapters don't use traditional filesystem operations - they are handled by Wings directly. + */ + public function createRusticS3Adapter(array $config): FilesystemAdapter + { + // Return a minimal adapter since rustic operations are handled by Wings + return new InMemoryFilesystemAdapter(); + } + /** * Returns the configuration associated with a given backup type. */ diff --git a/app/Http/Controllers/Admin/ServersController.php b/app/Http/Controllers/Admin/ServersController.php index f5a77cbd6..73bb7624e 100644 --- a/app/Http/Controllers/Admin/ServersController.php +++ b/app/Http/Controllers/Admin/ServersController.php @@ -145,7 +145,7 @@ class ServersController extends Controller $this->buildModificationService->handle($server, $request->only([ 'allocation_id', 'add_allocations', 'remove_allocations', 'memory', 'overhead_memory', 'swap', 'io', 'cpu', 'threads', 'disk', - 'database_limit', 'allocation_limit', 'backup_limit', 'oom_disabled', + 'database_limit', 'allocation_limit', 'backup_limit', 'backup_storage_limit', 'oom_disabled', 'exclude_from_resource_calculation', ])); } catch (DataValidationException $exception) { diff --git a/app/Http/Controllers/Api/Client/Servers/BackupController.php b/app/Http/Controllers/Api/Client/Servers/BackupController.php index 66c7bd46f..67259b1cc 100644 --- a/app/Http/Controllers/Api/Client/Servers/BackupController.php +++ b/app/Http/Controllers/Api/Client/Servers/BackupController.php @@ -11,6 +11,7 @@ use Pterodactyl\Models\Permission; use Illuminate\Auth\Access\AuthorizationException; use Pterodactyl\Services\Backups\DeleteBackupService; use Pterodactyl\Services\Backups\DownloadLinkService; +use Pterodactyl\Services\Backups\BackupStorageService; use Pterodactyl\Repositories\Eloquent\BackupRepository; use Pterodactyl\Services\Backups\InitiateBackupService; use Pterodactyl\Services\Backups\ServerStateService; @@ -33,6 +34,7 @@ class BackupController extends ClientApiController private DownloadLinkService $downloadLinkService, private BackupRepository $repository, private ServerStateService $serverStateService, + private BackupStorageService $backupStorageService, ) { parent::__construct(); } @@ -56,10 +58,26 @@ class BackupController extends ClientApiController ->orderByRaw('is_locked DESC, created_at DESC') ->paginate($limit); + $storageInfo = $this->backupStorageService->getStorageUsageInfo($server); + return $this->fractal->collection($backups) ->transformWith($this->getTransformer(BackupTransformer::class)) ->addMeta([ 'backup_count' => $this->repository->getNonFailedBackups($server)->count(), + 'storage' => [ + 'used_mb' => $storageInfo['used_mb'], + 'limit_mb' => $storageInfo['limit_mb'], + 'has_limit' => $storageInfo['has_limit'], + 'usage_percentage' => $storageInfo['usage_percentage'] ?? null, + 'available_mb' => $storageInfo['available_mb'] ?? null, + 'is_over_limit' => $storageInfo['is_over_limit'] ?? false, + ], + 'limits' => [ + 'count_limit' => $server->backup_limit, + 'has_count_limit' => $server->hasBackupCountLimit(), + 'storage_limit_mb' => $server->backup_storage_limit, + 'has_storage_limit' => $server->hasBackupStorageLimit(), + ], ]) ->toArray(); } @@ -88,7 +106,11 @@ class BackupController extends ClientApiController Activity::event('server:backup.start') ->subject($backup) - ->property(['name' => $backup->name, 'locked' => (bool) $request->input('is_locked')]) + ->property([ + 'name' => $backup->name, + 'locked' => (bool) $request->input('is_locked'), + 'adapter' => $backup->disk + ]) ->log(); return $this->fractal->item($backup) @@ -212,7 +234,14 @@ class BackupController extends ClientApiController throw new AuthorizationException(); } - if ($backup->disk !== Backup::ADAPTER_AWS_S3 && $backup->disk !== Backup::ADAPTER_WINGS) { + $allowedAdapters = [ + Backup::ADAPTER_AWS_S3, + Backup::ADAPTER_WINGS, + Backup::ADAPTER_RUSTIC_LOCAL, + Backup::ADAPTER_RUSTIC_S3 + ]; + + if (!in_array($backup->disk, $allowedAdapters)) { throw new BadRequestHttpException('The backup requested references an unknown disk driver type and cannot be downloaded.'); } @@ -277,10 +306,10 @@ class BackupController extends ClientApiController throw new BadRequestHttpException('Server state changed during restore initiation. Please try again.'); } - // If the backup is for an S3 file we need to generate a unique Download link for - // it that will allow Wings to actually access the file. + // If the backup is for an S3 file (legacy or rustic) we need to generate a unique + // Download link for it that will allow Wings to actually access the file. $url = null; - if ($backup->disk === Backup::ADAPTER_AWS_S3) { + if (in_array($backup->disk, [Backup::ADAPTER_AWS_S3, Backup::ADAPTER_RUSTIC_S3])) { try { $url = $this->downloadLinkService->handle($backup, $request->user()); } catch (\Exception $e) { diff --git a/app/Http/Controllers/Api/Client/Servers/NetworkAllocationController.php b/app/Http/Controllers/Api/Client/Servers/NetworkAllocationController.php index 0e9c3c87b..87ff63572 100644 --- a/app/Http/Controllers/Api/Client/Servers/NetworkAllocationController.php +++ b/app/Http/Controllers/Api/Client/Servers/NetworkAllocationController.php @@ -92,7 +92,11 @@ class NetworkAllocationController extends ClientApiController */ public function store(NewAllocationRequest $request, Server $server): array { - if ($server->allocations()->count() >= $server->allocation_limit) { + if (!$server->allowsAllocations()) { + throw new DisplayException('Cannot assign allocations to this server: allocations are disabled.'); + } + + if ($server->hasAllocationCountLimit() && $server->allocations()->count() >= $server->allocation_limit) { throw new DisplayException('Cannot assign additional allocations to this server: limit has been reached.'); } diff --git a/app/Http/Controllers/Api/Client/Servers/ScheduleTaskController.php b/app/Http/Controllers/Api/Client/Servers/ScheduleTaskController.php index 1091fb2ca..e38c9a9e0 100644 --- a/app/Http/Controllers/Api/Client/Servers/ScheduleTaskController.php +++ b/app/Http/Controllers/Api/Client/Servers/ScheduleTaskController.php @@ -44,8 +44,8 @@ class ScheduleTaskController extends ClientApiController throw new ServiceLimitExceededException("Schedules may not have more than $limit tasks associated with them. Creating this task would put this schedule over the limit."); } - if ($server->backup_limit === 0 && $request->action === 'backup') { - throw new HttpForbiddenException("A backup task cannot be created when the server's backup limit is set to 0."); + if (!$server->allowsBackups() && $request->action === 'backup') { + throw new HttpForbiddenException("A backup task cannot be created when backups are disabled for this server."); } /** @var Task|null $lastTask */ @@ -104,8 +104,8 @@ class ScheduleTaskController extends ClientApiController throw new NotFoundHttpException(); } - if ($server->backup_limit === 0 && $request->action === 'backup') { - throw new HttpForbiddenException("A backup task cannot be created when the server's backup limit is set to 0."); + if (!$server->allowsBackups() && $request->action === 'backup') { + throw new HttpForbiddenException("A backup task cannot be created when backups are disabled for this server."); } $this->connection->transaction(function () use ($request, $schedule, $task) { diff --git a/app/Http/Controllers/Api/Remote/Backups/BackupStatusController.php b/app/Http/Controllers/Api/Remote/Backups/BackupStatusController.php index 2492800ed..f89be94a5 100644 --- a/app/Http/Controllers/Api/Remote/Backups/BackupStatusController.php +++ b/app/Http/Controllers/Api/Remote/Backups/BackupStatusController.php @@ -66,6 +66,7 @@ class BackupStatusController extends Controller 'is_locked' => $successful ? $model->is_locked : false, 'checksum' => $successful ? ($request->input('checksum_type') . ':' . $request->input('checksum')) : null, 'bytes' => $successful ? $request->input('size') : 0, + 'snapshot_id' => $successful ? $request->input('snapshot_id') : null, 'completed_at' => CarbonImmutable::now(), ])->save(); @@ -209,4 +210,5 @@ class BackupStatusController extends Controller throw $e; } } + } diff --git a/app/Http/Controllers/Api/Remote/RusticConfigController.php b/app/Http/Controllers/Api/Remote/RusticConfigController.php new file mode 100644 index 000000000..fe64d6297 --- /dev/null +++ b/app/Http/Controllers/Api/Remote/RusticConfigController.php @@ -0,0 +1,102 @@ +firstOrFail(); + + $type = $request->query('type', 'local'); + + if (!in_array($type, ['local', 's3'])) { + return response()->json(['error' => 'Invalid backup type'], 400); + } + + $config = [ + 'backup_type' => $type, + 'repository_password' => $this->getRepositoryPassword($server), + 'repository_path' => $this->getRepositoryPath($server, $type), + ]; + + if ($type === 's3') { + $s3Credentials = $this->getS3Credentials($server); + if (!$s3Credentials) { + return response()->json(['error' => 'S3 credentials not configured for this server'], 400); + } + $config['s3_credentials'] = $s3Credentials; + } + + return response()->json($config); + } + + /** + * Generate server-specific repository password. + * This password is derived from the server UUID and application key for consistency. + */ + private function getRepositoryPassword(Server $server): string + { + // Use a deterministic approach: hash server UUID with app key + // This ensures the same password is generated each time for the same server + return hash('sha256', $server->uuid . config('app.key')); + } + + /** + * Get server-specific repository path. + */ + private function getRepositoryPath(Server $server, string $type): string + { + if ($type === 'local') { + $basePath = config('backups.disks.rustic_local.repository_base_path', '/var/lib/pterodactyl/rustic-repos'); + return rtrim($basePath, '/') . '/' . $server->uuid; + } + + // For S3, return the actual S3 path that rustic will use + $s3Config = config('backups.disks.rustic_s3'); + $bucket = $s3Config['bucket'] ?? ''; + $prefix = rtrim($s3Config['prefix'] ?? 'pterodactyl-backups/', '/'); + + if (empty($bucket)) { + throw new \InvalidArgumentException('S3 bucket not configured for rustic backups'); + } + + // Return the full S3 path for this server's repository + return sprintf('%s/%s/%s', $bucket, $prefix, $server->uuid); + } + + + /** + * Get S3 credentials for rustic S3 backups from global configuration. + */ + private function getS3Credentials(Server $server): ?array + { + $config = config('backups.disks.rustic_s3'); + + if (empty($config['bucket'])) { + return null; + } + + return [ + 'access_key_id' => $config['key'] ?? '', + 'secret_access_key' => $config['secret'] ?? '', + 'session_token' => '', // Not typically used for rustic + 'region' => $config['region'] ?? 'us-east-1', + 'bucket' => $config['bucket'], + 'endpoint' => $config['endpoint'] ?? '', + 'force_path_style' => $config['force_path_style'] ?? false, + 'disable_ssl' => $config['disable_ssl'] ?? false, + 'ca_cert_path' => $config['ca_cert_path'] ?? '', + ]; + } +} \ No newline at end of file diff --git a/app/Http/Requests/Api/Application/Servers/StoreServerRequest.php b/app/Http/Requests/Api/Application/Servers/StoreServerRequest.php index 8d87eecec..ae248a0a7 100644 --- a/app/Http/Requests/Api/Application/Servers/StoreServerRequest.php +++ b/app/Http/Requests/Api/Application/Servers/StoreServerRequest.php @@ -50,6 +50,7 @@ class StoreServerRequest extends ApplicationApiRequest 'feature_limits.databases' => $rules['database_limit'], 'feature_limits.allocations' => $rules['allocation_limit'], 'feature_limits.backups' => $rules['backup_limit'], + 'feature_limits.backup_storage_mb' => $rules['backup_storage_limit'], // Placeholders for rules added in withValidator() function. 'allocation.default' => '', @@ -97,6 +98,7 @@ class StoreServerRequest extends ApplicationApiRequest 'database_limit' => array_get($data, 'feature_limits.databases'), 'allocation_limit' => array_get($data, 'feature_limits.allocations'), 'backup_limit' => array_get($data, 'feature_limits.backups'), + 'backup_storage_limit' => array_get($data, 'feature_limits.backup_storage_mb'), 'oom_disabled' => array_get($data, 'oom_disabled'), 'exclude_from_resource_calculation' => array_get($data, 'exclude_from_resource_calculation', false), ]; diff --git a/app/Http/Requests/Api/Application/Servers/UpdateServerBuildConfigurationRequest.php b/app/Http/Requests/Api/Application/Servers/UpdateServerBuildConfigurationRequest.php index 64914e243..95e8ec68f 100644 --- a/app/Http/Requests/Api/Application/Servers/UpdateServerBuildConfigurationRequest.php +++ b/app/Http/Requests/Api/Application/Servers/UpdateServerBuildConfigurationRequest.php @@ -49,6 +49,7 @@ class UpdateServerBuildConfigurationRequest extends ServerWriteRequest 'feature_limits.databases' => $rules['database_limit'], 'feature_limits.allocations' => $rules['allocation_limit'], 'feature_limits.backups' => $rules['backup_limit'], + 'feature_limits.backup_storage_mb' => $rules['backup_storage_limit'], ]; } @@ -63,6 +64,7 @@ class UpdateServerBuildConfigurationRequest extends ServerWriteRequest $data['database_limit'] = $data['feature_limits']['databases'] ?? null; $data['allocation_limit'] = $data['feature_limits']['allocations'] ?? null; $data['backup_limit'] = $data['feature_limits']['backups'] ?? null; + $data['backup_storage_limit'] = $data['feature_limits']['backup_storage_mb'] ?? null; unset($data['allocation'], $data['feature_limits']); // Adjust the limits field to match what is expected by the model. @@ -90,6 +92,7 @@ class UpdateServerBuildConfigurationRequest extends ServerWriteRequest 'feature_limits.databases' => 'Database Limit', 'feature_limits.allocations' => 'Allocation Limit', 'feature_limits.backups' => 'Backup Limit', + 'feature_limits.backup_storage_mb' => 'Backup Storage Limit (MB)', ]; } diff --git a/app/Http/Requests/Api/Remote/ReportBackupCompleteRequest.php b/app/Http/Requests/Api/Remote/ReportBackupCompleteRequest.php index d0dd3090b..1c8c0052b 100644 --- a/app/Http/Requests/Api/Remote/ReportBackupCompleteRequest.php +++ b/app/Http/Requests/Api/Remote/ReportBackupCompleteRequest.php @@ -16,6 +16,7 @@ class ReportBackupCompleteRequest extends FormRequest 'parts' => 'nullable|array', 'parts.*.etag' => 'required|string', 'parts.*.part_number' => 'required|numeric', + 'snapshot_id' => 'nullable|string|max:64', ]; } } diff --git a/app/Models/Backup.php b/app/Models/Backup.php index 26004e9bd..6f7a254cd 100644 --- a/app/Models/Backup.php +++ b/app/Models/Backup.php @@ -19,6 +19,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; * @property string|null $checksum * @property int $bytes * @property string|null $upload_id + * @property string|null $snapshot_id * @property \Carbon\CarbonImmutable|null $completed_at * @property \Carbon\CarbonImmutable $created_at * @property \Carbon\CarbonImmutable $updated_at @@ -36,6 +37,8 @@ class Backup extends Model public const ADAPTER_WINGS = 'wings'; public const ADAPTER_AWS_S3 = 's3'; + public const ADAPTER_RUSTIC_LOCAL = 'rustic_local'; + public const ADAPTER_RUSTIC_S3 = 'rustic_s3'; protected $table = 'backups'; @@ -57,10 +60,47 @@ class Backup extends Model 'checksum' => null, 'bytes' => 0, 'upload_id' => null, + 'snapshot_id' => null, ]; protected $guarded = ['id', 'created_at', 'updated_at', 'deleted_at']; + /** + * Check if this backup uses the rustic backup system. + */ + public function isRustic(): bool + { + return in_array($this->disk, [self::ADAPTER_RUSTIC_LOCAL, self::ADAPTER_RUSTIC_S3]); + } + + /** + * Check if this backup is stored locally (not in cloud storage). + */ + public function isLocal(): bool + { + return in_array($this->disk, [self::ADAPTER_WINGS, self::ADAPTER_RUSTIC_LOCAL]); + } + + /** + * Get the repository type for rustic backups. + */ + public function getRepositoryType(): ?string + { + return match($this->disk) { + self::ADAPTER_RUSTIC_LOCAL => 'local', + self::ADAPTER_RUSTIC_S3 => 's3', + default => null, + }; + } + + /** + * Check if this backup has a rustic snapshot ID. + */ + public function hasSnapshotId(): bool + { + return !empty($this->snapshot_id); + } + public static array $validationRules = [ 'server_id' => 'bail|required|numeric|exists:servers,id', 'uuid' => 'required|uuid', @@ -69,8 +109,9 @@ class Backup extends Model 'name' => 'required|string', 'ignored_files' => 'array', 'server_state' => 'nullable|array', - 'disk' => 'required|string', + 'disk' => 'required|string|in:wings,s3,rustic_local,rustic_s3', 'checksum' => 'nullable|string', + 'snapshot_id' => 'nullable|string|max:64', 'bytes' => 'numeric', 'upload_id' => 'nullable|string', ]; diff --git a/app/Models/Node.php b/app/Models/Node.php index 18656c6e5..a4a7760b0 100644 --- a/app/Models/Node.php +++ b/app/Models/Node.php @@ -203,12 +203,60 @@ class Node extends Model 'sftp' => [ 'bind_port' => $this->daemonSFTP, ], + 'backups' => [ + 'rustic' => $this->getRusticBackupConfiguration(), + ], ], 'allowed_mounts' => $this->mounts->pluck('source')->toArray(), 'remote' => route('index'), ]; } + /** + * Get rustic backup configuration for Wings. + * Matches the exact structure expected by elytra rustic implementation. + */ + private function getRusticBackupConfiguration(): array + { + $localConfig = config('backups.disks.rustic_local', []); + $s3Config = config('backups.disks.rustic_s3', []); + + return [ + // Path to rustic binary + 'binary_path' => $localConfig['binary_path'] ?? 'rustic', + + // Repository version (optional, default handled by rustic) + 'repository_version' => $localConfig['repository_version'] ?? 2, + + // Pack size configuration for performance tuning + 'tree_pack_size_mb' => $localConfig['tree_pack_size_mb'] ?? 4, + 'data_pack_size_mb' => $localConfig['data_pack_size_mb'] ?? 32, + + // Local repository configuration + 'local' => [ + 'enabled' => !empty($localConfig), + 'repository_path' => $localConfig['repository_path'] ?? '/var/lib/pterodactyl/rustic-repos', + 'use_cold_storage' => $localConfig['use_cold_storage'] ?? false, + 'hot_repository_path' => $localConfig['hot_repository_path'] ?? '', + ], + + // S3 repository configuration + 's3' => [ + 'enabled' => !empty($s3Config['bucket']), + 'endpoint' => $s3Config['endpoint'] ?? '', + 'region' => $s3Config['region'] ?? 'us-east-1', + 'bucket' => $s3Config['bucket'] ?? '', + 'key_prefix' => $s3Config['prefix'] ?? 'pterodactyl-backups/', + 'use_cold_storage' => $s3Config['use_cold_storage'] ?? false, + 'hot_bucket' => $s3Config['hot_bucket'] ?? '', + 'cold_storage_class' => $s3Config['cold_storage_class'] ?? 'GLACIER', + 'force_path_style' => $s3Config['force_path_style'] ?? false, + 'disable_ssl' => $s3Config['disable_ssl'] ?? false, + 'ca_cert_path' => $s3Config['ca_cert_path'] ?? '', + ], + ]; + } + /** * Returns the configuration in Yaml format. */ diff --git a/app/Models/Server.php b/app/Models/Server.php index 0a3642b75..475dd4de4 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -42,7 +42,8 @@ use Pterodactyl\Exceptions\Http\Server\ServerStateConflictException; * @property string $image * @property int|null $allocation_limit * @property int|null $database_limit - * @property int $backup_limit + * @property int|null $backup_limit + * @property int|null $backup_storage_limit * @property \Illuminate\Support\Carbon|null $created_at * @property \Illuminate\Support\Carbon|null $updated_at * @property \Illuminate\Support\Carbon|null $installed_at @@ -173,9 +174,10 @@ class Server extends Model 'startup' => 'required|string', 'skip_scripts' => 'sometimes|boolean', 'image' => ['required', 'string', 'max:191', 'regex:/^~?[\w\.\/\-:@ ]*$/'], - 'database_limit' => 'present|nullable|integer|min:0', - 'allocation_limit' => 'sometimes|nullable|integer|min:0', - 'backup_limit' => 'present|nullable|integer|min:0', + 'database_limit' => 'nullable|integer|min:0', + 'allocation_limit' => 'nullable|integer|min:0', + 'backup_limit' => 'nullable|integer|min:0', + 'backup_storage_limit' => 'nullable|integer|min:0', ]; /** @@ -199,6 +201,7 @@ class Server extends Model 'database_limit' => 'integer', 'allocation_limit' => 'integer', 'backup_limit' => 'integer', + 'backup_storage_limit' => 'integer', self::CREATED_AT => 'datetime', self::UPDATED_AT => 'datetime', 'deleted_at' => 'datetime', @@ -372,6 +375,56 @@ class Server extends Model return $this->hasMany(Backup::class); } + /** + * Check if this server has a backup storage limit configured. + */ + public function hasBackupStorageLimit(): bool + { + return !is_null($this->backup_storage_limit) && $this->backup_storage_limit > 0; + } + + /** + * Get the backup storage limit in bytes. + */ + public function getBackupStorageLimitBytes(): ?int + { + if (!$this->hasBackupStorageLimit()) { + return null; + } + + return (int) ($this->backup_storage_limit * 1024 * 1024); + } + + public function hasBackupCountLimit(): bool + { + return !is_null($this->backup_limit) && $this->backup_limit > 0; + } + + public function allowsBackups(): bool + { + return is_null($this->backup_limit) || $this->backup_limit > 0; + } + + public function hasDatabaseLimit(): bool + { + return !is_null($this->database_limit) && $this->database_limit > 0; + } + + public function allowsDatabases(): bool + { + return is_null($this->database_limit) || $this->database_limit > 0; + } + + public function hasAllocationLimit(): bool + { + return !is_null($this->allocation_limit) && $this->allocation_limit > 0; + } + + public function allowsAllocations(): bool + { + return is_null($this->allocation_limit) || $this->allocation_limit > 0; + } + /** * Returns all mounts that have this server has mounted. */ diff --git a/app/Services/Backups/BackupStorageService.php b/app/Services/Backups/BackupStorageService.php new file mode 100644 index 000000000..6fec44595 --- /dev/null +++ b/app/Services/Backups/BackupStorageService.php @@ -0,0 +1,79 @@ +repository->getNonFailedBackups($server)->sum('bytes'); + } + + public function isOverStorageLimit(Server $server): bool + { + if (!$server->hasBackupStorageLimit()) { + return false; + } + + return $this->calculateServerBackupStorage($server) > $server->getBackupStorageLimitBytes(); + } + + public function wouldExceedStorageLimit(Server $server, int $estimatedBackupSizeBytes): bool + { + if (!$server->hasBackupStorageLimit()) { + return false; + } + + $currentUsage = $this->calculateServerBackupStorage($server); + $estimatedSize = $estimatedBackupSizeBytes * 0.5; // Conservative estimate for deduplication + + return ($currentUsage + $estimatedSize) > $server->getBackupStorageLimitBytes(); + } + + public function getStorageUsageInfo(Server $server): array + { + $usedBytes = $this->calculateServerBackupStorage($server); + $limitBytes = $server->getBackupStorageLimitBytes(); + $mbDivisor = 1024 * 1024; + + $result = [ + 'used_bytes' => $usedBytes, + 'used_mb' => round($usedBytes / $mbDivisor, 2), + 'limit_bytes' => $limitBytes, + 'limit_mb' => $server->backup_storage_limit, + 'has_limit' => $server->hasBackupStorageLimit(), + ]; + + if ($limitBytes) { + $result['usage_percentage'] = round(($usedBytes / $limitBytes) * 100, 1); + $result['available_bytes'] = max(0, $limitBytes - $usedBytes); + $result['available_mb'] = round($result['available_bytes'] / $mbDivisor, 2); + $result['is_over_limit'] = $usedBytes > $limitBytes; + } + + return $result; + } + + public function getBackupsForStorageCleanup(Server $server): \Illuminate\Database\Eloquent\Collection + { + return $this->repository->getNonFailedBackups($server) + ->where('is_locked', false) + ->sortBy('created_at'); + } + + public function calculateStorageFreedByDeletion(\Illuminate\Database\Eloquent\Collection $backups): int + { + return (int) $backups->sum(function ($backup) { + return $backup->isRustic() ? $backup->bytes * 0.3 : $backup->bytes; + }); + } +} \ No newline at end of file diff --git a/app/Services/Backups/DownloadLinkService.php b/app/Services/Backups/DownloadLinkService.php index f3f76c845..5ba718dd4 100644 --- a/app/Services/Backups/DownloadLinkService.php +++ b/app/Services/Backups/DownloadLinkService.php @@ -23,16 +23,24 @@ class DownloadLinkService */ public function handle(Backup $backup, User $user): string { + // Validate backup can be downloaded + $this->validateBackupForDownload($backup); + + // Legacy S3 backups use pre-signed URLs if ($backup->disk === Backup::ADAPTER_AWS_S3) { return $this->getS3BackupUrl($backup); } + // Wings local backups and Rustic backups (local & S3) use JWT tokens + // Wings handles rustic downloads internally by calling back to get rustic config $token = $this->jwtService ->setExpiresAt(CarbonImmutable::now()->addMinutes(15)) ->setUser($user) ->setClaims([ 'backup_uuid' => $backup->uuid, 'server_uuid' => $backup->server->uuid, + 'backup_disk' => $backup->disk, + 'repository_type' => $backup->getRepositoryType(), ]) ->handle($backup->server->node, $user->id . $backup->server->uuid); @@ -59,4 +67,38 @@ class DownloadLinkService return $request->getUri()->__toString(); } + + /** + * Validates that a backup can be downloaded. + */ + protected function validateBackupForDownload(Backup $backup): void + { + // General backup validation + if (!$backup->is_successful) { + throw new \InvalidArgumentException('Cannot download a failed backup.'); + } + + if (is_null($backup->completed_at)) { + throw new \InvalidArgumentException('Cannot download backup that is still in progress.'); + } + + // Rustic-specific validation + if ($backup->isRustic()) { + if (!$backup->hasSnapshotId()) { + throw new \InvalidArgumentException('Rustic backup cannot be downloaded: missing snapshot ID.'); + } + + // Validate snapshot ID format + if (strlen($backup->snapshot_id) !== 64 && strlen($backup->snapshot_id) !== 8) { + throw new \InvalidArgumentException('Rustic backup has invalid snapshot ID format.'); + } + } + + // Legacy S3 backup validation + if ($backup->disk === Backup::ADAPTER_AWS_S3) { + if ($backup->bytes <= 0) { + throw new \InvalidArgumentException('S3 backup has invalid size.'); + } + } + } } diff --git a/app/Services/Backups/InitiateBackupService.php b/app/Services/Backups/InitiateBackupService.php index 78a27eb08..8a4e094b9 100644 --- a/app/Services/Backups/InitiateBackupService.php +++ b/app/Services/Backups/InitiateBackupService.php @@ -10,6 +10,7 @@ use Illuminate\Database\ConnectionInterface; use Pterodactyl\Extensions\Backups\BackupManager; use Pterodactyl\Repositories\Eloquent\BackupRepository; use Pterodactyl\Repositories\Wings\DaemonBackupRepository; +use Pterodactyl\Services\Backups\BackupStorageService; use Pterodactyl\Exceptions\Service\Backup\TooManyBackupsException; use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException; use Carbon\CarbonImmutable; @@ -20,6 +21,7 @@ class InitiateBackupService private bool $isLocked = false; + /** * InitiateBackupService constructor. */ @@ -30,6 +32,7 @@ class InitiateBackupService private DeleteBackupService $deleteBackupService, private BackupManager $backupManager, private ServerStateService $serverStateService, + private BackupStorageService $backupStorageService, ) { } @@ -44,6 +47,7 @@ class InitiateBackupService return $this; } + /** * Sets the files to be ignored by this backup. * @@ -85,19 +89,26 @@ class InitiateBackupService throw new TooManyRequestsHttpException(30, 'A backup is already in progress. Please wait for it to complete before starting another.'); } - // Check if the server has reached or exceeded its backup limit. - // completed_at == null will cover any ongoing backups, while is_successful == true will cover any completed backups. $successful = $this->repository->getNonFailedBackups($server); - if (!$server->backup_limit || $successful->count() >= $server->backup_limit) { - // Do not allow the user to continue if this server is already at its limit and can't override. - if (!$override || $server->backup_limit <= 0) { + + if (!$server->allowsBackups()) { + throw new TooManyBackupsException(0, 'Backups are disabled for this server'); + } + + // Block backup creation if already over storage limit + if ($server->hasBackupStorageLimit() && $this->backupStorageService->isOverStorageLimit($server)) { + $usage = $this->backupStorageService->getStorageUsageInfo($server); + throw new TooManyBackupsException(0, sprintf( + 'Cannot create backup: server is already over storage limit (%.2fMB used of %.2fMB limit). Please delete old backups first.', + $usage['used_mb'], + $usage['limit_mb'] + )); + } + elseif ($server->hasBackupCountLimit() && $successful->count() >= $server->backup_limit) { + if (!$override) { throw new TooManyBackupsException($server->backup_limit); } - // Get the oldest backup the server has that is not "locked" (indicating a backup that should - // never be automatically purged). If we find a backup we will delete it and then continue with - // this process. If no backup is found that can be used an exception is thrown. - /** @var Backup $oldest */ $oldest = $successful->where('is_locked', false)->orderBy('created_at')->first(); if (!$oldest) { throw new TooManyBackupsException($server->backup_limit); @@ -107,27 +118,29 @@ class InitiateBackupService } return $this->connection->transaction(function () use ($server, $name) { - // Sanitize backup name to prevent injection $backupName = trim($name) ?: sprintf('Backup at %s', CarbonImmutable::now()->toDateTimeString()); $backupName = preg_replace('/[^a-zA-Z0-9\s\-_\.]/', '', $backupName); $backupName = substr($backupName, 0, 191); // Limit to database field length $serverState = $this->serverStateService->captureServerState($server); - + + // Use the configured default adapter + $adapter = $this->backupManager->getDefaultAdapter(); + /** @var Backup $backup */ $backup = $this->repository->create([ 'server_id' => $server->id, 'uuid' => Uuid::uuid4()->toString(), 'name' => $backupName, 'ignored_files' => array_values($this->ignoredFiles ?? []), - 'disk' => $this->backupManager->getDefaultAdapter(), + 'disk' => $adapter, 'is_locked' => $this->isLocked, 'server_state' => $serverState, ], true, true); try { $this->daemonBackupRepository->setServer($server) - ->setBackupAdapter($this->backupManager->getDefaultAdapter()) + ->setBackupAdapter($adapter) ->backup($backup); } catch (\Exception $e) { // If daemon backup request fails, clean up the backup record @@ -160,4 +173,5 @@ class InitiateBackupService throw new TooManyBackupsException(0, 'Cannot create backup while server is being transferred.'); } } + } diff --git a/app/Services/Databases/DatabaseManagementService.php b/app/Services/Databases/DatabaseManagementService.php index cb868cca6..13b46f3ee 100644 --- a/app/Services/Databases/DatabaseManagementService.php +++ b/app/Services/Databases/DatabaseManagementService.php @@ -76,9 +76,13 @@ class DatabaseManagementService } if ($this->validateDatabaseLimit) { + if (!$server->allowsDatabases()) { + throw new TooManyDatabasesException(); + } + // If the server has a limit assigned and we've already reached that limit, throw back // an exception and kill the process. - if (!is_null($server->database_limit) && $server->databases()->count() >= $server->database_limit) { + if ($server->hasDatabaseCountLimit() && $server->databases()->count() >= $server->database_limit) { throw new TooManyDatabasesException(); } } diff --git a/app/Services/Servers/BuildModificationService.php b/app/Services/Servers/BuildModificationService.php index cd145c489..db93c3e7b 100644 --- a/app/Services/Servers/BuildModificationService.php +++ b/app/Services/Servers/BuildModificationService.php @@ -49,9 +49,10 @@ class BuildModificationService $merge = Arr::only($data, ['oom_disabled', 'exclude_from_resource_calculation', 'memory', 'overhead_memory', 'swap', 'io', 'cpu', 'threads', 'disk', 'allocation_id']); $server->forceFill(array_merge($merge, [ - 'database_limit' => Arr::get($data, 'database_limit', 0) ?? null, - 'allocation_limit' => Arr::get($data, 'allocation_limit', 0) ?? null, - 'backup_limit' => Arr::get($data, 'backup_limit', 0) ?? 0, + 'database_limit' => Arr::get($data, 'database_limit') === '' ? null : Arr::get($data, 'database_limit'), + 'allocation_limit' => Arr::get($data, 'allocation_limit') === '' ? null : Arr::get($data, 'allocation_limit'), + 'backup_limit' => Arr::get($data, 'backup_limit') === '' ? null : Arr::get($data, 'backup_limit'), + 'backup_storage_limit' => Arr::get($data, 'backup_storage_limit') === '' ? null : Arr::get($data, 'backup_storage_limit'), ]))->saveOrFail(); return $server->refresh(); diff --git a/app/Services/Servers/ServerCreationService.php b/app/Services/Servers/ServerCreationService.php index d298e8e54..eda51760c 100644 --- a/app/Services/Servers/ServerCreationService.php +++ b/app/Services/Servers/ServerCreationService.php @@ -161,9 +161,10 @@ class ServerCreationService 'egg_id' => Arr::get($data, 'egg_id'), 'startup' => Arr::get($data, 'startup'), 'image' => Arr::get($data, 'image'), - 'database_limit' => Arr::get($data, 'database_limit') ?? 0, - 'allocation_limit' => Arr::get($data, 'allocation_limit') ?? 0, - 'backup_limit' => Arr::get($data, 'backup_limit') ?? 0, + 'database_limit' => Arr::get($data, 'database_limit'), + 'allocation_limit' => Arr::get($data, 'allocation_limit'), + 'backup_limit' => Arr::get($data, 'backup_limit'), + 'backup_storage_limit' => Arr::get($data, 'backup_storage_limit'), ]); return $model; diff --git a/app/Transformers/Api/Application/ServerTransformer.php b/app/Transformers/Api/Application/ServerTransformer.php index 1339b3b60..ee512687f 100644 --- a/app/Transformers/Api/Application/ServerTransformer.php +++ b/app/Transformers/Api/Application/ServerTransformer.php @@ -75,6 +75,7 @@ class ServerTransformer extends BaseTransformer 'databases' => $server->database_limit, 'allocations' => $server->allocation_limit, 'backups' => $server->backup_limit, + 'backup_storage_mb' => $server->backup_storage_limit, ], 'user' => $server->owner_id, 'node' => $server->node_id, diff --git a/app/Transformers/Api/Client/BackupTransformer.php b/app/Transformers/Api/Client/BackupTransformer.php index fa102b9f3..4c4c9eaed 100644 --- a/app/Transformers/Api/Client/BackupTransformer.php +++ b/app/Transformers/Api/Client/BackupTransformer.php @@ -27,6 +27,10 @@ class BackupTransformer extends BaseClientTransformer 'ignored_files' => $backup->ignored_files, 'checksum' => $backup->checksum, 'bytes' => $backup->bytes, + 'size_gb' => round($backup->bytes / (1024 * 1024 * 1024), 3), + 'adapter' => $backup->disk, + 'is_rustic' => $backup->isRustic(), + 'snapshot_id' => $backup->snapshot_id, 'created_at' => $backup->created_at->toAtomString(), 'completed_at' => $backup->completed_at ? $backup->completed_at->toAtomString() : null, ]; diff --git a/app/Transformers/Api/Client/ServerTransformer.php b/app/Transformers/Api/Client/ServerTransformer.php index 28d3dc1fc..46578a0f7 100644 --- a/app/Transformers/Api/Client/ServerTransformer.php +++ b/app/Transformers/Api/Client/ServerTransformer.php @@ -71,6 +71,7 @@ class ServerTransformer extends BaseClientTransformer 'databases' => $server->database_limit, 'allocations' => $server->allocation_limit, 'backups' => $server->backup_limit, + 'backupStorageMb' => $server->backup_storage_limit, ], 'status' => $server->status, // This field is deprecated, please use "status". diff --git a/config/backups.php b/config/backups.php index e20d0a469..b3d10cc3e 100644 --- a/config/backups.php +++ b/config/backups.php @@ -6,7 +6,8 @@ return [ // The backup driver to use for this Panel instance. All client generated server backups // will be stored in this location by default. It is possible to change this once backups // have been made, without losing data. - 'default' => env('APP_BACKUP_DRIVER', Backup::ADAPTER_WINGS), + // Options: wings, s3, rustic_local, rustic_s3 + 'default' => env('APP_BACKUP_DRIVER', Backup::ADAPTER_RUSTIC_LOCAL), // This value is used to determine the lifespan of UploadPart presigned urls that wings // uses to upload backups to S3 storage. Value is in minutes, so this would default to an hour. @@ -52,5 +53,53 @@ return [ 'storage_class' => env('AWS_BACKUPS_STORAGE_CLASS'), ], + + // Configuration for Rustic local backups. Rustic provides deduplicated, + // encrypted backups with fast incremental snapshots. + 'rustic_local' => [ + 'adapter' => Backup::ADAPTER_RUSTIC_LOCAL, + + // Path to rustic binary + 'binary_path' => env('RUSTIC_BINARY_PATH', 'rustic'), + + // Base path where rustic repositories will be stored (one per server) + 'repository_base_path' => env('RUSTIC_REPOSITORY_BASE_PATH', '/var/lib/pterodactyl/rustic-repos'), + + // Repository version (optional, default handled by rustic) + 'repository_version' => env('RUSTIC_REPOSITORY_VERSION', 2), + + // Pack size configuration for performance tuning + 'tree_pack_size_mb' => env('RUSTIC_TREE_PACK_SIZE_MB', 4), + 'data_pack_size_mb' => env('RUSTIC_DATA_PACK_SIZE_MB', 32), + + // Hot/cold storage setup option + 'use_cold_storage' => env('RUSTIC_LOCAL_USE_COLD_STORAGE', false), + 'hot_repository_path' => env('RUSTIC_LOCAL_HOT_REPOSITORY_PATH', ''), + ], + + // Configuration for Rustic S3 backups. Combines Rustic's features with S3 storage. + 'rustic_s3' => [ + 'adapter' => Backup::ADAPTER_RUSTIC_S3, + + // S3 configuration + 'endpoint' => env('RUSTIC_S3_ENDPOINT', env('AWS_ENDPOINT')), + 'region' => env('RUSTIC_S3_REGION', env('AWS_DEFAULT_REGION', 'us-east-1')), + 'bucket' => env('RUSTIC_S3_BUCKET'), + 'prefix' => env('RUSTIC_S3_PREFIX', 'pterodactyl-backups/'), + + // S3 credentials + 'key' => env('RUSTIC_S3_ACCESS_KEY_ID', env('AWS_ACCESS_KEY_ID')), + 'secret' => env('RUSTIC_S3_SECRET_ACCESS_KEY', env('AWS_SECRET_ACCESS_KEY')), + + // Hot/cold storage configuration + 'use_cold_storage' => env('RUSTIC_S3_USE_COLD_STORAGE', false), + 'hot_bucket' => env('RUSTIC_S3_HOT_BUCKET', ''), + 'cold_storage_class' => env('RUSTIC_S3_COLD_STORAGE_CLASS', 'GLACIER'), + + // Connection options + 'force_path_style' => env('RUSTIC_S3_FORCE_PATH_STYLE', false), + 'disable_ssl' => env('RUSTIC_S3_DISABLE_SSL', false), + 'ca_cert_path' => env('RUSTIC_S3_CA_CERT_PATH', ''), + ], ], ]; diff --git a/database/Factories/ServerFactory.php b/database/Factories/ServerFactory.php index e5ef37be6..97c17a873 100644 --- a/database/Factories/ServerFactory.php +++ b/database/Factories/ServerFactory.php @@ -42,7 +42,8 @@ class ServerFactory extends Factory 'image' => 'foo/bar:latest', 'allocation_limit' => null, 'database_limit' => null, - 'backup_limit' => 0, + 'backup_limit' => null, + 'backup_storage_limit' => null, 'created_at' => Carbon::now(), 'updated_at' => Carbon::now(), ]; diff --git a/database/migrations/2025_09_19_000000_add_rustic_backup_support.php b/database/migrations/2025_09_19_000000_add_rustic_backup_support.php new file mode 100644 index 000000000..e6a8da56b --- /dev/null +++ b/database/migrations/2025_09_19_000000_add_rustic_backup_support.php @@ -0,0 +1,38 @@ +enum('disk', ['wings', 's3', 'rustic_local', 'rustic_s3'])->default('wings')->change(); + + // Add rustic-specific columns + $table->string('snapshot_id', 64)->nullable()->after('checksum')->comment('Rustic snapshot ID for rustic backups'); + }); + + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('backups', function (Blueprint $table) { + // Revert disk column to original enum values + $table->enum('disk', ['wings', 's3'])->default('wings')->change(); + + // Drop rustic-specific columns + $table->dropColumn('snapshot_id'); + }); + + } +}; \ No newline at end of file diff --git a/database/migrations/2025_09_20_000000_add_backup_storage_limit_to_servers.php b/database/migrations/2025_09_20_000000_add_backup_storage_limit_to_servers.php new file mode 100644 index 000000000..d648db657 --- /dev/null +++ b/database/migrations/2025_09_20_000000_add_backup_storage_limit_to_servers.php @@ -0,0 +1,28 @@ +unsignedInteger('backup_storage_limit')->nullable()->after('backup_limit'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('servers', function (Blueprint $table) { + $table->dropColumn('backup_storage_limit'); + }); + } +}; \ No newline at end of file diff --git a/database/migrations/2025_09_20_000002_make_server_limits_nullable.php b/database/migrations/2025_09_20_000002_make_server_limits_nullable.php new file mode 100644 index 000000000..602e4724b --- /dev/null +++ b/database/migrations/2025_09_20_000002_make_server_limits_nullable.php @@ -0,0 +1,26 @@ +unsignedInteger('database_limit')->nullable()->change(); + $table->unsignedInteger('allocation_limit')->nullable()->change(); + $table->unsignedInteger('backup_limit')->nullable()->change(); + }); + } + + public function down(): void + { + Schema::table('servers', function (Blueprint $table) { + $table->unsignedInteger('database_limit')->default(0)->change(); + $table->unsignedInteger('allocation_limit')->default(0)->change(); + $table->unsignedInteger('backup_limit')->default(0)->change(); + }); + } +}; \ No newline at end of file diff --git a/resources/scripts/api/server/getServer.ts b/resources/scripts/api/server/getServer.ts index f357bf229..c7e65907c 100644 --- a/resources/scripts/api/server/getServer.ts +++ b/resources/scripts/api/server/getServer.ts @@ -39,6 +39,7 @@ export interface Server { databases: number; allocations: number; backups: number; + backupStorageMb: number | null; }; isTransferring: boolean; variables: ServerEggVariable[]; diff --git a/resources/scripts/api/server/types.d.ts b/resources/scripts/api/server/types.d.ts index c4a01e921..3ffa5111d 100644 --- a/resources/scripts/api/server/types.d.ts +++ b/resources/scripts/api/server/types.d.ts @@ -14,6 +14,10 @@ export interface ServerBackup { ignoredFiles: string; checksum: string; bytes: number; + sizeGb: number; + adapter: string; + isRustic: boolean; + snapshotId: string | null; createdAt: Date; completedAt: Date | null; } diff --git a/resources/scripts/api/swr/getServerBackups.ts b/resources/scripts/api/swr/getServerBackups.ts index aebfa2b69..02076d090 100644 --- a/resources/scripts/api/swr/getServerBackups.ts +++ b/resources/scripts/api/swr/getServerBackups.ts @@ -15,7 +15,23 @@ interface ctx { export const Context = createContext({ page: 1, setPage: () => 1 }); -type BackupResponse = PaginatedResult & { backupCount: number }; +type BackupResponse = PaginatedResult & { + backupCount: number; + storage: { + used_mb: number; + limit_mb: number | null; + has_limit: boolean; + usage_percentage: number | null; + available_mb: number | null; + is_over_limit: boolean; + }; + limits: { + count_limit: number | null; + has_count_limit: boolean; + storage_limit_mb: number | null; + has_storage_limit: boolean; + }; +}; export default () => { const { page } = useContext(Context); @@ -28,6 +44,8 @@ export default () => { items: (data.data || []).map(rawDataToServerBackup), pagination: getPaginationSet(data.meta.pagination), backupCount: data.meta.backup_count, + storage: data.meta.storage, + limits: data.meta.limits, }; }); }; diff --git a/resources/scripts/api/transformers.ts b/resources/scripts/api/transformers.ts index 05c9e9d76..794f52f4d 100644 --- a/resources/scripts/api/transformers.ts +++ b/resources/scripts/api/transformers.ts @@ -62,6 +62,10 @@ export const rawDataToServerBackup = ({ attributes }: FractalResponseData): Serv ignoredFiles: attributes.ignored_files, checksum: attributes.checksum, bytes: attributes.bytes, + sizeGb: attributes.size_gb, + adapter: attributes.adapter, + isRustic: attributes.is_rustic, + snapshotId: attributes.snapshot_id, createdAt: new Date(attributes.created_at), completedAt: attributes.completed_at ? new Date(attributes.completed_at) : null, }); diff --git a/resources/scripts/components/elements/MobileFullScreenMenu.tsx b/resources/scripts/components/elements/MobileFullScreenMenu.tsx index cd7049a44..6c18ee7f0 100644 --- a/resources/scripts/components/elements/MobileFullScreenMenu.tsx +++ b/resources/scripts/components/elements/MobileFullScreenMenu.tsx @@ -104,9 +104,9 @@ interface ServerMobileMenuProps { isVisible: boolean; onClose: () => void; serverId?: string; - databaseLimit?: number; - backupLimit?: number; - allocationLimit?: number; + databaseLimit?: number | null; + backupLimit?: number | null; + allocationLimit?: number | null; subdomainSupported?: boolean; } @@ -114,9 +114,9 @@ export const ServerMobileMenu = ({ isVisible, onClose, serverId, - databaseLimit = 0, - backupLimit = 0, - allocationLimit = 0, + databaseLimit, + backupLimit, + allocationLimit, subdomainSupported = false, }: ServerMobileMenuProps) => { const NavigationItem = ({ @@ -162,7 +162,7 @@ export const ServerMobileMenu = ({ - {databaseLimit > 0 && ( + {databaseLimit !== 0 && ( Databases @@ -170,7 +170,7 @@ export const ServerMobileMenu = ({ )} - {backupLimit > 0 && ( + {backupLimit !== 0 && ( Backups diff --git a/resources/scripts/components/server/backups/BackupContainer.tsx b/resources/scripts/components/server/backups/BackupContainer.tsx index aadf97d47..d833d97c5 100644 --- a/resources/scripts/components/server/backups/BackupContainer.tsx +++ b/resources/scripts/components/server/backups/BackupContainer.tsx @@ -24,6 +24,17 @@ import { ServerContext } from '@/state/server'; import useFlash from '@/plugins/useFlash'; +// Helper function to format storage values +const formatStorage = (mb: number | undefined | null): string => { + if (mb === null || mb === undefined) { + return '0MB'; + } + if (mb >= 1024) { + return `${(mb / 1024).toFixed(1)}GB`; + } + return `${mb.toFixed(1)}MB`; +}; + interface BackupValues { name: string; ignored: string; @@ -90,6 +101,8 @@ const BackupContainer = () => { const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid); const backupLimit = ServerContext.useStoreState((state) => state.server.data!.featureLimits.backups); + const backupStorageLimit = ServerContext.useStoreState((state) => state.server.data!.featureLimits.backupStorageMb); + const hasBackupsInProgress = backups?.items.some((backup) => backup.completedAt === null) || false; @@ -166,16 +179,57 @@ const BackupContainer = () => { titleChildren={
- {backupLimit > 0 && ( -

- {backups.backupCount} of {backupLimit} backups -

- )} - {backupLimit > 0 && backupLimit > backups.backupCount && ( - setCreateModalVisible(true)}> - New Backup - - )} +
+ {/* Backup Count Display */} + {backupLimit === null && ( +

+ {backups.backupCount} backups +

+ )} + {backupLimit > 0 && ( +

+ {backups.backupCount} of {backupLimit} backups +

+ )} + {backupLimit === 0 && ( +

+ Backups disabled +

+ )} + + {/* Storage Usage Display */} + {backups.storage && ( +
+ {backupStorageLimit === null ? ( +

+ {formatStorage(backups.storage.used_mb)} storage used +

+ ) : ( + <> +

+ {formatStorage(backups.storage.used_mb)} {' '} + {backupStorageLimit === null ? + "used" : + (of {formatStorage(backupStorageLimit)} used)} +

+ + + )} +
+ )} +
+ {(backupLimit === null || backupLimit > backups.backupCount) && + (!backupStorageLimit || !backups.storage?.is_over_limit) && ( + setCreateModalVisible(true)}> + New Backup + + )}
} @@ -215,12 +269,12 @@ const BackupContainer = () => {

- {backupLimit > 0 ? 'No backups found' : 'Backups unavailable'} + {backupLimit === 0 ? 'Backups unavailable' : 'No backups found'}

- {backupLimit > 0 - ? 'Your server does not have any backups. Create one to get started.' - : 'Backups cannot be created for this server.'} + {backupLimit === 0 + ? 'Backups cannot be created for this server.' + : 'Your server does not have any backups. Create one to get started.'}

diff --git a/resources/scripts/components/server/backups/BackupRow.tsx b/resources/scripts/components/server/backups/BackupRow.tsx index 7e666b16a..572b72aab 100644 --- a/resources/scripts/components/server/backups/BackupRow.tsx +++ b/resources/scripts/components/server/backups/BackupRow.tsx @@ -25,8 +25,6 @@ const BackupRow = ({ backup }: Props) => { useWebsocketEvent(`${SocketEvent.BACKUP_COMPLETED}:${backup.uuid}` as SocketEvent, async () => { try { - // When backup completes, refresh the backup list from API to get accurate completion time - // This ensures we get the exact completion timestamp from the database, not the websocket receive time await mutate(); } catch (e) { console.warn(e); @@ -36,7 +34,6 @@ const BackupRow = ({ backup }: Props) => { return (
- {/* Status Icon */}
{backup.completedAt === null ? ( @@ -49,7 +46,6 @@ const BackupRow = ({ backup }: Props) => { )}
- {/* Main Content */}
{backup.completedAt !== null && !backup.isSuccessful && ( @@ -69,7 +65,6 @@ const BackupRow = ({ backup }: Props) => { {backup.checksum &&

{backup.checksum}

}
- {/* Size Info */} {backup.completedAt !== null && backup.isSuccessful && (

Size

@@ -77,7 +72,6 @@ const BackupRow = ({ backup }: Props) => {
)} - {/* Date Info */}

Created

{

- {/* Actions Menu */}
{backup.completedAt ? : null} diff --git a/resources/scripts/components/server/databases/DatabasesContainer.tsx b/resources/scripts/components/server/databases/DatabasesContainer.tsx index 3b92664a9..2fdc6cd4f 100644 --- a/resources/scripts/components/server/databases/DatabasesContainer.tsx +++ b/resources/scripts/components/server/databases/DatabasesContainer.tsx @@ -92,12 +92,22 @@ const DatabasesContainer = () => { titleChildren={
+ {databaseLimit === null && ( +

+ {databases.length} databases (unlimited) +

+ )} {databaseLimit > 0 && (

{databases.length} of {databaseLimit} databases

)} - {databaseLimit > 0 && databaseLimit !== databases.length && ( + {databaseLimit === 0 && ( +

+ Databases disabled +

+ )} + {(databaseLimit === null || (databaseLimit > 0 && databaseLimit !== databases.length)) && ( setCreateModalVisible(true)}> New Database @@ -177,12 +187,12 @@ const DatabasesContainer = () => {

- {databaseLimit > 0 ? 'No databases found' : 'Databases unavailable'} + {databaseLimit === 0 ? 'Databases unavailable' : 'No databases found'}

- {databaseLimit > 0 - ? 'Your server does not have any databases. Create one to get started.' - : 'Databases cannot be created for this server.'} + {databaseLimit === 0 + ? 'Databases cannot be created for this server.' + : 'Your server does not have any databases. Create one to get started.'}

diff --git a/resources/scripts/components/server/network/NetworkContainer.tsx b/resources/scripts/components/server/network/NetworkContainer.tsx index 82e267b6f..ef9b4b7d1 100644 --- a/resources/scripts/components/server/network/NetworkContainer.tsx +++ b/resources/scripts/components/server/network/NetworkContainer.tsx @@ -73,13 +73,25 @@ const NetworkContainer = () => {

Port Allocations

- {data && allocationLimit > 0 && ( + {data && (
- - {data.filter((allocation) => !allocation.isDefault).length} of {allocationLimit} - - {allocationLimit > data.filter((allocation) => !allocation.isDefault).length && ( + {allocationLimit === null && ( + + {data.filter((allocation) => !allocation.isDefault).length} allocations (unlimited) + + )} + {allocationLimit > 0 && ( + + {data.filter((allocation) => !allocation.isDefault).length} of {allocationLimit} + + )} + {allocationLimit === 0 && ( + + Allocations disabled + + )} + {(allocationLimit === null || (allocationLimit > 0 && allocationLimit > data.filter((allocation) => !allocation.isDefault).length)) && ( New Allocation @@ -120,12 +132,12 @@ const NetworkContainer = () => {

- {allocationLimit > 0 ? 'No allocations found' : 'Allocations unavailable'} + {allocationLimit === 0 ? 'Allocations unavailable' : 'No allocations found'}

- {allocationLimit > 0 - ? 'Create your first allocation to get started.' - : 'Network allocations cannot be created for this server.'} + {allocationLimit === 0 + ? 'Network allocations cannot be created for this server.' + : 'Create your first allocation to get started.'}

diff --git a/resources/scripts/routers/ServerRouter.tsx b/resources/scripts/routers/ServerRouter.tsx index de78214b2..ca141fd2c 100644 --- a/resources/scripts/routers/ServerRouter.tsx +++ b/resources/scripts/routers/ServerRouter.tsx @@ -51,9 +51,9 @@ const blank_egg_prefix = '@'; // Sidebar item components that check both permissions and feature limits const DatabasesSidebarItem = React.forwardRef void }>( ({ id, onClick }, ref) => { - const databaseLimit = ServerContext.useStoreState((state) => state.server.data?.featureLimits.databases ?? 0); + const databaseLimit = ServerContext.useStoreState((state) => state.server.data?.featureLimits.databases); - // Hide if no database access (limit is 0) + // Hide if databases are disabled (limit is 0) if (databaseLimit === 0) return null; return ( @@ -76,9 +76,9 @@ DatabasesSidebarItem.displayName = 'DatabasesSidebarItem'; const BackupsSidebarItem = React.forwardRef void }>( ({ id, onClick }, ref) => { - const backupLimit = ServerContext.useStoreState((state) => state.server.data?.featureLimits.backups ?? 0); + const backupLimit = ServerContext.useStoreState((state) => state.server.data?.featureLimits.backups); - // Hide if no backup access (limit is 0) + // Hide if backups are disabled (limit is 0) if (backupLimit === 0) return null; return ( @@ -165,9 +165,9 @@ const ServerRouter = () => { const getServer = ServerContext.useStoreActions((actions) => actions.server.getServer); const clearServerState = ServerContext.useStoreActions((actions) => actions.clearServerState); const egg_id = ServerContext.useStoreState((state) => state.server.data?.egg); - const databaseLimit = ServerContext.useStoreState((state) => state.server.data?.featureLimits.databases ?? 0); - const backupLimit = ServerContext.useStoreState((state) => state.server.data?.featureLimits.backups ?? 0); - const allocationLimit = ServerContext.useStoreState((state) => state.server.data?.featureLimits.allocations ?? 0); + const databaseLimit = ServerContext.useStoreState((state) => state.server.data?.featureLimits.databases); + const backupLimit = ServerContext.useStoreState((state) => state.server.data?.featureLimits.backups); + const allocationLimit = ServerContext.useStoreState((state) => state.server.data?.featureLimits.allocations); // Mobile menu state const [isMobileMenuVisible, setMobileMenuVisible] = useState(false); diff --git a/resources/views/admin/servers/new.blade.php b/resources/views/admin/servers/new.blade.php index 67afa33f1..f04e622f2 100644 --- a/resources/views/admin/servers/new.blade.php +++ b/resources/views/admin/servers/new.blade.php @@ -112,23 +112,31 @@
- +
-

The total number of databases a user is allowed to create for this server.

+

The total number of databases a user is allowed to create for this server. Leave blank for unlimited, set to 0 to disable.

- +
-

The total number of allocations a user is allowed to create for this server.

+

The total number of allocations a user is allowed to create for this server. Leave blank for unlimited, set to 0 to disable.

- +
-

The total number of backups that can be created for this server.

+

The total number of backups that can be created for this server. Leave blank for unlimited, set to 0 to disable.

+
+
+ +
+ + MiB +
+

The total storage space that can be used for backups. Leave blank for unlimited storage.

diff --git a/resources/views/admin/servers/view/build.blade.php b/resources/views/admin/servers/view/build.blade.php index be2b31a7f..04c049e00 100644 --- a/resources/views/admin/servers/view/build.blade.php +++ b/resources/views/admin/servers/view/build.blade.php @@ -127,21 +127,29 @@
-

The total number of databases a user is allowed to create for this server.

+

The total number of databases a user is allowed to create for this server. Leave blank for unlimited, set to 0 to disable.

-

The total number of allocations a user is allowed to create for this server.

+

The total number of allocations a user is allowed to create for this server. Leave blank for unlimited, set to 0 to disable.

-

The total number of backups that can be created for this server.

+

The total number of backups that can be created for this server. Leave blank for unlimited, set to 0 to disable.

+
+
+ +
+ + MiB +
+

The total storage space that can be used for backups. Leave blank for unlimited storage.

diff --git a/routes/api-remote.php b/routes/api-remote.php index 8b77cf16e..b30d60f28 100644 --- a/routes/api-remote.php +++ b/routes/api-remote.php @@ -15,6 +15,8 @@ Route::group(['prefix' => '/servers/{uuid}'], function () { Route::get('/install', [Remote\Servers\ServerInstallController::class, 'index']); Route::post('/install', [Remote\Servers\ServerInstallController::class, 'store']); + Route::get('/rustic-config', [Remote\RusticConfigController::class, 'show']); + Route::get('/transfer/failure', [Remote\Servers\ServerTransferController::class, 'failure']); Route::get('/transfer/success', [Remote\Servers\ServerTransferController::class, 'success']); Route::post('/transfer/failure', [Remote\Servers\ServerTransferController::class, 'failure']);