Skip to content

Commit 48c39ab

Browse files
committed
Add database password rotation to view
1 parent f6ee885 commit 48c39ab

File tree

11 files changed

+178
-6
lines changed

11 files changed

+178
-6
lines changed

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22

33
namespace Pterodactyl\Http\Controllers\Api\Client\Servers;
44

5+
use Illuminate\Support\Str;
56
use Illuminate\Http\Response;
67
use Pterodactyl\Models\Server;
78
use Pterodactyl\Models\Database;
9+
use Pterodactyl\Services\Databases\DatabasePasswordService;
810
use Pterodactyl\Transformers\Api\Client\DatabaseTransformer;
911
use Pterodactyl\Services\Databases\DatabaseManagementService;
1012
use Pterodactyl\Services\Databases\DeployServerDatabaseService;
@@ -13,6 +15,7 @@
1315
use Pterodactyl\Http\Requests\Api\Client\Servers\Databases\GetDatabasesRequest;
1416
use Pterodactyl\Http\Requests\Api\Client\Servers\Databases\StoreDatabaseRequest;
1517
use Pterodactyl\Http\Requests\Api\Client\Servers\Databases\DeleteDatabaseRequest;
18+
use Pterodactyl\Http\Requests\Api\Client\Servers\Databases\RotatePasswordRequest;
1619

1720
class DatabaseController extends ClientApiController
1821
{
@@ -31,15 +34,22 @@ class DatabaseController extends ClientApiController
3134
*/
3235
private $managementService;
3336

37+
/**
38+
* @var \Pterodactyl\Services\Databases\DatabasePasswordService
39+
*/
40+
private $passwordService;
41+
3442
/**
3543
* DatabaseController constructor.
3644
*
3745
* @param \Pterodactyl\Services\Databases\DatabaseManagementService $managementService
46+
* @param \Pterodactyl\Services\Databases\DatabasePasswordService $passwordService
3847
* @param \Pterodactyl\Contracts\Repository\DatabaseRepositoryInterface $repository
3948
* @param \Pterodactyl\Services\Databases\DeployServerDatabaseService $deployDatabaseService
4049
*/
4150
public function __construct(
4251
DatabaseManagementService $managementService,
52+
DatabasePasswordService $passwordService,
4353
DatabaseRepositoryInterface $repository,
4454
DeployServerDatabaseService $deployDatabaseService
4555
) {
@@ -48,6 +58,7 @@ public function __construct(
4858
$this->deployDatabaseService = $deployDatabaseService;
4959
$this->repository = $repository;
5060
$this->managementService = $managementService;
61+
$this->passwordService = $passwordService;
5162
}
5263

5364
/**
@@ -81,6 +92,30 @@ public function store(StoreDatabaseRequest $request): array
8192
->toArray();
8293
}
8394

95+
/**
96+
* Rotates the password for the given server model and returns a fresh instance to
97+
* the caller.
98+
*
99+
* @param \Pterodactyl\Http\Requests\Api\Client\Servers\Databases\RotatePasswordRequest $request
100+
* @return array
101+
*
102+
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
103+
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
104+
*/
105+
public function rotatePassword(RotatePasswordRequest $request)
106+
{
107+
$database = $request->getModel(Database::class);
108+
109+
$this->passwordService->handle($database, Str::random(24));
110+
111+
$database->refresh();
112+
113+
return $this->fractal->item($database)
114+
->parseIncludes(['password'])
115+
->transformWith($this->getTransformer(DatabaseTransformer::class))
116+
->toArray();
117+
}
118+
84119
/**
85120
* @param \Pterodactyl\Http\Requests\Api\Client\Servers\Databases\DeleteDatabaseRequest $request
86121
* @return \Illuminate\Http\Response

app/Http/Requests/Api/Client/ClientApiRequest.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66
use Pterodactyl\Contracts\Http\ClientPermissionsRequest;
77
use Pterodactyl\Http\Requests\Api\Application\ApplicationApiRequest;
88

9+
/**
10+
* @method \Pterodactyl\Models\User user($guard = null)
11+
*/
912
abstract class ClientApiRequest extends ApplicationApiRequest
1013
{
1114
/**
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
namespace Pterodactyl\Http\Requests\Api\Client\Servers\Databases;
4+
5+
use Pterodactyl\Models\Server;
6+
use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest;
7+
8+
class RotatePasswordRequest extends ClientApiRequest
9+
{
10+
/**
11+
* Check that the user has permission to rotate the password.
12+
*
13+
* @return bool
14+
*/
15+
public function authorize(): bool
16+
{
17+
return $this->user()->can('reset-db-password', $this->getModel(Server::class));
18+
}
19+
}

app/Services/Databases/DatabasePasswordService.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace Pterodactyl\Services\Databases;
44

5+
use Webmozart\Assert\Assert;
56
use Pterodactyl\Models\Database;
67
use Illuminate\Database\ConnectionInterface;
78
use Illuminate\Contracts\Encryption\Encrypter;
@@ -63,6 +64,8 @@ public function __construct(
6364
public function handle($database, string $password): bool
6465
{
6566
if (! $database instanceof Database) {
67+
Assert::integerish($database);
68+
6669
$database = $this->repository->find($database);
6770
}
6871

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { rawDataToServerDatabase, ServerDatabase } from '@/api/server/getServerDatabases';
2+
import http from '@/api/http';
3+
4+
export default (uuid: string, database: string): Promise<ServerDatabase> => {
5+
return new Promise((resolve, reject) => {
6+
http.post(`/api/client/servers/${uuid}/databases/${database}/rotate-password`)
7+
.then((response) => resolve(rawDataToServerDatabase(response.data.attributes)))
8+
.catch(reject);
9+
});
10+
};
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import React from 'react';
2+
import classNames from 'classnames';
3+
4+
type Props = { isLoading?: boolean } & React.DetailedHTMLProps<React.ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>;
5+
6+
export default ({ isLoading, children, className, ...props }: Props) => (
7+
<button
8+
{...props}
9+
className={classNames('btn btn-sm relative', className)}
10+
>
11+
{isLoading &&
12+
<div className={'w-full flex absolute justify-center'} style={{ marginLeft: '-0.75rem' }}>
13+
<div className={'spinner-circle spinner-white spinner-sm'}/>
14+
</div>
15+
}
16+
<span className={isLoading ? 'text-transparent' : undefined}>
17+
{children}
18+
</span>
19+
</button>
20+
);

resources/scripts/components/server/databases/DatabaseRow.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,19 +15,26 @@ import { ApplicationStore } from '@/state';
1515
import { ServerContext } from '@/state/server';
1616
import deleteServerDatabase from '@/api/server/deleteServerDatabase';
1717
import { httpErrorToHuman } from '@/api/http';
18+
import RotatePasswordButton from '@/components/server/databases/RotatePasswordButton';
1819

1920
interface Props {
20-
database: ServerDatabase;
21+
databaseId: string | number;
2122
className?: string;
2223
onDelete: () => void;
2324
}
2425

25-
export default ({ database, className, onDelete }: Props) => {
26+
export default ({ databaseId, className, onDelete }: Props) => {
2627
const [visible, setVisible] = useState(false);
28+
const database = ServerContext.useStoreState(state => state.databases.items.find(item => item.id === databaseId));
29+
const appendDatabase = ServerContext.useStoreActions(actions => actions.databases.appendDatabase);
2730
const [connectionVisible, setConnectionVisible] = useState(false);
2831
const { addFlash, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
2932
const server = ServerContext.useStoreState(state => state.server.data!);
3033

34+
if (!database) {
35+
return null;
36+
}
37+
3138
const schema = object().shape({
3239
confirm: string()
3340
.required('The database name must be provided.')
@@ -104,6 +111,7 @@ export default ({ database, className, onDelete }: Props) => {
104111
}
105112
</Formik>
106113
<Modal visible={connectionVisible} onDismissed={() => setConnectionVisible(false)}>
114+
<FlashMessageRender byKey={'database-connection-modal'} className={'mb-6'}/>
107115
<h3 className={'mb-6'}>Database connection details</h3>
108116
<div>
109117
<label className={'input-dark-label'}>Password</label>
@@ -119,6 +127,7 @@ export default ({ database, className, onDelete }: Props) => {
119127
/>
120128
</div>
121129
<div className={'mt-6 text-right'}>
130+
<RotatePasswordButton databaseId={database.id} onUpdate={appendDatabase}/>
122131
<button className={'btn btn-sm btn-secondary'} onClick={() => setConnectionVisible(false)}>
123132
Close
124133
</button>

resources/scripts/components/server/databases/DatabasesContainer.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,15 @@ import CreateDatabaseButton from '@/components/server/databases/CreateDatabaseBu
1212

1313
export default () => {
1414
const [ loading, setLoading ] = useState(true);
15-
const [ databases, setDatabases ] = useState<ServerDatabase[]>([]);
1615
const server = ServerContext.useStoreState(state => state.server.data!);
16+
const databases = ServerContext.useStoreState(state => state.databases.items);
17+
const { setDatabases, appendDatabase, removeDatabase } = ServerContext.useStoreActions(state => state.databases);
1718
const { addFlash, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
1819

1920
useEffect(() => {
21+
setLoading(!databases.length);
2022
clearFlashes('databases');
23+
2124
getServerDatabases(server.uuid)
2225
.then(databases => {
2326
setDatabases(databases);
@@ -43,8 +46,8 @@ export default () => {
4346
databases.map((database, index) => (
4447
<DatabaseRow
4548
key={database.id}
46-
database={database}
47-
onDelete={() => setDatabases(s => [ ...s.filter(d => d.id !== database.id) ])}
49+
databaseId={database.id}
50+
onDelete={() => removeDatabase(database)}
4851
className={index > 0 ? 'mt-1' : undefined}
4952
/>
5053
))
@@ -54,7 +57,7 @@ export default () => {
5457
</p>
5558
}
5659
<div className={'mt-6 flex justify-end'}>
57-
<CreateDatabaseButton onCreated={database => setDatabases(s => [ ...s, database ])}/>
60+
<CreateDatabaseButton onCreated={appendDatabase}/>
5861
</div>
5962
</React.Fragment>
6063
</CSSTransition>
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import React, { useState } from 'react';
2+
import rotateDatabasePassword from '@/api/server/rotateDatabasePassword';
3+
import { Actions, useStoreActions } from 'easy-peasy';
4+
import { ApplicationStore } from '@/state';
5+
import { ServerContext } from '@/state/server';
6+
import { ServerDatabase } from '@/api/server/getServerDatabases';
7+
import { httpErrorToHuman } from '@/api/http';
8+
import Button from '@/components/elements/Button';
9+
10+
export default ({ databaseId, onUpdate }: {
11+
databaseId: string;
12+
onUpdate: (database: ServerDatabase) => void;
13+
}) => {
14+
const [ loading, setLoading ] = useState(false);
15+
const { addFlash, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
16+
const server = ServerContext.useStoreState(state => state.server.data!);
17+
18+
if (!databaseId) {
19+
return null;
20+
}
21+
22+
const rotate = () => {
23+
setLoading(true);
24+
clearFlashes();
25+
26+
rotateDatabasePassword(server.uuid, databaseId)
27+
.then(database => onUpdate(database))
28+
.catch(error => {
29+
console.error(error);
30+
addFlash({
31+
type: 'error',
32+
title: 'Error',
33+
message: httpErrorToHuman(error),
34+
key: 'database-connection-modal',
35+
});
36+
})
37+
.then(() => setLoading(false));
38+
};
39+
40+
return (
41+
<Button className={'btn-secondary mr-2'} onClick={rotate} isLoading={loading}>
42+
Rotate Password
43+
</Button>
44+
);
45+
};

resources/scripts/state/server/index.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import getServer, { Server } from '@/api/server/getServer';
22
import { action, Action, createContextStore, thunk, Thunk } from 'easy-peasy';
33
import socket, { SocketStore } from './socket';
4+
import { ServerDatabase } from '@/api/server/getServerDatabases';
45

56
export type ServerStatus = 'offline' | 'starting' | 'stopping' | 'running';
67

@@ -32,8 +33,29 @@ const status: ServerStatusStore = {
3233
}),
3334
};
3435

36+
interface ServerDatabaseStore {
37+
items: ServerDatabase[];
38+
setDatabases: Action<ServerDatabaseStore, ServerDatabase[]>;
39+
appendDatabase: Action<ServerDatabaseStore, ServerDatabase>;
40+
removeDatabase: Action<ServerDatabaseStore, ServerDatabase>;
41+
}
42+
43+
const databases: ServerDatabaseStore = {
44+
items: [],
45+
setDatabases: action((state, payload) => {
46+
state.items = payload;
47+
}),
48+
appendDatabase: action((state, payload) => {
49+
state.items = state.items.filter(item => item.id !== payload.id).concat(payload);
50+
}),
51+
removeDatabase: action((state, payload) => {
52+
state.items = state.items.filter(item => item.id !== payload.id);
53+
}),
54+
};
55+
3556
export interface ServerStore {
3657
server: ServerDataStore;
58+
databases: ServerDatabaseStore;
3759
socket: SocketStore;
3860
status: ServerStatusStore;
3961
clearServerState: Action<ServerStore>;
@@ -43,8 +65,10 @@ export const ServerContext = createContextStore<ServerStore>({
4365
server,
4466
socket,
4567
status,
68+
databases,
4669
clearServerState: action(state => {
4770
state.server.data = undefined;
71+
state.databases.items = [];
4872

4973
if (state.socket.instance) {
5074
state.socket.instance.removeAllListeners();

0 commit comments

Comments
 (0)