diff --git a/.env.example b/.env.example index 9062de215..67d496d47 100644 --- a/.env.example +++ b/.env.example @@ -19,8 +19,8 @@ HASHIDS_SALT= HASHIDS_LENGTH=8 MAIL_DRIVER=smtp -MAIL_HOST=mailtrap.io -MAIL_PORT=2525 +MAIL_HOST=smtp.example.com +MAIL_PORT=25 MAIL_USERNAME= MAIL_PASSWORD= MAIL_ENCRYPTION=tls diff --git a/.github/ISSUE_TEMPLATE/---bug-report.md b/.github/ISSUE_TEMPLATE/---bug-report.md index 640d6def5..f707992d2 100644 --- a/.github/ISSUE_TEMPLATE/---bug-report.md +++ b/.github/ISSUE_TEMPLATE/---bug-report.md @@ -6,7 +6,7 @@ about: For reporting code or design bugs with the software. DO NOT REPORT APACHE DO NOT REPORT ISSUES CONFIGURING: SSL, PHP, APACHE, NGINX, YOUR MACHINE, SSH, SFTP, ETC. ON THIS GITHUB TRACKER. -For assistance installating this software, as well as debugging issues with dependencies, please use our discord server: https://discord.gg/pterodactyl +For assistance installing this software, as well as debugging issues with dependencies, please use our discord server: https://discord.gg/pterodactyl You MUST complete all of the below information when reporting a bug, failure to do so will result in closure of your issue. PLEASE stop spamming our tracker with "bugs" that are not related to this project. diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 1f83ddf93..4fa0845e5 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -25,11 +25,13 @@ class Kernel extends ConsoleKernel // Execute scheduled commands for servers every minute, as if there was a normal cron running. $schedule->command('p:schedule:process')->everyMinute()->withoutOverlapping(); - // Every 30 minutes, run the backup pruning command so that any abandoned backups can be removed - // from the UI view for the server. - $schedule->command('p:maintenance:prune-backups', [ - '--since-minutes' => '30', - ])->everyThirtyMinutes(); + // Every 30 minutes, run the backup pruning command so that any abandoned backups can be deleted. + $pruneAge = config('backups.prune_age', 360); // Defaults to 6 hours (time is in minuteS) + if ($pruneAge > 0) { + $schedule->command('p:maintenance:prune-backups', [ + '--since-minutes' => $pruneAge, + ])->everyThirtyMinutes(); + } // Every day cleanup any internal backups of service files. $schedule->command('p:maintenance:clean-service-backups')->daily(); diff --git a/app/Http/Controllers/Admin/Nests/EggController.php b/app/Http/Controllers/Admin/Nests/EggController.php index 209dad691..4e3dd5e94 100644 --- a/app/Http/Controllers/Admin/Nests/EggController.php +++ b/app/Http/Controllers/Admin/Nests/EggController.php @@ -78,7 +78,14 @@ class EggController extends Controller */ public function store(EggFormRequest $request): RedirectResponse { - $egg = $this->creationService->handle($request->normalize()); + $data = $request->normalize(); + if (!empty($data['docker_images']) && !is_array($data['docker_images'])) { + $data['docker_images'] = array_map(function ($value) { + return trim($value); + }, explode("\n", $data['docker_images'])); + } + + $egg = $this->creationService->handle($data); $this->alert->success(trans('admin/nests.eggs.notices.egg_created'))->flash(); return redirect()->route('admin.nests.egg.view', $egg->id); @@ -108,7 +115,14 @@ class EggController extends Controller */ public function update(EggFormRequest $request, Egg $egg): RedirectResponse { - $this->updateService->handle($egg, $request->normalize()); + $data = $request->normalize(); + if (!empty($data['docker_images']) && !is_array($data['docker_images'])) { + $data['docker_images'] = array_map(function ($value) { + return trim($value); + }, explode("\n", $data['docker_images'])); + } + + $this->updateService->handle($egg, $data); $this->alert->success(trans('admin/nests.eggs.notices.updated'))->flash(); return redirect()->route('admin.nests.egg.view', $egg->id); diff --git a/app/Http/Controllers/Admin/Servers/CreateServerController.php b/app/Http/Controllers/Admin/Servers/CreateServerController.php index f63cf814b..1cea7651a 100644 --- a/app/Http/Controllers/Admin/Servers/CreateServerController.php +++ b/app/Http/Controllers/Admin/Servers/CreateServerController.php @@ -111,17 +111,19 @@ class CreateServerController extends Controller * * @throws \Illuminate\Validation\ValidationException * @throws \Pterodactyl\Exceptions\DisplayException - * @throws \Pterodactyl\Exceptions\Model\DataValidationException - * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException * @throws \Pterodactyl\Exceptions\Service\Deployment\NoViableAllocationException * @throws \Pterodactyl\Exceptions\Service\Deployment\NoViableNodeException * @throws \Throwable */ public function store(ServerFormRequest $request) { - $server = $this->creationService->handle( - $request->except(['_token']) - ); + $data = $request->except(['_token']); + if (!empty($data['custom_image'])) { + $data['image'] = $data['custom_image']; + unset($data['custom_image']); + } + + $server = $this->creationService->handle($data); $this->alert->success( trans('admin/server.alerts.server_created') diff --git a/app/Http/Controllers/Admin/ServersController.php b/app/Http/Controllers/Admin/ServersController.php index 363737159..bec5ac4aa 100644 --- a/app/Http/Controllers/Admin/ServersController.php +++ b/app/Http/Controllers/Admin/ServersController.php @@ -334,14 +334,19 @@ class ServersController extends Controller * @return \Illuminate\Http\RedirectResponse * * @throws \Illuminate\Validation\ValidationException - * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException */ public function saveStartup(Request $request, Server $server) { + $data = $request->except('_token'); + if (!empty($data['custom_docker_image'])) { + $data['docker_image'] = $data['custom_docker_image']; + unset($data['custom_docker_image']); + } + try { $this->startupModificationService ->setUserLevel(User::USER_LEVEL_ADMIN) - ->handle($server, $request->except('_token')); + ->handle($server, $data); } catch (DataValidationException $exception) { throw new ValidationException($exception->validator); } diff --git a/app/Http/Controllers/Api/Client/Servers/FileController.php b/app/Http/Controllers/Api/Client/Servers/FileController.php index 0e3a62f2e..317115b29 100644 --- a/app/Http/Controllers/Api/Client/Servers/FileController.php +++ b/app/Http/Controllers/Api/Client/Servers/FileController.php @@ -13,6 +13,7 @@ use Pterodactyl\Repositories\Wings\DaemonFileRepository; use Pterodactyl\Transformers\Daemon\FileObjectTransformer; use Pterodactyl\Http\Controllers\Api\Client\ClientApiController; use Pterodactyl\Http\Requests\Api\Client\Servers\Files\CopyFileRequest; +use Pterodactyl\Http\Requests\Api\Client\Servers\Files\PullFileRequest; use Pterodactyl\Http\Requests\Api\Client\Servers\Files\ListFilesRequest; use Pterodactyl\Http\Requests\Api\Client\Servers\Files\DeleteFileRequest; use Pterodactyl\Http\Requests\Api\Client\Servers\Files\RenameFileRequest; @@ -72,7 +73,7 @@ class FileController extends ClientApiController { $contents = $this->fileRepository ->setServer($server) - ->getDirectory($this->encode($request->get('directory') ?? '/')); + ->getDirectory($request->get('directory') ?? '/'); return $this->fractal->collection($contents) ->transformWith($this->getTransformer(FileObjectTransformer::class)) @@ -93,7 +94,7 @@ class FileController extends ClientApiController { return new Response( $this->fileRepository->setServer($server)->getContent( - $this->encode($request->get('file')), config('pterodactyl.files.max_edit_size') + $request->get('file'), config('pterodactyl.files.max_edit_size') ), Response::HTTP_OK, ['Content-Type' => 'text/plain'] @@ -143,10 +144,7 @@ class FileController extends ClientApiController */ public function write(WriteFileContentRequest $request, Server $server): JsonResponse { - $this->fileRepository->setServer($server)->putContent( - $this->encode($request->get('file')), - $request->getContent() - ); + $this->fileRepository->setServer($server)->putContent($request->get('file'), $request->getContent()); return new JsonResponse([], Response::HTTP_NO_CONTENT); } @@ -284,16 +282,18 @@ class FileController extends ClientApiController } /** - * Encodes a given file name & path in a format that should work for a good majority - * of file names without too much confusing logic. + * Requests that a file be downloaded from a remote location by Wings. * - * @param string $path - * @return string + * @param $request + * @param \Pterodactyl\Models\Server $server + * @return \Illuminate\Http\JsonResponse + * + * @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException */ - private function encode(string $path): string + public function pull(PullFileRequest $request, Server $server): JsonResponse { - return Collection::make(explode('/', rawurldecode($path)))->map(function ($value) { - return rawurlencode($value); - })->join('/'); + $this->fileRepository->setServer($server)->pull($request->input('url'), $request->input('directory')); + + return new JsonResponse([], Response::HTTP_NO_CONTENT); } } diff --git a/app/Http/Controllers/Api/Client/Servers/SettingsController.php b/app/Http/Controllers/Api/Client/Servers/SettingsController.php index 7dfbf7b4f..80fc0e181 100644 --- a/app/Http/Controllers/Api/Client/Servers/SettingsController.php +++ b/app/Http/Controllers/Api/Client/Servers/SettingsController.php @@ -2,13 +2,16 @@ namespace Pterodactyl\Http\Controllers\Api\Client\Servers; +use Illuminate\Http\Request; use Illuminate\Http\Response; use Pterodactyl\Models\Server; use Illuminate\Http\JsonResponse; use Pterodactyl\Repositories\Eloquent\ServerRepository; use Pterodactyl\Services\Servers\ReinstallServerService; use Pterodactyl\Http\Controllers\Api\Client\ClientApiController; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Pterodactyl\Http\Requests\Api\Client\Servers\Settings\RenameServerRequest; +use Pterodactyl\Http\Requests\Api\Client\Servers\Settings\SetDockerImageRequest; use Pterodactyl\Http\Requests\Api\Client\Servers\Settings\ReinstallServerRequest; class SettingsController extends ClientApiController @@ -73,4 +76,26 @@ class SettingsController extends ClientApiController return new JsonResponse([], Response::HTTP_ACCEPTED); } + + /** + * Changes the Docker image in use by the server. + * + * @param \Pterodactyl\Http\Requests\Api\Client\Servers\Settings\SetDockerImageRequest $request + * @param \Pterodactyl\Models\Server $server + * @return \Illuminate\Http\JsonResponse + * + * @throws \Throwable + */ + public function dockerImage(SetDockerImageRequest $request, Server $server) + { + if (!in_array($server->image, $server->egg->docker_images)) { + throw new BadRequestHttpException( + 'This server\'s Docker image has been manually set by an administrator and cannot be updated.' + ); + } + + $server->forceFill(['image' => $request->input('docker_image')])->saveOrFail(); + + return new JsonResponse([], Response::HTTP_NO_CONTENT); + } } diff --git a/app/Http/Controllers/Api/Client/Servers/StartupController.php b/app/Http/Controllers/Api/Client/Servers/StartupController.php index e0c580279..7e06abf8e 100644 --- a/app/Http/Controllers/Api/Client/Servers/StartupController.php +++ b/app/Http/Controllers/Api/Client/Servers/StartupController.php @@ -62,6 +62,7 @@ class StartupController extends ClientApiController ->transformWith($this->getTransformer(EggVariableTransformer::class)) ->addMeta([ 'startup_command' => $startup, + 'docker_images' => $server->egg->docker_images, 'raw_startup_command' => $server->startup, ]) ->toArray(); diff --git a/app/Http/Controllers/Api/Remote/Backups/BackupRemoteUploadController.php b/app/Http/Controllers/Api/Remote/Backups/BackupRemoteUploadController.php index 497d12904..6d50ad64d 100644 --- a/app/Http/Controllers/Api/Remote/Backups/BackupRemoteUploadController.php +++ b/app/Http/Controllers/Api/Remote/Backups/BackupRemoteUploadController.php @@ -52,7 +52,7 @@ class BackupRemoteUploadController extends Controller public function __invoke(Request $request, string $backup) { // Get the size query parameter. - $size = (int)$request->query('size'); + $size = (int) $request->query('size'); if (empty($size)) { throw new BadRequestHttpException('A non-empty "size" query parameter must be provided.'); } diff --git a/app/Http/Requests/Admin/Egg/EggFormRequest.php b/app/Http/Requests/Admin/Egg/EggFormRequest.php index bda0e8c4d..2c865f228 100644 --- a/app/Http/Requests/Admin/Egg/EggFormRequest.php +++ b/app/Http/Requests/Admin/Egg/EggFormRequest.php @@ -21,7 +21,7 @@ class EggFormRequest extends AdminFormRequest $rules = [ 'name' => 'required|string|max:191', 'description' => 'nullable|string', - 'docker_image' => 'required|string|max:191', + 'docker_images' => 'required|string', 'startup' => 'required|string', 'config_from' => 'sometimes|bail|nullable|numeric', 'config_stop' => 'required_without:config_from|nullable|string|max:191', diff --git a/app/Http/Requests/Admin/ServerFormRequest.php b/app/Http/Requests/Admin/ServerFormRequest.php index 6f930615c..585834224 100644 --- a/app/Http/Requests/Admin/ServerFormRequest.php +++ b/app/Http/Requests/Admin/ServerFormRequest.php @@ -1,11 +1,4 @@ . - * - * This software is licensed under the terms of the MIT license. - * https://opensource.org/licenses/MIT - */ namespace Pterodactyl\Http\Requests\Admin; @@ -23,6 +16,7 @@ class ServerFormRequest extends AdminFormRequest { $rules = Server::getRules(); $rules['description'][] = 'nullable'; + $rules['custom_image'] = 'sometimes|nullable|string'; return $rules; } diff --git a/app/Http/Requests/Api/Client/Servers/Files/PullFileRequest.php b/app/Http/Requests/Api/Client/Servers/Files/PullFileRequest.php new file mode 100644 index 000000000..02a2fd376 --- /dev/null +++ b/app/Http/Requests/Api/Client/Servers/Files/PullFileRequest.php @@ -0,0 +1,29 @@ + 'required|string|url', + 'directory' => 'sometimes|nullable|string', + ]; + } +} diff --git a/app/Http/Requests/Api/Client/Servers/Settings/SetDockerImageRequest.php b/app/Http/Requests/Api/Client/Servers/Settings/SetDockerImageRequest.php new file mode 100644 index 000000000..be0b7213a --- /dev/null +++ b/app/Http/Requests/Api/Client/Servers/Settings/SetDockerImageRequest.php @@ -0,0 +1,36 @@ +route()->parameter('server'); + + Assert::isInstanceOf($server, Server::class); + + return [ + 'docker_image' => ['required', 'string', Rule::in($server->egg->docker_images)], + ]; + } +} diff --git a/app/Models/Egg.php b/app/Models/Egg.php index ce2b52c0c..aed4be7e3 100644 --- a/app/Models/Egg.php +++ b/app/Models/Egg.php @@ -10,7 +10,9 @@ namespace Pterodactyl\Models; * @property string $name * @property string|null $description * @property array|null $features - * @property string $docker_image + * @property string $docker_image -- deprecated, use $docker_images + * @property string $update_url + * @property array $docker_images * @property string|null $config_files * @property string|null $config_startup * @property string|null $config_logs @@ -76,7 +78,7 @@ class Egg extends Model 'name', 'description', 'features', - 'docker_image', + 'docker_images', 'config_files', 'config_startup', 'config_logs', @@ -101,6 +103,7 @@ class Egg extends Model 'script_is_privileged' => 'boolean', 'copy_script_from' => 'integer', 'features' => 'array', + 'docker_images' => 'array', ]; /** @@ -113,13 +116,15 @@ class Egg extends Model 'description' => 'string|nullable', 'features' => 'array|nullable', 'author' => 'required|string|email', - 'docker_image' => 'required|string|max:191', + 'docker_images' => 'required|array|min:1', + 'docker_images.*' => 'required|string', 'startup' => 'required|nullable|string', 'config_from' => 'sometimes|bail|nullable|numeric|exists:eggs,id', 'config_stop' => 'required_without:config_from|nullable|string|max:191', 'config_startup' => 'required_without:config_from|nullable|json', 'config_logs' => 'required_without:config_from|nullable|json', 'config_files' => 'required_without:config_from|nullable|json', + 'update_url' => 'sometimes|nullable|string', ]; /** @@ -131,6 +136,7 @@ class Egg extends Model 'config_startup' => null, 'config_logs' => null, 'config_files' => null, + 'update_url' => null, ]; /** diff --git a/app/Models/Permission.php b/app/Models/Permission.php index 180d844f0..8f6f219c3 100644 --- a/app/Models/Permission.php +++ b/app/Models/Permission.php @@ -58,6 +58,7 @@ class Permission extends Model const ACTION_STARTUP_READ = 'startup.read'; const ACTION_STARTUP_UPDATE = 'startup.update'; + const ACTION_STARTUP_DOCKER_IMAGE = 'startup.docker-image'; const ACTION_SETTINGS_RENAME = 'settings.rename'; const ACTION_SETTINGS_REINSTALL = 'settings.reinstall'; @@ -176,6 +177,7 @@ class Permission extends Model 'keys' => [ 'read' => 'Allows a user to view the startup variables for a server.', 'update' => 'Allows a user to modify the startup variables for the server.', + 'docker-image' => 'Allows a user to modify the Docker image used when running the server.', ], ], diff --git a/app/Repositories/Wings/DaemonBackupRepository.php b/app/Repositories/Wings/DaemonBackupRepository.php index 6506ac8a2..0eb9d4792 100644 --- a/app/Repositories/Wings/DaemonBackupRepository.php +++ b/app/Repositories/Wings/DaemonBackupRepository.php @@ -2,6 +2,7 @@ namespace Pterodactyl\Repositories\Wings; +use Illuminate\Support\Arr; use Webmozart\Assert\Assert; use Pterodactyl\Models\Backup; use Pterodactyl\Models\Server; @@ -48,7 +49,7 @@ class DaemonBackupRepository extends DaemonRepository 'json' => [ 'adapter' => $this->adapter ?? config('backups.default'), 'uuid' => $backup->uuid, - 'ignored_files' => $backup->ignored_files, + 'ignore' => implode('\n', $backup->ignored_files), ], ] ); diff --git a/app/Repositories/Wings/DaemonFileRepository.php b/app/Repositories/Wings/DaemonFileRepository.php index c36a8abb0..da6cb7ae2 100644 --- a/app/Repositories/Wings/DaemonFileRepository.php +++ b/app/Repositories/Wings/DaemonFileRepository.php @@ -37,7 +37,7 @@ class DaemonFileRepository extends DaemonRepository throw new DaemonConnectionException($exception); } - $length = (int) $response->getHeader('Content-Length')[0] ?? 0; + $length = (int)$response->getHeader('Content-Length')[0] ?? 0; if ($notLargerThan && $length > $notLargerThan) { throw new FileSizeTooLargeException; @@ -297,4 +297,29 @@ class DaemonFileRepository extends DaemonRepository throw new DaemonConnectionException($exception); } } + + /** + * Pulls a file from the given URL and saves it to the disk. + * + * @param string $url + * @param string|null $directory + * @return \Psr\Http\Message\ResponseInterface + * + * @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException + */ + public function pull(string $url, ?string $directory): ResponseInterface + { + Assert::isInstanceOf($this->server, Server::class); + + try { + return $this->getHttpClient()->post( + sprintf('/api/servers/%s/files/pull', $this->server->uuid), + [ + 'json' => ['url' => $url, 'directory' => $directory ?? '/'], + ] + ); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } + } } diff --git a/app/Services/Backups/InitiateBackupService.php b/app/Services/Backups/InitiateBackupService.php index 210e92342..615f357ce 100644 --- a/app/Services/Backups/InitiateBackupService.php +++ b/app/Services/Backups/InitiateBackupService.php @@ -13,7 +13,6 @@ use Pterodactyl\Repositories\Eloquent\BackupRepository; use Pterodactyl\Repositories\Wings\DaemonBackupRepository; use Pterodactyl\Exceptions\Service\Backup\TooManyBackupsException; use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException; -use Pterodactyl\Services\Backups\DeleteBackupService; class InitiateBackupService { @@ -118,14 +117,17 @@ class InitiateBackupService } // Check if the server has reached or exceeded it's backup limit - if (!$server->backup_limit || $server->backups()->where('is_successful', true)->count() >= $server->backup_limit) { + if (! $server->backup_limit || $server->backups()->where('is_successful', true)->count() >= $server->backup_limit) { // Do not allow the user to continue if this server is already at its limit and can't override. - if (!$override || $server->backup_limit <= 0) { + if (! $override || $server->backup_limit <= 0) { throw new TooManyBackupsException($server->backup_limit); } - // Remove oldest backup - $oldestBackup = $server->backups()->where('is_successful', true)->orderByDesc('created_at')->first(); + // Get the oldest backup the server has. + /** @var \Pterodactyl\Models\Backup $oldestBackup */ + $oldestBackup = $server->backups()->where('is_successful', true)->orderBy('created_at')->first(); + + // Delete the oldest backup. $this->deleteBackupService->handle($oldestBackup); } diff --git a/app/Services/Eggs/Sharing/EggExporterService.php b/app/Services/Eggs/Sharing/EggExporterService.php index 2d32060ac..bd26a1ea1 100644 --- a/app/Services/Eggs/Sharing/EggExporterService.php +++ b/app/Services/Eggs/Sharing/EggExporterService.php @@ -38,13 +38,14 @@ class EggExporterService '_comment' => 'DO NOT EDIT: FILE GENERATED AUTOMATICALLY BY PTERODACTYL PANEL - PTERODACTYL.IO', 'meta' => [ 'version' => 'PTDL_v1', + 'update_url' => $egg->update_url, ], 'exported_at' => Carbon::now()->toIso8601String(), 'name' => $egg->name, 'author' => $egg->author, 'description' => $egg->description, 'features' => $egg->features, - 'image' => $egg->docker_image, + 'images' => $egg->docker_images, 'startup' => $egg->startup, 'config' => [ 'files' => $egg->inherit_config_files, diff --git a/app/Services/Eggs/Sharing/EggImporterService.php b/app/Services/Eggs/Sharing/EggImporterService.php index 786cf7a50..8955b1870 100644 --- a/app/Services/Eggs/Sharing/EggImporterService.php +++ b/app/Services/Eggs/Sharing/EggImporterService.php @@ -102,7 +102,10 @@ class EggImporterService 'name' => object_get($parsed, 'name'), 'description' => object_get($parsed, 'description'), 'features' => object_get($parsed, 'features'), - 'docker_image' => object_get($parsed, 'image'), + // Maintain backwards compatability for eggs that are still using the old single image + // string format. New eggs can provide an array of Docker images that can be used. + 'docker_images' => object_get($parsed, 'images') ?? [object_get($parsed, 'image')], + 'update_url' => object_get($parsed, 'meta.update_url'), 'config_files' => object_get($parsed, 'config.files'), 'config_startup' => object_get($parsed, 'config.startup'), 'config_logs' => object_get($parsed, 'config.logs'), diff --git a/app/Services/Eggs/Sharing/EggUpdateImporterService.php b/app/Services/Eggs/Sharing/EggUpdateImporterService.php index 3a3913a58..b4705d25e 100644 --- a/app/Services/Eggs/Sharing/EggUpdateImporterService.php +++ b/app/Services/Eggs/Sharing/EggUpdateImporterService.php @@ -87,7 +87,9 @@ class EggUpdateImporterService 'name' => object_get($parsed, 'name'), 'description' => object_get($parsed, 'description'), 'features' => object_get($parsed, 'features'), - 'docker_image' => object_get($parsed, 'image'), + // Maintain backwards compatibility for eggs that are still using the old single image + // string format. New eggs can provide an array of Docker images that can be used. + 'docker_images' => object_get($parsed, 'images') ?? [object_get($parsed, 'image')], 'config_files' => object_get($parsed, 'config.files'), 'config_startup' => object_get($parsed, 'config.startup'), 'config_logs' => object_get($parsed, 'config.logs'), diff --git a/app/Transformers/Api/Application/EggTransformer.php b/app/Transformers/Api/Application/EggTransformer.php index a3686341e..96a0abfb9 100644 --- a/app/Transformers/Api/Application/EggTransformer.php +++ b/app/Transformers/Api/Application/EggTransformer.php @@ -45,7 +45,11 @@ class EggTransformer extends BaseTransformer 'nest' => $model->nest_id, 'author' => $model->author, 'description' => $model->description, - 'docker_image' => $model->docker_image, + // "docker_image" is deprecated, but left here to avoid breaking too many things at once + // in external software. We'll remove it down the road once things have gotten the chance + // to upgrade to using "docker_images". + 'docker_image' => count($model->docker_images) > 0 ? $model->docker_images[0] : '', + 'docker_images' => $model->docker_images, 'config' => [ 'files' => json_decode($model->config_files, true), 'startup' => json_decode($model->config_startup, true), diff --git a/app/Transformers/Api/Client/ServerTransformer.php b/app/Transformers/Api/Client/ServerTransformer.php index 637772094..9897f8517 100644 --- a/app/Transformers/Api/Client/ServerTransformer.php +++ b/app/Transformers/Api/Client/ServerTransformer.php @@ -63,6 +63,7 @@ class ServerTransformer extends BaseClientTransformer 'cpu' => $server->cpu, ], 'invocation' => $service->handle($server, ! $this->getUser()->can(Permission::ACTION_STARTUP_READ, $server)), + 'docker_image' => $server->image, 'egg_features' => $server->egg->inherit_features, 'feature_limits' => [ 'databases' => $server->database_limit, diff --git a/app/Transformers/Daemon/FileObjectTransformer.php b/app/Transformers/Daemon/FileObjectTransformer.php index f19d9028c..b2c1deed3 100644 --- a/app/Transformers/Daemon/FileObjectTransformer.php +++ b/app/Transformers/Daemon/FileObjectTransformer.php @@ -23,7 +23,7 @@ class FileObjectTransformer extends BaseDaemonTransformer public function transform(array $item) { return [ - 'name' => Arr::get($item, 'name'), + 'name' => rawurlencode(Arr::get($item, 'name')), 'mode' => Arr::get($item, 'mode'), 'mode_bits' => Arr::get($item, 'mode_bits'), 'size' => Arr::get($item, 'size'), diff --git a/config/backups.php b/config/backups.php index 32ee1aa8a..a309a9ee6 100644 --- a/config/backups.php +++ b/config/backups.php @@ -12,6 +12,10 @@ return [ // uses to upload backups to S3 storage. Value is in minutes, so this would default to an hour. 'presigned_url_lifespan' => env('BACKUP_PRESIGNED_URL_LIFESPAN', 60), + // The time to wait before automatically failing a backup, time is in minutes and defaults + // to 6 hours. To disable this feature, set the value to `0`. + 'prune_age' => env('BACKUP_PRUNE_AGE', 360), + 'disks' => [ // There is no configuration for the local disk for Wings. That configuration // is determined by the Daemon configuration, and not the Panel. diff --git a/database/migrations/2019_03_02_151321_fix_unique_index_to_account_for_host.php b/database/migrations/2019_03_02_151321_fix_unique_index_to_account_for_host.php index fe5f85f88..59425aee7 100644 --- a/database/migrations/2019_03_02_151321_fix_unique_index_to_account_for_host.php +++ b/database/migrations/2019_03_02_151321_fix_unique_index_to_account_for_host.php @@ -30,6 +30,8 @@ class FixUniqueIndexToAccountForHost extends Migration public function down() { Schema::table('databases', function (Blueprint $table) { + $table->dropForeign(['database_host_id']); + $table->dropUnique(['database_host_id', 'database']); $table->dropUnique(['database_host_id', 'username']); diff --git a/database/migrations/2020_03_22_163911_merge_permissions_table_into_subusers.php b/database/migrations/2020_03_22_163911_merge_permissions_table_into_subusers.php index f46481b47..b40f4f55d 100644 --- a/database/migrations/2020_03_22_163911_merge_permissions_table_into_subusers.php +++ b/database/migrations/2020_03_22_163911_merge_permissions_table_into_subusers.php @@ -108,7 +108,8 @@ class MergePermissionsTableIntoSubusers extends Migration foreach (DB::select('SELECT id, permissions FROM subusers') as $datum) { $values = []; foreach (json_decode($datum->permissions, true) as $permission) { - if (! empty($v = $flipped[$permission])) { + $v = $flipped[$permission] ?? null; + if (! empty($v)) { $values[] = $datum->id; $values[] = $v; } diff --git a/database/migrations/2020_12_12_102435_support_multiple_docker_images_and_updates.php b/database/migrations/2020_12_12_102435_support_multiple_docker_images_and_updates.php new file mode 100644 index 000000000..a7cd7310d --- /dev/null +++ b/database/migrations/2020_12_12_102435_support_multiple_docker_images_and_updates.php @@ -0,0 +1,51 @@ +json('docker_images')->after('docker_image')->nullable(); + $table->text('update_url')->after('docker_images')->nullable(); + }); + + Schema::table('eggs', function (Blueprint $table) { + DB::statement('UPDATE `eggs` SET `docker_images` = JSON_ARRAY(docker_image)'); + }); + + Schema::table('eggs', function (Blueprint $table) { + $table->dropColumn('docker_image'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('eggs', function (Blueprint $table) { + $table->text('docker_image')->after('docker_images'); + }); + + Schema::table('eggs', function (Blueprint $table) { + DB::statement('UPDATE `eggs` SET `docker_image` = JSON_UNQUOTE(JSON_EXTRACT(docker_images, "$[0]"))'); + }); + + Schema::table('eggs', function (Blueprint $table) { + $table->dropColumn('docker_images'); + $table->dropColumn('update_url'); + }); + } +} diff --git a/database/seeds/eggs/minecraft/egg-forge-minecraft.json b/database/seeds/eggs/minecraft/egg-forge-minecraft.json index 2020d53a4..75a23d147 100644 --- a/database/seeds/eggs/minecraft/egg-forge-minecraft.json +++ b/database/seeds/eggs/minecraft/egg-forge-minecraft.json @@ -3,7 +3,7 @@ "meta": { "version": "PTDL_v1" }, - "exported_at": "2020-11-03T04:22:56+00:00", + "exported_at": "2020-12-06T17:39:27-08:00", "name": "Forge Minecraft", "author": "support@pterodactyl.io", "description": "Minecraft Forge Server. Minecraft Forge is a modding API (Application Programming Interface), which makes it easier to create mods, and also make sure mods are compatible with each other.", @@ -34,7 +34,7 @@ "rules": "required|regex:\/^([\\w\\d._-]+)(\\.jar)$\/" }, { - "name": "Forge version", + "name": "Minecraft Version", "description": "The version of minecraft you want to install for.\r\n\r\nLeaving latest will install the latest recommended version.", "env_variable": "MC_VERSION", "default_value": "latest", @@ -58,7 +58,7 @@ "default_value": "", "user_viewable": true, "user_editable": true, - "rules": "required|string|max:20" + "rules": "nullable|string|max:20" } ] -} +} \ No newline at end of file diff --git a/database/seeds/eggs/minecraft/egg-sponge--sponge-vanilla.json b/database/seeds/eggs/minecraft/egg-sponge--sponge-vanilla.json index 0a695ac2e..db3f7fc94 100644 --- a/database/seeds/eggs/minecraft/egg-sponge--sponge-vanilla.json +++ b/database/seeds/eggs/minecraft/egg-sponge--sponge-vanilla.json @@ -28,7 +28,7 @@ "name": "Sponge Version", "description": "The version of SpongeVanilla to download and use.", "env_variable": "SPONGE_VERSION", - "default_value": "1.11.2-6.1.0-BETA-21", + "default_value": "1.12.2-7.3.0", "user_viewable": true, "user_editable": false, "rules": "required|regex:\/^([a-zA-Z0-9.\\-_]+)$\/" diff --git a/public/themes/pterodactyl/js/admin/new-server.js b/public/themes/pterodactyl/js/admin/new-server.js index cda0d5cf3..67827cf06 100644 --- a/public/themes/pterodactyl/js/admin/new-server.js +++ b/public/themes/pterodactyl/js/admin/new-server.js @@ -82,7 +82,13 @@ $('#pEggId').on('change', function (event) { let parentChain = _.get(Pterodactyl.nests, $('#pNestId').val(), null); let objectChain = _.get(parentChain, 'eggs.' + $(this).val(), null); - $('#pDefaultContainer').val(_.get(objectChain, 'docker_image', 'not defined!')); + const images = _.get(objectChain, 'docker_images', []) + for (let i = 0; i < images.length; i++) { + let opt = document.createElement('option'); + opt.value = images[i]; + opt.innerHTML = images[i]; + $('#pDefaultContainer').append(opt); + } if (!_.get(objectChain, 'startup', false)) { $('#pStartup').val(_.get(parentChain, 'startup', 'ERROR: Startup Not Defined!')); diff --git a/resources/scripts/api/server/files/getFileContents.ts b/resources/scripts/api/server/files/getFileContents.ts index da380362d..ef25b1dbc 100644 --- a/resources/scripts/api/server/files/getFileContents.ts +++ b/resources/scripts/api/server/files/getFileContents.ts @@ -3,7 +3,7 @@ import http from '@/api/http'; export default (server: string, file: string): Promise => { return new Promise((resolve, reject) => { http.get(`/api/client/servers/${server}/files/contents`, { - params: { file: encodeURI(decodeURI(file)) }, + params: { file }, transformResponse: res => res, responseType: 'text', }) diff --git a/resources/scripts/api/server/files/loadDirectory.ts b/resources/scripts/api/server/files/loadDirectory.ts index 52bf8853e..d53a2634f 100644 --- a/resources/scripts/api/server/files/loadDirectory.ts +++ b/resources/scripts/api/server/files/loadDirectory.ts @@ -18,7 +18,9 @@ export interface FileObject { export default async (uuid: string, directory?: string): Promise => { const { data } = await http.get(`/api/client/servers/${uuid}/files/list`, { - params: { directory: encodeURI(directory ?? '/') }, + // At this point the directory is still encoded so we need to decode it since axios + // will automatically re-encode this value before sending it along in the request. + params: { directory: directory ?? '/' }, }); return (data.data || []).map(rawDataToFileObject); diff --git a/resources/scripts/api/server/files/saveFileContents.ts b/resources/scripts/api/server/files/saveFileContents.ts index 7f6f44efc..b97e60a6b 100644 --- a/resources/scripts/api/server/files/saveFileContents.ts +++ b/resources/scripts/api/server/files/saveFileContents.ts @@ -2,7 +2,7 @@ import http from '@/api/http'; export default async (uuid: string, file: string, content: string): Promise => { await http.post(`/api/client/servers/${uuid}/files/write`, content, { - params: { file: encodeURI(decodeURI(file)) }, + params: { file }, headers: { 'Content-Type': 'text/plain', }, diff --git a/resources/scripts/api/server/getServer.ts b/resources/scripts/api/server/getServer.ts index 3521ed0da..d9b76b400 100644 --- a/resources/scripts/api/server/getServer.ts +++ b/resources/scripts/api/server/getServer.ts @@ -22,6 +22,7 @@ export interface Server { port: number; }; invocation: string; + dockerImage: string; description: string; limits: { memory: number; @@ -50,6 +51,7 @@ export const rawDataToServerObject = ({ attributes: data }: FractalResponseData) name: data.name, node: data.node, invocation: data.invocation, + dockerImage: data.docker_image, sftpDetails: { ip: data.sftp_details.ip, port: data.sftp_details.port, diff --git a/resources/scripts/api/server/setSelectedDockerImage.ts b/resources/scripts/api/server/setSelectedDockerImage.ts new file mode 100644 index 000000000..70042f3a6 --- /dev/null +++ b/resources/scripts/api/server/setSelectedDockerImage.ts @@ -0,0 +1,5 @@ +import http from '@/api/http'; + +export default async (uuid: string, image: string): Promise => { + await http.put(`/api/client/servers/${uuid}/settings/docker-image`, { docker_image: image }); +}; diff --git a/resources/scripts/api/swr/getServerStartup.ts b/resources/scripts/api/swr/getServerStartup.ts index 892f78fdd..b7089b7bd 100644 --- a/resources/scripts/api/swr/getServerStartup.ts +++ b/resources/scripts/api/swr/getServerStartup.ts @@ -6,6 +6,7 @@ import { ServerEggVariable } from '@/api/server/types'; interface Response { invocation: string; variables: ServerEggVariable[]; + dockerImages: string[]; } export default (uuid: string, initialData?: Response) => useSWR([ uuid, '/startup' ], async (): Promise => { @@ -13,5 +14,5 @@ export default (uuid: string, initialData?: Response) => useSWR([ uuid, '/startu const variables = ((data as FractalResponseList).data || []).map(rawDataToServerEggVariable); - return { invocation: data.meta.startup_command, variables }; + return { invocation: data.meta.startup_command, variables, dockerImages: data.meta.docker_images || [] }; }, { initialData, errorRetryCount: 3 }); diff --git a/resources/scripts/components/elements/InputSpinner.tsx b/resources/scripts/components/elements/InputSpinner.tsx index cac920f8a..f26fba621 100644 --- a/resources/scripts/components/elements/InputSpinner.tsx +++ b/resources/scripts/components/elements/InputSpinner.tsx @@ -2,16 +2,28 @@ import React from 'react'; import Spinner from '@/components/elements/Spinner'; import Fade from '@/components/elements/Fade'; import tw from 'twin.macro'; +import styled, { css } from 'styled-components/macro'; +import Select from '@/components/elements/Select'; + +const Container = styled.div<{ visible?: boolean }>` + ${tw`relative`}; + + ${props => props.visible && css` + & ${Select} { + background-image: none; + } + `}; +`; const InputSpinner = ({ visible, children }: { visible: boolean, children: React.ReactNode }) => ( -
+
{children} -
+ ); export default InputSpinner; diff --git a/resources/scripts/components/server/databases/DatabaseRow.tsx b/resources/scripts/components/server/databases/DatabaseRow.tsx index caf4a702e..148415a13 100644 --- a/resources/scripts/components/server/databases/DatabaseRow.tsx +++ b/resources/scripts/components/server/databases/DatabaseRow.tsx @@ -127,7 +127,7 @@ export default ({ database, className }: Props) => {
- +
diff --git a/resources/scripts/components/server/files/FileEditContainer.tsx b/resources/scripts/components/server/files/FileEditContainer.tsx index 4dd519f8d..f26baf8b0 100644 --- a/resources/scripts/components/server/files/FileEditContainer.tsx +++ b/resources/scripts/components/server/files/FileEditContainer.tsx @@ -61,7 +61,7 @@ export default () => { setLoading(true); clearFlashes('files:view'); fetchFileContent() - .then(content => saveFileContents(uuid, name || hash.replace(/^#/, ''), content)) + .then(content => saveFileContents(uuid, name || decodeURI(hash.replace(/^#/, '')), content)) .then(() => { if (name) { history.push(`/server/${id}/files/edit#/${name}`); @@ -87,7 +87,9 @@ export default () => { - +
+ +
{hash.replace(/^#/, '').endsWith('.pteroignore') &&
diff --git a/resources/scripts/components/server/files/FileManagerBreadcrumbs.tsx b/resources/scripts/components/server/files/FileManagerBreadcrumbs.tsx index 9b1596f5c..7353edadf 100644 --- a/resources/scripts/components/server/files/FileManagerBreadcrumbs.tsx +++ b/resources/scripts/components/server/files/FileManagerBreadcrumbs.tsx @@ -1,33 +1,41 @@ import React, { useEffect, useState } from 'react'; import { ServerContext } from '@/state/server'; -import { NavLink, useRouteMatch } from 'react-router-dom'; +import { NavLink, useLocation } from 'react-router-dom'; import { cleanDirectoryPath } from '@/helpers'; import tw from 'twin.macro'; -import { FileActionCheckbox } from '@/components/server/files/SelectFileCheckbox'; -import useFileManagerSwr from '@/plugins/useFileManagerSwr'; interface Props { + renderLeft?: JSX.Element; withinFileEditor?: boolean; isNewFile?: boolean; } -export default ({ withinFileEditor, isNewFile }: Props) => { +export default ({ renderLeft, withinFileEditor, isNewFile }: Props) => { const [ file, setFile ] = useState(null); - const { params } = useRouteMatch>(); const id = ServerContext.useStoreState(state => state.server.data!.id); const directory = ServerContext.useStoreState(state => state.files.directory); - - const { data: files } = useFileManagerSwr(); - const setSelectedFiles = ServerContext.useStoreActions(actions => actions.files.setSelectedFiles); - const selectedFilesLength = ServerContext.useStoreState(state => state.files.selectedFiles.length); + const { hash } = useLocation(); useEffect(() => { - const parts = cleanDirectoryPath(window.location.hash).split('/'); + let pathHash = cleanDirectoryPath(hash); + try { + pathHash = decodeURI(pathHash); + } catch (e) { + console.warn('Error decoding URL parts in hash:', e); + } if (withinFileEditor && !isNewFile) { - setFile(parts.pop() || null); + let name = pathHash.split('/').pop() || null; + if (name) { + try { + name = decodeURIComponent(name); + } catch (e) { + console.warn('Error decoding filename:', e); + } + } + setFile(name); } - }, [ withinFileEditor, isNewFile ]); + }, [ withinFileEditor, isNewFile, hash ]); const breadcrumbs = (): { name: string; path?: string }[] => directory.split('/') .filter(directory => !!directory) @@ -39,22 +47,9 @@ export default ({ withinFileEditor, isNewFile }: Props) => { return { name: directory, path: `/${dirs.slice(0, index + 1).join('/')}` }; }); - const onSelectAllClick = (e: React.ChangeEvent) => { - setSelectedFiles(e.currentTarget.checked ? (files?.map(file => file.name) || []) : []); - }; - return (
- {(files && files.length > 0 && !params?.action) ? - - : -
- } + {renderLeft ||
} /home/ { to={`/server/${id}/files#${crumb.path}`} css={tw`px-1 text-neutral-200 no-underline hover:text-neutral-100`} > - {crumb.name} + {decodeURIComponent(crumb.name)} / : - {crumb.name} + {decodeURIComponent(crumb.name)} )) } {file && - {decodeURI(file)} + {file} }
diff --git a/resources/scripts/components/server/files/FileManagerContainer.tsx b/resources/scripts/components/server/files/FileManagerContainer.tsx index 67009605e..613a1baaa 100644 --- a/resources/scripts/components/server/files/FileManagerContainer.tsx +++ b/resources/scripts/components/server/files/FileManagerContainer.tsx @@ -18,6 +18,7 @@ import UploadButton from '@/components/server/files/UploadButton'; import ServerContentBlock from '@/components/elements/ServerContentBlock'; import { useStoreActions } from '@/state/hooks'; import ErrorBoundary from '@/components/elements/ErrorBoundary'; +import { FileActionCheckbox } from '@/components/server/files/SelectFileCheckbox'; const sortFiles = (files: FileObject[]): FileObject[] => { return files.sort((a, b) => a.name.localeCompare(b.name)) @@ -31,18 +32,24 @@ export default () => { const directory = ServerContext.useStoreState(state => state.files.directory); const clearFlashes = useStoreActions(actions => actions.flashes.clearFlashes); const setDirectory = ServerContext.useStoreActions(actions => actions.files.setDirectory); + const setSelectedFiles = ServerContext.useStoreActions(actions => actions.files.setSelectedFiles); + const selectedFilesLength = ServerContext.useStoreState(state => state.files.selectedFiles.length); useEffect(() => { clearFlashes('files'); setSelectedFiles([]); - setDirectory(hash.length > 0 ? decodeURI(hash) : '/'); + setDirectory(hash.length > 0 ? hash : '/'); }, [ hash ]); useEffect(() => { mutate(); }, [ directory ]); + const onSelectAllClick = (e: React.ChangeEvent) => { + setSelectedFiles(e.currentTarget.checked ? (files?.map(file => file.name) || []) : []); + }; + if (error) { return ( mutate()}/> @@ -53,9 +60,17 @@ export default () => {
- + + } + /> -
diff --git a/resources/scripts/components/server/files/FileObjectRow.tsx b/resources/scripts/components/server/files/FileObjectRow.tsx index 89a080759..fd18bd7ba 100644 --- a/resources/scripts/components/server/files/FileObjectRow.tsx +++ b/resources/scripts/components/server/files/FileObjectRow.tsx @@ -24,7 +24,7 @@ const Clickable: React.FC<{ file: FileObject }> = memo(({ file, children }) => { const history = useHistory(); const match = useRouteMatch(); - const destination = cleanDirectoryPath(`${directory}/${file.name}`).split('/').map(v => encodeURI(v)).join('/'); + const destination = cleanDirectoryPath(`${directory}/${file.name}`).split('/').join('/'); const onRowClick = (e: React.MouseEvent) => { // Don't rely on the onClick to work with the generated URL. Because of the way this @@ -64,11 +64,7 @@ const FileObjectRow = ({ file }: { file: FileObject }) => ( > - - -
+
{file.isFile ? : @@ -76,7 +72,7 @@ const FileObjectRow = ({ file }: { file: FileObject }) => ( }
- {file.name} + {decodeURIComponent(file.name)}
{file.isFile &&
- - -

The default docker image that should be used for new servers using this Egg. This can be changed per-server as needed.

+ + +

The docker images available to servers using this egg. Enter one per line. Users will be able to select from this list of images if more than one value is provided.

- +

A description of this Egg that will be displayed throughout the Panel as needed.

- +

The default startup command that should be used for new servers using this Egg.

diff --git a/resources/views/admin/servers/new.blade.php b/resources/views/admin/servers/new.blade.php index 1b74610d0..50ae7b90d 100644 --- a/resources/views/admin/servers/new.blade.php +++ b/resources/views/admin/servers/new.blade.php @@ -265,8 +265,9 @@
- -

This is the default Docker image that will be used to run this server.

+ + +

This is the default Docker image that will be used to run this server. Select an image from the dropdown above, or enter a custom image in the text field above.

@@ -323,11 +324,14 @@ @endforeach @endif @endif + @if(old('image')) + $('#pDefaultContainer').val('{{ old('image') }}'); + @endif } // END Persist 'Service Variables' - {!! Theme::js('js/admin/new-server.js?v=20201003') !!} + {!! Theme::js('js/admin/new-server.js?v=20201212') !!}