Compare commits

..

35 Commits

Author SHA1 Message Date
Lance Pioch
0b0fac7337 Surface passkey login errors to the user
The @laravel/passkeys client reads the failure reason from a top-level
"message" field, but Pelican's exception handler renders errors in a
JSON:API envelope ({"errors": [{"detail": ...}]}) without one, so the
client fell back to "Request failed with status 4xx".

Add a middleware scoped to the passkey routes that mirrors the first
error's detail into a "message" field, and catch failures in
authenticateWithPasskey() to show them via a Filament notification
instead of failing silently in the console.
2026-06-22 23:50:52 -04:00
JoanFo1456
50a716c964 Well, redirect is now working, something broke it.. 2026-06-21 22:16:27 +00:00
JoanFo1456
97bff76a7d Without this passkeys fail... 2026-06-21 21:45:59 +00:00
JoanFo1456
9977514926 Phpstan... 2026-06-20 13:29:21 +00:00
JoanFo1456
90b4995017 Passkeys not on HTTP 2026-06-20 12:43:59 +00:00
JoanFo1456
9f98c0da18 Lance changes 2026-06-20 12:40:40 +00:00
JoanFo1456
9e94c41ead Can't revert anything else + fix mysql 2026-06-19 21:43:47 +00:00
JoanFo1456
97725f067a Merge remote-tracking branch 'origin/main' into passkeys 2026-06-19 21:37:20 +00:00
JoanFo1456
2781593df3 Rabbit fixes 2026-06-06 17:32:42 +02:00
JoanFo1456
abcba9dc84 Rabbit idea... 2026-06-06 17:31:49 +02:00
JoanFo
bfc67bab8f Merge branch 'pelican-dev:main' into passkeys 2026-06-06 17:21:40 +02:00
JoanFo1456
158b9183ae Rabbit fixes and passkeys do not work on HTTP 2026-06-06 17:21:12 +02:00
JoanFo1456
47453b0587 Pint... 2026-05-31 03:28:47 +02:00
JoanFo1456
c1379415a1 This should fix Boy132's suggestion 2026-05-31 03:26:04 +02:00
JoanFo1456
63f6ba671f Fixed merge issues 2026-05-31 02:46:48 +02:00
JoanFo1456
80dbcfc2fb Suggested changes 2026-02-08 20:05:07 +00:00
JoanFo1456
5029e5767c Goodbye plugin 2026-02-08 17:00:13 +00:00
JoanFo1456
0c47ec2e2e Use trans() 2026-02-08 16:28:19 +00:00
JoanFo1456
e16a3d89eb Pint... 2026-02-07 23:55:11 +00:00
JoanFo1456
a2704f78b8 Rabbit suggestions ig. 2026-02-07 23:51:58 +00:00
JoanFo1456
33792a1b26 This should shut phpstan... 2026-02-07 21:42:25 +00:00
JoanFo
a045eaf1a7 Merge branch 'main' into passkeys 2026-02-07 22:34:46 +01:00
JoanFo1456
378c4ddd4d Composer should work now... 2026-02-07 21:33:01 +00:00
JoanFo1456
c57a54fa37 Added translation 2026-02-07 17:19:25 +01:00
JoanFo1456
e51f3c7583 Git and his mooshrooms 2026-02-07 16:35:55 +01:00
JoanFo1456
6906381a9a Lance's suggested changes 2026-02-07 16:35:06 +01:00
JoanFo
d87aa2dba7 Apply suggestion from @lancepioch
Co-authored-by: Lance Pioch <lancepioch@gmail.com>
2026-02-07 16:29:04 +01:00
JoanFo1456
816ad1feb8 I swear it worked!! 2026-02-07 16:26:29 +01:00
JoanFo1456
64799cdb4b Pint 2026-02-07 16:23:32 +01:00
JoanFo1456
3aaba31b7c Moved everything to Livewire 2026-02-07 16:17:19 +01:00
JoanFo1456
02e56448e6 Well, merged the package to codebase 2026-02-07 16:14:54 +01:00
JoanFo1456
13961c8042 ... 2026-02-07 12:59:02 +01:00
JoanFo1456
d051f4c224 Duplicated code on user 2026-02-07 12:56:14 +01:00
JoanFo1456
e60784bc8d Phpstan and pint 2026-02-07 12:39:59 +01:00
JoanFo1456
52d2ffb27e Passkeys working! 2026-02-07 12:36:07 +01:00
432 changed files with 4297 additions and 8048 deletions

View File

@@ -16,8 +16,6 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Download Translations from Crowdin
uses: crowdin/github-action@v2
@@ -37,14 +35,8 @@ jobs:
- name: Run Laravel Pint
uses: aglipanci/laravel-pint-action@8eedec46a1856977c41667f9c66d5562fa3f9c08 # 2.4
# The Crowdin and Pint steps run in Docker as root, so new translation
# files land owned by root. Reclaim them so the host-side git operations
# in create-pull-request can manage the working tree.
- name: Fix file ownership
run: sudo chown -R "$(id -u):$(id -g)" "${{ github.workspace }}"
- name: Create Pull Request
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
uses: peter-evans/create-pull-request@c5a7806660adbe173f04e3e038b0ccdcd758773c # v6
with:
token: ${{ secrets.GITHUB_TOKEN }}
commit-message: 'chore: update translations and format with pint'

View File

@@ -84,9 +84,6 @@ RUN mkdir -p /pelican-data/storage /var/run/supervisord \
&& chown -R www-data: /pelican-data .env ./storage ./bootstrap/cache /var/run/supervisord /var/www/html/public/storage \
&& chmod -R 770 /pelican-data ./storage ./bootstrap/cache /var/run/supervisord \
&& chown -R www-data: /usr/local/etc/php/ /usr/local/etc/php-fpm.d/ /var/www/html/composer.json /var/www/html/composer.lock
# Configure PHP and PHP-FPM
COPY docker/php/pelican.ini /usr/local/etc/php/conf.d/zz-pelican.ini
COPY docker/php/pelican-pool.conf /usr/local/etc/php-fpm.d/zz-pelican.conf
# Configure Supervisor
COPY docker/supervisord.conf /etc/supervisord.conf
COPY docker/Caddyfile /etc/caddy/Caddyfile

View File

@@ -5,9 +5,6 @@ FROM --platform=$TARGETOS/$TARGETARCH php:8.5-fpm-alpine
ADD --chmod=0755 https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions /usr/local/bin/
RUN install-php-extensions bcmath gd intl zip opcache pcntl pdo_mysql pdo_pgsql bz2
RUN install-php-extensions bcmath gd intl zip pcntl pdo_mysql pdo_pgsql bz2
RUN rm /usr/local/bin/install-php-extensions
# Use the production php.ini shipped with the image as the baseline
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"

View File

@@ -5,13 +5,10 @@ FROM --platform=$TARGETOS/$TARGETARCH php:8.5-fpm-alpine AS base
ADD --chmod=0755 https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions /usr/local/bin/
RUN install-php-extensions bcmath gd intl zip opcache pcntl pdo_mysql pdo_pgsql bz2
RUN install-php-extensions bcmath gd intl zip pcntl pdo_mysql pdo_pgsql bz2
RUN rm /usr/local/bin/install-php-extensions
# Use the production php.ini shipped with the image as the baseline
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
# ================================
# Stage 1-1: Composer Install
# ================================
@@ -93,9 +90,6 @@ RUN mkdir -p /pelican-data/storage /var/run/supervisord \
&& chmod -R 770 /pelican-data ./storage ./bootstrap/cache /var/run/supervisord \
&& chown -R www-data: /usr/local/etc/php/ /usr/local/etc/php-fpm.d/ /var/www/html/composer.json /var/www/html/composer.lock
# Configure PHP and PHP-FPM
COPY docker/php/pelican.ini /usr/local/etc/php/conf.d/zz-pelican.ini
COPY docker/php/pelican-pool.conf /usr/local/etc/php-fpm.d/zz-pelican.conf
# Configure Supervisor
COPY docker/supervisord.conf /etc/supervisord.conf
COPY docker/Caddyfile /etc/caddy/Caddyfile

View File

@@ -2,8 +2,8 @@
namespace App\Checks;
use Carbon\Carbon;
use Composer\InstalledVersions;
use Illuminate\Support\Carbon;
use Spatie\Health\Checks\Checks\ScheduleCheck as BaseCheck;
use Spatie\Health\Checks\Result;

View File

@@ -37,7 +37,9 @@ class CheckEggUpdatesCommand extends Command
$extension = strtolower(pathinfo(parse_url($egg->update_url, PHP_URL_PATH), PATHINFO_EXTENSION));
throw_if(empty($extension), new Exception('Unsupported file format.'));
if (empty($extension)) {
throw new Exception('Unsupported file format.');
}
$isYaml = in_array($extension, ['yaml', 'yml']);
@@ -47,7 +49,9 @@ class CheckEggUpdatesCommand extends Command
$remote = Http::timeout(5)->connectTimeout(1)->get($egg->update_url);
throw_if($remote->failed(), new Exception("HTTP request returned status code {$remote->status()}"));
if ($remote->failed()) {
throw new Exception("HTTP request returned status code {$remote->status()}");
}
$remote = $remote->body();
$remote = $isYaml ? Yaml::parse($remote) : json_decode($remote, true);

View File

@@ -7,8 +7,8 @@ use App\Exceptions\Service\InvalidFileUploadException;
use App\Models\Egg;
use App\Services\Eggs\Sharing\EggExporterService;
use App\Services\Eggs\Sharing\EggImporterService;
use Carbon\Carbon;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Symfony\Component\Yaml\Yaml;
class NormalizeEggCommand extends Command
@@ -112,7 +112,7 @@ class NormalizeEggCommand extends Command
private static function replaceExtension(string $path): string
{
return preg_replace('/^(.*\.)(?:yml|json|yaml)$/', '$1yaml', $path) ?? $path;
return preg_replace('/^(.*\.)(?:yml|json|yaml)$/', '$1yaml', $path);
}
/**

View File

@@ -2,10 +2,10 @@
namespace App\Console\Commands\Maintenance;
use Carbon\Carbon;
use Illuminate\Console\Command;
use Illuminate\Contracts\Filesystem\Factory as FilesystemFactory;
use Illuminate\Contracts\Filesystem\Filesystem;
use Illuminate\Support\Carbon;
use SplFileInfo;
class CleanServiceBackupFilesCommand extends Command

View File

@@ -16,7 +16,9 @@ class PruneOrphanedBackupsCommand extends Command
public function handle(): void
{
$since = $this->option('prune-age') ?? config('backups.prune_age', 360);
throw_if(!$since || !is_digit($since), new InvalidArgumentException('The "--prune-age" argument must be a value greater than 0.'));
if (!$since || !is_digit($since)) {
throw new InvalidArgumentException('The "--prune-age" argument must be a value greater than 0.');
}
$query = Backup::query()
->whereNull('completed_at')

View File

@@ -6,7 +6,6 @@ enum RolePermissionModels: string
{
case ApiKey = 'apiKey';
case Allocation = 'allocation';
case BackupHost = 'backupHost';
case DatabaseHost = 'databaseHost';
case Database = 'database';
case Egg = 'egg';

View File

@@ -11,6 +11,6 @@ class FileSizeTooLargeException extends DisplayException
*/
public function __construct()
{
parent::__construct(trans('exceptions.server.file_too_large'));
parent::__construct('The file you are attempting to open is too large to view in the file editor.');
}
}

View File

@@ -15,17 +15,17 @@ class ServerStateConflictException extends ConflictHttpException
*/
public function __construct(Server $server, ?Throwable $previous = null)
{
$message = trans('exceptions.server.state_conflict');
$message = 'This server is currently in an unsupported state, please try again later.';
if ($server->isSuspended()) {
$message = trans('exceptions.server.suspended');
$message = 'This server is currently suspended and the functionality requested is unavailable.';
} elseif ($server->node->isUnderMaintenance()) {
$message = trans('exceptions.server.maintenance');
$message = 'The node of this server is currently under maintenance and the functionality requested is unavailable.';
} elseif (!$server->isInstalled()) {
$message = trans('exceptions.server.marked_as_failed');
$message = 'This server has not yet completed its installation process, please try again later.';
} elseif ($server->status === ServerState::RestoringBackup) {
$message = trans('exceptions.server.restoring_backup');
$message = 'This server is currently restoring from a backup, please try again later.';
} elseif (!is_null($server->transfer)) {
$message = trans('exceptions.server.transferring');
$message = 'This server is currently being transferred to a new machine, please try again later.';
}
parent::__construct($message, $previous);

View File

@@ -1,23 +0,0 @@
<?php
namespace App\Extensions\BackupAdapter;
use App\Models\Backup;
use App\Models\User;
use Filament\Schemas\Components\Component;
interface BackupAdapterSchemaInterface
{
public function getId(): string;
public function getName(): string;
public function createBackup(Backup $backup): void;
public function deleteBackup(Backup $backup): void;
public function getDownloadLink(Backup $backup, User $user): string;
/** @return Component[] */
public function getConfigurationForm(): array;
}

View File

@@ -1,35 +0,0 @@
<?php
namespace App\Extensions\BackupAdapter;
class BackupAdapterService
{
/** @var array<string, BackupAdapterSchemaInterface> */
private array $schemas = [];
/** @return BackupAdapterSchemaInterface[] */
public function getAll(): array
{
return $this->schemas;
}
public function get(string $id): ?BackupAdapterSchemaInterface
{
return array_get($this->schemas, $id);
}
public function register(BackupAdapterSchemaInterface $schema): void
{
if (array_key_exists($schema->getId(), $this->schemas)) {
return;
}
$this->schemas[$schema->getId()] = $schema;
}
/** @return array<string, string> */
public function getMappings(): array
{
return collect($this->schemas)->mapWithKeys(fn ($schema) => [$schema->getId() => $schema->getName()])->all();
}
}

View File

@@ -1,14 +0,0 @@
<?php
namespace App\Extensions\BackupAdapter\Schemas;
use App\Extensions\BackupAdapter\BackupAdapterSchemaInterface;
use Illuminate\Support\Str;
abstract class BackupAdapterSchema implements BackupAdapterSchemaInterface
{
public function getName(): string
{
return Str::title($this->getId());
}
}

View File

@@ -1,207 +0,0 @@
<?php
namespace App\Extensions\BackupAdapter\Schemas;
use App\Enums\TablerIcon;
use App\Http\Controllers\Api\Remote\Backups\BackupRemoteUploadController;
use App\Models\Backup;
use App\Models\BackupHost;
use App\Models\User;
use App\Repositories\Daemon\DaemonBackupRepository;
use Aws\S3\S3Client;
use Carbon\CarbonImmutable;
use Exception;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Components\Component;
use Filament\Schemas\Components\StateCasts\BooleanStateCast;
use Illuminate\Support\Arr;
final class S3BackupSchema extends BackupAdapterSchema
{
public function __construct(private readonly DaemonBackupRepository $repository) {}
private function createClient(BackupHost $backupHost): S3Client
{
$config = $backupHost->configuration;
$config['version'] = 'latest';
if (!empty($config['key']) && !empty($config['secret'])) {
$config['credentials'] = Arr::only($config, ['key', 'secret', 'token']);
}
return new S3Client($config);
}
public function getId(): string
{
return 's3';
}
public function createBackup(Backup $backup): void
{
$this->repository->setServer($backup->server)->create($backup);
}
public function deleteBackup(Backup $backup): void
{
$client = $this->createClient($backup->backupHost);
$client->deleteObject([
'Bucket' => $backup->backupHost->configuration['bucket'],
'Key' => "{$backup->server->uuid}/$backup->uuid.tar.gz",
]);
}
public function getDownloadLink(Backup $backup, User $user): string
{
$client = $this->createClient($backup->backupHost);
$request = $client->createPresignedRequest(
$client->getCommand('GetObject', [
'Bucket' => $backup->backupHost->configuration['bucket'],
'Key' => "{$backup->server->uuid}/$backup->uuid.tar.gz",
'ContentType' => 'application/x-gzip',
]),
CarbonImmutable::now()->addMinutes(5)
);
return $request->getUri()->__toString();
}
/** @return Component[] */
public function getConfigurationForm(): array
{
return [
TextInput::make('configuration.region')
->label(trans('admin/setting.backup.s3.default_region'))
->required(),
TextInput::make('configuration.key')
->label(trans('admin/setting.backup.s3.access_key'))
->required(),
TextInput::make('configuration.secret')
->label(trans('admin/setting.backup.s3.secret_key'))
->required(),
TextInput::make('configuration.bucket')
->label(trans('admin/setting.backup.s3.bucket'))
->required(),
TextInput::make('configuration.endpoint')
->label(trans('admin/setting.backup.s3.endpoint'))
->required(),
Toggle::make('configuration.use_path_style_endpoint')
->label(trans('admin/setting.backup.s3.use_path_style_endpoint'))
->inline(false)
->onIcon(TablerIcon::Check)
->offIcon(TablerIcon::X)
->onColor('success')
->offColor('danger')
->live()
->stateCast(new BooleanStateCast(false)),
];
}
/** @return array{parts: string[], part_size: int} */
public function getUploadParts(Backup $backup, int $size): array
{
$expires = CarbonImmutable::now()->addMinutes(config('backups.presigned_url_lifespan', 60));
// Params for generating the presigned urls
$params = [
'Bucket' => $backup->backupHost->configuration['bucket'],
'Key' => "{$backup->server->uuid}/$backup->uuid.tar.gz",
'ContentType' => 'application/x-gzip',
];
$storageClass = $backup->backupHost->configuration['storage_class'];
if (!is_null($storageClass)) {
$params['StorageClass'] = $storageClass;
}
$client = $this->createClient($backup->backupHost);
// Execute the CreateMultipartUpload request
$result = $client->execute($client->getCommand('CreateMultipartUpload', $params));
// Get the UploadId from the CreateMultipartUpload request, this is needed to create
// the other presigned urls.
$params['UploadId'] = $result->get('UploadId');
// Retrieve configured part size
$maxPartSize = config('backups.max_part_size', BackupRemoteUploadController::DEFAULT_MAX_PART_SIZE);
if ($maxPartSize <= 0) {
$maxPartSize = BackupRemoteUploadController::DEFAULT_MAX_PART_SIZE;
}
// Create as many UploadPart presigned urls as needed
$parts = [];
for ($i = 0; $i < ($size / $maxPartSize); $i++) {
$parts[] = $client->createPresignedRequest(
$client->getCommand('UploadPart', array_merge($params, ['PartNumber' => $i + 1])),
$expires
)->getUri()->__toString();
}
// Set the upload_id on the backup in the database.
$backup->update(['upload_id' => $params['UploadId']]);
return [
'parts' => $parts,
'part_size' => $maxPartSize,
];
}
/**
* Marks a multipart upload in a given S3-compatible instance as failed or successful for the given backup.
*
* @param ?array<array{int, etag: string, part_number: string}> $parts
*
* @throws Exception
*/
public function completeMultipartUpload(Backup $backup, bool $successful, ?array $parts): void
{
// This should never really happen, but if it does don't let us fall victim to Amazon's
// wildly fun error messaging. Just stop the process right here.
if (empty($backup->upload_id)) {
// A failed backup doesn't need to error here, this can happen if the backup encounters
// an error before we even start the upload. AWS gives you tooling to clear these failed
// multipart uploads as needed too.
if (!$successful) {
return;
}
throw new Exception('Cannot complete backup request: no upload_id present on model.');
}
$params = [
'Bucket' => $backup->backupHost->configuration['bucket'],
'Key' => "{$backup->server->uuid}/$backup->uuid.tar.gz",
'UploadId' => $backup->upload_id,
];
$client = $this->createClient($backup->backupHost);
if (!$successful) {
$client->execute($client->getCommand('AbortMultipartUpload', $params));
return;
}
// Otherwise send a CompleteMultipartUpload request.
$params['MultipartUpload'] = [
'Parts' => [],
];
if (is_null($parts)) {
$params['MultipartUpload']['Parts'] = $client->execute($client->getCommand('ListParts', $params))['Parts'];
} else {
foreach ($parts as $part) {
$params['MultipartUpload']['Parts'][] = [
'ETag' => $part['etag'],
'PartNumber' => $part['part_number'],
];
}
}
$client->execute($client->getCommand('CompleteMultipartUpload', $params));
}
}

View File

@@ -1,66 +0,0 @@
<?php
namespace App\Extensions\BackupAdapter\Schemas;
use App\Enums\NodeJwtScope;
use App\Models\Backup;
use App\Models\User;
use App\Repositories\Daemon\DaemonBackupRepository;
use App\Services\Nodes\NodeJWTService;
use Carbon\CarbonImmutable;
use Exception;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Components\Component;
use Illuminate\Http\Response;
final class WingsBackupSchema extends BackupAdapterSchema
{
public function __construct(private readonly DaemonBackupRepository $repository, private readonly NodeJWTService $jwtService) {}
public function getId(): string
{
return 'wings';
}
public function createBackup(Backup $backup): void
{
$this->repository->setServer($backup->server)->create($backup);
}
/** @throws Exception */
public function deleteBackup(Backup $backup): void
{
try {
$this->repository->setServer($backup->server)->delete($backup);
} catch (Exception $exception) {
// Don't fail the request if the Daemon responds with a 404, just assume the backup
// doesn't actually exist and remove its reference from the Panel as well.
if ($exception->getCode() !== Response::HTTP_NOT_FOUND) {
throw $exception;
}
}
}
public function getDownloadLink(Backup $backup, User $user): string
{
$token = $this->jwtService
->setExpiresAt(CarbonImmutable::now()->addMinutes(15))
->setScopes(NodeJwtScope::BackupDownload)
->setUser($user)
->setClaims([
'backup_uuid' => $backup->uuid,
'server_uuid' => $backup->server->uuid,
])
->handle($backup->server->node, $user->id . $backup->server->uuid);
return $backup->server->node->getConnectionAddress() . '/download/backup?token=' . $token->toString();
}
/** @return Component[] */
public function getConfigurationForm(): array
{
return [
TextEntry::make(trans('admin/backuphost.no_configuration')),
];
}
}

View File

@@ -0,0 +1,177 @@
<?php
namespace App\Extensions\Backups;
use App\Extensions\Filesystem\S3Filesystem;
use Aws\S3\S3Client;
use Closure;
use Illuminate\Foundation\Application;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use InvalidArgumentException;
use League\Flysystem\FilesystemAdapter;
use League\Flysystem\InMemory\InMemoryFilesystemAdapter;
use Webmozart\Assert\Assert;
class BackupManager
{
/**
* The array of resolved backup drivers.
*
* @var array<string, FilesystemAdapter>
*/
protected array $adapters = [];
/**
* The registered custom driver creators.
*
* @var array<string, callable>
*/
protected array $customCreators;
public function __construct(protected Application $app) {}
/**
* Returns a backup adapter instance.
*/
public function adapter(?string $name = null): FilesystemAdapter
{
return $this->get($name ?: $this->getDefaultAdapter());
}
/**
* Set the given backup adapter instance.
*/
public function set(string $name, FilesystemAdapter $disk): self
{
$this->adapters[$name] = $disk;
return $this;
}
/**
* Gets a backup adapter.
*/
protected function get(string $name): FilesystemAdapter
{
return $this->adapters[$name] = $this->resolve($name);
}
/**
* Resolve the given backup disk.
*/
protected function resolve(string $name): FilesystemAdapter
{
$config = $this->getConfig($name);
if (empty($config['adapter'])) {
throw new InvalidArgumentException("Backup disk [$name] does not have a configured adapter.");
}
$adapter = $config['adapter'];
if (isset($this->customCreators[$name])) {
return $this->callCustomCreator($config);
}
$adapterMethod = 'create' . Str::studly($adapter) . 'Adapter';
if (method_exists($this, $adapterMethod)) {
$instance = $this->{$adapterMethod}($config);
Assert::isInstanceOf($instance, FilesystemAdapter::class);
return $instance;
}
throw new InvalidArgumentException("Adapter [$adapter] is not supported.");
}
/**
* Calls a custom creator for a given adapter type.
*
* @param array{adapter: string} $config
*/
protected function callCustomCreator(array $config): mixed
{
return $this->customCreators[$config['adapter']]($this->app, $config);
}
/**
* Creates a new daemon adapter.
*
* @param array<string, string> $config
*/
public function createWingsAdapter(array $config): FilesystemAdapter
{
return new InMemoryFilesystemAdapter();
}
/**
* Creates a new S3 adapter.
*
* @param array<string, string> $config
*/
public function createS3Adapter(array $config): FilesystemAdapter
{
$config['version'] = 'latest';
if (!empty($config['key']) && !empty($config['secret'])) {
$config['credentials'] = Arr::only($config, ['key', 'secret', 'token']);
}
$client = new S3Client($config);
return new S3Filesystem($client, $config['bucket'], $config['prefix'] ?? '', $config['options'] ?? []);
}
/**
* Returns the configuration associated with a given backup type.
*
* @return array<mixed>
*/
protected function getConfig(string $name): array
{
return config("backups.disks.$name") ?: [];
}
/**
* Get the default backup driver name.
*/
public function getDefaultAdapter(): string
{
return config('backups.default');
}
/**
* Set the default session driver name.
*/
public function setDefaultAdapter(string $name): void
{
config()->set('backups.default', $name);
}
/**
* Unset the given adapter instances.
*
* @param string|string[] $adapter
*/
public function forget(array|string $adapter): self
{
$adapters = &$this->adapters;
foreach ((array) $adapter as $adapterName) {
unset($adapters[$adapterName]);
}
return $this;
}
/**
* Register a custom adapter creator closure.
*/
public function extend(string $adapter, Closure $callback): self
{
$this->customCreators[$adapter] = $callback;
return $this;
}
}

View File

@@ -21,7 +21,7 @@ class TurnstileSchema extends BaseSchema implements CaptchaSchemaInterface
public function isEnabled(): bool
{
return (bool) env('CAPTCHA_TURNSTILE_ENABLED', false);
return env('CAPTCHA_TURNSTILE_ENABLED', false);
}
public function getFormComponent(): Component
@@ -75,7 +75,9 @@ class TurnstileSchema extends BaseSchema implements CaptchaSchemaInterface
{
$captchaResponse ??= request()->get('cf-turnstile-response');
throw_unless($secret = env('CAPTCHA_TURNSTILE_SECRET_KEY'), new Exception('Turnstile secret key is not defined.'));
if (!$secret = env('CAPTCHA_TURNSTILE_SECRET_KEY')) {
throw new Exception('Turnstile secret key is not defined.');
}
$response = Http::asJson()
->timeout(15)
@@ -99,7 +101,9 @@ class TurnstileSchema extends BaseSchema implements CaptchaSchemaInterface
};
}
throw_unless($this->verifyDomain($response['hostname'] ?? ''), new Exception('Domain verification failed.'));
if (!$this->verifyDomain($response['hostname'] ?? '')) {
throw new Exception('Domain verification failed.');
}
}
private function verifyDomain(string $hostname): bool

View File

@@ -52,13 +52,13 @@ class GSLTokenSchema implements FeatureSchemaInterface
return Action::make($this->getId())
->requiresConfirmation()
->modalHeading(trans('server/feature.gsl_token.heading'))
->modalDescription(trans('server/feature.gsl_token.description'))
->modalSubmitActionLabel(trans('server/feature.gsl_token.submit'))
->modalHeading('Invalid GSL token')
->modalDescription('It seems like your Gameserver Login Token (GSL token) is invalid or has expired.')
->modalSubmitActionLabel('Update GSL Token')
->disabledSchema(fn () => !user()?->can(SubuserPermission::StartupUpdate, $server))
->schema([
TextEntry::make('info')
->label(new HtmlString(Blade::render(trans('server/feature.gsl_token.info')))),
->label(new HtmlString(Blade::render('You can either <x-filament::link href="https://steamcommunity.com/dev/managegameservers" target="_blank">generate a new one</x-filament::link> and enter it below or leave the field blank to remove it completely.'))),
TextInput::make('gsltoken')
->label('GSL Token')
->rules([
@@ -104,13 +104,13 @@ class GSLTokenSchema implements FeatureSchemaInterface
$serverRepository->setServer($server)->power('restart');
Notification::make()
->title(trans('server/feature.gsl_token.updated'))
->body(trans('server/feature.restart_now'))
->title('GSL Token updated')
->body('Server will restart now.')
->success()
->send();
} catch (Exception $exception) {
Notification::make()
->title(trans('server/feature.gsl_token.failed'))
->title('Could not update GSL Token')
->body($exception->getMessage())
->danger()
->send();

View File

@@ -41,15 +41,15 @@ class JavaVersionSchema implements FeatureSchemaInterface
return Action::make($this->getId())
->requiresConfirmation()
->modalHeading(trans('server/feature.java_version.heading'))
->modalDescription(trans('server/feature.java_version.description'))
->modalSubmitActionLabel(trans('server/feature.java_version.submit'))
->modalHeading('Unsupported Java Version')
->modalDescription('This server is currently running an unsupported version of Java and cannot be started.')
->modalSubmitActionLabel('Update Docker Image')
->disabledSchema(fn () => !user()?->can(SubuserPermission::StartupDockerImage, $server))
->schema([
TextEntry::make('java')
->label(trans('server/feature.java_version.select_version')),
->label('Please select a supported version from the list below to continue starting the server.'),
Select::make('image')
->label(trans('server/feature.java_version.docker_image'))
->label('Docker Image')
->disabled(fn () => !in_array($server->image, $server->egg->docker_images))
->options(fn () => collect($server->egg->docker_images)->mapWithKeys(fn ($key, $value) => [$key => $value]))
->selectablePlaceholder(false)
@@ -73,13 +73,13 @@ class JavaVersionSchema implements FeatureSchemaInterface
$serverRepository->setServer($server)->power('restart');
Notification::make()
->title(trans('server/feature.java_version.updated'))
->body(trans('server/feature.restart_now'))
->title('Docker image updated')
->body('Server will restart now.')
->success()
->send();
} catch (Exception $exception) {
Notification::make()
->title(trans('server/feature.java_version.failed'))
->title('Could not update docker image')
->body($exception->getMessage())
->danger()
->send();

View File

@@ -32,9 +32,9 @@ class MinecraftEulaSchema implements FeatureSchemaInterface
{
return Action::make($this->getId())
->requiresConfirmation()
->modalHeading(trans('server/feature.eula.heading'))
->modalDescription(new HtmlString(Blade::render(trans('server/feature.eula.description'))))
->modalSubmitActionLabel(trans('server/feature.eula.accept'))
->modalHeading('Minecraft EULA')
->modalDescription(new HtmlString(Blade::render('By pressing "I Accept" below you are indicating your agreement to the <x-filament::link href="https://minecraft.net/eula" target="_blank">Minecraft EULA </x-filament::link>.')))
->modalSubmitActionLabel('I Accept')
->action(function (DaemonFileRepository $fileRepository, DaemonServerRepository $serverRepository) {
try {
/** @var Server $server */
@@ -45,13 +45,13 @@ class MinecraftEulaSchema implements FeatureSchemaInterface
$serverRepository->setServer($server)->power('restart');
Notification::make()
->title(trans('server/feature.eula.accepted'))
->body(trans('server/feature.restart_now'))
->title('Minecraft EULA accepted')
->body('Server will restart now.')
->success()
->send();
} catch (Exception $exception) {
Notification::make()
->title(trans('server/feature.eula.failed'))
->title('Could not accept Minecraft EULA')
->body($exception->getMessage())
->danger()
->send();

View File

@@ -5,6 +5,7 @@ namespace App\Extensions\Features\Schemas;
use App\Enums\TablerIcon;
use App\Extensions\Features\FeatureSchemaInterface;
use Filament\Actions\Action;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\HtmlString;
class PIDLimitSchema implements FeatureSchemaInterface
@@ -32,9 +33,35 @@ class PIDLimitSchema implements FeatureSchemaInterface
return Action::make($this->getId())
->requiresConfirmation()
->icon(TablerIcon::AlertTriangle)
->modalHeading(fn () => user()?->isAdmin() ? trans('server/feature.pid_limit.heading_admin') : trans('server/feature.pid_limit.heading_user'))
->modalDescription(new HtmlString(user()?->isAdmin() ? trans('server/feature.pid_limit.description_admin') : trans('server/feature.pid_limit.description_user')))
->modalCancelActionLabel(trans('server/feature.close'))
->modalHeading(fn () => user()?->isAdmin() ? 'Memory or process limit reached...' : 'Possible resource limit reached...')
->modalDescription(new HtmlString(Blade::render(
user()?->isAdmin() ? <<<'HTML'
<p>
This server has reached the maximum process or memory limit.
</p>
<p class="mt-4">
Increasing <code>container_pid_limit</code> in the wings
configuration, <code>config.yml</code>, might help resolve
this issue.
</p>
<p class="mt-4">
<b>Note: Wings must be restarted for the configuration file changes to take effect</b>
</p>
HTML
:
<<<'HTML'
<p>
This server is attempting to use more resources than allocated. Please contact the administrator
and give them the error below.
</p>
<p class="mt-4">
<code>
pthread_create failed, Possibly out of memory or process/resource limits reached
</code>
</p>
HTML
)))
->modalCancelActionLabel('Close')
->action(fn () => null);
}
}

View File

@@ -4,6 +4,7 @@ namespace App\Extensions\Features\Schemas;
use App\Extensions\Features\FeatureSchemaInterface;
use Filament\Actions\Action;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\HtmlString;
class SteamDiskSpaceSchema implements FeatureSchemaInterface
@@ -26,9 +27,28 @@ class SteamDiskSpaceSchema implements FeatureSchemaInterface
{
return Action::make($this->getId())
->requiresConfirmation()
->modalHeading(trans('server/feature.steam_disk_space.heading'))
->modalDescription(new HtmlString(user()?->isAdmin() ? trans('server/feature.steam_disk_space.description_admin') : trans('server/feature.steam_disk_space.description_user')))
->modalCancelActionLabel(trans('server/feature.close'))
->modalHeading('Out of available disk space...')
->modalDescription(new HtmlString(Blade::render(
user()?->isAdmin() ? <<<'HTML'
<p>
This server has run out of available disk space and cannot complete the install or update
process.
</p>
<p class="mt-4">
Ensure the machine has enough disk space by typing{' '}
<code class="rounded py-1 px-2">df -h</code> on the machine hosting
this server. Delete files or increase the available disk space to resolve the issue.
</p>
HTML
:
<<<'HTML'
<p>
This server has run out of available disk space and cannot complete the install or update
process. Please get in touch with the administrator(s) and inform them of disk space issues.
</p>
HTML
)))
->modalCancelActionLabel('Close')
->action(fn () => null);
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Extensions\Filesystem;
use Aws\S3\S3ClientInterface;
use League\Flysystem\AwsS3V3\AwsS3V3Adapter;
class S3Filesystem extends AwsS3V3Adapter
{
/**
* @param array<mixed> $options
*/
public function __construct(
private S3ClientInterface $client,
private string $bucket,
string $prefix = '',
array $options = [],
) {
parent::__construct(
$client,
$bucket,
$prefix,
null,
null,
$options,
);
}
public function getClient(): S3ClientInterface
{
return $this->client;
}
public function getBucket(): string
{
return $this->bucket;
}
}

View File

@@ -73,11 +73,11 @@ final class AuthentikSchema extends OAuthSchema
public function getName(): string
{
return (string) env('OAUTH_AUTHENTIK_DISPLAY_NAME', 'Authentik');
return env('OAUTH_AUTHENTIK_DISPLAY_NAME', 'Authentik');
}
public function getHexColor(): string
{
return (string) env('OAUTH_AUTHENTIK_DISPLAY_COLOR', '#fd4b2d');
return env('OAUTH_AUTHENTIK_DISPLAY_COLOR', '#fd4b2d');
}
}

View File

@@ -120,20 +120,20 @@ abstract class OAuthSchema implements OAuthSchemaInterface
public function isEnabled(): bool
{
return (bool) env($this->getConfigKey(), false);
return env($this->getConfigKey(), false);
}
public function shouldCreateMissingUser(OAuthUser $user): bool
{
$id = Str::upper($this->getId());
return (bool) env("OAUTH_{$id}_SHOULD_CREATE_MISSING_USERS", false);
return env("OAUTH_{$id}_SHOULD_CREATE_MISSING_USERS", false);
}
public function shouldLinkMissingUser(User $user, OAuthUser $oauthUser): bool
{
$id = Str::upper($this->getId());
return (bool) env("OAUTH_{$id}_SHOULD_LINK_MISSING_USERS", false);
return env("OAUTH_{$id}_SHOULD_LINK_MISSING_USERS", false);
}
}

View File

@@ -44,17 +44,4 @@ class Fractal extends SpatieFractal
return parent::createData();
}
/**
* Perform the transformation to array.
*
* @return array<mixed>
*
* @throws InvalidTransformation
* @throws NoTransformerSpecified
*/
public function toArray(): array
{
return parent::toArray() ?? [];
}
}

View File

@@ -8,7 +8,7 @@ use App\Services\Backups\InitiateBackupService;
final class CreateBackupSchema extends TaskSchema
{
public function __construct(private InitiateBackupService $initiateService) {}
public function __construct(private InitiateBackupService $backupService) {}
public function getId(): string
{
@@ -17,7 +17,7 @@ final class CreateBackupSchema extends TaskSchema
public function runTask(Task $task): void
{
$this->initiateService->setIgnoredFiles(explode(PHP_EOL, $task->payload))->handle($task->server, null, true);
$this->backupService->setIgnoredFiles(explode(PHP_EOL, $task->payload))->handle($task->server, null, true);
}
public function canCreate(Schedule $schedule): bool

View File

@@ -4,10 +4,10 @@ namespace App\Filament\Admin\Pages;
use App\Enums\TablerIcon;
use BackedEnum;
use Carbon\Carbon;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Artisan;
use Spatie\Health\Commands\RunHealthChecksCommand;
use Spatie\Health\Enums\Status;
@@ -41,7 +41,7 @@ class Health extends Page
public static function canAccess(): bool
{
return user()?->can('view health') ?? false;
return user()?->can('view health');
}
protected function getActions(): array

View File

@@ -6,22 +6,16 @@ use App\Enums\TablerIcon;
use Boquizo\FilamentLogViewer\Actions\DeleteAction;
use Boquizo\FilamentLogViewer\Actions\DownloadAction;
use Boquizo\FilamentLogViewer\Actions\ViewLogAction;
use Boquizo\FilamentLogViewer\FilamentLogViewerPlugin;
use Boquizo\FilamentLogViewer\Pages\ListLogs as BaseListLogs;
use Boquizo\FilamentLogViewer\Tables\Columns\LevelColumn;
use Boquizo\FilamentLogViewer\Tables\Columns\NameColumn;
use Boquizo\FilamentLogViewer\UseCases\ParseDateUseCase;
use Boquizo\FilamentLogViewer\Utils\Level;
use Filament\Actions\Action;
use Filament\Actions\BulkAction;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Notifications\Notification;
use Filament\Tables\Table;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
class ListLogs extends BaseListLogs
{
@@ -37,12 +31,6 @@ class ListLogs extends BaseListLogs
return parent::table($table)
->emptyStateHeading(trans('admin/log.empty_table'))
->emptyStateIcon(TablerIcon::Check)
->toolbarActions([
BulkActionGroup::make([
self::exclude_downloadBulkAction(),
self::exclude_deleteBulkAction(),
]),
])
->columns([
NameColumn::make('date'),
LevelColumn::make(Level::ALL)
@@ -62,9 +50,7 @@ class ListLogs extends BaseListLogs
ViewLogAction::make()
->icon(TablerIcon::FileDescription)->iconButton(),
DownloadAction::make()
->tooltip(function (array $record): string {
return trans('filament-log-viewer::log.table.actions.download.label', ['log' => ParseDateUseCase::execute((string) ($record['date'] ?? ''))]);
})
->tooltip(fn ($record) => trans('filament-log-viewer::log.table.actions.download.label', ['log' => ParseDateUseCase::execute($record['date'])]))
->icon(TablerIcon::FileDownload)->iconButton(),
Action::make('uploadLogs')
->hiddenLabel()
@@ -73,15 +59,15 @@ class ListLogs extends BaseListLogs
->requiresConfirmation()
->modalHeading(trans('admin/log.actions.upload_logs'))
->modalDescription(fn ($record) => trans('admin/log.actions.upload_logs_description', ['file' => $record['date'], 'url' => 'https://logs.pelican.dev']))
->action(function (array $record): void {
->action(function ($record) {
$prefix = config('filament-log-viewer.pattern.prefix', 'laravel-');
$extension = config('filament-log-viewer.pattern.extension', '.log');
$logPath = storage_path('logs/' . $prefix . (string) ($record['date'] ?? '') . $extension);
$logPath = storage_path('logs/' . $prefix . $record['date'] . $extension);
if (!file_exists($logPath)) {
Notification::make()
->title(trans('admin/log.actions.log_not_found'))
->body(trans('admin/log.actions.log_not_found_description', ['filename' => (string) ($record['date'] ?? '')]))
->body(trans('admin/log.actions.log_not_found_description', ['filename' => $record['date']]))
->danger()
->send();
@@ -89,9 +75,9 @@ class ListLogs extends BaseListLogs
}
$lines = file($logPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
$totalLines = is_array($lines) ? count($lines) : 0;
$uploadLines = $totalLines <= 1000 ? ($lines ?: []) : array_slice($lines ?: [], -1000);
$content = implode("\n", (array) $uploadLines);
$totalLines = count($lines);
$uploadLines = $totalLines <= 1000 ? $lines : array_slice($lines, -1000);
$content = implode("\n", $uploadLines);
try {
$response = Http::timeout(10)
@@ -111,7 +97,7 @@ class ListLogs extends BaseListLogs
}
$data = $response->json();
$url = (string) ($data['url'] ?? '');
$url = $data['url'];
Notification::make()
->title(trans('admin/log.actions.log_upload'))
@@ -140,50 +126,4 @@ class ListLogs extends BaseListLogs
->icon(TablerIcon::Trash)->iconButton(),
]);
}
private static function exclude_downloadBulkAction(): BulkAction
{
return BulkAction::make('exclude_download')
->label(trans('filament-log-viewer::log.table.actions.download.bulk.label'))
->icon(TablerIcon::FileZip)
->color('success')
->requiresConfirmation()
->modalHeading(trans('filament-log-viewer::log.table.actions.download.bulk.label'))
->failureNotificationTitle(trans('filament-log-viewer::log.table.actions.download.bulk.error'))
->action(self::exclude_downloadBulkAction_getAction(...));
}
/**
* @param Collection<array-key, array<string, mixed>> $records
*/
private static function exclude_downloadBulkAction_getAction(
BulkAction $action,
Collection $records,
): ?BinaryFileResponse {
try {
$logs = $records->pluck('date')->all();
return FilamentLogViewerPlugin::make()->downloadLogs($logs);
} catch (\Exception) {
$action->failure();
return null;
}
}
private static function exclude_deleteBulkAction(): DeleteBulkAction
{
return DeleteBulkAction::make('exclude_delete')
->modalHeading(trans('filament-log-viewer::log.table.actions.delete.bulk.label'))
->action(function (DeleteBulkAction $action) {
$action->process(function (Collection $records): void {
/** @var array<string, mixed> $record */
foreach ($records as $record) {
FilamentLogViewerPlugin::make()->deleteLog((string) ($record['date'] ?? ''));
}
});
$action->success();
});
}
}

View File

@@ -6,6 +6,7 @@ use App\Enums\TablerIcon;
use App\Extensions\Avatar\AvatarService;
use App\Extensions\Captcha\CaptchaService;
use App\Extensions\OAuth\OAuthService;
use App\Models\Backup;
use App\Notifications\MailTested;
use App\Traits\EnvironmentWriterTrait;
use App\Traits\Filament\CanCustomizeHeaderActions;
@@ -91,7 +92,7 @@ class Settings extends Page implements HasSchemas
public static function canAccess(): bool
{
return user()?->can('view settings') ?? false;
return user()?->can('view settings');
}
public function getTitle(): string
@@ -491,6 +492,16 @@ class Settings extends Page implements HasSchemas
private function backupSettings(): array
{
return [
ToggleButtons::make('APP_BACKUP_DRIVER')
->label(trans('admin/setting.backup.backup_driver'))
->columnSpanFull()
->inline()
->options([
Backup::ADAPTER_DAEMON => 'Wings',
Backup::ADAPTER_AWS_S3 => 'S3',
])
->live()
->default(env('APP_BACKUP_DRIVER', config('backups.default'))),
Section::make(trans('admin/setting.backup.throttle'))
->description(trans('admin/setting.backup.throttle_help'))
->columns()
@@ -510,6 +521,41 @@ class Settings extends Page implements HasSchemas
->suffix('Seconds')
->default(config('backups.throttles.period')),
]),
Section::make(trans('admin/setting.backup.s3.s3_title'))
->columns()
->visible(fn (Get $get) => $get('APP_BACKUP_DRIVER') === Backup::ADAPTER_AWS_S3)
->schema([
TextInput::make('AWS_DEFAULT_REGION')
->label(trans('admin/setting.backup.s3.default_region'))
->required()
->default(config('backups.disks.s3.region')),
TextInput::make('AWS_ACCESS_KEY_ID')
->label(trans('admin/setting.backup.s3.access_key'))
->required()
->default(config('backups.disks.s3.key')),
TextInput::make('AWS_SECRET_ACCESS_KEY')
->label(trans('admin/setting.backup.s3.secret_key'))
->required()
->default(config('backups.disks.s3.secret')),
TextInput::make('AWS_BACKUPS_BUCKET')
->label(trans('admin/setting.backup.s3.bucket'))
->required()
->default(config('backups.disks.s3.bucket')),
TextInput::make('AWS_ENDPOINT')
->label(trans('admin/setting.backup.s3.endpoint'))
->required()
->default(config('backups.disks.s3.endpoint')),
Toggle::make('AWS_USE_PATH_STYLE_ENDPOINT')
->label(trans('admin/setting.backup.s3.use_path_style_endpoint'))
->inline(false)
->onIcon(TablerIcon::Check)
->offIcon(TablerIcon::X)
->onColor('success')
->offColor('danger')
->live()
->stateCast(new BooleanStateCast(false))
->default(env('AWS_USE_PATH_STYLE_ENDPOINT', config('backups.disks.s3.use_path_style_endpoint'))),
]),
];
}

View File

@@ -1,162 +0,0 @@
<?php
namespace App\Filament\Admin\Resources\BackupHosts;
use App\Enums\TablerIcon;
use App\Extensions\BackupAdapter\BackupAdapterService;
use App\Filament\Admin\Resources\BackupHosts\Pages\CreateBackupHost;
use App\Filament\Admin\Resources\BackupHosts\Pages\EditBackupHost;
use App\Filament\Admin\Resources\BackupHosts\Pages\ListBackupHosts;
use App\Filament\Admin\Resources\BackupHosts\Pages\ViewBackupHost;
use App\Filament\Admin\Resources\BackupHosts\RelationManagers\BackupsRelationManager;
use App\Models\BackupHost;
use App\Traits\Filament\CanCustomizePages;
use App\Traits\Filament\CanCustomizeRelations;
use App\Traits\Filament\CanModifyForm;
use App\Traits\Filament\CanModifyTable;
use BackedEnum;
use Exception;
use Filament\Actions\CreateAction;
use Filament\Actions\EditAction;
use Filament\Actions\ViewAction;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Resources\Pages\PageRegistration;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class BackupHostResource extends Resource
{
use CanCustomizePages;
use CanCustomizeRelations;
use CanModifyForm;
use CanModifyTable;
protected static ?string $model = BackupHost::class;
protected static string|BackedEnum|null $navigationIcon = TablerIcon::FileZip;
protected static ?string $recordTitleAttribute = 'name';
public static function getNavigationBadge(): ?string
{
return (string) static::getEloquentQuery()->count() ?: null;
}
public static function getNavigationLabel(): string
{
return static::getPluralModelLabel();
}
public static function getModelLabel(): string
{
return trans_choice('admin/backuphost.model_label', 1);
}
public static function getPluralModelLabel(): string
{
return trans_choice('admin/backuphost.model_label', 2);
}
public static function getNavigationGroup(): ?string
{
return trans('admin/dashboard.advanced');
}
/** @throws Exception */
public static function defaultTable(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->label(trans('admin/backuphost.name')),
TextColumn::make('schema')
->label(trans('admin/backuphost.schema'))
->badge(),
TextColumn::make('backups_count')
->counts('backups')
->label(trans('admin/backuphost.backups')),
TextColumn::make('nodes.name')
->badge()
->placeholder(trans('admin/backuphost.all_nodes')),
])
->recordActions([
ViewAction::make()
->hidden(fn ($record) => static::getEditAuthorizationResponse($record)->allowed()),
EditAction::make(),
])
->toolbarActions([
CreateAction::make(),
])
->emptyStateIcon(TablerIcon::FileZip)
->emptyStateDescription(trans('admin/backuphost.local_backups_only'))
->emptyStateHeading(trans('admin/backuphost.no_backup_hosts'));
}
/** @throws Exception */
public static function defaultForm(Schema $schema): Schema
{
return $schema
->components([
TextInput::make('name')
->label(trans('admin/backuphost.name'))
->required(),
Select::make('schema')
->label(trans('admin/backuphost.schema'))
->required()
->selectablePlaceholder(false)
->searchable()
->options(fn (BackupAdapterService $service) => $service->getMappings())
->live(onBlur: true),
Select::make('node_ids')
->label(trans('admin/backuphost.linked_nodes'))
->multiple()
->searchable()
->preload()
->relationship('nodes', 'name', fn (Builder $query) => $query->whereIn('nodes.id', user()?->accessibleNodes()->pluck('id'))),
Section::make(trans('admin/backuphost.configuration'))
->columnSpanFull()
->columns()
->schema(function (?BackupHost $backupHost, Get $get, BackupAdapterService $service) {
$schema = $get('schema') ?? $backupHost?->schema;
if (!$schema) {
return [];
}
$schema = $service->get($schema);
if ($schema) {
return $schema->getConfigurationForm();
}
return [];
}),
]);
}
/** @return class-string<RelationManager>[] */
public static function getDefaultRelations(): array
{
return [
BackupsRelationManager::class,
];
}
/** @return array<string, PageRegistration> */
public static function getDefaultPages(): array
{
return [
'index' => ListBackupHosts::route('/'),
'create' => CreateBackupHost::route('/create'),
'view' => ViewBackupHost::route('/{record}'),
'edit' => EditBackupHost::route('/{record}/edit'),
];
}
}

View File

@@ -1,18 +0,0 @@
<?php
namespace App\Filament\Admin\Resources\BackupHosts\Pages;
use App\Filament\Admin\Resources\BackupHosts\BackupHostResource;
use App\Traits\Filament\CanCustomizeHeaderActions;
use App\Traits\Filament\CanCustomizeHeaderWidgets;
use Filament\Resources\Pages\CreateRecord;
class CreateBackupHost extends CreateRecord
{
use CanCustomizeHeaderActions;
use CanCustomizeHeaderWidgets;
protected static string $resource = BackupHostResource::class;
protected static bool $canCreateAnother = false;
}

View File

@@ -1,43 +0,0 @@
<?php
namespace App\Filament\Admin\Resources\BackupHosts\Pages;
use App\Enums\TablerIcon;
use App\Filament\Admin\Resources\BackupHosts\BackupHostResource;
use App\Models\BackupHost;
use App\Traits\Filament\CanCustomizeHeaderActions;
use App\Traits\Filament\CanCustomizeHeaderWidgets;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
class EditBackupHost extends EditRecord
{
use CanCustomizeHeaderActions;
use CanCustomizeHeaderWidgets;
protected static string $resource = BackupHostResource::class;
/** @return array<Action|ActionGroup> */
protected function getDefaultHeaderActions(): array
{
return [
DeleteAction::make()
->label(fn (BackupHost $backupHost) => $backupHost->backups()->count() > 0 ? trans('admin/backuphost.delete_help') : trans('filament-actions::delete.single.modal.actions.delete.label'))
->disabled(fn (BackupHost $backupHost) => $backupHost->backups()->count() > 0)
->hidden(fn () => BackupHost::count() === 1),
Action::make('save')
->hiddenLabel()
->action('save')
->keyBindings(['mod+s'])
->tooltip(trans('filament-panels::resources/pages/edit-record.form.actions.save.label'))
->icon(TablerIcon::DeviceFloppy),
];
}
protected function getFormActions(): array
{
return [];
}
}

View File

@@ -1,16 +0,0 @@
<?php
namespace App\Filament\Admin\Resources\BackupHosts\Pages;
use App\Filament\Admin\Resources\BackupHosts\BackupHostResource;
use App\Traits\Filament\CanCustomizeHeaderActions;
use App\Traits\Filament\CanCustomizeHeaderWidgets;
use Filament\Resources\Pages\ListRecords;
class ListBackupHosts extends ListRecords
{
use CanCustomizeHeaderActions;
use CanCustomizeHeaderWidgets;
protected static string $resource = BackupHostResource::class;
}

View File

@@ -1,27 +0,0 @@
<?php
namespace App\Filament\Admin\Resources\BackupHosts\Pages;
use App\Filament\Admin\Resources\BackupHosts\BackupHostResource;
use App\Traits\Filament\CanCustomizeHeaderActions;
use App\Traits\Filament\CanCustomizeHeaderWidgets;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Actions\EditAction;
use Filament\Resources\Pages\ViewRecord;
class ViewBackupHost extends ViewRecord
{
use CanCustomizeHeaderActions;
use CanCustomizeHeaderWidgets;
protected static string $resource = BackupHostResource::class;
/** @return array<Action|ActionGroup> */
protected function getDefaultHeaderActions(): array
{
return [
EditAction::make(),
];
}
}

View File

@@ -1,42 +0,0 @@
<?php
namespace App\Filament\Admin\Resources\BackupHosts\RelationManagers;
use App\Enums\TablerIcon;
use App\Filament\Components\Tables\Columns\BytesColumn;
use App\Filament\Components\Tables\Columns\DateTimeColumn;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class BackupsRelationManager extends RelationManager
{
protected static string $relationship = 'backups';
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('name')
->heading(null)
->columns([
TextColumn::make('name')
->label(trans('server/backup.actions.create.name'))
->searchable(),
BytesColumn::make('bytes')
->label(trans('server/backup.size')),
DateTimeColumn::make('created_at')
->label(trans('server/backup.created_at'))
->since()
->sortable(),
TextColumn::make('status')
->label(trans('server/backup.status'))
->badge(),
IconColumn::make('is_locked')
->label(trans('server/backup.is_locked'))
->visibleFrom('md')
->trueIcon(TablerIcon::Lock)
->falseIcon(TablerIcon::LockOpen),
]);
}
}

View File

@@ -89,7 +89,6 @@ class DatabaseHostResource extends Resource
->counts('databases')
->label(trans('admin/databasehost.databases')),
TextColumn::make('nodes.name')
->label(trans('admin/databasehost.nodes'))
->badge()
->placeholder(trans('admin/databasehost.no_nodes')),
])

View File

@@ -66,7 +66,6 @@ class DatabasesRelationManager extends RelationManager
DateTimeColumn::make('created_at')
->label(trans('admin/databasehost.table.created_at')),
])
->emptyStateHeading(trans('admin/databasehost.no_databases'))
->recordActions([
ViewAction::make()
->color('primary'),

View File

@@ -34,11 +34,7 @@ class EggResource extends Resource
public static function getNavigationGroup(): ?string
{
if (user()?->getCustomization(CustomizationKey::TopNavigation)) {
return null;
}
return trans('admin/dashboard.server');
return user()?->getCustomization(CustomizationKey::TopNavigation) ? false : trans('admin/dashboard.server');
}
public static function getNavigationLabel(): string

View File

@@ -46,11 +46,7 @@ class NodeResource extends Resource
public static function getNavigationGroup(): ?string
{
if (user()?->getCustomization(CustomizationKey::TopNavigation)) {
return null;
}
return trans('admin/dashboard.server');
return user()?->getCustomization(CustomizationKey::TopNavigation) ? false : trans('admin/dashboard.server');
}
public static function getNavigationBadge(): ?string

View File

@@ -665,7 +665,6 @@ class EditNode extends EditRecord
->disabled(fn (Get $get) => $get('pulled'))
->headerActions([
Action::make('pull')
->label(trans('admin/node.diagnostics.pull'))
->tooltip(trans('admin/node.diagnostics.pull'))
->icon(TablerIcon::CloudDownload)
->hidden(fn (Get $get) => $get('pulled'))
@@ -765,20 +764,17 @@ class EditNode extends EditRecord
])
->schema([
ToggleButtons::make('include_endpoints')
->label(trans('admin/node.diagnostics.include_endpoints'))
->hintIcon(TablerIcon::QuestionMark)->inline()
->hintIconTooltip(trans('admin/node.diagnostics.include_endpoints_hint'))
->formatStateUsing(fn () => 1)
->boolean(),
ToggleButtons::make('include_logs')
->label(trans('admin/node.diagnostics.include_logs'))
->live()
->hintIcon(TablerIcon::QuestionMark)->inline()
->hintIconTooltip(trans('admin/node.diagnostics.include_logs_hint'))
->formatStateUsing(fn () => 1)
->boolean(),
Slider::make('log_lines')
->label(trans('admin/node.diagnostics.log_lines'))
->columnSpan(2)
->hiddenLabel()
->live()

View File

@@ -83,12 +83,10 @@ class AllocationsRelationManager extends RelationManager
->searchable()
->label(trans('admin/node.table.ip')),
])
->emptyStateHeading(trans('admin/node.no_allocations'))
->toolbarActions([
DeleteBulkAction::make()
->authorize(fn () => user()?->can('update', $this->getOwnerRecord())),
Action::make('create new allocation')
->label(trans('admin/node.create_allocation'))
->tooltip(trans('admin/node.create_allocation'))
->icon(TablerIcon::WorldPlus)
->schema(fn () => [

View File

@@ -67,7 +67,6 @@ class ServersRelationManager extends RelationManager
->label(trans('admin/node.backups'))
->numeric()
->sortable(),
])
->emptyStateHeading(trans('admin/server.no_servers'));
]);
}
}

View File

@@ -124,7 +124,7 @@ class PluginResource extends Resource
->hidden(fn (Plugin $plugin) => $plugin->status !== PluginStatus::NotInstalled)
->action(function (Plugin $plugin) {
try {
InstallPlugin::dispatch(user(), $plugin->id);
InstallPlugin::dispatch(user(), $plugin);
Notification::make()
->success()
@@ -147,7 +147,7 @@ class PluginResource extends Resource
->visible(fn (Plugin $plugin) => $plugin->status !== PluginStatus::NotInstalled && $plugin->isUpdateAvailable())
->action(function (Plugin $plugin) {
try {
UpdatePlugin::dispatch(user(), $plugin->id);
UpdatePlugin::dispatch(user(), $plugin);
Notification::make()
->success()
@@ -223,7 +223,7 @@ class PluginResource extends Resource
->hidden(fn (Plugin $plugin) => $plugin->status === PluginStatus::NotInstalled || $plugin->status === PluginStatus::Errored)
->action(function (Plugin $plugin) {
try {
UninstallPlugin::dispatch(user(), $plugin->id);
UninstallPlugin::dispatch(user(), $plugin);
Notification::make()
->success()
@@ -242,8 +242,6 @@ class PluginResource extends Resource
])
->headerActions([
Action::make('import_from_file')
->label(trans('admin/plugin.import_from_file'))
->modalHeading(trans('admin/plugin.import_from_file'))
->hiddenLabel()
->tooltip(trans('admin/plugin.import_from_file'))
->authorize(fn () => user()?->can('create', Plugin::class))
@@ -251,7 +249,6 @@ class PluginResource extends Resource
->schema([
// TODO: switch to new file upload
FileUpload::make('file')
->label(trans('admin/plugin.file'))
->required()
->acceptedFileTypes(['application/zip', 'application/zip-compressed', 'application/x-zip-compressed'])
->preserveFilenames()
@@ -265,7 +262,9 @@ class PluginResource extends Resource
$pluginName = str($file->getClientOriginalName())->basename()->before('.zip')->toString();
throw_if(Plugin::where('id', $pluginName)->exists(), new Exception(trans('admin/plugin.notifications.import_exists')));
if (Plugin::where('id', $pluginName)->exists()) {
throw new Exception(trans('admin/plugin.notifications.import_exists'));
}
$pluginService->downloadPluginFromFile($file);
@@ -286,8 +285,6 @@ class PluginResource extends Resource
}
}),
Action::make('import_from_url')
->label(trans('admin/plugin.import_from_url'))
->modalHeading(trans('admin/plugin.import_from_url'))
->hiddenLabel()
->tooltip(trans('admin/plugin.import_from_url'))
->authorize(fn () => user()?->can('create', Plugin::class))
@@ -302,7 +299,9 @@ class PluginResource extends Resource
try {
$pluginName = str($data['url'])->before('.zip')->explode('/')->last();
throw_if(Plugin::where('id', $pluginName)->exists(), new Exception(trans('admin/plugin.notifications.import_exists')));
if (Plugin::where('id', $pluginName)->exists()) {
throw new Exception(trans('admin/plugin.notifications.import_exists'));
}
$pluginService->downloadPluginFromUrl($data['url']);

View File

@@ -123,9 +123,7 @@ class RoleResource extends Resource
$options = [];
foreach ($permissions as $permission) {
$options[$permission . ' ' . $model] = trans()->has("admin/role.permissions_list.$permission")
? trans("admin/role.permissions_list.$permission")
: Str::headline($permission);
$options[$permission . ' ' . $model] = Str::headline($permission);
}
$permissionSections[] = self::makeSection($model, $options);
@@ -168,9 +166,7 @@ class RoleResource extends Resource
*/
private static function makeSection(string $model, array $options): Section
{
return Section::make(trans()->has("admin/role.models.$model")
? trans("admin/role.models.$model")
: Str::headline($model))
return Section::make(Str::headline($model))
->columnSpan(1)
->collapsible()
->collapsed()

View File

@@ -305,7 +305,7 @@ class CreateServer extends CreateRecord
->createOptionUsing(function (array $data, Get $get, AssignmentService $assignmentService): int {
return collect(
$assignmentService->handle(Node::find($get('node_id')), $data)
)->firstOrFail();
)->first();
}),
Repeater::make('allocation_additional')
->label(trans('admin/server.additional_allocations'))

View File

@@ -4,8 +4,6 @@ namespace App\Filament\Admin\Resources\Servers\Pages;
use App\Enums\SuspendAction;
use App\Enums\TablerIcon;
use App\Extensions\BackupAdapter\BackupAdapterService;
use App\Extensions\BackupAdapter\Schemas\WingsBackupSchema;
use App\Filament\Admin\Resources\Servers\ServerResource;
use App\Filament\Components\Actions\DeleteIcon;
use App\Filament\Components\Actions\PreviewStartupAction;
@@ -15,6 +13,7 @@ use App\Filament\Components\Forms\Fields\StartupVariable;
use App\Filament\Components\StateCasts\ServerConditionStateCast;
use App\Filament\Server\Pages\Console;
use App\Models\Allocation;
use App\Models\Backup;
use App\Models\Egg;
use App\Models\Server;
use App\Models\User;
@@ -391,7 +390,7 @@ class EditServer extends EditRecord
->label(trans('admin/server.cpu_pin'))->inlineLabel()->inline()
->default(0)
->afterStateUpdated(fn (Set $set) => $set('threads', []))
->formatStateUsing(fn (Get $get) => filled($get('threads')))
->formatStateUsing(fn (Get $get) => !empty($get('threads')))
->live()
->stateCast(new BooleanStateCast(false, true))
->options([
@@ -851,20 +850,16 @@ class EditServer extends EditRecord
->disabled(fn (Server $server) => user()?->accessibleNodes()->count() <= 1 || $server->isInConflictState())
->modalHeading(trans('admin/server.transfer'))
->schema($this->transferServer())
->action(function (TransferServerService $transfer, BackupAdapterService $backupService, Server $server, $data) {
->action(function (TransferServerService $transfer, Server $server, $data) {
try {
$selectedBackupUuids = Arr::get($data, 'backups', []);
$transfer->handle($server, Arr::get($data, 'node_id'), Arr::get($data, 'allocation_id'), Arr::get($data, 'allocation_additional', []), $selectedBackupUuids);
$server->backups
->whereNotIn('uuid', $selectedBackupUuids)
->each(function ($backup) use ($backupService) {
$schema = $backupService->get($backup->backupHost->schema);
// Wings backups that aren't transferred only need to be delete on the panel, wings will cleanup the backup files automatically
if ($schema instanceof WingsBackupSchema) {
$backup->delete();
}
->where('disk', Backup::ADAPTER_DAEMON)
->each(function ($backup) {
$backup->delete();
});
Notification::make()
@@ -956,17 +951,17 @@ class EditServer extends EditRecord
->placeholder(trans('admin/server.select_additional')),
Grid::make()
->columnSpanFull()
->schema(fn (BackupAdapterService $backupService) => [
->schema([
CheckboxList::make('backups')
->label(trans('admin/server.backups'))
->bulkToggleable()
->options(fn (Server $server) => $server->backups->filter(fn ($backup) => $backupService->get($backup->backupHost->schema) instanceof WingsBackupSchema)->mapWithKeys(fn ($backup) => [$backup->uuid => $backup->name]))
->columns(fn (Server $record) => (int) ceil($record->backups->filter(fn ($backup) => $backupService->get($backup->backupHost->schema) instanceof WingsBackupSchema)->count() / 4)),
->options(fn (Server $server) => $server->backups->where('disk', Backup::ADAPTER_DAEMON)->mapWithKeys(fn ($backup) => [$backup->uuid => $backup->name]))
->columns(fn (Server $record) => (int) ceil($record->backups->where('disk', Backup::ADAPTER_DAEMON)->count() / 4)),
Text::make('backup_helper')
->columnSpanFull()
->content(trans('admin/server.warning_backups')),
])
->hidden(fn (Server $server, BackupAdapterService $backupService) => $server->backups->filter(fn ($backup) => $backupService->get($backup->backupHost->schema) instanceof WingsBackupSchema)->count() === 0),
->hidden(fn (Server $server) => $server->backups->where('disk', Backup::ADAPTER_DAEMON)->count() === 0),
];
}

View File

@@ -69,7 +69,6 @@ class AllocationsRelationManager extends RelationManager
->trueIcon(TablerIcon::Lock)
->falseIcon(TablerIcon::LockOpen),
])
->emptyStateHeading(trans('admin/server.no_allocations'))
->recordActions([
Action::make('make-primary')
->label(trans('admin/server.make_primary'))

View File

@@ -79,7 +79,6 @@ class DatabasesRelationManager extends RelationManager
DateTimeColumn::make('created_at')
->label(trans('admin/databasehost.table.created_at')),
])
->emptyStateHeading(trans('admin/server.no_databases'))
->recordActions([
ViewAction::make()
->color('primary'),

View File

@@ -50,11 +50,7 @@ class ServerResource extends Resource
public static function getNavigationGroup(): ?string
{
if (user()?->getCustomization(CustomizationKey::TopNavigation)) {
return null;
}
return trans('admin/dashboard.server');
return user()?->getCustomization(CustomizationKey::TopNavigation) ? false : trans('admin/dashboard.server');
}
public static function getNavigationBadge(): ?string

View File

@@ -89,7 +89,6 @@ class ServersRelationManager extends RelationManager
->label(trans('admin/server.backups'))
->numeric()
->sortable(),
])
->emptyStateHeading(trans('admin/server.no_servers'));
]);
}
}

View File

@@ -91,11 +91,7 @@ class UserResource extends Resource
public static function getNavigationGroup(): ?string
{
if (user()?->getCustomization(CustomizationKey::TopNavigation)) {
return null;
}
return trans('admin/dashboard.user');
return user()?->getCustomization(CustomizationKey::TopNavigation) ? false : trans('admin/dashboard.user');
}
public static function getNavigationBadge(): ?string
@@ -485,7 +481,7 @@ class UserResource extends Resource
->deletable(false)
->addable(false)
->relationship(null, function (Builder $query) {
$query->orderByDesc('timestamp');
$query->orderBy('timestamp', 'desc');
})
->schema([
TextEntry::make('log')

View File

@@ -64,8 +64,6 @@ class CreateWebhookConfiguration extends CreateRecord
}
$flags = collect($data['flags'] ?? [])->reduce(fn ($carry, $bit) => $carry | $bit, 0);
$selected = data_get($data, 'allowed_mentions', []);
$allowedMentions = $selected ? ['parse' => array_values($selected)] : [];
$tmp = collect([
'username' => data_get($data, 'username'),
@@ -76,7 +74,7 @@ class CreateWebhookConfiguration extends CreateRecord
'embeds' => $embeds,
'thread_name' => data_get($data, 'thread_name'),
'flags' => $flags,
'allowed_mentions' => $allowedMentions,
'allowed_mentions' => data_get($data, 'allowed_mentions', []),
])->filter(fn ($key) => !empty($key))->all();
unset($data['username'], $data['avatar_url'], $data['content'], $data['image'], $data['thumbnail'], $data['embeds'], $data['thread_name'], $data['flags'], $data['allowed_mentions']);

View File

@@ -56,8 +56,6 @@ class EditWebhookConfiguration extends EditRecord
}
$flags = collect($data['flags'] ?? [])->reduce(fn ($carry, $bit) => $carry | $bit, 0);
$selected = data_get($data, 'allowed_mentions', []);
$allowedMentions = $selected ? ['parse' => array_values($selected)] : [];
$tmp = collect([
'username' => data_get($data, 'username'),
@@ -68,7 +66,7 @@ class EditWebhookConfiguration extends EditRecord
'embeds' => $embeds,
'thread_name' => data_get($data, 'thread_name'),
'flags' => $flags,
'allowed_mentions' => $allowedMentions,
'allowed_mentions' => data_get($data, 'allowed_mentions', []),
])->filter(fn ($key) => !empty($key))->all();
unset($data['username'], $data['avatar_url'], $data['content'], $data['image'], $data['thumbnail'], $data['embeds'], $data['thread_name'], $data['flags'], $data['allowed_mentions']);
@@ -112,7 +110,7 @@ class EditWebhookConfiguration extends EditRecord
'embeds' => $embeds,
'thread_name' => data_get($data, 'payload.thread_name'),
'flags' => $flags,
'allowed_mentions' => data_get($data, 'payload.allowed_mentions.parse'),
'allowed_mentions' => data_get($data, 'payload.allowed_mentions'),
])->filter(fn ($key) => !empty($key))->all();
unset($data['payload'], $data['created_at'], $data['updated_at'], $data['deleted_at']);

View File

@@ -100,8 +100,7 @@ class WebhookResource extends Resource
->label(trans('admin/webhook.name')),
TextColumn::make('description')
->label(trans('admin/webhook.table.description')),
IconColumn::make('type')
->label(trans('admin/webhook.type')),
IconColumn::make('type'),
TextColumn::make('server.name')
->label('Server')
->placeholder('—')
@@ -173,7 +172,6 @@ class WebhookResource extends Resource
Grid::make()
->schema([
ToggleButtons::make('type')
->label(trans('admin/webhook.type'))
->live()
->inline()
->options(WebhookType::class)

View File

@@ -131,7 +131,7 @@ class ListServers extends ListRecords
->contentGrid($usingGrid ? ['default' => 1, 'md' => 2] : null)
->emptyStateIcon(TablerIcon::BrandDocker)
->emptyStateDescription('')
->emptyStateHeading(fn () => $this->activeTab === 'my' ? trans('server/dashboard.empty_own') : trans('server/dashboard.empty_other'))
->emptyStateHeading(fn () => $this->activeTab === 'my' ? 'You don\'t own any servers!' : 'You don\'t have access to any servers!')
->persistFiltersInSession()
->filters([
SelectFilter::make('egg')

View File

@@ -21,8 +21,6 @@ class UpdateNodeAllocations extends Action
{
parent::setUp();
$this->label(trans('admin/node.bulk_update_ip'));
$this->tooltip(trans('admin/node.bulk_update_ip'));
$this->icon(TablerIcon::Replace);

View File

@@ -104,11 +104,15 @@ class UploadIcon extends Action
$content = Http::timeout(5)->connectTimeout(1)->withoutRedirecting()->get($data['icon_url'])->body();
throw_if(empty($content), new Exception(trans('admin/egg.import.invalid_url')));
if (empty($content)) {
throw new Exception(trans('admin/egg.import.invalid_url'));
}
$extension = strtolower(pathinfo(parse_url($data['icon_url'], PHP_URL_PATH), PATHINFO_EXTENSION));
throw_if(empty($extension), new Exception(trans('admin/egg.import.invalid_url')));
if (empty($extension)) {
throw new Exception(trans('admin/egg.import.invalid_url'));
}
$record->writeIcon($extension, $content);
@@ -132,14 +136,20 @@ class UploadIcon extends Action
protected function validateIconUrl(string $url): void
{
throw_unless(in_array(parse_url($url, PHP_URL_SCHEME), ['http', 'https'], true), new Exception(trans('admin/egg.import.invalid_url')));
if (!in_array(parse_url($url, PHP_URL_SCHEME), ['http', 'https'], true)) {
throw new Exception(trans('admin/egg.import.invalid_url'));
}
throw_unless(filter_var($url, FILTER_VALIDATE_URL), new Exception(trans('admin/egg.import.invalid_url')));
if (!filter_var($url, FILTER_VALIDATE_URL)) {
throw new Exception(trans('admin/egg.import.invalid_url'));
}
$host = parse_url($url, PHP_URL_HOST);
$ip = gethostbyname($host);
throw_if(filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false, new Exception(trans('admin/egg.import.no_local_ip')));
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) {
throw new Exception(trans('admin/egg.import.no_local_ip'));
}
}
/** @param string[] $iconFormats */

View File

@@ -36,7 +36,9 @@ class MonacoEditor extends Field
{
$theme = $this->evaluate($this->theme);
throw_unless(isset(config('monaco-editor.themes')[$theme]), new Exception("Theme {$theme} not found in config file."));
if (!isset(config('monaco-editor.themes')[$theme])) {
throw new Exception("Theme {$theme} not found in config file.");
}
return json_encode([
'base' => config("monaco-editor.themes.{$theme}.base"),

View File

@@ -3,6 +3,7 @@
namespace App\Filament\Components\Tables\Columns\Concerns;
use Closure;
use Filament\Support\Colors\Color;
/**
* Trait extracted for progress-related shared functionality between columns.
@@ -16,19 +17,19 @@ trait HasProgress
protected float|Closure|null $dangerThresholdPercent = null;
/**
* @var string|array<int|string,int|string>|Closure|null
* @var string|array<int|string,string>|Closure|Color|null
*/
protected string|array|Closure|null $dangerColor = null;
protected string|array|Closure|Color|null $dangerColor = null;
/**
* @var string|array<int|string,int|string>|Closure|null
* @var string|array<int|string,string>|Closure|Color|null
*/
protected string|array|Closure|null $warningColor = null;
protected string|array|Closure|Color|null $warningColor = null;
/**
* @var string|array<int|string,int|string>|Closure|null
* @var string|array<int|string,string>|Closure|Color|null
*/
protected string|array|Closure|null $color = null;
protected string|array|Closure|Color|null $color = null;
public function warningThresholdPercent(float|Closure $value): static
{
@@ -55,9 +56,9 @@ trait HasProgress
}
/**
* @param string|array<int|string,int|string>|Closure $color
* @param string|array<int|string,string>|Closure|Color $color
*/
public function dangerColor(string|array|Closure $color): static
public function dangerColor(string|array|Closure|Color $color): static
{
$this->dangerColor = $color;
@@ -65,17 +66,17 @@ trait HasProgress
}
/**
* @return string|array<int|string,int|string>|null
* @return string|array<int|string,string>|Color|null
*/
public function getDangerColor(): string|array|null
public function getDangerColor(): string|array|Color|null
{
return $this->evaluate($this->dangerColor);
}
/**
* @param string|array<int|string,int|string>|Closure $color
* @param string|array<int|string,string>|Closure|Color $color
*/
public function warningColor(string|array|Closure $color): static
public function warningColor(string|array|Closure|Color $color): static
{
$this->warningColor = $color;
@@ -83,17 +84,17 @@ trait HasProgress
}
/**
* @return string|array<int|string,int|string>|null
* @return string|array<int|string,string>|Color|null
*/
public function getWarningColor(): string|array|null
public function getWarningColor(): string|array|Color|null
{
return $this->evaluate($this->warningColor);
}
/**
* @param string|array<int|string,int|string>|Closure $color
* @param string|array<int|string,string>|Closure|Color $color
*/
public function color(string|array|Closure $color): static
public function color(string|array|Closure|Color $color): static
{
$this->color = $color;
@@ -101,9 +102,9 @@ trait HasProgress
}
/**
* @return string|array<int|string,int|string>|null
* @return string|array<int|string,string>|Color|null
*/
public function getColor(): string|array|null
public function getColor(): string|array|Color|null
{
return $this->evaluate($this->color);
}
@@ -111,9 +112,9 @@ trait HasProgress
/**
* Resolve a progress color for a given status string ('danger','warning','success').
*
* @return string|array<int|string,int|string>
* @return string|array<int|string,string>|Color
*/
public function getProgressColorForStatus(string $status): string|array
public function getProgressColorForStatus(string $status): string|array|Color
{
$color = match ($status) {
'danger' => $this->getDangerColor(),

View File

@@ -104,7 +104,7 @@ class ProgressBarColumn extends Column
}
/**
* @return string|array<int|string,int|string>
* @return string|array<int|string,string>
*/
public function getProgressColor(): string|array
{

View File

@@ -448,7 +448,7 @@ class EditProfile extends BaseEditProfile
->deletable(false)
->addable(false)
->relationship(null, function (Builder $query) {
$query->orderByDesc('timestamp')->limit(50);
$query->orderBy('timestamp', 'desc')->limit(50);
})
->schema([
TextEntry::make('log')
@@ -481,12 +481,13 @@ class EditProfile extends BaseEditProfile
'mixed' => trans('profile.mixed'),
]),
ToggleButtons::make('button_style')
->label(trans('profile.button_style'))
->label('Button Style')
->inline()
->default('icon')
->options([
true => trans('profile.icon'),
false => trans('profile.icon_button'),
true => 'Icon',
false => 'Icon Button',
]),
]),
Section::make(trans('profile.admin'))

View File

@@ -56,7 +56,6 @@ class Startup extends ServerFormPage
->disabled(fn (Server $server) => !user()?->can(SubuserPermission::StartupUpdate, $server))
->formatStateUsing(fn (Server $server) => $server->startup)
->afterStateUpdated(function ($state, Server $server, Set $set) {
abort_unless(user()?->can(SubuserPermission::StartupUpdate, $server), 403);
$original = $server->startup;
$server->forceFill(['startup' => $state])->saveOrFail();
@@ -90,7 +89,6 @@ class Startup extends ServerFormPage
->visible(fn (Server $server) => in_array($server->image, $server->egg->docker_images))
->disabled(fn (Server $server) => !user()?->can(SubuserPermission::StartupDockerImage, $server))
->afterStateUpdated(function ($state, Server $server) {
abort_unless(user()?->can(SubuserPermission::StartupDockerImage, $server), 403);
$original = $server->image;
$server->forceFill(['image' => $state])->saveOrFail();
@@ -153,8 +151,6 @@ class Startup extends ServerFormPage
public function update(null|string|bool $state, ServerVariable $serverVariable): void
{
abort_unless(user()?->can(SubuserPermission::StartupUpdate, $this->getRecord()), 403);
if (!$serverVariable->variable->user_editable) {
return;
}

View File

@@ -133,7 +133,7 @@ class ActivityResource extends Resource
])
->filters([
SelectFilter::make('event')
->options(fn (Table $table) => $table->getQuery()->select('event')->distinct()->orderBy('event')->pluck('event', 'event'))
->options(fn (Table $table) => $table->getQuery()->pluck('event', 'event')->unique()->sort())
->searchable()
->preload(),
]);

View File

@@ -170,7 +170,7 @@ class BackupResource extends Resource
->color('primary')
->icon(TablerIcon::Download)
->authorize(fn () => user()?->can(SubuserPermission::BackupDownload, $server))
->url(fn (DownloadLinkService $downloadLinkService, Backup $backup) => $downloadLinkService->handle($backup, user()), true)
->url(fn (DownloadLinkService $downloadLinkService, Backup $backup, Request $request) => $downloadLinkService->handle($backup, $request->user()), true)
->visible(fn (Backup $backup) => $backup->status === BackupStatus::Successful),
Action::make('exclude_restore')
->label(trans('server/backup.actions.restore.title'))
@@ -207,13 +207,17 @@ class BackupResource extends Resource
->property(['name' => $backup->name, 'truncate' => $data['truncate']]);
$log->transaction(function () use ($downloadLinkService, $daemonRepository, $backup, $server, $data) {
$url = $downloadLinkService->handle($backup, user());
// If the backup is for an S3 file we need to generate a unique Download link for
// it that will allow daemon to actually access the file.
if ($backup->disk === Backup::ADAPTER_AWS_S3) {
$url = $downloadLinkService->handle($backup, user());
}
// Update the status right away for the server so that we know not to allow certain
// actions against it via the Panel API.
$server->update(['status' => ServerState::RestoringBackup]);
$daemonRepository->setServer($server)->restore($backup, $url, $data['truncate']);
$daemonRepository->setServer($server)->restore($backup, $url ?? null, $data['truncate']);
});
return Notification::make()

View File

@@ -632,7 +632,9 @@ class ListFiles extends ListRecords
/** @var Server $server */
$server = Filament::getTenant();
abort_unless(user()?->can(SubuserPermission::FileCreate, $server), 403, 'You do not have permission to create folders.');
if (!user()?->can(SubuserPermission::FileCreate, $server)) {
abort(403, 'You do not have permission to create folders.');
}
try {
$this->getDaemonFileRepository()->createDirectory($folderPath, $this->path);

View File

@@ -26,7 +26,7 @@ class CreateSchedule extends CreateRecord
$hasFormWrapper = $this->hasFormWrapper();
return Action::make('exclude_create')
->label(trans('filament-panels::resources/pages/create-record.form.actions.create.label'))
->label(__('filament-panels::resources/pages/create-record.form.actions.create.label'))
->submit($hasFormWrapper ? $this->getSubmitFormLivewireMethodName() : null)
->action($hasFormWrapper ? null : $this->getSubmitFormLivewireMethodName())
->keyBindings(['mod+s']);

View File

@@ -95,7 +95,6 @@ class TasksRelationManager extends RelationManager
->label(trans('server/schedule.tasks.continue_on_failure'))
->boolean(),
])
->emptyStateHeading(trans('server/schedule.no_tasks'))
->recordActions([
EditAction::make()
->schema($this->getTaskForm($schedule))

View File

@@ -21,6 +21,7 @@ use App\Traits\Filament\CanCustomizeRelations;
use App\Traits\Filament\CanModifyForm;
use App\Traits\Filament\CanModifyTable;
use BackedEnum;
use Carbon\Carbon;
use Exception;
use Filament\Actions\CreateAction;
use Filament\Actions\DeleteAction;
@@ -44,7 +45,6 @@ use Filament\Support\Exceptions\Halt;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Support\Carbon;
use Illuminate\Support\HtmlString;
use Throwable;
@@ -93,10 +93,9 @@ class ScheduleResource extends Resource
->required()
->default(1),
ToggleButtons::make('status')
->label(trans('server/schedule.status'))
->enum(ScheduleStatus::class)
->formatStateUsing(fn (?Schedule $schedule) => $schedule?->status->value ?? 'new')
->options(fn (?Schedule $schedule) => [$schedule?->status->value ?? 'new' => $schedule?->status->getLabel() ?? trans('server/schedule.schedule_status.new')])
->options(fn (?Schedule $schedule) => [$schedule?->status->value ?? 'new' => $schedule?->status->getLabel() ?? 'New'])
->visibleOn('view'),
Section::make(trans('server/schedule.cron'))
->description(function (Get $get) {

View File

@@ -92,8 +92,7 @@ class WebhookResource extends Resource
{
return $table
->columns([
IconColumn::make('type')
->label(trans('admin/webhook.type')),
IconColumn::make('type'),
TextColumn::make('name')
->label(trans('admin/webhook.name')),
TextColumn::make('endpoint')
@@ -134,7 +133,6 @@ class WebhookResource extends Resource
return $schema
->components([
ToggleButtons::make('type')
->label(trans('admin/webhook.type'))
->live()
->inline()
->options(WebhookType::class)

View File

@@ -47,7 +47,9 @@ class ServerConsole extends Widget
protected function getToken(): string
{
throw_if(!$this->user || !$this->server || $this->user->cannot(SubuserPermission::WebsocketConnect, $this->server), new HttpForbiddenException('You do not have permission to connect to this server\'s websocket.'));
if (!$this->user || !$this->server || $this->user->cannot(SubuserPermission::WebsocketConnect, $this->server)) {
throw new HttpForbiddenException('You do not have permission to connect to this server\'s websocket.');
}
$permissions = $this->getUserPermissionsService->handle($this->server, $this->user);

View File

@@ -4,10 +4,10 @@ namespace App\Filament\Server\Widgets;
use App\Enums\CustomizationKey;
use App\Models\Server;
use Carbon\Carbon;
use Filament\Facades\Filament;
use Filament\Support\RawJs;
use Filament\Widgets\ChartWidget;
use Illuminate\Support\Carbon;
class ServerCpuChart extends ChartWidget
{

View File

@@ -4,10 +4,10 @@ namespace App\Filament\Server\Widgets;
use App\Enums\CustomizationKey;
use App\Models\Server;
use Carbon\Carbon;
use Filament\Facades\Filament;
use Filament\Support\RawJs;
use Filament\Widgets\ChartWidget;
use Illuminate\Support\Carbon;
class ServerMemoryChart extends ChartWidget
{

View File

@@ -4,10 +4,10 @@ namespace App\Filament\Server\Widgets;
use App\Enums\CustomizationKey;
use App\Models\Server;
use Carbon\Carbon;
use Filament\Facades\Filament;
use Filament\Support\RawJs;
use Filament\Widgets\ChartWidget;
use Illuminate\Support\Carbon;
class ServerNetworkChart extends ChartWidget
{

View File

@@ -2,9 +2,9 @@
namespace App\Helpers;
use Carbon\Carbon;
use Cron\CronExpression;
use Exception;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use Illuminate\Support\ViewErrorBag;

View File

@@ -115,7 +115,9 @@ class MountController extends ApplicationApiController
*/
public function delete(DeleteMountRequest $request, Mount $mount): JsonResponse
{
throw_if($mount->servers()->count() > 0, new HasActiveServersException(trans('exceptions.mount.servers_attached')));
if ($mount->servers()->count() > 0) {
throw new HasActiveServersException(trans('exceptions.mount.servers_attached'));
}
$mount->delete();

View File

@@ -68,7 +68,9 @@ class PluginController extends ApplicationApiController
*/
public function importFile(WritePluginRequest $request): Response
{
throw_unless($request->hasFile('plugin'), new PanelException("No 'plugin' file in request"));
if (!$request->hasFile('plugin')) {
throw new PanelException("No 'plugin' file in request");
}
$this->pluginService->downloadPluginFromFile($request->file('plugin'));
@@ -100,7 +102,9 @@ class PluginController extends ApplicationApiController
*/
public function install(WritePluginRequest $request, Plugin $plugin): array
{
throw_if($plugin->status !== PluginStatus::NotInstalled, new PanelException('Plugin is already installed'));
if ($plugin->status !== PluginStatus::NotInstalled) {
throw new PanelException('Plugin is already installed');
}
$this->pluginService->installPlugin($plugin);
@@ -120,7 +124,9 @@ class PluginController extends ApplicationApiController
*/
public function update(WritePluginRequest $request, Plugin $plugin): array
{
throw_unless($plugin->isUpdateAvailable(), new PanelException("Plugin doesn't need updating"));
if (!$plugin->isUpdateAvailable()) {
throw new PanelException("Plugin doesn't need updating");
}
$this->pluginService->updatePlugin($plugin);
@@ -140,7 +146,9 @@ class PluginController extends ApplicationApiController
*/
public function uninstall(UninstallPluginRequest $request, Plugin $plugin): array
{
throw_if($plugin->status === PluginStatus::NotInstalled, new PanelException('Plugin is not installed'));
if ($plugin->status === PluginStatus::NotInstalled) {
throw new PanelException('Plugin is not installed');
}
$this->pluginService->uninstallPlugin($plugin, $request->boolean('delete'));
@@ -160,7 +168,9 @@ class PluginController extends ApplicationApiController
*/
public function enable(WritePluginRequest $request, Plugin $plugin): array
{
throw_unless($plugin->canEnable(), new PanelException("Plugin can't be enabled"));
if (!$plugin->canEnable()) {
throw new PanelException("Plugin can't be enabled");
}
$this->pluginService->enablePlugin($plugin);
@@ -180,7 +190,9 @@ class PluginController extends ApplicationApiController
*/
public function disable(WritePluginRequest $request, Plugin $plugin): array
{
throw_unless($plugin->canDisable(), new PanelException("Plugin can't be disabled"));
if (!$plugin->canDisable()) {
throw new PanelException("Plugin can't be disabled");
}
$this->pluginService->disablePlugin($plugin);

View File

@@ -84,7 +84,9 @@ class RoleController extends ApplicationApiController
*/
public function update(UpdateRoleRequest $request, Role $role): array
{
throw_if($role->isRootAdmin(), new PanelException('Can\'t update root admin role!'));
if ($role->isRootAdmin()) {
throw new PanelException('Can\'t update root admin role!');
}
$role->update($request->validated());
@@ -102,7 +104,9 @@ class RoleController extends ApplicationApiController
*/
public function delete(DeleteRoleRequest $request, Role $role): Response
{
throw_if($role->isRootAdmin(), new PanelException('Can\'t delete root admin role!'));
if ($role->isRootAdmin()) {
throw new PanelException('Can\'t delete root admin role!');
}
$role->delete();

View File

@@ -75,7 +75,9 @@ class AccountController extends ClientApiController
// Only allow a user to change their email three times in the span
// of 24 hours. This prevents malicious users from trying to find
// existing accounts in the system by constantly changing their email.
throw_if(RateLimiter::tooManyAttempts($key = "user:update-email:{$user->uuid}", 3), new TooManyRequestsHttpException(message: 'Your email address has been changed too many times today. Please try again later.'));
if (RateLimiter::tooManyAttempts($key = "user:update-email:{$user->uuid}", 3)) {
throw new TooManyRequestsHttpException(message: 'Your email address has been changed too many times today. Please try again later.');
}
$original = $user->email;

View File

@@ -37,7 +37,9 @@ class ApiKeyController extends ClientApiController
*/
public function store(StoreApiKeyRequest $request): array
{
throw_if($request->user()->apiKeys->count() >= config('panel.api.key_limit'), new DisplayException('You have reached the account limit for number of API keys.'));
if ($request->user()->apiKeys->count() >= config('panel.api.key_limit')) {
throw new DisplayException('You have reached the account limit for number of API keys.');
}
$token = $request->user()->createToken(
$request->input('description'),

View File

@@ -4,7 +4,6 @@ namespace App\Http\Controllers\Api\Client\Servers;
use App\Enums\ServerState;
use App\Enums\SubuserPermission;
use App\Extensions\BackupAdapter\BackupAdapterService;
use App\Facades\Activity;
use App\Http\Controllers\Api\Client\ClientApiController;
use App\Http\Requests\Api\Client\Servers\Backups\RenameBackupRequest;
@@ -34,7 +33,6 @@ class BackupController extends ClientApiController
private readonly DeleteBackupService $deleteBackupService,
private readonly InitiateBackupService $initiateBackupService,
private readonly DownloadLinkService $downloadLinkService,
private readonly BackupAdapterService $backupService
) {
parent::__construct();
}
@@ -50,7 +48,9 @@ class BackupController extends ClientApiController
*/
public function index(Request $request, Server $server): array
{
throw_unless($request->user()->can(SubuserPermission::BackupRead, $server), new AuthorizationException());
if (!$request->user()->can(SubuserPermission::BackupRead, $server)) {
throw new AuthorizationException();
}
$limit = min($request->query('per_page') ?? 20, 50);
@@ -116,7 +116,9 @@ class BackupController extends ClientApiController
*/
public function toggleLock(Request $request, Server $server, Backup $backup): array
{
throw_unless($request->user()->can(SubuserPermission::BackupDelete, $server), new AuthorizationException());
if (!$request->user()->can(SubuserPermission::BackupDelete, $server)) {
throw new AuthorizationException();
}
$action = $backup->is_locked ? 'server:backup.unlock' : 'server:backup.lock';
@@ -140,7 +142,9 @@ class BackupController extends ClientApiController
*/
public function view(Request $request, Server $server, Backup $backup): array
{
throw_unless($request->user()->can(SubuserPermission::BackupRead, $server), new AuthorizationException());
if (!$request->user()->can(SubuserPermission::BackupRead, $server)) {
throw new AuthorizationException();
}
return $this->fractal->item($backup)
->transformWith($this->getTransformer(BackupTransformer::class))
@@ -157,7 +161,9 @@ class BackupController extends ClientApiController
*/
public function delete(Request $request, Server $server, Backup $backup): JsonResponse
{
throw_unless($request->user()->can(SubuserPermission::BackupDelete, $server), new AuthorizationException());
if (!$request->user()->can(SubuserPermission::BackupDelete, $server)) {
throw new AuthorizationException();
}
$this->deleteBackupService->handle($backup);
@@ -181,10 +187,11 @@ class BackupController extends ClientApiController
*/
public function download(Request $request, Server $server, Backup $backup): JsonResponse
{
throw_unless($request->user()->can(SubuserPermission::BackupDownload, $server), new AuthorizationException());
if (!$request->user()->can(SubuserPermission::BackupDownload, $server)) {
throw new AuthorizationException();
}
$schema = $this->backupService->get($backup->backupHost->schema);
if (!$schema) {
if ($backup->disk !== Backup::ADAPTER_AWS_S3 && $backup->disk !== Backup::ADAPTER_DAEMON) {
throw new BadRequestHttpException('The backup requested references an unknown disk driver type and cannot be downloaded.');
}
@@ -244,22 +251,30 @@ class BackupController extends ClientApiController
{
// Cannot restore a backup unless a server is fully installed and not currently
// processing a different backup restoration request.
throw_unless(is_null($server->status), new BadRequestHttpException('This server is not currently in a state that allows for a backup to be restored.'));
if (!is_null($server->status)) {
throw new BadRequestHttpException('This server is not currently in a state that allows for a backup to be restored.');
}
throw_if(!$backup->is_successful && is_null($backup->completed_at), new BadRequestHttpException('This backup cannot be restored at this time: not completed or failed.'));
if (!$backup->is_successful && is_null($backup->completed_at)) {
throw new BadRequestHttpException('This backup cannot be restored at this time: not completed or failed.');
}
$log = Activity::event('server:backup.restore')
->subject($backup)
->property(['name' => $backup->name, 'truncate' => $request->input('truncate')]);
$log->transaction(function () use ($backup, $server, $request) {
$url = $this->downloadLinkService->handle($backup, $request->user());
// If the backup is for an S3 file we need to generate a unique Download link for
// it that will allow daemon to actually access the file.
if ($backup->disk === Backup::ADAPTER_AWS_S3) {
$url = $this->downloadLinkService->handle($backup, $request->user());
}
// Update the status right away for the server so that we know not to allow certain
// actions against it via the Panel API.
$server->update(['status' => ServerState::RestoringBackup]);
$this->daemonRepository->setServer($server)->restore($backup, $url, $request->input('truncate'));
$this->daemonRepository->setServer($server)->restore($backup, $url ?? null, $request->input('truncate'));
});
return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT);

View File

@@ -31,7 +31,9 @@ class CommandController extends ClientApiController
$previous = $exception->getPrevious();
if ($previous instanceof BadResponseException) {
throw_if($previous->getResponse()->getStatusCode() === Response::HTTP_BAD_GATEWAY, new HttpException(Response::HTTP_BAD_GATEWAY, 'Server must be online in order to send commands.', $exception));
if ($previous->getResponse()->getStatusCode() === Response::HTTP_BAD_GATEWAY) {
throw new HttpException(Response::HTTP_BAD_GATEWAY, 'Server must be online in order to send commands.', $exception);
}
}
throw $exception;

View File

@@ -110,7 +110,9 @@ class NetworkAllocationController extends ClientApiController
$allocation = Activity::event('server:allocation.create')->transaction(function ($log) use ($server) {
$server->allocations()->lockForUpdate();
throw_if($server->allocations->count() >= $server->allocation_limit, new DisplayException('Cannot assign additional allocations to this server: limit has been reached.'));
if ($server->allocations->count() >= $server->allocation_limit) {
throw new DisplayException('Cannot assign additional allocations to this server: limit has been reached.');
}
$allocation = $this->assignableAllocationService->handle($server);
@@ -135,7 +137,9 @@ class NetworkAllocationController extends ClientApiController
{
// Don't allow the deletion of allocations if the server does not have an
// allocation limit set.
throw_if(empty($server->allocation_limit), new DisplayException('You cannot delete allocations for this server: no allocation limit is set.'));
if (empty($server->allocation_limit)) {
throw new DisplayException('You cannot delete allocations for this server: no allocation limit is set.');
}
Allocation::query()->where('id', $allocation->id)->update([
'notes' => null,

View File

@@ -7,10 +7,10 @@ use App\Http\Requests\Api\Client\Servers\GetServerRequest;
use App\Models\Server;
use App\Repositories\Daemon\DaemonServerRepository;
use App\Transformers\Api\Client\StatsTransformer;
use Carbon\Carbon;
use Dedoc\Scramble\Attributes\Group;
use Illuminate\Cache\Repository;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Carbon;
#[Group('Server', weight: 3)]
class ResourceUtilizationController extends ClientApiController

View File

@@ -16,12 +16,12 @@ use App\Models\Schedule;
use App\Models\Server;
use App\Services\Schedules\ProcessScheduleService;
use App\Transformers\Api\Client\ScheduleTransformer;
use Carbon\Carbon;
use Dedoc\Scramble\Attributes\Group;
use Exception;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Carbon;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Throwable;
@@ -97,7 +97,9 @@ class ScheduleController extends ClientApiController
*/
public function view(ViewScheduleRequest $request, Server $server, Schedule $schedule): array
{
throw_if($schedule->server_id !== $server->id, new NotFoundHttpException());
if ($schedule->server_id !== $server->id) {
throw new NotFoundHttpException();
}
$schedule->loadMissing('tasks');

View File

@@ -46,9 +46,13 @@ class ScheduleTaskController extends ClientApiController
public function store(StoreTaskRequest $request, Server $server, Schedule $schedule): array
{
$limit = config('panel.client_features.schedules.per_schedule_task_limit', 10);
throw_if($schedule->tasks()->count() >= $limit, new ServiceLimitExceededException("Schedules may not have more than $limit tasks associated with them. Creating this task would put this schedule over the limit."));
if ($schedule->tasks()->count() >= $limit) {
throw new ServiceLimitExceededException("Schedules may not have more than $limit tasks associated with them. Creating this task would put this schedule over the limit.");
}
throw_if($server->backup_limit === 0 && $request->action === 'backup', new HttpForbiddenException("A backup task cannot be created when the server's backup limit is set to 0."));
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.");
}
/** @var Task|null $lastTask */
$lastTask = $schedule->tasks()->orderByDesc('sequence_id')->first();
@@ -105,9 +109,13 @@ class ScheduleTaskController extends ClientApiController
*/
public function update(StoreTaskRequest $request, Server $server, Schedule $schedule, Task $task): array
{
throw_if($schedule->id !== $task->schedule_id || $server->id !== $schedule->server_id, new NotFoundHttpException());
if ($schedule->id !== $task->schedule_id || $server->id !== $schedule->server_id) {
throw new NotFoundHttpException();
}
throw_if($server->backup_limit === 0 && $request->action === 'backup', new HttpForbiddenException("A backup task cannot be created when the server's backup limit is set to 0."));
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.");
}
$this->connection->transaction(function () use ($request, $schedule, $task) {
$sequenceId = $request->integer('sequence_id', $task->sequence_id);
@@ -158,9 +166,13 @@ class ScheduleTaskController extends ClientApiController
*/
public function delete(ClientApiRequest $request, Server $server, Schedule $schedule, Task $task): JsonResponse
{
throw_if($task->schedule_id !== $schedule->id || $schedule->server_id !== $server->id, new NotFoundHttpException());
if ($task->schedule_id !== $schedule->id || $schedule->server_id !== $server->id) {
throw new NotFoundHttpException();
}
throw_unless($request->user()->can(SubuserPermission::ScheduleDelete, $server), new HttpForbiddenException('You do not have permission to perform this action.'));
if (!$request->user()->can(SubuserPermission::ScheduleDelete, $server)) {
throw new HttpForbiddenException('You do not have permission to perform this action.');
}
$schedule->tasks()
->where('sequence_id', '>', $task->sequence_id)

View File

@@ -96,7 +96,9 @@ class SettingsController extends ClientApiController
*/
public function dockerImage(SetDockerImageRequest $request, Server $server): JsonResponse
{
throw_unless(in_array($server->image, $server->egg->docker_images), new BadRequestHttpException('This server\'s Docker image has been manually set by an administrator and cannot be updated.'));
if (!in_array($server->image, $server->egg->docker_images)) {
throw new BadRequestHttpException('This server\'s Docker image has been manually set by an administrator and cannot be updated.');
}
$original = $server->image;
$server->forceFill(['image' => $request->input('docker_image')])->saveOrFail();

View File

@@ -64,9 +64,11 @@ class StartupController extends ClientApiController
{
$variable = $server->variables()->where('env_variable', $request->input('key'))->first();
throw_if(!$variable || !$variable->user_viewable, new BadRequestHttpException('The environment variable you are trying to edit does not exist.'));
throw_unless($variable->user_editable, new BadRequestHttpException('The environment variable you are trying to edit is read-only.'));
if (!$variable || !$variable->user_viewable) {
throw new BadRequestHttpException('The environment variable you are trying to edit does not exist.');
} elseif (!$variable->user_editable) {
throw new BadRequestHttpException('The environment variable you are trying to edit is read-only.');
}
$original = $variable->server_value;

View File

@@ -38,14 +38,18 @@ class WebsocketController extends ClientApiController
public function __invoke(ClientApiRequest $request, Server $server): JsonResponse
{
$user = $request->user();
throw_if($user->cannot(SubuserPermission::WebsocketConnect, $server), new HttpForbiddenException('You do not have permission to connect to this server\'s websocket.'));
if ($user->cannot(SubuserPermission::WebsocketConnect, $server)) {
throw new HttpForbiddenException('You do not have permission to connect to this server\'s websocket.');
}
$permissions = $this->permissionsService->handle($server, $user);
$node = $server->node;
if (!is_null($server->transfer)) {
// Check if the user has permissions to receive transfer logs.
throw_unless(in_array('admin.websocket.transfer', $permissions), new HttpForbiddenException('You do not have permission to view server transfer logs.'));
if (!in_array('admin.websocket.transfer', $permissions)) {
throw new HttpForbiddenException('You do not have permission to view server transfer logs.');
}
// Redirect the websocket request to the new node if the server has been archived.
if ($server->transfer->archived) {

View File

@@ -9,9 +9,9 @@ use App\Models\ActivityLog;
use App\Models\Node;
use App\Models\Server;
use App\Models\User;
use Carbon\Carbon;
use DateTimeInterface;
use Exception;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Str;

View File

@@ -3,16 +3,20 @@
namespace App\Http\Controllers\Api\Remote\Backups;
use App\Exceptions\Http\HttpForbiddenException;
use App\Extensions\BackupAdapter\BackupAdapterService;
use App\Extensions\BackupAdapter\Schemas\S3BackupSchema;
use App\Extensions\Backups\BackupManager;
use App\Extensions\Filesystem\S3Filesystem;
use App\Http\Controllers\Controller;
use App\Models\Backup;
use App\Models\Node;
use App\Models\Server;
use Carbon\CarbonImmutable;
use Exception;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Throwable;
class BackupRemoteUploadController extends Controller
{
@@ -21,43 +25,117 @@ class BackupRemoteUploadController extends Controller
/**
* BackupRemoteUploadController constructor.
*/
public function __construct(private BackupAdapterService $backupService) {}
public function __construct(private BackupManager $backupManager) {}
/**
* Returns the required presigned urls to upload a backup to S3 cloud storage.
*
* @throws BadRequestHttpException
* @throws Exception
* @throws Throwable
* @throws ModelNotFoundException
* @throws HttpForbiddenException
* @throws ConflictHttpException
*/
public function __invoke(Request $request, string $backup): JsonResponse
{
// Get the node associated with the request.
/** @var Node $node */
$node = $request->attributes->get('node');
// Get the size query parameter.
$size = (int) $request->query('size');
throw_if(empty($size), new BadRequestHttpException('A non-empty "size" query parameter must be provided.'));
if (empty($size)) {
throw new BadRequestHttpException('A non-empty "size" query parameter must be provided.');
}
$backup = Backup::where('uuid', $backup)->firstOrFail();
/** @var Backup $model */
$model = Backup::query()
->where('uuid', $backup)
->firstOrFail();
// Check that the backup is "owned" by the node making the request. This avoids other nodes
// from messing with backups that they don't own.
if ($backup->server->node_id !== $node->id) {
/** @var Server $server */
$server = $model->server;
if ($server->node_id !== $node->id) {
throw new HttpForbiddenException('Requesting node does not have permission to access this server.');
}
// Prevent backups that have already been completed from trying to be uploaded again.
if (!is_null($backup->completed_at)) {
// Prevent backups that have already been completed from trying to
// be uploaded again.
if (!is_null($model->completed_at)) {
throw new ConflictHttpException('This backup is already in a completed state.');
}
// Ensure we are using the S3 schema.
$schema = $this->backupService->get($backup->backupHost->schema);
if (!$schema instanceof S3BackupSchema) {
throw new BadRequestHttpException('The configured backup schema is not an S3 compatible.');
// Ensure we are using the S3 adapter.
$adapter = $this->backupManager->adapter();
if (!$adapter instanceof S3Filesystem) {
throw new BadRequestHttpException('The configured backup adapter is not an S3 compatible adapter.');
}
return new JsonResponse($schema->getUploadParts($backup, $size));
// The path where backup will be uploaded to
$path = sprintf('%s/%s.tar.gz', $model->server->uuid, $model->uuid);
// Get the S3 client
$client = $adapter->getClient();
$expires = CarbonImmutable::now()->addMinutes(config('backups.presigned_url_lifespan', 60));
// Params for generating the presigned urls
$params = [
'Bucket' => $adapter->getBucket(),
'Key' => $path,
'ContentType' => 'application/x-gzip',
];
$storageClass = config('backups.disks.s3.storage_class');
if (!is_null($storageClass)) {
$params['StorageClass'] = $storageClass;
}
// Execute the CreateMultipartUpload request
$result = $client->execute($client->getCommand('CreateMultipartUpload', $params));
// Get the UploadId from the CreateMultipartUpload request, this is needed to create
// the other presigned urls.
$params['UploadId'] = $result->get('UploadId');
// Retrieve configured part size
$maxPartSize = $this->getConfiguredMaxPartSize();
// Create as many UploadPart presigned urls as needed
$parts = [];
for ($i = 0; $i < ($size / $maxPartSize); $i++) {
$parts[] = $client->createPresignedRequest(
$client->getCommand('UploadPart', array_merge($params, ['PartNumber' => $i + 1])),
$expires
)->getUri()->__toString();
}
// Set the upload_id on the backup in the database.
$model->update(['upload_id' => $params['UploadId']]);
return new JsonResponse([
'parts' => $parts,
'part_size' => $maxPartSize,
]);
}
/**
* Get the configured maximum size of a single part in the multipart upload.
*
* The function tries to retrieve a configured value from the configuration.
* If no value is specified, a fallback value will be used.
*
* Note if the received config cannot be converted to int (0), is zero or is negative,
* the fallback value will be used too.
*
* The fallback value is {@see BackupRemoteUploadController::DEFAULT_MAX_PART_SIZE}.
*/
private function getConfiguredMaxPartSize(): int
{
$maxPartSize = config('backups.max_part_size', self::DEFAULT_MAX_PART_SIZE);
if ($maxPartSize <= 0) {
$maxPartSize = self::DEFAULT_MAX_PART_SIZE;
}
return $maxPartSize;
}
}

View File

@@ -3,9 +3,10 @@
namespace App\Http\Controllers\Api\Remote\Backups;
use App\Events\Server\BackupCompleted;
use App\Exceptions\DisplayException;
use App\Exceptions\Http\HttpForbiddenException;
use App\Extensions\BackupAdapter\BackupAdapterService;
use App\Extensions\BackupAdapter\Schemas\S3BackupSchema;
use App\Extensions\Backups\BackupManager;
use App\Extensions\Filesystem\S3Filesystem;
use App\Facades\Activity;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\Remote\ReportBackupCompleteRequest;
@@ -13,6 +14,7 @@ use App\Models\Backup;
use App\Models\Node;
use App\Models\Server;
use Carbon\CarbonImmutable;
use Exception;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
@@ -23,7 +25,7 @@ class BackupStatusController extends Controller
/**
* BackupStatusController constructor.
*/
public function __construct(private BackupAdapterService $backupService) {}
public function __construct(private BackupManager $backupManager) {}
/**
* Handles updating the state of a backup.
@@ -45,9 +47,13 @@ class BackupStatusController extends Controller
// from messing with backups that they don't own.
/** @var Server $server */
$server = $model->server;
throw_if($server->node_id !== $node->id, new HttpForbiddenException('Requesting node does not have permission to access this server.'));
if ($server->node_id !== $node->id) {
throw new HttpForbiddenException('Requesting node does not have permission to access this server.');
}
throw_if($model->is_successful, new BadRequestHttpException('Cannot update the status of a backup that is already marked as completed.'));
if ($model->is_successful) {
throw new BadRequestHttpException('Cannot update the status of a backup that is already marked as completed.');
}
$action = $request->boolean('successful') ? 'server:backup.complete' : 'server:backup.fail';
$log = Activity::event($action)->subject($model, $model->server)->property('name', $model->name);
@@ -68,9 +74,9 @@ class BackupStatusController extends Controller
// Check if we are using the s3 backup adapter. If so, make sure we mark the backup as
// being completed in S3 correctly.
$schema = $this->backupService->get($model->backupHost->schema);
if ($schema instanceof S3BackupSchema) {
$schema->completeMultipartUpload($model, $successful, $request->input('parts'));
$adapter = $this->backupManager->adapter();
if ($adapter instanceof S3Filesystem) {
$this->completeMultipartUpload($model, $adapter, $successful, $request->input('parts'));
}
});
@@ -95,7 +101,9 @@ class BackupStatusController extends Controller
$model = Backup::query()->where('uuid', $backup)->firstOrFail();
$node = $request->attributes->get('node');
throw_unless($model->server->node->is($node), new HttpForbiddenException('Requesting node does not have permission to access this server.'));
if (!$model->server->node->is($node)) {
throw new HttpForbiddenException('Requesting node does not have permission to access this server.');
}
$model->server->update(['status' => null]);
@@ -106,4 +114,59 @@ class BackupStatusController extends Controller
return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT);
}
/**
* Marks a multipart upload in a given S3-compatible instance as failed or successful for the given backup.
*
* @param ?array<array{int, etag: string, part_number: string}> $parts
*
* @throws Exception
* @throws DisplayException
*/
protected function completeMultipartUpload(Backup $backup, S3Filesystem $adapter, bool $successful, ?array $parts): void
{
// This should never really happen, but if it does don't let us fall victim to Amazon's
// wildly fun error messaging. Just stop the process right here.
if (empty($backup->upload_id)) {
// A failed backup doesn't need to error here, this can happen if the backup encounters
// an error before we even start the upload. AWS gives you tooling to clear these failed
// multipart uploads as needed too.
if (!$successful) {
return;
}
throw new DisplayException('Cannot complete backup request: no upload_id present on model.');
}
$params = [
'Bucket' => $adapter->getBucket(),
'Key' => sprintf('%s/%s.tar.gz', $backup->server->uuid, $backup->uuid),
'UploadId' => $backup->upload_id,
];
$client = $adapter->getClient();
if (!$successful) {
$client->execute($client->getCommand('AbortMultipartUpload', $params));
return;
}
// Otherwise send a CompleteMultipartUpload request.
$params['MultipartUpload'] = [
'Parts' => [],
];
if (is_null($parts)) {
$params['MultipartUpload']['Parts'] = $client->execute($client->getCommand('ListParts', $params))['Parts'];
} else {
foreach ($parts as $part) {
$params['MultipartUpload']['Parts'][] = [
'ETag' => $part['etag'],
'PartNumber' => $part['part_number'],
];
}
}
$client->execute($client->getCommand('CompleteMultipartUpload', $params));
}
}

View File

@@ -16,7 +16,9 @@ class ServerContainersController extends Controller
*/
public function status(Request $request, Server $server): JsonResponse
{
throw_unless($server->node->is($request->attributes->get('node')), new HttpForbiddenException('Requesting node does not have permission to access this server.'));
if (!$server->node->is($request->attributes->get('node'))) {
throw new HttpForbiddenException('Requesting node does not have permission to access this server.');
}
$status = ContainerStatus::tryFrom($request->json('data.new_state')) ?? ContainerStatus::Missing;

View File

@@ -45,7 +45,9 @@ class ServerDetailsController extends Controller
// to fetch these details.
$valid = $transfer ? $node->id === $transfer->old_node || $node->id === $transfer->new_node : $node->id === $server->node_id;
throw_unless($valid, new HttpForbiddenException('Requesting node does not have permission to access this server.'));
if (!$valid) {
throw new HttpForbiddenException('Requesting node does not have permission to access this server.');
}
return new JsonResponse([
'settings' => $this->configurationStructureService->handle($server),

View File

@@ -20,7 +20,9 @@ class ServerInstallController extends Controller
*/
public function index(Request $request, Server $server): JsonResponse
{
throw_unless($server->node->is($request->attributes->get('node')), new HttpForbiddenException('Requesting node does not have permission to access this server.'));
if (!$server->node->is($request->attributes->get('node'))) {
throw new HttpForbiddenException('Requesting node does not have permission to access this server.');
}
$egg = $server->egg;
@@ -40,7 +42,9 @@ class ServerInstallController extends Controller
{
$status = null;
throw_unless($server->node->is($request->attributes->get('node')), new HttpForbiddenException('Requesting node does not have permission to access this server.'));
if (!$server->node->is($request->attributes->get('node'))) {
throw new HttpForbiddenException('Requesting node does not have permission to access this server.');
}
$successful = $request->boolean('successful');

View File

@@ -35,14 +35,18 @@ class ServerTransferController extends Controller
public function failure(Request $request, Server $server): JsonResponse
{
$transfer = $server->transfer;
throw_if(is_null($transfer), new ConflictHttpException('Server is not being transferred.'));
if (is_null($transfer)) {
throw new ConflictHttpException('Server is not being transferred.');
}
/* @var Node $node */
Assert::isInstanceOf($node = $request->attributes->get('node'), Node::class);
// Either node can tell the panel that the transfer has failed. Only the new node
// can tell the panel that it was successful.
throw_if(!$node->is($transfer->newNode) && !$node->is($transfer->oldNode), new HttpForbiddenException('Requesting node does not have permission to access this server.'));
if (!$node->is($transfer->newNode) && !$node->is($transfer->oldNode)) {
throw new HttpForbiddenException('Requesting node does not have permission to access this server.');
}
$this->connection->transaction(function () use ($transfer) {
$transfer->forceFill(['successful' => false])->saveOrFail();
@@ -64,14 +68,18 @@ class ServerTransferController extends Controller
public function success(Request $request, Server $server): JsonResponse
{
$transfer = $server->transfer;
throw_if(is_null($transfer), new ConflictHttpException('Server is not being transferred.'));
if (is_null($transfer)) {
throw new ConflictHttpException('Server is not being transferred.');
}
/* @var Node $node */
Assert::isInstanceOf($node = $request->attributes->get('node'), Node::class);
// Only the new node communicates a successful state to the panel, so we should
// not allow the old node to hit this endpoint.
throw_unless($node->is($transfer->newNode), new HttpForbiddenException('Requesting node does not have permission to access this server.'));
if (!$node->is($transfer->newNode)) {
throw new HttpForbiddenException('Requesting node does not have permission to access this server.');
}
/** @var Server $server */
$server = $this->connection->transaction(function () use ($server, $transfer) {

View File

@@ -32,7 +32,9 @@ class SftpAuthenticationController extends Controller
public function __invoke(SftpAuthenticationFormRequest $request): JsonResponse
{
$connection = $this->parseUsername($request->input('username'));
throw_if(empty($connection['server']), new BadRequestHttpException('No valid server identifier was included in the request.'));
if (empty($connection['server'])) {
throw new BadRequestHttpException('No valid server identifier was included in the request.');
}
if ($this->hasTooManyLoginAttempts($request)) {
$seconds = $this->limiter()->availableIn($this->throttleKey($request));
@@ -122,7 +124,7 @@ class SftpAuthenticationController extends Controller
/**
* Rejects the request and increments the login attempts.
*/
protected function reject(Request $request, bool $increment = true): never
protected function reject(Request $request, bool $increment = true): void
{
if ($increment) {
$this->incrementLoginAttempts($request);

View File

@@ -47,10 +47,10 @@ class OAuthController extends Controller
}
// Check for errors (https://www.oauth.com/oauth2-servers/server-side-apps/possible-errors/)
if ($request->input('error')) {
report($request->input('error_description') ?? $request->input('error'));
if ($request->get('error')) {
report($request->get('error_description') ?? $request->get('error'));
return $this->errorRedirect($request->input('error'));
return $this->errorRedirect($request->get('error'));
}
$oauthUser = Socialite::driver($driver->getId())->user();
@@ -77,10 +77,6 @@ class OAuthController extends Controller
return $this->errorRedirect('No email was linked to your account on the OAuth provider.');
}
if (isset($oauthUser->email_verified) && !filter_var($oauthUser->email_verified, FILTER_VALIDATE_BOOLEAN)) {
return $this->errorRedirect('Email not verified on OAuth provider.');
}
$user = User::whereEmail($email)->first();
if ($user) {
if (!$driver->shouldLinkMissingUser($user, $oauthUser)) {

Some files were not shown because too many files have changed in this diff Show More