Compare commits

..

16 Commits

Author SHA1 Message Date
Lance Pioch
60324225a7 docker: enable opcache, production php.ini, and tuned FPM pool
The image previously ran with no opcache (the official php images do not
enable it), no php.ini (defaults), and the stock FPM pool capped at
pm.max_children = 5. Every request recompiled the full framework and the
panel could serve at most five concurrent PHP requests.

- install and enable opcache in the base image, with timestamp validation
  kept on so runtime plugin installs still take effect
- use php.ini-production as the baseline and raise memory_limit to 256M
- tune the www pool (max_children 15, max_requests 500)
- cache Blade views at container start; config/route/event caches are
  deliberately skipped because settings live in .env and plugins register
  providers/routes at runtime
2026-07-15 20:32:42 -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
254 changed files with 4075 additions and 2552 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -37,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();

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