Add support for locking backups to prevent any accidental deletions

This commit is contained in:
Dane Everitt 2021-05-03 21:26:09 -07:00
parent 5f48712c28
commit 5d5e4ca7b1
No known key found for this signature in database
GPG Key ID: EEA66103B3D71F53
18 changed files with 250 additions and 88 deletions

View 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.');
}
}

View File

@ -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

View File

@ -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',
]; ];
} }

View File

@ -7,17 +7,17 @@ use Illuminate\Http\Request;
use Illuminate\Container\Container; use Illuminate\Container\Container;
/** /**
* @property int $id * @property int $id
* @property string $uuid * @property string $uuid
* @property bool $is_system * @property bool $is_system
* @property int|null $user_id * @property int|null $user_id
* @property int|null $server_id * @property int|null $server_id
* @property string $action * @property string $action
* @property string|null $subaction * @property string|null $subaction
* @property array $device * @property array $device
* @property array $metadata * @property array $metadata
* @property \Carbon\CarbonImmutable $created_at * @property \Carbon\CarbonImmutable $created_at
* @property \Pterodactyl\Models\User|null $user * @property \Pterodactyl\Models\User|null $user
* @property \Pterodactyl\Models\Server|null $server * @property \Pterodactyl\Models\Server|null $server
*/ */
class AuditLog extends Model class AuditLog extends Model
@ -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';

View File

@ -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',

View File

@ -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);

View File

@ -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)

View File

@ -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,

View File

@ -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');
});
}
}

View File

@ -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);
}; };

View File

@ -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;

View File

@ -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,

View File

@ -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 danger onClick={() => setModal('delete')}> <>
<FontAwesomeIcon fixedWidth icon={faTrashAlt} css={tw`text-xs`}/> <DropdownButtonRow onClick={onLockToggle}>
<span css={tw`ml-2`}>Delete</span> <FontAwesomeIcon
</DropdownButtonRow> fixedWidth
icon={backup.isLocked ? faUnlock : faLock}
css={tw`text-xs mr-2`}
/>
{backup.isLocked ? 'Unlock' : 'Lock'}
</DropdownButtonRow>
{!backup.isLocked &&
<DropdownButtonRow danger onClick={() => setModal('delete')}>
<FontAwesomeIcon fixedWidth icon={faTrashAlt} css={tw`text-xs`}/>
<span css={tw`ml-2`}>Delete</span>
</DropdownButtonRow>
}
</>
</Can> </Can>
</div> </div>
</DropdownMenu> </DropdownMenu>

View File

@ -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,7 +45,10 @@ 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 ?
<FontAwesomeIcon icon={faArchive} css={tw`text-neutral-300`}/> backup.isLocked ?
<FontAwesomeIcon icon={faLock} css={tw`text-yellow-500`}/>
:
<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>

View File

@ -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;

View File

@ -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 css={tw`mt-6`}>
</div>
<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)}/>

View File

@ -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
); );

View File

@ -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');
}); });