diff --git a/app/Http/Controllers/Api/Application/Roles/RoleController.php b/app/Http/Controllers/Api/Application/Roles/RoleController.php index 94b7fe592..e4cff2821 100644 --- a/app/Http/Controllers/Api/Application/Roles/RoleController.php +++ b/app/Http/Controllers/Api/Application/Roles/RoleController.php @@ -3,11 +3,9 @@ namespace Pterodactyl\Http\Controllers\Api\Application\Roles; use Illuminate\Http\Response; -use Pterodactyl\Models\Location; use Illuminate\Http\JsonResponse; use Pterodactyl\Models\AdminRole; use Spatie\QueryBuilder\QueryBuilder; -use Pterodactyl\Transformers\Api\Application\LocationTransformer; use Pterodactyl\Transformers\Api\Application\AdminRoleTransformer; use Pterodactyl\Exceptions\Http\QueryValueOutOfRangeHttpException; use Pterodactyl\Http\Requests\Api\Application\Roles\GetRoleRequest; diff --git a/app/Http/Controllers/Api/Client/SSHKeyController.php b/app/Http/Controllers/Api/Client/SSHKeyController.php new file mode 100644 index 000000000..848712f96 --- /dev/null +++ b/app/Http/Controllers/Api/Client/SSHKeyController.php @@ -0,0 +1,58 @@ +fractal->collection(UserSSHKey::query()->where('user_id', '=', $request->user()->id)->get()) + ->transformWith($this->getTransformer(UserSSHKeyTransformer::class)); + } + + /** + * ? + * + * @return JsonResponse + * @throws \Illuminate\Contracts\Container\BindingResolutionException + * @throws \Pterodactyl\Exceptions\DisplayException + */ + public function store(StoreSSHKeyRequest $request): JsonResponse + { + if ($request->user()->sshKeys->count() >= 5) { + throw new DisplayException('You have reached the account limit for number of SSH keys.'); + } + + $data = array_merge($request->validated(), [ + 'user_id' => $request->user()->id, + ]); + $key = UserSSHKey::query()->create($data); + + return $this->fractal->item($key) + ->transformWith($this->getTransformer(UserSSHKeyTransformer::class)) + ->respond(JsonResponse::HTTP_CREATED); + } + + /** + * ? + */ + public function delete(Request $request, UserSSHKey $sshKey): Response + { + $sshKey->delete(); + + return new Response('', Response::HTTP_NO_CONTENT); + } +} diff --git a/app/Http/Requests/Api/Client/Account/StoreSSHKeyRequest.php b/app/Http/Requests/Api/Client/Account/StoreSSHKeyRequest.php new file mode 100644 index 000000000..359b3400d --- /dev/null +++ b/app/Http/Requests/Api/Client/Account/StoreSSHKeyRequest.php @@ -0,0 +1,14 @@ + 'required|uuid', 'action' => 'required|string|max:191', 'subaction' => 'nullable|string|max:191', diff --git a/app/Models/DatabaseHost.php b/app/Models/DatabaseHost.php index a2cc2719c..f184f313b 100644 --- a/app/Models/DatabaseHost.php +++ b/app/Models/DatabaseHost.php @@ -64,7 +64,7 @@ class DatabaseHost extends Model * * @var array */ - public static $validationRules = [ + public static array $validationRules = [ 'name' => 'required|string|max:191', 'host' => 'required|string', 'port' => 'required|numeric|between:1,65535', diff --git a/app/Models/EggVariable.php b/app/Models/EggVariable.php index 64e2234ad..0a48132d7 100644 --- a/app/Models/EggVariable.php +++ b/app/Models/EggVariable.php @@ -70,7 +70,7 @@ class EggVariable extends Model /** * @var array */ - public static $validationRules = [ + public static array $validationRules = [ 'egg_id' => 'exists:eggs,id', 'name' => 'required|string|between:1,191', 'description' => 'string', diff --git a/app/Models/User.php b/app/Models/User.php index a5c335898..57b908e78 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -40,6 +40,7 @@ use Pterodactyl\Notifications\SendPasswordReset as ResetPasswordNotification; * @property \Pterodactyl\Models\AdminRole $adminRole * @property \Pterodactyl\Models\ApiKey[]|\Illuminate\Database\Eloquent\Collection $apiKeys * @property \Pterodactyl\Models\Server[]|\Illuminate\Database\Eloquent\Collection $servers + * @property \Pterodactyl\Models\UserSSHKey|\Illuminate\Database\Eloquent\Collection $sshKeys * @property \Pterodactyl\Models\RecoveryToken[]|\Illuminate\Database\Eloquent\Collection $recoveryTokens * @property \LaravelWebauthn\Models\WebauthnKey[]|\Illuminate\Database\Eloquent\Collection $webauthnKeys */ @@ -247,6 +248,11 @@ class User extends Model implements return $this->hasMany(Server::class, 'owner_id'); } + public function sshKeys(): HasMany + { + return $this->hasMany(UserSSHKey::class); + } + public function recoveryTokens(): HasMany { return $this->hasMany(RecoveryToken::class); diff --git a/app/Models/UserSSHKey.php b/app/Models/UserSSHKey.php index 147902236..7ecc6730b 100644 --- a/app/Models/UserSSHKey.php +++ b/app/Models/UserSSHKey.php @@ -2,15 +2,20 @@ namespace Pterodactyl\Models; +use Illuminate\Database\Eloquent\Relations\BelongsTo; + /** * @property int $id * @property int $user_id * @property string $name * @property string $public_key * @property \Carbon\CarbonImmutable $created_at + * @property \Pterodactyl\Models\User $user */ class UserSSHKey extends Model { + const UPDATED_AT = null; + protected $table = 'user_ssh_keys'; protected bool $immutableDates = true; @@ -23,4 +28,9 @@ class UserSSHKey extends Model 'name' => 'required|string', 'public_key' => 'required|string', ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } } diff --git a/app/Transformers/Api/Client/UserSSHKeyTransformer.php b/app/Transformers/Api/Client/UserSSHKeyTransformer.php new file mode 100644 index 000000000..e137ccd51 --- /dev/null +++ b/app/Transformers/Api/Client/UserSSHKeyTransformer.php @@ -0,0 +1,29 @@ + $model->id, + 'name' => $model->name, + 'public_key' => $model->public_key, + 'created_at' => $model->created_at->toIso8601String(), + ]; + } +} diff --git a/database/migrations/2021_07_17_211512_create_user_ssh_keys_table.php b/database/migrations/2021_07_17_211512_create_user_ssh_keys_table.php new file mode 100644 index 000000000..fed72191b --- /dev/null +++ b/database/migrations/2021_07_17_211512_create_user_ssh_keys_table.php @@ -0,0 +1,36 @@ +increments('id'); + $table->unsignedInteger('user_id'); + $table->string('name'); + $table->text('public_key'); + $table->timestamp('created_at')->nullable(); + + $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('user_ssh_keys'); + } +} diff --git a/resources/scripts/api/account/ssh/createSSHKey.ts b/resources/scripts/api/account/ssh/createSSHKey.ts new file mode 100644 index 000000000..158862058 --- /dev/null +++ b/resources/scripts/api/account/ssh/createSSHKey.ts @@ -0,0 +1,10 @@ +import http from '@/api/http'; +import { SSHKey, rawDataToSSHKey } from '@/api/account/ssh/getSSHKeys'; + +export default (name: string, publicKey: string): Promise => { + return new Promise((resolve, reject) => { + http.post('/api/client/account/ssh', { name, public_key: publicKey }) + .then(({ data }) => resolve(rawDataToSSHKey(data.attributes))) + .catch(reject); + }); +}; diff --git a/resources/scripts/api/account/ssh/deleteSSHKey.ts b/resources/scripts/api/account/ssh/deleteSSHKey.ts new file mode 100644 index 000000000..000a1326b --- /dev/null +++ b/resources/scripts/api/account/ssh/deleteSSHKey.ts @@ -0,0 +1,9 @@ +import http from '@/api/http'; + +export default (id: number): Promise => { + return new Promise((resolve, reject) => { + http.delete(`/api/client/account/ssh/${id}`) + .then(() => resolve()) + .catch(reject); + }); +}; diff --git a/resources/scripts/api/account/ssh/getSSHKeys.ts b/resources/scripts/api/account/ssh/getSSHKeys.ts new file mode 100644 index 000000000..004b6a192 --- /dev/null +++ b/resources/scripts/api/account/ssh/getSSHKeys.ts @@ -0,0 +1,23 @@ +import http from '@/api/http'; + +export interface SSHKey { + id: number; + name: string; + publicKey: string; + createdAt: Date; +} + +export const rawDataToSSHKey = (data: any): SSHKey => ({ + id: data.id, + name: data.name, + publicKey: data.public_key, + createdAt: new Date(data.created_at), +}); + +export default (): Promise => { + return new Promise((resolve, reject) => { + http.get('/api/client/account/ssh') + .then(({ data }) => resolve((data.data || []).map((d: any) => rawDataToSSHKey(d.attributes)))) + .catch(reject); + }); +}; diff --git a/resources/scripts/components/dashboard/SSHKeyContainer.tsx b/resources/scripts/components/dashboard/SSHKeyContainer.tsx new file mode 100644 index 000000000..b40e5516d --- /dev/null +++ b/resources/scripts/components/dashboard/SSHKeyContainer.tsx @@ -0,0 +1,166 @@ +import React, { useEffect, useState } from 'react'; +import { Field as FormikField, Form, Formik, FormikHelpers } from 'formik'; +import tw from 'twin.macro'; +import { object, string } from 'yup'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faKey, faTrashAlt } from '@fortawesome/free-solid-svg-icons'; +import createSSHKey from '@/api/account/ssh/createSSHKey'; +import deleteSSHKey from '@/api/account/ssh/deleteSSHKey'; +import getSSHKeys, { SSHKey } from '@/api/account/ssh/getSSHKeys'; +import FlashMessageRender from '@/components/FlashMessageRender'; +import Button from '@/components/elements/Button'; +import ContentBox from '@/components/elements/ContentBox'; +import Field from '@/components/elements/Field'; +import GreyRowBox from '@/components/elements/GreyRowBox'; +import PageContentBlock from '@/components/elements/PageContentBlock'; +import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; +import useFlash from '@/plugins/useFlash'; +import ConfirmationModal from '@/components/elements/ConfirmationModal'; +import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper'; +import { Textarea } from '@/components/elements/Input'; + +interface Values { + name: string; + publicKey: string; +} + +const AddSSHKeyForm = ({ onKeyAdded }: { onKeyAdded: (key: SSHKey) => void }) => { + const { clearFlashes, clearAndAddHttpError } = useFlash(); + + const submit = ({ name, publicKey }: Values, { setSubmitting, resetForm }: FormikHelpers) => { + clearFlashes('ssh_keys'); + + createSSHKey(name, publicKey) + .then(key => { + resetForm(); + onKeyAdded(key); + }) + .catch(error => { + console.error(error); + clearAndAddHttpError({ key: 'ssh_keys', error }); + }) + .then(() => setSubmitting(false)); + }; + + return ( + + {({ isSubmitting }) => ( +
+ + +
+ + + +
+
+ +
+ + )} +
+ ); +}; + +export default () => { + const { clearFlashes, clearAndAddHttpError } = useFlash(); + + const [ keys, setKeys ] = useState([]); + const [ loading, setLoading ] = useState(true); + const [ deleteId, setDeleteId ] = useState(null); + + const doDeletion = (id: number | null) => { + if (id === null) { + return; + } + + clearFlashes('ssh_keys'); + + deleteSSHKey(id) + .then(() => setKeys(s => ([ + ...(s || []).filter(key => key.id !== id), + ]))) + .catch(error => { + console.error(error); + clearAndAddHttpError({ key: 'ssh_keys', error }); + }); + }; + + useEffect(() => { + clearFlashes('ssh_keys'); + + getSSHKeys() + .then(keys => setKeys(keys)) + .then(() => setLoading(false)) + .catch(error => { + console.error(error); + clearAndAddHttpError({ key: 'ssh_keys', error }); + }); + }, []); + + return ( + + +
+ + + { + doDeletion(deleteId); + setDeleteId(null); + }} + onModalDismissed={() => setDeleteId(null)} + > + Are you sure you wish to delete this SSH key? + + {keys.length === 0 ? + !loading ? +

+ No SSH keys have been configured for this account. +

+ : null + : + keys.map((key, index) => ( + 0 && tw`mt-2` ]}> + +
+

{key.name}

+
+ +
+ )) + } +
+ + + setKeys(s => ([ ...s!, key ]))}/> + +
+
+ ); +}; diff --git a/resources/scripts/components/dashboard/SecurityKeyContainer.tsx b/resources/scripts/components/dashboard/SecurityKeyContainer.tsx index 1d72dbbe4..634f1c174 100644 --- a/resources/scripts/components/dashboard/SecurityKeyContainer.tsx +++ b/resources/scripts/components/dashboard/SecurityKeyContainer.tsx @@ -119,8 +119,8 @@ export default () => { }} onModalDismissed={() => setDeleteId(null)} > - Are you sure you wish to delete this API key? All requests using it will immediately be - invalidated and will fail. + Are you sure you wish to delete this security key? + You will no longer be able to authenticate using this key. {keys.length === 0 ? !loading ? diff --git a/resources/scripts/routers/DashboardRouter.tsx b/resources/scripts/routers/DashboardRouter.tsx index 8fd3c2293..52a5c6d9b 100644 --- a/resources/scripts/routers/DashboardRouter.tsx +++ b/resources/scripts/routers/DashboardRouter.tsx @@ -6,6 +6,7 @@ import DashboardContainer from '@/components/dashboard/DashboardContainer'; import AccountOverviewContainer from '@/components/dashboard/AccountOverviewContainer'; import AccountApiContainer from '@/components/dashboard/AccountApiContainer'; import SecurityKeyContainer from '@/components/dashboard/SecurityKeyContainer'; +import SSHKeyContainer from '@/components/dashboard/SSHKeyContainer'; import { NotFound } from '@/components/elements/ScreenBlock'; import SubNavigation from '@/components/elements/SubNavigation'; @@ -18,6 +19,7 @@ export default ({ location }: RouteComponentProps) => ( Settings API Credentials Security Keys + SSH Keys } @@ -35,6 +37,9 @@ export default ({ location }: RouteComponentProps) => ( + + + diff --git a/routes/api-client.php b/routes/api-client.php index a17559c32..f9d33555d 100644 --- a/routes/api-client.php +++ b/routes/api-client.php @@ -33,6 +33,10 @@ Route::group(['prefix' => '/account'], function () { Route::get('/webauthn/register', 'WebauthnController@register')->withoutMiddleware(RequireTwoFactorAuthentication::class); Route::post('/webauthn/register', 'WebauthnController@create')->withoutMiddleware(RequireTwoFactorAuthentication::class); Route::delete('/webauthn/{id}', 'WebauthnController@deleteKey')->withoutMiddleware(RequireTwoFactorAuthentication::class); + + Route::get('/ssh', 'SSHKeyController@index'); + Route::post('/ssh', 'SSHKeyController@store'); + Route::delete('/ssh/{ssh_key}', 'SSHKeyController@delete'); }); /*