1
1
mirror of https://github.com/pterodactyl/panel.git synced 2024-11-25 18:42:31 +01:00

Merge branch 'develop' into enhancement/wings-improved-server-loading

This commit is contained in:
Matthew Penner 2020-04-10 16:23:46 -06:00 committed by GitHub
commit 94d46affb8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
57 changed files with 602 additions and 1101 deletions

View File

@ -148,8 +148,8 @@ class NodeViewController extends Controller
public function servers(Request $request, Node $node)
{
$this->plainInject([
'node' => Collection::wrap($node->makeVisible('daemonSecret'))
->only(['scheme', 'fqdn', 'daemonListen', 'daemonSecret']),
'node' => Collection::wrap($node->makeVisible(['daemon_token_id', 'daemon_token']))
->only(['scheme', 'fqdn', 'daemonListen', 'daemon_token_id', 'daemon_token']),
]);
return $this->view->make('admin.nodes.view.servers', [

View File

@ -67,7 +67,7 @@ class StatisticsController extends Controller
$tokens = [];
foreach ($nodes as $node) {
$tokens[$node->id] = $node->daemonSecret;
$tokens[$node->id] = decrypt($node->daemon_token);
}
$this->injectJavascript([

View File

@ -145,7 +145,7 @@ class ServerTransferController extends Controller
->canOnlyBeUsedAfter($now->getTimestamp())
->expiresAt($now->addMinutes(15)->getTimestamp())
->relatedTo($server->uuid, true)
->getToken($signer, new Key($server->node->daemonSecret));
->getToken($signer, new Key($server->node->getDecryptedKey()));
// On the daemon transfer repository, make sure to set the node after the server
// because setServer() tells the repository to use the server's node and not the one

View File

@ -1,107 +0,0 @@
<?php
namespace Pterodactyl\Http\Controllers\Daemon;
use Cache;
use Illuminate\Http\Request;
use Pterodactyl\Models\Node;
use Illuminate\Http\Response;
use Illuminate\Http\JsonResponse;
use Pterodactyl\Http\Controllers\Controller;
use Pterodactyl\Repositories\Eloquent\ServerRepository;
use Pterodactyl\Events\Server\Installed as ServerInstalled;
use Illuminate\Contracts\Events\Dispatcher as EventDispatcher;
use Pterodactyl\Exceptions\Repository\RecordNotFoundException;
class ActionController extends Controller
{
/**
* @var \Illuminate\Contracts\Events\Dispatcher
*/
private $eventDispatcher;
/**
* @var \Pterodactyl\Repositories\Eloquent\ServerRepository
*/
private $repository;
/**
* ActionController constructor.
*
* @param \Pterodactyl\Repositories\Eloquent\ServerRepository $repository
* @param \Illuminate\Contracts\Events\Dispatcher $eventDispatcher
*/
public function __construct(ServerRepository $repository, EventDispatcher $eventDispatcher)
{
$this->eventDispatcher = $eventDispatcher;
$this->repository = $repository;
}
/**
* Handles install toggle request from daemon.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\JsonResponse
*
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
*/
public function markInstall(Request $request): JsonResponse
{
try {
/** @var \Pterodactyl\Models\Server $server */
$server = $this->repository->findFirstWhere([
'uuid' => $request->input('server'),
]);
} catch (RecordNotFoundException $exception) {
return JsonResponse::create([
'error' => 'No server by that ID was found on the system.',
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
if (! $server->relationLoaded('node')) {
$server->load('node');
}
$hmac = $request->input('signed');
$status = $request->input('installed');
if (! hash_equals(base64_decode($hmac), hash_hmac('sha256', $server->uuid, $server->getRelation('node')->daemonSecret, true))) {
return JsonResponse::create([
'error' => 'Signed HMAC was invalid.',
], Response::HTTP_FORBIDDEN);
}
$this->repository->update($server->id, [
'installed' => ($status === 'installed') ? 1 : 2,
], true, true);
// Only fire event if server installed successfully.
if ($status === 'installed') {
$this->eventDispatcher->dispatch(new ServerInstalled($server));
}
// Don't use a 204 here, the daemon is hard-checking for a 200 code.
return JsonResponse::create([]);
}
/**
* Handles configuration data request from daemon.
*
* @param \Illuminate\Http\Request $request
* @param string $token
* @return \Illuminate\Http\JsonResponse|\Illuminate\Http\Response
*/
public function configuration(Request $request, $token)
{
$nodeId = Cache::pull('Node:Configuration:' . $token);
if (is_null($nodeId)) {
return response()->json(['error' => 'token_invalid'], 403);
}
$node = Node::findOrFail($nodeId);
// Manually as getConfigurationAsJson() returns it in correct format already
return $node->getJsonConfiguration();
}
}

View File

@ -1,73 +0,0 @@
<?php
/**
* Pterodactyl - Panel
* Copyright (c) 2015 - 2017 Dane Everitt <dane@daneeveritt.com>.
*
* This software is licensed under the terms of the MIT license.
* https://opensource.org/licenses/MIT
*/
namespace Pterodactyl\Http\Controllers\Daemon;
use Storage;
use Pterodactyl\Models;
use Illuminate\Http\Request;
use Pterodactyl\Http\Controllers\Controller;
class PackController extends Controller
{
/**
* Pulls an install pack archive from the system.
*
* @param \Illuminate\Http\Request $request
* @param string $uuid
* @return \Illuminate\Http\JsonResponse|\Symfony\Component\HttpFoundation\BinaryFileResponse
*/
public function pull(Request $request, $uuid)
{
$pack = Models\Pack::where('uuid', $uuid)->first();
if (! $pack) {
return response()->json(['error' => 'No such pack.'], 404);
}
if (! Storage::exists('packs/' . $pack->uuid . '/archive.tar.gz')) {
return response()->json(['error' => 'There is no archive available for this pack.'], 503);
}
return response()->download(storage_path('app/packs/' . $pack->uuid . '/archive.tar.gz'));
}
/**
* Returns the hash information for a pack.
*
* @param \Illuminate\Http\Request $request
* @param string $uuid
* @return \Illuminate\Http\JsonResponse
*/
public function hash(Request $request, $uuid)
{
$pack = Models\Pack::where('uuid', $uuid)->first();
if (! $pack) {
return response()->json(['error' => 'No such pack.'], 404);
}
if (! Storage::exists('packs/' . $pack->uuid . '/archive.tar.gz')) {
return response()->json(['error' => 'There is no archive available for this pack.'], 503);
}
return response()->json([
'archive.tar.gz' => sha1_file(storage_path('app/packs/' . $pack->uuid . '/archive.tar.gz')),
]);
}
/**
* Pulls an update pack archive from the system.
*
* @param \Illuminate\Http\Request $request
*/
public function pullUpdate(Request $request)
{
}
}

View File

@ -38,7 +38,6 @@ use Illuminate\Foundation\Http\Middleware\CheckForMaintenanceMode;
use Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull;
use Pterodactyl\Http\Middleware\Api\Client\SubstituteClientApiBindings;
use Pterodactyl\Http\Middleware\Api\Application\AuthenticateApplicationUser;
use Pterodactyl\Http\Middleware\DaemonAuthenticate as OldDaemonAuthenticate;
class Kernel extends HttpKernel
{
@ -107,7 +106,6 @@ class Kernel extends HttpKernel
'server' => AccessingValidServer::class,
'subuser.auth' => AuthenticateAsSubuser::class,
'admin' => AdminAuthenticate::class,
'daemon-old' => OldDaemonAuthenticate::class,
'csrf' => VerifyCsrfToken::class,
'throttle' => ThrottleRequests::class,
'can' => Authorize::class,

View File

@ -4,6 +4,7 @@ namespace Pterodactyl\Http\Middleware\Api\Daemon;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Contracts\Encryption\Encrypter;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Pterodactyl\Contracts\Repository\NodeRepositoryInterface;
use Pterodactyl\Exceptions\Repository\RecordNotFoundException;
@ -25,14 +26,21 @@ class DaemonAuthenticate
'daemon.configuration',
];
/**
* @var \Illuminate\Contracts\Encryption\Encrypter
*/
private $encrypter;
/**
* DaemonAuthenticate constructor.
*
* @param \Illuminate\Contracts\Encryption\Encrypter $encrypter
* @param \Pterodactyl\Contracts\Repository\NodeRepositoryInterface $repository
*/
public function __construct(NodeRepositoryInterface $repository)
public function __construct(Encrypter $encrypter, NodeRepositoryInterface $repository)
{
$this->repository = $repository;
$this->encrypter = $encrypter;
}
/**
@ -50,20 +58,31 @@ class DaemonAuthenticate
return $next($request);
}
$token = $request->bearerToken();
if (is_null($token)) {
throw new HttpException(401, null, null, ['WWW-Authenticate' => 'Bearer']);
if (is_null($bearer = $request->bearerToken())) {
throw new HttpException(
401, 'Access this this endpoint must include an Authorization header.', null, ['WWW-Authenticate' => 'Bearer']
);
}
[$identifier, $token] = explode('.', $bearer);
try {
$node = $this->repository->findFirstWhere([['daemonSecret', '=', $token]]);
/** @var \Pterodactyl\Models\Node $node */
$node = $this->repository->findFirstWhere([
'daemon_token_id' => $identifier,
]);
if (hash_equals((string) $this->encrypter->decrypt($node->daemon_token), $token)) {
$request->attributes->set('node', $node);
return $next($request);
}
} catch (RecordNotFoundException $exception) {
throw new AccessDeniedHttpException;
// Do nothing, we don't want to expose a node not existing at all.
}
$request->attributes->set('node', $node);
return $next($request);
throw new AccessDeniedHttpException(
'You are not authorized to access this resource.'
);
}
}

View File

@ -1,69 +0,0 @@
<?php
/**
* Pterodactyl - Panel
* Copyright (c) 2015 - 2017 Dane Everitt <dane@daneeveritt.com>.
*
* This software is licensed under the terms of the MIT license.
* https://opensource.org/licenses/MIT
*/
namespace Pterodactyl\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Pterodactyl\Contracts\Repository\NodeRepositoryInterface;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
class DaemonAuthenticate
{
/**
* An array of route names to not apply this middleware to.
*
* @var array
*/
private $except = [
'daemon.configuration',
];
/**
* @var \Pterodactyl\Contracts\Repository\NodeRepositoryInterface
*/
private $repository;
/**
* Create a new filter instance.
*
* @param \Pterodactyl\Contracts\Repository\NodeRepositoryInterface $repository
* @deprecated
*/
public function __construct(NodeRepositoryInterface $repository)
{
$this->repository = $repository;
}
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return mixed
*
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
* @throws \Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException
*/
public function handle(Request $request, Closure $next)
{
if (in_array($request->route()->getName(), $this->except)) {
return $next($request);
}
if (! $request->header('X-Access-Node')) {
throw new AccessDeniedHttpException;
}
$node = $this->repository->findFirstWhere(['daemonSecret' => $request->header('X-Access-Node')]);
$request->attributes->set('node', $node);
return $next($request);
}
}

View File

@ -3,11 +3,14 @@
namespace Pterodactyl\Models;
use Symfony\Component\Yaml\Yaml;
use Illuminate\Container\Container;
use Illuminate\Notifications\Notifiable;
use Pterodactyl\Models\Traits\Searchable;
use Illuminate\Contracts\Encryption\Encrypter;
/**
* @property int $id
* @property string $uuid
* @property bool $public
* @property string $name
* @property string $description
@ -21,7 +24,8 @@ use Pterodactyl\Models\Traits\Searchable;
* @property int $disk
* @property int $disk_overallocate
* @property int $upload_size
* @property string $daemonSecret
* @property string $daemon_token_id
* @property string $daemon_token
* @property int $daemonListen
* @property int $daemonSFTP
* @property string $daemonBase
@ -43,7 +47,8 @@ class Node extends Model
*/
const RESOURCE_NAME = 'node';
const DAEMON_SECRET_LENGTH = 36;
const DAEMON_TOKEN_ID_LENGTH = 16;
const DAEMON_TOKEN_LENGTH = 64;
/**
* The table associated with the model.
@ -57,7 +62,7 @@ class Node extends Model
*
* @var array
*/
protected $hidden = ['daemonSecret'];
protected $hidden = ['daemon_token_id', 'daemon_token'];
/**
* Cast values to correct type.
@ -84,8 +89,7 @@ class Node extends Model
'public', 'name', 'location_id',
'fqdn', 'scheme', 'behind_proxy',
'memory', 'memory_overallocate', 'disk',
'disk_overallocate', 'upload_size',
'daemonSecret', 'daemonBase',
'disk_overallocate', 'upload_size', 'daemonBase',
'daemonSFTP', 'daemonListen',
'description', 'maintenance_mode',
];
@ -153,12 +157,15 @@ class Node extends Model
/**
* Returns the configuration as an array.
*
* @return string
* @return array
*/
private function getConfiguration()
public function getConfiguration()
{
return [
'debug' => false,
'uuid' => $this->uuid,
'token_id' => $this->daemon_token_id,
'token' => Container::getInstance()->make(Encrypter::class)->decrypt($this->daemon_token),
'api' => [
'host' => '0.0.0.0',
'port' => $this->daemonListen,
@ -202,7 +209,6 @@ class Node extends Model
'check_interval' => 100,
],
'remote' => route('index'),
'token' => $this->daemonSecret,
];
}
@ -211,17 +217,32 @@ class Node extends Model
*
* @return string
*/
public function getYamlConfiguration() {
public function getYamlConfiguration()
{
return Yaml::dump($this->getConfiguration(), 4, 2);
}
/**
/**
* Returns the configuration in JSON format.
*
* @param bool $pretty
* @return string
*/
public function getJsonConfiguration(bool $pretty = false)
{
return json_encode($this->getConfiguration(), $pretty ? JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT : JSON_UNESCAPED_SLASHES);
}
/**
* Helper function to return the decrypted key for a node.
*
* @return string
*/
public function getJsonConfiguration(bool $pretty = false) {
return json_encode($this->getConfiguration(), $pretty ? JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT : JSON_UNESCAPED_SLASHES);
public function getDecryptedKey(): string
{
return (string) Container::getInstance()->make(Encrypter::class)->decrypt(
$this->daemon_token
);
}
/**

View File

@ -49,9 +49,5 @@ class RouteServiceProvider extends ServiceProvider
Route::middleware(['daemon'])->prefix('/api/remote')
->namespace($this->namespace . '\Api\Remote')
->group(base_path('routes/api-remote.php'));
Route::middleware(['web', 'daemon-old'])->prefix('/daemon')
->namespace($this->namespace . '\Daemon')
->group(base_path('routes/daemon.php'));
}
}

View File

@ -1,154 +0,0 @@
<?php
namespace Pterodactyl\Repositories\Daemon;
use RuntimeException;
use GuzzleHttp\Client;
use Pterodactyl\Models\Node;
use Pterodactyl\Models\Server;
use Illuminate\Foundation\Application;
use Pterodactyl\Contracts\Repository\NodeRepositoryInterface;
use Pterodactyl\Contracts\Repository\Daemon\BaseRepositoryInterface;
abstract class BaseRepository implements BaseRepositoryInterface
{
/**
* @var \Illuminate\Foundation\Application
*/
private $app;
/**
* @var \Pterodactyl\Models\Server
*/
private $server;
/**
* @var string|null
*/
private $token;
/**
* @var \Pterodactyl\Models\Node|null
*/
private $node;
/**
* @var \Pterodactyl\Contracts\Repository\NodeRepositoryInterface
*/
private $nodeRepository;
/**
* BaseRepository constructor.
*
* @param \Illuminate\Foundation\Application $app
* @param \Pterodactyl\Contracts\Repository\NodeRepositoryInterface $nodeRepository
*/
public function __construct(Application $app, NodeRepositoryInterface $nodeRepository)
{
$this->app = $app;
$this->nodeRepository = $nodeRepository;
}
/**
* Set the node model to be used for this daemon connection.
*
* @param \Pterodactyl\Models\Node $node
* @return $this
*/
public function setNode(Node $node)
{
$this->node = $node;
return $this;
}
/**
* Return the node model being used.
*
* @return \Pterodactyl\Models\Node|null
*/
public function getNode()
{
return $this->node;
}
/**
* Set the Server model to use when requesting information from the Daemon.
*
* @param \Pterodactyl\Models\Server $server
* @return $this
*/
public function setServer(Server $server)
{
$this->server = $server;
return $this;
}
/**
* Return the Server model.
*
* @return \Pterodactyl\Models\Server|null
*/
public function getServer()
{
return $this->server;
}
/**
* Set the token to be used in the X-Access-Token header for requests to the daemon.
*
* @param string $token
* @return $this
*/
public function setToken(string $token)
{
$this->token = $token;
return $this;
}
/**
* Return the access token being used for requests.
*
* @return string|null
*/
public function getToken()
{
return $this->token;
}
/**
* Return an instance of the Guzzle HTTP Client to be used for requests.
*
* @param array $headers
* @return \GuzzleHttp\Client
*/
public function getHttpClient(array $headers = []): Client
{
// If no node is set, load the relationship onto the Server model
// and pass that to the setNode function.
if (! $this->getNode() instanceof Node) {
if (! $this->getServer() instanceof Server) {
throw new RuntimeException('An instance of ' . Node::class . ' or ' . Server::class . ' must be set on this repository in order to return a client.');
}
$this->getServer()->loadMissing('node');
$this->setNode($this->getServer()->getRelation('node'));
}
if ($this->getServer() instanceof Server) {
$headers['X-Access-Server'] = $this->getServer()->uuid;
}
$headers['X-Access-Token'] = $this->getToken() ?? $this->getNode()->daemonSecret;
return new Client([
'verify' => config('app.env') === 'production',
'base_uri' => sprintf('%s://%s:%s/v1/', $this->getNode()->scheme, $this->getNode()->fqdn, $this->getNode()->daemonListen),
'timeout' => config('pterodactyl.guzzle.timeout'),
'connect_timeout' => config('pterodactyl.guzzle.connect_timeout'),
'headers' => $headers,
]);
}
}

View File

@ -1,25 +0,0 @@
<?php
namespace Pterodactyl\Repositories\Daemon;
use Psr\Http\Message\ResponseInterface;
use Pterodactyl\Contracts\Repository\Daemon\CommandRepositoryInterface;
class CommandRepository extends BaseRepository implements CommandRepositoryInterface
{
/**
* Send a command to a server.
*
* @param string $command
* @return \Psr\Http\Message\ResponseInterface
* @throws \GuzzleHttp\Exception\GuzzleException
*/
public function send(string $command): ResponseInterface
{
return $this->getHttpClient()->request('POST', 'server/command', [
'json' => [
'command' => $command,
],
]);
}
}

View File

@ -1,46 +0,0 @@
<?php
namespace Pterodactyl\Repositories\Daemon;
use Psr\Http\Message\ResponseInterface;
use Pterodactyl\Contracts\Repository\Daemon\ConfigurationRepositoryInterface;
class ConfigurationRepository extends BaseRepository implements ConfigurationRepositoryInterface
{
/**
* Update the configuration details for the specified node using data from the database.
*
* @param array $overrides
* @return \Psr\Http\Message\ResponseInterface
* @throws \GuzzleHttp\Exception\GuzzleException
*/
public function update(array $overrides = []): ResponseInterface
{
$node = $this->getNode();
$structure = [
'web' => [
'listen' => $node->daemonListen,
'ssl' => [
'enabled' => (! $node->behind_proxy && $node->scheme === 'https'),
],
],
'sftp' => [
'path' => $node->daemonBase,
'port' => $node->daemonSFTP,
],
'remote' => [
'base' => config('app.url'),
],
'uploads' => [
'size_limit' => $node->upload_size,
],
'keys' => [
$node->daemonSecret,
],
];
return $this->getHttpClient()->request('PATCH', 'config', [
'json' => array_merge($structure, $overrides),
]);
}
}

View File

@ -1,104 +0,0 @@
<?php
namespace Pterodactyl\Repositories\Daemon;
use stdClass;
use RuntimeException;
use Psr\Http\Message\ResponseInterface;
use Pterodactyl\Contracts\Repository\Daemon\FileRepositoryInterface;
class FileRepository extends BaseRepository implements FileRepositoryInterface
{
/**
* Return stat information for a given file.
*
* @param string $path
* @return \stdClass
*
* @throws \GuzzleHttp\Exception\GuzzleException
*/
public function getFileStat(string $path): stdClass
{
$file = str_replace('\\', '/', pathinfo($path));
$file['dirname'] = in_array($file['dirname'], ['.', './', '/']) ? null : trim($file['dirname'], '/') . '/';
$response = $this->getHttpClient()->request('GET', sprintf(
'server/file/stat/%s',
rawurlencode($file['dirname'] . $file['basename'])
));
return json_decode($response->getBody());
}
/**
* Return the contents of a given file if it can be edited in the Panel.
*
* @param string $path
* @return string
*
* @throws \GuzzleHttp\Exception\GuzzleException
*/
public function getContent(string $path): string
{
$file = str_replace('\\', '/', pathinfo($path));
$file['dirname'] = in_array($file['dirname'], ['.', './', '/']) ? null : trim($file['dirname'], '/') . '/';
$response = $this->getHttpClient()->request('GET', sprintf(
'server/file/f/%s',
rawurlencode($file['dirname'] . $file['basename'])
));
return object_get(json_decode($response->getBody()), 'content');
}
/**
* Save new contents to a given file.
*
* @param string $path
* @param string $content
* @return \Psr\Http\Message\ResponseInterface
*
* @throws \GuzzleHttp\Exception\GuzzleException
*/
public function putContent(string $path, string $content): ResponseInterface
{
$file = str_replace('\\', '/', pathinfo($path));
$file['dirname'] = in_array($file['dirname'], ['.', './', '/']) ? null : trim($file['dirname'], '/') . '/';
return $this->getHttpClient()->request('POST', 'server/file/save', [
'json' => [
'path' => rawurlencode($file['dirname'] . $file['basename']),
'content' => $content,
],
]);
}
/**
* Return a directory listing for a given path.
*
* @param string $path
* @return array
*
* @throws \GuzzleHttp\Exception\GuzzleException
*/
public function getDirectory(string $path): array
{
$response = $this->getHttpClient()->request('GET', sprintf('server/directory/%s', rawurlencode($path)));
return json_decode($response->getBody());
}
/**
* Creates a new directory for the server in the given $path.
*
* @param string $name
* @param string $path
* @return \Psr\Http\Message\ResponseInterface
*
* @throws \RuntimeException
*/
public function createDirectory(string $name, string $path): ResponseInterface
{
throw new RuntimeException('Not implemented.');
}
}

View File

@ -1,36 +0,0 @@
<?php
namespace Pterodactyl\Repositories\Daemon;
use Psr\Http\Message\ResponseInterface;
use Pterodactyl\Contracts\Repository\Daemon\PowerRepositoryInterface;
use Pterodactyl\Exceptions\Repository\Daemon\InvalidPowerSignalException;
class PowerRepository extends BaseRepository implements PowerRepositoryInterface
{
/**
* Send a power signal to a server.
*
* @param string $signal
* @return \Psr\Http\Message\ResponseInterface
*
* @throws InvalidPowerSignalException
* @throws \GuzzleHttp\Exception\GuzzleException
*/
public function sendSignal(string $signal): ResponseInterface
{
switch ($signal) {
case self::SIGNAL_START:
case self::SIGNAL_STOP:
case self::SIGNAL_RESTART:
case self::SIGNAL_KILL:
return $this->getHttpClient()->request('PUT', 'server/power', [
'json' => [
'action' => $signal,
],
]);
default:
throw new InvalidPowerSignalException('The signal "' . $signal . '" is not defined and could not be processed.');
}
}
}

View File

@ -1,134 +0,0 @@
<?php
namespace Pterodactyl\Repositories\Daemon;
use Webmozart\Assert\Assert;
use Psr\Http\Message\ResponseInterface;
use Pterodactyl\Contracts\Repository\Daemon\ServerRepositoryInterface;
class ServerRepository extends BaseRepository implements ServerRepositoryInterface
{
/**
* Create a new server on the daemon for the panel.
*
* @param array $structure
* @param array $overrides
* @return \Psr\Http\Message\ResponseInterface
*
* @throws \GuzzleHttp\Exception\GuzzleException
*/
public function create(array $structure, array $overrides = []): ResponseInterface
{
foreach ($overrides as $key => $value) {
$structure[$key] = value($value);
}
return $this->getHttpClient()->request('POST', 'servers', [
'json' => $structure,
]);
}
/**
* Update server details on the daemon.
*
* @param array $data
* @return \Psr\Http\Message\ResponseInterface
* @throws \GuzzleHttp\Exception\GuzzleException
*/
public function update(array $data): ResponseInterface
{
return $this->getHttpClient()->request('PATCH', 'server', [
'json' => $data,
]);
}
/**
* Mark a server to be reinstalled on the system.
*
* @param array|null $data
* @return \Psr\Http\Message\ResponseInterface
* @throws \GuzzleHttp\Exception\GuzzleException
*/
public function reinstall(array $data = null): ResponseInterface
{
return $this->getHttpClient()->request('POST', 'server/reinstall', [
'json' => $data ?? [],
]);
}
/**
* Mark a server as needing a container rebuild the next time the server is booted.
*
* @return \Psr\Http\Message\ResponseInterface
* @throws \GuzzleHttp\Exception\GuzzleException
*/
public function rebuild(): ResponseInterface
{
return $this->getHttpClient()->request('POST', 'server/rebuild');
}
/**
* Suspend a server on the daemon.
*
* @return \Psr\Http\Message\ResponseInterface
* @throws \GuzzleHttp\Exception\GuzzleException
*/
public function suspend(): ResponseInterface
{
return $this->getHttpClient()->request('POST', 'server/suspend');
}
/**
* Un-suspend a server on the daemon.
*
* @return \Psr\Http\Message\ResponseInterface
* @throws \GuzzleHttp\Exception\GuzzleException
*/
public function unsuspend(): ResponseInterface
{
return $this->getHttpClient()->request('POST', 'server/unsuspend');
}
/**
* Delete a server on the daemon.
*
* @return \Psr\Http\Message\ResponseInterface
* @throws \GuzzleHttp\Exception\GuzzleException
*/
public function delete(): ResponseInterface
{
return $this->getHttpClient()->request('DELETE', 'servers');
}
/**
* Return details on a specific server.
*
* @return \Psr\Http\Message\ResponseInterface
* @throws \GuzzleHttp\Exception\GuzzleException
*/
public function details(): ResponseInterface
{
return $this->getHttpClient()->request('GET', 'server');
}
/**
* Revoke an access key on the daemon before the time is expired.
*
* @param string|array $key
* @return \Psr\Http\Message\ResponseInterface
*
* @throws \GuzzleHttp\Exception\GuzzleException
*/
public function revokeAccessKey($key): ResponseInterface
{
if (is_array($key)) {
return $this->getHttpClient()->request('POST', 'keys/batch-delete', [
'json' => ['keys' => $key],
]);
}
Assert::stringNotEmpty($key, 'First argument passed to revokeAccessKey must be a non-empty string or array, received %s.');
return $this->getHttpClient()->request('DELETE', 'keys/' . $key);
}
}

View File

@ -183,7 +183,7 @@ class NodeRepository extends EloquentRepository implements NodeRepositoryInterfa
public function getNodeWithResourceUsage(int $node_id): Node
{
$instance = $this->getBuilder()
->select(['nodes.id', 'nodes.fqdn', 'nodes.scheme', 'nodes.daemonSecret', 'nodes.daemonListen', 'nodes.memory', 'nodes.disk', 'nodes.memory_overallocate', 'nodes.disk_overallocate'])
->select(['nodes.id', 'nodes.fqdn', 'nodes.scheme', 'nodes.daemon_token', 'nodes.daemonListen', 'nodes.memory', 'nodes.disk', 'nodes.memory_overallocate', 'nodes.disk_overallocate'])
->selectRaw('IFNULL(SUM(servers.memory), 0) as sum_memory, IFNULL(SUM(servers.disk), 0) as sum_disk')
->leftJoin('servers', 'servers.node_id', '=', 'nodes.id')
->where('nodes.id', $node_id);

View File

@ -23,4 +23,22 @@ class DaemonConfigurationRepository extends DaemonRepository
return json_decode($response->getBody()->__toString(), true);
}
/**
* Updates the configuration information for a daemon.
*
* @param array $attributes
* @return \Psr\Http\Message\ResponseInterface
* @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException
*/
public function update(array $attributes = [])
{
try {
return $this->getHttpClient()->post(
'/api/update', array_merge($this->node->getConfiguration(), $attributes)
);
} catch (TransferException $exception) {
throw new DaemonConnectionException($exception);
}
}
}

View File

@ -79,7 +79,7 @@ abstract class DaemonRepository
'timeout' => config('pterodactyl.guzzle.timeout'),
'connect_timeout' => config('pterodactyl.guzzle.connect_timeout'),
'headers' => array_merge($headers, [
'Authorization' => 'Bearer ' . $this->node->daemonSecret,
'Authorization' => 'Bearer ' . $this->node->getDecryptedKey(),
'Accept' => 'application/json',
'Content-Type' => 'application/json',
]),

View File

@ -1,33 +1,34 @@
<?php
/**
* Pterodactyl - Panel
* Copyright (c) 2015 - 2017 Dane Everitt <dane@daneeveritt.com>.
*
* This software is licensed under the terms of the MIT license.
* https://opensource.org/licenses/MIT
*/
namespace Pterodactyl\Services\Nodes;
use Illuminate\Support\Str;
use Pterodactyl\Models\Node;
use Illuminate\Encryption\Encrypter;
use Pterodactyl\Contracts\Repository\NodeRepositoryInterface;
class NodeCreationService
{
const DAEMON_SECRET_LENGTH = 36;
/**
* @var \Pterodactyl\Contracts\Repository\NodeRepositoryInterface
*/
protected $repository;
/**
* @var \Illuminate\Encryption\Encrypter
*/
private $encrypter;
/**
* CreationService constructor.
*
* @param \Illuminate\Encryption\Encrypter $encrypter
* @param \Pterodactyl\Contracts\Repository\NodeRepositoryInterface $repository
*/
public function __construct(NodeRepositoryInterface $repository)
public function __construct(Encrypter $encrypter, NodeRepositoryInterface $repository)
{
$this->repository = $repository;
$this->encrypter = $encrypter;
}
/**
@ -40,8 +41,9 @@ class NodeCreationService
*/
public function handle(array $data)
{
$data['daemonSecret'] = str_random(self::DAEMON_SECRET_LENGTH);
$data['daemon_token'] = Str::random(Node::DAEMON_TOKEN_LENGTH);
$data['daemon_token_id'] = $this->encrypter->encrypt(Str::random(Node::DAEMON_TOKEN_ID_LENGTH));
return $this->repository->create($data);
return $this->repository->create($data, true, true);
}
}

View File

@ -69,6 +69,6 @@ class NodeJWTService
return $builder
->withClaim('unique_id', Str::random(16))
->getToken($signer, new Key($node->daemonSecret));
->getToken($signer, new Key($node->getDecryptedKey()));
}
}

View File

@ -2,12 +2,15 @@
namespace Pterodactyl\Services\Nodes;
use Illuminate\Support\Str;
use Pterodactyl\Models\Node;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Contracts\Encryption\Encrypter;
use Pterodactyl\Repositories\Daemon\ConfigurationRepository;
use Pterodactyl\Contracts\Repository\NodeRepositoryInterface;
use Pterodactyl\Repositories\Wings\DaemonConfigurationRepository;
use Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException;
use Pterodactyl\Exceptions\Service\Node\ConfigurationNotPersistedException;
@ -18,31 +21,39 @@ class NodeUpdateService
*/
private $connection;
/**
* @var \Pterodactyl\Contracts\Repository\Daemon\ConfigurationRepositoryInterface
*/
private $configRepository;
/**
* @var \Pterodactyl\Contracts\Repository\NodeRepositoryInterface
*/
private $repository;
/**
* @var \Pterodactyl\Repositories\Wings\DaemonConfigurationRepository
*/
private $configurationRepository;
/**
* @var \Illuminate\Contracts\Encryption\Encrypter
*/
private $encrypter;
/**
* UpdateService constructor.
*
* @param \Illuminate\Database\ConnectionInterface $connection
* @param \Pterodactyl\Repositories\Daemon\ConfigurationRepository $configurationRepository
* @param \Illuminate\Contracts\Encryption\Encrypter $encrypter
* @param \Pterodactyl\Repositories\Wings\DaemonConfigurationRepository $configurationRepository
* @param \Pterodactyl\Contracts\Repository\NodeRepositoryInterface $repository
*/
public function __construct(
ConnectionInterface $connection,
ConfigurationRepository $configurationRepository,
Encrypter $encrypter,
DaemonConfigurationRepository $configurationRepository,
NodeRepositoryInterface $repository
) {
$this->connection = $connection;
$this->configRepository = $configurationRepository;
$this->repository = $repository;
$this->configurationRepository = $configurationRepository;
$this->encrypter = $encrypter;
}
/**
@ -58,13 +69,14 @@ class NodeUpdateService
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
* @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException
* @throws \Pterodactyl\Exceptions\Service\Node\ConfigurationNotPersistedException
*
* @throws \GuzzleHttp\Exception\GuzzleException
*/
public function handle(Node $node, array $data, bool $resetToken = false)
{
if ($resetToken) {
$data['daemonSecret'] = str_random(Node::DAEMON_SECRET_LENGTH);
$data['daemon_token'] = Str::random(Node::DAEMON_TOKEN_LENGTH);
$data['daemon_token_id'] = $this->encrypter->encrypt(
Str::random(Node::DAEMON_TOKEN_ID_LENGTH)
);
}
$this->connection->beginTransaction();
@ -77,14 +89,15 @@ class NodeUpdateService
// We need to clone the new model and set it's authentication token to be the
// old one so we can connect. Then we will pass the new token through as an
// override on the call.
$cloned = $updatedModel->replicate(['daemonSecret']);
$cloned->setAttribute('daemonSecret', $node->getAttribute('daemonSecret'));
$cloned = $updatedModel->replicate(['daemon_token']);
$cloned->setAttribute('daemon_token', $node->getAttribute('daemon_token'));
$this->configRepository->setNode($cloned)->update([
'keys' => [$data['daemonSecret']],
$this->configurationRepository->setNode($cloned)->update([
'daemon_token_id' => $updatedModel->daemon_token_id,
'daemon_token' => $updatedModel->getDecryptedKey(),
]);
} else {
$this->configRepository->setNode($updatedModel)->update();
$this->configurationRepository->setNode($updatedModel)->update();
}
$this->connection->commit();

View File

@ -1,7 +1,10 @@
<?php
use Ramsey\Uuid\Uuid;
use Cake\Chronos\Chronos;
use Illuminate\Support\Str;
use Faker\Generator as Faker;
use Pterodactyl\Models\Node;
use Pterodactyl\Models\ApiKey;
/*
@ -80,6 +83,7 @@ $factory->define(Pterodactyl\Models\Location::class, function (Faker $faker) {
$factory->define(Pterodactyl\Models\Node::class, function (Faker $faker) {
return [
'id' => $faker->unique()->randomNumber(),
'uuid' => Uuid::uuid4()->toString(),
'public' => true,
'name' => $faker->firstName,
'fqdn' => $faker->ipv4,
@ -90,10 +94,11 @@ $factory->define(Pterodactyl\Models\Node::class, function (Faker $faker) {
'disk' => 10240,
'disk_overallocate' => 0,
'upload_size' => 100,
'daemonSecret' => $faker->uuid,
'daemon_token_id' => Str::random(Node::DAEMON_TOKEN_ID_LENGTH),
'daemon_token' => Str::random(Node::DAEMON_TOKEN_LENGTH),
'daemonListen' => 8080,
'daemonSFTP' => 2022,
'daemonBase' => '/srv/daemon',
'daemonBase' => '/srv/daemon-data',
];
});

View File

@ -18,15 +18,21 @@ class MergePermissionsTableIntoSubusers extends Migration
$table->json('permissions')->nullable()->after('server_id');
});
DB::statement('
UPDATE subusers as s
LEFT JOIN (
SELECT subuser_id, JSON_ARRAYAGG(permission) as permissions
FROM permissions
GROUP BY subuser_id
) as p ON p.subuser_id = s.id
SET s.permissions = p.permissions
');
$cursor = DB::table('permissions')
->select(['subuser_id'])
->selectRaw('GROUP_CONCAT(permission) as permissions')
->from('permissions')
->groupBy(['subuser_id'])
->cursor();
DB::transaction(function () use (&$cursor) {
$cursor->each(function ($datum) {
DB::update('UPDATE subusers SET permissions = ? WHERE id = ?', [
json_encode(explode(',', $datum->permissions)),
$datum->subuser_id,
]);
});
});
}
/**

View File

@ -13,11 +13,10 @@ class AddTableServerTransfers extends Migration
*/
public function up()
{
Schema::dropIfExists('server_transfers');
Schema::create('server_transfers', function (Blueprint $table) {
$table->increments('id');
$table->integer('server_id')->unsigned();
$table->tinyInteger('successful')->unsigned()->default(0);
$table->integer('old_node')->unsigned();
$table->integer('new_node')->unsigned();
$table->integer('old_allocation')->unsigned();
@ -25,10 +24,8 @@ class AddTableServerTransfers extends Migration
$table->string('old_additional_allocations')->nullable();
$table->string('new_additional_allocations')->nullable();
$table->timestamps();
});
Schema::table('server_transfers', function (Blueprint $table) {
$table->foreign('server_id')->references('id')->on('servers');
$table->foreign('server_id')->references('id')->on('servers')->onDelete('cascade');
});
}

View File

@ -1,32 +0,0 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class AddSuccessfulColumnToServerTransfers extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('server_transfers', function (Blueprint $table) {
$table->tinyInteger('successful')->unsigned()->default(0);
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('server_transfers', function (Blueprint $table) {
$table->dropColumn('successful');
});
}
}

View File

@ -0,0 +1,84 @@
<?php
use Ramsey\Uuid\Uuid;
use Illuminate\Support\Facades\DB;
use Illuminate\Container\Container;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Contracts\Encryption\Encrypter;
class StoreNodeTokensAsEncryptedValue extends Migration
{
/**
* Run the migrations.
*
* @return void
* @throws \Exception
*/
public function up()
{
Schema::table('nodes', function (Blueprint $table) {
$table->dropUnique(['daemonSecret']);
});
Schema::table('nodes', function (Blueprint $table) {
$table->char('uuid', 36)->after('id')->unique();
$table->char('daemon_token_id', 16)->after('upload_size')->unique();
$table->renameColumn('daemonSecret', 'daemon_token');
});
Schema::table('nodes', function (Blueprint $table) {
$table->text('daemon_token')->change();
});
DB::transaction(function () {
/** @var \Illuminate\Contracts\Encryption\Encrypter $encrypter */
$encrypter = Container::getInstance()->make(Encrypter::class);
foreach (DB::select('SELECT id, daemon_token FROM nodes') as $datum) {
DB::update('UPDATE nodes SET uuid = ?, daemon_token_id = ?, daemon_token = ? WHERE id = ?', [
Uuid::uuid4()->toString(),
substr($datum->daemon_token, 0, 16),
$encrypter->encrypt(substr($datum->daemon_token, 16)),
$datum->id,
]);
}
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
DB::transaction(function () {
/** @var \Illuminate\Contracts\Encryption\Encrypter $encrypter */
$encrypter = Container::getInstance()->make(Encrypter::class);
foreach (DB::select('SELECT id, daemon_token_id, daemon_token FROM nodes') as $datum) {
DB::update('UPDATE nodes SET daemon_token = ? WHERE id = ?', [
$datum->daemon_token_id . $encrypter->decrypt($datum->daemon_token),
$datum->id,
]);
}
});
Schema::table('nodes', function (Blueprint $table) {
$table->dropUnique(['uuid']);
$table->dropUnique(['daemon_token_id']);
});
Schema::table('nodes', function (Blueprint $table) {
$table->dropColumn(['uuid', 'daemon_token_id']);
$table->renameColumn('daemon_token', 'daemonSecret');
});
Schema::table('nodes', function (Blueprint $table) {
$table->string('daemonSecret', 36)->change();
$table->unique(['daemonSecret']);
});
}
}

View File

@ -1,6 +1,8 @@
import axios, { AxiosInstance } from 'axios';
import { store } from '@/state';
const http: AxiosInstance = axios.create({
timeout: 20000,
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json',
@ -9,6 +11,18 @@ const http: AxiosInstance = axios.create({
},
});
http.interceptors.request.use(req => {
store.getActions().progress.startContinuous();
return req;
});
http.interceptors.response.use(resp => {
store.getActions().progress.setComplete();
return resp;
});
// If we have a phpdebugbar instance registered at this point in time go
// ahead and route the response data through to it so things show up.
// @ts-ignore

View File

@ -9,6 +9,7 @@ import AuthenticationRouter from '@/routers/AuthenticationRouter';
import { Provider } from 'react-redux';
import { SiteSettings } from '@/state/settings';
import { DefaultTheme, ThemeProvider } from 'styled-components';
import ProgressBar from '@/components/elements/ProgressBar';
interface ExtendedWindow extends Window {
SiteConfiguration?: SiteSettings;
@ -57,6 +58,7 @@ const App = () => {
<ThemeProvider theme={theme}>
<StoreProvider store={store}>
<Provider store={store}>
<ProgressBar/>
<div className={'mx-auto w-auto'}>
<BrowserRouter basename={'/'} key={'root-router'}>
<Switch>

View File

@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faServer } from '@fortawesome/free-solid-svg-icons/faServer';
import { faEthernet } from '@fortawesome/free-solid-svg-icons/faEthernet';
@ -21,18 +21,19 @@ const isAlarmState = (current: number, limit: number): boolean => {
};
export default ({ server, className }: { server: Server; className: string | undefined }) => {
const interval = useRef<number>(null);
const [ stats, setStats ] = useState<ServerStats | null>(null);
const getStats = () => getServerResourceUsage(server.uuid).then(data => setStats(data));
useEffect(() => {
let interval: any = null;
getStats().then(() => {
interval = setInterval(() => getStats(), 20000);
// @ts-ignore
interval.current = setInterval(() => getStats(), 20000);
});
return () => {
interval && clearInterval(interval);
interval.current && clearInterval(interval.current);
};
}, []);

View File

@ -1,19 +0,0 @@
import React from 'react';
import Spinner from '@/components/elements/Spinner';
import { CSSTransition } from 'react-transition-group';
interface Props {
visible: boolean;
children?: React.ReactChild;
}
const ListRefreshIndicator = ({ visible, children }: Props) => (
<CSSTransition timeout={250} in={visible} appear={true} unmountOnExit={true} classNames={'fade'}>
<div className={'flex items-center mb-2'}>
<Spinner size={'tiny'}/>
<p className={'ml-2 text-sm text-neutral-400'}>{children || 'Refreshing listing...'}</p>
</div>
</CSSTransition>
);
export default ListRefreshIndicator;

View File

@ -0,0 +1,73 @@
import React, { useEffect, useRef, useState } from 'react';
import styled from 'styled-components';
import { useStoreActions, useStoreState } from 'easy-peasy';
import { randomInt } from '@/helpers';
import { CSSTransition } from 'react-transition-group';
const BarFill = styled.div`
${tw`h-full bg-cyan-400`};
transition: 250ms ease-in-out;
box-shadow: 0 -2px 10px 2px hsl(178, 78%, 57%);
`;
export default () => {
const interval = useRef<number>(null);
const timeout = useRef<number>(null);
const [ visible, setVisible ] = useState(false);
const progress = useStoreState(state => state.progress.progress);
const continuous = useStoreState(state => state.progress.continuous);
const setProgress = useStoreActions(actions => actions.progress.setProgress);
useEffect(() => {
return () => {
timeout.current && clearTimeout(timeout.current);
interval.current && clearInterval(interval.current);
};
}, []);
useEffect(() => {
setVisible((progress || 0) > 0);
if (progress === 100) {
// @ts-ignore
timeout.current = setTimeout(() => setProgress(undefined), 500);
}
}, [ progress ]);
useEffect(() => {
if (!continuous) {
interval.current && clearInterval(interval.current);
return;
}
if (!progress || progress === 0) {
setProgress(randomInt(20, 30));
}
}, [ continuous ]);
useEffect(() => {
if (continuous) {
interval.current && clearInterval(interval.current);
if ((progress || 0) >= 90) {
setProgress(90);
} else {
// @ts-ignore
interval.current = setTimeout(() => setProgress(progress + randomInt(1, 5)), 500);
}
}
}, [ progress, continuous ]);
return (
<div className={'w-full fixed'} style={{ height: '2px' }}>
<CSSTransition
timeout={250}
appear={true}
in={visible}
unmountOnExit={true}
classNames={'fade'}
>
<BarFill style={{ width: progress === undefined ? '100%' : `${progress}%` }}/>
</CSSTransition>
</div>
);
};

View File

@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react';
import Spinner from '@/components/elements/Spinner';
import getServerBackups, { ServerBackup } from '@/api/server/backups/getServerBackups';
import getServerBackups from '@/api/server/backups/getServerBackups';
import useServer from '@/plugins/useServer';
import useFlash from '@/plugins/useFlash';
import { httpErrorToHuman } from '@/api/http';
@ -9,7 +9,6 @@ import CreateBackupButton from '@/components/server/backups/CreateBackupButton';
import FlashMessageRender from '@/components/FlashMessageRender';
import BackupRow from '@/components/server/backups/BackupRow';
import { ServerContext } from '@/state/server';
import ListRefreshIndicator from '@/components/elements/ListRefreshIndicator';
export default () => {
const { uuid } = useServer();
@ -36,7 +35,6 @@ export default () => {
return (
<div className={'mt-10 mb-6'}>
<ListRefreshIndicator visible={loading}/>
<FlashMessageRender byKey={'backups'} className={'mb-4'}/>
{!backups.length ?
<p className="text-center text-sm text-neutral-400">

View File

@ -1,15 +1,14 @@
import React, { useState } from 'react';
import { ServerDatabase } from '@/api/server/getServerDatabases';
import Modal from '@/components/elements/Modal';
import { Form, Formik, FormikHelpers } from 'formik';
import Field from '@/components/elements/Field';
import { object, string } from 'yup';
import createServerDatabase from '@/api/server/createServerDatabase';
import { ServerContext } from '@/state/server';
import { Actions, useStoreActions } from 'easy-peasy';
import { ApplicationStore } from '@/state';
import { httpErrorToHuman } from '@/api/http';
import FlashMessageRender from '@/components/FlashMessageRender';
import useFlash from '@/plugins/useFlash';
import useServer from '@/plugins/useServer';
interface Values {
databaseName: string;
@ -27,28 +26,25 @@ const schema = object().shape({
.matches(/^([1-9]{1,3}|%)(\.([0-9]{1,3}|%))?(\.([0-9]{1,3}|%))?(\.([0-9]{1,3}|%))?$/, 'A valid connection address must be provided.'),
});
export default ({ onCreated }: { onCreated: (database: ServerDatabase) => void }) => {
export default () => {
const { uuid } = useServer();
const { addError, clearFlashes } = useFlash();
const [ visible, setVisible ] = useState(false);
const { addFlash, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
const server = ServerContext.useStoreState(state => state.server.data!);
const appendDatabase = ServerContext.useStoreActions(actions => actions.databases.appendDatabase);
const submit = (values: Values, { setSubmitting }: FormikHelpers<Values>) => {
clearFlashes();
createServerDatabase(server.uuid, { ...values })
clearFlashes('database:create');
createServerDatabase(uuid, { ...values })
.then(database => {
onCreated(database);
appendDatabase(database);
setVisible(false);
})
.catch(error => {
console.log(error);
addFlash({
key: 'create-database-modal',
type: 'error',
title: 'Error',
message: httpErrorToHuman(error),
});
})
.then(() => setSubmitting(false));
addError({ key: 'database:create', message: httpErrorToHuman(error) });
setSubmitting(false);
});
};
return (
@ -69,7 +65,7 @@ export default ({ onCreated }: { onCreated: (database: ServerDatabase) => void }
setVisible(false);
}}
>
<FlashMessageRender byKey={'create-database-modal'} className={'mb-6'}/>
<FlashMessageRender byKey={'database:create'} className={'mb-6'}/>
<h3 className={'mb-6'}>Create new database</h3>
<Form className={'m-0'}>
<Field
@ -105,7 +101,7 @@ export default ({ onCreated }: { onCreated: (database: ServerDatabase) => void }
)
}
</Formik>
<button className={'btn btn-primary btn-lg'} onClick={() => setVisible(true)}>
<button className={'btn btn-primary btn-sm'} onClick={() => setVisible(true)}>
New Database
</button>
</React.Fragment>

View File

@ -9,31 +9,28 @@ import { Form, Formik, FormikHelpers } from 'formik';
import Field from '@/components/elements/Field';
import { object, string } from 'yup';
import FlashMessageRender from '@/components/FlashMessageRender';
import { Actions, useStoreActions } from 'easy-peasy';
import { ApplicationStore } from '@/state';
import { ServerContext } from '@/state/server';
import deleteServerDatabase from '@/api/server/deleteServerDatabase';
import { httpErrorToHuman } from '@/api/http';
import RotatePasswordButton from '@/components/server/databases/RotatePasswordButton';
import Can from '@/components/elements/Can';
import { ServerDatabase } from '@/api/server/getServerDatabases';
import useServer from '@/plugins/useServer';
import useFlash from '@/plugins/useFlash';
interface Props {
databaseId: string | number;
database: ServerDatabase;
className?: string;
onDelete: () => void;
}
export default ({ databaseId, className, onDelete }: Props) => {
export default ({ database, className }: Props) => {
const { uuid } = useServer();
const { addError, clearFlashes } = useFlash();
const [ visible, setVisible ] = useState(false);
const database = ServerContext.useStoreState(state => state.databases.items.find(item => item.id === databaseId));
const appendDatabase = ServerContext.useStoreActions(actions => actions.databases.appendDatabase);
const [ connectionVisible, setConnectionVisible ] = useState(false);
const { addFlash, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
const server = ServerContext.useStoreState(state => state.server.data!);
if (!database) {
return null;
}
const appendDatabase = ServerContext.useStoreActions(actions => actions.databases.appendDatabase);
const removeDatabase = ServerContext.useStoreActions(actions => actions.databases.removeDatabase);
const schema = object().shape({
confirm: string()
@ -43,20 +40,15 @@ export default ({ databaseId, className, onDelete }: Props) => {
const submit = (values: { confirm: string }, { setSubmitting }: FormikHelpers<{ confirm: string }>) => {
clearFlashes();
deleteServerDatabase(server.uuid, database.id)
deleteServerDatabase(uuid, database.id)
.then(() => {
setVisible(false);
setTimeout(() => onDelete(), 150);
setTimeout(() => removeDatabase(database.id), 150);
})
.catch(error => {
console.error(error);
setSubmitting(false);
addFlash({
key: 'delete-database-modal',
type: 'error',
title: 'Error',
message: httpErrorToHuman(error),
});
addError({ key: 'database:delete', message: httpErrorToHuman(error) });
});
};
@ -78,7 +70,7 @@ export default ({ databaseId, className, onDelete }: Props) => {
resetForm();
}}
>
<FlashMessageRender byKey={'delete-database-modal'} className={'mb-6'}/>
<FlashMessageRender byKey={'database:delete'} className={'mb-6'}/>
<h3 className={'mb-6'}>Confirm database deletion</h3>
<p className={'text-sm'}>
Deleting a database is a permanent action, it cannot be undone. This will permanetly

View File

@ -1,8 +1,6 @@
import React, { useEffect, useState } from 'react';
import getServerDatabases from '@/api/server/getServerDatabases';
import { ServerContext } from '@/state/server';
import { Actions, useStoreActions } from 'easy-peasy';
import { ApplicationStore } from '@/state';
import { httpErrorToHuman } from '@/api/http';
import FlashMessageRender from '@/components/FlashMessageRender';
import DatabaseRow from '@/components/server/databases/DatabaseRow';
@ -10,35 +8,34 @@ import Spinner from '@/components/elements/Spinner';
import { CSSTransition } from 'react-transition-group';
import CreateDatabaseButton from '@/components/server/databases/CreateDatabaseButton';
import Can from '@/components/elements/Can';
import useFlash from '@/plugins/useFlash';
import useServer from '@/plugins/useServer';
export default () => {
const { uuid, featureLimits } = useServer();
const { addError, clearFlashes } = useFlash();
const [ loading, setLoading ] = useState(true);
const server = ServerContext.useStoreState(state => state.server.data!);
const databases = ServerContext.useStoreState(state => state.databases.items);
const { setDatabases, appendDatabase, removeDatabase } = ServerContext.useStoreActions(state => state.databases);
const { addFlash, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
const databases = ServerContext.useStoreState(state => state.databases.data);
const setDatabases = ServerContext.useStoreActions(state => state.databases.setDatabases);
useEffect(() => {
setLoading(!databases.length);
clearFlashes('databases');
getServerDatabases(server.uuid)
.then(databases => {
setDatabases(databases);
setLoading(false);
getServerDatabases(uuid)
.then(databases => setDatabases(databases))
.catch(error => {
console.error(error);
addError({ key: 'databases', message: httpErrorToHuman(error) });
})
.catch(error => addFlash({
key: 'databases',
title: 'Error',
message: httpErrorToHuman(error),
type: 'error',
}));
.then(() => setLoading(false));
}, []);
return (
<div className={'my-10 mb-6'}>
<FlashMessageRender byKey={'databases'}/>
{loading ?
<FlashMessageRender byKey={'databases'} className={'mb-4'}/>
{(!databases.length && loading) ?
<Spinner size={'large'} centered={true}/>
:
<CSSTransition classNames={'fade'} timeout={250}>
@ -47,14 +44,13 @@ export default () => {
databases.map((database, index) => (
<DatabaseRow
key={database.id}
databaseId={database.id}
onDelete={() => removeDatabase(database)}
database={database}
className={index > 0 ? 'mt-1' : undefined}
/>
))
:
<p className={'text-center text-sm text-neutral-400'}>
{server.featureLimits.databases > 0 ?
{featureLimits.databases > 0 ?
`It looks like you have no databases.`
:
`Databases cannot be created for this server.`
@ -62,9 +58,9 @@ export default () => {
</p>
}
<Can action={'database.create'}>
{server.featureLimits.databases > 0 &&
{featureLimits.databases > 0 &&
<div className={'mt-6 flex justify-end'}>
<CreateDatabaseButton onCreated={appendDatabase}/>
<CreateDatabaseButton/>
</div>
}
</Can>

View File

@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react';
import { ServerContext } from '@/state/server';
import { NavLink, useParams } from 'react-router-dom';
import { NavLink } from 'react-router-dom';
import { cleanDirectoryPath } from '@/helpers';
interface Props {
withinFileEditor?: boolean;
@ -8,21 +9,17 @@ interface Props {
}
export default ({ withinFileEditor, isNewFile }: Props) => {
const { action } = useParams();
const [ file, setFile ] = useState<string | null>(null);
const id = ServerContext.useStoreState(state => state.server.data!.id);
const directory = ServerContext.useStoreState(state => state.files.directory);
const setDirectory = ServerContext.useStoreActions(actions => actions.files.setDirectory);
useEffect(() => {
const parts = window.location.hash.replace(/^#(\/)*/, '/').split('/');
const parts = cleanDirectoryPath(window.location.hash).split('/');
if (withinFileEditor && !isNewFile) {
setFile(parts.pop() || null);
}
setDirectory(parts.join('/'));
}, [ withinFileEditor, isNewFile, setDirectory ]);
}, [ withinFileEditor, isNewFile ]);
const breadcrumbs = (): { name: string; path?: string }[] => directory.split('/')
.filter(directory => !!directory)
@ -39,7 +36,6 @@ export default ({ withinFileEditor, isNewFile }: Props) => {
/<span className={'px-1 text-neutral-300'}>home</span>/
<NavLink
to={`/server/${id}/files`}
onClick={() => setDirectory('/')}
className={'px-1 text-neutral-200 no-underline hover:text-neutral-100'}
>
container
@ -50,7 +46,6 @@ export default ({ withinFileEditor, isNewFile }: Props) => {
<React.Fragment key={index}>
<NavLink
to={`/server/${id}/files#${crumb.path}`}
onClick={() => setDirectory(crumb.path!)}
className={'px-1 text-neutral-200 no-underline hover:text-neutral-100'}
>
{crumb.name}

View File

@ -22,20 +22,20 @@ export default () => {
const [ loading, setLoading ] = useState(true);
const { addError, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
const { id } = ServerContext.useStoreState(state => state.server.data!);
const { contents: files, directory } = ServerContext.useStoreState(state => state.files);
const { contents: files } = ServerContext.useStoreState(state => state.files);
const { getDirectoryContents } = ServerContext.useStoreActions(actions => actions.files);
useEffect(() => {
setLoading(true);
clearFlashes();
getDirectoryContents(window.location.hash.replace(/^#(\/)*/, '/'))
getDirectoryContents(window.location.hash)
.then(() => setLoading(false))
.catch(error => {
console.error(error.message, { error });
addError({ message: httpErrorToHuman(error), key: 'files' });
});
}, [ directory ]);
}, []);
return (
<div className={'my-10 mb-6'}>

View File

@ -2,7 +2,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faFileImport } from '@fortawesome/free-solid-svg-icons/faFileImport';
import { faFileAlt } from '@fortawesome/free-solid-svg-icons/faFileAlt';
import { faFolder } from '@fortawesome/free-solid-svg-icons/faFolder';
import { bytesToHuman } from '@/helpers';
import { bytesToHuman, cleanDirectoryPath } from '@/helpers';
import differenceInHours from 'date-fns/difference_in_hours';
import format from 'date-fns/format';
import distanceInWordsToNow from 'date-fns/distance_in_words_to_now';
@ -16,7 +16,7 @@ import useRouter from 'use-react-router';
export default ({ file }: { file: FileObject }) => {
const directory = ServerContext.useStoreState(state => state.files.directory);
const setDirectory = ServerContext.useStoreActions(actions => actions.files.setDirectory);
const { match } = useRouter();
const { match, history } = useRouter();
return (
<div
@ -27,7 +27,7 @@ export default ({ file }: { file: FileObject }) => {
`}
>
<NavLink
to={`${match.url}/${file.isFile ? 'edit/' : ''}#${directory}/${file.name}`}
to={`${match.url}/${file.isFile ? 'edit/' : ''}#${cleanDirectoryPath(`${directory}/${file.name}`)}`}
className={'flex flex-1 text-neutral-300 no-underline p-3'}
onClick={e => {
// Don't rely on the onClick to work with the generated URL. Because of the way this
@ -38,7 +38,7 @@ export default ({ file }: { file: FileObject }) => {
if (!file.isFile) {
e.preventDefault();
window.location.hash = `#${directory}/${file.name}`;
history.push(`#${cleanDirectoryPath(`${directory}/${file.name}`)}`);
setDirectory(`${directory}/${file.name}`);
}
}}

View File

@ -6,14 +6,13 @@ import { Form, Formik, FormikHelpers, useFormikContext } from 'formik';
import Switch from '@/components/elements/Switch';
import createOrUpdateSchedule from '@/api/server/schedules/createOrUpdateSchedule';
import { ServerContext } from '@/state/server';
import { Actions, useStoreActions } from 'easy-peasy';
import { ApplicationStore } from '@/state';
import { httpErrorToHuman } from '@/api/http';
import FlashMessageRender from '@/components/FlashMessageRender';
import useServer from '@/plugins/useServer';
import useFlash from '@/plugins/useFlash';
type Props = {
schedule?: Schedule;
onScheduleUpdated: (schedule: Schedule) => void;
} & RequiredModalProps;
interface Values {
@ -73,15 +72,17 @@ const EditScheduleModal = ({ schedule, ...props }: Omit<Props, 'onScheduleUpdate
);
};
export default ({ schedule, onScheduleUpdated, visible, ...props }: Props) => {
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
const { addError, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
export default ({ schedule, visible, ...props }: Props) => {
const { uuid } = useServer();
const { addError, clearFlashes } = useFlash();
const [ modalVisible, setModalVisible ] = useState(visible);
const appendSchedule = ServerContext.useStoreActions(actions => actions.schedules.appendSchedule);
useEffect(() => {
setModalVisible(visible);
clearFlashes('schedule:edit');
}, [visible]);
}, [ visible ]);
const submit = (values: Values, { setSubmitting }: FormikHelpers<Values>) => {
clearFlashes('schedule:edit');
@ -98,7 +99,7 @@ export default ({ schedule, onScheduleUpdated, visible, ...props }: Props) => {
})
.then(schedule => {
setSubmitting(false);
onScheduleUpdated(schedule);
appendSchedule(schedule);
setModalVisible(false);
})
.catch(error => {

View File

@ -1,25 +1,21 @@
import React, { useState } from 'react';
import { Task } from '@/api/server/schedules/getServerSchedules';
import { Schedule } from '@/api/server/schedules/getServerSchedules';
import TaskDetailsModal from '@/components/server/schedules/TaskDetailsModal';
interface Props {
scheduleId: number;
onTaskAdded: (task: Task) => void;
schedule: Schedule;
}
export default ({ scheduleId, onTaskAdded }: Props) => {
const [visible, setVisible] = useState(false);
export default ({ schedule }: Props) => {
const [ visible, setVisible ] = useState(false);
return (
<>
{visible &&
<TaskDetailsModal
scheduleId={scheduleId}
onDismissed={task => {
task && onTaskAdded(task);
setVisible(false);
}}
/>
<TaskDetailsModal
schedule={schedule}
onDismissed={() => setVisible(false)}
/>
}
<button className={'btn btn-primary btn-sm'} onClick={() => setVisible(true)}>
New Task

View File

@ -1,36 +1,40 @@
import React, { useMemo, useState } from 'react';
import getServerSchedules, { Schedule } from '@/api/server/schedules/getServerSchedules';
import React, { useEffect, useState } from 'react';
import getServerSchedules from '@/api/server/schedules/getServerSchedules';
import { ServerContext } from '@/state/server';
import Spinner from '@/components/elements/Spinner';
import { RouteComponentProps } from 'react-router-dom';
import FlashMessageRender from '@/components/FlashMessageRender';
import ScheduleRow from '@/components/server/schedules/ScheduleRow';
import { httpErrorToHuman } from '@/api/http';
import { Actions, useStoreActions } from 'easy-peasy';
import { ApplicationStore } from '@/state';
import EditScheduleModal from '@/components/server/schedules/EditScheduleModal';
import Can from '@/components/elements/Can';
import useServer from '@/plugins/useServer';
import useFlash from '@/plugins/useFlash';
export default ({ match, history }: RouteComponentProps) => {
const { uuid } = ServerContext.useStoreState(state => state.server.data!);
const [ schedules, setSchedules ] = useState<Schedule[] | null>(null);
const { uuid } = useServer();
const { clearFlashes, addError } = useFlash();
const [ loading, setLoading ] = useState(true);
const [ visible, setVisible ] = useState(false);
const { clearFlashes, addError } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
useMemo(() => {
const schedules = ServerContext.useStoreState(state => state.schedules.data);
const setSchedules = ServerContext.useStoreActions(actions => actions.schedules.setSchedules);
useEffect(() => {
clearFlashes('schedules');
getServerSchedules(uuid)
.then(schedules => setSchedules(schedules))
.catch(error => {
addError({ message: httpErrorToHuman(error), key: 'schedules' });
console.error(error);
});
}, [ setSchedules ]);
})
.then(() => setLoading(false));
}, []);
return (
<div className={'my-10 mb-6'}>
<FlashMessageRender byKey={'schedules'} className={'mb-4'}/>
{!schedules ?
{(!schedules.length && loading) ?
<Spinner size={'large'} centered={true}/>
:
<>
@ -59,7 +63,6 @@ export default ({ match, history }: RouteComponentProps) => {
{visible && <EditScheduleModal
appear={true}
visible={true}
onScheduleUpdated={schedule => setSchedules(s => [ ...(s || []), schedule ])}
onDismissed={() => setVisible(false)}
/>}
<button

View File

@ -2,11 +2,8 @@ import React, { useEffect, useState } from 'react';
import { RouteComponentProps } from 'react-router-dom';
import { Schedule } from '@/api/server/schedules/getServerSchedules';
import getServerSchedule from '@/api/server/schedules/getServerSchedule';
import { ServerContext } from '@/state/server';
import Spinner from '@/components/elements/Spinner';
import FlashMessageRender from '@/components/FlashMessageRender';
import { Actions, useStoreActions } from 'easy-peasy';
import { ApplicationStore } from '@/state';
import { httpErrorToHuman } from '@/api/http';
import ScheduleRow from '@/components/server/schedules/ScheduleRow';
import ScheduleTaskRow from '@/components/server/schedules/ScheduleTaskRow';
@ -14,6 +11,9 @@ import EditScheduleModal from '@/components/server/schedules/EditScheduleModal';
import NewTaskButton from '@/components/server/schedules/NewTaskButton';
import DeleteScheduleButton from '@/components/server/schedules/DeleteScheduleButton';
import Can from '@/components/elements/Can';
import useServer from '@/plugins/useServer';
import useFlash from '@/plugins/useFlash';
import { ServerContext } from '@/state/server';
interface Params {
id: string;
@ -24,11 +24,13 @@ interface State {
}
export default ({ match, history, location: { state } }: RouteComponentProps<Params, {}, State>) => {
const { id, uuid } = ServerContext.useStoreState(state => state.server.data!);
const { id, uuid } = useServer();
const { clearFlashes, addError } = useFlash();
const [ isLoading, setIsLoading ] = useState(true);
const [ showEditModal, setShowEditModal ] = useState(false);
const [ schedule, setSchedule ] = useState<Schedule | undefined>(state?.schedule);
const { clearFlashes, addError } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
const schedule = ServerContext.useStoreState(st => st.schedules.data.find(s => s.id === state.schedule?.id), [ match ]);
const appendSchedule = ServerContext.useStoreActions(actions => actions.schedules.appendSchedule);
useEffect(() => {
if (schedule?.id === Number(match.params.id)) {
@ -38,13 +40,13 @@ export default ({ match, history, location: { state } }: RouteComponentProps<Par
clearFlashes('schedules');
getServerSchedule(uuid, Number(match.params.id))
.then(schedule => setSchedule(schedule))
.then(schedule => appendSchedule(schedule))
.catch(error => {
console.error(error);
addError({ message: httpErrorToHuman(error), key: 'schedules' });
})
.then(() => setIsLoading(false));
}, [ schedule, match ]);
}, [ match ]);
return (
<div className={'my-10 mb-6'}>
@ -59,7 +61,6 @@ export default ({ match, history, location: { state } }: RouteComponentProps<Par
<EditScheduleModal
visible={showEditModal}
schedule={schedule}
onScheduleUpdated={schedule => setSchedule(schedule)}
onDismissed={() => setShowEditModal(false)}
/>
<div className={'flex items-center mt-8 mb-4'}>
@ -67,23 +68,13 @@ export default ({ match, history, location: { state } }: RouteComponentProps<Par
<h2>Configured Tasks</h2>
</div>
</div>
{schedule?.tasks.length > 0 ?
{schedule.tasks.length > 0 ?
<>
{
schedule.tasks
.sort((a, b) => a.sequenceId - b.sequenceId)
.map(task => (
<ScheduleTaskRow
key={task.id}
task={task}
schedule={schedule.id}
onTaskUpdated={task => setSchedule(s => ({
...s!, tasks: s!.tasks.map(t => t.id === task.id ? task : t),
}))}
onTaskRemoved={() => setSchedule(s => ({
...s!, tasks: s!.tasks.filter(t => t.id !== task.id),
}))}
/>
<ScheduleTaskRow key={task.id} task={task} schedule={schedule}/>
))
}
{schedule.tasks.length > 1 &&
@ -108,12 +99,7 @@ export default ({ match, history, location: { state } }: RouteComponentProps<Par
<button className={'btn btn-primary btn-sm mr-4'} onClick={() => setShowEditModal(true)}>
Edit
</button>
<NewTaskButton
scheduleId={schedule.id}
onTaskAdded={task => setSchedule(s => ({
...s!, tasks: [ ...s!.tasks, task ],
}))}
/>
<NewTaskButton schedule={schedule}/>
</Can>
</div>
</>

View File

@ -1,39 +1,41 @@
import React, { useState } from 'react';
import { Task } from '@/api/server/schedules/getServerSchedules';
import { Schedule, Task } from '@/api/server/schedules/getServerSchedules';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faTrashAlt } from '@fortawesome/free-solid-svg-icons/faTrashAlt';
import { faCode } from '@fortawesome/free-solid-svg-icons/faCode';
import { faToggleOn } from '@fortawesome/free-solid-svg-icons/faToggleOn';
import ConfirmTaskDeletionModal from '@/components/server/schedules/ConfirmTaskDeletionModal';
import { ServerContext } from '@/state/server';
import { Actions, useStoreActions } from 'easy-peasy';
import { ApplicationStore } from '@/state';
import deleteScheduleTask from '@/api/server/schedules/deleteScheduleTask';
import { httpErrorToHuman } from '@/api/http';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import TaskDetailsModal from '@/components/server/schedules/TaskDetailsModal';
import { faPencilAlt } from '@fortawesome/free-solid-svg-icons/faPencilAlt';
import Can from '@/components/elements/Can';
import useServer from '@/plugins/useServer';
import useFlash from '@/plugins/useFlash';
import { ServerContext } from '@/state/server';
interface Props {
schedule: number;
schedule: Schedule;
task: Task;
onTaskUpdated: (task: Task) => void;
onTaskRemoved: () => void;
}
export default ({ schedule, task, onTaskUpdated, onTaskRemoved }: Props) => {
export default ({ schedule, task }: Props) => {
const { uuid } = useServer();
const { clearFlashes, addError } = useFlash();
const [ visible, setVisible ] = useState(false);
const [ isLoading, setIsLoading ] = useState(false);
const [ isEditing, setIsEditing ] = useState(false);
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
const { clearFlashes, addError } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
const appendSchedule = ServerContext.useStoreActions(actions => actions.schedules.appendSchedule);
const onConfirmDeletion = () => {
setIsLoading(true);
clearFlashes('schedules');
deleteScheduleTask(uuid, schedule, task.id)
.then(() => onTaskRemoved())
deleteScheduleTask(uuid, schedule.id, task.id)
.then(() => appendSchedule({
...schedule,
tasks: schedule.tasks.filter(t => t.id !== task.id),
}))
.catch(error => {
console.error(error);
setIsLoading(false);
@ -45,12 +47,9 @@ export default ({ schedule, task, onTaskUpdated, onTaskRemoved }: Props) => {
<div className={'flex items-center bg-neutral-700 border border-neutral-600 mb-2 px-6 py-4 rounded'}>
<SpinnerOverlay visible={isLoading} fixed={true} size={'large'}/>
{isEditing && <TaskDetailsModal
scheduleId={schedule}
schedule={schedule}
task={task}
onDismissed={task => {
task && onTaskUpdated(task);
setIsEditing(false);
}}
onDismissed={() => setIsEditing(false)}
/>}
<ConfirmTaskDeletionModal
visible={visible}

View File

@ -1,22 +1,22 @@
import React, { useEffect } from 'react';
import Modal from '@/components/elements/Modal';
import { Task } from '@/api/server/schedules/getServerSchedules';
import { Schedule, Task } from '@/api/server/schedules/getServerSchedules';
import { Field as FormikField, Form, Formik, FormikHelpers, useFormikContext } from 'formik';
import { ServerContext } from '@/state/server';
import { Actions, useStoreActions } from 'easy-peasy';
import { ApplicationStore } from '@/state';
import createOrUpdateScheduleTask from '@/api/server/schedules/createOrUpdateScheduleTask';
import { httpErrorToHuman } from '@/api/http';
import Field from '@/components/elements/Field';
import FlashMessageRender from '@/components/FlashMessageRender';
import { number, object, string } from 'yup';
import useFlash from '@/plugins/useFlash';
import useServer from '@/plugins/useServer';
interface Props {
scheduleId: number;
schedule: Schedule;
// If a task is provided we can assume we're editing it. If not provided,
// we are creating a new one.
task?: Task;
onDismissed: (task: Task | undefined | void) => void;
onDismissed: () => void;
}
interface Values {
@ -29,9 +29,11 @@ const TaskDetailsForm = ({ isEditingTask }: { isEditingTask: boolean }) => {
const { values: { action }, setFieldValue, setFieldTouched } = useFormikContext<Values>();
useEffect(() => {
setFieldValue('payload', '');
setFieldTouched('payload', false);
}, [action]);
return () => {
setFieldValue('payload', '');
setFieldTouched('payload', false);
};
}, [ action ]);
return (
<Form className={'m-0'}>
@ -80,9 +82,10 @@ const TaskDetailsForm = ({ isEditingTask }: { isEditingTask: boolean }) => {
);
};
export default ({ task, scheduleId, onDismissed }: Props) => {
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
const { clearFlashes, addError } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
export default ({ task, schedule, onDismissed }: Props) => {
const { uuid } = useServer();
const { clearFlashes, addError } = useFlash();
const appendSchedule = ServerContext.useStoreActions(actions => actions.schedules.appendSchedule);
useEffect(() => {
clearFlashes('schedule:task');
@ -90,8 +93,16 @@ export default ({ task, scheduleId, onDismissed }: Props) => {
const submit = (values: Values, { setSubmitting }: FormikHelpers<Values>) => {
clearFlashes('schedule:task');
createOrUpdateScheduleTask(uuid, scheduleId, task?.id, values)
.then(task => onDismissed(task))
createOrUpdateScheduleTask(uuid, schedule.id, task?.id, values)
.then(task => {
let tasks = schedule.tasks.map(t => t.id === task.id ? task : t);
if (!schedule.tasks.find(t => t.id === task.id)) {
tasks = [ ...tasks, task ];
}
appendSchedule({ ...schedule, tasks });
onDismissed();
})
.catch(error => {
console.error(error);
setSubmitting(false);

View File

@ -21,10 +21,6 @@ export default () => {
const getPermissions = useStoreActions((actions: Actions<ApplicationStore>) => actions.permissions.getPermissions);
const { addError, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
useEffect(() => {
getPermissions().catch(error => console.error(error));
}, []);
useEffect(() => {
clearFlashes('users');
getServerSubusers(uuid)
@ -38,7 +34,14 @@ export default () => {
});
}, []);
if (loading || !Object.keys(permissions).length) {
useEffect(() => {
getPermissions().catch(error => {
addError({ key: 'users', message: httpErrorToHuman(error) });
console.error(error);
});
}, []);
if (!subusers.length && (loading || !Object.keys(permissions).length)) {
return <Spinner size={'large'} centered={true}/>;
}

View File

@ -1,9 +1,13 @@
// noinspection ES6UnusedImports
import EasyPeasy from 'easy-peasy';
import EasyPeasy, { Actions, State } from 'easy-peasy';
import { ApplicationStore } from '@/state';
declare module 'easy-peasy' {
export function useStoreState<Result>(
mapState: (state: ApplicationStore) => Result,
mapState: (state: State<ApplicationStore>) => Result,
): Result;
export function useStoreActions<Result>(
mapActions: (actions: Actions<ApplicationStore>) => Result,
): Result;
}

View File

@ -9,3 +9,7 @@ export function bytesToHuman (bytes: number): string {
}
export const bytesToMegabytes = (bytes: number) => Math.floor(bytes / 1000 / 1000);
export const randomInt = (low: number, high: number) => Math.floor(Math.random() * (high - low) + low);
export const cleanDirectoryPath = (path: string) => path.replace(/(^#\/*)|(\/(\/*))|(^$)/g, '/');

View File

@ -64,7 +64,7 @@ const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>)
<Spinner size={'large'}/>
</div>
:
<Switch location={location} key={'server-switch'}>
<Switch location={location}>
<Route path={`${match.path}`} component={ServerConsole} exact/>
<Route path={`${match.path}/files`} component={FileManagerContainer} exact/>
<Route

View File

@ -3,12 +3,14 @@ import flashes, { FlashStore } from '@/state/flashes';
import user, { UserStore } from '@/state/user';
import permissions, { GloablPermissionsStore } from '@/state/permissions';
import settings, { SettingsStore } from '@/state/settings';
import progress, { ProgressStore } from '@/state/progress';
export interface ApplicationStore {
permissions: GloablPermissionsStore;
flashes: FlashStore;
user: UserStore;
settings: SettingsStore;
progress: ProgressStore;
}
const state: ApplicationStore = {
@ -16,6 +18,7 @@ const state: ApplicationStore = {
flashes,
user,
settings,
progress,
};
export const store = createStore(state);

View File

@ -0,0 +1,30 @@
import { action, Action } from 'easy-peasy';
export interface ProgressStore {
continuous: boolean;
progress?: number;
startContinuous: Action<ProgressStore>;
setProgress: Action<ProgressStore, number | undefined>;
setComplete: Action<ProgressStore>;
}
const progress: ProgressStore = {
continuous: false,
progress: undefined,
startContinuous: action(state => {
state.continuous = true;
}),
setProgress: action((state, payload) => {
state.progress = payload;
}),
setComplete: action(state => {
state.progress = 100;
state.continuous = false;
}),
};
export default progress;

View File

@ -0,0 +1,31 @@
import { action, Action } from 'easy-peasy';
import { ServerDatabase } from '@/api/server/getServerDatabases';
export interface ServerDatabaseStore {
data: ServerDatabase[];
setDatabases: Action<ServerDatabaseStore, ServerDatabase[]>;
appendDatabase: Action<ServerDatabaseStore, ServerDatabase>;
removeDatabase: Action<ServerDatabaseStore, string>;
}
const databases: ServerDatabaseStore = {
data: [],
setDatabases: action((state, payload) => {
state.data = payload;
}),
appendDatabase: action((state, payload) => {
if (state.data.find(database => database.id === payload.id)) {
state.data = state.data.map(database => database.id === payload.id ? payload : database);
} else {
state.data = [ ...state.data, payload ];
}
}),
removeDatabase: action((state, payload) => {
state.data = [ ...state.data.filter(database => database.id !== payload) ];
}),
};
export default databases;

View File

@ -1,6 +1,7 @@
import loadDirectory, { FileObject } from '@/api/server/files/loadDirectory';
import { action, Action, thunk, Thunk } from 'easy-peasy';
import { ServerStore } from '@/state/server/index';
import { cleanDirectoryPath } from '@/helpers';
export interface ServerFileStore {
directory: string;
@ -22,7 +23,7 @@ const files: ServerFileStore = {
return;
}
const contents = await loadDirectory(server.uuid, payload);
const contents = await loadDirectory(server.uuid, cleanDirectoryPath(payload));
actions.setDirectory(payload.length === 0 ? '/' : payload);
actions.setContents(contents);
@ -47,7 +48,7 @@ const files: ServerFileStore = {
}),
setDirectory: action((state, payload) => {
state.directory = payload.length === 0 ? '/' : payload;
state.directory = cleanDirectoryPath(payload)
}),
};

View File

@ -1,11 +1,12 @@
import getServer, { Server } from '@/api/server/getServer';
import { action, Action, createContextStore, thunk, Thunk } from 'easy-peasy';
import socket, { SocketStore } from './socket';
import { ServerDatabase } from '@/api/server/getServerDatabases';
import files, { ServerFileStore } from '@/state/server/files';
import subusers, { ServerSubuserStore } from '@/state/server/subusers';
import { composeWithDevTools } from 'redux-devtools-extension';
import backups, { ServerBackupStore } from '@/state/server/backups';
import schedules, { ServerScheduleStore } from '@/state/server/schedules';
import databases, { ServerDatabaseStore } from '@/state/server/databases';
export type ServerStatus = 'offline' | 'starting' | 'stopping' | 'running';
@ -22,7 +23,7 @@ const server: ServerDataStore = {
permissions: [],
getServer: thunk(async (actions, payload) => {
const [server, permissions] = await getServer(payload);
const [ server, permissions ] = await getServer(payload);
actions.setServer(server);
actions.setPermissions(permissions);
@ -49,31 +50,12 @@ const status: ServerStatusStore = {
}),
};
interface ServerDatabaseStore {
items: ServerDatabase[];
setDatabases: Action<ServerDatabaseStore, ServerDatabase[]>;
appendDatabase: Action<ServerDatabaseStore, ServerDatabase>;
removeDatabase: Action<ServerDatabaseStore, ServerDatabase>;
}
const databases: ServerDatabaseStore = {
items: [],
setDatabases: action((state, payload) => {
state.items = payload;
}),
appendDatabase: action((state, payload) => {
state.items = state.items.filter(item => item.id !== payload.id).concat(payload);
}),
removeDatabase: action((state, payload) => {
state.items = state.items.filter(item => item.id !== payload.id);
}),
};
export interface ServerStore {
server: ServerDataStore;
subusers: ServerSubuserStore;
databases: ServerDatabaseStore;
files: ServerFileStore;
schedules: ServerScheduleStore;
backups: ServerBackupStore;
socket: SocketStore;
status: ServerStatusStore;
@ -88,14 +70,16 @@ export const ServerContext = createContextStore<ServerStore>({
files,
subusers,
backups,
schedules,
clearServerState: action(state => {
state.server.data = undefined;
state.server.permissions = [];
state.databases.items = [];
state.databases.data = [];
state.subusers.data = [];
state.files.directory = '/';
state.files.contents = [];
state.backups.backups = [];
state.backups.data = [];
state.schedules.data = [];
if (state.socket.instance) {
state.socket.instance.removeAllListeners();

View File

@ -0,0 +1,31 @@
import { action, Action } from 'easy-peasy';
import { Schedule } from '@/api/server/schedules/getServerSchedules';
export interface ServerScheduleStore {
data: Schedule[];
setSchedules: Action<ServerScheduleStore, Schedule[]>;
appendSchedule: Action<ServerScheduleStore, Schedule>;
removeSchedule: Action<ServerScheduleStore, number>;
}
const schedules: ServerScheduleStore = {
data: [],
setSchedules: action((state, payload) => {
state.data = payload;
}),
appendSchedule: action((state, payload) => {
if (state.data.find(schedule => schedule.id === payload.id)) {
state.data = state.data.map(schedule => schedule.id === payload.id ? payload : schedule);
} else {
state.data = [ ...state.data, payload ];
}
}),
removeSchedule: action((state, payload) => {
state.data = [ ...state.data.filter(schedule => schedule.id !== payload) ];
}),
};
export default schedules;

View File

@ -55,7 +55,7 @@
</tr>
@foreach ($nodes as $node)
<tr>
<td class="text-center text-muted left-icon" data-action="ping" data-secret="{{ $node->daemonSecret }}" data-location="{{ $node->scheme }}://{{ $node->fqdn }}:{{ $node->daemonListen }}/api/system"><i class="fa fa-fw fa-refresh fa-spin"></i></td>
<td class="text-center text-muted left-icon" data-action="ping" data-secret="{{ $node->getDecryptedKey() }}" data-location="{{ $node->scheme }}://{{ $node->fqdn }}:{{ $node->daemonListen }}/api/system"><i class="fa fa-fw fa-refresh fa-spin"></i></td>
<td>{!! $node->maintenance_mode ? '<span class="label label-warning"><i class="fa fa-wrench"></i></span> ' : '' !!}<a href="{{ route('admin.nodes.view', $node->id) }}">{{ $node->name }}</a></td>
<td>{{ $node->location->short }}</td>
<td>{{ $node->memory }} MB</td>

View File

@ -1,13 +0,0 @@
<?php
/**
* Pterodactyl - Panel
* Copyright (c) 2015 - 2017 Dane Everitt <dane@daneeveritt.com>.
*
* This software is licensed under the terms of the MIT license.
* https://opensource.org/licenses/MIT
*/
Route::get('/packs/pull/{uuid}', 'PackController@pull')->name('daemon.pack.pull');
Route::get('/packs/pull/{uuid}/hash', 'PackController@hash')->name('daemon.pack.hash');
Route::get('/configure/{token}', 'ActionController@configuration')->name('daemon.configuration');
Route::post('/install', 'ActionController@markInstall')->name('daemon.install');