mirror of
https://github.com/pterodactyl/panel.git
synced 2024-11-25 18:42:31 +01:00
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
|
||||
* result set.
|
||||
*
|
||||
* @throws \Spatie\Fractalistic\Exceptions\InvalidTransformation
|
||||
* @throws \Spatie\Fractalistic\Exceptions\NoTransformerSpecified
|
||||
* @throws \Illuminate\Auth\Access\AuthorizationException
|
||||
*/
|
||||
public function index(Request $request, Server $server): array
|
||||
@ -89,11 +87,18 @@ class BackupController extends ClientApiController
|
||||
{
|
||||
/** @var \Pterodactyl\Models\Backup $backup */
|
||||
$backup = $server->audit(AuditLog::SERVER__BACKUP_STARTED, function (AuditLog $model, Server $server) use ($request) {
|
||||
$backup = $this->initiateBackupService
|
||||
->setIgnoredFiles(
|
||||
explode(PHP_EOL, $request->input('ignored') ?? '')
|
||||
)
|
||||
->handle($server, $request->input('name'));
|
||||
$action = $this->initiateBackupService
|
||||
->setIgnoredFiles(explode(PHP_EOL, $request->input('ignored') ?? ''));
|
||||
|
||||
// Only set the lock status if the user even has permission to delete backups,
|
||||
// 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];
|
||||
|
||||
@ -105,11 +110,35 @@ class BackupController extends ClientApiController
|
||||
->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.
|
||||
*
|
||||
* @throws \Spatie\Fractalistic\Exceptions\InvalidTransformation
|
||||
* @throws \Spatie\Fractalistic\Exceptions\NoTransformerSpecified
|
||||
* @throws \Illuminate\Auth\Access\AuthorizationException
|
||||
*/
|
||||
public function view(Request $request, Server $server, Backup $backup): array
|
||||
|
@ -19,6 +19,7 @@ class StoreBackupRequest extends ClientApiRequest
|
||||
{
|
||||
return [
|
||||
'name' => 'nullable|string|max:191',
|
||||
'is_locked' => 'nullable|boolean',
|
||||
'ignored' => 'nullable|string',
|
||||
];
|
||||
}
|
||||
|
@ -36,6 +36,8 @@ class AuditLog extends Model
|
||||
public const SERVER__BACKUP_COMPELTED = 'server:backup.completed';
|
||||
public const SERVER__BACKUP_DELETED = 'server:backup.deleted';
|
||||
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_COMPLETED = 'server:backup.restore.completed';
|
||||
public const SERVER__BACKUP_RESTORE_FAILED = 'server:backup.restore.failed';
|
||||
|
@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
* @property int $server_id
|
||||
* @property string $uuid
|
||||
* @property bool $is_successful
|
||||
* @property bool $is_locked
|
||||
* @property string $name
|
||||
* @property string[] $ignored_files
|
||||
* @property string $disk
|
||||
@ -46,6 +47,7 @@ class Backup extends Model
|
||||
protected $casts = [
|
||||
'id' => 'int',
|
||||
'is_successful' => 'bool',
|
||||
'is_locked' => 'bool',
|
||||
'ignored_files' => 'array',
|
||||
'bytes' => 'int',
|
||||
];
|
||||
@ -62,6 +64,7 @@ class Backup extends Model
|
||||
*/
|
||||
protected $attributes = [
|
||||
'is_successful' => true,
|
||||
'is_locked' => false,
|
||||
'checksum' => null,
|
||||
'bytes' => 0,
|
||||
'upload_id' => null,
|
||||
@ -79,6 +82,7 @@ class Backup extends Model
|
||||
'server_id' => 'bail|required|numeric|exists:servers,id',
|
||||
'uuid' => 'required|uuid',
|
||||
'is_successful' => 'boolean',
|
||||
'is_locked' => 'boolean',
|
||||
'name' => 'required|string',
|
||||
'ignored_files' => 'array',
|
||||
'disk' => 'required|string',
|
||||
|
@ -9,6 +9,7 @@ use Illuminate\Database\ConnectionInterface;
|
||||
use Pterodactyl\Extensions\Backups\BackupManager;
|
||||
use Pterodactyl\Repositories\Eloquent\BackupRepository;
|
||||
use Pterodactyl\Repositories\Wings\DaemonBackupRepository;
|
||||
use Pterodactyl\Exceptions\Service\Backup\BackupLockedException;
|
||||
use Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException;
|
||||
|
||||
class DeleteBackupService
|
||||
@ -55,6 +56,10 @@ class DeleteBackupService
|
||||
*/
|
||||
public function handle(Backup $backup)
|
||||
{
|
||||
if ($backup->is_locked) {
|
||||
throw new BackupLockedException();
|
||||
}
|
||||
|
||||
if ($backup->disk === Backup::ADAPTER_AWS_S3) {
|
||||
$this->deleteFromS3($backup);
|
||||
|
||||
|
@ -21,6 +21,11 @@ class InitiateBackupService
|
||||
*/
|
||||
private $ignoredFiles;
|
||||
|
||||
/**
|
||||
* @var bool
|
||||
*/
|
||||
private $isLocked = false;
|
||||
|
||||
/**
|
||||
* @var \Pterodactyl\Repositories\Eloquent\BackupRepository
|
||||
*/
|
||||
@ -49,7 +54,11 @@ class InitiateBackupService
|
||||
/**
|
||||
* 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\Extensions\Backups\BackupManager $backupManager
|
||||
*/
|
||||
public function __construct(
|
||||
BackupRepository $repository,
|
||||
@ -65,6 +74,19 @@ class InitiateBackupService
|
||||
$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.
|
||||
*
|
||||
@ -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 \Pterodactyl\Exceptions\Service\Backup\TooManyBackupsException
|
||||
@ -104,23 +126,30 @@ class InitiateBackupService
|
||||
if ($period > 0) {
|
||||
$previous = $this->repository->getBackupsGeneratedDuringTimespan($server->id, $period);
|
||||
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
|
||||
if (!$server->backup_limit || $server->backups()->where('is_successful', true)->count() >= $server->backup_limit) {
|
||||
// Check if the server has reached or exceeded it's 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.
|
||||
if (!$override || $server->backup_limit <= 0) {
|
||||
throw new TooManyBackupsException($server->backup_limit);
|
||||
}
|
||||
|
||||
// Get the oldest backup the server has.
|
||||
/** @var \Pterodactyl\Models\Backup $oldestBackup */
|
||||
$oldestBackup = $server->backups()->where('is_successful', true)->orderBy('created_at')->first();
|
||||
// Get the oldest backup the server has that is not "locked" (indicating a backup that should
|
||||
// never be automatically purged). If we find a backup we will delete it and then continue with
|
||||
// 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($oldestBackup);
|
||||
$this->deleteBackupService->handle($oldest);
|
||||
}
|
||||
|
||||
return $this->connection->transaction(function () use ($server, $name) {
|
||||
@ -131,6 +160,7 @@ class InitiateBackupService
|
||||
'name' => trim($name) ?: sprintf('Backup at %s', CarbonImmutable::now()->toDateTimeString()),
|
||||
'ignored_files' => array_values($this->ignoredFiles ?? []),
|
||||
'disk' => $this->backupManager->getDefaultAdapter(),
|
||||
'is_locked' => $this->isLocked,
|
||||
], true, true);
|
||||
|
||||
$this->daemonBackupRepository->setServer($server)
|
||||
|
@ -19,6 +19,7 @@ class BackupTransformer extends BaseClientTransformer
|
||||
return [
|
||||
'uuid' => $backup->uuid,
|
||||
'is_successful' => $backup->is_successful,
|
||||
'is_locked' => $backup->is_locked,
|
||||
'name' => $backup->name,
|
||||
'ignored_files' => $backup->ignored_files,
|
||||
'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 { rawDataToServerBackup } from '@/api/transformers';
|
||||
|
||||
export default (uuid: string, name?: string, ignored?: string): Promise<ServerBackup> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
http.post(`/api/client/servers/${uuid}/backups`, {
|
||||
name, ignored,
|
||||
})
|
||||
.then(({ data }) => resolve(rawDataToServerBackup(data)))
|
||||
.catch(reject);
|
||||
interface RequestParameters {
|
||||
name?: string;
|
||||
ignored?: string;
|
||||
isLocked: boolean;
|
||||
}
|
||||
|
||||
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 {
|
||||
uuid: string;
|
||||
isSuccessful: boolean;
|
||||
isLocked: boolean;
|
||||
name: string;
|
||||
ignoredFiles: string;
|
||||
checksum: string;
|
||||
|
@ -58,6 +58,7 @@ export const rawDataToFileObject = (data: FractalResponseData): FileObject => ({
|
||||
export const rawDataToServerBackup = ({ attributes }: FractalResponseData): ServerBackup => ({
|
||||
uuid: attributes.uuid,
|
||||
isSuccessful: attributes.is_successful,
|
||||
isLocked: attributes.is_locked,
|
||||
name: attributes.name,
|
||||
ignoredFiles: attributes.ignored_files,
|
||||
checksum: attributes.checksum,
|
||||
|
@ -1,10 +1,16 @@
|
||||
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 DropdownMenu, { DropdownButtonRow } from '@/components/elements/DropdownMenu';
|
||||
import getBackupDownloadUrl from '@/api/server/backups/getBackupDownloadUrl';
|
||||
import useFlash from '@/plugins/useFlash';
|
||||
import ChecksumModal from '@/components/server/backups/ChecksumModal';
|
||||
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
|
||||
import deleteBackup from '@/api/server/backups/deleteBackup';
|
||||
import ConfirmationModal from '@/components/elements/ConfirmationModal';
|
||||
@ -15,6 +21,7 @@ import { ServerBackup } from '@/api/server/types';
|
||||
import { ServerContext } from '@/state/server';
|
||||
import Input from '@/components/elements/Input';
|
||||
import { restoreServerBackup } from '@/api/server/backups';
|
||||
import http, { httpErrorToHuman } from '@/api/http';
|
||||
|
||||
interface Props {
|
||||
backup: ServerBackup;
|
||||
@ -76,14 +83,35 @@ export default ({ backup }: Props) => {
|
||||
.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 (
|
||||
<>
|
||||
<ChecksumModal
|
||||
appear
|
||||
visible={modal === 'checksum'}
|
||||
onDismissed={() => setModal('')}
|
||||
checksum={backup.checksum}
|
||||
/>
|
||||
<ConfirmationModal
|
||||
visible={modal === 'unlock'}
|
||||
title={'Unlock this backup?'}
|
||||
onConfirmed={onLockToggle}
|
||||
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
|
||||
visible={modal === 'restore'}
|
||||
title={'Restore this backup?'}
|
||||
@ -151,15 +179,23 @@ export default ({ backup }: Props) => {
|
||||
<span css={tw`ml-2`}>Restore</span>
|
||||
</DropdownButtonRow>
|
||||
</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'}>
|
||||
<>
|
||||
<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')}>
|
||||
<FontAwesomeIcon fixedWidth icon={faTrashAlt} css={tw`text-xs`}/>
|
||||
<span css={tw`ml-2`}>Delete</span>
|
||||
</DropdownButtonRow>
|
||||
}
|
||||
</>
|
||||
</Can>
|
||||
</div>
|
||||
</DropdownMenu>
|
||||
|
@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
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 Spinner from '@/components/elements/Spinner';
|
||||
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`mr-4`}>
|
||||
{backup.completedAt ?
|
||||
backup.isLocked ?
|
||||
<FontAwesomeIcon icon={faLock} css={tw`text-yellow-500`}/>
|
||||
:
|
||||
<FontAwesomeIcon icon={faArchive} css={tw`text-neutral-300`}/>
|
||||
:
|
||||
<Spinner size={'small'}/>
|
||||
@ -65,7 +68,7 @@ export default ({ backup, className }: Props) => {
|
||||
}
|
||||
</div>
|
||||
<p css={tw`mt-1 md:mt-0 text-xs text-neutral-400 font-mono truncate`}>
|
||||
{backup.uuid}
|
||||
{backup.checksum}
|
||||
</p>
|
||||
</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 Modal, { RequiredModalProps } from '@/components/elements/Modal';
|
||||
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 FormikFieldWrapper from '@/components/elements/FormikFieldWrapper';
|
||||
import useFlash from '@/plugins/useFlash';
|
||||
@ -12,10 +12,13 @@ import tw from 'twin.macro';
|
||||
import { Textarea } from '@/components/elements/Input';
|
||||
import getServerBackups from '@/api/swr/getServerBackups';
|
||||
import { ServerContext } from '@/state/server';
|
||||
import FormikSwitch from '@/components/elements/FormikSwitch';
|
||||
import Can from '@/components/elements/Can';
|
||||
|
||||
interface Values {
|
||||
name: string;
|
||||
ignored: string;
|
||||
isLocked: boolean;
|
||||
}
|
||||
|
||||
const ModalContent = ({ ...props }: RequiredModalProps) => {
|
||||
@ -26,14 +29,12 @@ const ModalContent = ({ ...props }: RequiredModalProps) => {
|
||||
<Form>
|
||||
<FlashMessageRender byKey={'backups:create'} css={tw`mb-4`}/>
|
||||
<h2 css={tw`text-2xl mb-6`}>Create server backup</h2>
|
||||
<div css={tw`mb-6`}>
|
||||
<Field
|
||||
name={'name'}
|
||||
label={'Backup name'}
|
||||
description={'If provided, the name that should be used to reference this backup.'}
|
||||
/>
|
||||
</div>
|
||||
<div css={tw`mb-6`}>
|
||||
<div css={tw`mt-6`}>
|
||||
<FormikFieldWrapper
|
||||
name={'ignored'}
|
||||
label={'Ignored Files & Directories'}
|
||||
@ -47,7 +48,16 @@ const ModalContent = ({ ...props }: RequiredModalProps) => {
|
||||
<FormikField as={Textarea} name={'ignored'} rows={6}/>
|
||||
</FormikFieldWrapper>
|
||||
</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}>
|
||||
Start backup
|
||||
</Button>
|
||||
@ -67,9 +77,9 @@ export default () => {
|
||||
clearFlashes('backups:create');
|
||||
}, [ visible ]);
|
||||
|
||||
const submit = ({ name, ignored }: Values, { setSubmitting }: FormikHelpers<Values>) => {
|
||||
const submit = (values: Values, { setSubmitting }: FormikHelpers<Values>) => {
|
||||
clearFlashes('backups:create');
|
||||
createServerBackup(uuid, name, ignored)
|
||||
createServerBackup(uuid, values)
|
||||
.then(backup => {
|
||||
mutate(data => ({ ...data, items: data.items.concat(backup) }), false);
|
||||
setVisible(false);
|
||||
@ -85,10 +95,11 @@ export default () => {
|
||||
{visible &&
|
||||
<Formik
|
||||
onSubmit={submit}
|
||||
initialValues={{ name: '', ignored: '' }}
|
||||
initialValues={{ name: '', ignored: '', isLocked: false }}
|
||||
validationSchema={object().shape({
|
||||
name: string().max(191),
|
||||
ignored: string(),
|
||||
isLocked: boolean(),
|
||||
})}
|
||||
>
|
||||
<ModalContent appear visible={visible} onDismissed={() => setVisible(false)}/>
|
||||
|
@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import Modal, { ModalProps } from '@/components/elements/Modal';
|
||||
import PortaledModal, { ModalProps } from '@/components/elements/Modal';
|
||||
import ModalContext from '@/context/ModalContext';
|
||||
|
||||
export interface AsModalProps {
|
||||
@ -57,7 +57,7 @@ function asModal<P extends object> (modalProps?: ExtendedModalProps | ((props: P
|
||||
render () {
|
||||
return (
|
||||
this.state.render ?
|
||||
<Modal
|
||||
<PortaledModal
|
||||
appear
|
||||
visible={this.state.visible}
|
||||
onDismissed={() => this.setState({ render: false }, () => {
|
||||
@ -75,7 +75,7 @@ function asModal<P extends object> (modalProps?: ExtendedModalProps | ((props: P
|
||||
>
|
||||
<Component {...this.props}/>
|
||||
</ModalContext.Provider>
|
||||
</Modal>
|
||||
</PortaledModal>
|
||||
:
|
||||
null
|
||||
);
|
||||
|
@ -103,6 +103,7 @@ Route::group(['prefix' => '/servers/{server}', 'middleware' => [AuthenticateServ
|
||||
Route::post('/', 'Servers\BackupController@store');
|
||||
Route::get('/{backup}', 'Servers\BackupController@view');
|
||||
Route::get('/{backup}/download', 'Servers\BackupController@download');
|
||||
Route::post('/{backup}/lock', 'Servers\BackupController@toggleLock');
|
||||
Route::post('/{backup}/restore', 'Servers\BackupController@restore');
|
||||
Route::delete('/{backup}', 'Servers\BackupController@delete');
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user