Skip to content

Commit 5d5e4ca

Browse files
committed
Add support for locking backups to prevent any accidental deletions
1 parent 5f48712 commit 5d5e4ca

File tree

18 files changed

+250
-88
lines changed

18 files changed

+250
-88
lines changed
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
namespace Pterodactyl\Exceptions\Service\Backup;
4+
5+
use Pterodactyl\Exceptions\DisplayException;
6+
7+
class BackupLockedException extends DisplayException
8+
{
9+
/**
10+
* TooManyBackupsException constructor.
11+
*/
12+
public function __construct()
13+
{
14+
parent::__construct('Cannot delete a backup that is marked as locked.');
15+
}
16+
}

app/Http/Controllers/Api/Client/Servers/BackupController.php

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,6 @@ public function __construct(
6161
* Returns all of the backups for a given server instance in a paginated
6262
* result set.
6363
*
64-
* @throws \Spatie\Fractalistic\Exceptions\InvalidTransformation
65-
* @throws \Spatie\Fractalistic\Exceptions\NoTransformerSpecified
6664
* @throws \Illuminate\Auth\Access\AuthorizationException
6765
*/
6866
public function index(Request $request, Server $server): array
@@ -89,11 +87,18 @@ public function store(StoreBackupRequest $request, Server $server): array
8987
{
9088
/** @var \Pterodactyl\Models\Backup $backup */
9189
$backup = $server->audit(AuditLog::SERVER__BACKUP_STARTED, function (AuditLog $model, Server $server) use ($request) {
92-
$backup = $this->initiateBackupService
93-
->setIgnoredFiles(
94-
explode(PHP_EOL, $request->input('ignored') ?? '')
95-
)
96-
->handle($server, $request->input('name'));
90+
$action = $this->initiateBackupService
91+
->setIgnoredFiles(explode(PHP_EOL, $request->input('ignored') ?? ''));
92+
93+
// Only set the lock status if the user even has permission to delete backups,
94+
// otherwise ignore this status. This gets a little funky since it isn't clear
95+
// how best to allow a user to create a backup that is locked without also preventing
96+
// them from just filling up a server with backups that can never be deleted?
97+
if ($request->user()->can(Permission::ACTION_BACKUP_DELETE, $server)) {
98+
$action->setIsLocked((bool) $request->input('is_locked'));
99+
}
100+
101+
$backup = $action->handle($server, $request->input('name'));
97102

98103
$model->metadata = ['backup_uuid' => $backup->uuid];
99104

@@ -105,11 +110,35 @@ public function store(StoreBackupRequest $request, Server $server): array
105110
->toArray();
106111
}
107112

113+
/**
114+
* Toggles the lock status of a given backup for a server.
115+
*
116+
* @throws \Throwable
117+
* @throws \Illuminate\Auth\Access\AuthorizationException
118+
*/
119+
public function toggleLock(Request $request, Server $server, Backup $backup): array
120+
{
121+
if (!$request->user()->can(Permission::ACTION_BACKUP_DELETE, $server)) {
122+
throw new AuthorizationException();
123+
}
124+
125+
$action = $backup->is_locked ? AuditLog::SERVER__BACKUP_UNLOCKED : AuditLog::SERVER__BACKUP_LOCKED;
126+
$server->audit($action, function (AuditLog $audit) use ($backup) {
127+
$audit->metadata = ['backup_uuid' => $backup->uuid];
128+
129+
$backup->update(['is_locked' => !$backup->is_locked]);
130+
});
131+
132+
$backup->refresh();
133+
134+
return $this->fractal->item($backup)
135+
->transformWith($this->getTransformer(BackupTransformer::class))
136+
->toArray();
137+
}
138+
108139
/**
109140
* Returns information about a single backup.
110141
*
111-
* @throws \Spatie\Fractalistic\Exceptions\InvalidTransformation
112-
* @throws \Spatie\Fractalistic\Exceptions\NoTransformerSpecified
113142
* @throws \Illuminate\Auth\Access\AuthorizationException
114143
*/
115144
public function view(Request $request, Server $server, Backup $backup): array

app/Http/Requests/Api/Client/Servers/Backups/StoreBackupRequest.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ public function rules(): array
1919
{
2020
return [
2121
'name' => 'nullable|string|max:191',
22+
'is_locked' => 'nullable|boolean',
2223
'ignored' => 'nullable|string',
2324
];
2425
}

app/Models/AuditLog.php

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,17 @@
77
use Illuminate\Container\Container;
88

99
/**
10-
* @property int $id
11-
* @property string $uuid
12-
* @property bool $is_system
13-
* @property int|null $user_id
14-
* @property int|null $server_id
15-
* @property string $action
16-
* @property string|null $subaction
17-
* @property array $device
18-
* @property array $metadata
19-
* @property \Carbon\CarbonImmutable $created_at
20-
* @property \Pterodactyl\Models\User|null $user
10+
* @property int $id
11+
* @property string $uuid
12+
* @property bool $is_system
13+
* @property int|null $user_id
14+
* @property int|null $server_id
15+
* @property string $action
16+
* @property string|null $subaction
17+
* @property array $device
18+
* @property array $metadata
19+
* @property \Carbon\CarbonImmutable $created_at
20+
* @property \Pterodactyl\Models\User|null $user
2121
* @property \Pterodactyl\Models\Server|null $server
2222
*/
2323
class AuditLog extends Model
@@ -36,6 +36,8 @@ class AuditLog extends Model
3636
public const SERVER__BACKUP_COMPELTED = 'server:backup.completed';
3737
public const SERVER__BACKUP_DELETED = 'server:backup.deleted';
3838
public const SERVER__BACKUP_DOWNLOADED = 'server:backup.downloaded';
39+
public const SERVER__BACKUP_LOCKED = 'server:backup.locked';
40+
public const SERVER__BACKUP_UNLOCKED = 'server:backup.unlocked';
3941
public const SERVER__BACKUP_RESTORE_STARTED = 'server:backup.restore.started';
4042
public const SERVER__BACKUP_RESTORE_COMPLETED = 'server:backup.restore.completed';
4143
public const SERVER__BACKUP_RESTORE_FAILED = 'server:backup.restore.failed';

app/Models/Backup.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
* @property int $server_id
1010
* @property string $uuid
1111
* @property bool $is_successful
12+
* @property bool $is_locked
1213
* @property string $name
1314
* @property string[] $ignored_files
1415
* @property string $disk
@@ -46,6 +47,7 @@ class Backup extends Model
4647
protected $casts = [
4748
'id' => 'int',
4849
'is_successful' => 'bool',
50+
'is_locked' => 'bool',
4951
'ignored_files' => 'array',
5052
'bytes' => 'int',
5153
];
@@ -62,6 +64,7 @@ class Backup extends Model
6264
*/
6365
protected $attributes = [
6466
'is_successful' => true,
67+
'is_locked' => false,
6568
'checksum' => null,
6669
'bytes' => 0,
6770
'upload_id' => null,
@@ -79,6 +82,7 @@ class Backup extends Model
7982
'server_id' => 'bail|required|numeric|exists:servers,id',
8083
'uuid' => 'required|uuid',
8184
'is_successful' => 'boolean',
85+
'is_locked' => 'boolean',
8286
'name' => 'required|string',
8387
'ignored_files' => 'array',
8488
'disk' => 'required|string',

app/Services/Backups/DeleteBackupService.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
use Pterodactyl\Extensions\Backups\BackupManager;
1010
use Pterodactyl\Repositories\Eloquent\BackupRepository;
1111
use Pterodactyl\Repositories\Wings\DaemonBackupRepository;
12+
use Pterodactyl\Exceptions\Service\Backup\BackupLockedException;
1213
use Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException;
1314

1415
class DeleteBackupService
@@ -55,6 +56,10 @@ public function __construct(
5556
*/
5657
public function handle(Backup $backup)
5758
{
59+
if ($backup->is_locked) {
60+
throw new BackupLockedException();
61+
}
62+
5863
if ($backup->disk === Backup::ADAPTER_AWS_S3) {
5964
$this->deleteFromS3($backup);
6065

app/Services/Backups/InitiateBackupService.php

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ class InitiateBackupService
2121
*/
2222
private $ignoredFiles;
2323

24+
/**
25+
* @var bool
26+
*/
27+
private $isLocked = false;
28+
2429
/**
2530
* @var \Pterodactyl\Repositories\Eloquent\BackupRepository
2631
*/
@@ -49,7 +54,11 @@ class InitiateBackupService
4954
/**
5055
* InitiateBackupService constructor.
5156
*
57+
* @param \Pterodactyl\Repositories\Eloquent\BackupRepository $repository
58+
* @param \Illuminate\Database\ConnectionInterface $connection
59+
* @param \Pterodactyl\Repositories\Wings\DaemonBackupRepository $daemonBackupRepository
5260
* @param \Pterodactyl\Services\Backups\DeleteBackupService $deleteBackupService
61+
* @param \Pterodactyl\Extensions\Backups\BackupManager $backupManager
5362
*/
5463
public function __construct(
5564
BackupRepository $repository,
@@ -65,6 +74,19 @@ public function __construct(
6574
$this->deleteBackupService = $deleteBackupService;
6675
}
6776

77+
/**
78+
* Set if the backup should be locked once it is created which will prevent
79+
* its deletion by users or automated system processes.
80+
*
81+
* @return $this
82+
*/
83+
public function setIsLocked(bool $isLocked): self
84+
{
85+
$this->isLocked = $isLocked;
86+
87+
return $this;
88+
}
89+
6890
/**
6991
* Sets the files to be ignored by this backup.
7092
*
@@ -91,7 +113,7 @@ public function setIgnoredFiles(?array $ignored)
91113
}
92114

93115
/**
94-
* Initiates the backup process for a server on the daemon.
116+
* Initiates the backup process for a server on Wings.
95117
*
96118
* @throws \Throwable
97119
* @throws \Pterodactyl\Exceptions\Service\Backup\TooManyBackupsException
@@ -104,23 +126,30 @@ public function handle(Server $server, string $name = null, bool $override = fal
104126
if ($period > 0) {
105127
$previous = $this->repository->getBackupsGeneratedDuringTimespan($server->id, $period);
106128
if ($previous->count() >= $limit) {
107-
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));
129+
$message = sprintf('Only %d backups may be generated within a %d second span of time.', $limit, $period);
130+
131+
throw new TooManyRequestsHttpException(CarbonImmutable::now()->diffInSeconds($previous->last()->created_at->addSeconds($period)), $message);
108132
}
109133
}
110134

111-
// Check if the server has reached or exceeded it's backup limit
112-
if (!$server->backup_limit || $server->backups()->where('is_successful', true)->count() >= $server->backup_limit) {
135+
// Check if the server has reached or exceeded it's backup limit.
136+
$successful = $server->backups()->where('is_successful', true);
137+
if (!$server->backup_limit || $successful->count() >= $server->backup_limit) {
113138
// Do not allow the user to continue if this server is already at its limit and can't override.
114139
if (!$override || $server->backup_limit <= 0) {
115140
throw new TooManyBackupsException($server->backup_limit);
116141
}
117142

118-
// Get the oldest backup the server has.
119-
/** @var \Pterodactyl\Models\Backup $oldestBackup */
120-
$oldestBackup = $server->backups()->where('is_successful', true)->orderBy('created_at')->first();
143+
// Get the oldest backup the server has that is not "locked" (indicating a backup that should
144+
// never be automatically purged). If we find a backup we will delete it and then continue with
145+
// this process. If no backup is found that can be used an exception is thrown.
146+
/** @var \Pterodactyl\Models\Backup $oldest */
147+
$oldest = $successful->where('is_locked', false)->orderBy('created_at')->first();
148+
if (!$oldest) {
149+
throw new TooManyBackupsException($server->backup_limit);
150+
}
121151

122-
// Delete the oldest backup.
123-
$this->deleteBackupService->handle($oldestBackup);
152+
$this->deleteBackupService->handle($oldest);
124153
}
125154

126155
return $this->connection->transaction(function () use ($server, $name) {
@@ -131,6 +160,7 @@ public function handle(Server $server, string $name = null, bool $override = fal
131160
'name' => trim($name) ?: sprintf('Backup at %s', CarbonImmutable::now()->toDateTimeString()),
132161
'ignored_files' => array_values($this->ignoredFiles ?? []),
133162
'disk' => $this->backupManager->getDefaultAdapter(),
163+
'is_locked' => $this->isLocked,
134164
], true, true);
135165

136166
$this->daemonBackupRepository->setServer($server)

app/Transformers/Api/Client/BackupTransformer.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ public function transform(Backup $backup)
1919
return [
2020
'uuid' => $backup->uuid,
2121
'is_successful' => $backup->is_successful,
22+
'is_locked' => $backup->is_locked,
2223
'name' => $backup->name,
2324
'ignored_files' => $backup->ignored_files,
2425
'checksum' => $backup->checksum,
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
3+
use Illuminate\Database\Migrations\Migration;
4+
use Illuminate\Database\Schema\Blueprint;
5+
use Illuminate\Support\Facades\Schema;
6+
7+
class AddSupportForLockingABackup extends Migration
8+
{
9+
/**
10+
* Run the migrations.
11+
*
12+
* @return void
13+
*/
14+
public function up()
15+
{
16+
Schema::table('backups', function (Blueprint $table) {
17+
$table->unsignedTinyInteger('is_locked')->after('is_successful')->default(0);
18+
});
19+
}
20+
21+
/**
22+
* Reverse the migrations.
23+
*
24+
* @return void
25+
*/
26+
public function down()
27+
{
28+
Schema::table('backups', function (Blueprint $table) {
29+
$table->dropColumn('is_locked');
30+
});
31+
}
32+
}

resources/scripts/api/server/backups/createServerBackup.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,18 @@ import http from '@/api/http';
22
import { ServerBackup } from '@/api/server/types';
33
import { rawDataToServerBackup } from '@/api/transformers';
44

5-
export default (uuid: string, name?: string, ignored?: string): Promise<ServerBackup> => {
6-
return new Promise((resolve, reject) => {
7-
http.post(`/api/client/servers/${uuid}/backups`, {
8-
name, ignored,
9-
})
10-
.then(({ data }) => resolve(rawDataToServerBackup(data)))
11-
.catch(reject);
5+
interface RequestParameters {
6+
name?: string;
7+
ignored?: string;
8+
isLocked: boolean;
9+
}
10+
11+
export default async (uuid: string, params: RequestParameters): Promise<ServerBackup> => {
12+
const { data } = await http.post(`/api/client/servers/${uuid}/backups`, {
13+
name: params.name,
14+
ignored: params.ignored,
15+
is_locked: params.isLocked,
1216
});
17+
18+
return rawDataToServerBackup(data);
1319
};

0 commit comments

Comments
 (0)