Merge pull request #19 from vitodeploy/database-backups

database backups
This commit is contained in:
Saeed Vaziry 2023-08-25 21:09:03 +02:00 committed by GitHub
commit de9333fa27
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
55 changed files with 1111 additions and 172 deletions

View File

@ -15,7 +15,7 @@ DB_USERNAME=
DB_PASSWORD=
BROADCAST_DRIVER=pusher
CACHE_DRIVER=redis
CACHE_DRIVER=file
FILESYSTEM_DRIVER=local
QUEUE_CONNECTION=default
SESSION_DRIVER=database

View File

@ -8,11 +8,11 @@ LOG_CHANNEL=stack
LOG_LEVEL=debug
DB_CONNECTION=mysql
DB_HOST=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=testing
DB_DATABASE=vito_test
DB_USERNAME=root
DB_PASSWORD=password
DB_PASSWORD=
BROADCAST_DRIVER=pusher
CACHE_DRIVER=array

View File

@ -1,14 +1,12 @@
<?php
namespace App\Actions\Backup;
namespace App\Actions\Database;
use App\Enums\BackupStatus;
use App\Enums\DatabaseStatus;
use App\Models\Backup;
use App\Models\Database;
use App\Models\Server;
use App\Models\User;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
@ -19,23 +17,18 @@ class CreateBackup
* @throws AuthorizationException
* @throws ValidationException
*/
public function create($type, Server $server, User $user, array $input): Backup
public function create($type, Server $server, array $input): Backup
{
$this->validate($type, $server, $user, $input);
if ($type == 'database') {
Gate::forUser($user)->authorize('viewAny', [Database::class, $server]);
}
$this->validate($type, $server, $input);
$backup = new Backup([
'name' => $input['name'],
'type' => $type,
'server_id' => $server->id,
'database_id' => $input['database'] ?? null,
'storage_id' => $input['storage'],
'interval' => $input['interval'],
'keep_backups' => $input['keep_backups'],
'status' => 'running',
'interval' => $input['interval'] == 'custom' ? $input['custom'] : $input['interval'],
'keep_backups' => $input['keep'],
'status' => BackupStatus::RUNNING,
]);
$backup->save();
@ -47,17 +40,14 @@ public function create($type, Server $server, User $user, array $input): Backup
/**
* @throws ValidationException
*/
private function validate($type, Server $server, User $user, array $input): void
private function validate($type, Server $server, array $input): void
{
$rules = [
'name' => 'required',
'storage' => [
'required',
Rule::exists('storage_providers', 'id')
->where('user_id', $user->id)
->where('connected', 1),
Rule::exists('storage_providers', 'id'),
],
'keep_backups' => [
'keep' => [
'required',
'numeric',
'min:1',
@ -69,9 +59,15 @@ private function validate($type, Server $server, User $user, array $input): void
'0 0 * * *',
'0 0 * * 0',
'0 0 1 * *',
'custom'
]),
],
];
if ($input['interval'] == 'custom') {
$rules['custom'] = [
'required',
];
}
if ($type === 'database') {
$rules['database'] = [
'required',
@ -80,6 +76,6 @@ private function validate($type, Server $server, User $user, array $input): void
->where('status', DatabaseStatus::READY),
];
}
Validator::make($input, $rules)->validateWithBag('createBackup');
Validator::make($input, $rules)->validate();
}
}

View File

@ -1,30 +0,0 @@
<?php
namespace App\Actions\StorageProvider;
use App\Models\StorageProvider;
use App\Models\User;
use Illuminate\Validation\ValidationException;
class AddStorageProvider
{
use ValidateProvider;
/**
* @throws ValidationException
*/
public function add(User $user, array $input): mixed
{
$this->validate($user, $input);
$storageProvider = new StorageProvider([
'user_id' => $user->id,
'provider' => $input['provider'],
'label' => $input['label'],
'connected' => false,
]);
$storageProvider->save();
return $storageProvider->provider()->connect();
}
}

View File

@ -0,0 +1,52 @@
<?php
namespace App\Actions\StorageProvider;
use App\Models\StorageProvider;
use App\Models\User;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
class CreateStorageProvider
{
/**
* @throws ValidationException
*/
public function create(User $user, array $input): void
{
$this->validate($user, $input);
$storageProvider = new StorageProvider([
'user_id' => $user->id,
'provider' => $input['provider'],
'profile' => $input['name'],
'credentials' => [
'token' => $input['token']
]
]);
if (! $storageProvider->provider()->connect()) {
throw ValidationException::withMessages([
'token' => __("Couldn't connect to the provider")
]);
}
$storageProvider->save();;
}
private function validate(User $user, array $input): void
{
Validator::make($input, [
'provider' => [
'required',
Rule::in(config('core.storage_providers')),
],
'name' => [
'required',
Rule::unique('storage_providers', 'profile')->where('user_id', $user->id),
],
'token' => [
'required'
]
])->validate();
}
}

View File

@ -1,34 +0,0 @@
<?php
namespace App\Actions\StorageProvider;
use App\Models\StorageProvider;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Laravel\Socialite\Facades\Socialite;
use Laravel\Socialite\Two\User;
use Throwable;
class HandleProviderCallback
{
public function callback(Request $request, string $provider): string|RedirectResponse
{
try {
$providerId = $request->session()->get('storage_provider_id');
/** @var StorageProvider $storageProvider */
$storageProvider = StorageProvider::query()->findOrFail($providerId);
/** @var User $oauthUser */
$oauthUser = Socialite::driver($provider)->user();
$storageProvider->token = $oauthUser->token;
$storageProvider->refresh_token = $oauthUser->refreshToken;
$storageProvider->token_expires_at = now()->addSeconds($oauthUser->expiresIn);
$storageProvider->connected = true;
$storageProvider->save();
/** @TODO toast success message */
} catch (Throwable) {
/** @TODO toast failed message */
}
return redirect()->route('storage-providers');
}
}

View File

@ -1,28 +0,0 @@
<?php
namespace App\Actions\StorageProvider;
use App\Models\User;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
trait ValidateProvider
{
/**
* @throws ValidationException
*/
private function validate(User $user, array $input): void
{
Validator::make($input, [
'label' => [
'required',
Rule::unique('storage_providers', 'label')->where('user_id', $user->id),
],
'provider' => [
'required',
Rule::in(config('core.storage_providers')),
],
])->validateWithBag('addStorageProvider');
}
}

View File

@ -0,0 +1,49 @@
<?php
namespace App\Console\Commands;
use App\Models\Backup;
use Illuminate\Console\Command;
class RunBackup extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'backups:run {interval}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Run backup';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*/
public function handle(): void
{
Backup::query()
->where('interval', $this->argument('interval'))
->where('status', 'running')
->chunk(100, function ($backups) {
/** @var Backup $backup */
foreach ($backups as $backup) {
$backup->run();
}
});
}
}

View File

@ -12,7 +12,10 @@ class Kernel extends ConsoleKernel
*/
protected function schedule(Schedule $schedule): void
{
// $schedule->command('inspire')->hourly();
$schedule->command('backups:run "0 * * * *"')->hourly();
$schedule->command('backups:run "0 0 * * *"')->daily();
$schedule->command('backups:run "0 0 * * 0"')->weekly();
$schedule->command('backups:run "0 0 1 * *"')->monthly();
}
/**

View File

@ -7,7 +7,7 @@
interface StorageProvider
{
public function connect(): RedirectResponse;
public function connect(): bool;
public function upload(Server $server, string $src, string $dest): array;

View File

@ -0,0 +1,22 @@
<?php
namespace App\Enums;
use BenSampo\Enum\Enum;
final class BackupFileStatus extends Enum
{
const CREATED = 'created';
const CREATING = 'creating';
const FAILED = 'failed';
const DELETING = 'deleting';
const RESTORING = 'restoring';
const RESTORED = 'restored';
const RESTORE_FAILED = 'restore_failed';
}

View File

@ -0,0 +1,16 @@
<?php
namespace App\Enums;
use BenSampo\Enum\Enum;
final class BackupStatus extends Enum
{
const READY = 'ready';
const RUNNING = 'running';
const FAILED = 'failed';
const DELETING = 'deleting';
}

View File

@ -0,0 +1,9 @@
<?php
namespace App\Exceptions;
use Exception;
class BackupFileException extends Exception
{
}

View File

@ -2,14 +2,24 @@
namespace App\Http\Controllers;
use App\Models\Backup;
use App\Models\Server;
use Illuminate\Contracts\View\View;
class DatabaseController extends Controller
{
public function index(Server $server)
public function index(Server $server): View
{
return view('databases.index', [
'server' => $server,
]);
}
public function backups(Server $server, Backup $backup): View
{
return view('databases.backups', [
'server' => $server,
'backup' => $backup,
]);
}
}

View File

@ -0,0 +1,67 @@
<?php
namespace App\Http\Livewire\Databases;
use App\Models\Backup;
use App\Models\BackupFile;
use App\Models\Database;
use App\Models\Server;
use App\Traits\HasCustomPaginationView;
use App\Traits\RefreshComponentOnBroadcast;
use Illuminate\Contracts\View\View;
use Livewire\Component;
class DatabaseBackupFiles extends Component
{
use HasCustomPaginationView;
use RefreshComponentOnBroadcast;
public Server $server;
public Backup $backup;
public string $restoreId = "";
public string $restoreDatabaseId = "";
public int $deleteId;
public function backup(): void
{
$this->backup->run();
$this->refreshComponent([]);
}
public function restore(): void
{
/** @var BackupFile $file */
$file = BackupFile::query()->findOrFail($this->restoreId);
/** @var Database $database */
$database = Database::query()->findOrFail($this->restoreDatabaseId);
$file->restore($database);
$this->refreshComponent([]);
$this->dispatchBrowserEvent('restored', true);
}
public function delete(): void
{
/** @var BackupFile $file */
$file = BackupFile::query()->findOrFail($this->deleteId);
$file->delete();
$this->dispatchBrowserEvent('confirmed', true);
}
public function render(): View
{
return view('livewire.databases.database-backup-files', [
'files' => $this->backup->files()->orderByDesc('id')->simplePaginate(10)
]);
}
}

View File

@ -0,0 +1,81 @@
<?php
namespace App\Http\Livewire\Databases;
use App\Actions\Database\CreateBackup;
use App\Models\Backup;
use App\Models\Server;
use App\Traits\RefreshComponentOnBroadcast;
use Illuminate\Contracts\Pagination\Paginator;
use Illuminate\Contracts\View\View;
use Livewire\Component;
use Livewire\WithPagination;
class DatabaseBackups extends Component
{
use RefreshComponentOnBroadcast;
use WithPagination;
public Server $server;
public int $deleteId;
public string $database = '';
public string $storage = '';
public string $interval = '';
public string $custom = '';
public int $keep = 10;
public ?Backup $backup = null;
protected ?Paginator $files = null;
public function create(): void
{
app(CreateBackup::class)->create('database', $this->server, $this->all());
$this->refreshComponent([]);
$this->dispatchBrowserEvent('backup-created', true);
}
public function files(int $id): void
{
$backup = Backup::query()->findOrFail($id);
$this->backup = $backup;
$this->files = $backup->files()->orderByDesc('id')->simplePaginate(1);
$this->dispatchBrowserEvent('show-files', true);
}
public function backup(): void
{
$this->backup?->run();
$this->files = $this->backup?->files()->orderByDesc('id')->simplePaginate();
$this->dispatchBrowserEvent('backup-running', true);
}
public function delete(): void
{
/** @var Backup $backup */
$backup = Backup::query()->findOrFail($this->deleteId);
$backup->delete();
$this->dispatchBrowserEvent('confirmed', true);
}
public function render(): View
{
return view('livewire.databases.database-backups', [
'backups' => $this->server->backups,
'databases' => $this->server->databases,
'files' => $this->files
]);
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Http\Livewire\StorageProviders;
use App\Actions\StorageProvider\CreateStorageProvider;
use Illuminate\Contracts\View\View;
use Livewire\Component;
class ConnectProvider extends Component
{
public string $provider = '';
public string $name;
public string $token;
public function connect(): void
{
app(CreateStorageProvider::class)->create(auth()->user(), $this->all());
$this->emitTo(ProvidersList::class, '$refresh');
$this->dispatchBrowserEvent('connected', true);
}
public function render(): View
{
return view('livewire.storage-providers.connect-provider');
}
}

View File

@ -0,0 +1,37 @@
<?php
namespace App\Http\Livewire\StorageProviders;
use App\Models\StorageProvider;
use App\Traits\RefreshComponentOnBroadcast;
use Illuminate\Contracts\View\View;
use Livewire\Component;
class ProvidersList extends Component
{
use RefreshComponentOnBroadcast;
public int $deleteId;
protected $listeners = [
'$refresh',
];
public function delete(): void
{
$provider = StorageProvider::query()->findOrFail($this->deleteId);
$provider->delete();
$this->refreshComponent([]);
$this->dispatchBrowserEvent('confirmed', true);
}
public function render(): View
{
return view('livewire.storage-providers.providers-list', [
'providers' => StorageProvider::query()->latest()->get(),
]);
}
}

View File

@ -2,6 +2,7 @@
namespace App\Jobs\Backup;
use App\Enums\BackupFileStatus;
use App\Events\Broadcast;
use App\Jobs\Job;
use App\Models\BackupFile;
@ -23,7 +24,7 @@ public function handle(): void
{
$this->database->server->database()->handler()->restoreBackup($this->backupFile, $this->database->name);
$this->backupFile->status = 'restored';
$this->backupFile->status = BackupFileStatus::RESTORED;
$this->backupFile->restored_at = now();
$this->backupFile->save();
@ -36,7 +37,7 @@ public function handle(): void
public function failed(): void
{
$this->backupFile->status = 'restore_failed';
$this->backupFile->status = BackupFileStatus::RESTORE_FAILED;
$this->backupFile->save();
event(
new Broadcast('backup-restore-failed', [

View File

@ -2,6 +2,7 @@
namespace App\Jobs\Backup;
use App\Enums\BackupFileStatus;
use App\Events\Broadcast;
use App\Jobs\Job;
use App\Models\BackupFile;
@ -21,7 +22,7 @@ public function handle(): void
$this->backupFile->backup->server->database()->handler()->runBackup($this->backupFile);
}
$this->backupFile->status = 'finished';
$this->backupFile->status = BackupFileStatus::CREATED;
$this->backupFile->save();
event(
@ -33,7 +34,7 @@ public function handle(): void
public function failed(): void
{
$this->backupFile->status = 'failed';
$this->backupFile->status = BackupFileStatus::FAILED;
$this->backupFile->save();
event(
new Broadcast('run-backup-failed', [

View File

@ -2,6 +2,7 @@
namespace App\Models;
use App\Enums\BackupFileStatus;
use App\Jobs\Backup\RunBackup;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -10,7 +11,6 @@
/**
* @property string $type
* @property string $name
* @property int $server_id
* @property int $storage_id
* @property int $database_id
@ -28,7 +28,6 @@ class Backup extends AbstractModel
protected $fillable = [
'type',
'name',
'server_id',
'storage_id',
'database_id',
@ -77,8 +76,8 @@ public function run(): void
{
$file = new BackupFile([
'backup_id' => $this->id,
'name' => Str::of($this->name)->slug().'-'.now()->format('YmdHis'),
'status' => 'creating',
'name' => Str::of($this->database->name)->slug().'-'.now()->format('YmdHis'),
'status' => BackupFileStatus::CREATING,
]);
$file->save();

View File

@ -2,6 +2,7 @@
namespace App\Models;
use App\Enums\BackupFileStatus;
use App\Jobs\Backup\RestoreDatabase;
use App\Jobs\StorageProvider\DeleteFile;
use Carbon\Carbon;
@ -75,12 +76,12 @@ public function getPathAttribute(): string
public function getStoragePathAttribute(): string
{
return '/'.$this->backup->name.'/'.$this->name.'.zip';
return '/'.$this->backup->database->name.'/'.$this->name.'.zip';
}
public function restore(Database $database): void
{
$this->status = 'restoring';
$this->status = BackupFileStatus::RESTORING;
$this->restored_to = $database->name;
$this->save();
dispatch(new RestoreDatabase($this, $database))->onConnection('ssh');

View File

@ -2,18 +2,14 @@
namespace App\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* @property int $user_id
* @property string $profile
* @property string $provider
* @property string $label
* @property string $token
* @property string $refresh_token
* @property bool $connected
* @property Carbon $token_expires_at
* @property array $credentials
* @property User $user
*/
class StorageProvider extends AbstractModel
@ -22,18 +18,14 @@ class StorageProvider extends AbstractModel
protected $fillable = [
'user_id',
'profile',
'provider',
'label',
'token',
'refresh_token',
'connected',
'token_expires_at',
'credentials',
];
protected $casts = [
'user_id' => 'integer',
'connected' => 'boolean',
'token_expires_at' => 'datetime',
'credentials' => 'encrypted:array',
];
public function user(): BelongsTo

View File

@ -99,7 +99,7 @@ public function runBackup(BackupFile $backupFile): void
'backup-database'
);
// upload to cloud
// upload to storage
$upload = $backupFile->backup->storage->provider()->upload(
$backupFile->backup->server,
$backupFile->path,

View File

@ -2,21 +2,25 @@
namespace App\StorageProviders;
use App\Exceptions\BackupFileException;
use App\Models\Server;
use App\SSHCommands\Storage\DownloadFromDropboxCommand;
use App\SSHCommands\Storage\UploadToDropboxCommand;
use Illuminate\Support\Facades\Http;
use Laravel\Socialite\Facades\Socialite;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Throwable;
class Dropbox extends AbstractStorageProvider
{
public function connect(): RedirectResponse
{
session()->put('storage_provider_id', $this->storageProvider->id);
protected string $apiUrl = 'https://api.dropboxapi.com/2';
return Socialite::driver('dropbox')->redirect();
public function connect(): bool
{
$res = Http::withToken($this->storageProvider->credentials['token'])
->post($this->apiUrl.'/check/user', [
'query' => ''
]);
return $res->successful();
}
/**
@ -28,15 +32,19 @@ public function upload(Server $server, string $src, string $dest): array
new UploadToDropboxCommand(
$src,
$dest,
$this->storageProvider->token
$this->storageProvider->credentials['token']
),
'upload-to-dropbox'
);
$data = json_decode($upload);
$data = json_decode($upload, true);
if (isset($data['error'])) {
throw new BackupFileException("Failed to upload to Dropbox ".$data['error_summary'] ?? '');
}
return [
'size' => $data?->size,
'size' => $data['size'] ?? null,
];
}
@ -49,7 +57,7 @@ public function download(Server $server, string $src, string $dest): void
new DownloadFromDropboxCommand(
$src,
$dest,
$this->storageProvider->token
$this->storageProvider->credentials['token']
),
'download-from-dropbox'
);
@ -61,11 +69,11 @@ public function delete(array $paths): void
foreach ($paths as $path) {
$data[] = ['path' => $path];
}
Http::withToken($this->storageProvider->token)
Http::withToken($this->storageProvider->credentials['token'])
->withHeaders([
'Content-Type:application/json',
])
->post('https://api.dropboxapi.com/2/files/delete_batch', [
->post($this->apiUrl.'/files/delete_batch', [
'entries' => $data,
]);
}

View File

@ -2,6 +2,7 @@
namespace Database\Factories;
use App\Enums\BackupStatus;
use Illuminate\Database\Eloquent\Factories\Factory;
class BackupFactory extends Factory
@ -9,7 +10,10 @@ class BackupFactory extends Factory
public function definition(): array
{
return [
//
'type' => 'database',
'interval' => '0 * * * *',
'keep_backups' => 10,
'status' => BackupStatus::RUNNING
];
}
}

View File

@ -2,6 +2,7 @@
namespace Database\Factories;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
class StorageProviderFactory extends Factory
@ -9,7 +10,10 @@ class StorageProviderFactory extends Factory
public function definition(): array
{
return [
//
'profile' => $this->faker->word(),
'provider' => $this->faker->randomElement(\App\Enums\StorageProvider::getValues()),
'credentials' => [],
'user_id' => User::factory(),
];
}
}

View File

@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::table('storage_providers', function (Blueprint $table) {
$table->dropColumn('token');
$table->dropColumn('refresh_token');
$table->dropColumn('token_expires_at');
$table->dropColumn('label');
$table->dropColumn('connected');
$table->unsignedBigInteger('user_id')->after('id');
$table->string('profile')->after('user_id');
$table->longText('credentials')->nullable()->after('provider');
});
}
public function down(): void
{
Schema::table('storage_providers', function (Blueprint $table) {
$table->string('token');
$table->string('refresh_token');
$table->string('token_expires_at');
$table->string('label');
$table->dropColumn('user_id');
$table->dropColumn('profile');
$table->dropColumn('credentials');
});
}
};

View File

@ -0,0 +1,21 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::table('backups', function (Blueprint $table) {
$table->dropColumn('name');
});
}
public function down(): void
{
Schema::table('backups', function (Blueprint $table) {
$table->string('name')->nullable();
});
}
};

View File

@ -0,0 +1,21 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::table('backup_files', function (Blueprint $table) {
$table->string('restored_to')->after('status')->nullable();
});
}
public function down(): void
{
Schema::table('backup_files', function (Blueprint $table) {
$table->dropColumn('restored_to');
});
}
};

View File

@ -224,6 +224,9 @@ supervisorctl reread
supervisorctl update
supervisorctl start worker:*
# setup cronjobs
echo "* * * * * cd /home/${V_USERNAME}/${V_DOMAIN} && php artisan schedule:run >> /dev/null 2>&1" | sudo -u ${V_USERNAME} crontab -
# make the update file executable
chmod +x /home/${V_USERNAME}/${V_DOMAIN}/update.sh

View File

@ -3,7 +3,8 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"queue:listen": "php artisan queue:listen --timeout=600 --queue=default,ssh,ssh-long"
"queue:listen": "php artisan queue:listen --timeout=600 --queue=default,ssh,ssh-long",
"scheduler:run": "php artisan schedule:run"
},
"devDependencies": {
"@ryangjchandler/alpine-clipboard": "^2.2.0",

View File

@ -1,4 +1,4 @@
curl --location --request POST 'https://content.dropboxapi.com/2/files/upload' \
curl -sb --location --request POST 'https://content.dropboxapi.com/2/files/upload' \
--header 'Accept: application/json' \
--header 'Dropbox-API-Arg: {"path":"__dest__"}' \
--header 'Content-Type: text/plain; charset=dropbox-cors-hack' \

View File

@ -0,0 +1,7 @@
<x-server-layout :server="$server">
<x-slot name="pageTitle">{{ __("Backup Files") }}</x-slot>
<div class="space-y-10">
<livewire:databases.database-backup-files :server="$server" :backup="$backup" />
</div>
</x-server-layout>

View File

@ -5,5 +5,7 @@
<livewire:databases.database-list :server="$server" />
<livewire:databases.database-user-list :server="$server" />
<livewire:databases.database-backups :server="$server" />
</div>
</x-server-layout>

View File

@ -58,7 +58,7 @@
</x-sidebar-link>
@endif
@if($server->database())
<x-sidebar-link :href="route('servers.databases', ['server' => $server])" :active="request()->routeIs('servers.databases')">
<x-sidebar-link :href="route('servers.databases', ['server' => $server])" :active="request()->routeIs('servers.databases') || request()->routeIs('servers.databases.backups')">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125" />
</svg>

View File

@ -28,6 +28,12 @@
</svg>
{{ __('Source Controls') }}
</x-secondary-sidebar-link>
<x-secondary-sidebar-link :href="route('storage-providers')" :active="request()->routeIs('storage-providers')">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6 mr-2">
<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125" />
</svg>
{{ __('Storage Providers') }}
</x-secondary-sidebar-link>
<x-secondary-sidebar-link :href="route('notification-channels')" :active="request()->routeIs('notification-channels')">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6 mr-2">
<path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75v-.7V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0" />

View File

@ -1 +1 @@
<div wire:poll.7s></div>
<div wire:poll.5s></div>

View File

@ -0,0 +1,74 @@
<div x-data="">
<x-card-header>
<x-slot name="title">{{ __("Backup Files") }}</x-slot>
<x-slot name="description">{{ __("Here you can see your backup files") }}</x-slot>
<x-slot name="aside">
<div>
<x-secondary-button :href="route('servers.databases', ['server' => $server])">
{{ __('Back to Databases') }}
</x-secondary-button>
<x-primary-button class="ml-1" wire:click="backup" wire:loading.attr="disabled">
{{ __("Backup Now") }}
</x-primary-button>
</div>
</x-slot>
</x-card-header>
@if(count($files) > 0)
<x-table class="mt-5">
<tr>
<x-th>{{ __("Name") }}</x-th>
<x-th>{{ __("Created") }}</x-th>
<x-th>{{ __("Size") }}</x-th>
<x-th>{{ __("Status") }}</x-th>
<x-th>{{ __("Restored") }}</x-th>
<x-th>{{ __("Restored To") }}</x-th>
<x-th></x-th>
</tr>
@foreach($files as $file)
<tr>
<x-td>{{ $file->name }}</x-td>
<x-td>
<x-datetime :value="$file->created_at" />
</x-td>
<x-td>{{ $file->size }}</x-td>
<x-td>
<div class="inline-flex">
@include('livewire.databases.partials.backup-file-status', ['status' => $file->status])
</div>
</x-td>
<x-td>
@if($file->restored_at)
<x-datetime :value="$file->restored_at" />
@else
-
@endif
</x-td>
<x-td>
@if($file->restored_to)
{{ $file->restored_to }}
@else
-
@endif
</x-td>
<x-td class="flex w-full justify-end">
@if(in_array($file->status, [\App\Enums\BackupFileStatus::CREATED, \App\Enums\BackupFileStatus::RESTORED, \App\Enums\BackupFileStatus::RESTORE_FAILED]))
<x-icon-button x-on:click="$wire.restoreId = '{{ $file->id }}'; $dispatch('open-modal', 'restore-backup')">
Restore
</x-icon-button>
@endif
<x-icon-button x-on:click="$wire.deleteId = '{{ $file->id }}'; $dispatch('open-modal', 'delete-backup-file')">
Delete
</x-icon-button>
</x-td>
</tr>
@endforeach
</x-table>
<div class="mt-5">
{{ $files->withQueryString()->links() }}
</div>
@include('livewire.databases.partials.restore-backup-modal', ['databases' => $server->databases])
@include('livewire.databases.partials.delete-backup-file-modal')
@else
<x-simple-card class="text-center">{{ __("You don't have any backups yet") }}</x-simple-card>
@endif
</div>

View File

@ -0,0 +1,49 @@
<div x-data="">
<x-card-header>
<x-slot name="title">{{ __("Backups") }}</x-slot>
<x-slot name="description">{{ __("You can backup your databases into external storages") }}</x-slot>
<x-slot name="aside">
<div>
<x-primary-button x-data="" x-on:click.prevent="$dispatch('open-modal', 'create-backup')">
{{ __('Create Backup') }}
</x-primary-button>
@include('livewire.databases.partials.create-backup-modal')
</div>
</x-slot>
</x-card-header>
@if(count($backups) > 0)
<x-table>
<tr>
<x-th>{{ __("Database") }}</x-th>
<x-th>{{ __("Created") }}</x-th>
<x-th>{{ __("Status") }}</x-th>
<x-th></x-th>
</tr>
@foreach($backups as $backup)
<tr>
<x-td>{{ $backup->database->name }}</x-td>
<x-td>
<x-datetime :value="$backup->created_at" />
</x-td>
<x-td>
<div class="inline-flex">
@include('livewire.databases.partials.backup-status', ['status' => $backup->status])
</div>
</x-td>
<x-td class="flex w-full justify-end">
<x-icon-button :href="route('servers.databases.backups', ['server' => $server->id, 'backup' => $backup->id])">
Files
</x-icon-button>
<x-icon-button x-on:click="$wire.deleteId = '{{ $backup->id }}'; $dispatch('open-modal', 'delete-backup')">
Delete
</x-icon-button>
</x-td>
</tr>
@endforeach
</x-table>
@include('livewire.databases.partials.delete-backup-modal')
@else
<x-simple-card class="text-center">{{ __("You don't have any backups yet") }}</x-simple-card>
@endif
</div>

View File

@ -15,10 +15,10 @@
@if(count($databases) > 0)
<x-table>
<tr>
<x-td>{{ __("Name") }}</x-td>
<x-td>{{ __("Created") }}</x-td>
<x-td>{{ __("Status") }}</x-td>
<x-td></x-td>
<x-th>{{ __("Name") }}</x-th>
<x-th>{{ __("Created") }}</x-th>
<x-th>{{ __("Status") }}</x-th>
<x-th></x-th>
</tr>
@foreach($databases as $database)
<tr>

View File

@ -15,11 +15,11 @@
@if(count($databaseUsers) > 0)
<x-table>
<tr>
<x-td>{{ __("Username") }}</x-td>
<x-td>{{ __("Created") }}</x-td>
<x-td>{{ __("Linked Databases") }}</x-td>
<x-td>{{ __("Status") }}</x-td>
<x-td></x-td>
<x-th>{{ __("Username") }}</x-th>
<x-th>{{ __("Created") }}</x-th>
<x-th>{{ __("Linked Databases") }}</x-th>
<x-th>{{ __("Status") }}</x-th>
<x-th></x-th>
</tr>
@foreach($databaseUsers as $databaseUser)
<tr>

View File

@ -0,0 +1,21 @@
@if($status == \App\Enums\BackupFileStatus::CREATED)
<x-status status="success">{{ $status }}</x-status>
@endif
@if($status == \App\Enums\BackupFileStatus::CREATING)
<x-status status="warning">{{ $status }}</x-status>
@endif
@if($status == \App\Enums\BackupFileStatus::FAILED)
<x-status status="danger">{{ $status }}</x-status>
@endif
@if($status == \App\Enums\BackupFileStatus::DELETING)
<x-status status="danger">{{ $status }}</x-status>
@endif
@if($status == \App\Enums\BackupFileStatus::RESTORING)
<x-status status="warning">{{ $status }}</x-status>
@endif
@if($status == \App\Enums\BackupFileStatus::RESTORED)
<x-status status="success">{{ $status }}</x-status>
@endif
@if($status == \App\Enums\BackupFileStatus::RESTORE_FAILED)
<x-status status="danger">{{ $status }}</x-status>
@endif

View File

@ -0,0 +1,9 @@
@if($status == \App\Enums\BackupStatus::RUNNING)
<x-status status="success">{{ $status }}</x-status>
@endif
@if($status == \App\Enums\BackupStatus::DELETING)
<x-status status="danger">{{ $status }}</x-status>
@endif
@if($status == \App\Enums\BackupStatus::FAILED)
<x-status status="danger">{{ $status }}</x-status>
@endif

View File

@ -0,0 +1,78 @@
<x-modal name="create-backup">
<form wire:submit.prevent="create" class="p-6" x-data="{user: false, remote: false}">
<h2 class="text-lg font-medium text-gray-900 dark:text-gray-100">
{{ __('Create Backup') }}
</h2>
<div class="mt-6">
<x-input-label for="database" :value="__('Database')" />
<x-select-input wire:model="database" id="database" name="database" class="mt-1 w-full">
<option value="" selected disabled>{{ __("Select") }}</option>
@foreach($databases as $db)
<option value="{{ $db->id }}" @if($database == $db->id) selected @endif>{{ $db->name }}</option>
@endforeach
</x-select-input>
@error('database')
<x-input-error class="mt-2" :messages="$message" />
@enderror
</div>
<div class="mt-6">
<x-input-label for="storage" :value="__('Storage')" />
<x-select-input wire:model="storage" id="storage" name="storage" class="mt-1 w-full">
<option value="" selected disabled>{{ __("Select") }}</option>
@foreach(auth()->user()->storageProviders as $st)
<option value="{{ $st->id }}" @if($storage == $st->id) selected @endif>{{ $st->profile }} - {{ $st->provider }}</option>
@endforeach
</x-select-input>
@error('storage')
<x-input-error class="mt-2" :messages="$message" />
@enderror
</div>
<div class="mt-6">
<x-input-label for="interval" :value="__('Interval')" />
<x-select-input wire:model="interval" id="interval" name="interval" class="mt-1 w-full">
<option value="" selected disabled>{{ __("Select") }}</option>
<option value="0 * * * *" @if($interval === '0 * * * *') selected @endif>{{ __("Hourly") }}</option>
<option value="0 0 * * *" @if($interval === '0 0 * * *') selected @endif>{{ __("Daily") }}</option>
<option value="0 0 * * 0" @if($interval === '0 0 * * 0') selected @endif>{{ __("Weekly") }}</option>
<option value="0 0 1 * *" @if($interval === '0 0 1 * *') selected @endif>{{ __("Monthly") }}</option>
<option value="custom">{{ __("Custom") }}</option>
</x-select-input>
@error('interval')
<x-input-error class="mt-2" :messages="$message" />
@enderror
</div>
@if($interval === 'custom')
<div class="mt-6">
<x-input-label for="custom" :value="__('Custom interval (Cron)')" />
<x-text-input wire:model.defer="custom" id="custom" name="custom" type="text" class="mt-1 w-full" placeholder="* * * * *" />
@error('custom')
<x-input-error class="mt-2" :messages="$message" />
@enderror
</div>
@endif
<div class="mt-6">
<x-input-label for="keep" :value="__('Backups to Keep')" />
<x-text-input wire:model.defer="keep" id="keep" name="keep" type="text" class="mt-1 w-full" />
@error('keep')
<x-input-error class="mt-2" :messages="$message" />
@enderror
</div>
<div class="mt-6 flex justify-end">
<x-secondary-button type="button" x-on:click="$dispatch('close')">
{{ __('Cancel') }}
</x-secondary-button>
<x-primary-button class="ml-3" @backup-created.window="$dispatch('close')">
{{ __('Create') }}
</x-primary-button>
</div>
</form>
</x-modal>

View File

@ -0,0 +1,6 @@
<x-confirm-modal
name="delete-backup-file"
:title="__('Confirm')"
:description="__('Are you sure that you want to delete this file?')"
method="delete"
/>

View File

@ -0,0 +1,6 @@
<x-confirm-modal
name="delete-backup"
:title="__('Confirm')"
:description="__('Are you sure that you want to delete this backup?')"
method="delete"
/>

View File

@ -0,0 +1,30 @@
<x-modal name="restore-backup">
<form wire:submit.prevent="restore" class="p-6" x-data="{}">
<h2 class="text-lg font-medium text-gray-900 dark:text-gray-100">
{{ __('Restore Backup') }}
</h2>
<div class="mt-6">
<x-input-label for="database" :value="__('Database')" />
<x-select-input wire:model="restoreDatabaseId" id="restoreDatabaseId" name="restoreDatabaseId" class="mt-1 w-full">
<option value="" selected disabled>{{ __("Select") }}</option>
@foreach($databases as $db)
<option value="{{ $db->id }}" @if($restoreDatabaseId == $db->id) selected @endif>{{ $db->name }}</option>
@endforeach
</x-select-input>
@error('restoreDatabaseId')
<x-input-error class="mt-2" :messages="$message" />
@enderror
</div>
<div class="mt-6 flex justify-end">
<x-secondary-button type="button" x-on:click="$dispatch('close')">
{{ __('Cancel') }}
</x-secondary-button>
<x-primary-button class="ml-3" @restored.window="$dispatch('close')">
{{ __('Restore') }}
</x-primary-button>
</div>
</form>
</x-modal>

View File

@ -0,0 +1,54 @@
<div>
<x-primary-button x-data="" x-on:click.prevent="$dispatch('open-modal', 'connect-provider')">
{{ __('Connect') }}
</x-primary-button>
<x-modal name="connect-provider">
<form wire:submit.prevent="connect" class="p-6">
<h2 class="text-lg font-medium text-gray-900 dark:text-gray-100">
{{ __('Connect to a Storage Provider') }}
</h2>
<div class="mt-6">
<x-input-label for="provider" value="Provider" />
<x-select-input wire:model="provider" id="provider" name="provider" class="mt-1 w-full">
<option value="" selected disabled>{{ __("Select") }}</option>
@foreach(config('core.storage_providers') as $p)
@if($p !== 'custom')
<option value="{{ $p }}" @if($provider === $p) selected @endif>{{ $p }}</option>
@endif
@endforeach
</x-select-input>
@error('provider')
<x-input-error class="mt-2" :messages="$message" />
@enderror
</div>
<div class="mt-6">
<x-input-label for="name" value="Name" />
<x-text-input wire:model.defer="name" id="name" name="name" type="text" class="mt-1 w-full" />
@error('name')
<x-input-error class="mt-2" :messages="$message" />
@enderror
</div>
<div class="mt-6">
<x-input-label for="token" value="API Key" />
<x-text-input wire:model.defer="token" id="token" name="token" type="text" class="mt-1 w-full" />
@error('token')
<x-input-error class="mt-2" :messages="$message" />
@enderror
</div>
<div class="mt-6 flex justify-end">
<x-secondary-button type="button" x-on:click="$dispatch('close')">
{{ __('Cancel') }}
</x-secondary-button>
<x-primary-button class="ml-3" @connected.window="$dispatch('close')">
{{ __('Connect') }}
</x-primary-button>
</div>
</form>
</x-modal>
</div>

View File

@ -0,0 +1,45 @@
<div>
<x-card-header>
<x-slot name="title">Storage Providers</x-slot>
<x-slot name="description">You can connect to your storage providers</x-slot>
<x-slot name="aside">
<livewire:storage-providers.connect-provider />
</x-slot>
</x-card-header>
<div x-data="" class="space-y-3">
@if(count($providers) > 0)
@foreach($providers as $provider)
<x-item-card>
<div class="flex-none">
<img src="{{ asset('static/images/' . $provider->provider . '.svg') }}" class="h-10 w-10" alt="">
</div>
<div class="ml-3 flex flex-grow flex-col items-start justify-center">
<span class="mb-1">{{ $provider->profile }}</span>
<span class="text-sm text-gray-400">
<x-datetime :value="$provider->created_at"/>
</span>
</div>
<div class="flex items-center">
<div class="inline">
<x-icon-button x-on:click="$wire.deleteId = '{{ $provider->id }}'; $dispatch('open-modal', 'delete-provider')">
Delete
</x-icon-button>
</div>
</div>
</x-item-card>
@endforeach
<x-confirm-modal
name="delete-provider"
:title="__('Confirm')"
:description="__('Are you sure that you want to delete this provider?')"
method="delete"
/>
@else
<x-simple-card>
<div class="text-center">
{{ __("You haven't connected to any storage providers yet!") }}
</div>
</x-simple-card>
@endif
</div>
</div>

View File

@ -1,5 +1,5 @@
<x-profile-layout>
<x-slot name="pageTitle">{{ __("Server Providers") }}</x-slot>
<x-slot name="pageTitle">{{ __("Storage Providers") }}</x-slot>
<livewire:server-providers.providers-list />
</x-profile-layout>

View File

@ -0,0 +1,5 @@
<x-profile-layout>
<x-slot name="pageTitle">{{ __("Storage Providers") }}</x-slot>
<livewire:storage-providers.providers-list />
</x-profile-layout>

View File

@ -21,6 +21,7 @@
Route::view('/profile', 'profile.index')->name('profile');
Route::view('/server-providers', 'server-providers.index')->name('server-providers');
Route::view('/source-controls', 'source-controls.index')->name('source-controls');
Route::view('/storage-providers', 'storage-providers.index')->name('storage-providers');
Route::view('/notification-channels', 'notification-channels.index')->name('notification-channels');
Route::view('/ssh-keys', 'ssh-keys.index')->name('ssh-keys');
});
@ -32,6 +33,9 @@
Route::get('/{server}/settings', [ServerSettingController::class, 'index'])->name('servers.settings');
Route::middleware('server-is-ready')->group(function () {
Route::get('/{server}/databases', [DatabaseController::class, 'index'])->name('servers.databases');
Route::get('/{server}/databases/backups/{backup}', [DatabaseController::class, 'backups'])->name(
'servers.databases.backups'
);
Route::prefix('/{server}/sites')->group(function () {
Route::get('/', [SiteController::class, 'index'])->name('servers.sites');
Route::get('/create', [SiteController::class, 'create'])->name('servers.sites.create');

View File

@ -0,0 +1,105 @@
<?php
namespace Tests\Feature\Http;
use App\Enums\BackupStatus;
use App\Facades\SSH;
use App\Http\Livewire\Databases\DatabaseBackups;
use App\Jobs\Backup\RunBackup;
use App\Models\Backup;
use App\Models\Database;
use App\Models\StorageProvider;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Livewire\Livewire;
use Tests\TestCase;
class DatabaseBackupTest extends TestCase
{
use RefreshDatabase;
public function test_create_backup(): void
{
$this->actingAs($this->user);
Bus::fake();
SSH::fake()->outputShouldBe('test');
$database = Database::factory()->create([
'server_id' => $this->server,
]);
$storage = StorageProvider::factory()->create([
'user_id' => $this->user->id,
'provider' => \App\Enums\StorageProvider::DROPBOX
]);
Livewire::test(DatabaseBackups::class, ['server' => $this->server])
->set('database', $database->id)
->set('storage', $storage->id)
->set('interval', '0 * * * *')
->set('keep', '10')
->call('create')
->assertSuccessful();
Bus::assertDispatched(RunBackup::class);
$this->assertDatabaseHas('backups', [
'status' => BackupStatus::RUNNING,
]);
}
public function test_see_backups_list(): 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,
]);
Livewire::test(DatabaseBackups::class, ['server' => $this->server])
->assertSee([
$backup->database->name,
]);
}
public function test_delete_database(): 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,
]);
Livewire::test(DatabaseBackups::class, ['server' => $this->server])
->set('deleteId', $backup->id)
->call('delete');
$this->assertDatabaseMissing('backups', [
'id' => $backup->id,
]);
}
}

View File

@ -0,0 +1,68 @@
<?php
namespace Tests\Feature\Http;
use App\Enums\StorageProvider;
use App\Http\Livewire\StorageProviders\ProvidersList;
use App\Http\Livewire\StorageProviders\ConnectProvider;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
use Tests\TestCase;
class StorageProvidersTest extends TestCase
{
use RefreshDatabase;
public function test_connect_dropbox(): void
{
$this->actingAs($this->user);
Http::fake();
Livewire::test(ConnectProvider::class)
->set('provider', StorageProvider::DROPBOX)
->set('name', 'profile')
->set('token', 'token')
->call('connect')
->assertSuccessful();
$this->assertDatabaseHas('storage_providers', [
'provider' => StorageProvider::DROPBOX,
'profile' => 'profile',
]);
}
public function test_see_providers_list(): void
{
$this->actingAs($this->user);
$provider = \App\Models\StorageProvider::factory()->create([
'user_id' => $this->user->id,
'provider' => StorageProvider::DROPBOX
]);
Livewire::test(ProvidersList::class)
->assertSee([
$provider->profile,
]);
}
public function test_delete_provider(): void
{
$this->actingAs($this->user);
$provider = \App\Models\StorageProvider::factory()->create([
'user_id' => $this->user->id,
]);
Livewire::test(ProvidersList::class)
->set('deleteId', $provider->id)
->call('delete')
->assertSuccessful();
$this->assertDatabaseMissing('storage_providers', [
'id' => $provider->id,
]);
}
}