diff --git a/.env.example b/.env.example index 9062de21..67d496d4 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 640d6def..f707992d 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 1f83ddf9..4fa0845e 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 209dad69..4e3dd5e9 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 f63cf814..1cea7651 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 36373715..bec5ac4a 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 0e3a62f2..317115b2 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 7dfbf7b4..80fc0e18 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 e0c58027..7e06abf8 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 497d1290..6d50ad64 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 bda0e8c4..2c865f22 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 6f930615..58583422 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 00000000..02a2fd37 --- /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 00000000..be0b7213 --- /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 ce2b52c0..aed4be7e 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 180d844f..8f6f219c 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 6506ac8a..0eb9d479 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 c36a8abb..da6cb7ae 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 210e9234..615f357c 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 2d32060a..bd26a1ea 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 786cf7a5..8955b187 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 3a3913a5..b4705d25 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 a3686341..96a0abfb 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 63777209..9897f851 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 f19d9028..b2c1deed 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 32ee1aa8..a309a9ee 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 fe5f85f8..59425aee 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 f46481b4..b40f4f55 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 00000000..a7cd7310 --- /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 2020d53a..75a23d14 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 0a695ac2..db3f7fc9 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 cda0d5cf..67827cf0 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 da380362..ef25b1db 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 52bf8853..d53a2634 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 7f6f44ef..b97e60a6 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 3521ed0d..d9b76b40 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 00000000..70042f3a --- /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 892f78fd..b7089b7b 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 cac920f8..f26fba62 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 caf4a702..148415a1 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 4dd519f8..f26baf8b 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 9b1596f5..7353edad 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 67009605..613a1baa 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 89a08075..fd18bd7b 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 1b74610d..50ae7b90 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') !!}