diff --git a/app/Actions/Database/CreateBackup.php b/app/Actions/Database/ManageBackup.php similarity index 71% rename from app/Actions/Database/CreateBackup.php rename to app/Actions/Database/ManageBackup.php index 7ece8f3..0fe7f72 100644 --- a/app/Actions/Database/CreateBackup.php +++ b/app/Actions/Database/ManageBackup.php @@ -2,6 +2,7 @@ namespace App\Actions\Database; +use App\Enums\BackupFileStatus; use App\Enums\BackupStatus; use App\Enums\DatabaseStatus; use App\Models\Backup; @@ -10,7 +11,7 @@ use Illuminate\Validation\Rule; use Illuminate\Validation\ValidationException; -class CreateBackup +class ManageBackup { /** * @throws AuthorizationException @@ -34,6 +35,31 @@ public function create(Server $server, array $input): Backup return $backup; } + public function update(Backup $backup, array $input): void + { + $backup->interval = $input['interval'] == 'custom' ? $input['custom_interval'] : $input['interval']; + $backup->keep_backups = $input['keep']; + $backup->save(); + } + + public function delete(Backup $backup): void + { + $backup->status = BackupStatus::DELETING; + $backup->save(); + + dispatch(function () use ($backup) { + $files = $backup->files; + foreach ($files as $file) { + $file->status = BackupFileStatus::DELETING; + $file->save(); + + $file->deleteFile(); + } + + $backup->delete(); + }); + } + public static function rules(Server $server, array $input): array { $rules = [ diff --git a/app/Actions/Database/ManageBackupFile.php b/app/Actions/Database/ManageBackupFile.php new file mode 100644 index 0000000..a88212a --- /dev/null +++ b/app/Actions/Database/ManageBackupFile.php @@ -0,0 +1,39 @@ +id}_{$file->name}.zip"; + + if (! Storage::disk('backups')->exists($localFilename)) { + $file->backup->server->ssh()->download( + Storage::disk('backups')->path($localFilename), + $file->path() + ); + } + + return Storage::disk('backups')->download($localFilename, $file->name.'.zip'); + } + + public function delete(BackupFile $file): void + { + $file->status = BackupFileStatus::DELETING; + $file->save(); + + dispatch(function () use ($file) { + $file->deleteFile(); + }); + } +} diff --git a/app/Enums/BackupStatus.php b/app/Enums/BackupStatus.php index 85525e1..d44ebec 100644 --- a/app/Enums/BackupStatus.php +++ b/app/Enums/BackupStatus.php @@ -7,4 +7,6 @@ final class BackupStatus const RUNNING = 'running'; const FAILED = 'failed'; + + const DELETING = 'deleting'; } diff --git a/app/Helpers/SSH.php b/app/Helpers/SSH.php index 4a1900c..70f6e3c 100755 --- a/app/Helpers/SSH.php +++ b/app/Helpers/SSH.php @@ -181,7 +181,7 @@ public function download(string $local, string $remote): void $this->connect(true); } - $this->connection->get($remote, $local, SFTP::SOURCE_LOCAL_FILE); + $this->connection->get($remote, $local); } /** diff --git a/app/Models/Backup.php b/app/Models/Backup.php index 529f394..2f6b1ec 100644 --- a/app/Models/Backup.php +++ b/app/Models/Backup.php @@ -56,8 +56,17 @@ public static function boot(): void public static array $statusColors = [ BackupStatus::RUNNING => 'success', BackupStatus::FAILED => 'danger', + BackupStatus::DELETING => 'warning', ]; + public function isCustomInterval(): bool + { + $intervals = array_keys(config('core.cronjob_intervals')); + $intervals = array_filter($intervals, fn ($interval) => $interval !== 'custom'); + + return ! in_array($this->interval, $intervals); + } + public function server(): BelongsTo { return $this->belongsTo(Server::class); diff --git a/app/Models/BackupFile.php b/app/Models/BackupFile.php index b8c60db..7a177f2 100644 --- a/app/Models/BackupFile.php +++ b/app/Models/BackupFile.php @@ -2,7 +2,9 @@ namespace App\Models; +use App\Actions\Database\ManageBackupFile; use App\Enums\BackupFileStatus; +use App\Enums\StorageProvider as StorageProviderAlias; use Carbon\Carbon; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -46,19 +48,11 @@ protected static function booted(): void ->where('id', '<=', $lastFileToKeep->id) ->get(); foreach ($files as $file) { - $file->delete(); + app(ManageBackupFile::class)->delete($file); } } } }); - - static::deleting(function (BackupFile $backupFile) { - $provider = $backupFile->backup->storage->provider(); - $path = $backupFile->storagePath(); - dispatch(function () use ($provider, $path) { - $provider->delete([$path]); - }); - }); } public static array $statusColors = [ @@ -71,18 +65,52 @@ protected static function booted(): void BackupFileStatus::RESTORE_FAILED => 'danger', ]; + public function isAvailable(): bool + { + return ! in_array( + $this->status, + [BackupFileStatus::CREATING, BackupFileStatus::FAILED, BackupFileStatus::DELETING] + ); + } + + public function isLocal(): bool + { + return $this->backup->storage->provider === StorageProviderAlias::LOCAL; + } + public function backup(): BelongsTo { return $this->belongsTo(Backup::class); } - public function path(): string + public function tempPath(): string { return '/home/'.$this->backup->server->getSshUser().'/'.$this->name.'.zip'; } - public function storagePath(): string + public function path(): string { - return '/'.$this->backup->database->name.'/'.$this->name.'.zip'; + $storage = $this->backup->storage; + $databaseName = $this->backup->database->name; + + return match ($storage->provider) { + StorageProviderAlias::DROPBOX => '/'.$databaseName.'/'.$this->name.'.zip', + StorageProviderAlias::S3, StorageProviderAlias::FTP, StorageProviderAlias::LOCAL => implode('/', [ + rtrim($storage->credentials['path'], '/'), + $databaseName, + $this->name.'.zip', + ]), + default => '', + }; + } + + public function deleteFile(): void + { + try { + $storage = $this->backup->storage->provider()->ssh($this->backup->server); + $storage->delete($this->path()); + } finally { + $this->delete(); + } } } diff --git a/app/SSH/OS/OS.php b/app/SSH/OS/OS.php index 3df64b8..15e4bd3 100644 --- a/app/SSH/OS/OS.php +++ b/app/SSH/OS/OS.php @@ -243,6 +243,16 @@ public function resourceInfo(): array ]; } + public function deleteFile(string $path): void + { + $this->server->ssh()->exec( + $this->getScript('delete-file.sh', [ + 'path' => $path, + ]), + 'delete-file' + ); + } + private function deleteTempFile(string $name): void { if (Storage::disk('local')->exists($name)) { diff --git a/app/SSH/OS/scripts/delete-file.sh b/app/SSH/OS/scripts/delete-file.sh new file mode 100644 index 0000000..4a45e4a --- /dev/null +++ b/app/SSH/OS/scripts/delete-file.sh @@ -0,0 +1 @@ +rm -f __path__ diff --git a/app/SSH/Services/Database/AbstractDatabase.php b/app/SSH/Services/Database/AbstractDatabase.php index d72a88b..2f9bddb 100755 --- a/app/SSH/Services/Database/AbstractDatabase.php +++ b/app/SSH/Services/Database/AbstractDatabase.php @@ -159,12 +159,12 @@ public function runBackup(BackupFile $backupFile): void // upload to storage $upload = $backupFile->backup->storage->provider()->ssh($this->service->server)->upload( + $backupFile->tempPath(), $backupFile->path(), - $backupFile->storagePath(), ); // cleanup - $this->service->server->ssh()->exec('rm '.$backupFile->path()); + $this->service->server->ssh()->exec('rm '.$backupFile->tempPath()); $backupFile->size = $upload['size']; $backupFile->save(); @@ -174,14 +174,14 @@ public function restoreBackup(BackupFile $backupFile, string $database): void { // download $backupFile->backup->storage->provider()->ssh($this->service->server)->download( - $backupFile->storagePath(), - $backupFile->name.'.zip', + $backupFile->path(), + $backupFile->tempPath(), ); $this->service->server->ssh()->exec( $this->getScript($this->getScriptsDir().'/restore.sh', [ 'database' => $database, - 'file' => $backupFile->name, + 'file' => rtrim($backupFile->tempPath(), '.zip'), ]), 'restore-database' ); diff --git a/app/SSH/Storage/Dropbox.php b/app/SSH/Storage/Dropbox.php index d560b95..5ea570d 100644 --- a/app/SSH/Storage/Dropbox.php +++ b/app/SSH/Storage/Dropbox.php @@ -45,11 +45,14 @@ public function download(string $src, string $dest): void ); } - /** - * @TODO Implement delete method - */ - public function delete(string $path): void + public function delete(string $src): void { - // + $this->server->ssh()->exec( + $this->getScript('dropbox/delete-file.sh', [ + 'src' => $src, + 'token' => $this->storageProvider->credentials['token'], + ]), + 'delete-from-dropbox' + ); } } diff --git a/app/SSH/Storage/FTP.php b/app/SSH/Storage/FTP.php index de7d98e..c988b03 100644 --- a/app/SSH/Storage/FTP.php +++ b/app/SSH/Storage/FTP.php @@ -13,7 +13,7 @@ public function upload(string $src, string $dest): array $this->server->ssh()->exec( $this->getScript('ftp/upload.sh', [ 'src' => $src, - 'dest' => $this->storageProvider->credentials['path'].'/'.$dest, + 'dest' => $dest, 'host' => $this->storageProvider->credentials['host'], 'port' => $this->storageProvider->credentials['port'], 'username' => $this->storageProvider->credentials['username'], @@ -33,7 +33,7 @@ public function download(string $src, string $dest): void { $this->server->ssh()->exec( $this->getScript('ftp/download.sh', [ - 'src' => $this->storageProvider->credentials['path'].'/'.$src, + 'src' => $src, 'dest' => $dest, 'host' => $this->storageProvider->credentials['host'], 'port' => $this->storageProvider->credentials['port'], @@ -46,11 +46,19 @@ public function download(string $src, string $dest): void ); } - /** - * @TODO Implement delete method - */ - public function delete(string $path): void + public function delete(string $src): void { - // + $this->server->ssh()->exec( + $this->getScript('ftp/delete-file.sh', [ + 'src' => $src, + 'host' => $this->storageProvider->credentials['host'], + 'port' => $this->storageProvider->credentials['port'], + 'username' => $this->storageProvider->credentials['username'], + 'password' => $this->storageProvider->credentials['password'], + 'ssl' => $this->storageProvider->credentials['ssl'], + 'passive' => $this->storageProvider->credentials['passive'], + ]), + 'delete-from-ftp' + ); } } diff --git a/app/SSH/Storage/Local.php b/app/SSH/Storage/Local.php index af065c5..50c9d8a 100644 --- a/app/SSH/Storage/Local.php +++ b/app/SSH/Storage/Local.php @@ -10,13 +10,12 @@ class Local extends AbstractStorage public function upload(string $src, string $dest): array { - $destDir = dirname($this->storageProvider->credentials['path'].$dest); - $destFile = basename($this->storageProvider->credentials['path'].$dest); + $destDir = dirname($dest); $this->server->ssh()->exec( $this->getScript('local/upload.sh', [ 'src' => $src, 'dest_dir' => $destDir, - 'dest_file' => $destFile, + 'dest_file' => $dest, ]), 'upload-to-local' ); @@ -30,20 +29,15 @@ public function download(string $src, string $dest): void { $this->server->ssh()->exec( $this->getScript('local/download.sh', [ - 'src' => $this->storageProvider->credentials['path'].$src, + 'src' => $src, 'dest' => $dest, ]), 'download-from-local' ); } - public function delete(string $path): void + public function delete(string $src): void { - $this->server->ssh()->exec( - $this->getScript('local/delete.sh', [ - 'path' => $this->storageProvider->credentials['path'].$path, - ]), - 'delete-from-local' - ); + $this->server->os()->deleteFile($src); } } diff --git a/app/SSH/Storage/S3.php b/app/SSH/Storage/S3.php index 07731c2..547258c 100644 --- a/app/SSH/Storage/S3.php +++ b/app/SSH/Storage/S3.php @@ -23,7 +23,7 @@ public function upload(string $src, string $dest): array $uploadCommand = $this->getScript('s3/upload.sh', [ 'src' => $src, 'bucket' => $this->storageProvider->credentials['bucket'], - 'dest' => $this->prepareS3Path($this->storageProvider->credentials['path'].'/'.$dest), + 'dest' => $this->prepareS3Path($dest), 'key' => $this->storageProvider->credentials['key'], 'secret' => $this->storageProvider->credentials['secret'], 'region' => $this->storageProvider->credentials['region'], @@ -52,7 +52,7 @@ public function download(string $src, string $dest): void $provider = $this->storageProvider->provider(); $downloadCommand = $this->getScript('s3/download.sh', [ - 'src' => $this->prepareS3Path($this->storageProvider->credentials['path'].'/'.$src), + 'src' => $this->prepareS3Path($src), 'dest' => $dest, 'bucket' => $this->storageProvider->credentials['bucket'], 'key' => $this->storageProvider->credentials['key'], @@ -71,8 +71,21 @@ public function download(string $src, string $dest): void } } - /** - * @TODO Implement delete method - */ - public function delete(string $path): void {} + public function delete(string $src): void + { + /** @var \App\StorageProviders\S3 $provider */ + $provider = $this->storageProvider->provider(); + + $this->server->ssh()->exec( + $this->getScript('s3/delete-file.sh', [ + 'src' => $this->prepareS3Path($src), + 'bucket' => $this->storageProvider->credentials['bucket'], + 'key' => $this->storageProvider->credentials['key'], + 'secret' => $this->storageProvider->credentials['secret'], + 'region' => $this->storageProvider->credentials['region'], + 'endpoint' => $provider->getApiUrl(), + ]), + 'delete-from-s3' + ); + } } diff --git a/app/SSH/Storage/Storage.php b/app/SSH/Storage/Storage.php index 42c7dee..afd7123 100644 --- a/app/SSH/Storage/Storage.php +++ b/app/SSH/Storage/Storage.php @@ -8,5 +8,5 @@ public function upload(string $src, string $dest): array; public function download(string $src, string $dest): void; - public function delete(string $path): void; + public function delete(string $src): void; } diff --git a/app/SSH/Storage/scripts/dropbox/delete-file.sh b/app/SSH/Storage/scripts/dropbox/delete-file.sh new file mode 100644 index 0000000..5b81eea --- /dev/null +++ b/app/SSH/Storage/scripts/dropbox/delete-file.sh @@ -0,0 +1,6 @@ +curl --location --request POST 'https://api.dropboxapi.com/2/files/delete_v2' \ +--header 'Authorization: Bearer __token__' \ +--header 'Content-Type: application/json' \ +--data-raw '{ + "path": "__src__" +}' diff --git a/app/SSH/Storage/scripts/ftp/delete-file.sh b/app/SSH/Storage/scripts/ftp/delete-file.sh new file mode 100644 index 0000000..c756e83 --- /dev/null +++ b/app/SSH/Storage/scripts/ftp/delete-file.sh @@ -0,0 +1 @@ +curl __passive__ -u "__username__:__password__" ftp__ssl__://__host__:__port__/__src__ -Q "DELE /__src__" diff --git a/app/SSH/Storage/scripts/local/delete.sh b/app/SSH/Storage/scripts/local/delete.sh deleted file mode 100644 index 0946469..0000000 --- a/app/SSH/Storage/scripts/local/delete.sh +++ /dev/null @@ -1 +0,0 @@ -rm __path__ diff --git a/app/SSH/Storage/scripts/local/upload.sh b/app/SSH/Storage/scripts/local/upload.sh index a5c6f61..34d885f 100644 --- a/app/SSH/Storage/scripts/local/upload.sh +++ b/app/SSH/Storage/scripts/local/upload.sh @@ -1,2 +1,2 @@ mkdir -p __dest_dir__ -cp __src__ __dest_dir__/__dest_file__ +cp __src__ __dest_file__ diff --git a/app/SSH/Storage/scripts/s3/delete-file.sh b/app/SSH/Storage/scripts/s3/delete-file.sh new file mode 100644 index 0000000..b607c2d --- /dev/null +++ b/app/SSH/Storage/scripts/s3/delete-file.sh @@ -0,0 +1,30 @@ +#!/bin/bash + +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +install_aws_cli() { + echo "Installing AWS CLI" + ARCH=$(uname -m) + curl "https://awscli.amazonaws.com/awscli-exe-linux-$ARCH.zip" -o "aws.zip" + unzip -q aws.zip + sudo ./aws/install --bin-dir /usr/local/bin --install-dir /usr/local/aws-cli --update + rm -rf aws.zip aws +} + +if ! command_exists aws; then + install_aws_cli +fi + +if ! command_exists aws; then + echo "Error: AWS CLI installation failed" + exit 1 +fi + +export AWS_ACCESS_KEY_ID=__key__ +export AWS_SECRET_ACCESS_KEY=__secret__ +export AWS_DEFAULT_REGION=__region__ +export AWS_ENDPOINT_URL=__endpoint__ + +aws s3 rm s3://__bucket__/__src__ diff --git a/app/SSH/Storage/scripts/s3/download.sh b/app/SSH/Storage/scripts/s3/download.sh index a2b5298..7c2ddcc 100644 --- a/app/SSH/Storage/scripts/s3/download.sh +++ b/app/SSH/Storage/scripts/s3/download.sh @@ -1,32 +1,32 @@ #!/bin/bash -# Configure AWS CLI with provided credentials -/usr/local/bin/aws configure set aws_access_key_id "__key__" -/usr/local/bin/aws configure set aws_secret_access_key "__secret__" -/usr/local/bin/aws configure set default.region "__region__" +command_exists() { + command -v "$1" >/dev/null 2>&1 +} -# Use the provided endpoint in the correct format -ENDPOINT="__endpoint__" -BUCKET="__bucket__" -REGION="__region__" +install_aws_cli() { + echo "Installing AWS CLI" + ARCH=$(uname -m) + curl "https://awscli.amazonaws.com/awscli-exe-linux-$ARCH.zip" -o "aws.zip" + unzip -q aws.zip + sudo ./aws/install --bin-dir /usr/local/bin --install-dir /usr/local/aws-cli --update + rm -rf aws.zip aws +} -# Ensure that DEST does not have a trailing slash -SRC="__src__" -DEST="__dest__" +if ! command_exists aws; then + install_aws_cli +fi -# Download the file from S3 -echo "Downloading s3://__bucket__/__src__ to __dest__" -download_output=$(/usr/local/bin/aws s3 cp "s3://$BUCKET/$SRC" "$DEST" --endpoint-url="$ENDPOINT" --region "$REGION" 2>&1) -download_exit_code=$? - -# Log output and exit code -echo "Download command output: $download_output" -echo "Download command exit code: $download_exit_code" - -# Check if the download was successful -if [ $download_exit_code -eq 0 ]; then - echo "Download successful" -else - echo "Download failed" +if ! command_exists aws; then + echo "Error: AWS CLI installation failed" exit 1 fi + +export AWS_ACCESS_KEY_ID=__key__ +export AWS_SECRET_ACCESS_KEY=__secret__ +export AWS_DEFAULT_REGION=__region__ +export AWS_ENDPOINT_URL=__endpoint__ + +if aws s3 cp s3://__bucket__/__src__ __dest__; then + echo "Download successful" +fi diff --git a/app/SSH/Storage/scripts/s3/upload.sh b/app/SSH/Storage/scripts/s3/upload.sh index 7969018..4d6892a 100644 --- a/app/SSH/Storage/scripts/s3/upload.sh +++ b/app/SSH/Storage/scripts/s3/upload.sh @@ -1,59 +1,32 @@ #!/bin/bash -# Check if AWS CLI is installed -if ! command -v aws &> /dev/null -then - echo "AWS CLI is not installed. Installing..." +command_exists() { + command -v "$1" >/dev/null 2>&1 +} - # Detect system architecture +install_aws_cli() { + echo "Installing AWS CLI" ARCH=$(uname -m) - if [ "$ARCH" == "x86_64" ]; then - CLI_URL="https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" - elif [ "$ARCH" == "aarch64" ]; then - CLI_URL="https://awscli.amazonaws.com/awscli-exe-linux-aarch64.zip" - else - echo "Unsupported architecture: $ARCH" - exit 1 - fi + curl "https://awscli.amazonaws.com/awscli-exe-linux-$ARCH.zip" -o "aws.zip" + unzip -q aws.zip + sudo ./aws/install --bin-dir /usr/local/bin --install-dir /usr/local/aws-cli --update + rm -rf aws.zip aws +} - # Download and install AWS CLI - sudo curl "$CLI_URL" -o "awscliv2.zip" - sudo unzip awscliv2.zip - sudo ./aws/install --update - sudo rm -rf awscliv2.zip aws - - echo "AWS CLI installation completed." -else - echo "AWS CLI is already installed." - /usr/local/bin/aws --version +if ! command_exists aws; then + install_aws_cli fi -# Configure AWS CLI with provided credentials -/usr/local/bin/aws configure set aws_access_key_id "__key__" -/usr/local/bin/aws configure set aws_secret_access_key "__secret__" - -# Use the provided endpoint in the correct format -ENDPOINT="__endpoint__" -BUCKET="__bucket__" -REGION="__region__" - -# Ensure that DEST does not have a trailing slash -SRC="__src__" -DEST="__dest__" - -# Upload the file -echo "Uploading __src__ to s3://$BUCKET/$DEST" -upload_output=$(/usr/local/bin/aws s3 cp "$SRC" "s3://$BUCKET/$DEST" --endpoint-url="$ENDPOINT" --region "$REGION" 2>&1) -upload_exit_code=$? - -# Log output and exit code -echo "Upload command output: $upload_output" -echo "Upload command exit code: $upload_exit_code" - -# Check if the upload was successful -if [ $upload_exit_code -eq 0 ]; then - echo "Upload successful" -else - echo "Upload failed" +if ! command_exists aws; then + echo "Error: AWS CLI installation failed" exit 1 fi + +export AWS_ACCESS_KEY_ID=__key__ +export AWS_SECRET_ACCESS_KEY=__secret__ +export AWS_DEFAULT_REGION=__region__ +export AWS_ENDPOINT_URL=__endpoint__ + +if aws s3 cp __src__ s3://__bucket__/__dest__; then + echo "Upload successful" +fi diff --git a/app/StorageProviders/Dropbox.php b/app/StorageProviders/Dropbox.php index 763177a..a2199ab 100644 --- a/app/StorageProviders/Dropbox.php +++ b/app/StorageProviders/Dropbox.php @@ -38,19 +38,4 @@ public function ssh(Server $server): Storage { return new \App\SSH\Storage\Dropbox($server, $this->storageProvider); } - - public function delete(array $paths): void - { - $data = []; - foreach ($paths as $path) { - $data[] = ['path' => $path]; - } - Http::withToken($this->storageProvider->credentials['token']) - ->withHeaders([ - 'Content-Type:application/json', - ]) - ->post($this->apiUrl.'/files/delete_batch', [ - 'entries' => $data, - ]); - } } diff --git a/app/StorageProviders/FTP.php b/app/StorageProviders/FTP.php index ba9bdf2..40435f5 100644 --- a/app/StorageProviders/FTP.php +++ b/app/StorageProviders/FTP.php @@ -57,23 +57,6 @@ public function ssh(Server $server): Storage return new \App\SSH\Storage\FTP($server, $this->storageProvider); } - public function delete(array $paths): void - { - $connection = $this->connection(); - - if ($connection && $this->login($connection)) { - if ($this->storageProvider->credentials['passive']) { - \App\Facades\FTP::passive($connection, true); - } - - foreach ($paths as $path) { - \App\Facades\FTP::delete($connection, $this->storageProvider->credentials['path'].'/'.$path); - } - } - - \App\Facades\FTP::close($connection); - } - private function connection(): bool|Connection { $credentials = $this->storageProvider->credentials; diff --git a/app/StorageProviders/Local.php b/app/StorageProviders/Local.php index 2221744..bfbbd61 100644 --- a/app/StorageProviders/Local.php +++ b/app/StorageProviders/Local.php @@ -30,9 +30,4 @@ public function ssh(Server $server): Storage { return new \App\SSH\Storage\Local($server, $this->storageProvider); } - - public function delete(array $paths): void - { - // - } } diff --git a/app/StorageProviders/S3.php b/app/StorageProviders/S3.php index a228d32..5d225e1 100644 --- a/app/StorageProviders/S3.php +++ b/app/StorageProviders/S3.php @@ -95,6 +95,4 @@ public function ssh(Server $server): Storage { return new S3Storage($server, $this->storageProvider); } - - public function delete(array $paths): void {} } diff --git a/app/StorageProviders/StorageProvider.php b/app/StorageProviders/StorageProvider.php index 06f57f4..ede7351 100644 --- a/app/StorageProviders/StorageProvider.php +++ b/app/StorageProviders/StorageProvider.php @@ -14,6 +14,4 @@ public function credentialData(array $input): array; public function connect(): bool; public function ssh(Server $server): Storage; - - public function delete(array $paths): void; } diff --git a/app/Web/Pages/Servers/Databases/Backups.php b/app/Web/Pages/Servers/Databases/Backups.php index bba8380..553fe51 100644 --- a/app/Web/Pages/Servers/Databases/Backups.php +++ b/app/Web/Pages/Servers/Databases/Backups.php @@ -2,7 +2,7 @@ namespace App\Web\Pages\Servers\Databases; -use App\Actions\Database\CreateBackup; +use App\Actions\Database\ManageBackup; use App\Models\Backup; use App\Models\StorageProvider; use App\Web\Contracts\HasSecondSubNav; @@ -38,12 +38,12 @@ protected function getHeaderActions(): array Select::make('database') ->label('Database') ->options($this->server->databases()->pluck('name', 'id')->toArray()) - ->rules(fn (callable $get) => CreateBackup::rules($this->server, $get())['database']) + ->rules(fn (callable $get) => ManageBackup::rules($this->server, $get())['database']) ->searchable(), Select::make('storage') ->label('Storage') ->options(StorageProvider::getByProjectId($this->server->project_id)->pluck('profile', 'id')->toArray()) - ->rules(fn (callable $get) => CreateBackup::rules($this->server, $get())['storage']) + ->rules(fn (callable $get) => ManageBackup::rules($this->server, $get())['storage']) ->suffixAction( \Filament\Forms\Components\Actions\Action::make('connect') ->form(Create::form()) @@ -59,21 +59,21 @@ protected function getHeaderActions(): array ->label('Interval') ->options(config('core.cronjob_intervals')) ->reactive() - ->rules(fn (callable $get) => CreateBackup::rules($this->server, $get())['interval']), + ->rules(fn (callable $get) => ManageBackup::rules($this->server, $get())['interval']), TextInput::make('custom_interval') ->label('Custom Interval (Cron)') - ->rules(fn (callable $get) => CreateBackup::rules($this->server, $get())['custom_interval']) + ->rules(fn (callable $get) => ManageBackup::rules($this->server, $get())['custom_interval']) ->visible(fn (callable $get) => $get('interval') === 'custom') ->placeholder('0 * * * *'), TextInput::make('keep') ->label('Backups to Keep') - ->rules(fn (callable $get) => CreateBackup::rules($this->server, $get())['keep']) + ->rules(fn (callable $get) => ManageBackup::rules($this->server, $get())['keep']) ->helperText('How many backups to keep before deleting the oldest one'), ]) ->modalSubmitActionLabel('Create') ->action(function (array $data) { run_action($this, function () use ($data) { - app(CreateBackup::class)->create($this->server, $data); + app(ManageBackup::class)->create($this->server, $data); $this->dispatch('$refresh'); diff --git a/app/Web/Pages/Servers/Databases/Widgets/BackupFilesList.php b/app/Web/Pages/Servers/Databases/Widgets/BackupFilesList.php index eab0a35..0c4adc4 100644 --- a/app/Web/Pages/Servers/Databases/Widgets/BackupFilesList.php +++ b/app/Web/Pages/Servers/Databases/Widgets/BackupFilesList.php @@ -2,6 +2,7 @@ namespace App\Web\Pages\Servers\Databases\Widgets; +use App\Actions\Database\ManageBackupFile; use App\Actions\Database\RestoreBackup; use App\Models\Backup; use App\Models\BackupFile; @@ -59,11 +60,21 @@ public function table(Table $table): Table ->query($this->getTableQuery()) ->columns($this->getTableColumns()) ->actions([ + Action::make('download') + ->hiddenLabel() + ->icon('heroicon-o-arrow-down-tray') + ->visible(fn (BackupFile $record) => $record->isAvailable() && $record->isLocal()) + ->tooltip('Download') + ->action(function (BackupFile $record) { + return app(ManageBackupFile::class)->download($record); + }) + ->authorize(fn (BackupFile $record) => auth()->user()->can('view', $record)), Action::make('restore') ->hiddenLabel() ->icon('heroicon-o-arrow-path') ->modalHeading('Restore Backup') ->tooltip('Restore Backup') + ->disabled(fn (BackupFile $record) => ! $record->isAvailable()) ->authorize(fn (BackupFile $record) => auth()->user()->can('update', $record->backup)) ->form([ Select::make('database') @@ -95,16 +106,15 @@ public function table(Table $table): Table Action::make('delete') ->hiddenLabel() ->icon('heroicon-o-trash') - ->modalHeading('Delete Database') + ->modalHeading('Delete Backup File') ->color('danger') + ->disabled(fn (BackupFile $record) => ! $record->isAvailable()) ->tooltip('Delete') ->authorize(fn (BackupFile $record) => auth()->user()->can('delete', $record)) ->requiresConfirmation() ->action(function (BackupFile $record) { - run_action($this, function () use ($record) { - $record->delete(); - $this->dispatch('$refresh'); - }); + app(ManageBackupFile::class)->delete($record); + $this->dispatch('$refresh'); }), ]); } diff --git a/app/Web/Pages/Servers/Databases/Widgets/BackupsList.php b/app/Web/Pages/Servers/Databases/Widgets/BackupsList.php index c8cd449..3e6c3ad 100644 --- a/app/Web/Pages/Servers/Databases/Widgets/BackupsList.php +++ b/app/Web/Pages/Servers/Databases/Widgets/BackupsList.php @@ -2,10 +2,14 @@ namespace App\Web\Pages\Servers\Databases\Widgets; +use App\Actions\Database\ManageBackup; use App\Actions\Database\RunBackup; use App\Models\Backup; use App\Models\BackupFile; use App\Models\Server; +use Filament\Forms\Components\Select; +use Filament\Forms\Components\TextInput; +use Filament\Notifications\Notification; use Filament\Support\Enums\MaxWidth; use Filament\Tables\Actions\Action; use Filament\Tables\Columns\TextColumn; @@ -57,12 +61,53 @@ public function table(Table $table): Table ->query($this->getTableQuery()) ->columns($this->getTableColumns()) ->actions([ + Action::make('edit') + ->hiddenLabel() + ->icon('heroicon-o-pencil') + ->tooltip('Edit Configuration') + ->disabled(fn (Backup $record) => ! in_array($record->status, ['running', 'failed'])) + ->authorize(fn (Backup $record) => auth()->user()->can('update', $record)) + ->modelLabel('Edit Backup') + ->modalWidth(MaxWidth::Large) + ->modalSubmitActionLabel('Update') + ->form([ + Select::make('interval') + ->label('Interval') + ->options(config('core.cronjob_intervals')) + ->reactive() + ->default(fn (Backup $record) => $record->isCustomInterval() ? 'custom' : $record->interval) + ->rules(fn (callable $get) => ManageBackup::rules($this->server, $get())['interval']), + TextInput::make('custom_interval') + ->label('Custom Interval (Cron)') + ->rules(fn (callable $get) => ManageBackup::rules($this->server, $get())['custom_interval']) + ->visible(fn (callable $get) => $get('interval') === 'custom') + ->default(fn (Backup $record) => $record->isCustomInterval() ? $record->interval : '') + ->placeholder('0 * * * *'), + TextInput::make('keep') + ->label('Backups to Keep') + ->default(fn (Backup $record) => $record->keep_backups) + ->rules(fn (callable $get) => ManageBackup::rules($this->server, $get())['keep']) + ->helperText('How many backups to keep before deleting the oldest one'), + ]) + ->action(function (Backup $backup, array $data) { + run_action($this, function () use ($data, $backup) { + app(ManageBackup::class)->update($backup, $data); + + $this->dispatch('$refresh'); + + Notification::make() + ->success() + ->title('Backup updated!') + ->send(); + }); + }), Action::make('files') ->hiddenLabel() ->icon('heroicon-o-rectangle-stack') ->modalHeading('Backup Files') ->color('gray') ->tooltip('Show backup files') + ->disabled(fn (Backup $record) => ! in_array($record->status, ['running', 'failed'])) ->authorize(fn (Backup $record) => auth()->user()->can('viewAny', [BackupFile::class, $record])) ->modalContent(fn (Backup $record) => view('components.dynamic-widget', [ 'widget' => BackupFilesList::class, @@ -88,16 +133,16 @@ public function table(Table $table): Table Action::make('delete') ->hiddenLabel() ->icon('heroicon-o-trash') - ->modalHeading('Delete Database') + ->modalHeading('Delete Backup & Files') + ->disabled(fn (Backup $record) => ! in_array($record->status, ['running', 'failed'])) ->color('danger') ->tooltip('Delete') ->authorize(fn (Backup $record) => auth()->user()->can('delete', $record)) ->requiresConfirmation() ->action(function (Backup $record) { - run_action($this, function () use ($record) { - $record->delete(); - $this->dispatch('$refresh'); - }); + app(ManageBackup::class)->delete($record); + + $this->dispatch('$refresh'); }), ]); } diff --git a/config/filesystems.php b/config/filesystems.php index 8a22cd7..59b7c61 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -62,6 +62,11 @@ 'root' => storage_path('app/key-pairs'), ], + 'backups' => [ + 'driver' => 'local', + 'root' => sys_get_temp_dir(), + ], + // @deprecated 'key-pairs-local' => [ 'driver' => 'local', diff --git a/tests/Feature/DatabaseBackupTest.php b/tests/Feature/DatabaseBackupTest.php index 8d0dd8d..be38754 100644 --- a/tests/Feature/DatabaseBackupTest.php +++ b/tests/Feature/DatabaseBackupTest.php @@ -115,6 +115,43 @@ public function test_see_backups_list(): void ->assertSee($backup->database->name); } + public function test_update_backup(): void + { + $this->actingAs($this->user); + + $database = Database::factory()->create([ + 'server_id' => $this->server, + ]); + + $storage = StorageProvider::factory()->create([ + 'user_id' => $this->user->id, + 'provider' => \App\Enums\StorageProvider::DROPBOX, + ]); + + $backup = Backup::factory()->create([ + 'server_id' => $this->server->id, + 'database_id' => $database->id, + 'storage_id' => $storage->id, + 'interval' => '0 * * * *', + 'keep_backups' => 5, + ]); + + Livewire::test(BackupsList::class, [ + 'server' => $this->server, + ]) + ->callTableAction('edit', $backup->id, [ + 'interval' => '0 0 * * *', + 'keep' => '10', + ]) + ->assertSuccessful(); + + $this->assertDatabaseHas('backups', [ + 'id' => $backup->id, + 'interval' => '0 0 * * *', + 'keep_backups' => 10, + ]); + } + public function test_delete_backup(): void { $this->actingAs($this->user);