mirror of
https://github.com/pelican-dev/panel.git
synced 2026-07-16 04:03:50 +03:00
Compare commits
16 Commits
v1.0.0-bet
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f2ff09a8b | ||
|
|
f44ad34dc3 | ||
|
|
4cd8b5767a | ||
|
|
4ff5adc2a6 | ||
|
|
b8bbf29a7f | ||
|
|
c5181c9c84 | ||
|
|
54e583e0e4 | ||
|
|
614153a1ed | ||
|
|
7f14a72002 | ||
|
|
6614ae5ea1 | ||
|
|
9e0f7cee35 | ||
|
|
f9793c36fd | ||
|
|
d4e974b806 | ||
|
|
00a56398fb | ||
|
|
ab9535eb37 | ||
|
|
a9cb16e042 |
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
35
app/Extensions/BackupAdapter/BackupAdapterService.php
Normal file
35
app/Extensions/BackupAdapter/BackupAdapterService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
14
app/Extensions/BackupAdapter/Schemas/BackupAdapterSchema.php
Normal file
14
app/Extensions/BackupAdapter/Schemas/BackupAdapterSchema.php
Normal 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());
|
||||
}
|
||||
}
|
||||
207
app/Extensions/BackupAdapter/Schemas/S3BackupSchema.php
Normal file
207
app/Extensions/BackupAdapter/Schemas/S3BackupSchema.php
Normal 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));
|
||||
}
|
||||
}
|
||||
66
app/Extensions/BackupAdapter/Schemas/WingsBackupSchema.php
Normal file
66
app/Extensions/BackupAdapter/Schemas/WingsBackupSchema.php
Normal 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')),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'))),
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
162
app/Filament/Admin/Resources/BackupHosts/BackupHostResource.php
Normal file
162
app/Filament/Admin/Resources/BackupHosts/BackupHostResource.php
Normal 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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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']);
|
||||
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
35
app/Http/Middleware/AddPasskeyErrorMessage.php
Normal file
35
app/Http/Middleware/AddPasskeyErrorMessage.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -100,6 +100,10 @@ class DispatchWebhooks
|
||||
{
|
||||
$eventName = $activityLogged->model->event;
|
||||
|
||||
if ($eventName === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$activityLogged->isServerEvent()) {
|
||||
return;
|
||||
}
|
||||
@@ -175,17 +179,33 @@ class DispatchWebhooks
|
||||
protected function handleGlobalWebhooks(ActivityLogged $activityLogged): void
|
||||
{
|
||||
$eventName = $activityLogged->model->event;
|
||||
$activityLoggedClass = ActivityLogged::class;
|
||||
|
||||
if (!$this->eventIsWatched($eventName)) {
|
||||
if ($eventName === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$matchingHooks = cache()->rememberForever("webhooks.$eventName", function () use ($eventName) {
|
||||
return WebhookConfiguration::query()
|
||||
$matchingHooks = collect();
|
||||
|
||||
if ($this->eventIsWatched($eventName)) {
|
||||
$matchingHooks = $matchingHooks->merge(
|
||||
cache()->rememberForever("webhooks.$eventName", fn () => WebhookConfiguration::query()
|
||||
->where('scope', WebhookScope::Global)
|
||||
->whereJsonContains('events', $eventName)
|
||||
->get())
|
||||
);
|
||||
}
|
||||
|
||||
$matchingHooks = $matchingHooks->merge(
|
||||
cache()->rememberForever("webhooks.$activityLoggedClass", fn () => WebhookConfiguration::query()
|
||||
->where('scope', WebhookScope::Global)
|
||||
->whereJsonContains('events', $eventName)
|
||||
->get();
|
||||
});
|
||||
->whereJsonContains('events', $activityLoggedClass)
|
||||
->get())
|
||||
)->unique('id');
|
||||
|
||||
if ($matchingHooks->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$webhookData = $this->buildActivityPayload($activityLogged);
|
||||
|
||||
@@ -198,8 +218,12 @@ class DispatchWebhooks
|
||||
}
|
||||
}
|
||||
|
||||
protected function eventIsWatched(string $eventName): bool
|
||||
protected function eventIsWatched(?string $eventName): bool
|
||||
{
|
||||
if ($eventName === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$watchedEvents = cache()->rememberForever('watchedWebhooks', function () {
|
||||
return WebhookConfiguration::where('scope', WebhookScope::Global)
|
||||
->pluck('events')
|
||||
|
||||
@@ -10,6 +10,7 @@ use Filament\Notifications\Concerns\HasStatus;
|
||||
use Filament\Notifications\Concerns\HasTitle;
|
||||
use Filament\Support\Components\ViewComponent;
|
||||
use Illuminate\Contracts\Support\Arrayable;
|
||||
use Illuminate\Contracts\Support\Htmlable;
|
||||
use Livewire\Livewire;
|
||||
|
||||
final class AlertBanner extends ViewComponent implements Arrayable
|
||||
@@ -40,7 +41,7 @@ final class AlertBanner extends ViewComponent implements Arrayable
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{id: string, title: ?string, body: ?string, status: ?string, icon: ?string, closeable: bool}
|
||||
* @return array{id: string, title: ?string, body: ?string, status: ?string, icon: string|\BackedEnum|Htmlable|null, closeable: bool}
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
@@ -55,7 +56,7 @@ final class AlertBanner extends ViewComponent implements Arrayable
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{id: string, title: ?string, body: ?string, status: ?string, icon: ?string, closeable: bool} $data
|
||||
* @param array{id: string, title: ?string, body: ?string, status: ?string, icon: string|\BackedEnum|Htmlable|null, closeable: bool} $data
|
||||
*/
|
||||
public static function fromArray(array $data): AlertBanner
|
||||
{
|
||||
|
||||
@@ -3,13 +3,15 @@
|
||||
namespace App\Livewire;
|
||||
|
||||
use Filament\Notifications\Collection;
|
||||
use Illuminate\Contracts\Support\Htmlable;
|
||||
|
||||
class AlertBannerCollection extends Collection
|
||||
{
|
||||
public static function fromLivewire($value): static
|
||||
{
|
||||
return (new static($value))->transform(
|
||||
fn (array $alertBanner): AlertBanner => AlertBanner::fromArray($alertBanner),
|
||||
);
|
||||
return (new static($value))->transform(function (array $alertBanner): AlertBanner {
|
||||
/** @var array{id: string, title: ?string, body: ?string, status: ?string, icon: string|\BackedEnum|Htmlable|null, closeable: bool} $alertBanner */
|
||||
return AlertBanner::fromArray($alertBanner);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user