forked from Alex/Pterodactyl-Panel
Add support for locking backups to prevent any accidental deletions
This commit is contained in:
parent
5f48712c28
commit
5d5e4ca7b1
16
app/Exceptions/Service/Backup/BackupLockedException.php
Normal file
16
app/Exceptions/Service/Backup/BackupLockedException.php
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Pterodactyl\Exceptions\Service\Backup;
|
||||||
|
|
||||||
|
use Pterodactyl\Exceptions\DisplayException;
|
||||||
|
|
||||||
|
class BackupLockedException extends DisplayException
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* TooManyBackupsException constructor.
|
||||||
|
*/
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
parent::__construct('Cannot delete a backup that is marked as locked.');
|
||||||
|
}
|
||||||
|
}
|
@ -61,8 +61,6 @@ class BackupController extends ClientApiController
|
|||||||
* Returns all of the backups for a given server instance in a paginated
|
* Returns all of the backups for a given server instance in a paginated
|
||||||
* result set.
|
* result set.
|
||||||
*
|
*
|
||||||
* @throws \Spatie\Fractalistic\Exceptions\InvalidTransformation
|
|
||||||
* @throws \Spatie\Fractalistic\Exceptions\NoTransformerSpecified
|
|
||||||
* @throws \Illuminate\Auth\Access\AuthorizationException
|
* @throws \Illuminate\Auth\Access\AuthorizationException
|
||||||
*/
|
*/
|
||||||
public function index(Request $request, Server $server): array
|
public function index(Request $request, Server $server): array
|
||||||
@ -89,11 +87,18 @@ class BackupController extends ClientApiController
|
|||||||
{
|
{
|
||||||
/** @var \Pterodactyl\Models\Backup $backup */
|
/** @var \Pterodactyl\Models\Backup $backup */
|
||||||
$backup = $server->audit(AuditLog::SERVER__BACKUP_STARTED, function (AuditLog $model, Server $server) use ($request) {
|
$backup = $server->audit(AuditLog::SERVER__BACKUP_STARTED, function (AuditLog $model, Server $server) use ($request) {
|
||||||
$backup = $this->initiateBackupService
|
$action = $this->initiateBackupService
|
||||||
->setIgnoredFiles(
|
->setIgnoredFiles(explode(PHP_EOL, $request->input('ignored') ?? ''));
|
||||||
explode(PHP_EOL, $request->input('ignored') ?? '')
|
|
||||||
)
|
// Only set the lock status if the user even has permission to delete backups,
|
||||||
->handle($server, $request->input('name'));
|
// otherwise ignore this status. This gets a little funky since it isn't clear
|
||||||
|
// how best to allow a user to create a backup that is locked without also preventing
|
||||||
|
// them from just filling up a server with backups that can never be deleted?
|
||||||
|
if ($request->user()->can(Permission::ACTION_BACKUP_DELETE, $server)) {
|
||||||
|
$action->setIsLocked((bool) $request->input('is_locked'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$backup = $action->handle($server, $request->input('name'));
|
||||||
|
|
||||||
$model->metadata = ['backup_uuid' => $backup->uuid];
|
$model->metadata = ['backup_uuid' => $backup->uuid];
|
||||||
|
|
||||||
@ -105,11 +110,35 @@ class BackupController extends ClientApiController
|
|||||||
->toArray();
|
->toArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggles the lock status of a given backup for a server.
|
||||||
|
*
|
||||||
|
* @throws \Throwable
|
||||||
|
* @throws \Illuminate\Auth\Access\AuthorizationException
|
||||||
|
*/
|
||||||
|
public function toggleLock(Request $request, Server $server, Backup $backup): array
|
||||||
|
{
|
||||||
|
if (!$request->user()->can(Permission::ACTION_BACKUP_DELETE, $server)) {
|
||||||
|
throw new AuthorizationException();
|
||||||
|
}
|
||||||
|
|
||||||
|
$action = $backup->is_locked ? AuditLog::SERVER__BACKUP_UNLOCKED : AuditLog::SERVER__BACKUP_LOCKED;
|
||||||
|
$server->audit($action, function (AuditLog $audit) use ($backup) {
|
||||||
|
$audit->metadata = ['backup_uuid' => $backup->uuid];
|
||||||
|
|
||||||
|
$backup->update(['is_locked' => !$backup->is_locked]);
|
||||||
|
});
|
||||||
|
|
||||||
|
$backup->refresh();
|
||||||
|
|
||||||
|
return $this->fractal->item($backup)
|
||||||
|
->transformWith($this->getTransformer(BackupTransformer::class))
|
||||||
|
->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns information about a single backup.
|
* Returns information about a single backup.
|
||||||
*
|
*
|
||||||
* @throws \Spatie\Fractalistic\Exceptions\InvalidTransformation
|
|
||||||
* @throws \Spatie\Fractalistic\Exceptions\NoTransformerSpecified
|
|
||||||
* @throws \Illuminate\Auth\Access\AuthorizationException
|
* @throws \Illuminate\Auth\Access\AuthorizationException
|
||||||
*/
|
*/
|
||||||
public function view(Request $request, Server $server, Backup $backup): array
|
public function view(Request $request, Server $server, Backup $backup): array
|
||||||
|
@ -19,6 +19,7 @@ class StoreBackupRequest extends ClientApiRequest
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'name' => 'nullable|string|max:191',
|
'name' => 'nullable|string|max:191',
|
||||||
|
'is_locked' => 'nullable|boolean',
|
||||||
'ignored' => 'nullable|string',
|
'ignored' => 'nullable|string',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
@ -36,6 +36,8 @@ class AuditLog extends Model
|
|||||||
public const SERVER__BACKUP_COMPELTED = 'server:backup.completed';
|
public const SERVER__BACKUP_COMPELTED = 'server:backup.completed';
|
||||||
public const SERVER__BACKUP_DELETED = 'server:backup.deleted';
|
public const SERVER__BACKUP_DELETED = 'server:backup.deleted';
|
||||||
public const SERVER__BACKUP_DOWNLOADED = 'server:backup.downloaded';
|
public const SERVER__BACKUP_DOWNLOADED = 'server:backup.downloaded';
|
||||||
|
public const SERVER__BACKUP_LOCKED = 'server:backup.locked';
|
||||||
|
public const SERVER__BACKUP_UNLOCKED = 'server:backup.unlocked';
|
||||||
public const SERVER__BACKUP_RESTORE_STARTED = 'server:backup.restore.started';
|
public const SERVER__BACKUP_RESTORE_STARTED = 'server:backup.restore.started';
|
||||||
public const SERVER__BACKUP_RESTORE_COMPLETED = 'server:backup.restore.completed';
|
public const SERVER__BACKUP_RESTORE_COMPLETED = 'server:backup.restore.completed';
|
||||||
public const SERVER__BACKUP_RESTORE_FAILED = 'server:backup.restore.failed';
|
public const SERVER__BACKUP_RESTORE_FAILED = 'server:backup.restore.failed';
|
||||||
|
@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
|||||||
* @property int $server_id
|
* @property int $server_id
|
||||||
* @property string $uuid
|
* @property string $uuid
|
||||||
* @property bool $is_successful
|
* @property bool $is_successful
|
||||||
|
* @property bool $is_locked
|
||||||
* @property string $name
|
* @property string $name
|
||||||
* @property string[] $ignored_files
|
* @property string[] $ignored_files
|
||||||
* @property string $disk
|
* @property string $disk
|
||||||
@ -46,6 +47,7 @@ class Backup extends Model
|
|||||||
protected $casts = [
|
protected $casts = [
|
||||||
'id' => 'int',
|
'id' => 'int',
|
||||||
'is_successful' => 'bool',
|
'is_successful' => 'bool',
|
||||||
|
'is_locked' => 'bool',
|
||||||
'ignored_files' => 'array',
|
'ignored_files' => 'array',
|
||||||
'bytes' => 'int',
|
'bytes' => 'int',
|
||||||
];
|
];
|
||||||
@ -62,6 +64,7 @@ class Backup extends Model
|
|||||||
*/
|
*/
|
||||||
protected $attributes = [
|
protected $attributes = [
|
||||||
'is_successful' => true,
|
'is_successful' => true,
|
||||||
|
'is_locked' => false,
|
||||||
'checksum' => null,
|
'checksum' => null,
|
||||||
'bytes' => 0,
|
'bytes' => 0,
|
||||||
'upload_id' => null,
|
'upload_id' => null,
|
||||||
@ -79,6 +82,7 @@ class Backup extends Model
|
|||||||
'server_id' => 'bail|required|numeric|exists:servers,id',
|
'server_id' => 'bail|required|numeric|exists:servers,id',
|
||||||
'uuid' => 'required|uuid',
|
'uuid' => 'required|uuid',
|
||||||
'is_successful' => 'boolean',
|
'is_successful' => 'boolean',
|
||||||
|
'is_locked' => 'boolean',
|
||||||
'name' => 'required|string',
|
'name' => 'required|string',
|
||||||
'ignored_files' => 'array',
|
'ignored_files' => 'array',
|
||||||
'disk' => 'required|string',
|
'disk' => 'required|string',
|
||||||
|
@ -9,6 +9,7 @@ use Illuminate\Database\ConnectionInterface;
|
|||||||
use Pterodactyl\Extensions\Backups\BackupManager;
|
use Pterodactyl\Extensions\Backups\BackupManager;
|
||||||
use Pterodactyl\Repositories\Eloquent\BackupRepository;
|
use Pterodactyl\Repositories\Eloquent\BackupRepository;
|
||||||
use Pterodactyl\Repositories\Wings\DaemonBackupRepository;
|
use Pterodactyl\Repositories\Wings\DaemonBackupRepository;
|
||||||
|
use Pterodactyl\Exceptions\Service\Backup\BackupLockedException;
|
||||||
use Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException;
|
use Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException;
|
||||||
|
|
||||||
class DeleteBackupService
|
class DeleteBackupService
|
||||||
@ -55,6 +56,10 @@ class DeleteBackupService
|
|||||||
*/
|
*/
|
||||||
public function handle(Backup $backup)
|
public function handle(Backup $backup)
|
||||||
{
|
{
|
||||||
|
if ($backup->is_locked) {
|
||||||
|
throw new BackupLockedException();
|
||||||
|
}
|
||||||
|
|
||||||
if ($backup->disk === Backup::ADAPTER_AWS_S3) {
|
if ($backup->disk === Backup::ADAPTER_AWS_S3) {
|
||||||
$this->deleteFromS3($backup);
|
$this->deleteFromS3($backup);
|
||||||
|
|
||||||
|
@ -21,6 +21,11 @@ class InitiateBackupService
|
|||||||
*/
|
*/
|
||||||
private $ignoredFiles;
|
private $ignoredFiles;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var bool
|
||||||
|
*/
|
||||||
|
private $isLocked = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var \Pterodactyl\Repositories\Eloquent\BackupRepository
|
* @var \Pterodactyl\Repositories\Eloquent\BackupRepository
|
||||||
*/
|
*/
|
||||||
@ -49,7 +54,11 @@ class InitiateBackupService
|
|||||||
/**
|
/**
|
||||||
* InitiateBackupService constructor.
|
* InitiateBackupService constructor.
|
||||||
*
|
*
|
||||||
|
* @param \Pterodactyl\Repositories\Eloquent\BackupRepository $repository
|
||||||
|
* @param \Illuminate\Database\ConnectionInterface $connection
|
||||||
|
* @param \Pterodactyl\Repositories\Wings\DaemonBackupRepository $daemonBackupRepository
|
||||||
* @param \Pterodactyl\Services\Backups\DeleteBackupService $deleteBackupService
|
* @param \Pterodactyl\Services\Backups\DeleteBackupService $deleteBackupService
|
||||||
|
* @param \Pterodactyl\Extensions\Backups\BackupManager $backupManager
|
||||||
*/
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
BackupRepository $repository,
|
BackupRepository $repository,
|
||||||
@ -65,6 +74,19 @@ class InitiateBackupService
|
|||||||
$this->deleteBackupService = $deleteBackupService;
|
$this->deleteBackupService = $deleteBackupService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set if the backup should be locked once it is created which will prevent
|
||||||
|
* its deletion by users or automated system processes.
|
||||||
|
*
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function setIsLocked(bool $isLocked): self
|
||||||
|
{
|
||||||
|
$this->isLocked = $isLocked;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the files to be ignored by this backup.
|
* Sets the files to be ignored by this backup.
|
||||||
*
|
*
|
||||||
@ -91,7 +113,7 @@ class InitiateBackupService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initiates the backup process for a server on the daemon.
|
* Initiates the backup process for a server on Wings.
|
||||||
*
|
*
|
||||||
* @throws \Throwable
|
* @throws \Throwable
|
||||||
* @throws \Pterodactyl\Exceptions\Service\Backup\TooManyBackupsException
|
* @throws \Pterodactyl\Exceptions\Service\Backup\TooManyBackupsException
|
||||||
@ -104,23 +126,30 @@ class InitiateBackupService
|
|||||||
if ($period > 0) {
|
if ($period > 0) {
|
||||||
$previous = $this->repository->getBackupsGeneratedDuringTimespan($server->id, $period);
|
$previous = $this->repository->getBackupsGeneratedDuringTimespan($server->id, $period);
|
||||||
if ($previous->count() >= $limit) {
|
if ($previous->count() >= $limit) {
|
||||||
throw new TooManyRequestsHttpException(CarbonImmutable::now()->diffInSeconds($previous->last()->created_at->addSeconds($period)), sprintf('Only %d backups may be generated within a %d second span of time.', $limit, $period));
|
$message = sprintf('Only %d backups may be generated within a %d second span of time.', $limit, $period);
|
||||||
|
|
||||||
|
throw new TooManyRequestsHttpException(CarbonImmutable::now()->diffInSeconds($previous->last()->created_at->addSeconds($period)), $message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the server has reached or exceeded it's backup limit
|
// Check if the server has reached or exceeded it's backup limit.
|
||||||
if (!$server->backup_limit || $server->backups()->where('is_successful', true)->count() >= $server->backup_limit) {
|
$successful = $server->backups()->where('is_successful', true);
|
||||||
|
if (!$server->backup_limit || $successful->count() >= $server->backup_limit) {
|
||||||
// Do not allow the user to continue if this server is already at its limit and can't override.
|
// Do not allow the user to continue if this server is already at its limit and can't override.
|
||||||
if (!$override || $server->backup_limit <= 0) {
|
if (!$override || $server->backup_limit <= 0) {
|
||||||
throw new TooManyBackupsException($server->backup_limit);
|
throw new TooManyBackupsException($server->backup_limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the oldest backup the server has.
|
// Get the oldest backup the server has that is not "locked" (indicating a backup that should
|
||||||
/** @var \Pterodactyl\Models\Backup $oldestBackup */
|
// never be automatically purged). If we find a backup we will delete it and then continue with
|
||||||
$oldestBackup = $server->backups()->where('is_successful', true)->orderBy('created_at')->first();
|
// this process. If no backup is found that can be used an exception is thrown.
|
||||||
|
/** @var \Pterodactyl\Models\Backup $oldest */
|
||||||
|
$oldest = $successful->where('is_locked', false)->orderBy('created_at')->first();
|
||||||
|
if (!$oldest) {
|
||||||
|
throw new TooManyBackupsException($server->backup_limit);
|
||||||
|
}
|
||||||
|
|
||||||
// Delete the oldest backup.
|
$this->deleteBackupService->handle($oldest);
|
||||||
$this->deleteBackupService->handle($oldestBackup);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->connection->transaction(function () use ($server, $name) {
|
return $this->connection->transaction(function () use ($server, $name) {
|
||||||
@ -131,6 +160,7 @@ class InitiateBackupService
|
|||||||
'name' => trim($name) ?: sprintf('Backup at %s', CarbonImmutable::now()->toDateTimeString()),
|
'name' => trim($name) ?: sprintf('Backup at %s', CarbonImmutable::now()->toDateTimeString()),
|
||||||
'ignored_files' => array_values($this->ignoredFiles ?? []),
|
'ignored_files' => array_values($this->ignoredFiles ?? []),
|
||||||
'disk' => $this->backupManager->getDefaultAdapter(),
|
'disk' => $this->backupManager->getDefaultAdapter(),
|
||||||
|
'is_locked' => $this->isLocked,
|
||||||
], true, true);
|
], true, true);
|
||||||
|
|
||||||
$this->daemonBackupRepository->setServer($server)
|
$this->daemonBackupRepository->setServer($server)
|
||||||
|
@ -19,6 +19,7 @@ class BackupTransformer extends BaseClientTransformer
|
|||||||
return [
|
return [
|
||||||
'uuid' => $backup->uuid,
|
'uuid' => $backup->uuid,
|
||||||
'is_successful' => $backup->is_successful,
|
'is_successful' => $backup->is_successful,
|
||||||
|
'is_locked' => $backup->is_locked,
|
||||||
'name' => $backup->name,
|
'name' => $backup->name,
|
||||||
'ignored_files' => $backup->ignored_files,
|
'ignored_files' => $backup->ignored_files,
|
||||||
'checksum' => $backup->checksum,
|
'checksum' => $backup->checksum,
|
||||||
|
@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
class AddSupportForLockingABackup extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
Schema::table('backups', function (Blueprint $table) {
|
||||||
|
$table->unsignedTinyInteger('is_locked')->after('is_successful')->default(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function down()
|
||||||
|
{
|
||||||
|
Schema::table('backups', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('is_locked');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -2,12 +2,18 @@ import http from '@/api/http';
|
|||||||
import { ServerBackup } from '@/api/server/types';
|
import { ServerBackup } from '@/api/server/types';
|
||||||
import { rawDataToServerBackup } from '@/api/transformers';
|
import { rawDataToServerBackup } from '@/api/transformers';
|
||||||
|
|
||||||
export default (uuid: string, name?: string, ignored?: string): Promise<ServerBackup> => {
|
interface RequestParameters {
|
||||||
return new Promise((resolve, reject) => {
|
name?: string;
|
||||||
http.post(`/api/client/servers/${uuid}/backups`, {
|
ignored?: string;
|
||||||
name, ignored,
|
isLocked: boolean;
|
||||||
})
|
}
|
||||||
.then(({ data }) => resolve(rawDataToServerBackup(data)))
|
|
||||||
.catch(reject);
|
export default async (uuid: string, params: RequestParameters): Promise<ServerBackup> => {
|
||||||
|
const { data } = await http.post(`/api/client/servers/${uuid}/backups`, {
|
||||||
|
name: params.name,
|
||||||
|
ignored: params.ignored,
|
||||||
|
is_locked: params.isLocked,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return rawDataToServerBackup(data);
|
||||||
};
|
};
|
||||||
|
1
resources/scripts/api/server/types.d.ts
vendored
1
resources/scripts/api/server/types.d.ts
vendored
@ -3,6 +3,7 @@ export type ServerStatus = 'installing' | 'install_failed' | 'suspended' | 'rest
|
|||||||
export interface ServerBackup {
|
export interface ServerBackup {
|
||||||
uuid: string;
|
uuid: string;
|
||||||
isSuccessful: boolean;
|
isSuccessful: boolean;
|
||||||
|
isLocked: boolean;
|
||||||
name: string;
|
name: string;
|
||||||
ignoredFiles: string;
|
ignoredFiles: string;
|
||||||
checksum: string;
|
checksum: string;
|
||||||
|
@ -58,6 +58,7 @@ export const rawDataToFileObject = (data: FractalResponseData): FileObject => ({
|
|||||||
export const rawDataToServerBackup = ({ attributes }: FractalResponseData): ServerBackup => ({
|
export const rawDataToServerBackup = ({ attributes }: FractalResponseData): ServerBackup => ({
|
||||||
uuid: attributes.uuid,
|
uuid: attributes.uuid,
|
||||||
isSuccessful: attributes.is_successful,
|
isSuccessful: attributes.is_successful,
|
||||||
|
isLocked: attributes.is_locked,
|
||||||
name: attributes.name,
|
name: attributes.name,
|
||||||
ignoredFiles: attributes.ignored_files,
|
ignoredFiles: attributes.ignored_files,
|
||||||
checksum: attributes.checksum,
|
checksum: attributes.checksum,
|
||||||
|
@ -1,10 +1,16 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { faBoxOpen, faCloudDownloadAlt, faEllipsisH, faLock, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
|
import {
|
||||||
|
faBoxOpen,
|
||||||
|
faCloudDownloadAlt,
|
||||||
|
faEllipsisH,
|
||||||
|
faLock,
|
||||||
|
faTrashAlt,
|
||||||
|
faUnlock,
|
||||||
|
} from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import DropdownMenu, { DropdownButtonRow } from '@/components/elements/DropdownMenu';
|
import DropdownMenu, { DropdownButtonRow } from '@/components/elements/DropdownMenu';
|
||||||
import getBackupDownloadUrl from '@/api/server/backups/getBackupDownloadUrl';
|
import getBackupDownloadUrl from '@/api/server/backups/getBackupDownloadUrl';
|
||||||
import useFlash from '@/plugins/useFlash';
|
import useFlash from '@/plugins/useFlash';
|
||||||
import ChecksumModal from '@/components/server/backups/ChecksumModal';
|
|
||||||
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
||||||
import deleteBackup from '@/api/server/backups/deleteBackup';
|
import deleteBackup from '@/api/server/backups/deleteBackup';
|
||||||
import ConfirmationModal from '@/components/elements/ConfirmationModal';
|
import ConfirmationModal from '@/components/elements/ConfirmationModal';
|
||||||
@ -15,6 +21,7 @@ import { ServerBackup } from '@/api/server/types';
|
|||||||
import { ServerContext } from '@/state/server';
|
import { ServerContext } from '@/state/server';
|
||||||
import Input from '@/components/elements/Input';
|
import Input from '@/components/elements/Input';
|
||||||
import { restoreServerBackup } from '@/api/server/backups';
|
import { restoreServerBackup } from '@/api/server/backups';
|
||||||
|
import http, { httpErrorToHuman } from '@/api/http';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
backup: ServerBackup;
|
backup: ServerBackup;
|
||||||
@ -76,14 +83,35 @@ export default ({ backup }: Props) => {
|
|||||||
.then(() => setModal(''));
|
.then(() => setModal(''));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onLockToggle = () => {
|
||||||
|
if (backup.isLocked && modal !== 'unlock') {
|
||||||
|
return setModal('unlock');
|
||||||
|
}
|
||||||
|
|
||||||
|
http.post(`/api/client/servers/${uuid}/backups/${backup.uuid}/lock`)
|
||||||
|
.then(() => mutate(data => ({
|
||||||
|
...data,
|
||||||
|
items: data.items.map(b => b.uuid !== backup.uuid ? b : {
|
||||||
|
...b,
|
||||||
|
isLocked: !b.isLocked,
|
||||||
|
}),
|
||||||
|
}), false))
|
||||||
|
.catch(error => alert(httpErrorToHuman(error)))
|
||||||
|
.then(() => setModal(''));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ChecksumModal
|
<ConfirmationModal
|
||||||
appear
|
visible={modal === 'unlock'}
|
||||||
visible={modal === 'checksum'}
|
title={'Unlock this backup?'}
|
||||||
onDismissed={() => setModal('')}
|
onConfirmed={onLockToggle}
|
||||||
checksum={backup.checksum}
|
onModalDismissed={() => setModal('')}
|
||||||
/>
|
buttonText={'Yes, unlock'}
|
||||||
|
>
|
||||||
|
Are you sure you want to unlock this backup? It will no longer be protected from automated or
|
||||||
|
accidental deletions.
|
||||||
|
</ConfirmationModal>
|
||||||
<ConfirmationModal
|
<ConfirmationModal
|
||||||
visible={modal === 'restore'}
|
visible={modal === 'restore'}
|
||||||
title={'Restore this backup?'}
|
title={'Restore this backup?'}
|
||||||
@ -151,15 +179,23 @@ export default ({ backup }: Props) => {
|
|||||||
<span css={tw`ml-2`}>Restore</span>
|
<span css={tw`ml-2`}>Restore</span>
|
||||||
</DropdownButtonRow>
|
</DropdownButtonRow>
|
||||||
</Can>
|
</Can>
|
||||||
<DropdownButtonRow onClick={() => setModal('checksum')}>
|
|
||||||
<FontAwesomeIcon fixedWidth icon={faLock} css={tw`text-xs`}/>
|
|
||||||
<span css={tw`ml-2`}>Checksum</span>
|
|
||||||
</DropdownButtonRow>
|
|
||||||
<Can action={'backup.delete'}>
|
<Can action={'backup.delete'}>
|
||||||
|
<>
|
||||||
|
<DropdownButtonRow onClick={onLockToggle}>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
fixedWidth
|
||||||
|
icon={backup.isLocked ? faUnlock : faLock}
|
||||||
|
css={tw`text-xs mr-2`}
|
||||||
|
/>
|
||||||
|
{backup.isLocked ? 'Unlock' : 'Lock'}
|
||||||
|
</DropdownButtonRow>
|
||||||
|
{!backup.isLocked &&
|
||||||
<DropdownButtonRow danger onClick={() => setModal('delete')}>
|
<DropdownButtonRow danger onClick={() => setModal('delete')}>
|
||||||
<FontAwesomeIcon fixedWidth icon={faTrashAlt} css={tw`text-xs`}/>
|
<FontAwesomeIcon fixedWidth icon={faTrashAlt} css={tw`text-xs`}/>
|
||||||
<span css={tw`ml-2`}>Delete</span>
|
<span css={tw`ml-2`}>Delete</span>
|
||||||
</DropdownButtonRow>
|
</DropdownButtonRow>
|
||||||
|
}
|
||||||
|
</>
|
||||||
</Can>
|
</Can>
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faArchive, faEllipsisH } from '@fortawesome/free-solid-svg-icons';
|
import { faArchive, faEllipsisH, faLock } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { format, formatDistanceToNow } from 'date-fns';
|
import { format, formatDistanceToNow } from 'date-fns';
|
||||||
import Spinner from '@/components/elements/Spinner';
|
import Spinner from '@/components/elements/Spinner';
|
||||||
import { bytesToHuman } from '@/helpers';
|
import { bytesToHuman } from '@/helpers';
|
||||||
@ -45,6 +45,9 @@ export default ({ backup, className }: Props) => {
|
|||||||
<div css={tw`flex items-center truncate w-full md:flex-1`}>
|
<div css={tw`flex items-center truncate w-full md:flex-1`}>
|
||||||
<div css={tw`mr-4`}>
|
<div css={tw`mr-4`}>
|
||||||
{backup.completedAt ?
|
{backup.completedAt ?
|
||||||
|
backup.isLocked ?
|
||||||
|
<FontAwesomeIcon icon={faLock} css={tw`text-yellow-500`}/>
|
||||||
|
:
|
||||||
<FontAwesomeIcon icon={faArchive} css={tw`text-neutral-300`}/>
|
<FontAwesomeIcon icon={faArchive} css={tw`text-neutral-300`}/>
|
||||||
:
|
:
|
||||||
<Spinner size={'small'}/>
|
<Spinner size={'small'}/>
|
||||||
@ -65,7 +68,7 @@ export default ({ backup, className }: Props) => {
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<p css={tw`mt-1 md:mt-0 text-xs text-neutral-400 font-mono truncate`}>
|
<p css={tw`mt-1 md:mt-0 text-xs text-neutral-400 font-mono truncate`}>
|
||||||
{backup.uuid}
|
{backup.checksum}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,17 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import Modal, { RequiredModalProps } from '@/components/elements/Modal';
|
|
||||||
import tw from 'twin.macro';
|
|
||||||
|
|
||||||
const ChecksumModal = ({ checksum, ...props }: RequiredModalProps & { checksum: string }) => (
|
|
||||||
<Modal {...props}>
|
|
||||||
<h3 css={tw`mb-6 text-2xl`}>Verify file checksum</h3>
|
|
||||||
<p css={tw`text-sm`}>
|
|
||||||
The checksum of this file is:
|
|
||||||
</p>
|
|
||||||
<pre css={tw`mt-2 text-sm p-2 bg-neutral-900 rounded`}>
|
|
||||||
<code css={tw`block font-mono overflow-auto`}>{checksum}</code>
|
|
||||||
</pre>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default ChecksumModal;
|
|
@ -1,7 +1,7 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import Modal, { RequiredModalProps } from '@/components/elements/Modal';
|
import Modal, { RequiredModalProps } from '@/components/elements/Modal';
|
||||||
import { Field as FormikField, Form, Formik, FormikHelpers, useFormikContext } from 'formik';
|
import { Field as FormikField, Form, Formik, FormikHelpers, useFormikContext } from 'formik';
|
||||||
import { object, string } from 'yup';
|
import { boolean, object, string } from 'yup';
|
||||||
import Field from '@/components/elements/Field';
|
import Field from '@/components/elements/Field';
|
||||||
import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper';
|
import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper';
|
||||||
import useFlash from '@/plugins/useFlash';
|
import useFlash from '@/plugins/useFlash';
|
||||||
@ -12,10 +12,13 @@ import tw from 'twin.macro';
|
|||||||
import { Textarea } from '@/components/elements/Input';
|
import { Textarea } from '@/components/elements/Input';
|
||||||
import getServerBackups from '@/api/swr/getServerBackups';
|
import getServerBackups from '@/api/swr/getServerBackups';
|
||||||
import { ServerContext } from '@/state/server';
|
import { ServerContext } from '@/state/server';
|
||||||
|
import FormikSwitch from '@/components/elements/FormikSwitch';
|
||||||
|
import Can from '@/components/elements/Can';
|
||||||
|
|
||||||
interface Values {
|
interface Values {
|
||||||
name: string;
|
name: string;
|
||||||
ignored: string;
|
ignored: string;
|
||||||
|
isLocked: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ModalContent = ({ ...props }: RequiredModalProps) => {
|
const ModalContent = ({ ...props }: RequiredModalProps) => {
|
||||||
@ -26,14 +29,12 @@ const ModalContent = ({ ...props }: RequiredModalProps) => {
|
|||||||
<Form>
|
<Form>
|
||||||
<FlashMessageRender byKey={'backups:create'} css={tw`mb-4`}/>
|
<FlashMessageRender byKey={'backups:create'} css={tw`mb-4`}/>
|
||||||
<h2 css={tw`text-2xl mb-6`}>Create server backup</h2>
|
<h2 css={tw`text-2xl mb-6`}>Create server backup</h2>
|
||||||
<div css={tw`mb-6`}>
|
|
||||||
<Field
|
<Field
|
||||||
name={'name'}
|
name={'name'}
|
||||||
label={'Backup name'}
|
label={'Backup name'}
|
||||||
description={'If provided, the name that should be used to reference this backup.'}
|
description={'If provided, the name that should be used to reference this backup.'}
|
||||||
/>
|
/>
|
||||||
</div>
|
<div css={tw`mt-6`}>
|
||||||
<div css={tw`mb-6`}>
|
|
||||||
<FormikFieldWrapper
|
<FormikFieldWrapper
|
||||||
name={'ignored'}
|
name={'ignored'}
|
||||||
label={'Ignored Files & Directories'}
|
label={'Ignored Files & Directories'}
|
||||||
@ -47,7 +48,16 @@ const ModalContent = ({ ...props }: RequiredModalProps) => {
|
|||||||
<FormikField as={Textarea} name={'ignored'} rows={6}/>
|
<FormikField as={Textarea} name={'ignored'} rows={6}/>
|
||||||
</FormikFieldWrapper>
|
</FormikFieldWrapper>
|
||||||
</div>
|
</div>
|
||||||
<div css={tw`flex justify-end`}>
|
<Can action={'backup.delete'}>
|
||||||
|
<div css={tw`mt-6 bg-neutral-700 border border-neutral-800 shadow-inner p-4 rounded`}>
|
||||||
|
<FormikSwitch
|
||||||
|
name={'isLocked'}
|
||||||
|
label={'Locked'}
|
||||||
|
description={'Prevents this backup from being deleted until explicitly unlocked.'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Can>
|
||||||
|
<div css={tw`flex justify-end mt-6`}>
|
||||||
<Button type={'submit'} disabled={isSubmitting}>
|
<Button type={'submit'} disabled={isSubmitting}>
|
||||||
Start backup
|
Start backup
|
||||||
</Button>
|
</Button>
|
||||||
@ -67,9 +77,9 @@ export default () => {
|
|||||||
clearFlashes('backups:create');
|
clearFlashes('backups:create');
|
||||||
}, [ visible ]);
|
}, [ visible ]);
|
||||||
|
|
||||||
const submit = ({ name, ignored }: Values, { setSubmitting }: FormikHelpers<Values>) => {
|
const submit = (values: Values, { setSubmitting }: FormikHelpers<Values>) => {
|
||||||
clearFlashes('backups:create');
|
clearFlashes('backups:create');
|
||||||
createServerBackup(uuid, name, ignored)
|
createServerBackup(uuid, values)
|
||||||
.then(backup => {
|
.then(backup => {
|
||||||
mutate(data => ({ ...data, items: data.items.concat(backup) }), false);
|
mutate(data => ({ ...data, items: data.items.concat(backup) }), false);
|
||||||
setVisible(false);
|
setVisible(false);
|
||||||
@ -85,10 +95,11 @@ export default () => {
|
|||||||
{visible &&
|
{visible &&
|
||||||
<Formik
|
<Formik
|
||||||
onSubmit={submit}
|
onSubmit={submit}
|
||||||
initialValues={{ name: '', ignored: '' }}
|
initialValues={{ name: '', ignored: '', isLocked: false }}
|
||||||
validationSchema={object().shape({
|
validationSchema={object().shape({
|
||||||
name: string().max(191),
|
name: string().max(191),
|
||||||
ignored: string(),
|
ignored: string(),
|
||||||
|
isLocked: boolean(),
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<ModalContent appear visible={visible} onDismissed={() => setVisible(false)}/>
|
<ModalContent appear visible={visible} onDismissed={() => setVisible(false)}/>
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Modal, { ModalProps } from '@/components/elements/Modal';
|
import PortaledModal, { ModalProps } from '@/components/elements/Modal';
|
||||||
import ModalContext from '@/context/ModalContext';
|
import ModalContext from '@/context/ModalContext';
|
||||||
|
|
||||||
export interface AsModalProps {
|
export interface AsModalProps {
|
||||||
@ -57,7 +57,7 @@ function asModal<P extends object> (modalProps?: ExtendedModalProps | ((props: P
|
|||||||
render () {
|
render () {
|
||||||
return (
|
return (
|
||||||
this.state.render ?
|
this.state.render ?
|
||||||
<Modal
|
<PortaledModal
|
||||||
appear
|
appear
|
||||||
visible={this.state.visible}
|
visible={this.state.visible}
|
||||||
onDismissed={() => this.setState({ render: false }, () => {
|
onDismissed={() => this.setState({ render: false }, () => {
|
||||||
@ -75,7 +75,7 @@ function asModal<P extends object> (modalProps?: ExtendedModalProps | ((props: P
|
|||||||
>
|
>
|
||||||
<Component {...this.props}/>
|
<Component {...this.props}/>
|
||||||
</ModalContext.Provider>
|
</ModalContext.Provider>
|
||||||
</Modal>
|
</PortaledModal>
|
||||||
:
|
:
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
|
@ -103,6 +103,7 @@ Route::group(['prefix' => '/servers/{server}', 'middleware' => [AuthenticateServ
|
|||||||
Route::post('/', 'Servers\BackupController@store');
|
Route::post('/', 'Servers\BackupController@store');
|
||||||
Route::get('/{backup}', 'Servers\BackupController@view');
|
Route::get('/{backup}', 'Servers\BackupController@view');
|
||||||
Route::get('/{backup}/download', 'Servers\BackupController@download');
|
Route::get('/{backup}/download', 'Servers\BackupController@download');
|
||||||
|
Route::post('/{backup}/lock', 'Servers\BackupController@toggleLock');
|
||||||
Route::post('/{backup}/restore', 'Servers\BackupController@restore');
|
Route::post('/{backup}/restore', 'Servers\BackupController@restore');
|
||||||
Route::delete('/{backup}', 'Servers\BackupController@delete');
|
Route::delete('/{backup}', 'Servers\BackupController@delete');
|
||||||
});
|
});
|
||||||
|
Loading…
Reference in New Issue
Block a user