Support naming docker images on eggs; closes #4052

Bumps PTDL_v1 export images to PTDL_v2, updates the Minecraft specific eggs to use named images.
This commit is contained in:
DaneEveritt 2022-05-07 17:45:22 -04:00
parent 53207abcb3
commit c8faf64059
No known key found for this signature in database
GPG key ID: EEA66103B3D71F53
17 changed files with 212 additions and 261 deletions

View file

@ -74,11 +74,7 @@ class EggController extends Controller
public function store(EggFormRequest $request): RedirectResponse
{
$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']));
}
$data['docker_images'] = $this->normalizeDockerImages($data['docker_images'] ?? null);
$egg = $this->creationService->handle($data);
$this->alert->success(trans('admin/nests.eggs.notices.egg_created'))->flash();
@ -91,7 +87,14 @@ class EggController extends Controller
*/
public function view(Egg $egg): View
{
return view('admin.eggs.view', ['egg' => $egg]);
return view('admin.eggs.view', [
'egg' => $egg,
'images' => array_map(
fn ($key, $value) => $key === $value ? $value : "$key|$value",
array_keys($egg->docker_images),
$egg->docker_images,
),
]);
}
/**
@ -104,11 +107,7 @@ class EggController extends Controller
public function update(EggFormRequest $request, Egg $egg): RedirectResponse
{
$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']));
}
$data['docker_images'] = $this->normalizeDockerImages($data['docker_images'] ?? null);
$this->updateService->handle($egg, $data);
$this->alert->success(trans('admin/nests.eggs.notices.updated'))->flash();
@ -129,4 +128,22 @@ class EggController extends Controller
return redirect()->route('admin.nests.view', $egg->nest_id);
}
/**
* Normalizes a string of docker image data into the expected egg format.
*/
protected function normalizeDockerImages(string $input = null): array
{
$data = array_map(fn ($value) => trim($value), explode("\n", $input ?? ''));
$images = [];
// Iterate over the image data provided and convert it into a name => image
// pairing that is used to improve the display on the front-end.
foreach ($data as $value) {
$parts = explode('|', $value, 2);
$images[$parts[0]] = empty($parts[1]) ? $parts[0] : $parts[1];
}
return $images;
}
}

View file

@ -78,7 +78,7 @@ class SettingsController extends ClientApiController
*/
public function dockerImage(SetDockerImageRequest $request, Server $server)
{
if (!in_array($server->image, $server->egg->docker_images)) {
if (!in_array($server->image, array_values($server->egg->docker_images))) {
throw new BadRequestHttpException('This server\'s Docker image has been manually set by an administrator and cannot be updated.');
}

View file

@ -27,7 +27,7 @@ class SetDockerImageRequest extends ClientApiRequest implements ClientPermission
Assert::isInstanceOf($server, Server::class);
return [
'docker_image' => ['required', 'string', Rule::in($server->egg->docker_images)],
'docker_image' => ['required', 'string', Rule::in(array_values($server->egg->docker_images))],
];
}
}

View file

@ -12,7 +12,7 @@ namespace Pterodactyl\Models;
* @property array|null $features
* @property string $docker_image -- deprecated, use $docker_images
* @property string $update_url
* @property array $docker_images
* @property array<string, string> $docker_images
* @property array|null $file_denylist
* @property string|null $config_files
* @property string|null $config_startup
@ -50,6 +50,11 @@ class Egg extends Model
*/
public const RESOURCE_NAME = 'egg';
/**
* Defines the current egg export version.
*/
public const EXPORT_VERSION = 'PTDL_v2';
/**
* Different features that can be enabled on any given egg. These are used internally
* to determine which types of frontend functionality should be shown to the user. Eggs

View file

@ -3,6 +3,7 @@
namespace Pterodactyl\Services\Eggs\Sharing;
use Carbon\Carbon;
use Pterodactyl\Models\Egg;
use Illuminate\Support\Collection;
use Pterodactyl\Models\EggVariable;
use Pterodactyl\Contracts\Repository\EggRepositoryInterface;
@ -34,7 +35,7 @@ class EggExporterService
$struct = [
'_comment' => 'DO NOT EDIT: FILE GENERATED AUTOMATICALLY BY PTERODACTYL PANEL - PTERODACTYL.IO',
'meta' => [
'version' => 'PTDL_v1',
'version' => Egg::EXPORT_VERSION,
'update_url' => $egg->update_url,
],
'exported_at' => Carbon::now()->toIso8601String(),
@ -42,7 +43,7 @@ class EggExporterService
'author' => $egg->author,
'description' => $egg->description,
'features' => $egg->features,
'images' => $egg->docker_images,
'docker_images' => $egg->docker_images,
'file_denylist' => Collection::make($egg->inherit_file_denylist)->filter(function ($value) {
return !empty($value);
}),
@ -63,6 +64,7 @@ class EggExporterService
'variables' => $egg->variables->transform(function (EggVariable $item) {
return Collection::make($item->toArray())
->except(['id', 'egg_id', 'created_at', 'updated_at'])
->merge(['field_type' => 'text'])
->toArray();
}),
];

View file

@ -10,7 +10,6 @@ use Illuminate\Support\Collection;
use Illuminate\Database\ConnectionInterface;
use Pterodactyl\Contracts\Repository\EggRepositoryInterface;
use Pterodactyl\Contracts\Repository\NestRepositoryInterface;
use Pterodactyl\Exceptions\Service\Egg\BadJsonFormatException;
use Pterodactyl\Exceptions\Service\InvalidFileUploadException;
use Pterodactyl\Contracts\Repository\EggVariableRepositoryInterface;
@ -56,8 +55,8 @@ class EggImporterService
*
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
* @throws \Pterodactyl\Exceptions\Service\Egg\BadJsonFormatException
* @throws \Pterodactyl\Exceptions\Service\InvalidFileUploadException
* @throws \JsonException
*/
public function handle(UploadedFile $file, int $nest): Egg
{
@ -66,13 +65,13 @@ class EggImporterService
}
/** @var array $parsed */
$parsed = json_decode($file->openFile()->fread($file->getSize()), true);
if (json_last_error() !== 0) {
throw new BadJsonFormatException(trans('exceptions.nest.importer.json_error', ['error' => json_last_error_msg()]));
$parsed = json_decode($file->openFile()->fread($file->getSize()), true, 512, JSON_THROW_ON_ERROR);
if (!in_array(Arr::get($parsed, 'meta.version') ?? '', ['PTDL_v1', 'PTDL_v2'])) {
throw new InvalidFileUploadException(trans('exceptions.nest.importer.invalid_json_provided'));
}
if (Arr::get($parsed, 'meta.version') !== 'PTDL_v1') {
throw new InvalidFileUploadException(trans('exceptions.nest.importer.invalid_json_provided'));
if ($parsed['meta']['version'] !== Egg::EXPORT_VERSION) {
$parsed = $this->convertV1ToV2($parsed);
}
$nest = $this->nestRepository->getWithEggs($nest);
@ -86,9 +85,7 @@ class EggImporterService
'name' => Arr::get($parsed, 'name'),
'description' => Arr::get($parsed, 'description'),
'features' => Arr::get($parsed, 'features'),
// 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' => Arr::get($parsed, 'images') ?? [Arr::get($parsed, 'image')],
'docker_images' => Arr::get($parsed, 'docker_images'),
'file_denylist' => Collection::make(Arr::get($parsed, 'file_denylist'))->filter(function ($value) {
return !empty($value);
}),
@ -105,6 +102,8 @@ class EggImporterService
], true, true);
Collection::make($parsed['variables'] ?? [])->each(function (array $variable) use ($egg) {
unset($variable['field_type']);
$this->eggVariableRepository->create(array_merge($variable, [
'egg_id' => $egg->id,
]));
@ -114,4 +113,33 @@ class EggImporterService
return $egg;
}
/**
* Converts a PTDL_V1 egg into the expected PTDL_V2 egg format. This just handles
* the "docker_images" field potentially not being present, and not being in the
* expected "key => value" format.
*/
protected function convertV1ToV2(array $parsed): array
{
// 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.
if (!isset($parsed['images'])) {
$images = [Arr::get($parsed, 'image') ?? 'nil'];
} else {
$images = $parsed['images'];
}
unset($parsed['images'], $parsed['image']);
$parsed['docker_images'] = [];
foreach ($images as $image) {
$parsed['docker_images'][$image] = $image;
}
$parsed['variables'] = array_map(function ($value) {
return array_merge($value, ['field_type' => 'text']);
}, $parsed['variables']);
return $parsed;
}
}

View file

@ -3,6 +3,7 @@
namespace Pterodactyl\Transformers\Api\Application;
use Pterodactyl\Models\Egg;
use Illuminate\Support\Arr;
use Pterodactyl\Models\Nest;
use Pterodactyl\Models\Server;
use Pterodactyl\Models\EggVariable;
@ -49,7 +50,7 @@ class EggTransformer extends BaseTransformer
// "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_image' => count($model->docker_images) > 0 ? Arr::first($model->docker_images) : '',
'docker_images' => $model->docker_images,
'config' => [
'files' => json_decode($model->config_files, true),

View file

@ -1,10 +1,10 @@
{
"_comment": "DO NOT EDIT: FILE GENERATED AUTOMATICALLY BY PTERODACTYL PANEL - PTERODACTYL.IO",
"meta": {
"version": "PTDL_v1",
"version": "PTDL_v2",
"update_url": null
},
"exported_at": "2021-11-14T19:23:12+00:00",
"exported_at": "2022-05-07T17:35:07-04:00",
"name": "Bungeecord",
"author": "support@pterodactyl.io",
"description": "For a long time, Minecraft server owners have had a dream that encompasses a free, easy, and reliable way to connect multiple Minecraft servers together. BungeeCord is the answer to said dream. Whether you are a small server wishing to string multiple game-modes together, or the owner of the ShotBow Network, BungeeCord is the ideal solution for you. With the help of BungeeCord, you will be able to unlock your community's full potential.",
@ -13,12 +13,12 @@
"java_version",
"pid_limit"
],
"images": [
"ghcr.io\/pterodactyl\/yolks:java_8",
"ghcr.io\/pterodactyl\/yolks:java_11",
"ghcr.io\/pterodactyl\/yolks:java_16",
"ghcr.io\/pterodactyl\/yolks:java_17"
],
"docker_images": {
"Java 17": "ghcr.io\/pterodactyl\/yolks:java_17",
"Java 16": "ghcr.io\/pterodactyl\/yolks:java_16",
"Java 11": "ghcr.io\/pterodactyl\/yolks:java_11",
"Java 8": "ghcr.io\/pterodactyl\/yolks:java_8"
},
"file_denylist": [],
"startup": "java -Xms128M -Xmx{{SERVER_MEMORY}}M -jar {{SERVER_JARFILE}}",
"config": {
@ -42,7 +42,8 @@
"default_value": "latest",
"user_viewable": true,
"user_editable": true,
"rules": "required|alpha_num|between:1,6"
"rules": "required|alpha_num|between:1,6",
"field_type": "text"
},
{
"name": "Bungeecord Jar File",
@ -51,7 +52,8 @@
"default_value": "bungeecord.jar",
"user_viewable": true,
"user_editable": true,
"rules": "required|regex:\/^([\\w\\d._-]+)(\\.jar)$\/"
"rules": "required|regex:\/^([\\w\\d._-]+)(\\.jar)$\/",
"field_type": "text"
}
]
}
}

View file

@ -1,10 +1,10 @@
{
"_comment": "DO NOT EDIT: FILE GENERATED AUTOMATICALLY BY PTERODACTYL PANEL - PTERODACTYL.IO",
"meta": {
"version": "PTDL_v1",
"version": "PTDL_v2",
"update_url": null
},
"exported_at": "2021-12-11T22:51:29+00:00",
"exported_at": "2022-05-07T17:35:08-04: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.",
@ -13,12 +13,12 @@
"java_version",
"pid_limit"
],
"images": [
"ghcr.io\/pterodactyl\/yolks:java_17",
"ghcr.io\/pterodactyl\/yolks:java_16",
"ghcr.io\/pterodactyl\/yolks:java_11",
"ghcr.io\/pterodactyl\/yolks:java_8"
],
"docker_images": {
"Java 17": "ghcr.io\/pterodactyl\/yolks:java_17",
"Java 16": "ghcr.io\/pterodactyl\/yolks:java_16",
"Java 11": "ghcr.io\/pterodactyl\/yolks:java_11",
"Java 8": "ghcr.io\/pterodactyl\/yolks:java_8"
},
"file_denylist": [],
"startup": "java -Xms128M -Xmx{{SERVER_MEMORY}}M -Dterminal.jline=false -Dterminal.ansi=true $( [[ ! -f unix_args.txt ]] && printf %s \"-jar {{SERVER_JARFILE}}\" || printf %s \"@unix_args.txt\" )",
"config": {
@ -42,7 +42,8 @@
"default_value": "server.jar",
"user_viewable": true,
"user_editable": true,
"rules": "required|regex:\/^([\\w\\d._-]+)(\\.jar)$\/"
"rules": "required|regex:\/^([\\w\\d._-]+)(\\.jar)$\/",
"field_type": "text"
},
{
"name": "Minecraft Version",
@ -51,7 +52,8 @@
"default_value": "latest",
"user_viewable": true,
"user_editable": true,
"rules": "required|string|max:9"
"rules": "required|string|max:9",
"field_type": "text"
},
{
"name": "Build Type",
@ -60,7 +62,8 @@
"default_value": "recommended",
"user_viewable": true,
"user_editable": true,
"rules": "required|string|in:recommended,latest"
"rules": "required|string|in:recommended,latest",
"field_type": "text"
},
{
"name": "Forge Version",
@ -69,7 +72,8 @@
"default_value": "",
"user_viewable": true,
"user_editable": true,
"rules": "nullable|string|max:25"
"rules": "nullable|string|max:25",
"field_type": "text"
}
]
}

View file

@ -1,10 +1,10 @@
{
"_comment": "DO NOT EDIT: FILE GENERATED AUTOMATICALLY BY PTERODACTYL PANEL - PTERODACTYL.IO",
"meta": {
"version": "PTDL_v1",
"version": "PTDL_v2",
"update_url": null
},
"exported_at": "2022-03-11T10:21:01-05:00",
"exported_at": "2022-05-07T17:35:09-04:00",
"name": "Paper",
"author": "parker@pterodactyl.io",
"description": "High performance Spigot fork that aims to fix gameplay and mechanics inconsistencies.",
@ -13,12 +13,12 @@
"java_version",
"pid_limit"
],
"images": [
"ghcr.io\/pterodactyl\/yolks:java_8",
"ghcr.io\/pterodactyl\/yolks:java_11",
"ghcr.io\/pterodactyl\/yolks:java_16",
"ghcr.io\/pterodactyl\/yolks:java_17"
],
"docker_images": {
"Java 17": "ghcr.io\/pterodactyl\/yolks:java_17",
"Java 16": "ghcr.io\/pterodactyl\/yolks:java_16",
"Java 11": "ghcr.io\/pterodactyl\/yolks:java_11",
"Java 8": "ghcr.io\/pterodactyl\/yolks:java_8"
},
"file_denylist": [],
"startup": "java -Xms128M -Xmx{{SERVER_MEMORY}}M -Dterminal.jline=false -Dterminal.ansi=true -jar {{SERVER_JARFILE}}",
"config": {
@ -42,7 +42,8 @@
"default_value": "latest",
"user_viewable": true,
"user_editable": true,
"rules": "nullable|string|max:20"
"rules": "nullable|string|max:20",
"field_type": "text"
},
{
"name": "Server Jar File",
@ -51,7 +52,8 @@
"default_value": "server.jar",
"user_viewable": true,
"user_editable": true,
"rules": "required|regex:\/^([\\w\\d._-]+)(\\.jar)$\/"
"rules": "required|regex:\/^([\\w\\d._-]+)(\\.jar)$\/",
"field_type": "text"
},
{
"name": "Download Path",
@ -60,7 +62,8 @@
"default_value": "",
"user_viewable": false,
"user_editable": false,
"rules": "nullable|string"
"rules": "nullable|string",
"field_type": "text"
},
{
"name": "Build Number",
@ -69,7 +72,8 @@
"default_value": "latest",
"user_viewable": true,
"user_editable": true,
"rules": "required|string|max:20"
"rules": "required|string|max:20",
"field_type": "text"
}
]
}
}

View file

@ -1,10 +1,10 @@
{
"_comment": "DO NOT EDIT: FILE GENERATED AUTOMATICALLY BY PTERODACTYL PANEL - PTERODACTYL.IO",
"meta": {
"version": "PTDL_v1",
"version": "PTDL_v2",
"update_url": null
},
"exported_at": "2021-10-22T19:19:17+02:00",
"exported_at": "2022-05-07T17:35:10-04:00",
"name": "Sponge (SpongeVanilla)",
"author": "support@pterodactyl.io",
"description": "SpongeVanilla is the SpongeAPI implementation for Vanilla Minecraft.",
@ -13,11 +13,11 @@
"java_version",
"pid_limit"
],
"images": [
"ghcr.io\/pterodactyl\/yolks:java_8",
"ghcr.io\/pterodactyl\/yolks:java_11",
"ghcr.io\/pterodactyl\/yolks:java_16"
],
"docker_images": {
"Java 16": "ghcr.io\/pterodactyl\/yolks:java_16",
"Java 11": "ghcr.io\/pterodactyl\/yolks:java_11",
"Java 8": "ghcr.io\/pterodactyl\/yolks:java_8"
},
"file_denylist": [],
"startup": "java -Xms128M -Xmx{{SERVER_MEMORY}}M -jar {{SERVER_JARFILE}}",
"config": {
@ -41,7 +41,8 @@
"default_value": "1.12.2-7.3.0",
"user_viewable": true,
"user_editable": true,
"rules": "required|regex:\/^([a-zA-Z0-9.\\-_]+)$\/"
"rules": "required|regex:\/^([a-zA-Z0-9.\\-_]+)$\/",
"field_type": "text"
},
{
"name": "Server Jar File",
@ -50,7 +51,8 @@
"default_value": "server.jar",
"user_viewable": true,
"user_editable": true,
"rules": "required|regex:\/^([\\w\\d._-]+)(\\.jar)$\/"
"rules": "required|regex:\/^([\\w\\d._-]+)(\\.jar)$\/",
"field_type": "text"
}
]
}
}

View file

@ -1,10 +1,10 @@
{
"_comment": "DO NOT EDIT: FILE GENERATED AUTOMATICALLY BY PTERODACTYL PANEL - PTERODACTYL.IO",
"meta": {
"version": "PTDL_v1",
"version": "PTDL_v2",
"update_url": null
},
"exported_at": "2021-11-14T19:18:30+00:00",
"exported_at": "2022-05-07T17:35:11-04:00",
"name": "Vanilla Minecraft",
"author": "support@pterodactyl.io",
"description": "Minecraft is a game about placing blocks and going on adventures. Explore randomly generated worlds and build amazing things from the simplest of homes to the grandest of castles. Play in Creative Mode with unlimited resources or mine deep in Survival Mode, crafting weapons and armor to fend off dangerous mobs. Do all this alone or with friends.",
@ -13,12 +13,12 @@
"java_version",
"pid_limit"
],
"images": [
"ghcr.io\/pterodactyl\/yolks:java_8",
"ghcr.io\/pterodactyl\/yolks:java_11",
"ghcr.io\/pterodactyl\/yolks:java_16",
"ghcr.io\/pterodactyl\/yolks:java_17"
],
"docker_images": {
"Java 17": "ghcr.io\/pterodactyl\/yolks:java_17",
"Java 16": "ghcr.io\/pterodactyl\/yolks:java_16",
"Java 11": "ghcr.io\/pterodactyl\/yolks:java_11",
"Java 8": "ghcr.io\/pterodactyl\/yolks:java_8"
},
"file_denylist": [],
"startup": "java -Xms128M -Xmx{{SERVER_MEMORY}}M -jar {{SERVER_JARFILE}}",
"config": {
@ -42,7 +42,8 @@
"default_value": "server.jar",
"user_viewable": true,
"user_editable": true,
"rules": "required|regex:\/^([\\w\\d._-]+)(\\.jar)$\/"
"rules": "required|regex:\/^([\\w\\d._-]+)(\\.jar)$\/",
"field_type": "text"
},
{
"name": "Server Version",
@ -51,7 +52,8 @@
"default_value": "latest",
"user_viewable": true,
"user_editable": true,
"rules": "required|string|between:3,15"
"rules": "required|string|between:3,15",
"field_type": "text"
}
]
}
}

View file

@ -0,0 +1,40 @@
<?php
use Illuminate\Support\Facades\DB;
use Illuminate\Database\Migrations\Migration;
class MigrateEggImagesArrayToNewFormat extends Migration
{
/**
* Run the migrations. This will loop over every egg on the system and update the
* images array to both exist, and have key => value pairings to support naming the
* images provided.
*/
public function up()
{
DB::table('eggs')->select(['id', 'docker_images'])->cursor()->each(function ($egg) {
$images = is_null($egg->docker_images) ? [] : json_decode($egg->docker_images, true, 512, JSON_THROW_ON_ERROR);
$results = [];
foreach ($images as $key => $value) {
$results[is_int($key) ? $value : $key] = $value;
}
DB::table('eggs')->where('id', $egg->id)->update(['docker_images' => $results]);
});
}
/**
* Reverse the migrations. This just keeps the values from the docker images array.
*
* @return void
*/
public function down()
{
DB::table('eggs')->select(['id', 'docker_images'])->cursor()->each(function ($egg) {
DB::table('eggs')->where('id', $egg->id)->update([
'docker_images' => array_values(json_decode($egg->docker_images, true, 512, JSON_THROW_ON_ERROR)),
]);
});
}
}

View file

@ -6,7 +6,7 @@ import { ServerEggVariable } from '@/api/server/types';
interface Response {
invocation: string;
variables: ServerEggVariable[];
dockerImages: string[];
dockerImages: Record<string, string>;
}
export default (uuid: string, initialData?: Response) => useSWR([ uuid, '/startup' ], async (): Promise<Response> => {
@ -14,5 +14,9 @@ export default (uuid: string, initialData?: Response) => useSWR([ uuid, '/startu
const variables = ((data as FractalResponseList).data || []).map(rawDataToServerEggVariable);
return { invocation: data.meta.startup_command, variables, dockerImages: data.meta.docker_images || [] };
return {
variables,
invocation: data.meta.startup_command,
dockerImages: data.meta.docker_images || {},
};
}, { initialData, errorRetryCount: 3 });

View file

@ -29,11 +29,11 @@ const StartupContainer = () => {
const { data, error, isValidating, mutate } = getServerStartup(uuid, {
...variables,
dockerImages: [ variables.dockerImage ],
dockerImages: { [variables.dockerImage]: variables.dockerImage },
});
const setServerFromState = ServerContext.useStoreActions(actions => actions.server.setServerFromState);
const isCustomImage = data && !data.dockerImages.map(v => v.toLowerCase()).includes(variables.dockerImage.toLowerCase());
const isCustomImage = data && !Object.values(data.dockerImages).map(v => v.toLowerCase()).includes(variables.dockerImage.toLowerCase());
useEffect(() => {
// Since we're passing in initial data this will not trigger on mount automatically. We
@ -87,16 +87,18 @@ const StartupContainer = () => {
</div>
</TitledGreyBox>
<TitledGreyBox title={'Docker Image'} css={tw`flex-1 lg:flex-none lg:w-1/3 mt-8 md:mt-0 md:ml-10`}>
{data.dockerImages.length > 1 && !isCustomImage ?
{Object.keys(data.dockerImages).length > 1 && !isCustomImage ?
<>
<InputSpinner visible={loading}>
<Select
disabled={data.dockerImages.length < 2}
disabled={(Object.keys(data.dockerImages)).length < 2}
onChange={updateSelectedDockerImage}
defaultValue={variables.dockerImage}
>
{data.dockerImages.map(image => (
<option key={image} value={image}>{image}</option>
{Object.keys(data.dockerImages).map(key => (
<option key={data.dockerImages[key]} value={data.dockerImages[key]}>
{key}
</option>
))}
</Select>
</InputSpinner>

View file

@ -83,8 +83,13 @@
</div>
<div class="form-group">
<label for="pDockerImage" class="control-label">Docker Images <span class="field-required"></span></label>
<textarea id="pDockerImages" name="docker_images" class="form-control" rows="4">{{ implode("\n", $egg->docker_images) }}</textarea>
<p class="text-muted small">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.</p>
<textarea id="pDockerImages" name="docker_images" class="form-control" rows="4">{{ implode(PHP_EOL, $images) }}</textarea>
<p class="text-muted small">
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.
Optionally, a display name may be provided by prefixing the image with the name
followed by a pipe character, and then the image URL. Example: <code>Display Name|ghcr.io/my/egg</code>
</p>
</div>
</div>
<div class="col-sm-6">

View file

@ -1,167 +0,0 @@
<?php
namespace Pterodactyl\Tests\Unit\Services\Api;
use Mockery as m;
use phpmock\phpunit\PHPMock;
use Pterodactyl\Models\ApiKey;
use Pterodactyl\Tests\TestCase;
use Illuminate\Contracts\Encryption\Encrypter;
use Pterodactyl\Services\Api\KeyCreationService;
use Pterodactyl\Contracts\Repository\ApiKeyRepositoryInterface;
class KeyCreationServiceTest extends TestCase
{
use PHPMock;
/**
* @var \Illuminate\Contracts\Encryption\Encrypter|\Mockery\Mock
*/
private $encrypter;
/**
* @var \Pterodactyl\Contracts\Repository\ApiKeyRepositoryInterface|\Mockery\Mock
*/
private $repository;
/**
* Setup tests.
*/
public function setUp(): void
{
parent::setUp();
$this->encrypter = m::mock(Encrypter::class);
$this->repository = m::mock(ApiKeyRepositoryInterface::class);
}
/**
* Test that the service is able to create a keypair and assign the correct permissions.
*/
public function testKeyIsCreated()
{
$model = ApiKey::factory()->make();
$this->getFunctionMock('\\Pterodactyl\\Services\\Api', 'str_random')
->expects($this->exactly(2))->willReturnCallback(function ($length) {
return 'str_' . $length;
});
$this->encrypter->shouldReceive('encrypt')->with('str_' . ApiKey::KEY_LENGTH)->once()->andReturn($model->token);
$this->repository->shouldReceive('create')->with([
'test-data' => 'test',
'key_type' => ApiKey::TYPE_NONE,
'identifier' => 'str_' . ApiKey::IDENTIFIER_LENGTH,
'token' => $model->token,
], true, true)->once()->andReturn($model);
$response = $this->getService()->handle(['test-data' => 'test']);
$this->assertNotEmpty($response);
$this->assertInstanceOf(ApiKey::class, $response);
$this->assertSame($model, $response);
}
/**
* Test that an identifier is only set by the function.
*/
public function testIdentifierAndTokenAreOnlySetByFunction()
{
$model = ApiKey::factory()->make();
$this->getFunctionMock('\\Pterodactyl\\Services\\Api', 'str_random')
->expects($this->exactly(2))->willReturnCallback(function ($length) {
return 'str_' . $length;
});
$this->encrypter->shouldReceive('encrypt')->with('str_' . ApiKey::KEY_LENGTH)->once()->andReturn($model->token);
$this->repository->shouldReceive('create')->with([
'key_type' => ApiKey::TYPE_NONE,
'identifier' => 'str_' . ApiKey::IDENTIFIER_LENGTH,
'token' => $model->token,
], true, true)->once()->andReturn($model);
$response = $this->getService()->handle(['identifier' => 'customIdentifier', 'token' => 'customToken']);
$this->assertNotEmpty($response);
$this->assertInstanceOf(ApiKey::class, $response);
$this->assertSame($model, $response);
}
/**
* Test that permissions passed in are loaded onto the key data.
*/
public function testPermissionsAreRetrievedForApplicationKeys()
{
$model = ApiKey::factory()->make();
$this->getFunctionMock('\\Pterodactyl\\Services\\Api', 'str_random')
->expects($this->exactly(2))->willReturnCallback(function ($length) {
return 'str_' . $length;
});
$this->encrypter->shouldReceive('encrypt')->with('str_' . ApiKey::KEY_LENGTH)->once()->andReturn($model->token);
$this->repository->shouldReceive('create')->with([
'key_type' => ApiKey::TYPE_APPLICATION,
'identifier' => 'str_' . ApiKey::IDENTIFIER_LENGTH,
'token' => $model->token,
'permission-key' => 'exists',
], true, true)->once()->andReturn($model);
$response = $this->getService()->setKeyType(ApiKey::TYPE_APPLICATION)->handle([], ['permission-key' => 'exists']);
$this->assertNotEmpty($response);
$this->assertInstanceOf(ApiKey::class, $response);
$this->assertSame($model, $response);
}
/**
* Test that permissions are not retrieved for any key that is not an application key.
*
* @dataProvider keyTypeDataProvider
*/
public function testPermissionsAreNotRetrievedForNonApplicationKeys($keyType)
{
$model = ApiKey::factory()->make();
$this->getFunctionMock('\\Pterodactyl\\Services\\Api', 'str_random')
->expects($this->exactly(2))->willReturnCallback(function ($length) {
return 'str_' . $length;
});
$this->encrypter->shouldReceive('encrypt')->with('str_' . ApiKey::KEY_LENGTH)->once()->andReturn($model->token);
$this->repository->shouldReceive('create')->with([
'key_type' => $keyType,
'identifier' => 'str_' . ApiKey::IDENTIFIER_LENGTH,
'token' => $model->token,
], true, true)->once()->andReturn($model);
$response = $this->getService()->setKeyType($keyType)->handle([], ['fake-permission' => 'should-not-exist']);
$this->assertNotEmpty($response);
$this->assertInstanceOf(ApiKey::class, $response);
$this->assertSame($model, $response);
}
/**
* Provide key types that are not an application specific key.
*/
public function keyTypeDataProvider(): array
{
return [
[ApiKey::TYPE_NONE], [ApiKey::TYPE_ACCOUNT], [ApiKey::TYPE_DAEMON_USER], [ApiKey::TYPE_DAEMON_APPLICATION],
];
}
/**
* Return an instance of the service with mocked dependencies for testing.
*/
private function getService(): KeyCreationService
{
return new KeyCreationService($this->repository, $this->encrypter);
}
}