diff --git a/app/Exceptions/Service/Backup/BackupLockedException.php b/app/Exceptions/Service/Backup/BackupLockedException.php new file mode 100644 index 000000000..3c9dbcf89 --- /dev/null +++ b/app/Exceptions/Service/Backup/BackupLockedException.php @@ -0,0 +1,16 @@ +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 diff --git a/app/Http/Requests/Api/Client/Servers/Backups/StoreBackupRequest.php b/app/Http/Requests/Api/Client/Servers/Backups/StoreBackupRequest.php index 538588ab0..5fbdaf728 100644 --- a/app/Http/Requests/Api/Client/Servers/Backups/StoreBackupRequest.php +++ b/app/Http/Requests/Api/Client/Servers/Backups/StoreBackupRequest.php @@ -19,6 +19,7 @@ class StoreBackupRequest extends ClientApiRequest { return [ 'name' => 'nullable|string|max:191', + 'is_locked' => 'nullable|boolean', 'ignored' => 'nullable|string', ]; } diff --git a/app/Models/AuditLog.php b/app/Models/AuditLog.php index 88e8c1d8d..eee50d694 100644 --- a/app/Models/AuditLog.php +++ b/app/Models/AuditLog.php @@ -7,17 +7,17 @@ use Illuminate\Http\Request; use Illuminate\Container\Container; /** - * @property int $id - * @property string $uuid - * @property bool $is_system - * @property int|null $user_id - * @property int|null $server_id - * @property string $action - * @property string|null $subaction - * @property array $device - * @property array $metadata - * @property \Carbon\CarbonImmutable $created_at - * @property \Pterodactyl\Models\User|null $user + * @property int $id + * @property string $uuid + * @property bool $is_system + * @property int|null $user_id + * @property int|null $server_id + * @property string $action + * @property string|null $subaction + * @property array $device + * @property array $metadata + * @property \Carbon\CarbonImmutable $created_at + * @property \Pterodactyl\Models\User|null $user * @property \Pterodactyl\Models\Server|null $server */ class AuditLog extends Model @@ -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'; diff --git a/app/Models/Backup.php b/app/Models/Backup.php index c2f9e5d96..3f791abf4 100644 --- a/app/Models/Backup.php +++ b/app/Models/Backup.php @@ -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', diff --git a/app/Services/Backups/DeleteBackupService.php b/app/Services/Backups/DeleteBackupService.php index 6c612e048..772392e21 100644 --- a/app/Services/Backups/DeleteBackupService.php +++ b/app/Services/Backups/DeleteBackupService.php @@ -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); diff --git a/app/Services/Backups/InitiateBackupService.php b/app/Services/Backups/InitiateBackupService.php index b55f0f8ce..347740dc1 100644 --- a/app/Services/Backups/InitiateBackupService.php +++ b/app/Services/Backups/InitiateBackupService.php @@ -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) diff --git a/app/Transformers/Api/Client/BackupTransformer.php b/app/Transformers/Api/Client/BackupTransformer.php index fe341ff16..ae6ae5213 100644 --- a/app/Transformers/Api/Client/BackupTransformer.php +++ b/app/Transformers/Api/Client/BackupTransformer.php @@ -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, diff --git a/database/migrations/2021_05_03_201016_add_support_for_locking_a_backup.php b/database/migrations/2021_05_03_201016_add_support_for_locking_a_backup.php new file mode 100644 index 000000000..28826822f --- /dev/null +++ b/database/migrations/2021_05_03_201016_add_support_for_locking_a_backup.php @@ -0,0 +1,32 @@ +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'); + }); + } +} diff --git a/resources/scripts/api/server/backups/createServerBackup.ts b/resources/scripts/api/server/backups/createServerBackup.ts index a27d5d146..3167b5b46 100644 --- a/resources/scripts/api/server/backups/createServerBackup.ts +++ b/resources/scripts/api/server/backups/createServerBackup.ts @@ -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 => { - 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 => { + const { data } = await http.post(`/api/client/servers/${uuid}/backups`, { + name: params.name, + ignored: params.ignored, + is_locked: params.isLocked, }); + + return rawDataToServerBackup(data); }; diff --git a/resources/scripts/api/server/types.d.ts b/resources/scripts/api/server/types.d.ts index dd871162c..7e3e540c0 100644 --- a/resources/scripts/api/server/types.d.ts +++ b/resources/scripts/api/server/types.d.ts @@ -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; diff --git a/resources/scripts/api/transformers.ts b/resources/scripts/api/transformers.ts index 64db56f9a..069baf126 100644 --- a/resources/scripts/api/transformers.ts +++ b/resources/scripts/api/transformers.ts @@ -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, diff --git a/resources/scripts/components/server/backups/BackupContextMenu.tsx b/resources/scripts/components/server/backups/BackupContextMenu.tsx index 06f101ba6..146ab50e3 100644 --- a/resources/scripts/components/server/backups/BackupContextMenu.tsx +++ b/resources/scripts/components/server/backups/BackupContextMenu.tsx @@ -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 ( <> - setModal('')} - checksum={backup.checksum} - /> + setModal('')} + buttonText={'Yes, unlock'} + > + Are you sure you want to unlock this backup? It will no longer be protected from automated or + accidental deletions. + { Restore - setModal('checksum')}> - - Checksum - - setModal('delete')}> - - Delete - + <> + + + {backup.isLocked ? 'Unlock' : 'Lock'} + + {!backup.isLocked && + setModal('delete')}> + + Delete + + } + diff --git a/resources/scripts/components/server/backups/BackupRow.tsx b/resources/scripts/components/server/backups/BackupRow.tsx index 6a3518537..570fd574d 100644 --- a/resources/scripts/components/server/backups/BackupRow.tsx +++ b/resources/scripts/components/server/backups/BackupRow.tsx @@ -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,7 +45,10 @@ export default ({ backup, className }: Props) => {
{backup.completedAt ? - + backup.isLocked ? + + : + : } @@ -65,7 +68,7 @@ export default ({ backup, className }: Props) => { }

- {backup.uuid} + {backup.checksum}

diff --git a/resources/scripts/components/server/backups/ChecksumModal.tsx b/resources/scripts/components/server/backups/ChecksumModal.tsx deleted file mode 100644 index a0a318d67..000000000 --- a/resources/scripts/components/server/backups/ChecksumModal.tsx +++ /dev/null @@ -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 }) => ( - -

Verify file checksum

-

- The checksum of this file is: -

-
-            {checksum}
-        
-
-); - -export default ChecksumModal; diff --git a/resources/scripts/components/server/backups/CreateBackupButton.tsx b/resources/scripts/components/server/backups/CreateBackupButton.tsx index 754bc0245..c26dd728e 100644 --- a/resources/scripts/components/server/backups/CreateBackupButton.tsx +++ b/resources/scripts/components/server/backups/CreateBackupButton.tsx @@ -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) => {

Create server backup

-
- -
-
+ +
{
-
+ +
+ +
+
+
@@ -67,9 +77,9 @@ export default () => { clearFlashes('backups:create'); }, [ visible ]); - const submit = ({ name, ignored }: Values, { setSubmitting }: FormikHelpers) => { + const submit = (values: Values, { setSubmitting }: FormikHelpers) => { 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 && setVisible(false)}/> diff --git a/resources/scripts/hoc/asModal.tsx b/resources/scripts/hoc/asModal.tsx index 1e680fe33..452aa7eb1 100644 --- a/resources/scripts/hoc/asModal.tsx +++ b/resources/scripts/hoc/asModal.tsx @@ -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

(modalProps?: ExtendedModalProps | ((props: P render () { return ( this.state.render ? - this.setState({ render: false }, () => { @@ -75,7 +75,7 @@ function asModal

(modalProps?: ExtendedModalProps | ((props: P > - + : null ); diff --git a/routes/api-client.php b/routes/api-client.php index 86dcff070..ec4988bc8 100644 --- a/routes/api-client.php +++ b/routes/api-client.php @@ -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'); });