diff --git a/app/Http/Controllers/Api/Client/Servers/BackupController.php b/app/Http/Controllers/Api/Client/Servers/BackupController.php new file mode 100644 index 000000000..c0d598da4 --- /dev/null +++ b/app/Http/Controllers/Api/Client/Servers/BackupController.php @@ -0,0 +1,54 @@ +fractal->collection($server->backups()->paginate(20)) + ->transformWith($this->getTransformer(BackupTransformer::class)) + ->toArray(); + } + + /** + * Starts the backup process for a server. + * + * @param \Pterodactyl\Http\Requests\Api\Client\Servers\Backups\StoreBackupRequest $request + * @param \Pterodactyl\Models\Server $server + */ + public function store(StoreBackupRequest $request, Server $server) + { + } + + public function view() + { + } + + public function update() + { + } + + public function delete() + { + } +} diff --git a/app/Http/Requests/Api/Client/Servers/Backups/GetBackupsRequest.php b/app/Http/Requests/Api/Client/Servers/Backups/GetBackupsRequest.php new file mode 100644 index 000000000..f938906d1 --- /dev/null +++ b/app/Http/Requests/Api/Client/Servers/Backups/GetBackupsRequest.php @@ -0,0 +1,17 @@ + 'nullable|string|max:255', + 'ignore' => 'nullable|string', + ]; + } +} diff --git a/app/Models/Backup.php b/app/Models/Backup.php index 663015831..3384d98ec 100644 --- a/app/Models/Backup.php +++ b/app/Models/Backup.php @@ -6,9 +6,10 @@ use Illuminate\Database\Eloquent\SoftDeletes; /** * @property int $id + * @property int $server_id * @property int $uuid * @property string $name - * @property string $contents + * @property string $ignore * @property string $disk * @property string|null $sha256_hash * @property int $bytes @@ -16,11 +17,15 @@ use Illuminate\Database\Eloquent\SoftDeletes; * @property \Carbon\CarbonImmutable $created_at * @property \Carbon\CarbonImmutable $updated_at * @property \Carbon\CarbonImmutable|null $deleted_at + * + * @property \Pterodactyl\Models\Server $server */ class Backup extends Model { use SoftDeletes; + const RESOURCE_NAME = 'backup'; + /** * @var string */ @@ -56,4 +61,12 @@ class Backup extends Model { return $this->asImmutableDateTime($value); } + + /** + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function server() + { + return $this->belongsTo(Server::class); + } } diff --git a/app/Models/Permission.php b/app/Models/Permission.php index d91e87da4..15d70c4ee 100644 --- a/app/Models/Permission.php +++ b/app/Models/Permission.php @@ -37,6 +37,12 @@ class Permission extends Model const ACTION_USER_UPDATE = 'user.update'; const ACTION_USER_DELETE = 'user.delete'; + const ACTION_BACKUP_READ = 'backup.read'; + const ACTION_BACKUP_CREATE = 'backup.create'; + const ACTION_BACKUP_UPDATE = 'backup.update'; + const ACTION_BACKUP_DELETE = 'backup.delete'; + const ACTION_BACKUP_DOWNLOAD = 'backup.download'; + const ACTION_ALLOCATION_READ = 'allocation.read'; const ACTION_ALLOCIATION_UPDATE = 'allocation.update'; @@ -135,6 +141,17 @@ class Permission extends Model ], ], + 'backup' => [ + 'description' => 'Permissions that control a user\'s ability to generate and manage server backups.', + 'keys' => [ + 'create' => 'Allows a user to create new backups for this server.', + 'read' => 'Allows a user to view all backups that exist for this server.', + 'update' => '', + 'delete' => 'Allows a user to remove backups from the system.', + 'download' => 'Allows a user to download backups.', + ], + ], + // Controls permissions for editing or viewing a server's allocations. 'allocation' => [ 'description' => 'Permissions that control a user\'s ability to modify the port allocations for this server.', diff --git a/app/Models/Server.php b/app/Models/Server.php index 2629449a2..9f5495028 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -51,6 +51,7 @@ use Znck\Eloquent\Traits\BelongsToThrough; * @property \Pterodactyl\Models\Location $location * @property \Pterodactyl\Models\DaemonKey $key * @property \Pterodactyl\Models\DaemonKey[]|\Illuminate\Database\Eloquent\Collection $keys + * @property \Pterodactyl\Models\Backup[]|\Illuminate\Database\Eloquent\Collection $backups */ class Server extends Model { @@ -339,4 +340,12 @@ class Server extends Model { return $this->hasMany(DaemonKey::class); } + + /** + * @return \Illuminate\Database\Eloquent\Relations\HasMany + */ + public function backups() + { + return $this->hasMany(Backup::class); + } } diff --git a/app/Transformers/Api/Client/BackupTransformer.php b/app/Transformers/Api/Client/BackupTransformer.php new file mode 100644 index 000000000..2312b68d3 --- /dev/null +++ b/app/Transformers/Api/Client/BackupTransformer.php @@ -0,0 +1,33 @@ + $backup->uuid, + 'name' => $backup->name, + 'ignore' => $backup->ignore, + 'sha256_hash' => $backup->sha256_hash, + 'bytes' => $backup->bytes, + 'created_at' => $backup->created_at->toIso8601String(), + 'completed_at' => $backup->completed_at->toIso8601String(), + ]; + } +} diff --git a/database/migrations/2020_04_03_230614_create_backups_table.php b/database/migrations/2020_04_03_230614_create_backups_table.php index 963ea9dd7..db1a3ee12 100644 --- a/database/migrations/2020_04_03_230614_create_backups_table.php +++ b/database/migrations/2020_04_03_230614_create_backups_table.php @@ -15,15 +15,19 @@ class CreateBackupsTable extends Migration { Schema::create('backups', function (Blueprint $table) { $table->bigIncrements('id'); + $table->unsignedInteger('server_id'); $table->char('uuid', 36); $table->string('name'); - $table->text('contents'); + $table->text('ignored'); $table->string('disk'); $table->string('sha256_hash')->nullable(); $table->integer('bytes')->default(0); $table->timestamp('completed_at')->nullable(); $table->timestamps(); $table->softDeletes(); + + $table->unique('uuid'); + $table->foreign('server_id')->references('id')->on('servers')->onDelete('cascade'); }); } diff --git a/resources/scripts/api/server/backups/createServerBackup.ts b/resources/scripts/api/server/backups/createServerBackup.ts new file mode 100644 index 000000000..4f0754a54 --- /dev/null +++ b/resources/scripts/api/server/backups/createServerBackup.ts @@ -0,0 +1,12 @@ +import { rawDataToServerBackup, ServerBackup } from '@/api/server/backups/getServerBackups'; +import http from '@/api/http'; + +export default (uuid: string, name?: string, ignore?: string): Promise => { + return new Promise((resolve, reject) => { + http.post(`/api/client/servers/${uuid}/backups`, { + name, ignore, + }) + .then(({ data }) => resolve(rawDataToServerBackup(data.attributes))) + .catch(reject); + }); +}; diff --git a/resources/scripts/api/server/backups/getServerBackups.ts b/resources/scripts/api/server/backups/getServerBackups.ts new file mode 100644 index 000000000..6263bcf3a --- /dev/null +++ b/resources/scripts/api/server/backups/getServerBackups.ts @@ -0,0 +1,32 @@ +import http, { FractalResponseData, getPaginationSet, PaginatedResult } from '@/api/http'; + +export interface ServerBackup { + uuid: string; + name: string; + contents: string; + sha256Hash: string; + bytes: number; + createdAt: Date; + completedAt: Date | null; +} + +export const rawDataToServerBackup = ({ attributes }: FractalResponseData): ServerBackup => ({ + uuid: attributes.uuid, + name: attributes.name, + contents: attributes.contents, + sha256Hash: attributes.sha256_hash, + bytes: attributes.bytes, + createdAt: new Date(attributes.created_at), + completedAt: attributes.completed_at ? new Date(attributes.completed_at) : null, +}); + +export default (uuid: string, page?: number | string): Promise> => { + return new Promise((resolve, reject) => { + http.get(`/api/client/servers/${uuid}/backups`, { params: { page } }) + .then(({ data }) => resolve({ + items: (data.data || []).map(rawDataToServerBackup), + pagination: getPaginationSet(data.meta.pagination), + })) + .catch(reject); + }); +}; diff --git a/resources/scripts/components/server/backups/BackupContainer.tsx b/resources/scripts/components/server/backups/BackupContainer.tsx new file mode 100644 index 000000000..3cc0f1757 --- /dev/null +++ b/resources/scripts/components/server/backups/BackupContainer.tsx @@ -0,0 +1,61 @@ +import React, { useEffect, useState } from 'react'; +import Spinner from '@/components/elements/Spinner'; +import getServerBackups, { ServerBackup } from '@/api/server/backups/getServerBackups'; +import useServer from '@/plugins/useServer'; +import useFlash from '@/plugins/useFlash'; +import { httpErrorToHuman } from '@/api/http'; +import Can from '@/components/elements/Can'; +import CreateBackupButton from '@/components/server/backups/CreateBackupButton'; +import FlashMessageRender from '@/components/FlashMessageRender'; + +export default () => { + const { uuid } = useServer(); + const { addError, clearFlashes } = useFlash(); + const [ loading, setLoading ] = useState(true); + const [ backups, setBackups ] = useState([]); + + useEffect(() => { + clearFlashes('backups'); + getServerBackups(uuid) + .then(data => { + setBackups(data.items); + }) + .catch(error => { + console.error(error); + addError({ key: 'backups', message: httpErrorToHuman(error) }); + }) + .then(() => setLoading(false)); + }, []); + + if (loading) { + return ; + } + + return ( +
+ + {!backups.length ? +

+ There are no backups stored for this server. +

+ : +
+ { + backups.map(backup => ( +
+ {backup.uuid} +
+ )) + } +
+ } + +
+ setBackups(s => [...s, backup])} + /> +
+
+
+ ); +}; diff --git a/resources/scripts/components/server/backups/CreateBackupButton.tsx b/resources/scripts/components/server/backups/CreateBackupButton.tsx new file mode 100644 index 000000000..256a88325 --- /dev/null +++ b/resources/scripts/components/server/backups/CreateBackupButton.tsx @@ -0,0 +1,118 @@ +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 Field from '@/components/elements/Field'; +import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper'; +import useFlash from '@/plugins/useFlash'; +import useServer from '@/plugins/useServer'; +import createServerBackup from '@/api/server/backups/createServerBackup'; +import { httpErrorToHuman } from '@/api/http'; +import FlashMessageRender from '@/components/FlashMessageRender'; +import { ServerBackup } from '@/api/server/backups/getServerBackups'; + +interface Values { + name: string; + ignored: string; +} + +interface Props { + onBackupGenerated: (backup: ServerBackup) => void; +} + +const ModalContent = ({ ...props }: RequiredModalProps) => { + const { isSubmitting } = useFormikContext(); + + return ( + +
+ +

Create server backup

+
+ +
+
+ + + +
+
+ +
+ +
+ ); +}; + +export default ({ onBackupGenerated }: Props) => { + const { uuid } = useServer(); + const { addError, clearFlashes } = useFlash(); + const [ visible, setVisible ] = useState(false); + + useEffect(() => { + clearFlashes('backups:create'); + }, [visible]); + + const submit = ({ name, ignored }: Values, { setSubmitting }: FormikHelpers) => { + clearFlashes('backups:create') + createServerBackup(uuid, name, ignored) + .then(backup => { + onBackupGenerated(backup); + setVisible(false); + }) + .catch(error => { + console.error(error); + addError({ key: 'backups:create', message: httpErrorToHuman(error) }); + setSubmitting(false); + }); + }; + + return ( + <> + {visible && + + setVisible(false)} + /> + + } + + + ); +}; diff --git a/resources/scripts/plugins/useFlash.ts b/resources/scripts/plugins/useFlash.ts new file mode 100644 index 000000000..a55b87312 --- /dev/null +++ b/resources/scripts/plugins/useFlash.ts @@ -0,0 +1,9 @@ +import { Actions, useStoreActions } from 'easy-peasy'; +import { FlashStore } from '@/state/flashes'; +import { ApplicationStore } from '@/state'; + +const useFlash = (): Actions => { + return useStoreActions((actions: Actions) => actions.flashes); +}; + +export default useFlash; diff --git a/resources/scripts/plugins/useServer.ts b/resources/scripts/plugins/useServer.ts new file mode 100644 index 000000000..40fd93da1 --- /dev/null +++ b/resources/scripts/plugins/useServer.ts @@ -0,0 +1,9 @@ +import { DependencyList } from 'react'; +import { ServerContext } from '@/state/server'; +import { Server } from '@/api/server/getServer'; + +const useServer = (dependencies?: DependencyList): Server => { + return ServerContext.useStoreState(state => state.server.data!, [ dependencies ]); +}; + +export default useServer; diff --git a/resources/scripts/routers/ServerRouter.tsx b/resources/scripts/routers/ServerRouter.tsx index 241337b79..ce592dfeb 100644 --- a/resources/scripts/routers/ServerRouter.tsx +++ b/resources/scripts/routers/ServerRouter.tsx @@ -16,6 +16,7 @@ import ScheduleContainer from '@/components/server/schedules/ScheduleContainer'; import ScheduleEditContainer from '@/components/server/schedules/ScheduleEditContainer'; import UsersContainer from '@/components/server/users/UsersContainer'; import Can from '@/components/elements/Can'; +import BackupContainer from '@/components/server/backups/BackupContainer'; const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>) => { const server = ServerContext.useStoreState(state => state.server.data); @@ -47,6 +48,9 @@ const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>) Users + + Backups + Settings @@ -77,6 +81,7 @@ const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>) + diff --git a/routes/api-client.php b/routes/api-client.php index 0a6fa39e9..d0619949f 100644 --- a/routes/api-client.php +++ b/routes/api-client.php @@ -87,6 +87,14 @@ Route::group(['prefix' => '/servers/{server}', 'middleware' => [AuthenticateServ Route::delete('/{subuser}', 'Servers\SubuserController@delete'); }); + Route::group(['prefix' => '/backups'], function () { + Route::get('/', 'Servers\BackupController@index'); + Route::post('/', 'Servers\BackupController@store'); + Route::get('/{backup}', 'Servers\BackupController@view'); + Route::post('/{backup}', 'Servers\BackupController@update'); + Route::delete('/{backup}', 'Servers\BackupController@delete'); + }); + Route::group(['prefix' => '/settings'], function () { Route::post('/rename', 'Servers\SettingsController@rename'); Route::post('/reinstall', 'Servers\SettingsController@reinstall');