Compare commits

...

16 Commits

Author SHA1 Message Date
sangege
1f2ff09a8b fix #2435 handle missing event names in webhook dispatcher (#2440) 2026-07-11 16:36:25 -04:00
Lance Pioch
f44ad34dc3 Fix PHPStan return.type errors (#2445) 2026-07-11 15:58:44 -04:00
Charles
4cd8b5767a Charles/fix actions (#2444)
Co-authored-by: Lance Pioch <git@lance.sh>
2026-07-11 15:31:38 -04:00
Lance Pioch
4ff5adc2a6 Monthly Shift: July 2026 (#2433)
Co-authored-by: Shift <shift@laravelshift.com>
2026-07-09 09:55:11 -04:00
Boy132
b8bbf29a7f Check email_verified when handling missing user in oauth controller (#2434) 2026-07-08 13:13:45 +02:00
Boy132
c5181c9c84 Backup hosts (#2125)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-07-08 07:06:59 -04:00
Boy132
54e583e0e4 Run composer update and remove package-lock.json (#2432) 2026-07-08 09:14:02 +02:00
kazamin
614153a1ed fix(plugin): avoid stale Sushi snapshot causing ModelNotFoundException on install (#2425)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Boy132 <mail@boy132.de>
2026-07-06 05:29:46 -04:00
execsuroot
7f14a72002 Fix activity log event filter query and add missing timestamp index (#2429) 2026-07-02 20:15:20 -04:00
Lance Pioch
6614ae5ea1 Laravel 13.18.1 Shift (#2428)
Co-authored-by: Shift <shift@laravelshift.com>
2026-07-02 20:12:06 -04:00
github-actions[bot]
9e0f7cee35 New Crowdin Translations (#2421)
Co-authored-by: notAreYouScared <1757840+notAreYouScared@users.noreply.github.com>
2026-07-02 08:24:53 -04:00
jsexp
f9793c36fd Fix missing index on activity_log_id in activity_log_subjects table (#2418)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-07-02 08:24:19 -04:00
wuzuantw
d4e974b806 fix(admin): fix cpu pinning showing as disabled when thread 0 is configured (#2419) 2026-07-02 08:24:02 -04:00
Boy132
00a56398fb Add more byte suffixes and make sure we don't overflow (#2417) 2026-06-25 16:41:51 +02:00
JoanFo
ab9535eb37 Fix discord webhooks (#2412) 2026-06-23 15:40:14 -04:00
JoanFo
a9cb16e042 Passkeys (#2192)
Co-authored-by: Lance Pioch <git@lance.sh>
2026-06-22 23:59:07 -04:00
248 changed files with 4041 additions and 2550 deletions

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,9 +37,7 @@ class CheckEggUpdatesCommand extends Command
$extension = strtolower(pathinfo(parse_url($egg->update_url, PHP_URL_PATH), PATHINFO_EXTENSION));
if (empty($extension)) {
throw new Exception('Unsupported file format.');
}
throw_if(empty($extension), new Exception('Unsupported file format.'));
$isYaml = in_array($extension, ['yaml', 'yml']);
@@ -49,9 +47,7 @@ class CheckEggUpdatesCommand extends Command
$remote = Http::timeout(5)->connectTimeout(1)->get($egg->update_url);
if ($remote->failed()) {
throw new Exception("HTTP request returned status code {$remote->status()}");
}
throw_if($remote->failed(), 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);
return preg_replace('/^(.*\.)(?:yml|json|yaml)$/', '$1yaml', $path) ?? $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,9 +16,7 @@ class PruneOrphanedBackupsCommand extends Command
public function handle(): void
{
$since = $this->option('prune-age') ?? config('backups.prune_age', 360);
if (!$since || !is_digit($since)) {
throw new InvalidArgumentException('The "--prune-age" argument must be a value greater than 0.');
}
throw_if(!$since || !is_digit($since), new InvalidArgumentException('The "--prune-age" argument must be a value greater than 0.'));
$query = Backup::query()
->whereNull('completed_at')

View File

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

View File

@@ -0,0 +1,23 @@
<?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

@@ -0,0 +1,35 @@
<?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

@@ -0,0 +1,14 @@
<?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

@@ -0,0 +1,207 @@
<?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

@@ -0,0 +1,66 @@
<?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

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

View File

@@ -1,38 +0,0 @@
<?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 env('OAUTH_AUTHENTIK_DISPLAY_NAME', 'Authentik');
return (string) env('OAUTH_AUTHENTIK_DISPLAY_NAME', 'Authentik');
}
public function getHexColor(): string
{
return env('OAUTH_AUTHENTIK_DISPLAY_COLOR', '#fd4b2d');
return (string) env('OAUTH_AUTHENTIK_DISPLAY_COLOR', '#fd4b2d');
}
}

View File

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

View File

@@ -44,4 +44,17 @@ 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 $backupService) {}
public function __construct(private InitiateBackupService $initiateService) {}
public function getId(): string
{
@@ -17,7 +17,7 @@ final class CreateBackupSchema extends TaskSchema
public function runTask(Task $task): void
{
$this->backupService->setIgnoredFiles(explode(PHP_EOL, $task->payload))->handle($task->server, null, true);
$this->initiateService->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');
return user()?->can('view health') ?? false;
}
protected function getActions(): array

View File

@@ -6,16 +6,22 @@ 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
{
@@ -31,6 +37,12 @@ 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)
@@ -50,7 +62,9 @@ class ListLogs extends BaseListLogs
ViewLogAction::make()
->icon(TablerIcon::FileDescription)->iconButton(),
DownloadAction::make()
->tooltip(fn ($record) => trans('filament-log-viewer::log.table.actions.download.label', ['log' => ParseDateUseCase::execute($record['date'])]))
->tooltip(function (array $record): string {
return trans('filament-log-viewer::log.table.actions.download.label', ['log' => ParseDateUseCase::execute((string) ($record['date'] ?? ''))]);
})
->icon(TablerIcon::FileDownload)->iconButton(),
Action::make('uploadLogs')
->hiddenLabel()
@@ -59,15 +73,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 ($record) {
->action(function (array $record): void {
$prefix = config('filament-log-viewer.pattern.prefix', 'laravel-');
$extension = config('filament-log-viewer.pattern.extension', '.log');
$logPath = storage_path('logs/' . $prefix . $record['date'] . $extension);
$logPath = storage_path('logs/' . $prefix . (string) ($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' => $record['date']]))
->body(trans('admin/log.actions.log_not_found_description', ['filename' => (string) ($record['date'] ?? '')]))
->danger()
->send();
@@ -75,9 +89,9 @@ class ListLogs extends BaseListLogs
}
$lines = file($logPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
$totalLines = count($lines);
$uploadLines = $totalLines <= 1000 ? $lines : array_slice($lines, -1000);
$content = implode("\n", $uploadLines);
$totalLines = is_array($lines) ? count($lines) : 0;
$uploadLines = $totalLines <= 1000 ? ($lines ?: []) : array_slice($lines ?: [], -1000);
$content = implode("\n", (array) $uploadLines);
try {
$response = Http::timeout(10)
@@ -97,7 +111,7 @@ class ListLogs extends BaseListLogs
}
$data = $response->json();
$url = $data['url'];
$url = (string) ($data['url'] ?? '');
Notification::make()
->title(trans('admin/log.actions.log_upload'))
@@ -126,4 +140,50 @@ 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,7 +6,6 @@ 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;
@@ -92,7 +91,7 @@ class Settings extends Page implements HasSchemas
public static function canAccess(): bool
{
return user()?->can('view settings');
return user()?->can('view settings') ?? false;
}
public function getTitle(): string
@@ -492,16 +491,6 @@ 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()
@@ -521,41 +510,6 @@ 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

@@ -0,0 +1,162 @@
<?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

@@ -0,0 +1,18 @@
<?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

@@ -0,0 +1,43 @@
<?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

@@ -0,0 +1,16 @@
<?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

@@ -0,0 +1,27 @@
<?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

@@ -0,0 +1,42 @@
<?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

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

View File

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

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);
InstallPlugin::dispatch(user(), $plugin->id);
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);
UpdatePlugin::dispatch(user(), $plugin->id);
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);
UninstallPlugin::dispatch(user(), $plugin->id);
Notification::make()
->success()
@@ -265,9 +265,7 @@ class PluginResource extends Resource
$pluginName = str($file->getClientOriginalName())->basename()->before('.zip')->toString();
if (Plugin::where('id', $pluginName)->exists()) {
throw new Exception(trans('admin/plugin.notifications.import_exists'));
}
throw_if(Plugin::where('id', $pluginName)->exists(), new Exception(trans('admin/plugin.notifications.import_exists')));
$pluginService->downloadPluginFromFile($file);
@@ -304,9 +302,7 @@ class PluginResource extends Resource
try {
$pluginName = str($data['url'])->before('.zip')->explode('/')->last();
if (Plugin::where('id', $pluginName)->exists()) {
throw new Exception(trans('admin/plugin.notifications.import_exists'));
}
throw_if(Plugin::where('id', $pluginName)->exists(), new Exception(trans('admin/plugin.notifications.import_exists')));
$pluginService->downloadPluginFromUrl($data['url']);

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)
)->first();
)->firstOrFail();
}),
Repeater::make('allocation_additional')
->label(trans('admin/server.additional_allocations'))

View File

@@ -4,6 +4,8 @@ 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;
@@ -13,7 +15,6 @@ 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;
@@ -390,7 +391,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) => !empty($get('threads')))
->formatStateUsing(fn (Get $get) => filled($get('threads')))
->live()
->stateCast(new BooleanStateCast(false, true))
->options([
@@ -850,16 +851,20 @@ 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, Server $server, $data) {
->action(function (TransferServerService $transfer, BackupAdapterService $backupService, 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)
->where('disk', Backup::ADAPTER_DAEMON)
->each(function ($backup) {
$backup->delete();
->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();
}
});
Notification::make()
@@ -951,17 +956,17 @@ class EditServer extends EditRecord
->placeholder(trans('admin/server.select_additional')),
Grid::make()
->columnSpanFull()
->schema([
->schema(fn (BackupAdapterService $backupService) => [
CheckboxList::make('backups')
->label(trans('admin/server.backups'))
->bulkToggleable()
->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)),
->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)),
Text::make('backup_helper')
->columnSpanFull()
->content(trans('admin/server.warning_backups')),
])
->hidden(fn (Server $server) => $server->backups->where('disk', Backup::ADAPTER_DAEMON)->count() === 0),
->hidden(fn (Server $server, BackupAdapterService $backupService) => $server->backups->filter(fn ($backup) => $backupService->get($backup->backupHost->schema) instanceof WingsBackupSchema)->count() === 0),
];
}

View File

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

View File

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

View File

@@ -64,6 +64,8 @@ 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'),
@@ -74,7 +76,7 @@ class CreateWebhookConfiguration extends CreateRecord
'embeds' => $embeds,
'thread_name' => data_get($data, 'thread_name'),
'flags' => $flags,
'allowed_mentions' => data_get($data, 'allowed_mentions', []),
'allowed_mentions' => $allowedMentions,
])->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,6 +56,8 @@ 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'),
@@ -66,7 +68,7 @@ class EditWebhookConfiguration extends EditRecord
'embeds' => $embeds,
'thread_name' => data_get($data, 'thread_name'),
'flags' => $flags,
'allowed_mentions' => data_get($data, 'allowed_mentions', []),
'allowed_mentions' => $allowedMentions,
])->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']);
@@ -110,7 +112,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'),
'allowed_mentions' => data_get($data, 'payload.allowed_mentions.parse'),
])->filter(fn ($key) => !empty($key))->all();
unset($data['payload'], $data['created_at'], $data['updated_at'], $data['deleted_at']);

View File

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

View File

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

View File

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

View File

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

View File

@@ -39,10 +39,12 @@ use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Tabs;
use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\View;
use Filament\Schemas\Schema;
use Filament\Support\Colors\Color;
use Filament\Support\Enums\Width;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\HtmlString;
@@ -61,9 +63,12 @@ class EditProfile extends BaseEditProfile
protected OAuthService $oauthService;
public function boot(OAuthService $oauthService): void
protected Request $request;
public function boot(OAuthService $oauthService, Request $request): void
{
$this->oauthService = $oauthService;
$this->request = $request;
}
public function getMaxWidth(): Width|string
@@ -234,6 +239,17 @@ class EditProfile extends BaseEditProfile
->map(fn (MultiFactorAuthenticationProvider $multiFactorAuthenticationProvider) => Group::make($multiFactorAuthenticationProvider->getManagementSchemaComponents())
->statePath($multiFactorAuthenticationProvider->getId()))
->all()),
Tab::make('passkeys')
->label(trans('profile.tabs.passkeys'))
->icon(TablerIcon::Fingerprint)
->hidden(fn () => !$this->request->isSecure())
->schema([
Section::make(trans('profile.tabs.passkeys'))
->description(trans('passkeys.description'))
->schema([
View::make('passkeys.livewire.passkeys-tab'),
]),
]),
Tab::make('api_keys')
->label(trans('profile.tabs.api_keys'))
->icon(TablerIcon::Key)
@@ -432,7 +448,7 @@ class EditProfile extends BaseEditProfile
->deletable(false)
->addable(false)
->relationship(null, function (Builder $query) {
$query->orderBy('timestamp', 'desc')->limit(50);
$query->orderByDesc('timestamp')->limit(50);
})
->schema([
TextEntry::make('log')

View File

@@ -2,6 +2,7 @@
namespace App\Filament\Pages\Auth;
use App\Enums\TablerIcon;
use App\Extensions\Captcha\CaptchaService;
use App\Extensions\OAuth\OAuthService;
use BladeUI\Icons\Exceptions\SvgNotFound;
@@ -9,11 +10,13 @@ use BladeUI\Icons\Factory as IconFactory;
use Filament\Actions\Action;
use Filament\Auth\Pages\Login as BaseLogin;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Schemas\Components\Actions;
use Filament\Schemas\Components\Component;
use Filament\Schemas\Schema;
use Filament\Support\Colors\Color;
use Filament\Support\Enums\Alignment;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
class Login extends BaseLogin
@@ -24,11 +27,23 @@ class Login extends BaseLogin
protected IconFactory $iconFactory;
public function boot(OAuthService $oauthService, CaptchaService $captchaService, IconFactory $iconFactory): void
protected Request $request;
public function boot(OAuthService $oauthService, CaptchaService $captchaService, IconFactory $iconFactory, Request $request): void
{
$this->oauthService = $oauthService;
$this->captchaService = $captchaService;
$this->iconFactory = $iconFactory;
$this->request = $request;
}
public function mount(): void
{
parent::mount();
if ($message = session()->pull('authenticatePasskey::message')) {
Notification::make()->title($message)->danger()->send();
}
}
public function form(Schema $schema): Schema
@@ -36,6 +51,7 @@ class Login extends BaseLogin
if (config('auth.disable_password_login', false)) {
return $schema->components([
$this->getOAuthFormComponent(),
$this->getPasskeyFormComponent(),
]);
}
@@ -44,6 +60,7 @@ class Login extends BaseLogin
$this->getPasswordFormComponent(),
$this->getRememberFormComponent(),
$this->getOAuthFormComponent(),
$this->getPasskeyFormComponent(),
];
if ($captchaComponent = $this->getCaptchaComponent()) {
@@ -51,8 +68,7 @@ class Login extends BaseLogin
->hidden(fn () => filled($this->userUndertakingMultiFactorAuthentication));
}
return $schema
->components($components);
return $schema->components($components);
}
private function getCaptchaComponent(): ?Component
@@ -60,6 +76,18 @@ class Login extends BaseLogin
return $this->captchaService->getActiveSchema()?->getFormComponent();
}
protected function getPasskeyFormComponent(): Component
{
return Actions::make([
Action::make('passkey')
->label(trans('passkeys.authenticate_using_passkey'))
->icon(TablerIcon::Key->value)
->color('gray')
->alpineClickHandler('window.authenticateWithPasskey()')
->extraAttributes(['type' => 'button']),
])->fullWidth()->hidden(fn () => !$this->request->isSecure());
}
protected function throwFailureValidationException(): never
{
$this->dispatch('reset-captcha');

View File

@@ -133,7 +133,7 @@ class ActivityResource extends Resource
])
->filters([
SelectFilter::make('event')
->options(fn (Table $table) => $table->getQuery()->pluck('event', 'event')->unique()->sort())
->options(fn (Table $table) => $table->getQuery()->select('event')->distinct()->orderBy('event')->pluck('event', 'event'))
->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, Request $request) => $downloadLinkService->handle($backup, $request->user()), true)
->url(fn (DownloadLinkService $downloadLinkService, Backup $backup) => $downloadLinkService->handle($backup, user()), true)
->visible(fn (Backup $backup) => $backup->status === BackupStatus::Successful),
Action::make('exclude_restore')
->label(trans('server/backup.actions.restore.title'))
@@ -207,17 +207,13 @@ class BackupResource extends Resource
->property(['name' => $backup->name, 'truncate' => $data['truncate']]);
$log->transaction(function () use ($downloadLinkService, $daemonRepository, $backup, $server, $data) {
// 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());
}
$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 ?? null, $data['truncate']);
$daemonRepository->setServer($server)->restore($backup, $url, $data['truncate']);
});
return Notification::make()

View File

@@ -632,9 +632,7 @@ class ListFiles extends ListRecords
/** @var Server $server */
$server = Filament::getTenant();
if (!user()?->can(SubuserPermission::FileCreate, $server)) {
abort(403, 'You do not have permission to create folders.');
}
abort_unless(user()?->can(SubuserPermission::FileCreate, $server), 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(__('filament-panels::resources/pages/create-record.form.actions.create.label'))
->label(trans('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

@@ -21,7 +21,6 @@ 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;
@@ -45,6 +44,7 @@ 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;

View File

@@ -47,9 +47,7 @@ class ServerConsole extends Widget
protected function getToken(): string
{
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.');
}
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.'));
$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,9 +115,7 @@ class MountController extends ApplicationApiController
*/
public function delete(DeleteMountRequest $request, Mount $mount): JsonResponse
{
if ($mount->servers()->count() > 0) {
throw new HasActiveServersException(trans('exceptions.mount.servers_attached'));
}
throw_if($mount->servers()->count() > 0, new HasActiveServersException(trans('exceptions.mount.servers_attached')));
$mount->delete();

View File

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

View File

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

View File

@@ -75,9 +75,7 @@ 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.
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.');
}
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.'));
$original = $user->email;

View File

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

View File

@@ -4,6 +4,7 @@ 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;
@@ -33,6 +34,7 @@ class BackupController extends ClientApiController
private readonly DeleteBackupService $deleteBackupService,
private readonly InitiateBackupService $initiateBackupService,
private readonly DownloadLinkService $downloadLinkService,
private readonly BackupAdapterService $backupService
) {
parent::__construct();
}
@@ -48,9 +50,7 @@ class BackupController extends ClientApiController
*/
public function index(Request $request, Server $server): array
{
if (!$request->user()->can(SubuserPermission::BackupRead, $server)) {
throw new AuthorizationException();
}
throw_unless($request->user()->can(SubuserPermission::BackupRead, $server), new AuthorizationException());
$limit = min($request->query('per_page') ?? 20, 50);
@@ -116,9 +116,7 @@ class BackupController extends ClientApiController
*/
public function toggleLock(Request $request, Server $server, Backup $backup): array
{
if (!$request->user()->can(SubuserPermission::BackupDelete, $server)) {
throw new AuthorizationException();
}
throw_unless($request->user()->can(SubuserPermission::BackupDelete, $server), new AuthorizationException());
$action = $backup->is_locked ? 'server:backup.unlock' : 'server:backup.lock';
@@ -142,9 +140,7 @@ class BackupController extends ClientApiController
*/
public function view(Request $request, Server $server, Backup $backup): array
{
if (!$request->user()->can(SubuserPermission::BackupRead, $server)) {
throw new AuthorizationException();
}
throw_unless($request->user()->can(SubuserPermission::BackupRead, $server), new AuthorizationException());
return $this->fractal->item($backup)
->transformWith($this->getTransformer(BackupTransformer::class))
@@ -161,9 +157,7 @@ class BackupController extends ClientApiController
*/
public function delete(Request $request, Server $server, Backup $backup): JsonResponse
{
if (!$request->user()->can(SubuserPermission::BackupDelete, $server)) {
throw new AuthorizationException();
}
throw_unless($request->user()->can(SubuserPermission::BackupDelete, $server), new AuthorizationException());
$this->deleteBackupService->handle($backup);
@@ -187,11 +181,10 @@ class BackupController extends ClientApiController
*/
public function download(Request $request, Server $server, Backup $backup): JsonResponse
{
if (!$request->user()->can(SubuserPermission::BackupDownload, $server)) {
throw new AuthorizationException();
}
throw_unless($request->user()->can(SubuserPermission::BackupDownload, $server), new AuthorizationException());
if ($backup->disk !== Backup::ADAPTER_AWS_S3 && $backup->disk !== Backup::ADAPTER_DAEMON) {
$schema = $this->backupService->get($backup->backupHost->schema);
if (!$schema) {
throw new BadRequestHttpException('The backup requested references an unknown disk driver type and cannot be downloaded.');
}
@@ -251,30 +244,22 @@ class BackupController extends ClientApiController
{
// Cannot restore a backup unless a server is fully installed and not currently
// processing a different backup restoration request.
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_unless(is_null($server->status), new BadRequestHttpException('This server is not currently in a state that allows for a backup to be restored.'));
if (!$backup->is_successful && is_null($backup->completed_at)) {
throw new BadRequestHttpException('This backup cannot be restored at this time: not completed or failed.');
}
throw_if(!$backup->is_successful && is_null($backup->completed_at), 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) {
// 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());
}
$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 ?? null, $request->input('truncate'));
$this->daemonRepository->setServer($server)->restore($backup, $url, $request->input('truncate'));
});
return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT);

View File

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

View File

@@ -110,9 +110,7 @@ class NetworkAllocationController extends ClientApiController
$allocation = Activity::event('server:allocation.create')->transaction(function ($log) use ($server) {
$server->allocations()->lockForUpdate();
if ($server->allocations->count() >= $server->allocation_limit) {
throw new DisplayException('Cannot assign additional allocations to this server: limit has been reached.');
}
throw_if($server->allocations->count() >= $server->allocation_limit, new DisplayException('Cannot assign additional allocations to this server: limit has been reached.'));
$allocation = $this->assignableAllocationService->handle($server);
@@ -137,9 +135,7 @@ class NetworkAllocationController extends ClientApiController
{
// Don't allow the deletion of allocations if the server does not have an
// allocation limit set.
if (empty($server->allocation_limit)) {
throw new DisplayException('You cannot delete allocations for this server: no allocation limit is set.');
}
throw_if(empty($server->allocation_limit), 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,9 +97,7 @@ class ScheduleController extends ClientApiController
*/
public function view(ViewScheduleRequest $request, Server $server, Schedule $schedule): array
{
if ($schedule->server_id !== $server->id) {
throw new NotFoundHttpException();
}
throw_if($schedule->server_id !== $server->id, new NotFoundHttpException());
$schedule->loadMissing('tasks');

View File

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

View File

@@ -96,9 +96,7 @@ class SettingsController extends ClientApiController
*/
public function dockerImage(SetDockerImageRequest $request, Server $server): JsonResponse
{
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.');
}
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.'));
$original = $server->image;
$server->forceFill(['image' => $request->input('docker_image')])->saveOrFail();

View File

@@ -64,11 +64,9 @@ class StartupController extends ClientApiController
{
$variable = $server->variables()->where('env_variable', $request->input('key'))->first();
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.');
}
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.'));
$original = $variable->server_value;

View File

@@ -38,18 +38,14 @@ class WebsocketController extends ClientApiController
public function __invoke(ClientApiRequest $request, Server $server): JsonResponse
{
$user = $request->user();
if ($user->cannot(SubuserPermission::WebsocketConnect, $server)) {
throw new HttpForbiddenException('You do not have permission to connect to this server\'s websocket.');
}
throw_if($user->cannot(SubuserPermission::WebsocketConnect, $server), 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.
if (!in_array('admin.websocket.transfer', $permissions)) {
throw new HttpForbiddenException('You do not have permission to view server transfer logs.');
}
throw_unless(in_array('admin.websocket.transfer', $permissions), 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,20 +3,16 @@
namespace App\Http\Controllers\Api\Remote\Backups;
use App\Exceptions\Http\HttpForbiddenException;
use App\Extensions\Backups\BackupManager;
use App\Extensions\Filesystem\S3Filesystem;
use App\Extensions\BackupAdapter\BackupAdapterService;
use App\Extensions\BackupAdapter\Schemas\S3BackupSchema;
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
{
@@ -25,117 +21,43 @@ class BackupRemoteUploadController extends Controller
/**
* BackupRemoteUploadController constructor.
*/
public function __construct(private BackupManager $backupManager) {}
public function __construct(private BackupAdapterService $backupService) {}
/**
* Returns the required presigned urls to upload a backup to S3 cloud storage.
*
* @throws Exception
* @throws Throwable
* @throws BadRequestHttpException
* @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');
if (empty($size)) {
throw new BadRequestHttpException('A non-empty "size" query parameter must be provided.');
}
throw_if(empty($size), new BadRequestHttpException('A non-empty "size" query parameter must be provided.'));
/** @var Backup $model */
$model = Backup::query()
->where('uuid', $backup)
->firstOrFail();
$backup = Backup::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.
/** @var Server $server */
$server = $model->server;
if ($server->node_id !== $node->id) {
if ($backup->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($model->completed_at)) {
// Prevent backups that have already been completed from trying to be uploaded again.
if (!is_null($backup->completed_at)) {
throw new ConflictHttpException('This backup is already in a completed state.');
}
// 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.');
// 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.');
}
// 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;
return new JsonResponse($schema->getUploadParts($backup, $size));
}
}

View File

@@ -3,10 +3,9 @@
namespace App\Http\Controllers\Api\Remote\Backups;
use App\Events\Server\BackupCompleted;
use App\Exceptions\DisplayException;
use App\Exceptions\Http\HttpForbiddenException;
use App\Extensions\Backups\BackupManager;
use App\Extensions\Filesystem\S3Filesystem;
use App\Extensions\BackupAdapter\BackupAdapterService;
use App\Extensions\BackupAdapter\Schemas\S3BackupSchema;
use App\Facades\Activity;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\Remote\ReportBackupCompleteRequest;
@@ -14,7 +13,6 @@ 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;
@@ -25,7 +23,7 @@ class BackupStatusController extends Controller
/**
* BackupStatusController constructor.
*/
public function __construct(private BackupManager $backupManager) {}
public function __construct(private BackupAdapterService $backupService) {}
/**
* Handles updating the state of a backup.
@@ -47,13 +45,9 @@ class BackupStatusController extends Controller
// from messing with backups that they don't own.
/** @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.');
}
throw_if($server->node_id !== $node->id, new HttpForbiddenException('Requesting node does not have permission to access this server.'));
if ($model->is_successful) {
throw new BadRequestHttpException('Cannot update the status of a backup that is already marked as completed.');
}
throw_if($model->is_successful, 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);
@@ -74,9 +68,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.
$adapter = $this->backupManager->adapter();
if ($adapter instanceof S3Filesystem) {
$this->completeMultipartUpload($model, $adapter, $successful, $request->input('parts'));
$schema = $this->backupService->get($model->backupHost->schema);
if ($schema instanceof S3BackupSchema) {
$schema->completeMultipartUpload($model, $successful, $request->input('parts'));
}
});
@@ -101,9 +95,7 @@ class BackupStatusController extends Controller
$model = Backup::query()->where('uuid', $backup)->firstOrFail();
$node = $request->attributes->get('node');
if (!$model->server->node->is($node)) {
throw new HttpForbiddenException('Requesting node does not have permission to access this server.');
}
throw_unless($model->server->node->is($node), new HttpForbiddenException('Requesting node does not have permission to access this server.'));
$model->server->update(['status' => null]);
@@ -114,59 +106,4 @@ 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,9 +16,7 @@ class ServerContainersController extends Controller
*/
public function status(Request $request, Server $server): JsonResponse
{
if (!$server->node->is($request->attributes->get('node'))) {
throw new HttpForbiddenException('Requesting node does not have permission to access this server.');
}
throw_unless($server->node->is($request->attributes->get('node')), 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,9 +45,7 @@ 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;
if (!$valid) {
throw new HttpForbiddenException('Requesting node does not have permission to access this server.');
}
throw_unless($valid, new HttpForbiddenException('Requesting node does not have permission to access this server.'));
return new JsonResponse([
'settings' => $this->configurationStructureService->handle($server),

View File

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

View File

@@ -35,18 +35,14 @@ class ServerTransferController extends Controller
public function failure(Request $request, Server $server): JsonResponse
{
$transfer = $server->transfer;
if (is_null($transfer)) {
throw new ConflictHttpException('Server is not being transferred.');
}
throw_if(is_null($transfer), 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.
if (!$node->is($transfer->newNode) && !$node->is($transfer->oldNode)) {
throw new HttpForbiddenException('Requesting node does not have permission to access this server.');
}
throw_if(!$node->is($transfer->newNode) && !$node->is($transfer->oldNode), new HttpForbiddenException('Requesting node does not have permission to access this server.'));
$this->connection->transaction(function () use ($transfer) {
$transfer->forceFill(['successful' => false])->saveOrFail();
@@ -68,18 +64,14 @@ class ServerTransferController extends Controller
public function success(Request $request, Server $server): JsonResponse
{
$transfer = $server->transfer;
if (is_null($transfer)) {
throw new ConflictHttpException('Server is not being transferred.');
}
throw_if(is_null($transfer), 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.
if (!$node->is($transfer->newNode)) {
throw new HttpForbiddenException('Requesting node does not have permission to access this server.');
}
throw_unless($node->is($transfer->newNode), 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,9 +32,7 @@ class SftpAuthenticationController extends Controller
public function __invoke(SftpAuthenticationFormRequest $request): JsonResponse
{
$connection = $this->parseUsername($request->input('username'));
if (empty($connection['server'])) {
throw new BadRequestHttpException('No valid server identifier was included in the request.');
}
throw_if(empty($connection['server']), new BadRequestHttpException('No valid server identifier was included in the request.'));
if ($this->hasTooManyLoginAttempts($request)) {
$seconds = $this->limiter()->availableIn($this->throttleKey($request));
@@ -124,7 +122,7 @@ class SftpAuthenticationController extends Controller
/**
* Rejects the request and increments the login attempts.
*/
protected function reject(Request $request, bool $increment = true): void
protected function reject(Request $request, bool $increment = true): never
{
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->get('error')) {
report($request->get('error_description') ?? $request->get('error'));
if ($request->input('error')) {
report($request->input('error_description') ?? $request->input('error'));
return $this->errorRedirect($request->get('error'));
return $this->errorRedirect($request->input('error'));
}
$oauthUser = Socialite::driver($driver->getId())->user();
@@ -77,6 +77,10 @@ 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)) {

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class AddPasskeyErrorMessage
{
/**
* The @laravel/passkeys browser 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. Mirror
* the first error's detail into "message" so passkey failures surface a
* useful reason instead of "Request failed with status 4xx".
*/
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
if ($response instanceof JsonResponse && ($response->isClientError() || $response->isServerError())) {
$data = $response->getData(true);
if (is_array($data) && !isset($data['message']) && isset($data['errors'][0]['detail'])) {
$data['message'] = $data['errors'][0]['detail'];
$response->setData($data);
}
}
return $response;
}
}

View File

@@ -17,9 +17,7 @@ class AuthenticateApplicationUser
{
/** @var User|null $user */
$user = $request->user();
if (!$user || !$user->isAdmin()) {
throw new AccessDeniedHttpException('This account does not have permission to access the API.');
}
throw_if(!$user || !$user->isAdmin(), new AccessDeniedHttpException('This account does not have permission to access the API.'));
return $next($request);
}

View File

@@ -17,9 +17,7 @@ class RequireClientApiKey
{
$token = $request->user()->currentAccessToken();
if ($token instanceof ApiKey && $token->key_type === ApiKey::TYPE_APPLICATION) {
throw new AccessDeniedHttpException('You are attempting to use an application API key on an endpoint that requires a client API key.');
}
throw_if($token instanceof ApiKey && $token->key_type === ApiKey::TYPE_APPLICATION, new AccessDeniedHttpException('You are attempting to use an application API key on an endpoint that requires a client API key.'));
return $next($request);
}

View File

@@ -34,18 +34,14 @@ class AuthenticateServerAccess
$user = $request->user();
$server = $request->route()->parameter('server');
if (!$server instanceof Server) {
throw new NotFoundHttpException(trans('exceptions.api.resource_not_found'));
}
throw_unless($server instanceof Server, new NotFoundHttpException(trans('exceptions.api.resource_not_found')));
// At the very least, ensure that the user trying to make this request is the
// server owner, a subuser, or an admin. We'll leave it up to the controllers
// to authenticate more detailed permissions if needed.
if ($user->id !== $server->owner_id && $user->cannot('update server', $server)) {
// Check for subuser status.
if (!$server->subusers->contains('user_id', $user->id)) {
throw new NotFoundHttpException(trans('exceptions.api.resource_not_found'));
}
throw_unless($server->subusers->contains('user_id', $user->id), new NotFoundHttpException(trans('exceptions.api.resource_not_found')));
}
try {
@@ -54,12 +50,8 @@ class AuthenticateServerAccess
// Still allow users to get information about their server if it is installing or
// being transferred.
if (!$request->routeIs('api:client:server.view')) {
if (($server->isSuspended() || $server->node->isUnderMaintenance()) && !$request->routeIs('api:client:server.resources')) {
throw $exception;
}
if ($user->cannot('update server', $server) || !$request->routeIs($this->except)) {
throw $exception;
}
throw_if(($server->isSuspended() || $server->node->isUnderMaintenance()) && !$request->routeIs('api:client:server.resources'), $exception);
throw_if($user->cannot('update server', $server) || !$request->routeIs($this->except), $exception);
}
}

View File

@@ -31,9 +31,7 @@ class ResourceBelongsToServer
$params = $request->route()->parameters();
$server = $params['server'] ?? null;
if (!$server instanceof Server) {
throw new InvalidArgumentException('This middleware cannot be used in a context that is missing a server in the parameters.');
}
throw_unless($server instanceof Server, new InvalidArgumentException('This middleware cannot be used in a context that is missing a server in the parameters.'));
/** @var Server $server */
$server = $request->route()->parameter('server');
@@ -55,17 +53,13 @@ class ResourceBelongsToServer
case Database::class:
case Schedule::class:
case Subuser::class:
if ($model->server_id !== $server->id) {
throw $exception;
}
throw_if($model->server_id !== $server->id, $exception);
break;
// Regular users are a special case here as we need to make sure they're
// currently assigned as a subuser on the server.
case User::class:
$subuser = $server->subusers()->where('user_id', $model->id)->first();
if (is_null($subuser)) {
throw $exception;
}
throw_if(is_null($subuser), $exception);
// This is a special case to avoid an additional query being triggered
// in the underlying logic.
$request->attributes->set('subuser', $subuser);
@@ -75,9 +69,7 @@ class ResourceBelongsToServer
case Task::class:
/** @var Schedule $schedule */
$schedule = $request->route()->parameter('schedule');
if ($model->schedule_id !== $schedule->id || $schedule->server_id !== $server->id) {
throw $exception;
}
throw_if($model->schedule_id !== $schedule->id || $schedule->server_id !== $server->id, $exception);
break;
default:
// Don't return a 404 here since we want to make sure no one relies

View File

@@ -31,15 +31,11 @@ class DaemonAuthenticate
return $next($request);
}
if (is_null($bearer = $request->bearerToken())) {
throw new HttpException(401, 'Access to this endpoint must include an Authorization header.', null, ['WWW-Authenticate' => 'Bearer']);
}
throw_if(is_null($bearer = $request->bearerToken()), new HttpException(401, 'Access to this endpoint must include an Authorization header.', null, ['WWW-Authenticate' => 'Bearer']));
$parts = explode('.', $bearer);
// Ensure that all the correct parts are provided in the header.
if (count($parts) !== 2 || empty($parts[0]) || empty($parts[1])) {
throw new BadRequestHttpException('The Authorization header provided was not in a valid format.');
}
throw_if(count($parts) !== 2 || empty($parts[0]) || empty($parts[1]), new BadRequestHttpException('The Authorization header provided was not in a valid format.'));
/** @var Node $node */
$node = Node::query()->where('daemon_token_id', $parts[0])->firstOrFail();

View File

@@ -49,9 +49,7 @@ class RequireTwoFactorAuthentication
}
// For API calls return an exception which gets rendered nicely in the API response...
if ($request->isJson() || Str::startsWith($request->path(), '/api')) {
throw new TwoFactorAuthRequiredException();
}
throw_if($request->isJson() || Str::startsWith($request->path(), '/api'), new TwoFactorAuthRequiredException());
// ... otherwise display banner and redirect to profile
AlertBanner::make('2fa_must_be_enabled')

View File

@@ -5,6 +5,7 @@ namespace App\Http\Requests\Api\Application;
use App\Exceptions\PanelException;
use App\Models\ApiKey;
use App\Services\Acl\Api\AdminAcl;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Validator;
@@ -33,9 +34,7 @@ abstract class ApplicationApiRequest extends FormRequest
*/
public function authorize(): bool
{
if (is_null($this->resource)) {
throw new PanelException('An ACL resource must be defined on API requests.');
}
throw_if(is_null($this->resource), new PanelException('An ACL resource must be defined on API requests.'));
/** @var TransientToken|ApiKey $token */
$token = $this->user()->currentAccessToken();
@@ -55,7 +54,7 @@ abstract class ApplicationApiRequest extends FormRequest
return AdminAcl::check($token, $this->resource, $this->permission);
}
/** @return array<string, string|string[]> */
/** @return array<string, string|array<string|\Stringable|ValidationRule>> */
public function rules(): array
{
return [];

View File

@@ -5,6 +5,7 @@ namespace App\Http\Requests\Api\Application\DatabaseHosts;
use App\Http\Requests\Api\Application\ApplicationApiRequest;
use App\Models\DatabaseHost;
use App\Services\Acl\Api\AdminAcl;
use Illuminate\Contracts\Validation\ValidationRule;
class StoreDatabaseHostRequest extends ApplicationApiRequest
{
@@ -13,8 +14,8 @@ class StoreDatabaseHostRequest extends ApplicationApiRequest
protected int $permission = AdminAcl::WRITE;
/**
* @param array<string, string|string[]>|null $rules
* @return array<string, string|string[]>
* @param array<string, string|array<string|\Stringable|ValidationRule>>|null $rules
* @return array<string, string|array<string|\Stringable|ValidationRule>>
*/
public function rules(?array $rules = null): array
{

View File

@@ -3,10 +3,11 @@
namespace App\Http\Requests\Api\Application\DatabaseHosts;
use App\Models\DatabaseHost;
use Illuminate\Contracts\Validation\ValidationRule;
class UpdateDatabaseHostRequest extends StoreDatabaseHostRequest
{
/** @return array<array-key, string|string[]> */
/** @return array<string, string|array<string|\Stringable|ValidationRule>> */
public function rules(?array $rules = null): array
{
/** @var DatabaseHost $databaseHost */

View File

@@ -3,12 +3,13 @@
namespace App\Http\Requests\Api\Application\Mounts;
use App\Models\Mount;
use Illuminate\Contracts\Validation\ValidationRule;
class UpdateMountRequest extends StoreMountRequest
{
/**
* @param array<string, string|string[]>|null $rules
* @return array<string, string|string[]>
* @param array<string, string|array<string|\Stringable|ValidationRule>>|null $rules
* @return array<string, string|array<string|\Stringable|ValidationRule>>
*/
public function rules(?array $rules = null): array
{

View File

@@ -5,6 +5,7 @@ namespace App\Http\Requests\Api\Client\Account;
use App\Http\Requests\Api\Client\ClientApiRequest;
use App\Models\UserSSHKey;
use Exception;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Validation\Validator;
use phpseclib3\Crypt\Common\PublicKey;
use phpseclib3\Crypt\DSA;
@@ -16,7 +17,7 @@ class StoreSSHKeyRequest extends ClientApiRequest
{
protected ?PublicKey $key;
/** @return array<string, string|string[]> */
/** @return array<string, string|array<string|\Stringable|ValidationRule>> */
public function rules(): array
{
return [
@@ -68,9 +69,7 @@ class StoreSSHKeyRequest extends ClientApiRequest
*/
public function getKeyFingerprint(): string
{
if (!$this->key) {
throw new Exception('The public key was not properly loaded for this request.');
}
throw_unless($this->key, new Exception('The public key was not properly loaded for this request.'));
return $this->key->getFingerprint('sha256');
}

View File

@@ -22,9 +22,7 @@ class UpdateEmailRequest extends ClientApiRequest
$hasher = Container::getInstance()->make(Hasher::class);
// Verify password matches when changing password or email.
if (!$hasher->check($this->input('password'), $this->user()->password)) {
throw new InvalidPasswordProvidedException(trans('validation.internal.invalid_password'));
}
throw_unless($hasher->check($this->input('password'), $this->user()->password), new InvalidPasswordProvidedException(trans('validation.internal.invalid_password')));
return !$this->user()->is_managed_externally;
}

View File

@@ -21,9 +21,7 @@ class UpdatePasswordRequest extends ClientApiRequest
$hasher = Container::getInstance()->make(Hasher::class);
// Verify password matches when changing password or email.
if (!$hasher->check($this->input('current_password'), $this->user()->password)) {
throw new InvalidPasswordProvidedException(trans('validation.internal.invalid_password'));
}
throw_unless($hasher->check($this->input('current_password'), $this->user()->password), new InvalidPasswordProvidedException(trans('validation.internal.invalid_password')));
return !$this->user()->is_managed_externally;
}

View File

@@ -22,9 +22,7 @@ class UpdateUsernameRequest extends ClientApiRequest
$hasher = Container::getInstance()->make(Hasher::class);
// Verify password matches when changing password or email.
if (!$hasher->check($this->input('password'), $this->user()->password)) {
throw new InvalidPasswordProvidedException(trans('validation.internal.invalid_password'));
}
throw_unless($hasher->check($this->input('password'), $this->user()->password), new InvalidPasswordProvidedException(trans('validation.internal.invalid_password')));
return !$this->user()->is_managed_externally;
}

View File

@@ -28,9 +28,7 @@ class ViewScheduleRequest extends ClientApiRequest
if ($server instanceof Server && $schedule instanceof Schedule) {
$task = $this->route()->parameter('task');
if ($schedule->server_id !== $server->id || ($task instanceof Task && $task->schedule_id !== $schedule->id)) {
throw new NotFoundHttpException('The requested resource does not exist on the system.');
}
throw_if($schedule->server_id !== $server->id || ($task instanceof Task && $task->schedule_id !== $schedule->id), new NotFoundHttpException('The requested resource does not exist on the system.'));
}
return true;

View File

@@ -70,8 +70,6 @@ abstract class SubuserRequest extends ClientApiRequest
/** @var GetUserPermissionsService $service */
$service = $this->container->make(GetUserPermissionsService::class);
if (count(array_diff($permissions, $service->handle($server, $user))) > 0) {
throw new HttpForbiddenException('Cannot assign permissions to a subuser that your account does not actively possess.');
}
throw_if(count(array_diff($permissions, $service->handle($server, $user))) > 0, new HttpForbiddenException('Cannot assign permissions to a subuser that your account does not actively possess.'));
}
}

View File

@@ -20,17 +20,20 @@ class InstallPlugin implements ShouldBeUnique, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(public User $user, public Plugin $plugin) {}
public function __construct(public User $user, public string $pluginId) {}
public function handle(PluginService $pluginService): void
{
try {
$pluginService->installPlugin($this->plugin, !$this->plugin->isTheme() || !$pluginService->hasThemePluginEnabled());
Plugin::refreshRows();
$plugin = Plugin::findOrFail($this->pluginId);
$pluginService->installPlugin($plugin, !$plugin->isTheme() || !$pluginService->hasThemePluginEnabled());
Notification::make()
->success()
->title(trans('admin/plugin.notifications.installed'))
->body($this->plugin->name)
->body($plugin->name)
->actions([
Action::make('goto_plugins')
->label(trans('admin/plugin.notifications.goto_plugins'))
@@ -50,6 +53,6 @@ class InstallPlugin implements ShouldBeUnique, ShouldQueue
public function uniqueId(): string
{
return 'plugin:install:' . $this->plugin->id;
return 'plugin:install:' . $this->pluginId;
}
}

View File

@@ -20,17 +20,20 @@ class UninstallPlugin implements ShouldBeUnique, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(public User $user, public Plugin $plugin) {}
public function __construct(public User $user, public string $pluginId) {}
public function handle(PluginService $pluginService): void
{
try {
$pluginService->uninstallPlugin($this->plugin);
Plugin::refreshRows();
$plugin = Plugin::findOrFail($this->pluginId);
$pluginService->uninstallPlugin($plugin);
Notification::make()
->success()
->title(trans('admin/plugin.notifications.uninstalled'))
->body($this->plugin->name)
->body($plugin->name)
->actions([
Action::make('goto_plugins')
->label(trans('admin/plugin.notifications.goto_plugins'))
@@ -50,6 +53,6 @@ class UninstallPlugin implements ShouldBeUnique, ShouldQueue
public function uniqueId(): string
{
return 'plugin:uninstall:' . $this->plugin->id;
return 'plugin:uninstall:' . $this->pluginId;
}
}

View File

@@ -20,17 +20,20 @@ class UpdatePlugin implements ShouldBeUnique, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(public User $user, public Plugin $plugin) {}
public function __construct(public User $user, public string $pluginId) {}
public function handle(PluginService $pluginService): void
{
try {
$pluginService->updatePlugin($this->plugin);
Plugin::refreshRows();
$plugin = Plugin::findOrFail($this->pluginId);
$pluginService->updatePlugin($plugin);
Notification::make()
->success()
->title(trans('admin/plugin.notifications.updated'))
->body($this->plugin->name)
->body($plugin->name)
->actions([
Action::make('goto_plugins')
->label(trans('admin/plugin.notifications.goto_plugins'))
@@ -50,6 +53,6 @@ class UpdatePlugin implements ShouldBeUnique, ShouldQueue
public function uniqueId(): string
{
return 'plugin:update:' . $this->plugin->id;
return 'plugin:update:' . $this->pluginId;
}
}

View File

@@ -51,6 +51,13 @@ class ProcessWebhook implements ShouldQueue
}
}
$data['embeds'] = $embeds;
if (isset($data['flags'])) {
$data['flags'] &= ~(1 << 2);
}
}
if (isset($data['content']) && $data['content'] === '') {
unset($data['content']);
}
}

View File

@@ -56,17 +56,13 @@ class RunTaskJob implements ShouldQueue
try {
$taskSchema = $taskService->get($this->task->action);
if (!$taskSchema) {
throw new InvalidArgumentException('Invalid task action provided: ' . $this->task->action);
}
throw_unless($taskSchema, new InvalidArgumentException('Invalid task action provided: ' . $this->task->action));
$taskSchema->runTask($this->task);
} catch (Exception $exception) {
// If this isn't a ConnectionException on a task that allows for failures
// throw the exception back up the chain so that the task is stopped.
if (!($this->task->continue_on_failure && $exception instanceof ConnectionException)) {
throw $exception;
}
throw_unless(($this->task->continue_on_failure && $exception instanceof ConnectionException), $exception);
}
$this->markTaskNotQueued();
@@ -89,7 +85,7 @@ class RunTaskJob implements ShouldQueue
{
/** @var Task|null $nextTask */
$nextTask = Task::query()->where('schedule_id', $this->task->schedule_id)
->orderBy('sequence_id', 'asc')
->orderBy('sequence_id')
->where('sequence_id', '>', $this->task->sequence_id)
->first();

View File

@@ -100,6 +100,10 @@ class DispatchWebhooks
{
$eventName = $activityLogged->model->event;
if ($eventName === null) {
return;
}
if (!$activityLogged->isServerEvent()) {
return;
}
@@ -175,17 +179,33 @@ class DispatchWebhooks
protected function handleGlobalWebhooks(ActivityLogged $activityLogged): void
{
$eventName = $activityLogged->model->event;
$activityLoggedClass = ActivityLogged::class;
if (!$this->eventIsWatched($eventName)) {
if ($eventName === null) {
return;
}
$matchingHooks = cache()->rememberForever("webhooks.$eventName", function () use ($eventName) {
return WebhookConfiguration::query()
$matchingHooks = collect();
if ($this->eventIsWatched($eventName)) {
$matchingHooks = $matchingHooks->merge(
cache()->rememberForever("webhooks.$eventName", fn () => WebhookConfiguration::query()
->where('scope', WebhookScope::Global)
->whereJsonContains('events', $eventName)
->get())
);
}
$matchingHooks = $matchingHooks->merge(
cache()->rememberForever("webhooks.$activityLoggedClass", fn () => WebhookConfiguration::query()
->where('scope', WebhookScope::Global)
->whereJsonContains('events', $eventName)
->get();
});
->whereJsonContains('events', $activityLoggedClass)
->get())
)->unique('id');
if ($matchingHooks->isEmpty()) {
return;
}
$webhookData = $this->buildActivityPayload($activityLogged);
@@ -198,8 +218,12 @@ class DispatchWebhooks
}
}
protected function eventIsWatched(string $eventName): bool
protected function eventIsWatched(?string $eventName): bool
{
if ($eventName === null) {
return false;
}
$watchedEvents = cache()->rememberForever('watchedWebhooks', function () {
return WebhookConfiguration::where('scope', WebhookScope::Global)
->pluck('events')

View File

@@ -10,6 +10,7 @@ use Filament\Notifications\Concerns\HasStatus;
use Filament\Notifications\Concerns\HasTitle;
use Filament\Support\Components\ViewComponent;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Contracts\Support\Htmlable;
use Livewire\Livewire;
final class AlertBanner extends ViewComponent implements Arrayable
@@ -40,7 +41,7 @@ final class AlertBanner extends ViewComponent implements Arrayable
}
/**
* @return array{id: string, title: ?string, body: ?string, status: ?string, icon: ?string, closeable: bool}
* @return array{id: string, title: ?string, body: ?string, status: ?string, icon: string|\BackedEnum|Htmlable|null, closeable: bool}
*/
public function toArray(): array
{
@@ -55,7 +56,7 @@ final class AlertBanner extends ViewComponent implements Arrayable
}
/**
* @param array{id: string, title: ?string, body: ?string, status: ?string, icon: ?string, closeable: bool} $data
* @param array{id: string, title: ?string, body: ?string, status: ?string, icon: string|\BackedEnum|Htmlable|null, closeable: bool} $data
*/
public static function fromArray(array $data): AlertBanner
{

View File

@@ -3,13 +3,15 @@
namespace App\Livewire;
use Filament\Notifications\Collection;
use Illuminate\Contracts\Support\Htmlable;
class AlertBannerCollection extends Collection
{
public static function fromLivewire($value): static
{
return (new static($value))->transform(
fn (array $alertBanner): AlertBanner => AlertBanner::fromArray($alertBanner),
);
return (new static($value))->transform(function (array $alertBanner): AlertBanner {
/** @var array{id: string, title: ?string, body: ?string, status: ?string, icon: string|\BackedEnum|Htmlable|null, closeable: bool} $alertBanner */
return AlertBanner::fromArray($alertBanner);
});
}
}

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