mirror of
https://github.com/pelican-dev/panel.git
synced 2026-02-24 19:08:53 +03:00
Compare commits
67 Commits
lance/2163
...
boy132/bac
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cdd69c29c5 | ||
|
|
5057d72cb7 | ||
|
|
f30025994e | ||
|
|
1a9cc5f565 | ||
|
|
a9f6bcb1e8 | ||
|
|
a6ba81eb2d | ||
|
|
c3b597db9b | ||
|
|
a949a565a8 | ||
|
|
bc727b72fd | ||
|
|
e35ce1e79d | ||
|
|
42c127c004 | ||
|
|
593f209142 | ||
|
|
9bf5b2cf0a | ||
|
|
f76e864a30 | ||
|
|
01cfa31ee1 | ||
|
|
dead664e4d | ||
|
|
c215e95133 | ||
|
|
1bbbcd0e25 | ||
|
|
677d2f742c | ||
|
|
650fb16d2d | ||
|
|
58814ea782 | ||
|
|
0918ed308b | ||
|
|
85d5f2ec3f | ||
|
|
810f237547 | ||
|
|
8f191890a1 | ||
|
|
160e0e54f5 | ||
|
|
fdcfbb00ca | ||
|
|
44f6cf8928 | ||
|
|
cf2a26bbf0 | ||
|
|
adb6678eee | ||
|
|
9539e21b39 | ||
|
|
33660f635f | ||
|
|
8c475ed95f | ||
|
|
d43cb1d180 | ||
|
|
fe55dbd200 | ||
|
|
0bb4503c2b | ||
|
|
3c1168beb5 | ||
|
|
1a092aedc8 | ||
|
|
8c99a8030f | ||
|
|
6e53b1cd7d | ||
|
|
4f2a4726a2 | ||
|
|
4042e0416b | ||
|
|
cc8973cf00 | ||
|
|
8ebe75b947 | ||
|
|
f8144407d1 | ||
|
|
e431ccb66a | ||
|
|
9291bb4477 | ||
|
|
e8c80ae420 | ||
|
|
f1be003276 | ||
|
|
e532a9a180 | ||
|
|
41fdd7bc8e | ||
|
|
9b01203b7c | ||
|
|
ab4eadec32 | ||
|
|
789c4c7284 | ||
|
|
e3893ff872 | ||
|
|
3a9f09c188 | ||
|
|
9252b21205 | ||
|
|
6c8c2a0b91 | ||
|
|
7da9d8c21d | ||
|
|
f1dbbbb7b0 | ||
|
|
150f8035d6 | ||
|
|
efebb999df | ||
|
|
53761f8b21 | ||
|
|
a181978a96 | ||
|
|
12d8b23c98 | ||
|
|
ad2333ea9d | ||
|
|
dd4e7231d0 |
14
.github/workflows/ci.yaml
vendored
14
.github/workflows/ci.yaml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
php: [8.2, 8.3, 8.4, 8.5]
|
||||
php: [8.3, 8.4, 8.5]
|
||||
env:
|
||||
DB_CONNECTION: sqlite
|
||||
DB_DATABASE: testing.sqlite
|
||||
@@ -79,8 +79,8 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
php: [8.2, 8.3, 8.4, 8.5]
|
||||
database: ["mysql:8"]
|
||||
php: [8.5]
|
||||
database: ["mysql:8.4", "mysql:9.6"]
|
||||
services:
|
||||
database:
|
||||
image: ${{ matrix.database }}
|
||||
@@ -147,8 +147,8 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
php: [8.2, 8.3, 8.4, 8.5]
|
||||
database: ["mariadb:10.6", "mariadb:10.11", "mariadb:11.4"]
|
||||
php: [8.5]
|
||||
database: ["mariadb:10.11", "mariadb:11.4"]
|
||||
services:
|
||||
database:
|
||||
image: ${{ matrix.database }}
|
||||
@@ -215,8 +215,8 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
php: [8.2, 8.3, 8.4, 8.5]
|
||||
database: ["postgres:14"]
|
||||
php: [8.5]
|
||||
database: ["postgres:17", "postgres:18"]
|
||||
services:
|
||||
database:
|
||||
image: ${{ matrix.database }}
|
||||
|
||||
@@ -68,6 +68,8 @@ RUN apk add --no-cache \
|
||||
# required for installing plugins. Pulled from https://github.com/pelican-dev/panel/pull/2034
|
||||
zip unzip 7zip bzip2-dev yarn git
|
||||
|
||||
# Copy composer binary for runtime plugin dependency management
|
||||
COPY --from=composer /usr/local/bin/composer /usr/local/bin/composer
|
||||
COPY --chown=root:www-data --chmod=770 --from=composerbuild /build .
|
||||
COPY --chown=root:www-data --chmod=770 --from=yarnbuild /build/public ./public
|
||||
|
||||
@@ -83,8 +85,7 @@ RUN mkdir -p /pelican-data/storage /pelican-data/plugins /var/run/supervisord \
|
||||
# Allow www-data write permissions where necessary
|
||||
&& chown -R www-data: /pelican-data .env ./storage ./plugins ./bootstrap/cache /var/run/supervisord /var/www/html/public/storage \
|
||||
&& chmod -R 770 /pelican-data ./storage ./bootstrap/cache /var/run/supervisord \
|
||||
&& chown -R www-data: /usr/local/etc/php/ /usr/local/etc/php-fpm.d/
|
||||
|
||||
&& chown -R www-data: /usr/local/etc/php/ /usr/local/etc/php-fpm.d/ /var/www/html/composer.json /var/www/html/composer.lock
|
||||
# Configure Supervisor
|
||||
COPY docker/supervisord.conf /etc/supervisord.conf
|
||||
COPY docker/Caddyfile /etc/caddy/Caddyfile
|
||||
|
||||
@@ -73,6 +73,8 @@ RUN apk add --no-cache \
|
||||
# required for installing plugins. Pulled from https://github.com/pelican-dev/panel/pull/2034
|
||||
zip unzip 7zip bzip2-dev yarn git
|
||||
|
||||
# Copy composer binary for runtime plugin dependency management
|
||||
COPY --from=composer /usr/local/bin/composer /usr/local/bin/composer
|
||||
COPY --chown=root:www-data --chmod=770 --from=composerbuild /build .
|
||||
COPY --chown=root:www-data --chmod=770 --from=yarnbuild /build/public ./public
|
||||
|
||||
@@ -88,7 +90,7 @@ RUN mkdir -p /pelican-data/storage /pelican-data/plugins /var/run/supervisord \
|
||||
# Allow www-data write permissions where necessary
|
||||
&& chown -R www-data: /pelican-data .env ./storage ./plugins ./bootstrap/cache /var/run/supervisord /var/www/html/public/storage \
|
||||
&& chmod -R 770 /pelican-data ./storage ./bootstrap/cache /var/run/supervisord \
|
||||
&& chown -R www-data: /usr/local/etc/php/ /usr/local/etc/php-fpm.d/
|
||||
&& chown -R www-data: /usr/local/etc/php/ /usr/local/etc/php-fpm.d/ /var/www/html/composer.json /var/www/html/composer.lock
|
||||
|
||||
# Configure Supervisor
|
||||
COPY docker/supervisord.conf /etc/supervisord.conf
|
||||
|
||||
@@ -8,7 +8,6 @@ use App\Services\Eggs\Sharing\EggExporterService;
|
||||
use Exception;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use JsonException;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
class CheckEggUpdatesCommand extends Command
|
||||
@@ -22,14 +21,12 @@ class CheckEggUpdatesCommand extends Command
|
||||
try {
|
||||
$this->check($egg, $exporterService);
|
||||
} catch (Exception $exception) {
|
||||
$this->error("{$egg->name}: Error ({$exception->getMessage()})");
|
||||
$this->error("$egg->name: Error ({$exception->getMessage()})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws JsonException
|
||||
*/
|
||||
/** @throws Exception */
|
||||
private function check(Egg $egg, EggExporterService $exporterService): void
|
||||
{
|
||||
if (is_null($egg->update_url)) {
|
||||
@@ -45,7 +42,13 @@ class CheckEggUpdatesCommand extends Command
|
||||
? Yaml::parse($exporterService->handle($egg->id, EggFormat::YAML))
|
||||
: json_decode($exporterService->handle($egg->id, EggFormat::JSON), true);
|
||||
|
||||
$remote = Http::timeout(5)->connectTimeout(1)->get($egg->update_url)->throw()->body();
|
||||
$remote = Http::timeout(5)->connectTimeout(1)->get($egg->update_url);
|
||||
|
||||
if ($remote->failed()) {
|
||||
throw new Exception("HTTP request returned status code {$remote->status()}");
|
||||
}
|
||||
|
||||
$remote = $remote->body();
|
||||
$remote = $isYaml ? Yaml::parse($remote) : json_decode($remote, true);
|
||||
|
||||
unset($local['exported_at'], $remote['exported_at']);
|
||||
|
||||
@@ -16,7 +16,7 @@ class DisablePluginCommand extends Command
|
||||
{
|
||||
$id = $this->argument('id') ?? $this->choice('Plugin', Plugin::pluck('name', 'id')->toArray());
|
||||
|
||||
$plugin = Plugin::find($id);
|
||||
$plugin = Plugin::find(str($id)->lower()->toString());
|
||||
|
||||
if (!$plugin) {
|
||||
$this->error('Plugin does not exist!');
|
||||
|
||||
@@ -18,7 +18,7 @@ class InstallPluginCommand extends Command
|
||||
{
|
||||
$id = $this->argument('id') ?? $this->choice('Plugin', Plugin::pluck('name', 'id')->toArray());
|
||||
|
||||
$plugin = Plugin::find($id);
|
||||
$plugin = Plugin::find(str($id)->lower()->toString());
|
||||
|
||||
if (!$plugin) {
|
||||
$this->error('Plugin does not exist!');
|
||||
|
||||
@@ -18,7 +18,7 @@ class UninstallPluginCommand extends Command
|
||||
{
|
||||
$id = $this->argument('id') ?? $this->choice('Plugin', Plugin::pluck('name', 'id')->toArray());
|
||||
|
||||
$plugin = Plugin::find($id);
|
||||
$plugin = Plugin::find(str($id)->lower()->toString());
|
||||
|
||||
if (!$plugin) {
|
||||
$this->error('Plugin does not exist!');
|
||||
|
||||
@@ -17,7 +17,7 @@ class UpdatePluginCommand extends Command
|
||||
{
|
||||
$id = $this->argument('id') ?? $this->choice('Plugin', Plugin::pluck('name', 'id')->toArray());
|
||||
|
||||
$plugin = Plugin::find($id);
|
||||
$plugin = Plugin::find(str($id)->lower()->toString());
|
||||
|
||||
if (!$plugin) {
|
||||
$this->error('Plugin does not exist!');
|
||||
|
||||
@@ -12,6 +12,7 @@ enum CustomizationKey: string
|
||||
case DashboardLayout = 'dashboard_layout';
|
||||
|
||||
case ButtonStyle = 'button_style';
|
||||
case RedirectToAdmin = 'redirect_to_admin';
|
||||
|
||||
public function getDefaultValue(): string|int|bool
|
||||
{
|
||||
@@ -23,6 +24,7 @@ enum CustomizationKey: string
|
||||
self::TopNavigation => config('panel.filament.default-navigation', 'sidebar'),
|
||||
self::DashboardLayout => 'grid',
|
||||
self::ButtonStyle => true,
|
||||
self::RedirectToAdmin => false,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use App\Exceptions\Solutions\ManifestDoesNotExistSolution;
|
||||
use Exception;
|
||||
use Spatie\Ignition\Contracts\ProvidesSolution;
|
||||
use Spatie\Ignition\Contracts\Solution;
|
||||
|
||||
class ManifestDoesNotExistException extends Exception implements ProvidesSolution
|
||||
{
|
||||
public function getSolution(): Solution
|
||||
{
|
||||
return new ManifestDoesNotExistSolution();
|
||||
}
|
||||
}
|
||||
7
app/Exceptions/PluginIdMismatchException.php
Normal file
7
app/Exceptions/PluginIdMismatchException.php
Normal file
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
||||
class PluginIdMismatchException extends Exception {}
|
||||
@@ -1,25 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exceptions\Solutions;
|
||||
|
||||
use Spatie\Ignition\Contracts\Solution;
|
||||
|
||||
class ManifestDoesNotExistSolution implements Solution
|
||||
{
|
||||
public function getSolutionTitle(): string
|
||||
{
|
||||
return "The manifest.json file hasn't been generated yet";
|
||||
}
|
||||
|
||||
public function getSolutionDescription(): string
|
||||
{
|
||||
return 'Run yarn run build:production to build the frontend first.';
|
||||
}
|
||||
|
||||
public function getDocumentationLinks(): array
|
||||
{
|
||||
return [
|
||||
'Docs' => 'https://github.com/pelican/panel/blob/master/package.json',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
64
app/Extensions/BackupAdapter/Schemas/WingsBackupSchema.php
Normal file
64
app/Extensions/BackupAdapter/Schemas/WingsBackupSchema.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Extensions\BackupAdapter\Schemas;
|
||||
|
||||
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))
|
||||
->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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -6,13 +6,14 @@ 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;
|
||||
use App\Traits\Filament\CanCustomizeHeaderWidgets;
|
||||
use App\Traits\Filament\CanCustomizeTabs;
|
||||
use BackedEnum;
|
||||
use BladeUI\Icons\Exceptions\SvgNotFound;
|
||||
use BladeUI\Icons\Factory as IconFactory;
|
||||
use Exception;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\ActionGroup;
|
||||
@@ -68,6 +69,8 @@ class Settings extends Page implements HasSchemas
|
||||
|
||||
protected CaptchaService $captchaService;
|
||||
|
||||
protected IconFactory $iconFactory;
|
||||
|
||||
/** @var array<mixed>|null */
|
||||
public ?array $data = [];
|
||||
|
||||
@@ -76,11 +79,12 @@ class Settings extends Page implements HasSchemas
|
||||
$this->form->fill();
|
||||
}
|
||||
|
||||
public function boot(OAuthService $oauthService, AvatarService $avatarService, CaptchaService $captchaService): void
|
||||
public function boot(OAuthService $oauthService, AvatarService $avatarService, CaptchaService $captchaService, IconFactory $iconFactory): void
|
||||
{
|
||||
$this->oauthService = $oauthService;
|
||||
$this->avatarService = $avatarService;
|
||||
$this->captchaService = $captchaService;
|
||||
$this->iconFactory = $iconFactory;
|
||||
}
|
||||
|
||||
public static function canAccess(): bool
|
||||
@@ -485,16 +489,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()
|
||||
@@ -514,41 +508,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'))),
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -565,9 +524,18 @@ class Settings extends Page implements HasSchemas
|
||||
foreach ($oauthSchemas as $schema) {
|
||||
$key = $schema->getConfigKey();
|
||||
|
||||
$icon = $schema->getIcon();
|
||||
if (is_string($icon)) {
|
||||
try {
|
||||
$this->iconFactory->svg($icon);
|
||||
} catch (SvgNotFound) {
|
||||
$icon = null;
|
||||
}
|
||||
}
|
||||
|
||||
$formFields[] = Section::make($schema->getName())
|
||||
->columns(5)
|
||||
->icon($schema->getIcon() ?? TablerIcon::BrandOauth)
|
||||
->icon($icon ?? TablerIcon::BrandOauth)
|
||||
->collapsed(fn () => !$schema->isEnabled())
|
||||
->collapsible()
|
||||
->schema([
|
||||
|
||||
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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -101,7 +101,7 @@ class DatabaseHostResource extends Resource
|
||||
->toolbarActions([
|
||||
CreateAction::make(),
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make(),
|
||||
DeleteBulkAction::make('exclude_bulk_delete'),
|
||||
]),
|
||||
])
|
||||
->emptyStateIcon(TablerIcon::Database)
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Services\Databases\Hosts\HostCreationService;
|
||||
use App\Traits\Filament\CanCustomizeHeaderActions;
|
||||
use App\Traits\Filament\CanCustomizeHeaderWidgets;
|
||||
use Exception;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\Hidden;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
@@ -45,6 +46,17 @@ class CreateDatabaseHost extends CreateRecord
|
||||
$this->service = $service;
|
||||
}
|
||||
|
||||
protected function getCreateFormAction(): Action
|
||||
{
|
||||
$hasFormWrapper = $this->hasFormWrapper();
|
||||
|
||||
return Action::make('exclude_create')
|
||||
->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']);
|
||||
}
|
||||
|
||||
/** @return Step[]
|
||||
* @throws Exception
|
||||
*/
|
||||
|
||||
@@ -175,7 +175,8 @@ class CreateEgg extends CreateRecord
|
||||
->addActionLabel(trans('admin/egg.add_new_variable'))
|
||||
->grid()
|
||||
->relationship('variables')
|
||||
->reorderable()->orderColumn()
|
||||
->orderColumn()
|
||||
->reorderAction(fn (Action $action) => $action->hiddenLabel()->tooltip(fn () => $action->getLabel()))
|
||||
->collapsible()->collapsed()
|
||||
->columnSpan(2)
|
||||
->defaultItems(0)
|
||||
|
||||
@@ -318,6 +318,7 @@ class EditEgg extends EditRecord
|
||||
->helperText(trans('admin/egg.start_config_help')),
|
||||
Textarea::make('config_files')->rows(10)->json()
|
||||
->label(trans('admin/egg.config_files'))
|
||||
->dehydrateStateUsing(fn ($state) => blank($state) ? '{}' : $state)
|
||||
->helperText(trans('admin/egg.config_files_help')),
|
||||
Textarea::make('config_logs')->rows(10)->json()
|
||||
->label(trans('admin/egg.log_config'))
|
||||
@@ -332,9 +333,9 @@ class EditEgg extends EditRecord
|
||||
->hiddenLabel()
|
||||
->grid()
|
||||
->relationship('variables')
|
||||
->reorderable()
|
||||
->collapsible()->collapsed()
|
||||
->orderColumn()
|
||||
->reorderAction(fn (Action $action) => $action->hiddenLabel()->tooltip(fn () => $action->getLabel()))
|
||||
->collapsible()->collapsed()
|
||||
->addActionLabel(trans('admin/egg.add_new_variable'))
|
||||
->itemLabel(fn (array $state) => $state['name'])
|
||||
->mutateRelationshipDataBeforeCreateUsing(function (array $data): array {
|
||||
@@ -484,18 +485,20 @@ class EditEgg extends EditRecord
|
||||
],
|
||||
]);
|
||||
|
||||
$normalizedExtension = match ($extension) {
|
||||
'svg+xml', 'svg' => 'svg',
|
||||
'jpeg', 'jpg' => 'jpg',
|
||||
'png' => 'png',
|
||||
'webp' => 'webp',
|
||||
default => throw new Exception(trans('admin/egg.import.unknown_extension')),
|
||||
};
|
||||
|
||||
$data = @file_get_contents($imageUrl, false, $context, 0, 1048576); // 1024KB
|
||||
|
||||
if (empty($data)) {
|
||||
throw new Exception(trans('admin/egg.import.invalid_url'));
|
||||
}
|
||||
|
||||
$normalizedExtension = match ($extension) {
|
||||
'svg+xml' => 'svg',
|
||||
'jpeg' => 'jpg',
|
||||
default => $extension,
|
||||
};
|
||||
|
||||
Storage::disk('public')->put(Egg::ICON_STORAGE_PATH . "/$egg->uuid.$normalizedExtension", $data);
|
||||
}
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ class ListEggs extends ListRecords
|
||||
->multiple(),
|
||||
CreateAction::make(),
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make()
|
||||
DeleteBulkAction::make('exclude_bulk_delete')
|
||||
->before(function (Collection &$records) {
|
||||
$eggsWithServers = $records->filter(fn (Egg $egg) => $egg->servers_count > 0);
|
||||
|
||||
@@ -106,7 +106,7 @@ class ListEggs extends ListRecords
|
||||
$this->halt();
|
||||
}
|
||||
}),
|
||||
UpdateEggBulkAction::make()
|
||||
UpdateEggBulkAction::make('exclude_bulk_update')
|
||||
->before(function (Collection &$records) {
|
||||
$eggsWithoutUpdateUrl = $records->filter(fn (Egg $egg) => $egg->update_url === null);
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@ class MountResource extends Resource
|
||||
->toolbarActions([
|
||||
CreateAction::make(),
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make(),
|
||||
DeleteBulkAction::make('exclude_bulk_delete'),
|
||||
]),
|
||||
])
|
||||
->emptyStateIcon(TablerIcon::LayersLinked)
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Filament\Admin\Resources\Nodes\Pages;
|
||||
|
||||
use App\Enums\TablerIcon;
|
||||
use App\Filament\Admin\Resources\Nodes\NodeResource;
|
||||
use App\Filament\Components\Tables\Columns\NodeClientHealthColumn;
|
||||
use App\Filament\Components\Tables\Columns\NodeHealthColumn;
|
||||
use App\Filament\Components\Tables\Filters\TagsFilter;
|
||||
use App\Models\Node;
|
||||
@@ -34,6 +35,7 @@ class ListNodes extends ListRecords
|
||||
->searchable()
|
||||
->hidden(),
|
||||
NodeHealthColumn::make('health'),
|
||||
NodeClientHealthColumn::make('reachable'),
|
||||
TextColumn::make('name')
|
||||
->label(trans('admin/node.table.name'))
|
||||
->sortable()
|
||||
|
||||
@@ -91,7 +91,10 @@ class AllocationsRelationManager extends RelationManager
|
||||
->icon(TablerIcon::WorldPlus)
|
||||
->schema(fn () => [
|
||||
Select::make('allocation_ip')
|
||||
->options(fn () => collect($this->getOwnerRecord()->ipAddresses())->mapWithKeys(fn (string $ip) => [$ip => $ip]))
|
||||
->options(fn (Get $get) => collect($this->getOwnerRecord()->ipAddresses())
|
||||
->when($get('allocation_ip'), fn ($ips, $current) => $ips->push($current))
|
||||
->unique()
|
||||
->mapWithKeys(fn (string $ip) => [$ip => $ip]))
|
||||
->label(trans('admin/node.ip_address'))
|
||||
->inlineLabel()
|
||||
->ip()
|
||||
@@ -100,12 +103,25 @@ class AllocationsRelationManager extends RelationManager
|
||||
->live()
|
||||
->hintAction(
|
||||
Action::make('hint_refresh')
|
||||
->hiddenLabel()
|
||||
->icon(TablerIcon::Refresh)
|
||||
->tooltip(trans('admin/node.refresh'))
|
||||
->action(function () {
|
||||
cache()->forget("nodes.{$this->getOwnerRecord()->id}.ips");
|
||||
})
|
||||
)
|
||||
->suffixAction(
|
||||
Action::make('custom_ip')
|
||||
->icon(TablerIcon::Keyboard)
|
||||
->tooltip(trans('admin/node.custom_ip'))
|
||||
->schema([
|
||||
TextInput::make('custom_ip')
|
||||
->label(trans('admin/node.ip_address'))
|
||||
->ip()
|
||||
->required(),
|
||||
])
|
||||
->action(fn (array $data, Set $set) => $set('allocation_ip', $data['custom_ip']))
|
||||
)
|
||||
->required(),
|
||||
TextInput::make('allocation_alias')
|
||||
->label(trans('admin/node.table.alias'))
|
||||
|
||||
@@ -202,7 +202,7 @@ class PluginResource extends Resource
|
||||
->icon(TablerIcon::Trash)
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (Plugin $plugin) => $plugin->status === PluginStatus::NotInstalled)
|
||||
->visible(fn (Plugin $plugin) => $plugin->status === PluginStatus::NotInstalled || $plugin->status === PluginStatus::Errored)
|
||||
->action(function (Plugin $plugin, $livewire, PluginService $pluginService) {
|
||||
$pluginService->deletePlugin($plugin);
|
||||
|
||||
|
||||
@@ -106,7 +106,7 @@ class RoleResource extends Resource
|
||||
->toolbarActions([
|
||||
CreateAction::make(),
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make(),
|
||||
DeleteBulkAction::make('exclude_bulk_delete'),
|
||||
]),
|
||||
])
|
||||
->checkIfRecordIsSelectableUsing(fn (Role $role) => !$role->isRootAdmin() && $role->users_count <= 0);
|
||||
|
||||
@@ -251,7 +251,10 @@ class CreateServer extends CreateRecord
|
||||
|
||||
return [
|
||||
Select::make('allocation_ip')
|
||||
->options(fn () => collect(Node::find($get('node_id'))?->ipAddresses())->mapWithKeys(fn (string $ip) => [$ip => $ip]))
|
||||
->options(fn (Get $get) => collect(Node::find($getPage('node_id'))?->ipAddresses())
|
||||
->when($get('allocation_ip'), fn ($ips, $current) => $ips->push($current))
|
||||
->unique()
|
||||
->mapWithKeys(fn (string $ip) => [$ip => $ip]))
|
||||
->label(trans('admin/server.ip_address'))->inlineLabel()
|
||||
->helperText(trans('admin/server.ip_address_helper'))
|
||||
->afterStateUpdated(fn (Set $set) => $set('allocation_ports', []))
|
||||
@@ -266,6 +269,18 @@ class CreateServer extends CreateRecord
|
||||
cache()->forget("nodes.{$get('node_id')}.ips");
|
||||
})
|
||||
)
|
||||
->suffixAction(
|
||||
Action::make('custom_ip')
|
||||
->icon(TablerIcon::Keyboard)
|
||||
->tooltip(trans('admin/node.custom_ip'))
|
||||
->schema([
|
||||
TextInput::make('custom_ip')
|
||||
->label(trans('admin/node.ip_address'))
|
||||
->ip()
|
||||
->required(),
|
||||
])
|
||||
->action(fn (array $data, Set $set) => $set('allocation_ip', $data['custom_ip']))
|
||||
)
|
||||
->required(),
|
||||
TextInput::make('allocation_alias')
|
||||
->label(trans('admin/server.alias'))->inlineLabel()
|
||||
|
||||
@@ -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\DeleteServerIcon;
|
||||
use App\Filament\Components\Actions\PreviewStartupAction;
|
||||
@@ -12,7 +14,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;
|
||||
@@ -121,6 +122,7 @@ class EditServer extends EditRecord
|
||||
->columnSpan(2)
|
||||
->alignJustify(),
|
||||
Action::make('uploadIcon')
|
||||
->hiddenLabel()
|
||||
->icon(TablerIcon::PhotoUp)
|
||||
->tooltip(trans('admin/server.import_image'))
|
||||
->modal()
|
||||
@@ -935,7 +937,7 @@ class EditServer extends EditRecord
|
||||
->send();
|
||||
}
|
||||
}),
|
||||
Action::make('toggleUnsuspend')
|
||||
Action::make('exclude_toggle_unsuspend')
|
||||
->label(trans('admin/server.unsuspend'))
|
||||
->color('success')
|
||||
->hidden(fn (Server $server) => !$server->isSuspended())
|
||||
@@ -975,16 +977,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()
|
||||
@@ -1076,17 +1082,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),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1126,7 +1132,7 @@ class EditServer extends EditRecord
|
||||
->hidden(fn () => $canForceDelete)
|
||||
->authorize(fn (Server $server) => user()?->can('delete server', $server))
|
||||
->icon(TablerIcon::Trash),
|
||||
Action::make('ForceDelete')
|
||||
Action::make('exclude_force_delete')
|
||||
->color('danger')
|
||||
->label(trans('filament-actions::force-delete.single.label'))
|
||||
->modalHeading(trans('filament-actions::force-delete.single.modal.heading', ['label' => $this->getRecordTitle()]))
|
||||
@@ -1218,18 +1224,20 @@ class EditServer extends EditRecord
|
||||
],
|
||||
]);
|
||||
|
||||
$normalizedExtension = match ($extension) {
|
||||
'svg+xml', 'svg' => 'svg',
|
||||
'jpeg', 'jpg' => 'jpg',
|
||||
'png' => 'png',
|
||||
'webp' => 'webp',
|
||||
default => throw new Exception(trans('admin/egg.import.unknown_extension')),
|
||||
};
|
||||
|
||||
$data = @file_get_contents($imageUrl, false, $context, 0, 262144); //256KB
|
||||
|
||||
if (empty($data)) {
|
||||
throw new \Exception(trans('admin/egg.import.invalid_url'));
|
||||
throw new Exception(trans('admin/egg.import.invalid_url'));
|
||||
}
|
||||
|
||||
$normalizedExtension = match ($extension) {
|
||||
'svg+xml' => 'svg',
|
||||
'jpeg' => 'jpg',
|
||||
default => $extension,
|
||||
};
|
||||
|
||||
Storage::disk('public')->put(Server::ICON_STORAGE_PATH . "/$server->uuid.$normalizedExtension", $data);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,7 +113,10 @@ class AllocationsRelationManager extends RelationManager
|
||||
->createAnother(false)
|
||||
->schema(fn () => [
|
||||
Select::make('allocation_ip')
|
||||
->options(fn () => collect($this->getOwnerRecord()->node->ipAddresses())->mapWithKeys(fn (string $ip) => [$ip => $ip]))
|
||||
->options(fn (Get $get) => collect($this->getOwnerRecord()->node->ipAddresses())
|
||||
->when($get('allocation_ip'), fn ($ips, $current) => $ips->push($current))
|
||||
->unique()
|
||||
->mapWithKeys(fn (string $ip) => [$ip => $ip]))
|
||||
->label(trans('admin/server.ip_address'))
|
||||
->inlineLabel()
|
||||
->ip()
|
||||
@@ -126,6 +129,18 @@ class AllocationsRelationManager extends RelationManager
|
||||
cache()->forget("nodes.{$this->getOwnerRecord()->node->id}.ips");
|
||||
})
|
||||
)
|
||||
->suffixAction(
|
||||
Action::make('custom_ip')
|
||||
->icon(TablerIcon::Keyboard)
|
||||
->tooltip(trans('admin/node.custom_ip'))
|
||||
->schema([
|
||||
TextInput::make('custom_ip')
|
||||
->label(trans('admin/node.ip_address'))
|
||||
->ip()
|
||||
->required(),
|
||||
])
|
||||
->action(fn (array $data, Set $set) => $set('allocation_ip', $data['custom_ip']))
|
||||
)
|
||||
->afterStateUpdated(fn (Set $set) => $set('allocation_ports', []))
|
||||
->required(),
|
||||
TextInput::make('allocation_alias')
|
||||
|
||||
@@ -142,7 +142,7 @@ class UserResource extends Resource
|
||||
])
|
||||
->toolbarActions([
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make(),
|
||||
DeleteBulkAction::make('exclude_bulk_delete'),
|
||||
]),
|
||||
CreateAction::make()
|
||||
->hiddenLabel()
|
||||
|
||||
@@ -114,7 +114,7 @@ class WebhookResource extends Resource
|
||||
->toolbarActions([
|
||||
CreateAction::make(),
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make(),
|
||||
DeleteBulkAction::make('exclude_bulk_delete'),
|
||||
]),
|
||||
])
|
||||
->emptyStateIcon(TablerIcon::Webhook)
|
||||
|
||||
@@ -16,13 +16,15 @@ class RotateDatabasePasswordAction extends Action
|
||||
{
|
||||
public static function getDefaultName(): ?string
|
||||
{
|
||||
return 'hint_rotate';
|
||||
return 'exclude_hint_rotate';
|
||||
}
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->hiddenLabel();
|
||||
|
||||
$this->tooltip(trans('admin/databasehost.rotate'));
|
||||
|
||||
$this->icon(TablerIcon::Refresh);
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Components\Tables\Columns;
|
||||
|
||||
use Filament\Support\Enums\Alignment;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
|
||||
class NodeClientHealthColumn extends IconColumn
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->label(trans('admin/node.table.reachable'));
|
||||
|
||||
$this->alignCenter();
|
||||
}
|
||||
|
||||
public function toEmbeddedHtml(): string
|
||||
{
|
||||
$alignment = $this->getAlignment();
|
||||
|
||||
$attributes = $this->getExtraAttributeBag()
|
||||
->class([
|
||||
'fi-ta-icon',
|
||||
'fi-inline' => $this->isInline(),
|
||||
'fi-ta-icon-has-line-breaks' => $this->isListWithLineBreaks(),
|
||||
'fi-wrapped' => $this->canWrap(),
|
||||
($alignment instanceof Alignment) ? "fi-align-{$alignment->value}" : (is_string($alignment) ? $alignment : ''),
|
||||
])
|
||||
->toHtml();
|
||||
|
||||
return Blade::render(<<<'BLADE'
|
||||
<div <?= $attributes ?>>
|
||||
@livewire('node-client-connectivity', ['node' => $record, 'lazy' => true])
|
||||
</div>
|
||||
BLADE, [
|
||||
'attributes' => $attributes,
|
||||
'record' => $this->getRecord(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -254,7 +254,7 @@ class EditProfile extends BaseEditProfile
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->headerActions([
|
||||
Action::make('create_api_key')
|
||||
Action::make('exclude_create_api_key')
|
||||
->label(trans('filament-actions::create.single.modal.actions.create.label'))
|
||||
->disabled(fn (Get $get) => empty($get('description')))
|
||||
->successRedirectUrl(self::getUrl(['tab' => 'api-keys::data::tab'], panel: 'app'))
|
||||
@@ -343,7 +343,7 @@ class EditProfile extends BaseEditProfile
|
||||
->live(),
|
||||
])
|
||||
->headerActions([
|
||||
Action::make('create_ssh_key')
|
||||
Action::make('exclude_create_ssh_key')
|
||||
->label(trans('filament-actions::create.single.modal.actions.create.label'))
|
||||
->disabled(fn (Get $get) => empty($get('name')) || empty($get('public_key')))
|
||||
->successRedirectUrl(self::getUrl(['tab' => 'ssh-keys::data::tab'], panel: 'app'))
|
||||
@@ -426,13 +426,13 @@ class EditProfile extends BaseEditProfile
|
||||
->label(trans('profile.tabs.activity'))
|
||||
->icon(TablerIcon::History)
|
||||
->schema([
|
||||
Repeater::make('activity')
|
||||
->hiddenLabel()
|
||||
Repeater::make('activity') // TODO: move to a table
|
||||
->label(trans('profile.activity_info'))
|
||||
->inlineLabel(false)
|
||||
->deletable(false)
|
||||
->addable(false)
|
||||
->relationship(null, function (Builder $query) {
|
||||
$query->orderBy('timestamp', 'desc');
|
||||
$query->orderBy('timestamp', 'desc')->limit(50);
|
||||
})
|
||||
->schema([
|
||||
TextEntry::make('log')
|
||||
@@ -474,6 +474,17 @@ class EditProfile extends BaseEditProfile
|
||||
false => 'Icon Button',
|
||||
]),
|
||||
]),
|
||||
Section::make(trans('profile.admin'))
|
||||
->collapsible()
|
||||
->icon(TablerIcon::Shield)
|
||||
->visible(fn (User $user) => $user->isAdmin())
|
||||
->schema([
|
||||
ToggleButtons::make('redirect_to_admin')
|
||||
->label(trans('profile.redirect_to_admin'))
|
||||
->helperText(trans('profile.redirect_to_admin_help'))
|
||||
->inline()
|
||||
->boolean(),
|
||||
]),
|
||||
Section::make(trans('profile.console'))
|
||||
->collapsible()
|
||||
->icon(TablerIcon::Terminal2)
|
||||
@@ -599,6 +610,7 @@ class EditProfile extends BaseEditProfile
|
||||
'dashboard_layout' => $data['dashboard_layout'],
|
||||
'top_navigation' => $data['top_navigation'],
|
||||
'button_style' => $data['button_style'],
|
||||
'redirect_to_admin' => $data['redirect_to_admin'] ?? $this->getUser()->getCustomization(CustomizationKey::RedirectToAdmin),
|
||||
];
|
||||
|
||||
unset(
|
||||
@@ -608,6 +620,7 @@ class EditProfile extends BaseEditProfile
|
||||
$data['dashboard_layout'],
|
||||
$data['top_navigation'],
|
||||
$data['button_style'],
|
||||
$data['redirect_to_admin'],
|
||||
);
|
||||
|
||||
$data['customization'] = json_encode($customization);
|
||||
@@ -623,6 +636,7 @@ class EditProfile extends BaseEditProfile
|
||||
$data['console_graph_period'] = (int) $this->getUser()->getCustomization(CustomizationKey::ConsoleGraphPeriod);
|
||||
$data['dashboard_layout'] = $this->getUser()->getCustomization(CustomizationKey::DashboardLayout);
|
||||
$data['button_style'] = $this->getUser()->getCustomization(CustomizationKey::ButtonStyle);
|
||||
$data['redirect_to_admin'] = $this->getUser()->getCustomization(CustomizationKey::RedirectToAdmin);
|
||||
|
||||
// Handle migration from boolean to string navigation types
|
||||
$topNavigation = $this->getUser()->getCustomization(CustomizationKey::TopNavigation);
|
||||
|
||||
@@ -4,6 +4,8 @@ namespace App\Filament\Pages\Auth;
|
||||
|
||||
use App\Extensions\Captcha\CaptchaService;
|
||||
use App\Extensions\OAuth\OAuthService;
|
||||
use BladeUI\Icons\Exceptions\SvgNotFound;
|
||||
use BladeUI\Icons\Factory as IconFactory;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Auth\Pages\Login as BaseLogin;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
@@ -19,10 +21,13 @@ class Login extends BaseLogin
|
||||
|
||||
protected CaptchaService $captchaService;
|
||||
|
||||
public function boot(OAuthService $oauthService, CaptchaService $captchaService): void
|
||||
protected IconFactory $iconFactory;
|
||||
|
||||
public function boot(OAuthService $oauthService, CaptchaService $captchaService, IconFactory $iconFactory): void
|
||||
{
|
||||
$this->oauthService = $oauthService;
|
||||
$this->captchaService = $captchaService;
|
||||
$this->iconFactory = $iconFactory;
|
||||
}
|
||||
|
||||
public function form(Schema $schema): Schema
|
||||
@@ -87,9 +92,18 @@ class Login extends BaseLogin
|
||||
$color = $schema->getHexColor();
|
||||
$color = is_string($color) ? Color::hex($color) : null;
|
||||
|
||||
$icon = $schema->getIcon();
|
||||
if (is_string($icon)) {
|
||||
try {
|
||||
$this->iconFactory->svg($icon);
|
||||
} catch (SvgNotFound) {
|
||||
$icon = null;
|
||||
}
|
||||
}
|
||||
|
||||
$actions[] = Action::make("oauth_$id")
|
||||
->label($schema->getName())
|
||||
->icon($schema->getIcon())
|
||||
->icon($icon)
|
||||
->color($color)
|
||||
->url(route('auth.oauth.redirect', ['driver' => $id], false));
|
||||
}
|
||||
|
||||
@@ -462,18 +462,20 @@ class Settings extends ServerFormPage
|
||||
],
|
||||
]);
|
||||
|
||||
$normalizedExtension = match ($extension) {
|
||||
'svg+xml', 'svg' => 'svg',
|
||||
'jpeg', 'jpg' => 'jpg',
|
||||
'png' => 'png',
|
||||
'webp' => 'webp',
|
||||
default => throw new Exception(trans('admin/egg.import.unknown_extension')),
|
||||
};
|
||||
|
||||
$data = @file_get_contents($imageUrl, false, $context, 0, 262144); //256KB
|
||||
|
||||
if (empty($data)) {
|
||||
throw new \Exception(trans('admin/egg.import.invalid_url'));
|
||||
throw new Exception(trans('admin/egg.import.invalid_url'));
|
||||
}
|
||||
|
||||
$normalizedExtension = match ($extension) {
|
||||
'svg+xml' => 'svg',
|
||||
'jpeg' => 'jpg',
|
||||
default => $extension,
|
||||
};
|
||||
|
||||
Storage::disk('public')->put(Server::ICON_STORAGE_PATH . "/$server->uuid.$normalizedExtension", $data);
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,6 @@ use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Forms\Components\Checkbox;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
@@ -77,7 +76,7 @@ class BackupResource extends Resource
|
||||
/** @var Server $server */
|
||||
$server = Filament::getTenant();
|
||||
|
||||
return $server->backup_limit;
|
||||
return $server->backup_limit ?? 0;
|
||||
}
|
||||
|
||||
public static function defaultForm(Schema $schema): Schema
|
||||
@@ -128,7 +127,7 @@ class BackupResource extends Resource
|
||||
])
|
||||
->recordActions([
|
||||
ActionGroup::make([
|
||||
Action::make('rename')
|
||||
Action::make('exclude_rename')
|
||||
->icon(TablerIcon::Pencil)
|
||||
->authorize(fn () => user()?->can(SubuserPermission::BackupDelete, $server))
|
||||
->label(trans('server/backup.actions.rename.title'))
|
||||
@@ -158,22 +157,22 @@ class BackupResource extends Resource
|
||||
->send();
|
||||
})
|
||||
->visible(fn (Backup $backup) => $backup->status === BackupStatus::Successful),
|
||||
Action::make('lock')
|
||||
Action::make('exclude_lock')
|
||||
->iconSize(IconSize::Large)
|
||||
->icon(fn (Backup $backup) => !$backup->is_locked ? TablerIcon::Lock : TablerIcon::LockOpen)
|
||||
->authorize(fn () => user()?->can(SubuserPermission::BackupDelete, $server))
|
||||
->label(fn (Backup $backup) => !$backup->is_locked ? trans('server/backup.actions.lock.lock') : trans('server/backup.actions.lock.unlock'))
|
||||
->action(fn (BackupController $backupController, Backup $backup, Request $request) => $backupController->toggleLock($request, $server, $backup))
|
||||
->visible(fn (Backup $backup) => $backup->status === BackupStatus::Successful),
|
||||
Action::make('download')
|
||||
Action::make('exclude_download')
|
||||
->label(trans('server/backup.actions.download'))
|
||||
->iconSize(IconSize::Large)
|
||||
->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('restore')
|
||||
Action::make('exclude_restore')
|
||||
->label(trans('server/backup.actions.restore.title'))
|
||||
->iconSize(IconSize::Large)
|
||||
->color('success')
|
||||
@@ -208,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()
|
||||
@@ -226,7 +221,12 @@ class BackupResource extends Resource
|
||||
->send();
|
||||
})
|
||||
->visible(fn (Backup $backup) => $backup->status === BackupStatus::Successful),
|
||||
DeleteAction::make('delete')
|
||||
Action::make('exclude_delete')
|
||||
->icon(TablerIcon::Trash)
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->authorize(fn () => user()?->can(SubuserPermission::BackupDelete, $server))
|
||||
->label(trans('filament-actions::delete.single.label'))
|
||||
->iconSize(IconSize::Large)
|
||||
->disabled(fn (Backup $backup) => $backup->is_locked && $backup->status !== BackupStatus::Failed)
|
||||
->modalDescription(fn (Backup $backup) => trans('server/backup.actions.delete.description', ['backup' => $backup->name]))
|
||||
|
||||
@@ -62,7 +62,7 @@ class DatabaseResource extends Resource
|
||||
/** @var Server $server */
|
||||
$server = Filament::getTenant();
|
||||
|
||||
return $server->database_limit;
|
||||
return $server->database_limit ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Models\Schedule;
|
||||
use App\Models\Server;
|
||||
use App\Traits\Filament\CanCustomizeHeaderActions;
|
||||
use App\Traits\Filament\CanCustomizeHeaderWidgets;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
@@ -20,6 +21,17 @@ class CreateSchedule extends CreateRecord
|
||||
|
||||
protected static bool $canCreateAnother = false;
|
||||
|
||||
protected function getCreateFormAction(): Action
|
||||
{
|
||||
$hasFormWrapper = $this->hasFormWrapper();
|
||||
|
||||
return Action::make('exclude_create')
|
||||
->label(__('filament-panels::resources/pages/create-record.form.actions.create.label'))
|
||||
->submit($hasFormWrapper ? $this->getSubmitFormLivewireMethodName() : null)
|
||||
->action($hasFormWrapper ? null : $this->getSubmitFormLivewireMethodName())
|
||||
->keyBindings(['mod+s']);
|
||||
}
|
||||
|
||||
protected function afterCreate(): void
|
||||
{
|
||||
/** @var Schedule $schedule */
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Facades\Activity;
|
||||
use App\Models\Schedule;
|
||||
use App\Models\Task;
|
||||
use Exception;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\EditAction;
|
||||
@@ -73,6 +74,7 @@ class TasksRelationManager extends RelationManager
|
||||
return $table
|
||||
->reorderable('sequence_id')
|
||||
->defaultSort('sequence_id')
|
||||
->reorderRecordsTriggerAction(fn (Action $action, bool $isReordering) => $action->hiddenLabel()->tooltip(fn () => $action->getLabel()))
|
||||
->columns([
|
||||
TextColumn::make('action')
|
||||
->label(trans('server/schedule.tasks.actions.title'))
|
||||
|
||||
@@ -97,8 +97,7 @@ class ScheduleResource extends Resource
|
||||
->formatStateUsing(fn (?Schedule $schedule) => $schedule?->status->value ?? 'new')
|
||||
->options(fn (?Schedule $schedule) => [$schedule?->status->value ?? 'new' => $schedule?->status->getLabel() ?? 'New'])
|
||||
->visibleOn('view'),
|
||||
Section::make('Cron')
|
||||
->label(trans('server/schedule.cron'))
|
||||
Section::make(trans('server/schedule.cron'))
|
||||
->description(function (Get $get) {
|
||||
try {
|
||||
$nextRun = Utilities::getScheduleNextRunDate($get('cron_minute'), $get('cron_hour'), $get('cron_day_of_month'), $get('cron_month'), $get('cron_day_of_week'))->timezone(user()->timezone ?? 'UTC');
|
||||
@@ -110,22 +109,22 @@ class ScheduleResource extends Resource
|
||||
})
|
||||
->schema([
|
||||
Actions::make([
|
||||
CronPresetAction::make('hourly')
|
||||
CronPresetAction::make('exclude_hourly')
|
||||
->label(trans('server/schedule.time.hourly'))
|
||||
->cron('0', '*', '*', '*', '*'),
|
||||
CronPresetAction::make('daily')
|
||||
CronPresetAction::make('exclude_daily')
|
||||
->label(trans('server/schedule.time.daily'))
|
||||
->cron('0', '0', '*', '*', '*'),
|
||||
CronPresetAction::make('weekly_monday')
|
||||
CronPresetAction::make('exclude_weekly_monday')
|
||||
->label(trans('server/schedule.time.weekly_mon'))
|
||||
->cron('0', '0', '*', '*', '1'),
|
||||
CronPresetAction::make('weekly_sunday')
|
||||
CronPresetAction::make('exclude_weekly_sunday')
|
||||
->label(trans('server/schedule.time.weekly_sun'))
|
||||
->cron('0', '0', '*', '*', '0'),
|
||||
CronPresetAction::make('monthly')
|
||||
CronPresetAction::make('exclude_monthly')
|
||||
->label(trans('server/schedule.time.monthly'))
|
||||
->cron('0', '0', '1', '*', '*'),
|
||||
CronPresetAction::make('every_x_minutes')
|
||||
CronPresetAction::make('exclude_every_x_minutes')
|
||||
->label(trans('server/schedule.time.every_min'))
|
||||
->color(fn (Get $get) => str($get('cron_minute'))->startsWith('*/')
|
||||
&& $get('cron_hour') == '*'
|
||||
@@ -148,7 +147,7 @@ class ScheduleResource extends Resource
|
||||
$set('cron_month', '*');
|
||||
$set('cron_day_of_week', '*');
|
||||
}),
|
||||
CronPresetAction::make('every_x_hours')
|
||||
CronPresetAction::make('exclude_every_x_hours')
|
||||
->color(fn (Get $get) => $get('cron_minute') == '0'
|
||||
&& str($get('cron_hour'))->startsWith('*/')
|
||||
&& $get('cron_day_of_month') == '*'
|
||||
@@ -170,7 +169,7 @@ class ScheduleResource extends Resource
|
||||
$set('cron_month', '*');
|
||||
$set('cron_day_of_week', '*');
|
||||
}),
|
||||
CronPresetAction::make('every_x_days')
|
||||
CronPresetAction::make('exclude_every_x_days')
|
||||
->color(fn (Get $get) => $get('cron_minute') == '0'
|
||||
&& $get('cron_hour') == '0'
|
||||
&& str($get('cron_day_of_month'))->startsWith('*/')
|
||||
@@ -192,7 +191,7 @@ class ScheduleResource extends Resource
|
||||
$set('cron_month', '*');
|
||||
$set('cron_day_of_week', '*');
|
||||
}),
|
||||
CronPresetAction::make('every_x_months')
|
||||
CronPresetAction::make('exclude_every_x_months')
|
||||
->color(fn (Get $get) => $get('cron_minute') == '0'
|
||||
&& $get('cron_hour') == '0'
|
||||
&& $get('cron_day_of_month') == '1'
|
||||
@@ -214,7 +213,7 @@ class ScheduleResource extends Resource
|
||||
$set('cron_month', '*/' . $data['x']);
|
||||
$set('cron_day_of_week', '*');
|
||||
}),
|
||||
CronPresetAction::make('every_x_day_of_week')
|
||||
CronPresetAction::make('exclude_every_x_day_of_week')
|
||||
->color(fn (Get $get) => $get('cron_minute') == '0'
|
||||
&& $get('cron_hour') == '0'
|
||||
&& $get('cron_day_of_month') == '*'
|
||||
|
||||
@@ -123,18 +123,6 @@ class SubuserResource extends Resource
|
||||
),
|
||||
])
|
||||
->recordActions([
|
||||
DeleteAction::make()
|
||||
->label(trans('server/user.delete'))
|
||||
->hidden(fn (Subuser $subuser) => user()?->id === $subuser->user->id)
|
||||
->successNotificationTitle(null)
|
||||
->action(function (Subuser $subuser, SubuserDeletionService $subuserDeletionService) use ($server) {
|
||||
$subuserDeletionService->handle($subuser, $server);
|
||||
|
||||
Notification::make()
|
||||
->title(trans('server/user.notification_delete'))
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
EditAction::make()
|
||||
->label(trans('server/user.edit'))
|
||||
->hidden(fn (Subuser $subuser) => user()?->id === $subuser->user->id)
|
||||
@@ -179,7 +167,7 @@ class SubuserResource extends Resource
|
||||
])
|
||||
->formatStateUsing(fn (Subuser $subuser) => $subuser->user->email),
|
||||
Actions::make([
|
||||
Action::make('assignAll')
|
||||
Action::make('exclude_assignAll')
|
||||
->label(trans('server/user.assign_all'))
|
||||
->action(function (Set $set) use ($permissionsArray) {
|
||||
$permissions = $permissionsArray;
|
||||
@@ -214,6 +202,19 @@ class SubuserResource extends Resource
|
||||
|
||||
return $data;
|
||||
}),
|
||||
DeleteAction::make()
|
||||
->label(trans('server/user.delete'))
|
||||
->hidden(fn (Subuser $subuser) => user()?->id === $subuser->user->id)
|
||||
->authorize(fn () => user()?->can(SubuserPermission::UserDelete, $server))
|
||||
->successNotificationTitle(null)
|
||||
->action(function (Subuser $subuser, SubuserDeletionService $subuserDeletionService) use ($server) {
|
||||
$subuserDeletionService->handle($subuser, $server);
|
||||
|
||||
Notification::make()
|
||||
->title(trans('server/user.notification_delete'))
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
])
|
||||
->toolbarActions([
|
||||
CreateAction::make('invite')
|
||||
@@ -243,7 +244,7 @@ class SubuserResource extends Resource
|
||||
])
|
||||
->required(),
|
||||
Actions::make([
|
||||
Action::make('assignAll')
|
||||
Action::make('exclude_assignAll')
|
||||
->label(trans('server/user.assign_all'))
|
||||
->action(function (Set $set, Get $get) use ($permissionsArray) {
|
||||
$permissions = $permissionsArray;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -191,7 +193,8 @@ class BackupController extends ClientApiController
|
||||
throw 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.');
|
||||
}
|
||||
|
||||
@@ -264,17 +267,13 @@ class BackupController extends ClientApiController
|
||||
->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);
|
||||
|
||||
@@ -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,45 @@ 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.');
|
||||
}
|
||||
|
||||
/** @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('You do not have permission to access that backup.');
|
||||
}
|
||||
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
|
||||
namespace App\Http\Controllers\Api\Remote\Backups;
|
||||
|
||||
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;
|
||||
@@ -13,7 +12,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;
|
||||
@@ -24,7 +22,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.
|
||||
@@ -73,9 +71,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'));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -106,59 +104,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));
|
||||
}
|
||||
}
|
||||
|
||||
25
app/Http/Responses/LoginResponse.php
Normal file
25
app/Http/Responses/LoginResponse.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Responses;
|
||||
|
||||
use App\Enums\CustomizationKey;
|
||||
use App\Models\User;
|
||||
use Filament\Auth\Http\Responses\Contracts\LoginResponse as LoginResponseContract;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Livewire\Features\SupportRedirects\Redirector;
|
||||
|
||||
class LoginResponse implements LoginResponseContract
|
||||
{
|
||||
public function toResponse($request): RedirectResponse|Redirector
|
||||
{
|
||||
/** @var User|null $user */
|
||||
$user = Filament::auth()->user();
|
||||
|
||||
if ($user?->getCustomization(CustomizationKey::RedirectToAdmin) && $user->canAccessPanel(Filament::getPanel('admin'))) {
|
||||
return redirect()->intended(Filament::getPanel('admin')->getUrl());
|
||||
}
|
||||
|
||||
return redirect()->intended(Filament::getUrl());
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,10 @@ class ProcessWebhook implements ShouldQueue
|
||||
$data = reset($data);
|
||||
}
|
||||
|
||||
if (is_object($data)) {
|
||||
$data = get_object_vars($data);
|
||||
}
|
||||
|
||||
if (is_string($data)) {
|
||||
$data = Arr::wrap(json_decode($data, true) ?? []);
|
||||
}
|
||||
|
||||
@@ -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 Livewire\Livewire;
|
||||
|
||||
final class AlertBanner extends ViewComponent implements Arrayable
|
||||
{
|
||||
@@ -83,7 +84,13 @@ final class AlertBanner extends ViewComponent implements Arrayable
|
||||
|
||||
public function send(): AlertBanner
|
||||
{
|
||||
session()->push('alert-banners', $this->toArray());
|
||||
$data = $this->toArray();
|
||||
|
||||
if (Livewire::isLivewireRequest()) {
|
||||
$data['from_livewire'] = true;
|
||||
}
|
||||
|
||||
session()->push('alert-banners', $data);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
15
app/Livewire/AlertBannerCollection.php
Normal file
15
app/Livewire/AlertBannerCollection.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use Filament\Notifications\Collection;
|
||||
|
||||
class AlertBannerCollection extends Collection
|
||||
{
|
||||
public static function fromLivewire($value): static
|
||||
{
|
||||
return (new static($value))->transform(
|
||||
fn (array $alertBanner): AlertBanner => AlertBanner::fromArray($alertBanner),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,25 +2,35 @@
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use Filament\Notifications\Collection;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
|
||||
class AlertBannerContainer extends Component
|
||||
{
|
||||
public Collection $alertBanners;
|
||||
public AlertBannerCollection $alertBanners;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->alertBanners = new Collection();
|
||||
$this->pullFromSession();
|
||||
$this->alertBanners = new AlertBannerCollection();
|
||||
|
||||
foreach (session()->pull('alert-banners', []) as $alertBanner) {
|
||||
// Alerts created during Livewire requests should have been consumed by the event handler on the same page.
|
||||
if (!empty($alertBanner['from_livewire'])) {
|
||||
// If they weren't, then discard them instead of showing on the wrong page.
|
||||
continue;
|
||||
}
|
||||
|
||||
$alertBanner = AlertBanner::fromArray($alertBanner);
|
||||
$this->alertBanners->put($alertBanner->getId(), $alertBanner);
|
||||
}
|
||||
}
|
||||
|
||||
#[On('alertBannerSent')]
|
||||
public function pullFromSession(): void
|
||||
{
|
||||
foreach (session()->pull('alert-banners', []) as $alertBanner) {
|
||||
unset($alertBanner['from_livewire']);
|
||||
$alertBanner = AlertBanner::fromArray($alertBanner);
|
||||
$this->alertBanners->put($alertBanner->getId(), $alertBanner);
|
||||
}
|
||||
|
||||
94
app/Livewire/NodeClientConnectivity.php
Normal file
94
app/Livewire/NodeClientConnectivity.php
Normal file
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Enums\TablerIcon;
|
||||
use App\Models\Node;
|
||||
use App\Services\Nodes\NodeJWTService;
|
||||
use App\Services\Servers\GetUserPermissionsService;
|
||||
use Filament\Support\Enums\IconSize;
|
||||
use Filament\Tables\View\Components\Columns\IconColumnComponent\IconComponent;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\View\ComponentAttributeBag;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Component;
|
||||
|
||||
use function Filament\Support\generate_icon_html;
|
||||
|
||||
class NodeClientConnectivity extends Component
|
||||
{
|
||||
#[Locked]
|
||||
public Node $node;
|
||||
|
||||
private GetUserPermissionsService $getUserPermissionsService;
|
||||
|
||||
private NodeJWTService $nodeJWTService;
|
||||
|
||||
public function boot(GetUserPermissionsService $getUserPermissionsService, NodeJWTService $nodeJWTService): void
|
||||
{
|
||||
$this->getUserPermissionsService = $getUserPermissionsService;
|
||||
$this->nodeJWTService = $nodeJWTService;
|
||||
}
|
||||
|
||||
public function render(): \Illuminate\Contracts\View\View
|
||||
{
|
||||
$httpUrl = $this->node->getConnectionAddress();
|
||||
|
||||
$wsUrl = null;
|
||||
$wsToken = null;
|
||||
|
||||
$server = $this->node->servers()->first();
|
||||
|
||||
if ($server) {
|
||||
$user = Auth::user();
|
||||
|
||||
$permissions = $this->getUserPermissionsService->handle($server, $user);
|
||||
|
||||
$wsToken = $this->nodeJWTService
|
||||
->setExpiresAt(now()->addMinute()->toImmutable())
|
||||
->setUser($user)
|
||||
->setClaims([
|
||||
'server_uuid' => $server->uuid,
|
||||
'permissions' => $permissions,
|
||||
])
|
||||
->handle($this->node, $user->id . $server->uuid)->toString();
|
||||
|
||||
$wsUrl = str_replace(['https://', 'http://'], ['wss://', 'ws://'], $this->node->getConnectionAddress());
|
||||
$wsUrl .= sprintf('/api/servers/%s/ws', $server->uuid);
|
||||
}
|
||||
|
||||
return view('livewire.node-client-connectivity', [
|
||||
'httpUrl' => $httpUrl,
|
||||
'wsUrl' => $wsUrl,
|
||||
'wsToken' => $wsToken,
|
||||
'loadingIcon' => $this->makeIcon(TablerIcon::WorldQuestion, 'warning', 'Checking...'),
|
||||
'offlineIcon' => $this->makeIcon(TablerIcon::WorldX, 'danger', 'Node is not reachable from your browser'),
|
||||
'onlineIcon' => $this->makeIcon(TablerIcon::WorldCheck, 'success', 'Node is reachable'),
|
||||
'warningIcon' => $this->makeIcon(TablerIcon::WorldExclamation, 'warning', 'Node is reachable, but WebSocket failed. Check reverse proxy config.'),
|
||||
'onlineNoWsIcon' => $this->makeIcon(TablerIcon::WorldCheck, 'success', 'Node is reachable (WebSocket not tested — no servers)'),
|
||||
]);
|
||||
}
|
||||
|
||||
private function makeIcon(TablerIcon $icon, string $color, string $tooltip): string
|
||||
{
|
||||
return generate_icon_html($icon, attributes: (new ComponentAttributeBag())
|
||||
->merge([
|
||||
'x-tooltip' => '{
|
||||
content: "' . $tooltip . '",
|
||||
theme: $store.theme,
|
||||
allowHTML: true,
|
||||
placement: "bottom",
|
||||
}',
|
||||
'style' => 'color: var(--dark-text, var(--text))',
|
||||
], escape: false)
|
||||
->color(IconComponent::class, $color), size: IconSize::Large)
|
||||
->toHtml();
|
||||
}
|
||||
|
||||
public function placeholder(): string
|
||||
{
|
||||
return generate_icon_html(TablerIcon::WorldQuestion, attributes: (new ComponentAttributeBag())
|
||||
->color(IconComponent::class, 'warning'), size: IconSize::Large)
|
||||
->toHtml();
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,8 @@ use Illuminate\Database\Query\Builder;
|
||||
* @property bool $is_locked
|
||||
* @property string $name
|
||||
* @property string[] $ignored_files
|
||||
* @property string $disk
|
||||
* @property int $backup_host_id
|
||||
* @property BackupHost $backupHost
|
||||
* @property string|null $checksum
|
||||
* @property int $bytes
|
||||
* @property string|null $upload_id
|
||||
@@ -41,10 +42,6 @@ class Backup extends Model implements Validatable
|
||||
|
||||
public const RESOURCE_NAME = 'backup';
|
||||
|
||||
public const ADAPTER_DAEMON = 'wings';
|
||||
|
||||
public const ADAPTER_AWS_S3 = 's3';
|
||||
|
||||
protected $attributes = [
|
||||
'is_successful' => false,
|
||||
'is_locked' => false,
|
||||
@@ -63,7 +60,7 @@ class Backup extends Model implements Validatable
|
||||
'is_locked' => ['boolean'],
|
||||
'name' => ['required', 'string'],
|
||||
'ignored_files' => ['array'],
|
||||
'disk' => ['required', 'string'],
|
||||
'backup_host_id' => ['required', 'numeric', 'exists:backup_hosts,id'],
|
||||
'checksum' => ['nullable', 'string'],
|
||||
'bytes' => ['numeric'],
|
||||
'upload_id' => ['nullable', 'string'],
|
||||
@@ -96,6 +93,11 @@ class Backup extends Model implements Validatable
|
||||
return $this->belongsTo(Server::class);
|
||||
}
|
||||
|
||||
public function backupHost(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(BackupHost::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder $query
|
||||
* @return BackupQueryBuilder<Model>
|
||||
|
||||
62
app/Models/BackupHost.php
Normal file
62
app/Models/BackupHost.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Contracts\Validatable;
|
||||
use App\Traits\HasValidation;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property string $name
|
||||
* @property string $schema
|
||||
* @property array<string, mixed> $configuration
|
||||
* @property CarbonImmutable $created_at
|
||||
* @property CarbonImmutable $updated_at
|
||||
* @property Collection|Node[] $nodes
|
||||
* @property int|null $nodes_count
|
||||
* @property Collection|Backup[] $backups
|
||||
* @property int|null $backups_count
|
||||
*/
|
||||
class BackupHost extends Model implements Validatable
|
||||
{
|
||||
use HasFactory;
|
||||
use HasValidation;
|
||||
|
||||
public const RESOURCE_NAME = 'backup_host';
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'schema',
|
||||
'configuration',
|
||||
];
|
||||
|
||||
/** @var array<array-key, string[]> */
|
||||
public static array $validationRules = [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'schema' => ['required', 'string', 'max:255'],
|
||||
'configuration' => ['nullable', 'array'],
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'configuration' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
public function nodes(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Node::class);
|
||||
}
|
||||
|
||||
public function backups(): HasMany
|
||||
{
|
||||
return $this->hasMany(Backup::class);
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,7 @@ use Symfony\Component\Yaml\Yaml;
|
||||
* @property int|null $allocations_count
|
||||
* @property Role[]|Collection $roles
|
||||
* @property int|null $roles_count
|
||||
* @property BackupHost[]|Collection $backupHosts
|
||||
*/
|
||||
class Node extends Model implements Validatable
|
||||
{
|
||||
@@ -255,6 +256,8 @@ class Node extends Model implements Validatable
|
||||
|
||||
/**
|
||||
* Gets the servers associated with a node.
|
||||
*
|
||||
* @return HasMany<Server, $this>
|
||||
*/
|
||||
public function servers(): HasMany
|
||||
{
|
||||
@@ -269,14 +272,17 @@ class Node extends Model implements Validatable
|
||||
return $this->hasMany(Allocation::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsToMany<DatabaseHost, $this>
|
||||
*/
|
||||
/** @return BelongsToMany<DatabaseHost, $this> */
|
||||
public function databaseHosts(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(DatabaseHost::class);
|
||||
}
|
||||
|
||||
public function backupHosts(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(BackupHost::class);
|
||||
}
|
||||
|
||||
public function roles(): HasManyThrough
|
||||
{
|
||||
return $this->hasManyThrough(Role::class, NodeRole::class, 'node_id', 'id', 'id', 'role_id');
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Models;
|
||||
use App\Contracts\Plugins\HasPluginSettings;
|
||||
use App\Enums\PluginCategory;
|
||||
use App\Enums\PluginStatus;
|
||||
use App\Exceptions\PluginIdMismatchException;
|
||||
use App\Facades\Plugins;
|
||||
use Exception;
|
||||
use Filament\Schemas\Components\Component;
|
||||
@@ -110,9 +111,10 @@ class Plugin extends Model implements HasPluginSettings
|
||||
|
||||
try {
|
||||
$data = File::json($path, JSON_THROW_ON_ERROR);
|
||||
$data['id'] = Str::lower($data['id']);
|
||||
|
||||
if ($data['id'] !== $plugin) {
|
||||
throw new Exception("Plugin id mismatch for folder name ($plugin) and id in plugin.json ({$data['id']})!");
|
||||
if ($data['id'] !== Str::lower($plugin)) {
|
||||
throw new PluginIdMismatchException("Plugin id mismatch for folder name ($plugin) and id in plugin.json ({$data['id']})!");
|
||||
}
|
||||
|
||||
$panels = null;
|
||||
@@ -157,13 +159,13 @@ class Plugin extends Model implements HasPluginSettings
|
||||
|
||||
if (!$exception instanceof JsonException) {
|
||||
$plugins[] = [
|
||||
'id' => $data['id'] ?? Str::uuid(),
|
||||
'name' => $data['name'] ?? $plugin,
|
||||
'id' => $exception instanceof PluginIdMismatchException ? $plugin : ($data['id'] ?? Str::uuid()),
|
||||
'name' => $data['name'] ?? Str::headline($plugin),
|
||||
'author' => $data['author'] ?? 'Unknown',
|
||||
'version' => '0.0.0',
|
||||
'description' => 'Plugin.json is invalid!',
|
||||
'version' => $data['version'] ?? '0.0.0',
|
||||
'description' => $exception instanceof PluginIdMismatchException ? $exception->getMessage() : 'Plugin.json is invalid!',
|
||||
'category' => PluginCategory::Plugin->value,
|
||||
'url' => null,
|
||||
'url' => $data['url'] ?? null,
|
||||
'update_url' => null,
|
||||
'namespace' => 'Error',
|
||||
'class' => 'Error',
|
||||
@@ -197,7 +199,7 @@ class Plugin extends Model implements HasPluginSettings
|
||||
|
||||
public function shouldLoad(?string $panelId = null): bool
|
||||
{
|
||||
return ($this->status === PluginStatus::Enabled || $this->status === PluginStatus::Errored) && (is_null($panelId) || !$this->panels || in_array($panelId, explode(',', $this->panels)));
|
||||
return $this->fullClass() !== '\\Error\\Error' && ($this->status === PluginStatus::Enabled || $this->status === PluginStatus::Errored) && (is_null($panelId) || !$this->panels || in_array($panelId, explode(',', $this->panels)));
|
||||
}
|
||||
|
||||
public function canEnable(): bool
|
||||
|
||||
@@ -23,14 +23,16 @@ class AccountCreated extends Notification implements ShouldQueue
|
||||
|
||||
public function toMail(User $notifiable): MailMessage
|
||||
{
|
||||
$locale = $notifiable->language ?? 'en';
|
||||
|
||||
$message = (new MailMessage())
|
||||
->greeting('Hello ' . $notifiable->username . '!')
|
||||
->line('You are receiving this email because an account has been created for you on ' . config('app.name') . '.')
|
||||
->line('Username: ' . $notifiable->username)
|
||||
->line('Email: ' . $notifiable->email);
|
||||
->greeting(trans('mail.greeting', ['name' => $notifiable->username], $locale))
|
||||
->line(trans('mail.account_created.body', ['app' => config('app.name')], $locale))
|
||||
->line(trans('mail.account_created.username', ['username' => $notifiable->username], $locale))
|
||||
->line(trans('mail.account_created.email', ['email' => $notifiable->email], $locale));
|
||||
|
||||
if (!is_null($this->token)) {
|
||||
return $message->action('Setup Your Account', Filament::getPanel('app')->getResetPasswordUrl($this->token, $notifiable));
|
||||
return $message->action(trans('mail.account_created.action', locale: $locale), Filament::getPanel('app')->getResetPasswordUrl($this->token, $notifiable));
|
||||
}
|
||||
|
||||
return $message;
|
||||
|
||||
@@ -24,10 +24,12 @@ class AddedToServer extends Notification implements ShouldQueue
|
||||
|
||||
public function toMail(User $notifiable): MailMessage
|
||||
{
|
||||
$locale = $notifiable->language ?? 'en';
|
||||
|
||||
return (new MailMessage())
|
||||
->greeting('Hello ' . $notifiable->username . '!')
|
||||
->line('You have been added as a subuser for the following server, allowing you certain control over the server.')
|
||||
->line('Server Name: ' . $this->server->name)
|
||||
->action('Visit Server', Console::getUrl(panel: 'server', tenant: $this->server));
|
||||
->greeting(trans('mail.greeting', ['name' => $notifiable->username], $locale))
|
||||
->line(trans('mail.added_to_server.body', locale: $locale))
|
||||
->line(trans('mail.added_to_server.server_name', ['name' => $this->server->name], $locale))
|
||||
->action(trans('mail.added_to_server.action', locale: $locale), Console::getUrl(panel: 'server', tenant: $this->server));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,9 +20,11 @@ class MailTested extends Notification
|
||||
|
||||
public function toMail(): MailMessage
|
||||
{
|
||||
$locale = $this->user->language ?? 'en';
|
||||
|
||||
return (new MailMessage())
|
||||
->subject('Panel Test Message')
|
||||
->greeting('Hello ' . $this->user->username . '!')
|
||||
->line('This is a test of the Panel mail system. You\'re good to go!');
|
||||
->subject(trans('mail.mail_tested.subject', locale: $locale))
|
||||
->greeting(trans('mail.greeting', ['name' => $this->user->username], $locale))
|
||||
->line(trans('mail.mail_tested.body', locale: $locale));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,11 +23,13 @@ class RemovedFromServer extends Notification implements ShouldQueue
|
||||
|
||||
public function toMail(User $notifiable): MailMessage
|
||||
{
|
||||
$locale = $notifiable->language ?? 'en';
|
||||
|
||||
return (new MailMessage())
|
||||
->error()
|
||||
->greeting('Hello ' . $notifiable->username . '.')
|
||||
->line('You have been removed as a subuser for the following server.')
|
||||
->line('Server Name: ' . $this->server->name)
|
||||
->action('Visit Panel', url(''));
|
||||
->greeting(trans('mail.greeting', ['name' => $notifiable->username], $locale))
|
||||
->line(trans('mail.removed_from_server.body', locale: $locale))
|
||||
->line(trans('mail.removed_from_server.server_name', ['name' => $this->server->name], $locale))
|
||||
->action(trans('mail.removed_from_server.action', locale: $locale), url(''));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,10 +24,12 @@ class ServerInstalled extends Notification implements ShouldQueue
|
||||
|
||||
public function toMail(User $notifiable): MailMessage
|
||||
{
|
||||
$locale = $notifiable->language ?? 'en';
|
||||
|
||||
return (new MailMessage())
|
||||
->greeting('Hello ' . $notifiable->username . '.')
|
||||
->line('Your server has finished installing and is now ready for you to use.')
|
||||
->line('Server Name: ' . $this->server->name)
|
||||
->action('Login and Begin Using', Console::getUrl(panel: 'server', tenant: $this->server));
|
||||
->greeting(trans('mail.greeting', ['name' => $notifiable->username], $locale))
|
||||
->line(trans('mail.server_installed.body', locale: $locale))
|
||||
->line(trans('mail.server_installed.server_name', ['name' => $this->server->name], $locale))
|
||||
->action(trans('mail.server_installed.action', locale: $locale), Console::getUrl(panel: 'server', tenant: $this->server));
|
||||
}
|
||||
}
|
||||
|
||||
29
app/Policies/BackupHostPolicy.php
Normal file
29
app/Policies/BackupHostPolicy.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\BackupHost;
|
||||
use App\Models\User;
|
||||
|
||||
class BackupHostPolicy
|
||||
{
|
||||
use DefaultAdminPolicies;
|
||||
|
||||
protected string $modelName = 'backupHost';
|
||||
|
||||
public function before(User $user, string $ability, string|BackupHost $backupHost): ?bool
|
||||
{
|
||||
// For "viewAny" the $backupHost param is the class name
|
||||
if (is_string($backupHost)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($backupHost->nodes as $node) {
|
||||
if (!$user->canTarget($node)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ use App\Services\Helpers\SoftwareVersionService;
|
||||
use Dedoc\Scramble\Scramble;
|
||||
use Dedoc\Scramble\Support\Generator\OpenApi;
|
||||
use Dedoc\Scramble\Support\Generator\SecurityScheme;
|
||||
use Filament\Auth\Http\Responses\Contracts\LoginResponse as LoginResponseContract;
|
||||
use Illuminate\Config\Repository;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
use Illuminate\Foundation\Application;
|
||||
@@ -115,8 +116,6 @@ class AppServiceProvider extends ServiceProvider
|
||||
'Up-to-Date' => $versionService->isLatestPanel() ? '<fg=green;options=bold>Yes</>' : '<fg=red;options=bold>No</>',
|
||||
]);
|
||||
|
||||
AboutCommand::add('Drivers', 'Backups', config('backups.default'));
|
||||
|
||||
AboutCommand::add('Environment', 'Installation Directory', base_path());
|
||||
}
|
||||
|
||||
@@ -125,6 +124,8 @@ class AppServiceProvider extends ServiceProvider
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->bind(LoginResponseContract::class, \App\Http\Responses\LoginResponse::class);
|
||||
|
||||
Scramble::ignoreDefaultRoutes();
|
||||
|
||||
/** @var PluginService $pluginService */
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Extensions\Backups\BackupManager;
|
||||
use Illuminate\Contracts\Support\DeferrableProvider;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class BackupsServiceProvider extends ServiceProvider implements DeferrableProvider
|
||||
{
|
||||
/**
|
||||
* Register the S3 backup disk.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->singleton(BackupManager::class, function ($app) {
|
||||
return new BackupManager($app);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return class-string[]
|
||||
*/
|
||||
public function provides(): array
|
||||
{
|
||||
return [BackupManager::class];
|
||||
}
|
||||
}
|
||||
26
app/Providers/Extensions/BackupAdapterServiceProvider.php
Normal file
26
app/Providers/Extensions/BackupAdapterServiceProvider.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers\Extensions;
|
||||
|
||||
use App\Extensions\BackupAdapter\BackupAdapterService;
|
||||
use App\Extensions\BackupAdapter\Schemas\S3BackupSchema;
|
||||
use App\Extensions\BackupAdapter\Schemas\WingsBackupSchema;
|
||||
use App\Repositories\Daemon\DaemonBackupRepository;
|
||||
use App\Services\Nodes\NodeJWTService;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class BackupAdapterServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->singleton(BackupAdapterService::class, function ($app) {
|
||||
$service = new BackupAdapterService();
|
||||
|
||||
// Default Backup adapter providers
|
||||
$service->register(new WingsBackupSchema($app->make(DaemonBackupRepository::class), $app->make(NodeJWTService::class)));
|
||||
$service->register(new S3BackupSchema($app->make(DaemonBackupRepository::class)));
|
||||
|
||||
return $service;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@ use Illuminate\Support\ServiceProvider;
|
||||
use Livewire\Component;
|
||||
use Livewire\Livewire;
|
||||
|
||||
use function Livewire\on;
|
||||
use function Livewire\before;
|
||||
use function Livewire\store;
|
||||
|
||||
class FilamentServiceProvider extends ServiceProvider
|
||||
@@ -74,7 +74,7 @@ class FilamentServiceProvider extends ServiceProvider
|
||||
fn () => Blade::render("@vite(['resources/js/app.js'])"),
|
||||
);
|
||||
|
||||
on('dehydrate', function (Component $component) {
|
||||
before('dehydrate', function (Component $component) {
|
||||
if (!Livewire::isLivewireRequest()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -29,6 +29,9 @@ abstract class PanelProvider extends BasePanelProvider
|
||||
{
|
||||
return $panel
|
||||
->spa(fn () => !request()->routeIs('filament.server.pages.console'))
|
||||
->spaUrlExceptions([
|
||||
'*/oauth/redirect/*',
|
||||
])
|
||||
->databaseNotifications()
|
||||
->brandName(config('app.name', 'Pelican'))
|
||||
->brandLogo(config('app.logo'))
|
||||
|
||||
@@ -8,28 +8,16 @@ use Illuminate\Http\Client\Response;
|
||||
|
||||
class DaemonBackupRepository extends DaemonRepository
|
||||
{
|
||||
protected ?string $adapter;
|
||||
|
||||
/**
|
||||
* Sets the backup adapter for this execution instance.
|
||||
*/
|
||||
public function setBackupAdapter(string $adapter): self
|
||||
{
|
||||
$this->adapter = $adapter;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells the remote Daemon to begin generating a backup for the server.
|
||||
*
|
||||
* @throws ConnectionException
|
||||
*/
|
||||
public function backup(Backup $backup): Response
|
||||
public function create(Backup $backup): Response
|
||||
{
|
||||
return $this->getHttpClient()->post("/api/servers/{$this->server->uuid}/backup",
|
||||
[
|
||||
'adapter' => $this->adapter ?? config('backups.default'),
|
||||
'adapter' => $backup->backupHost->schema,
|
||||
'uuid' => $backup->uuid,
|
||||
'ignore' => implode("\n", $backup->ignored_files),
|
||||
]
|
||||
@@ -45,7 +33,7 @@ class DaemonBackupRepository extends DaemonRepository
|
||||
{
|
||||
return $this->getHttpClient()->post("/api/servers/{$this->server->uuid}/backup/$backup->uuid/restore",
|
||||
[
|
||||
'adapter' => $backup->disk,
|
||||
'adapter' => $backup->backupHost->schema,
|
||||
'truncate_directory' => $truncate,
|
||||
'download_url' => $url ?? '',
|
||||
]
|
||||
|
||||
@@ -3,23 +3,15 @@
|
||||
namespace App\Services\Backups;
|
||||
|
||||
use App\Exceptions\Service\Backup\BackupLockedException;
|
||||
use App\Extensions\Backups\BackupManager;
|
||||
use App\Extensions\Filesystem\S3Filesystem;
|
||||
use App\Extensions\BackupAdapter\BackupAdapterService;
|
||||
use App\Models\Backup;
|
||||
use App\Repositories\Daemon\DaemonBackupRepository;
|
||||
use Aws\S3\S3Client;
|
||||
use Exception;
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Illuminate\Http\Response;
|
||||
use Throwable;
|
||||
|
||||
class DeleteBackupService
|
||||
{
|
||||
public function __construct(
|
||||
private ConnectionInterface $connection,
|
||||
private BackupManager $manager,
|
||||
private DaemonBackupRepository $daemonBackupRepository
|
||||
) {}
|
||||
public function __construct(private readonly ConnectionInterface $connection, private readonly BackupAdapterService $backupService) {}
|
||||
|
||||
/**
|
||||
* Deletes a backup from the system. If the backup is stored in S3 a request
|
||||
@@ -39,47 +31,15 @@ class DeleteBackupService
|
||||
throw new BackupLockedException();
|
||||
}
|
||||
|
||||
if ($backup->disk === Backup::ADAPTER_AWS_S3) {
|
||||
$this->deleteFromS3($backup);
|
||||
|
||||
return;
|
||||
$schema = $this->backupService->get($backup->backupHost->schema);
|
||||
if (!$schema) {
|
||||
throw new Exception('Backup has unknown backup adapter.');
|
||||
}
|
||||
|
||||
$this->connection->transaction(function () use ($backup) {
|
||||
try {
|
||||
$this->daemonBackupRepository->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;
|
||||
}
|
||||
}
|
||||
$this->connection->transaction(function () use ($schema, $backup) {
|
||||
$schema->deleteBackup($backup);
|
||||
|
||||
$backup->delete();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a backup from an S3 disk.
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
protected function deleteFromS3(Backup $backup): void
|
||||
{
|
||||
$this->connection->transaction(function () use ($backup) {
|
||||
$backup->delete();
|
||||
|
||||
/** @var S3Filesystem $adapter */
|
||||
$adapter = $this->manager->adapter(Backup::ADAPTER_AWS_S3);
|
||||
|
||||
/** @var S3Client $client */
|
||||
$client = $adapter->getClient();
|
||||
|
||||
$client->deleteObject([
|
||||
'Bucket' => $adapter->getBucket(),
|
||||
'Key' => sprintf('%s/%s.tar.gz', $backup->server->uuid, $backup->uuid),
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,60 +2,28 @@
|
||||
|
||||
namespace App\Services\Backups;
|
||||
|
||||
use App\Extensions\Backups\BackupManager;
|
||||
use App\Extensions\Filesystem\S3Filesystem;
|
||||
use App\Extensions\BackupAdapter\BackupAdapterService;
|
||||
use App\Models\Backup;
|
||||
use App\Models\User;
|
||||
use App\Services\Nodes\NodeJWTService;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Exception;
|
||||
|
||||
class DownloadLinkService
|
||||
{
|
||||
/**
|
||||
* DownloadLinkService constructor.
|
||||
*/
|
||||
public function __construct(private BackupManager $backupManager, private NodeJWTService $jwtService) {}
|
||||
public function __construct(private readonly BackupAdapterService $backupService) {}
|
||||
|
||||
/**
|
||||
* Returns the URL that allows for a backup to be downloaded by an individual
|
||||
* user, or by the daemon control software.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function handle(Backup $backup, User $user): string
|
||||
{
|
||||
if ($backup->disk === Backup::ADAPTER_AWS_S3) {
|
||||
return $this->getS3BackupUrl($backup);
|
||||
$schema = $this->backupService->get($backup->backupHost->schema);
|
||||
if (!$schema) {
|
||||
throw new Exception('Backup has unknown backup adapter.');
|
||||
}
|
||||
|
||||
$token = $this->jwtService
|
||||
->setExpiresAt(CarbonImmutable::now()->addMinutes(15))
|
||||
->setUser($user)
|
||||
->setClaims([
|
||||
'backup_uuid' => $backup->uuid,
|
||||
'server_uuid' => $backup->server->uuid,
|
||||
])
|
||||
->handle($backup->server->node, $user->id . $backup->server->uuid);
|
||||
|
||||
return sprintf('%s/download/backup?token=%s', $backup->server->node->getConnectionAddress(), $token->toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a signed URL that allows us to download a file directly out of a non-public
|
||||
* S3 bucket by using a signed URL.
|
||||
*/
|
||||
protected function getS3BackupUrl(Backup $backup): string
|
||||
{
|
||||
/** @var S3Filesystem $adapter */
|
||||
$adapter = $this->backupManager->adapter(Backup::ADAPTER_AWS_S3);
|
||||
|
||||
$request = $adapter->getClient()->createPresignedRequest(
|
||||
$adapter->getClient()->getCommand('GetObject', [
|
||||
'Bucket' => $adapter->getBucket(),
|
||||
'Key' => sprintf('%s/%s.tar.gz', $backup->server->uuid, $backup->uuid),
|
||||
'ContentType' => 'application/x-gzip',
|
||||
]),
|
||||
CarbonImmutable::now()->addMinutes(5)
|
||||
);
|
||||
|
||||
return $request->getUri()->__toString();
|
||||
return $schema->getDownloadLink($backup, $user);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
namespace App\Services\Backups;
|
||||
|
||||
use App\Exceptions\Service\Backup\TooManyBackupsException;
|
||||
use App\Extensions\Backups\BackupManager;
|
||||
use App\Extensions\BackupAdapter\BackupAdapterService;
|
||||
use App\Models\Backup;
|
||||
use App\Models\BackupHost;
|
||||
use App\Models\Server;
|
||||
use App\Repositories\Daemon\DaemonBackupRepository;
|
||||
use Exception;
|
||||
use Illuminate\Database\ConnectionInterface;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
|
||||
@@ -25,9 +26,8 @@ class InitiateBackupService
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly ConnectionInterface $connection,
|
||||
private readonly DaemonBackupRepository $daemonBackupRepository,
|
||||
private readonly DeleteBackupService $deleteBackupService,
|
||||
private readonly BackupManager $backupManager
|
||||
private readonly BackupAdapterService $backupService
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -110,20 +110,28 @@ class InitiateBackupService
|
||||
$this->deleteBackupService->handle($oldest);
|
||||
}
|
||||
|
||||
return $this->connection->transaction(function () use ($server, $name) {
|
||||
/** @var Backup $backup */
|
||||
$backup = Backup::query()->create([
|
||||
// TODO: select backup host via frontend
|
||||
$backupHost = $server->node->backupHosts()->first();
|
||||
if (!$backupHost) {
|
||||
$backupHost = BackupHost::doesntHave('nodes')->firstOrFail();
|
||||
}
|
||||
|
||||
$schema = $this->backupService->get($backupHost->schema);
|
||||
if (!$schema) {
|
||||
throw new Exception('Backup host has unknown backup adapter.');
|
||||
}
|
||||
|
||||
return $this->connection->transaction(function () use ($backupHost, $schema, $server, $name) {
|
||||
$backup = Backup::create([
|
||||
'server_id' => $server->id,
|
||||
'uuid' => Uuid::uuid4()->toString(),
|
||||
'name' => trim($name) ?: sprintf('Backup at %s', now()->toDateTimeString()),
|
||||
'ignored_files' => array_values($this->ignoredFiles ?? []),
|
||||
'disk' => $this->backupManager->getDefaultAdapter(),
|
||||
'backup_host_id' => $backupHost->id,
|
||||
'is_locked' => $this->isLocked,
|
||||
]);
|
||||
|
||||
$this->daemonBackupRepository->setServer($server)
|
||||
->setBackupAdapter($this->backupManager->getDefaultAdapter())
|
||||
->backup($backup);
|
||||
$schema->createBackup($backup);
|
||||
|
||||
return $backup;
|
||||
});
|
||||
|
||||
@@ -32,10 +32,10 @@ class EggConfigurationService
|
||||
*/
|
||||
public function handle(Server $server): array
|
||||
{
|
||||
$configs = $this->replacePlaceholders(
|
||||
$server,
|
||||
json_decode($server->egg->inherit_config_files)
|
||||
);
|
||||
$configFiles = json_decode($server->egg->inherit_config_files ?? '{}');
|
||||
$configs = is_object($configFiles) || is_array($configFiles)
|
||||
? $this->replacePlaceholders($server, $configFiles)
|
||||
: [];
|
||||
|
||||
return [
|
||||
'startup' => $this->convertStartupToNewFormat(json_decode($server->egg->inherit_config_startup, true)),
|
||||
|
||||
@@ -92,7 +92,7 @@ class EggExporterService
|
||||
return $this->yamlExport($decoded);
|
||||
}
|
||||
|
||||
return str_replace(["\r\n", '\\r\\n', '\\n'], "\n", $data);
|
||||
return str_replace("\r\n", "\n", $data);
|
||||
}
|
||||
|
||||
if (is_array($data)) {
|
||||
|
||||
@@ -189,6 +189,18 @@ class EggImporterService
|
||||
}
|
||||
}
|
||||
|
||||
// Convert YAML booleans to strings to prevent Laravel from converting them to 1/0
|
||||
// when saving to TEXT field. Required for validation rules like "in:true,false".
|
||||
if (isset($parsed['variables'])) {
|
||||
$parsed['variables'] = array_map(function ($variable) {
|
||||
if (isset($variable['default_value']) && is_bool($variable['default_value'])) {
|
||||
$variable['default_value'] = $variable['default_value'] ? 'true' : 'false';
|
||||
}
|
||||
|
||||
return $variable;
|
||||
}, $parsed['variables']);
|
||||
}
|
||||
|
||||
// Reserved env var name handling
|
||||
[$forbidden, $allowed] = collect($parsed['variables'])
|
||||
->map(fn ($variable) => array_merge(
|
||||
|
||||
@@ -39,7 +39,7 @@ class PluginService
|
||||
/** @var ClassLoader $classLoader */
|
||||
$classLoader = File::getRequire(base_path('vendor/autoload.php'));
|
||||
|
||||
$plugins = Plugin::query()->orderBy('load_order')->get();
|
||||
$plugins = Plugin::orderBy('load_order')->get();
|
||||
foreach ($plugins as $plugin) {
|
||||
try {
|
||||
// Filter out plugins that are not compatible with the current panel version
|
||||
@@ -54,6 +54,10 @@ class PluginService
|
||||
}
|
||||
}
|
||||
|
||||
if ($plugin->namespace === 'Error') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Always autoload src directory to make sure all class names can be resolved (e.g. in migrations)
|
||||
$namespace = $plugin->namespace . '\\';
|
||||
if (!array_key_exists($namespace, $classLoader->getPrefixesPsr4())) {
|
||||
@@ -134,7 +138,7 @@ class PluginService
|
||||
return;
|
||||
}
|
||||
|
||||
$plugins = Plugin::query()->orderBy('load_order')->get();
|
||||
$plugins = Plugin::orderBy('load_order')->get();
|
||||
foreach ($plugins as $plugin) {
|
||||
try {
|
||||
if (!$plugin->shouldLoad($panel->getId())) {
|
||||
@@ -168,7 +172,7 @@ class PluginService
|
||||
{
|
||||
$newPackages ??= [];
|
||||
|
||||
$plugins = Plugin::query()->orderBy('load_order')->get();
|
||||
$plugins = Plugin::orderBy('load_order')->get();
|
||||
foreach ($plugins as $plugin) {
|
||||
if (!$plugin->composer_packages) {
|
||||
continue;
|
||||
@@ -430,7 +434,7 @@ class PluginService
|
||||
/** @param array<string, mixed> $data */
|
||||
private function setMetaData(string|Plugin $plugin, array $data): void
|
||||
{
|
||||
$path = plugin_path($plugin instanceof Plugin ? $plugin->id : $plugin, 'plugin.json');
|
||||
$path = plugin_path($plugin->id, 'plugin.json');
|
||||
|
||||
if (File::exists($path)) {
|
||||
$pluginData = File::json($path, JSON_THROW_ON_ERROR);
|
||||
@@ -439,7 +443,6 @@ class PluginService
|
||||
|
||||
File::put($path, json_encode($pluginData, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||
|
||||
$plugin = $plugin instanceof Plugin ? $plugin : Plugin::findOrFail($plugin);
|
||||
$plugin->update($metaData);
|
||||
}
|
||||
}
|
||||
@@ -460,6 +463,8 @@ class PluginService
|
||||
public function updateLoadOrder(array $order): void
|
||||
{
|
||||
foreach ($order as $i => $plugin) {
|
||||
$plugin = Plugin::firstOrFail(str($plugin)->lower()->toString());
|
||||
|
||||
$this->setMetaData($plugin, [
|
||||
'load_order' => $i,
|
||||
]);
|
||||
@@ -468,7 +473,7 @@ class PluginService
|
||||
|
||||
public function hasThemePluginEnabled(): bool
|
||||
{
|
||||
$plugins = Plugin::query()->orderBy('load_order')->get();
|
||||
$plugins = Plugin::orderBy('load_order')->get();
|
||||
foreach ($plugins as $plugin) {
|
||||
if ($plugin->isTheme() && $plugin->status === PluginStatus::Enabled) {
|
||||
return true;
|
||||
@@ -483,7 +488,7 @@ class PluginService
|
||||
{
|
||||
$languages = [];
|
||||
|
||||
$plugins = Plugin::query()->orderBy('load_order')->get();
|
||||
$plugins = Plugin::orderBy('load_order')->get();
|
||||
foreach ($plugins as $plugin) {
|
||||
if ($plugin->status !== PluginStatus::Enabled || !$plugin->isLanguage()) {
|
||||
continue;
|
||||
@@ -500,7 +505,7 @@ class PluginService
|
||||
return config('panel.plugin.dev_mode', false);
|
||||
}
|
||||
|
||||
private function handlePluginException(string|Plugin $plugin, Exception $exception): void
|
||||
private function handlePluginException(Plugin $plugin, Exception $exception): void
|
||||
{
|
||||
if ($this->isDevModeActive()) {
|
||||
throw ($exception);
|
||||
|
||||
@@ -31,13 +31,22 @@ class GetUserPermissionsService
|
||||
'admin.websocket.transfer',
|
||||
];
|
||||
|
||||
if ($isAdmin) {
|
||||
return $isOwner || $user->can('update', $server) ? array_merge(['*'], $adminPermissions) : array_merge([SubuserPermission::WebsocketConnect->value], $adminPermissions);
|
||||
if ($isAdmin && ($isOwner || $user->can('update', $server))) {
|
||||
return array_merge(['*'], $adminPermissions);
|
||||
}
|
||||
|
||||
/** @var Subuser|null $subuser */
|
||||
$subuser = $server->subusers()->where('user_id', $user->id)->first();
|
||||
$subuserPermissions = $subuser !== null ? $subuser->permissions : [];
|
||||
|
||||
return $subuser->permissions ?? [];
|
||||
if ($isAdmin) {
|
||||
return array_unique(array_merge(
|
||||
[SubuserPermission::WebsocketConnect->value],
|
||||
$adminPermissions,
|
||||
$subuserPermissions,
|
||||
));
|
||||
}
|
||||
|
||||
return $subuserPermissions;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Services\Servers;
|
||||
|
||||
use App\Extensions\BackupAdapter\BackupAdapterService;
|
||||
use App\Extensions\BackupAdapter\Schemas\WingsBackupSchema;
|
||||
use App\Models\Allocation;
|
||||
use App\Models\Backup;
|
||||
use App\Models\Node;
|
||||
@@ -20,19 +22,22 @@ class TransferServerService
|
||||
* TransferService constructor.
|
||||
*/
|
||||
public function __construct(
|
||||
private ConnectionInterface $connection,
|
||||
private NodeJWTService $nodeJWTService,
|
||||
private readonly ConnectionInterface $connection,
|
||||
private readonly NodeJWTService $nodeJWTService,
|
||||
private readonly BackupAdapterService $backupService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param string[] $backup_uuids
|
||||
*/
|
||||
/** @param string[] $backup_uuids */ // TODO: add backup uuids to ServerTransfer model
|
||||
private function notify(ServerTransfer $transfer, UnencryptedToken $token, array $backup_uuids = []): void
|
||||
{
|
||||
$backups = [];
|
||||
if (config('backups.default') === Backup::ADAPTER_DAEMON) {
|
||||
$backups = $backup_uuids;
|
||||
}
|
||||
// Make sure only wings backups of the current server are forwarded in the wings request
|
||||
$backups = Backup::where('server_id', $transfer->server_id)
|
||||
->whereIn('uuid', $backup_uuids)
|
||||
->get()
|
||||
->filter(fn (Backup $backup) => $this->backupService->get($backup->backupHost->schema) instanceof WingsBackupSchema)
|
||||
->pluck('uuid')
|
||||
->all();
|
||||
|
||||
Http::daemon($transfer->oldNode)->post("/api/servers/{$transfer->server->uuid}/transfer", [
|
||||
'url' => $transfer->newNode->getConnectionAddress() . '/api/transfers',
|
||||
'token' => 'Bearer ' . $token->toString(),
|
||||
@@ -48,11 +53,11 @@ class TransferServerService
|
||||
* Starts a transfer of a server to a new node.
|
||||
*
|
||||
* @param int[] $additional_allocations
|
||||
* @param string[] $backup_uuid
|
||||
* @param string[] $backup_uuids
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function handle(Server $server, int $node_id, ?int $allocation_id = null, ?array $additional_allocations = [], ?array $backup_uuid = []): bool
|
||||
public function handle(Server $server, int $node_id, ?int $allocation_id = null, ?array $additional_allocations = [], array $backup_uuids = []): bool
|
||||
{
|
||||
$additional_allocations = array_map(intval(...), $additional_allocations);
|
||||
|
||||
@@ -103,7 +108,7 @@ class TransferServerService
|
||||
->handle($transfer->newNode, $server->uuid, 'sha256');
|
||||
|
||||
// Notify the source node of the pending outgoing transfer.
|
||||
$this->notify($transfer, $token, $backup_uuid);
|
||||
$this->notify($transfer, $token, $backup_uuids);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ class EggTransformer extends BaseTransformer
|
||||
{
|
||||
$model->loadMissing('configFrom');
|
||||
|
||||
$files = json_decode($model->inherit_config_files, true, 512, JSON_THROW_ON_ERROR);
|
||||
$files = json_decode($model->inherit_config_files ?: '{}', true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
$model->loadMissing('scriptFrom');
|
||||
|
||||
@@ -53,9 +53,9 @@ class EggTransformer extends BaseTransformer
|
||||
'docker_images' => $model->docker_images,
|
||||
'config' => [
|
||||
'files' => $files,
|
||||
'startup' => json_decode($model->inherit_config_startup, true),
|
||||
'startup' => json_decode($model->inherit_config_startup ?: '{}', true),
|
||||
'stop' => $model->inherit_config_stop,
|
||||
'logs' => json_decode($model->inherit_config_logs, true),
|
||||
'logs' => json_decode($model->inherit_config_logs ?: '{}', true),
|
||||
'file_denylist' => $model->inherit_file_denylist,
|
||||
'extends' => $model->config_from,
|
||||
],
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
return [
|
||||
App\Providers\ActivityLogServiceProvider::class,
|
||||
App\Providers\AppServiceProvider::class,
|
||||
App\Providers\BackupsServiceProvider::class,
|
||||
App\Providers\EventServiceProvider::class,
|
||||
App\Providers\Extensions\AvatarServiceProvider::class,
|
||||
App\Providers\Extensions\BackupAdapterServiceProvider::class,
|
||||
App\Providers\Extensions\CaptchaServiceProvider::class,
|
||||
App\Providers\Extensions\FeatureServiceProvider::class,
|
||||
App\Providers\Extensions\OAuthServiceProvider::class,
|
||||
|
||||
@@ -13,11 +13,11 @@
|
||||
"calebporzio/sushi": "^2.5",
|
||||
"dedoc/scramble": "^0.13",
|
||||
"filament/filament": "^4.5",
|
||||
"gboquizosanchez/filament-log-viewer": "^2.1",
|
||||
"gboquizosanchez/filament-log-viewer": "^2.2",
|
||||
"guzzlehttp/guzzle": "^7.10",
|
||||
"laravel/framework": "^12.49",
|
||||
"laravel/framework": "^12.52",
|
||||
"laravel/helpers": "^1.8",
|
||||
"laravel/sanctum": "^4.2",
|
||||
"laravel/sanctum": "^4.3",
|
||||
"laravel/socialite": "^5.24",
|
||||
"laravel/tinker": "^2.10.1",
|
||||
"laravel/ui": "^4.6",
|
||||
@@ -34,7 +34,7 @@
|
||||
"socialiteproviders/steam": "^4.3",
|
||||
"spatie/laravel-data": "^4.19",
|
||||
"spatie/laravel-fractal": "^6.3",
|
||||
"spatie/laravel-health": "^1.34",
|
||||
"spatie/laravel-health": "^1.37",
|
||||
"spatie/laravel-permission": "^6.24",
|
||||
"spatie/laravel-query-builder": "^6.4",
|
||||
"spatie/temporary-directory": "^2.3",
|
||||
@@ -55,8 +55,7 @@
|
||||
"nunomaduro/collision": "^8.6",
|
||||
"pestphp/pest": "^3.7",
|
||||
"pestphp/pest-plugin-faker": "^3.0",
|
||||
"pestphp/pest-plugin-livewire": "^3.0",
|
||||
"spatie/laravel-ignition": "^2.9"
|
||||
"pestphp/pest-plugin-livewire": "^3.0"
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
|
||||
960
composer.lock
generated
960
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -4,11 +4,6 @@ use App\Http\Controllers\Api\Remote\Backups\BackupRemoteUploadController;
|
||||
use App\Models\Backup;
|
||||
|
||||
return [
|
||||
// The backup driver to use for this Panel instance. All client generated server backups
|
||||
// will be stored in this location by default. It is possible to change this once backups
|
||||
// have been made, without losing data.
|
||||
'default' => env('APP_BACKUP_DRIVER', Backup::ADAPTER_DAEMON),
|
||||
|
||||
// This value is used to determine the lifespan of UploadPart presigned urls that daemon
|
||||
// uses to upload backups to S3 storage. Value is in minutes, so this would default to an hour.
|
||||
'presigned_url_lifespan' => (int) env('BACKUP_PRESIGNED_URL_LIFESPAN', 60),
|
||||
@@ -31,37 +26,4 @@ return [
|
||||
'limit' => (int) env('BACKUP_THROTTLE_LIMIT', 2),
|
||||
'period' => (int) env('BACKUP_THROTTLE_PERIOD', 600),
|
||||
],
|
||||
|
||||
'disks' => [
|
||||
// There is no configuration for the local disk for Daemon. That configuration
|
||||
// is determined by the Daemon configuration, and not the Panel.
|
||||
'wings' => [
|
||||
'adapter' => Backup::ADAPTER_DAEMON,
|
||||
],
|
||||
|
||||
// Configuration for storing backups in Amazon S3. This uses the same credentials
|
||||
// specified in filesystems.php but does include some more specific settings for
|
||||
// backups, notably bucket, location, and use_accelerate_endpoint.
|
||||
's3' => [
|
||||
'adapter' => Backup::ADAPTER_AWS_S3,
|
||||
|
||||
'region' => env('AWS_DEFAULT_REGION'),
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||
|
||||
// The S3 bucket to use for backups.
|
||||
'bucket' => env('AWS_BACKUPS_BUCKET'),
|
||||
|
||||
// The location within the S3 bucket where backups will be stored. Backups
|
||||
// are stored within a folder using the server's UUID as the name. Each
|
||||
// backup for that server lives within that folder.
|
||||
'prefix' => env('AWS_BACKUPS_BUCKET') ?? '',
|
||||
|
||||
'endpoint' => env('AWS_ENDPOINT'),
|
||||
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
|
||||
'use_accelerate_endpoint' => env('AWS_BACKUPS_USE_ACCELERATE', false),
|
||||
|
||||
'storage_class' => env('AWS_BACKUPS_STORAGE_CLASS'),
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
@@ -24,7 +24,6 @@ class BackupFactory extends Factory
|
||||
return [
|
||||
'uuid' => Uuid::uuid4()->toString(),
|
||||
'name' => $this->faker->sentence(),
|
||||
'disk' => Backup::ADAPTER_DAEMON,
|
||||
'is_successful' => true,
|
||||
'created_at' => CarbonImmutable::now(),
|
||||
'completed_at' => CarbonImmutable::now(),
|
||||
|
||||
28
database/Factories/BackupHostFactory.php
Normal file
28
database/Factories/BackupHostFactory.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\BackupHost;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
class BackupHostFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* The name of the factory's corresponding model.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $model = BackupHost::class;
|
||||
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'name' => $this->faker->colorName(),
|
||||
'schema' => 'wings',
|
||||
'configuration' => null,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -8,29 +8,35 @@ return new class extends Migration
|
||||
{
|
||||
$mappings = [
|
||||
// Forge Minecraft
|
||||
'ed072427-f209-4603-875c-f540c6dd5a65' => [
|
||||
'new_uuid' => 'd6018085-eecc-42bf-bf8c-51ea45a69ace',
|
||||
'd6018085-eecc-42bf-bf8c-51ea45a69ace' => [
|
||||
'new_uuid' => 'ed072427-f209-4603-875c-f540c6dd5a65',
|
||||
'new_update_url' => 'https://raw.githubusercontent.com/pelican-eggs/minecraft/refs/heads/main/java/forge/egg-forge-minecraft.yaml',
|
||||
],
|
||||
|
||||
// Paper
|
||||
'5da37ef6-58da-4169-90a6-e683e1721247' => [
|
||||
'new_uuid' => '150956be-4164-4086-9057-631ae95505e9',
|
||||
'150956be-4164-4086-9057-631ae95505e9' => [
|
||||
'new_uuid' => '5da37ef6-58da-4169-90a6-e683e1721247',
|
||||
'new_update_url' => 'https://raw.githubusercontent.com/pelican-eggs/minecraft/refs/heads/main/java/paper/egg-paper.yaml',
|
||||
],
|
||||
|
||||
// Garrys Mod
|
||||
'60ef81d4-30a2-4d98-ab64-f59c69e2f915' => [
|
||||
'new_uuid' => 'c0b2f96a-f753-4d82-a73e-6e5be2bbadd5',
|
||||
'c0b2f96a-f753-4d82-a73e-6e5be2bbadd5' => [
|
||||
'new_uuid' => '60ef81d4-30a2-4d98-ab64-f59c69e2f915',
|
||||
'new_update_url' => 'https://raw.githubusercontent.com/pelican-eggs/games-steamcmd/refs/heads/main/gmod/egg-garrys-mod.yaml',
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($mappings as $oldUuid => $newData) {
|
||||
DB::table('eggs')->where('uuid', $oldUuid)->update([
|
||||
'uuid' => $newData['new_uuid'],
|
||||
'update_url' => $newData['new_update_url'],
|
||||
]);
|
||||
if (DB::table('eggs')->where('uuid', $newData['new_uuid'])->exists()) {
|
||||
DB::table('eggs')->where('uuid', $newData['new_uuid'])->update([
|
||||
'update_url' => $newData['new_update_url'],
|
||||
]);
|
||||
} else {
|
||||
DB::table('eggs')->where('uuid', $oldUuid)->update([
|
||||
'uuid' => $newData['new_uuid'],
|
||||
'update_url' => $newData['new_update_url'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
use App\Models\BackupHost;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('backup_hosts', function (Blueprint $table) {
|
||||
$table->increments('id');
|
||||
$table->string('name');
|
||||
$table->string('schema');
|
||||
$table->json('configuration')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('backup_host_node', function (Blueprint $table) {
|
||||
$table->unsignedInteger('node_id');
|
||||
$table->foreign('node_id')->references('id')->on('nodes')->cascadeOnDelete();
|
||||
|
||||
$table->unsignedInteger('backup_host_id');
|
||||
$table->foreign('backup_host_id')->references('id')->on('backup_hosts')->cascadeOnDelete();
|
||||
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['node_id']);
|
||||
});
|
||||
|
||||
Schema::table('backups', function (Blueprint $table) {
|
||||
$table->unsignedInteger('backup_host_id')->after('disk');
|
||||
$table->foreign('backup_host_id')->references('id')->on('backup_hosts');
|
||||
|
||||
$table->dropColumn('disk');
|
||||
});
|
||||
|
||||
$oldDriver = env('APP_BACKUP_DRIVER', 'wings');
|
||||
|
||||
$oldConfiguration = null;
|
||||
if ($oldDriver === 's3') {
|
||||
$oldConfiguration = [
|
||||
'region' => env('AWS_DEFAULT_REGION'),
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||
'bucket' => env('AWS_BACKUPS_BUCKET'),
|
||||
'prefix' => env('AWS_BACKUPS_BUCKET', ''),
|
||||
'endpoint' => env('AWS_ENDPOINT'),
|
||||
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
|
||||
'use_accelerate_endpoint' => env('AWS_BACKUPS_USE_ACCELERATE', false),
|
||||
'storage_class' => env('AWS_BACKUPS_STORAGE_CLASS'),
|
||||
];
|
||||
}
|
||||
|
||||
$backupHost = BackupHost::create([
|
||||
'name' => $oldDriver === 's3' ? 'Remote' : 'Local',
|
||||
'schema' => $oldDriver,
|
||||
'configuration' => $oldConfiguration,
|
||||
]);
|
||||
|
||||
DB::table('backups')->update(['backup_host_id' => $backupHost->id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('backups', function (Blueprint $table) {
|
||||
$table->string('disk')->after('backup_host_id');
|
||||
|
||||
$table->dropForeign(['backup_host_id']);
|
||||
$table->dropColumn('backup_host_id');
|
||||
});
|
||||
|
||||
Schema::dropIfExists('backup_hosts');
|
||||
|
||||
Schema::dropIfExists('backup_host_node');
|
||||
}
|
||||
};
|
||||
@@ -5,11 +5,11 @@
|
||||
{$CADDY_STRICT_PROXIES}
|
||||
}
|
||||
admin off
|
||||
{$PARSED_AUTO_HTTPS}
|
||||
{$PARSED_LE_EMAIL}
|
||||
{$CADDY_AUTO_HTTPS}
|
||||
{$CADDY_LE_EMAIL}
|
||||
}
|
||||
|
||||
{$PARSED_APP_URL} {
|
||||
{$CADDY_APP_URL} {
|
||||
root * /var/www/html/public
|
||||
encode gzip
|
||||
|
||||
|
||||
@@ -1,34 +1,48 @@
|
||||
#!/bin/ash -e
|
||||
# shellcheck shell=dash
|
||||
|
||||
# check for .env file or symlink and generate app keys if missing
|
||||
if [ -f /var/www/html/.env ]; then
|
||||
echo "external vars exist."
|
||||
if [ -f /pelican-data/.env ]; then
|
||||
echo ".env vars exist."
|
||||
# load specific env vars from .env used in the entrypoint and they are not already set
|
||||
for VAR in "APP_KEY" "APP_INSTALLED" "DB_CONNECTION" "DB_HOST" "DB_PORT"; do if ! (printenv | grep -q ${VAR}); then export $(grep ${VAR} .env | grep -ve "^#"); fi; done
|
||||
for VAR in "APP_KEY" "APP_INSTALLED" "DB_CONNECTION" "DB_HOST" "DB_PORT"; do
|
||||
echo "checking for ${VAR}"
|
||||
## skip if it looks like it might try to execute code
|
||||
if (grep "${VAR}" .env | grep -qE "\$\(|=\`|\$#"); then echo "var in .env may be executable or a comment, skipping"; continue; fi
|
||||
# if the variable is in .env then set it
|
||||
if (grep -q "${VAR}" .env); then
|
||||
echo "loading ${VAR} from .env"
|
||||
export "$(grep "${VAR}" .env | sed 's/"//g')"
|
||||
continue
|
||||
fi
|
||||
## variable wasn't loaded or in the env to set
|
||||
echo "didn't find variable to set"
|
||||
done
|
||||
else
|
||||
echo "external vars don't exist."
|
||||
echo ".env vars don't exist."
|
||||
# webroot .env is symlinked to this path
|
||||
touch /pelican-data/.env
|
||||
|
||||
# manually generate a key because key generate --force fails
|
||||
if [ -z ${APP_KEY} ]; then
|
||||
echo -e "Generating key."
|
||||
if [ -z "${APP_KEY}" ]; then
|
||||
echo "No key set, Generating key."
|
||||
APP_KEY=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1)
|
||||
echo -e "Generated app key: $APP_KEY"
|
||||
echo -e "APP_KEY=$APP_KEY" > /pelican-data/.env
|
||||
echo "APP_KEY=$APP_KEY" > /pelican-data/.env
|
||||
echo "Generated app key written to .env file"
|
||||
else
|
||||
echo -e "APP_KEY exists in environment, using that."
|
||||
echo -e "APP_KEY=$APP_KEY" > /pelican-data/.env
|
||||
echo "APP_KEY exists in environment, using that."
|
||||
echo "APP_KEY=$APP_KEY" > /pelican-data/.env
|
||||
fi
|
||||
|
||||
# enable installer
|
||||
echo -e "APP_INSTALLED=false" >> /pelican-data/.env
|
||||
echo "APP_INSTALLED=false" >> /pelican-data/.env
|
||||
fi
|
||||
|
||||
# create directories for volumes
|
||||
mkdir -p /pelican-data/database /pelican-data/storage/avatars /pelican-data/storage/fonts /pelican-data/storage/icons /pelican-data/plugins /var/www/html/storage/logs/supervisord 2>/dev/null
|
||||
|
||||
# if the app is installed then we need to run migrations on start. New installs will run migrations when you run the installer.
|
||||
if [ "${APP_INSTALLED}" == "true" ]; then
|
||||
if [ "${APP_INSTALLED}" = "true" ]; then
|
||||
#if the db is anything but sqlite wait until it's accepting connections
|
||||
if [ "${DB_CONNECTION}" != "sqlite" ]; then
|
||||
# check for DB up before starting the panel
|
||||
@@ -39,36 +53,44 @@ if [ "${APP_INSTALLED}" == "true" ]; then
|
||||
# wait for 1 seconds before check again
|
||||
sleep 1
|
||||
done
|
||||
else
|
||||
echo "using sqlite database"
|
||||
fi
|
||||
|
||||
# run migration
|
||||
php artisan migrate --force
|
||||
fi
|
||||
|
||||
echo -e "Optimizing Filament"
|
||||
echo "Optimizing Filament"
|
||||
php artisan filament:optimize
|
||||
|
||||
# default to caddy not starting
|
||||
export SUPERVISORD_CADDY=false
|
||||
export PARSED_APP_URL=${APP_URL}
|
||||
export CADDY_APP_URL="${APP_URL}"
|
||||
|
||||
# checking if app url is using https
|
||||
if echo "${APP_URL}" | grep -qE '^https://'; then
|
||||
# checking if app url is https
|
||||
if (echo "${APP_URL}" | grep -qE '^https://'); then
|
||||
# check lets encrypt email was set without a proxy
|
||||
if [ -z "${LE_EMAIL}" ] && [ "${BEHIND_PROXY}" != "true" ]; then
|
||||
echo "when app url is https a lets encrypt email must be set when not behind a proxy"
|
||||
exit 1
|
||||
fi
|
||||
echo "https domain found setting email var"
|
||||
export PARSED_LE_EMAIL="email ${LE_EMAIL}"
|
||||
export CADDY_LE_EMAIL="email ${LE_EMAIL}"
|
||||
fi
|
||||
|
||||
# when running behind a proxy
|
||||
if [ "${BEHIND_PROXY}" == "true" ]; then
|
||||
if [ "${BEHIND_PROXY}" = "true" ]; then
|
||||
echo "running behind proxy"
|
||||
echo "listening on port 80 internally"
|
||||
export PARSED_LE_EMAIL=""
|
||||
export PARSED_APP_URL=":80"
|
||||
export PARSED_AUTO_HTTPS="auto_https off"
|
||||
export ASSET_URL=${APP_URL}
|
||||
export CADDY_LE_EMAIL=""
|
||||
export CADDY_APP_URL=":80"
|
||||
export CADDY_AUTO_HTTPS="auto_https off"
|
||||
export ASSET_URL="${APP_URL}"
|
||||
fi
|
||||
|
||||
# disable caddy if SKIP_CADDY is set
|
||||
if [ "${SKIP_CADDY:-}" == "true" ]; then
|
||||
if [ "${SKIP_CADDY:-}" = "true" ]; then
|
||||
echo "Starting PHP-FPM only"
|
||||
else
|
||||
echo "Starting PHP-FPM and Caddy"
|
||||
@@ -76,8 +98,9 @@ else
|
||||
export SUPERVISORD_CADDY=true
|
||||
|
||||
# handle trusted proxies for caddy when variable has data
|
||||
if [ ! -z ${TRUSTED_PROXIES} ]; then
|
||||
export CADDY_TRUSTED_PROXIES=$(echo "trusted_proxies static ${TRUSTED_PROXIES}" | sed 's/,/ /g')
|
||||
if [ -n "${TRUSTED_PROXIES:-}" ]; then
|
||||
FORMATTED_PROXIES=$(echo "trusted_proxies static ${TRUSTED_PROXIES}" | sed 's/,/ /g')
|
||||
export CADDY_TRUSTED_PROXIES="${FORMATTED_PROXIES}"
|
||||
export CADDY_STRICT_PROXIES="trusted_proxies_strict"
|
||||
fi
|
||||
fi
|
||||
|
||||
14
lang/en/admin/backuphost.php
Normal file
14
lang/en/admin/backuphost.php
Normal file
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'model_label' => 'Backup Host|Backup Hosts',
|
||||
'name' => 'Name',
|
||||
'schema' => 'Schema',
|
||||
'backups' => 'Backups',
|
||||
'linked_nodes' => 'Linked Nodes',
|
||||
'all_nodes' => 'All Nodes',
|
||||
'configuration' => 'Configuration',
|
||||
'no_configuration' => 'No additional configuration required',
|
||||
'no_backup_hosts' => 'No Backup Hosts',
|
||||
'local_backups_only' => 'All backups will be created locally on the respective node',
|
||||
];
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user