From c28e9c1ab7ea70d26b4e02c1327babc6182b9aa9 Mon Sep 17 00:00:00 2001
From: Dane Everitt <dane@daneeveritt.com>
Date: Wed, 22 Aug 2018 22:29:20 -0700
Subject: [PATCH] Add ability to create new database through the UI

---
 .../Http/ClientPermissionsRequest.php         | 15 ++++
 .../Api/Client/Servers/DatabaseController.php | 33 +++++++-
 .../Requests/Api/Client/ClientApiRequest.php  |  9 ++-
 .../Servers/Databases/GetDatabasesRequest.php | 17 ++++
 .../Databases/StoreDatabaseRequest.php        | 28 +++++++
 .../Client/Servers/GetDatabasesRequest.php    | 20 -----
 resources/assets/scripts/components/Flash.vue | 16 ++--
 .../assets/scripts/components/MessageBox.vue  | 16 ++++
 .../server/components/CreateDatabaseModal.vue | 81 +++++++++++++++++++
 .../components/server/subpages/Databases.vue  | 37 ++++++++-
 .../assets/styles/components/buttons.css      |  4 +
 routes/api-client.php                         |  1 +
 12 files changed, 240 insertions(+), 37 deletions(-)
 create mode 100644 app/Contracts/Http/ClientPermissionsRequest.php
 create mode 100644 app/Http/Requests/Api/Client/Servers/Databases/GetDatabasesRequest.php
 create mode 100644 app/Http/Requests/Api/Client/Servers/Databases/StoreDatabaseRequest.php
 delete mode 100644 app/Http/Requests/Api/Client/Servers/GetDatabasesRequest.php
 create mode 100644 resources/assets/scripts/components/MessageBox.vue
 create mode 100644 resources/assets/scripts/components/server/components/CreateDatabaseModal.vue

diff --git a/app/Contracts/Http/ClientPermissionsRequest.php b/app/Contracts/Http/ClientPermissionsRequest.php
new file mode 100644
index 00000000..6b863fce
--- /dev/null
+++ b/app/Contracts/Http/ClientPermissionsRequest.php
@@ -0,0 +1,15 @@
+<?php
+
+namespace Pterodactyl\Contracts\Http;
+
+interface ClientPermissionsRequest
+{
+    /**
+     * Returns the permissions string indicating which permission should be used to
+     * validate that the authenticated user has permission to perform this action aganist
+     * the given resource (server).
+     *
+     * @return string
+     */
+    public function permission(): string;
+}
diff --git a/app/Http/Controllers/Api/Client/Servers/DatabaseController.php b/app/Http/Controllers/Api/Client/Servers/DatabaseController.php
index 34f0c0e9..72605e88 100644
--- a/app/Http/Controllers/Api/Client/Servers/DatabaseController.php
+++ b/app/Http/Controllers/Api/Client/Servers/DatabaseController.php
@@ -4,12 +4,19 @@ namespace Pterodactyl\Http\Controllers\Api\Client\Servers;
 
 use Pterodactyl\Models\Server;
 use Pterodactyl\Transformers\Api\Client\DatabaseTransformer;
+use Pterodactyl\Services\Databases\DeployServerDatabaseService;
 use Pterodactyl\Http\Controllers\Api\Client\ClientApiController;
 use Pterodactyl\Contracts\Repository\DatabaseRepositoryInterface;
-use Pterodactyl\Http\Requests\Api\Client\Servers\GetDatabasesRequest;
+use Pterodactyl\Http\Requests\Api\Client\Servers\Databases\GetDatabasesRequest;
+use Pterodactyl\Http\Requests\Api\Client\Servers\Databases\StoreDatabaseRequest;
 
 class DatabaseController extends ClientApiController
 {
+    /**
+     * @var \Pterodactyl\Services\Databases\DeployServerDatabaseService
+     */
+    private $deployDatabaseService;
+
     /**
      * @var \Pterodactyl\Contracts\Repository\DatabaseRepositoryInterface
      */
@@ -19,16 +26,18 @@ class DatabaseController extends ClientApiController
      * DatabaseController constructor.
      *
      * @param \Pterodactyl\Contracts\Repository\DatabaseRepositoryInterface $repository
+     * @param \Pterodactyl\Services\Databases\DeployServerDatabaseService   $deployDatabaseService
      */
-    public function __construct(DatabaseRepositoryInterface $repository)
+    public function __construct(DatabaseRepositoryInterface $repository, DeployServerDatabaseService $deployDatabaseService)
     {
         parent::__construct();
 
+        $this->deployDatabaseService = $deployDatabaseService;
         $this->repository = $repository;
     }
 
     /**
-     * @param \Pterodactyl\Http\Requests\Api\Client\Servers\GetDatabasesRequest $request
+     * @param \Pterodactyl\Http\Requests\Api\Client\Servers\Databases\GetDatabasesRequest $request
      * @return array
      */
     public function index(GetDatabasesRequest $request): array
@@ -39,4 +48,22 @@ class DatabaseController extends ClientApiController
             ->transformWith($this->getTransformer(DatabaseTransformer::class))
             ->toArray();
     }
+
+    /**
+     * Create a new database for the given server and return it.
+     *
+     * @param \Pterodactyl\Http\Requests\Api\Client\Servers\Databases\StoreDatabaseRequest $request
+     * @return array
+     *
+     * @throws \Pterodactyl\Exceptions\Service\Database\DatabaseClientFeatureNotEnabledException
+     */
+    public function store(StoreDatabaseRequest $request): array
+    {
+        $database = $this->deployDatabaseService->handle($request->getModel(Server::class), $request->validated());
+
+        return $this->fractal->item($database)
+            ->parseIncludes(['password'])
+            ->transformWith($this->getTransformer(DatabaseTransformer::class))
+            ->toArray();
+    }
 }
diff --git a/app/Http/Requests/Api/Client/ClientApiRequest.php b/app/Http/Requests/Api/Client/ClientApiRequest.php
index 92402e51..468b294f 100644
--- a/app/Http/Requests/Api/Client/ClientApiRequest.php
+++ b/app/Http/Requests/Api/Client/ClientApiRequest.php
@@ -2,18 +2,23 @@
 
 namespace Pterodactyl\Http\Requests\Api\Client;
 
+use Pterodactyl\Models\Server;
+use Pterodactyl\Contracts\Http\ClientPermissionsRequest;
 use Pterodactyl\Http\Requests\Api\Application\ApplicationApiRequest;
 
 abstract class ClientApiRequest extends ApplicationApiRequest
 {
     /**
-     * Determine if the current user is authorized to perform
-     * the requested action against the API.
+     * Determine if the current user is authorized to perform the requested action against the API.
      *
      * @return bool
      */
     public function authorize(): bool
     {
+        if ($this instanceof ClientPermissionsRequest || method_exists($this, 'permission')) {
+            return $this->user()->can($this->permission(), $this->getModel(Server::class));
+        }
+
         return true;
     }
 }
diff --git a/app/Http/Requests/Api/Client/Servers/Databases/GetDatabasesRequest.php b/app/Http/Requests/Api/Client/Servers/Databases/GetDatabasesRequest.php
new file mode 100644
index 00000000..0e90d80b
--- /dev/null
+++ b/app/Http/Requests/Api/Client/Servers/Databases/GetDatabasesRequest.php
@@ -0,0 +1,17 @@
+<?php
+
+namespace Pterodactyl\Http\Requests\Api\Client\Servers\Databases;
+
+use Pterodactyl\Contracts\Http\ClientPermissionsRequest;
+use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest;
+
+class GetDatabasesRequest extends ClientApiRequest implements ClientPermissionsRequest
+{
+    /**
+     * @return string
+     */
+    public function permission(): string
+    {
+        return 'view-databases';
+    }
+}
diff --git a/app/Http/Requests/Api/Client/Servers/Databases/StoreDatabaseRequest.php b/app/Http/Requests/Api/Client/Servers/Databases/StoreDatabaseRequest.php
new file mode 100644
index 00000000..d8fc7849
--- /dev/null
+++ b/app/Http/Requests/Api/Client/Servers/Databases/StoreDatabaseRequest.php
@@ -0,0 +1,28 @@
+<?php
+
+namespace Pterodactyl\Http\Requests\Api\Client\Servers\Databases;
+
+use Pterodactyl\Contracts\Http\ClientPermissionsRequest;
+use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest;
+
+class StoreDatabaseRequest extends ClientApiRequest implements ClientPermissionsRequest
+{
+    /**
+     * @return string
+     */
+    public function permission(): string
+    {
+        return 'create-database';
+    }
+
+    /**
+     * @return array
+     */
+    public function rules(): array
+    {
+        return [
+            'database' => 'required|alpha_dash|min:1|max:100',
+            'remote' => 'required|string|regex:/^[0-9%.]{1,15}$/',
+        ];
+    }
+}
diff --git a/app/Http/Requests/Api/Client/Servers/GetDatabasesRequest.php b/app/Http/Requests/Api/Client/Servers/GetDatabasesRequest.php
deleted file mode 100644
index 1c8b201a..00000000
--- a/app/Http/Requests/Api/Client/Servers/GetDatabasesRequest.php
+++ /dev/null
@@ -1,20 +0,0 @@
-<?php
-
-namespace Pterodactyl\Http\Requests\Api\Client\Servers;
-
-use Pterodactyl\Models\Server;
-use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest;
-
-class GetDatabasesRequest extends ClientApiRequest
-{
-    /**
-     * Determine if this user has permission to view all of the databases available
-     * to this server.
-     *
-     * @return bool
-     */
-    public function authorize(): bool
-    {
-        return $this->user()->can('view-databases', $this->getModel(Server::class));
-    }
-}
diff --git a/resources/assets/scripts/components/Flash.vue b/resources/assets/scripts/components/Flash.vue
index b42da3b1..5a776c19 100644
--- a/resources/assets/scripts/components/Flash.vue
+++ b/resources/assets/scripts/components/Flash.vue
@@ -1,22 +1,22 @@
 <template>
     <div v-if="notifications.length > 0" :class="this.container">
         <transition-group tag="div" name="fade">
-            <div class="lg:inline-flex" role="alert" v-for="(item, index) in notifications"
-                :key="index"
-                :class="[item.class, {
-                    'mb-2': index < notifications.length - 1
-                }]"
-            >
-                <span class="title" v-html="item.title" v-if="item.title.length > 0"></span>
-                <span class="message" v-html="item.message"></span>
+            <div v-for="(item, index) in notifications" :key="index">
+                <message-box
+                        :class="[item.class, {'mb-2': index < notifications.length - 1}]"
+                        :title="item.title"
+                        :message="item.message"
+                />
             </div>
         </transition-group>
     </div>
 </template>
 
 <script>
+    import MessageBox from './MessageBox';
     export default {
         name: 'flash',
+        components: {MessageBox},
         props: {
             container: {
                 type: String,
diff --git a/resources/assets/scripts/components/MessageBox.vue b/resources/assets/scripts/components/MessageBox.vue
new file mode 100644
index 00000000..b552f774
--- /dev/null
+++ b/resources/assets/scripts/components/MessageBox.vue
@@ -0,0 +1,16 @@
+<template>
+    <div class="lg:inline-flex" role="alert">
+        <span class="title" v-html="title" v-if="title && title.length > 0"></span>
+        <span class="message" v-html="message"></span>
+    </div>
+</template>
+
+<script>
+    export default {
+        name: 'message-box',
+        props: {
+            title: {type: String, required: false},
+            message: {type: String, required: true}
+        },
+    };
+</script>
diff --git a/resources/assets/scripts/components/server/components/CreateDatabaseModal.vue b/resources/assets/scripts/components/server/components/CreateDatabaseModal.vue
new file mode 100644
index 00000000..81cefad1
--- /dev/null
+++ b/resources/assets/scripts/components/server/components/CreateDatabaseModal.vue
@@ -0,0 +1,81 @@
+<template>
+    <div>
+        <message-box class="alert error mb-6" :message="errorMessage" v-show="errorMessage.length"/>
+        <h2 class="font-medium text-grey-darkest mb-6">Create a new database</h2>
+        <div class="mb-6">
+            <label class="input-label" for="grid-database-name">Database name</label>
+            <input id="grid-database-name" type="text" class="input" name="database_name" required
+                   v-model="database"
+                   v-validate="{ alpha_dash: true, max: 100 }"
+                   :class="{ error: errors.has('database_name') }"
+            >
+            <p class="input-help error" v-show="errors.has('database_name')">{{ errors.first('database_name') }}</p>
+        </div>
+        <div class="mb-6">
+            <label class="input-label" for="grid-database-remote">Allow connections from</label>
+            <input id="grid-database-remote" type="text" class="input" name="remote" required
+                   v-model="remote"
+                   v-validate="{ regex: /^[0-9%.]{1,15}$/ }"
+                   :class="{ error: errors.has('remote') }"
+            >
+            <p class="input-help error" v-show="errors.has('remote')">{{ errors.first('remote') }}</p>
+        </div>
+        <div class="text-right">
+            <button class="btn btn-secondary btn-sm mr-2" v-on:click.once="$emit('close')">Cancel</button>
+            <button class="btn btn-green btn-sm"
+                    :disabled="errors.any() || !canSubmit"
+                    v-on:click="submit"
+            >Create</button>
+        </div>
+    </div>
+</template>
+
+<script>
+    import MessageBox from '../../MessageBox';
+    import get from 'lodash/get';
+
+    export default {
+        name: 'create-database-modal',
+        components: {MessageBox},
+        data: function () {
+            return {
+                loading: false,
+                database: '',
+                remote: '%',
+                errorMessage: '',
+            };
+        },
+
+        computed: {
+            canSubmit: function () {
+                return this.database.length && this.remote.length;
+            },
+        },
+
+        methods: {
+            submit: function () {
+                this.errorMessage = '';
+                this.loading = true;
+
+                window.axios.post(this.route('api.client.servers.databases', {
+                    server: this.$route.params.id,
+                }), {
+                    database: this.database,
+                    remote: this.remote,
+                }).then(response => {
+                    this.$emit('database', response.data.attributes);
+                    this.$emit('close');
+                }).catch(err => {
+                    if (get(err, 'response.data.errors[0]')) {
+                        this.errorMessage = err.response.data.errors[0].detail;
+                    }
+
+                    console.error('A network error was encountered while processing this request.', err.response);
+                }).then(() => {
+                    this.loading = false;
+                })
+            }
+        }
+    };
+</script>
+
diff --git a/resources/assets/scripts/components/server/subpages/Databases.vue b/resources/assets/scripts/components/server/subpages/Databases.vue
index 01cf69c3..a18af988 100644
--- a/resources/assets/scripts/components/server/subpages/Databases.vue
+++ b/resources/assets/scripts/components/server/subpages/Databases.vue
@@ -3,7 +3,7 @@
         <div v-if="loading">
             <div class="spinner spinner-xl blue"></div>
         </div>
-        <div class="bg-white p-6 rounded border border-grey-light" v-else-if="!databases.length">
+        <div class="context-box" v-else-if="!databases.length">
             <div class="flex items-center">
                 <database-icon class="flex-none text-grey-darker"></database-icon>
                 <div class="flex-1 px-4 text-grey-darker">
@@ -12,7 +12,7 @@
             </div>
         </div>
         <div v-else>
-            <div class="bg-white p-6 rounded border border-grey-light mb-6" v-for="database in databases" :key="database.name">
+            <div class="content-box mb-6" v-for="database in databases" :key="database.name">
                 <div class="flex items-center text-grey-darker">
                     <database-icon class="flex-none text-green"></database-icon>
                     <div class="flex-1 px-4">
@@ -40,22 +40,35 @@
                     </div>
                 </div>
             </div>
+            <div>
+                <button class="btn btn-blue btn-lg" v-on:click="showCreateModal = true">Create new database</button>
+            </div>
         </div>
+        <modal :show="showCreateModal" v-on:close="showCreateModal = false">
+            <create-database-modal
+                    v-on:close="showCreateModal = false"
+                    v-on:database="handleModalCallback"
+                    v-if="showCreateModal"
+            />
+        </modal>
     </div>
 </template>
 
 <script>
     import { DatabaseIcon, LockIcon } from 'vue-feather-icons';
     import map from 'lodash/map';
+    import Modal from '../../core/Modal';
+    import CreateDatabaseModal from '../components/CreateDatabaseModal';
 
     export default {
         name: 'databases-page',
-        components: { DatabaseIcon, LockIcon },
+        components: {CreateDatabaseModal, Modal, DatabaseIcon, LockIcon },
 
         data: function () {
             return {
-                loading: true,
                 databases: [],
+                loading: true,
+                showCreateModal: false,
             };
         },
 
@@ -95,6 +108,22 @@
                     });
             },
 
+            /**
+             * Add the database to the list of existing databases automatically when the modal
+             * is closed with a successful callback.
+             */
+            handleModalCallback: function (object) {
+                console.log('handle', object);
+
+                const data = object;
+                data.password = data.relationships.password.attributes.password;
+                data.showPassword = false;
+
+                delete data.relationships;
+
+                this.databases.push(data);
+            },
+
             /**
              * Show the password for a given database object.
              *
diff --git a/resources/assets/styles/components/buttons.css b/resources/assets/styles/components/buttons.css
index 0171adda..e457a5db 100644
--- a/resources/assets/styles/components/buttons.css
+++ b/resources/assets/styles/components/buttons.css
@@ -43,6 +43,10 @@
     @apply .p-4 .w-full .uppercase .tracking-wide .text-sm;
   }
 
+  &.btn-lg {
+    @apply .p-4 .uppercase .tracking-wide .text-sm;
+  }
+
   &.btn-sm {
     @apply .px-6 .py-3 .uppercase .tracking-wide .text-sm;
   }
diff --git a/routes/api-client.php b/routes/api-client.php
index 14f783eb..d13c103e 100644
--- a/routes/api-client.php
+++ b/routes/api-client.php
@@ -37,5 +37,6 @@ Route::group(['prefix' => '/servers/{server}', 'middleware' => [AuthenticateClie
 
     Route::group(['prefix' => '/databases'], function () {
         Route::get('/', 'Servers\DatabaseController@index')->name('api.client.servers.databases');
+        Route::post('/', 'Servers\DatabaseController@store');
     });
 });