diff --git a/.env.ci b/.env.ci index e99f46691..991f47a98 100644 --- a/.env.ci +++ b/.env.ci @@ -5,6 +5,7 @@ APP_THEME=pterodactyl APP_TIMEZONE=America/Los_Angeles APP_URL=http://localhost/ +DB_CONNECTION=testing TESTING_DB_HOST=127.0.0.1 TESTING_DB_DATABASE=panel_test TESTING_DB_USERNAME=root diff --git a/.env.example b/.env.example index 72a0a764e..e2e9d2dce 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,7 @@ APP_DELETE_MINUTES=10 APP_ENVIRONMENT_ONLY=true LOG_CHANNEL=daily APP_LOCALE=en +APP_URL=http://panel.example.com DB_HOST=127.0.0.1 DB_PORT=3306 @@ -30,7 +31,7 @@ MAILGUN_ENDPOINT=api.mailgun.net # mail servers such as Gmail to reject your mail. # # @see: https://github.com/pterodactyl/panel/pull/3110 -# SERVER_NAME=panel.yourdomain.com +# SERVER_NAME=panel.example.com QUEUE_HIGH=high QUEUE_STANDARD=standard diff --git a/.eslintrc.yml b/.eslintrc.yml index a4630dcb8..1c3adcb14 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -43,6 +43,10 @@ rules: array-bracket-spacing: - warn - always + "@typescript-eslint/no-unused-vars": + - warn + - argsIgnorePattern: '^_' + varsIgnorePattern: '^_' # Remove errors for not having newlines between operands of ternary expressions https://eslint.org/docs/rules/multiline-ternary multiline-ternary: 0 "react-hooks/rules-of-hooks": diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml index b2a9113ac..adf3d2738 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yml +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -7,14 +7,6 @@ body: value: | Bug reports should only be used for reporting issues with how the software works. For assistance installing this software, as well as debugging issues with dependencies, please use our [Discord server](https://discord.gg/pterodactyl). -- type: checkboxes - attributes: - label: Is there an existing issue for this? - description: Please [search here](https://github.com/pterodactyl/panel/issues) to see if an issue already exists for your problem. - options: - - label: I have searched the existing issues before opening this issue. - required: true - - type: textarea attributes: label: Current Behavior @@ -32,7 +24,7 @@ body: - type: textarea attributes: label: Steps to Reproduce - description: Please be as detailed as possible when providing steps to reproduce, failure to provide steps will likely result in this issue being closed. + description: Please be as detailed as possible when providing steps to reproduce, failure to provide steps will result in this issue being closed. validations: required: true @@ -53,6 +45,20 @@ body: placeholder: 1.4.2 validations: required: true + +- type: input + id: egg-details + attributes: + label: Games and/or Eggs Affected + description: Please include the specific game(s) or egg(s) you are running into this bug with. + placeholder: Minecraft (Paper), Minecraft (Forge) + +- type: input + id: docker-image + attributes: + label: Docker Image + description: The specific Docker image you are using for the game(s) above. + placeholder: ghcr.io/pterodactyl/yolks:java_17 - type: textarea id: panel-logs @@ -67,3 +73,15 @@ body: render: bash validations: required: false + +- type: checkboxes + attributes: + label: Is there an existing issue for this? + description: Please [search here](https://github.com/pterodactyl/panel/issues) to see if an issue already exists for your problem. + options: + - label: I have searched the existing issues before opening this issue. + required: true + - label: I have provided all relevant details, including the specific game and Docker images I am using if this issue is related to running a server. + required: true + - label: I have checked in the Discord server and believe this is a bug with the software, and not a configuration issue with my specific system. + required: true diff --git a/.github/docker/README.md b/.github/docker/README.md index 434e509d6..b195b8300 100644 --- a/.github/docker/README.md +++ b/.github/docker/README.md @@ -12,7 +12,7 @@ You can provide additional settings using a custom `.env` file or by setting the ## Setup -Start the docker container and the required dependencies (either provide existing ones or start containers as well, see the [docker-compose.yml](docker-compose.yml) file as an example). +Start the docker container and the required dependencies (either provide existing ones or start containers as well, see the [docker-compose.yml](https://github.com/pterodactyl/panel/blob/develop/docker-compose.example.yml) file as an example. After the startup is complete you'll need to create a user. If you are running the docker container without docker-compose, use: @@ -33,7 +33,7 @@ Note: If your `APP_URL` starts with `https://` you need to provide an `LETSENCRY | ------------------- | ------------------------------------------------------------------------------ | -------- | | `APP_URL` | The URL the panel will be reachable with (including protocol) | yes | | `APP_TIMEZONE` | The timezone to use for the panel | yes | -| `LETSENCRYPT_EMAIL` | The email used for letsencrypt certificate generation | yes | +| `LE_EMAIL` | The email used for letsencrypt certificate generation | yes | | `DB_HOST` | The host of the mysql instance | yes | | `DB_PORT` | The port of the mysql instance | yes | | `DB_DATABASE` | The name of the mysql database | yes | diff --git a/.github/docker/entrypoint.sh b/.github/docker/entrypoint.sh index e6e1b0966..d3df9c150 100644 --- a/.github/docker/entrypoint.sh +++ b/.github/docker/entrypoint.sh @@ -30,7 +30,7 @@ else fi echo "Checking if https is required." -if [ -f /etc/nginx/conf.d/default.conf ]; then +if [ -f /etc/nginx/http.d/panel.conf ]; then echo "Using nginx config already in place." if [ $LE_EMAIL ]; then echo "Checking for cert update" @@ -42,20 +42,27 @@ else echo "Checking if letsencrypt email is set." if [ -z $LE_EMAIL ]; then echo "No letsencrypt email is set using http config." - cp .github/docker/default.conf /etc/nginx/conf.d/default.conf + cp .github/docker/default.conf /etc/nginx/http.d/panel.conf else echo "writing ssl config" - cp .github/docker/default_ssl.conf /etc/nginx/conf.d/default.conf + cp .github/docker/default_ssl.conf /etc/nginx/http.d/panel.conf echo "updating ssl config for domain" - sed -i "s||$(echo $APP_URL | sed 's~http[s]*://~~g')|g" /etc/nginx/conf.d/default.conf + sed -i "s||$(echo $APP_URL | sed 's~http[s]*://~~g')|g" /etc/nginx/http.d/panel.conf echo "generating certs" certbot certonly -d $(echo $APP_URL | sed 's~http[s]*://~~g') --standalone -m $LE_EMAIL --agree-tos -n fi + echo "Removing the default nginx config" + rm -rf /etc/nginx/http.d/default.conf +fi + +if [[ -z $DB_PORT ]]; then + echo -e "DB_PORT not specified, defaulting to 3306" + DB_PORT=3306 fi ## check for DB up before starting the panel echo "Checking database status." -until nc -z -v -w30 $DB_HOST 3306 +until nc -z -v -w30 $DB_HOST $DB_PORT do echo "Waiting for database connection..." # wait for 1 seconds before check again diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 1aa6125f4..c9f5c68ae 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -36,6 +36,7 @@ jobs: if: "!contains(github.ref, 'develop')" with: push: true + platforms: linux/amd64,linux/arm64 tags: ${{ steps.docker_meta.outputs.tags }} labels: ${{ steps.docker_meta.outputs.labels }} - name: Release Development Build @@ -43,5 +44,6 @@ jobs: if: "contains(github.ref, 'develop')" with: push: ${{ github.event_name != 'pull_request' }} + platforms: linux/amd64,linux/arm64 tags: ${{ steps.docker_meta.outputs.tags }} labels: ${{ steps.docker_meta.outputs.labels }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3143871b0..46b07fe22 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -37,9 +37,7 @@ jobs: path: | ~/.php_cs.cache ${{ steps.composer-cache.outputs.dir }} - key: ${{ runner.os }}-cache-${{ matrix.php }}-${{ hashFiles('**.composer.lock') }} - restore-keys: | - ${{ runner.os }}-cache-${{ matrix.php }}- + key: ${{ runner.os }}-cache-${{ matrix.php }}-${{ hashFiles('composer.lock') }} - name: Setup PHP uses: shivammathur/setup-php@v2 with: @@ -52,16 +50,22 @@ jobs: - name: composer install run: composer install --prefer-dist --no-interaction --no-progress - name: Run cs-fixer - run: vendor/bin/php-cs-fixer fix --dry-run --diff --diff-format=udiff --config .php-cs-fixer.dist.php + run: vendor/bin/php-cs-fixer fix --dry-run --diff --format=txt --config .php-cs-fixer.dist.php continue-on-error: true + - name: Static Analysis + if: ${{ matrix.php }} == '8.0' + run: | + php artisan ide-helper:models -N + ./vendor/bin/phpstan analyse --memory-limit=2G + env: + TESTING_DB_PORT: ${{ job.services.database.ports[3306] }} - name: Execute Unit Tests run: php artisan test tests/Unit if: ${{ always() }} env: TESTING_DB_PORT: ${{ job.services.database.ports[3306] }} - TESTING_DB_USERNAME: root - name: Execute Integration Tests run: php artisan test tests/Integration + if: ${{ always() }} env: TESTING_DB_PORT: ${{ job.services.database.ports[3306] }} - TESTING_DB_USERNAME: root diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index 9245b7e6e..b2a25ca19 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -10,6 +10,9 @@ $finder = (new Finder()) 'node_modules', 'storage', 'bootstrap/cache', + '.phpstorm.meta.php', + '_ide_helper.php', + '_ide_helper_models.php', ]) ->notName(['_ide_helper*']); diff --git a/BUILDING.md b/BUILDING.md index df60f70a8..d8b703338 100644 --- a/BUILDING.md +++ b/BUILDING.md @@ -5,9 +5,9 @@ Release versions of Pterodactyl will include pre-compiled, minified, and hashed However, if you are interested in running custom themes or making modifications to the React files you'll need a build system in place to generate these compiled assets. To get your environment setup you'll need at minimum: -* Node.js 12 -* [Yarn](https://classic.yarnpkg.com/lang/en/) v1 -* [Go](https://golang.org/) 1.15. +* [Node.js](https://nodejs.org/en/) v14.x.x +* [Yarn](https://classic.yarnpkg.com/lang/en/) v1.x.x +* [Go](https://golang.org/) 1.17.x ### Install Dependencies ```bash diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b3da3007..78a403449 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,62 @@ This file is a running track of new features and fixes to each version of the pa This project follows [Semantic Versioning](http://semver.org) guidelines. +## v1.7.0 +### Fixed +* Fixes typo in message shown to user when deleting a database. +* Fixes formatting of IPv6 addresses when displaying allocations to users. +* Fixes an exception thrown while trying to return error messages from API endpoints that inproperly masked the true underlying error. +* Fixes SSL certificate path generation for Let's Encrypt by ensuring they are always transformed to lowercase. +* Removes duplicate entries when creating a nested folder in the file manager. +* Fixes missing validation of Egg Author email addresses during the setup process that could cause unexpected failures later on. +* Fixes font rendering issues of the console on Firefox due to an outdated version of xterm.js being used. +* Fixes display overlap issues of the two-factor configuration form in a user's settings. +* **[security]** When authenticating using an API key a user session is now only persisted for the duration of the request before being destroyed. + +### Changed +* CPU graph changed to show the maximum amount of CPU available to a server to better match how the memory graph is displayed. + +### Added +* Adds support for `DB_PORT` environment variable in the Docker enterpoint for the Panel image. +* Adds suport for ARM environments in the Docker image. +* Adds a new warning modal for Steam servers shown when an invalid Game Server Login Token (GSL Token) is detected. +* Adds a new warning modal for Steam servers shown when the installation process runs out of available disk space. +* Adds a new warning modal for Minecraft servers shown when a server exceeds the maximum number of child processes. +* Adds support for displaying certain server variable fields as a checkbox when they're detected as using `boolean` or `in:0,1` validation rules. +* Adds support for Pug and Jade in the file editor. +* Adds an entry to the `robots.txt` file to correctly disallow all bot indexing. + + +## v1.6.6 +### Fixed +* **[security]** Fixes a CSRF vulnerability for both the administrative test email endpoint and node auto-deployment token generation endpoint. [GHSA-wwgq-9jhf-qgw6](https://github.com/pterodactyl/panel/security/advisories/GHSA-wwgq-9jhf-qgw6) + +### Changed +* Updates Minecraft eggs to include latest Java 17 yolk by default. + +## v1.6.5 +### Fixed +* Fixes broken application API endpoints due to changes introduced with session management in 1.6.4. + +## v1.6.4 +_This release should not be used, please use `1.6.5`. It has been pulled from our releases._ + +### Fixed +* Fixes a session management bug that would cause a user who signs out of one browser to be unintentionally logged out of other browser sessions when using the client API. + +## v1.6.3 +### Fixed +* **[Security]** Changes logout endpoint to be a POST request with CSRF-token validation to prevent a malicious actor from triggering a user logout. +* Fixes Wings receiving the wrong server suspension state when syncing servers. + +### Added +* Adds additional throttling to login and password reset endpoints. +* Adds server uptime display when viewing a server console. + +## v1.6.2 +### Fixed +* **[Security]** Fixes an authentication bypass vulerability that could allow a malicious actor to login as another user in the Panel without knowing that user's email or password. + ## v1.6.1 ### Fixed * Fixes server build modifications not being properly persisted to the database when edited. diff --git a/Dockerfile b/Dockerfile index 8d50d3845..2c743cab2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ # Build the assets that are needed for the frontend. This build stage is then discarded # since we won't need NodeJS anymore in the future. This Docker image ships a final production # level distribution of Pterodactyl. -FROM mhart/alpine-node:14 +FROM --platform=$TARGETOS/$TARGETARCH mhart/alpine-node:14 WORKDIR /app COPY . ./ RUN yarn install --frozen-lockfile \ @@ -10,11 +10,11 @@ RUN yarn install --frozen-lockfile \ # Stage 1: # Build the actual container with all of the needed PHP dependencies that will run the application. -FROM php:7.4-fpm-alpine +FROM --platform=$TARGETOS/$TARGETARCH php:7.4-fpm-alpine WORKDIR /app COPY . ./ COPY --from=0 /app/public/assets ./public/assets -RUN apk add --no-cache --update ca-certificates dcron curl git supervisor tar unzip nginx libpng-dev libxml2-dev libzip-dev certbot \ +RUN apk add --no-cache --update ca-certificates dcron curl git supervisor tar unzip nginx libpng-dev libxml2-dev libzip-dev certbot certbot-nginx \ && docker-php-ext-configure zip \ && docker-php-ext-install bcmath gd pdo_mysql zip \ && curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer \ @@ -27,6 +27,7 @@ RUN apk add --no-cache --update ca-certificates dcron curl git supervisor tar un RUN rm /usr/local/etc/php-fpm.conf \ && echo "* * * * * /usr/local/bin/php /app/artisan schedule:run >> /dev/null 2>&1" >> /var/spool/cron/crontabs/root \ + && echo "0 23 * * * certbot renew --nginx --quiet" >> /var/spool/cron/crontabs/root \ && sed -i s/ssl_session_cache/#ssl_session_cache/g /etc/nginx/nginx.conf \ && mkdir -p /var/run/php /var/run/nginx @@ -35,5 +36,5 @@ COPY .github/docker/www.conf /usr/local/etc/php-fpm.conf COPY .github/docker/supervisord.conf /etc/supervisord.conf EXPOSE 80 443 -ENTRYPOINT ["/bin/ash", ".github/docker/entrypoint.sh"] +ENTRYPOINT [ "/bin/ash", ".github/docker/entrypoint.sh" ] CMD [ "supervisord", "-n", "-c", "/etc/supervisord.conf" ] diff --git a/LICENSE.md b/LICENSE.md index 1fb886e97..cb0e2a9d9 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,7 +1,8 @@ # The MIT License (MIT) ``` -Copyright (c) 2015 - 2021 Dane Everitt and Contributors +Pterodactyl® +Copyright © Dane Everitt and contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 42e02d5c0..5357c02c8 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ ![GitHub contributors](https://img.shields.io/github/contributors/pterodactyl/panel?style=for-the-badge) # Pterodactyl Panel -Pterodactyl is an open-source game server management panel built with PHP 7, React, and Go. Designed with security +Pterodactyl® is a free, open-source game server management panel built with PHP, React, and Go. Designed with security in mind, Pterodactyl runs all game servers in isolated Docker containers while exposing a beautiful and intuitive UI to end users. @@ -14,6 +14,12 @@ Stop settling for less. Make game servers a first class citizen on your platform ![Image](https://cdn.pterodactyl.io/site-assets/pterodactyl_v1_demo.gif) +## Documentation +* [Panel Documentation](https://pterodactyl.io/panel/1.0/getting_started.html) +* [Wings Documentation](https://pterodactyl.io/wings/1.0/installing.html) +* [Community Guides](https://pterodactyl.io/community/about.html) +* Or, get additional help [via Discord](https://discord.gg/pterodactyl) + ## Sponsors I would like to extend my sincere thanks to the following sponsors for helping fund Pterodactyl's developement. [Interested in becoming a sponsor?](https://github.com/sponsors/DaneEveritt) @@ -32,21 +38,16 @@ I would like to extend my sincere thanks to the following sponsors for helping f | [**Spill Hosting**](https://spillhosting.no/) | Spill Hosting is a Norwegian hosting service, which aims for inexpensive services on quality servers. Premium i9-9900K processors will run your game like a dream. | | [**DeinServerHost**](https://deinserverhost.de/) | DeinServerHost offers Dedicated, vps and Gameservers for many popular Games like Minecraft and Rust in Germany since 2013. | | [**HostBend**](https://hostbend.com/) | HostBend offers a variety of solutions for developers, students, and others who have a tight budget but don't want to compromise quality and support. | -| [**Capitol Hosting Solutions**](https://capitolsolutions.cloud/) | CHS is *the* budget friendly hosting company for Australian and American gamers, offering a variety of plans from Web Hosting to Game Servers; Custom Solutions too! | +| [**Capitol Hosting Solutions**](https://chs.gg/) | CHS is *the* budget friendly hosting company for Australian and American gamers, offering a variety of plans from Web Hosting to Game Servers; Custom Solutions too! | | [**ByteAnia**](https://byteania.com/?utm_source=pterodactyl) | ByteAnia offers the best performing and most affordable **Ryzen 5000 Series hosting** on the market for *unbeatable prices*! | | [**Aussie Server Hosts**](https://aussieserverhosts.com/) | No frills Australian Owned and operated High Performance Server hosting for some of the most demanding games serving Australia and New Zealand. | | [**VibeGAMES**](https://vibegames.net/) | VibeGAMES is a game server provider that specializes in DDOS protection for the games we offer. We have multiple locations in the US, Brazil, France, Germany, Singapore, Australia and South Africa.| | [**RocketNode**](https://rocketnode.net) | RocketNode is a VPS and Game Server provider that offers the best performing VPS and Game hosting Solutions at affordable prices! | - -## Documentation -* [Panel Documentation](https://pterodactyl.io/panel/1.0/getting_started.html) -* [Wings Documentation](https://pterodactyl.io/wings/1.0/installing.html) -* [Community Guides](https://pterodactyl.io/community/about.html) -* Or, get additional help [via Discord](https://discord.gg/pterodactyl) +| [**HostEZ**](https://hostez.io) | Providing North America Valheim, Minecraft and other popular games with low latency, high uptime and maximum availability. EZ! | ### Supported Games -We support a huge variety of games by utilizing Docker containers to isolate each instance, giving you the power to -host your games across the world without having to bloat each physical machine with additional dependencies. +Pterodactyl supports a wide variety of games by utilizing Docker containers to isolate each instance. This gives +you the power to run game servers without bloating machines with a host of additional dependencies. Some of our core supported games include: @@ -73,27 +74,6 @@ and there are plenty more games available provided by the community. Some of the * [and many more...](https://github.com/parkervcp/eggs) ## License -``` -Copyright (c) 2015 - 2021 Dane Everitt and Contributors +Pterodactyl® Copyright © 2015 - 2022 Dane Everitt and contributors. -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -``` - -Some Javascript and CSS used within the panel are licensed under a `MIT` or `Apache 2.0` license. Please check their -respective header files for more information. +Code released under the [MIT License](./LICENSE.md). diff --git a/SECURITY.md b/SECURITY.md index 5b412f853..673a38ee3 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -5,18 +5,13 @@ The following versions of Pterodactyl are receiving active support and maintenan | Panel | Daemon | Supported | | ----- | ------------ | ------------------ | -| 1.4.x | wings@1.4.x | :white_check_mark: | -| 1.3.x | wings@1.3.x | :x: | -| 1.2.x | wings@1.2.x | :x: | -| 1.1.x | wings@1.1.x | :x: | -| 1.0.x | wings@1.0.x | :x: | +| 1.7.x | wings@1.5.x | :white_check_mark: | | 0.7.x | daemon@0.6.x | :x: | -| 0.6.x | daemon@0.5.x | :x: | -| 0.5.x | daemon@0.4.x | :x: | + ## Reporting a Vulnerability -Please reach out directly to any project team member on Discord when reporting a security vulnerability, or you can send an email to `dane [ät] pterodactyl.io`. +Please reach out directly to any project team member on Discord when reporting a security vulnerability, or you can send an email to `dane@pterodactyl.io`. We make every effort to respond as soon as possible, although it may take a day or two for us to sync internally and determine the severity of the report and its impact. Please, _do not_ use a public facing channel or GitHub issues to report sensitive security issues. diff --git a/app/Console/Commands/Environment/AppSettingsCommand.php b/app/Console/Commands/Environment/AppSettingsCommand.php index cfe1d7981..2746d1a4f 100644 --- a/app/Console/Commands/Environment/AppSettingsCommand.php +++ b/app/Console/Commands/Environment/AppSettingsCommand.php @@ -12,6 +12,7 @@ namespace Pterodactyl\Console\Commands\Environment; use DateTimeZone; use Illuminate\Console\Command; use Illuminate\Contracts\Console\Kernel; +use Illuminate\Validation\Factory as ValidatorFactory; use Pterodactyl\Traits\Commands\EnvironmentWriterTrait; use Illuminate\Contracts\Config\Repository as ConfigRepository; @@ -78,12 +79,13 @@ class AppSettingsCommand extends Command /** * AppSettingsCommand constructor. */ - public function __construct(ConfigRepository $config, Kernel $command) + public function __construct(ConfigRepository $config, Kernel $command, ValidatorFactory $validator) { parent::__construct(); - $this->command = $command; $this->config = $config; + $this->command = $command; + $this->validator = $validator; } /** @@ -103,6 +105,18 @@ class AppSettingsCommand extends Command $this->config->get('pterodactyl.service.author', 'unknown@unknown.com') ); + $validator = $this->validator->make( + ['email' => $this->variables['APP_SERVICE_AUTHOR']], + ['email' => 'email'] + ); + + if ($validator->fails()) { + foreach ($validator->errors()->all() as $error) { + $this->output->error($error); + } + return 1; + } + $this->output->comment(trans('command/messages.environment.app.app_url_help')); $this->variables['APP_URL'] = $this->option('url') ?? $this->ask( trans('command/messages.environment.app.app_url'), diff --git a/app/Console/Commands/InfoCommand.php b/app/Console/Commands/InfoCommand.php index e712fd1e4..6e9a2c827 100644 --- a/app/Console/Commands/InfoCommand.php +++ b/app/Console/Commands/InfoCommand.php @@ -54,15 +54,15 @@ class InfoCommand extends Command $this->output->title('Version Information'); $this->table([], [ ['Panel Version', $this->config->get('app.version')], - ['Latest Version', $this->versionService->getPanel()], + ['Latest Version', $this->versionService->getLatestPanel()], ['Up-to-Date', $this->versionService->isLatestPanel() ? 'Yes' : $this->formatText('No', 'bg=red')], ['Unique Identifier', $this->config->get('pterodactyl.service.author')], ], 'compact'); $this->output->title('Application Configuration'); $this->table([], [ - ['Environment', $this->formatText($this->config->get('app.env'), $this->config->get('app.env') === 'production' ?: 'bg=red')], - ['Debug Mode', $this->formatText($this->config->get('app.debug') ? 'Yes' : 'No', !$this->config->get('app.debug') ?: 'bg=red')], + ['Environment', $this->formatText($this->config->get('app.env'), $this->config->get('app.env') === 'production' ? '' : 'bg=red')], + ['Debug Mode', $this->formatText($this->config->get('app.debug') ? 'Yes' : 'No', !$this->config->get('app.debug') ? '' : 'bg=red')], ['Installation URL', $this->config->get('app.url')], ['Installation Directory', base_path()], ['Timezone', $this->config->get('app.timezone')], diff --git a/app/Console/Commands/Location/DeleteLocationCommand.php b/app/Console/Commands/Location/DeleteLocationCommand.php index 898dbb924..d1fb951d8 100644 --- a/app/Console/Commands/Location/DeleteLocationCommand.php +++ b/app/Console/Commands/Location/DeleteLocationCommand.php @@ -1,11 +1,4 @@ . - * - * This software is licensed under the terms of the MIT license. - * https://opensource.org/licenses/MIT - */ namespace Pterodactyl\Console\Commands\Location; @@ -26,9 +19,9 @@ class DeleteLocationCommand extends Command protected $description = 'Deletes a location from the Panel.'; /** - * @var \Illuminate\Support\Collection + * @var \Illuminate\Support\Collection|null */ - protected $locations; + protected $locations = null; /** * @var \Pterodactyl\Contracts\Repository\LocationRepositoryInterface diff --git a/app/Console/Commands/Overrides/SeedCommand.php b/app/Console/Commands/Overrides/SeedCommand.php index 5f050e340..7b7b5edb7 100644 --- a/app/Console/Commands/Overrides/SeedCommand.php +++ b/app/Console/Commands/Overrides/SeedCommand.php @@ -13,14 +13,14 @@ class SeedCommand extends BaseSeedCommand * Block someone from running this seed command if they have not completed * the migration process. */ - public function handle() + public function handle(): int { if (!$this->hasCompletedMigrations()) { $this->showMigrationWarning(); - return; + return 1; } - parent::handle(); + return parent::handle(); } } diff --git a/app/Console/Commands/Overrides/UpCommand.php b/app/Console/Commands/Overrides/UpCommand.php index 3001edcbe..0a7caaeb7 100644 --- a/app/Console/Commands/Overrides/UpCommand.php +++ b/app/Console/Commands/Overrides/UpCommand.php @@ -13,14 +13,14 @@ class UpCommand extends BaseUpCommand * Block someone from running this up command if they have not completed * the migration process. */ - public function handle() + public function handle(): int { if (!$this->hasCompletedMigrations()) { $this->showMigrationWarning(); - return; + return 1; } - parent::handle(); + return parent::handle(); } } diff --git a/app/Console/Commands/Schedule/ProcessRunnableCommand.php b/app/Console/Commands/Schedule/ProcessRunnableCommand.php index 9e51feaeb..d2a7ec661 100644 --- a/app/Console/Commands/Schedule/ProcessRunnableCommand.php +++ b/app/Console/Commands/Schedule/ProcessRunnableCommand.php @@ -24,7 +24,7 @@ class ProcessRunnableCommand extends Command /** * Handle command execution. */ - public function handle() + public function handle(): int { $schedules = Schedule::query()->with('tasks') ->where('is_active', true) @@ -35,7 +35,7 @@ class ProcessRunnableCommand extends Command if ($schedules->count() < 1) { $this->line('There are no scheduled tasks for servers that need to be run.'); - return; + return 0; } $bar = $this->output->createProgressBar(count($schedules)); @@ -47,6 +47,8 @@ class ProcessRunnableCommand extends Command } $this->line(''); + + return 0; } /** @@ -69,7 +71,7 @@ class ProcessRunnableCommand extends Command 'schedule' => $schedule->name, 'hash' => $schedule->hashid, ])); - } catch (Throwable | Exception $exception) { + } catch (Throwable|Exception $exception) { Log::error($exception, ['schedule_id' => $schedule->id]); $this->error("An error was encountered while processing Schedule #{$schedule->id}: " . $exception->getMessage()); diff --git a/app/Console/Commands/UpgradeCommand.php b/app/Console/Commands/UpgradeCommand.php index feb21ec60..3636c778d 100644 --- a/app/Console/Commands/UpgradeCommand.php +++ b/app/Console/Commands/UpgradeCommand.php @@ -57,7 +57,7 @@ class UpgradeCommand extends Command $userDetails = posix_getpwuid(fileowner('public')); $user = $userDetails['name'] ?? 'www-data'; - if (!$this->confirm("Your webserver user has been detected as [{$user}]: is this correct?", true)) { + if (!$this->confirm("Your webserver user has been detected as [{$user}]: is this correct?", true)) { $user = $this->anticipate( 'Please enter the name of the user running your webserver process. This varies from system to system, but is generally "www-data", "nginx", or "apache".', [ @@ -73,7 +73,7 @@ class UpgradeCommand extends Command $groupDetails = posix_getgrgid(filegroup('public')); $group = $groupDetails['name'] ?? 'www-data'; - if (!$this->confirm("Your webserver group has been detected as [{$group}]: is this correct?", true)) { + if (!$this->confirm("Your webserver group has been detected as [{$group}]: is this correct?", true)) { $group = $this->anticipate( 'Please enter the name of the group running your webserver process. Normally this is the same as your user.', [ @@ -86,11 +86,13 @@ class UpgradeCommand extends Command } if (!$this->confirm('Are you sure you want to run the upgrade process for your Panel?')) { + $this->warn('Upgrade process terminated by user.'); + return; } } - ini_set('output_buffering', 0); + ini_set('output_buffering', '0'); $bar = $this->output->createProgressBar($skipDownload ? 9 : 10); $bar->start(); @@ -173,8 +175,8 @@ class UpgradeCommand extends Command $this->call('up'); }); - $this->newLine(); - $this->info('Finished running upgrade.'); + $this->newLine(2); + $this->info('Panel has been successfully upgraded. Please ensure you also update any Wings instances: https://pterodactyl.io/wings/1.0/upgrading.html'); } protected function withProgress(ProgressBar $bar, Closure $callback) diff --git a/app/Console/Commands/User/DeleteUserCommand.php b/app/Console/Commands/User/DeleteUserCommand.php index 4c847257e..57916da13 100644 --- a/app/Console/Commands/User/DeleteUserCommand.php +++ b/app/Console/Commands/User/DeleteUserCommand.php @@ -1,11 +1,4 @@ . - * - * This software is licensed under the terms of the MIT license. - * https://opensource.org/licenses/MIT - */ namespace Pterodactyl\Console\Commands\User; @@ -47,11 +40,9 @@ class DeleteUserCommand extends Command } /** - * @return bool - * * @throws \Pterodactyl\Exceptions\DisplayException */ - public function handle() + public function handle(): int { $search = $this->option('user') ?? $this->ask(trans('command/messages.user.search_users')); Assert::notEmpty($search, 'Search term should be an email address, got: %s.'); @@ -68,13 +59,13 @@ class DeleteUserCommand extends Command return $this->handle(); } - return false; + return 1; } if ($this->input->isInteractive()) { $tableValues = []; foreach ($results as $user) { - $tableValues[] = [$user->id, $user->email, $user->name]; + $tableValues[] = [$user->id, $user->email, $user->name_first]; } $this->table(['User ID', 'Email', 'Name'], $tableValues); @@ -85,7 +76,7 @@ class DeleteUserCommand extends Command if (count($results) > 1) { $this->error(trans('command/messages.user.multiple_found')); - return false; + return 1; } $deleteUser = $results->first(); @@ -95,5 +86,7 @@ class DeleteUserCommand extends Command $this->deletionService->handle($deleteUser); $this->info(trans('command/messages.user.deleted')); } + + return 0; } } diff --git a/app/Console/Commands/User/MakeUserCommand.php b/app/Console/Commands/User/MakeUserCommand.php index a3fefd965..73a2e0fa4 100644 --- a/app/Console/Commands/User/MakeUserCommand.php +++ b/app/Console/Commands/User/MakeUserCommand.php @@ -62,7 +62,7 @@ class MakeUserCommand extends Command ['UUID', $user->uuid], ['Email', $user->email], ['Username', $user->username], - ['Name', $user->name], + ['Name', $user->name_first], ['Admin', $user->root_admin ? 'Yes' : 'No'], ]); } diff --git a/app/Contracts/Repository/DatabaseRepositoryInterface.php b/app/Contracts/Repository/DatabaseRepositoryInterface.php index 622072203..4a1d0eb9f 100644 --- a/app/Contracts/Repository/DatabaseRepositoryInterface.php +++ b/app/Contracts/Repository/DatabaseRepositoryInterface.php @@ -39,10 +39,8 @@ interface DatabaseRepositoryInterface extends RepositoryInterface /** * Create a new database user on a given connection. - * - * @param $max_connections */ - public function createUser(string $username, string $remote, string $password, string $max_connections): bool; + public function createUser(string $username, string $remote, string $password, int $max_connections): bool; /** * Give a specific user access to a given database. @@ -61,8 +59,6 @@ interface DatabaseRepositoryInterface extends RepositoryInterface /** * Drop a given user on a specific connection. - * - * @return mixed */ public function dropUser(string $username, string $remote): bool; } diff --git a/app/Contracts/Repository/LocationRepositoryInterface.php b/app/Contracts/Repository/LocationRepositoryInterface.php index d24cee5bd..4a1b4ab12 100644 --- a/app/Contracts/Repository/LocationRepositoryInterface.php +++ b/app/Contracts/Repository/LocationRepositoryInterface.php @@ -20,8 +20,6 @@ interface LocationRepositoryInterface extends RepositoryInterface /** * Return all of the nodes and their respective count of servers for a location. * - * @return mixed - * * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException */ public function getWithNodes(int $id): Location; @@ -29,8 +27,6 @@ interface LocationRepositoryInterface extends RepositoryInterface /** * Return a location and the count of nodes in that location. * - * @return mixed - * * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException */ public function getWithNodeCount(int $id): Location; diff --git a/app/Contracts/Repository/NestRepositoryInterface.php b/app/Contracts/Repository/NestRepositoryInterface.php index 1f430dbf8..8556200d7 100644 --- a/app/Contracts/Repository/NestRepositoryInterface.php +++ b/app/Contracts/Repository/NestRepositoryInterface.php @@ -1,11 +1,4 @@ . - * - * This software is licensed under the terms of the MIT license. - * https://opensource.org/licenses/MIT - */ namespace Pterodactyl\Contracts\Repository; @@ -16,27 +9,7 @@ interface NestRepositoryInterface extends RepositoryInterface /** * Return a nest or all nests with their associated eggs and variables. * - * @param int $id - * - * @return \Illuminate\Database\Eloquent\Collection|\Pterodactyl\Models\Nest - * * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException */ - public function getWithEggs(int $id = null); - - /** - * Return a nest or all nests and the count of eggs and servers for that nest. - * - * @return \Pterodactyl\Models\Nest|\Illuminate\Database\Eloquent\Collection - * - * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException - */ - public function getWithCounts(int $id = null); - - /** - * Return a nest along with its associated eggs and the servers relation on those eggs. - * - * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException - */ - public function getWithEggServers(int $id): Nest; + public function getWithEggs(int $id = null): Nest; } diff --git a/app/Extensions/Spatie/Fractalistic/Fractal.php b/app/Extensions/Spatie/Fractalistic/Fractal.php index adb3c96a9..e2e6d2442 100644 --- a/app/Extensions/Spatie/Fractalistic/Fractal.php +++ b/app/Extensions/Spatie/Fractalistic/Fractal.php @@ -21,20 +21,20 @@ class Fractal extends SpatieFractal public function createData() { // Set the serializer by default. - if (is_null($this->serializer)) { + if (is_null($this->serializer)) { // @phpstan-ignore-line $this->serializer = new PterodactylSerializer(); } // Automatically set the paginator on the response object if the // data being provided implements a paginator. - if (is_null($this->paginator) && $this->data instanceof LengthAwarePaginator) { + if (is_null($this->paginator) && $this->data instanceof LengthAwarePaginator) { // @phpstan-ignore-line $this->paginator = new IlluminatePaginatorAdapter($this->data); } // If the resource name is not set attempt to pull it off the transformer // itself and set it automatically. $class = is_string($this->transformer) ? new $this->transformer() : $this->transformer; - if (is_null($this->resourceName) && $class instanceof Transformer) { + if (is_null($this->resourceName) && $class instanceof Transformer) { // @phpstan-ignore-line $this->resourceName = $class->getResourceName(); } diff --git a/app/Helpers/Time.php b/app/Helpers/Time.php index fd9a265a3..e8e585c2b 100644 --- a/app/Helpers/Time.php +++ b/app/Helpers/Time.php @@ -17,6 +17,6 @@ final class Time { $offset = round(CarbonImmutable::now($timezone)->getTimezone()->getOffset(CarbonImmutable::now('UTC')) / 3600); - return sprintf('%s%s:00', $offset > 0 ? '+' : '-', str_pad(abs($offset), 2, '0', STR_PAD_LEFT)); + return sprintf('%s%s:00', $offset > 0 ? '+' : '-', str_pad((string) abs($offset), 2, '0', STR_PAD_LEFT)); } } diff --git a/app/Http/Controllers/Api/Application/Databases/DatabaseController.php b/app/Http/Controllers/Api/Application/Databases/DatabaseController.php index 3398572a3..31a90bafc 100644 --- a/app/Http/Controllers/Api/Application/Databases/DatabaseController.php +++ b/app/Http/Controllers/Api/Application/Databases/DatabaseController.php @@ -40,7 +40,7 @@ class DatabaseController extends ApplicationApiController */ public function index(GetDatabasesRequest $request): array { - $perPage = $request->query('per_page', 10); + $perPage = (int) $request->query('per_page', '10'); if ($perPage < 1 || $perPage > 100) { throw new QueryValueOutOfRangeHttpException('per_page', 1, 100); } diff --git a/app/Http/Controllers/Api/Application/Eggs/EggController.php b/app/Http/Controllers/Api/Application/Eggs/EggController.php index 113ab26a2..4f1b9700c 100644 --- a/app/Http/Controllers/Api/Application/Eggs/EggController.php +++ b/app/Http/Controllers/Api/Application/Eggs/EggController.php @@ -2,11 +2,13 @@ namespace Pterodactyl\Http\Controllers\Api\Application\Eggs; +use Ramsey\Uuid\Uuid; use Pterodactyl\Models\Egg; use Pterodactyl\Models\Nest; use Illuminate\Http\Response; use Illuminate\Http\JsonResponse; use Spatie\QueryBuilder\QueryBuilder; +use Pterodactyl\Services\Eggs\Sharing\EggExporterService; use Pterodactyl\Transformers\Api\Application\EggTransformer; use Pterodactyl\Http\Requests\Api\Application\Eggs\GetEggRequest; use Pterodactyl\Exceptions\Http\QueryValueOutOfRangeHttpException; @@ -14,20 +16,31 @@ use Pterodactyl\Http\Requests\Api\Application\Eggs\GetEggsRequest; use Pterodactyl\Http\Requests\Api\Application\Eggs\StoreEggRequest; use Pterodactyl\Http\Requests\Api\Application\Eggs\DeleteEggRequest; use Pterodactyl\Http\Requests\Api\Application\Eggs\UpdateEggRequest; +use Pterodactyl\Http\Requests\Api\Application\Eggs\ExportEggRequest; use Pterodactyl\Http\Controllers\Api\Application\ApplicationApiController; class EggController extends ApplicationApiController { + private EggExporterService $eggExporterService; + + public function __construct(EggExporterService $eggExporterService) + { + parent::__construct(); + + $this->eggExporterService = $eggExporterService; + } + /** * Return an array of all eggs on a given nest. */ public function index(GetEggsRequest $request, Nest $nest): array { - $perPage = $request->query('per_page', 10); + $perPage = (int) $request->query('per_page', '10'); if ($perPage > 100) { throw new QueryValueOutOfRangeHttpException('per_page', 1, 100); } + // @phpstan-ignore-next-line $eggs = QueryBuilder::for(Egg::query()) ->where('nest_id', '=', $nest->id) ->allowedFilters(['id', 'name', 'author']) @@ -56,11 +69,18 @@ class EggController extends ApplicationApiController */ public function store(StoreEggRequest $request): JsonResponse { - $egg = Egg::query()->create($request->validated()); + $validated = $request->validated(); + $merged = array_merge($validated, [ + 'uuid' => Uuid::uuid4()->toString(), + // TODO: allow this to be set in the request, and default to config value if null or not present. + 'author' => config('pterodactyl.service.author'), + ]); + + $egg = Egg::query()->create($merged); return $this->fractal->item($egg) ->transformWith(EggTransformer::class) - ->respond(JsonResponse::HTTP_CREATED); + ->respond(Response::HTTP_CREATED); } /** @@ -86,4 +106,14 @@ class EggController extends ApplicationApiController return $this->returnNoContent(); } + + /** + * Exports an egg. + * + * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException + */ + public function export(ExportEggRequest $request, int $eggId): JsonResponse + { + return new JsonResponse($this->eggExporterService->handle($eggId)); + } } diff --git a/app/Http/Controllers/Api/Application/Eggs/EggVariableController.php b/app/Http/Controllers/Api/Application/Eggs/EggVariableController.php new file mode 100644 index 000000000..c67089a48 --- /dev/null +++ b/app/Http/Controllers/Api/Application/Eggs/EggVariableController.php @@ -0,0 +1,81 @@ +connection = $connection; + $this->variableCreationService = $variableCreationService; + $this->variableUpdateService = $variableUpdateService; + } + + /** + * Creates a new egg variable. + * + * @throws \Pterodactyl\Exceptions\Service\Egg\Variable\BadValidationRuleException + * @throws \Pterodactyl\Exceptions\Service\Egg\Variable\ReservedVariableNameException + */ + public function store(StoreEggVariableRequest $request, Egg $egg): array + { + $variable = $this->variableCreationService->handle($egg->id, $request->validated()); + + return $this->fractal->item($variable) + ->transformWith(EggVariableTransformer::class) + ->toArray(); + } + + /** + * Updates multiple egg variables. + * + * @throws \Throwable + */ + public function update(UpdateEggVariablesRequest $request, Egg $egg): array + { + $validated = $request->validated(); + + $this->connection->transaction(function () use ($egg, $validated) { + foreach ($validated as $data) { + $this->variableUpdateService->handle($egg, $data); + } + }); + + return $this->fractal->collection($egg->refresh()->variables) + ->transformWith(EggVariableTransformer::class) + ->toArray(); + } + + /** + * Deletes a single egg variable. + */ + public function delete(Request $request, Egg $egg, EggVariable $eggVariable): Response + { + EggVariable::query() + ->where('id', $eggVariable->id) + ->where('egg_id', $egg->id) + ->delete(); + + return $this->returnNoContent(); + } +} diff --git a/app/Http/Controllers/Api/Application/Locations/LocationController.php b/app/Http/Controllers/Api/Application/Locations/LocationController.php index a3a4af6e7..e7b7b7a38 100644 --- a/app/Http/Controllers/Api/Application/Locations/LocationController.php +++ b/app/Http/Controllers/Api/Application/Locations/LocationController.php @@ -46,7 +46,7 @@ class LocationController extends ApplicationApiController */ public function index(GetLocationsRequest $request): array { - $perPage = $request->query('per_page', 10); + $perPage = (int) $request->query('per_page', '10'); if ($perPage < 1 || $perPage > 100) { throw new QueryValueOutOfRangeHttpException('per_page', 1, 100); } diff --git a/app/Http/Controllers/Api/Application/Mounts/MountController.php b/app/Http/Controllers/Api/Application/Mounts/MountController.php index 9a5ddfc1b..6fb6e6d16 100644 --- a/app/Http/Controllers/Api/Application/Mounts/MountController.php +++ b/app/Http/Controllers/Api/Application/Mounts/MountController.php @@ -34,7 +34,7 @@ class MountController extends ApplicationApiController */ public function index(GetMountsRequest $request): array { - $perPage = $request->query('per_page', 10); + $perPage = (int) $request->query('per_page', '10'); if ($perPage < 1 || $perPage > 100) { throw new QueryValueOutOfRangeHttpException('per_page', 1, 100); } diff --git a/app/Http/Controllers/Api/Application/Nests/NestController.php b/app/Http/Controllers/Api/Application/Nests/NestController.php index 8f2b68905..34e42804c 100644 --- a/app/Http/Controllers/Api/Application/Nests/NestController.php +++ b/app/Http/Controllers/Api/Application/Nests/NestController.php @@ -13,8 +13,8 @@ use Pterodactyl\Transformers\Api\Application\EggTransformer; use Pterodactyl\Transformers\Api\Application\NestTransformer; use Pterodactyl\Exceptions\Http\QueryValueOutOfRangeHttpException; use Pterodactyl\Http\Requests\Api\Application\Nests\GetNestRequest; -use Pterodactyl\Http\Requests\Api\Application\Nests\GetNestsRequest; use Pterodactyl\Http\Requests\Api\Application\Eggs\ImportEggRequest; +use Pterodactyl\Http\Requests\Api\Application\Nests\GetNestsRequest; use Pterodactyl\Http\Requests\Api\Application\Nests\StoreNestRequest; use Pterodactyl\Http\Requests\Api\Application\Nests\DeleteNestRequest; use Pterodactyl\Http\Requests\Api\Application\Nests\UpdateNestRequest; @@ -51,7 +51,7 @@ class NestController extends ApplicationApiController */ public function index(GetNestsRequest $request): array { - $perPage = $request->query('per_page', 10); + $perPage = (int) $request->query('per_page', '10'); if ($perPage > 100) { throw new QueryValueOutOfRangeHttpException('per_page', 1, 100); } diff --git a/app/Http/Controllers/Api/Application/Nodes/AllocationController.php b/app/Http/Controllers/Api/Application/Nodes/AllocationController.php index 9845a4c62..8f0455c21 100644 --- a/app/Http/Controllers/Api/Application/Nodes/AllocationController.php +++ b/app/Http/Controllers/Api/Application/Nodes/AllocationController.php @@ -42,7 +42,7 @@ class AllocationController extends ApplicationApiController */ public function index(GetAllocationsRequest $request, Node $node): array { - $perPage = $request->query('per_page', 10); + $perPage = (int) $request->query('per_page', '10'); if ($perPage < 1 || $perPage > 100) { throw new QueryValueOutOfRangeHttpException('per_page', 1, 100); } diff --git a/app/Http/Controllers/Api/Application/Nodes/NodeController.php b/app/Http/Controllers/Api/Application/Nodes/NodeController.php index c9cfbb857..5d9dc5b91 100644 --- a/app/Http/Controllers/Api/Application/Nodes/NodeController.php +++ b/app/Http/Controllers/Api/Application/Nodes/NodeController.php @@ -50,7 +50,7 @@ class NodeController extends ApplicationApiController */ public function index(GetNodesRequest $request): array { - $perPage = $request->query('per_page', 10); + $perPage = (int) $request->query('per_page', '10'); if ($perPage < 1 || $perPage > 100) { throw new QueryValueOutOfRangeHttpException('per_page', 1, 100); } diff --git a/app/Http/Controllers/Api/Application/Nodes/NodeDeploymentController.php b/app/Http/Controllers/Api/Application/Nodes/NodeDeploymentController.php index cebf5a8a0..9633bc8fe 100644 --- a/app/Http/Controllers/Api/Application/Nodes/NodeDeploymentController.php +++ b/app/Http/Controllers/Api/Application/Nodes/NodeDeploymentController.php @@ -35,7 +35,7 @@ class NodeDeploymentController extends ApplicationApiController $nodes = $this->viableNodesService->setLocations($data['location_ids'] ?? []) ->setMemory($data['memory']) ->setDisk($data['disk']) - ->handle($request->query('per_page'), $request->query('page')); + ->handle($request->query('per_page'), $request->query('page')); // @phpstan-ignore-line return $this->fractal->collection($nodes) ->transformWith(NodeTransformer::class) diff --git a/app/Http/Controllers/Api/Application/Roles/RoleController.php b/app/Http/Controllers/Api/Application/Roles/RoleController.php index aab08f276..253e070fe 100644 --- a/app/Http/Controllers/Api/Application/Roles/RoleController.php +++ b/app/Http/Controllers/Api/Application/Roles/RoleController.php @@ -32,7 +32,7 @@ class RoleController extends ApplicationApiController */ public function index(GetRolesRequest $request): array { - $perPage = $request->query('per_page', 10); + $perPage = (int) $request->query('per_page', '10'); if ($perPage < 1 || $perPage > 100) { throw new QueryValueOutOfRangeHttpException('per_page', 1, 100); } diff --git a/app/Http/Controllers/Api/Application/Servers/ServerController.php b/app/Http/Controllers/Api/Application/Servers/ServerController.php index 40436fb7e..93260f321 100644 --- a/app/Http/Controllers/Api/Application/Servers/ServerController.php +++ b/app/Http/Controllers/Api/Application/Servers/ServerController.php @@ -52,7 +52,7 @@ class ServerController extends ApplicationApiController */ public function index(GetServersRequest $request): array { - $perPage = $request->query('per_page', 10); + $perPage = (int) $request->query('per_page', '10'); if ($perPage < 1 || $perPage > 100) { throw new QueryValueOutOfRangeHttpException('per_page', 1, 100); } @@ -79,7 +79,7 @@ class ServerController extends ApplicationApiController */ public function store(StoreServerRequest $request): JsonResponse { - $server = $this->creationService->handle($request->validated(), $request->getDeploymentObject()); + $server = $this->creationService->handle($request->validated()); return $this->fractal->item($server) ->transformWith(ServerTransformer::class) diff --git a/app/Http/Controllers/Api/Application/Users/UserController.php b/app/Http/Controllers/Api/Application/Users/UserController.php index 8cb40cc88..44e765d4c 100644 --- a/app/Http/Controllers/Api/Application/Users/UserController.php +++ b/app/Http/Controllers/Api/Application/Users/UserController.php @@ -52,7 +52,7 @@ class UserController extends ApplicationApiController */ public function index(GetUsersRequest $request): array { - $perPage = $request->query('per_page', 10); + $perPage = (int) $request->query('per_page', '10'); if ($perPage < 1 || $perPage > 100) { throw new QueryValueOutOfRangeHttpException('per_page', 1, 100); } diff --git a/app/Http/Controllers/Api/Client/AccountController.php b/app/Http/Controllers/Api/Client/AccountController.php index 84b789c95..4a2873f79 100644 --- a/app/Http/Controllers/Api/Client/AccountController.php +++ b/app/Http/Controllers/Api/Client/AccountController.php @@ -4,7 +4,7 @@ namespace Pterodactyl\Http\Controllers\Api\Client; use Illuminate\Http\Request; use Illuminate\Http\Response; -use Illuminate\Auth\SessionGuard; +use Illuminate\Auth\AuthManager; use Pterodactyl\Services\Users\UserUpdateService; use Pterodactyl\Transformers\Api\Client\AccountTransformer; use Pterodactyl\Http\Requests\Api\Client\Account\UpdateEmailRequest; @@ -12,24 +12,26 @@ use Pterodactyl\Http\Requests\Api\Client\Account\UpdatePasswordRequest; class AccountController extends ClientApiController { - private SessionGuard $sessionGuard; private UserUpdateService $updateService; + /** + * @var \Illuminate\Auth\AuthManager + */ + private $sessionGuard; + /** * AccountController constructor. */ - public function __construct(SessionGuard $sessionGuard, UserUpdateService $updateService) + public function __construct(UserUpdateService $updateService, AuthManager $sessionGuard) { parent::__construct(); - $this->sessionGuard = $sessionGuard; $this->updateService = $updateService; + $this->sessionGuard = $sessionGuard; } /** * Gets information about the currently authenticated user. - * - * @throws \Illuminate\Contracts\Container\BindingResolutionException */ public function index(Request $request): array { @@ -40,9 +42,6 @@ class AccountController extends ClientApiController /** * Update the authenticated user's email address. - * - * @throws \Pterodactyl\Exceptions\Model\DataValidationException - * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException */ public function updateEmail(UpdateEmailRequest $request): Response { @@ -65,9 +64,12 @@ class AccountController extends ClientApiController // cached copy of the user that does not include the updated password. Do this // to correctly store the new user details in the guard and allow the logout // other devices functionality to work. - $this->sessionGuard->setUser($user); + if (method_exists($this->sessionGuard, 'setUser')) { + $this->sessionGuard->setUser($user); + } - $this->sessionGuard->logoutOtherDevices($request->input('password')); + // TODO: Find another way to do this, function doesn't exist due to API changes. + //$this->sessionGuard->logoutOtherDevices($request->input('password')); return $this->returnNoContent(); } diff --git a/app/Http/Controllers/Api/Client/ClientController.php b/app/Http/Controllers/Api/Client/ClientController.php index 67b15555d..caf99e2f1 100644 --- a/app/Http/Controllers/Api/Client/ClientController.php +++ b/app/Http/Controllers/Api/Client/ClientController.php @@ -66,7 +66,7 @@ class ClientController extends ClientApiController $builder = $builder->whereIn('servers.id', $user->accessibleServers()->pluck('id')->all()); } - $servers = $builder->paginate(min($request->query('per_page', 50), 100))->appends($request->query()); + $servers = $builder->paginate(min((int) $request->query('per_page', '50'), 100))->appends($request->query()); return $this->fractal->transformWith(new ServerTransformer())->collection($servers)->toArray(); } diff --git a/app/Http/Controllers/Api/Client/Servers/StartupController.php b/app/Http/Controllers/Api/Client/Servers/StartupController.php index 856bae29d..f9328264d 100644 --- a/app/Http/Controllers/Api/Client/Servers/StartupController.php +++ b/app/Http/Controllers/Api/Client/Servers/StartupController.php @@ -61,7 +61,6 @@ class StartupController extends ClientApiController */ public function update(UpdateStartupVariableRequest $request, Server $server): array { - /** @var \Pterodactyl\Models\EggVariable $variable */ $variable = $server->variables()->where('env_variable', $request->input('key'))->first(); if (is_null($variable) || !$variable->user_viewable) { diff --git a/app/Http/Controllers/Auth/AbstractLoginController.php b/app/Http/Controllers/Auth/AbstractLoginController.php index b490d6036..7a12ad665 100644 --- a/app/Http/Controllers/Auth/AbstractLoginController.php +++ b/app/Http/Controllers/Auth/AbstractLoginController.php @@ -7,7 +7,7 @@ use Pterodactyl\Models\User; use Illuminate\Auth\AuthManager; use Illuminate\Http\JsonResponse; use Illuminate\Auth\Events\Failed; -use Illuminate\Contracts\Config\Repository; +use Illuminate\Container\Container; use Pterodactyl\Exceptions\DisplayException; use Pterodactyl\Http\Controllers\Controller; use Illuminate\Contracts\Auth\Authenticatable; @@ -17,6 +17,8 @@ abstract class AbstractLoginController extends Controller { use AuthenticatesUsers; + protected AuthManager $auth; + /** * Lockout time for failed login requests. * @@ -38,26 +40,14 @@ abstract class AbstractLoginController extends Controller */ protected $redirectTo = '/'; - /** - * @var \Illuminate\Auth\AuthManager - */ - protected $auth; - - /** - * @var \Illuminate\Contracts\Config\Repository - */ - protected $config; - /** * LoginController constructor. */ - public function __construct(AuthManager $auth, Repository $config) + public function __construct() { - $this->lockoutTime = $config->get('auth.lockout.time'); - $this->maxLoginAttempts = $config->get('auth.lockout.attempts'); - - $this->auth = $auth; - $this->config = $config; + $this->lockoutTime = config('auth.lockout.time'); + $this->maxLoginAttempts = config('auth.lockout.attempts'); + $this->auth = Container::getInstance()->make(AuthManager::class); } /** @@ -72,7 +62,7 @@ abstract class AbstractLoginController extends Controller $this->getField($request->input('user')) => $request->input('user'), ]); - if ($request->route()->named('auth.login-checkpoint')) { + if ($request->route()->named('auth.checkpoint') || $request->route()->named('auth.checkpoint.key')) { throw new DisplayException($message ?? trans('auth.two_factor.checkpoint_failed')); } @@ -84,7 +74,9 @@ abstract class AbstractLoginController extends Controller */ protected function sendLoginResponse(User $user, Request $request): JsonResponse { + $request->session()->remove('auth_confirmation_token'); $request->session()->regenerate(); + $this->clearLoginAttempts($request); $this->auth->guard()->login($user, true); @@ -99,8 +91,6 @@ abstract class AbstractLoginController extends Controller /** * Determine if the user is logging in using an email or username,. - * - * @param string $input */ protected function getField(string $input = null): string { diff --git a/app/Http/Controllers/Auth/ForgotPasswordController.php b/app/Http/Controllers/Auth/ForgotPasswordController.php index 7b409e519..bb272f58f 100644 --- a/app/Http/Controllers/Auth/ForgotPasswordController.php +++ b/app/Http/Controllers/Auth/ForgotPasswordController.php @@ -16,7 +16,6 @@ class ForgotPasswordController extends Controller /** * Get the response for a failed password reset link. * - * @param \Illuminate\Http\Request * @param string $response */ protected function sendResetLinkFailedResponse(Request $request, $response): JsonResponse diff --git a/app/Http/Controllers/Auth/LoginCheckpointController.php b/app/Http/Controllers/Auth/LoginCheckpointController.php index d38f31230..865377b7b 100644 --- a/app/Http/Controllers/Auth/LoginCheckpointController.php +++ b/app/Http/Controllers/Auth/LoginCheckpointController.php @@ -2,36 +2,35 @@ namespace Pterodactyl\Http\Controllers\Auth; +use Carbon\CarbonImmutable; +use Carbon\CarbonInterface; use Pterodactyl\Models\User; -use Illuminate\Auth\AuthManager; use PragmaRX\Google2FA\Google2FA; -use Illuminate\Contracts\Config\Repository; use Illuminate\Contracts\Encryption\Encrypter; use Illuminate\Database\Eloquent\ModelNotFoundException; use Pterodactyl\Http\Requests\Auth\LoginCheckpointRequest; -use Illuminate\Contracts\Cache\Repository as CacheRepository; +use Illuminate\Contracts\Validation\Factory as ValidationFactory; class LoginCheckpointController extends AbstractLoginController { - private CacheRepository $cache; + public const TOKEN_EXPIRED_MESSAGE = 'The authentication token provided has expired, please refresh the page and try again.'; + private Encrypter $encrypter; + private Google2FA $google2FA; + private ValidationFactory $validation; + /** * LoginCheckpointController constructor. */ - public function __construct( - AuthManager $auth, - Repository $config, - CacheRepository $cache, - Encrypter $encrypter, - Google2FA $google2FA - ) { - parent::__construct($auth, $config); + public function __construct(Encrypter $encrypter, Google2FA $google2FA, ValidationFactory $validation) + { + parent::__construct(); - $this->cache = $cache; $this->encrypter = $encrypter; $this->google2FA = $google2FA; + $this->validation = $validation; } /** @@ -45,6 +44,7 @@ class LoginCheckpointController extends AbstractLoginController * @throws \PragmaRX\Google2FA\Exceptions\InvalidCharactersException * @throws \PragmaRX\Google2FA\Exceptions\SecretKeyTooShortException * @throws \Illuminate\Validation\ValidationException + * @throws \Pterodactyl\Exceptions\DisplayException */ public function __invoke(LoginCheckpointRequest $request) { @@ -54,18 +54,24 @@ class LoginCheckpointController extends AbstractLoginController return; } - $token = $request->input('confirmation_token'); + $details = $request->session()->get('auth_confirmation_token'); + if (!$this->hasValidSessionData($details)) { + $this->sendFailedLoginResponse($request, null, self::TOKEN_EXPIRED_MESSAGE); + + return; + } + + if (!hash_equals($request->input('confirmation_token') ?? '', $details['token_value'])) { + $this->sendFailedLoginResponse($request); + + return; + } + try { /** @var \Pterodactyl\Models\User $user */ - $user = User::query()->findOrFail($this->cache->get($token, 0)); + $user = User::query()->findOrFail($details['user_id']); } catch (ModelNotFoundException $exception) { - $this->incrementLoginAttempts($request); - - $this->sendFailedLoginResponse( - $request, - null, - 'The authentication token provided has expired, please refresh the page and try again.' - ); + $this->sendFailedLoginResponse($request, null, self::TOKEN_EXPIRED_MESSAGE); return; } @@ -79,25 +85,18 @@ class LoginCheckpointController extends AbstractLoginController $decrypted = $this->encrypter->decrypt($user->totp_secret); if ($this->google2FA->verifyKey($decrypted, (string) $request->input('authentication_code') ?? '', config('pterodactyl.auth.2fa.window'))) { - $this->cache->delete($token); - return $this->sendLoginResponse($user, $request); } } - $this->incrementLoginAttempts($request); $this->sendFailedLoginResponse($request, $user, !empty($recoveryToken) ? 'The recovery token provided is not valid.' : null); } /** * Determines if a given recovery token is valid for the user account. If we find a matching token * it will be deleted from the database. - * - * @return bool - * - * @throws \Exception */ - protected function isValidRecoveryToken(User $user, string $value) + protected function isValidRecoveryToken(User $user, string $value): bool { foreach ($user->recoveryTokens as $token) { if (password_verify($value, $token->token)) { @@ -109,4 +108,37 @@ class LoginCheckpointController extends AbstractLoginController return false; } + + protected function hasValidSessionData(array $data): bool + { + return static::isValidSessionData($this->validation, $data); + } + + /** + * Determines if the data provided from the session is valid or not. This + * will return false if the data is invalid, or if more time has passed than + * was configured when the session was written. + */ + public static function isValidSessionData(ValidationFactory $validation, array $data): bool + { + $validator = $validation->make($data, [ + 'user_id' => 'required|integer|min:1', + 'token_value' => 'required|string', + 'expires_at' => 'required', + ]); + + if ($validator->fails()) { + return false; + } + + if (!$data['expires_at'] instanceof CarbonInterface) { + return false; + } + + if ($data['expires_at']->isBefore(CarbonImmutable::now())) { + return false; + } + + return true; + } } diff --git a/app/Http/Controllers/Auth/LoginController.php b/app/Http/Controllers/Auth/LoginController.php index 7df497865..4ed326623 100644 --- a/app/Http/Controllers/Auth/LoginController.php +++ b/app/Http/Controllers/Auth/LoginController.php @@ -5,14 +5,11 @@ namespace Pterodactyl\Http\Controllers\Auth; use Carbon\CarbonImmutable; use Illuminate\Support\Str; use Illuminate\Http\Request; -use Illuminate\Auth\AuthManager; +use Pterodactyl\Models\User; use Illuminate\Http\JsonResponse; use Illuminate\Contracts\View\View; -use Illuminate\Contracts\Config\Repository; +use LaravelWebauthn\Facades\Webauthn; use Illuminate\Contracts\View\Factory as ViewFactory; -use Illuminate\Contracts\Cache\Repository as CacheRepository; -use Pterodactyl\Contracts\Repository\UserRepositoryInterface; -use Pterodactyl\Exceptions\Repository\RecordNotFoundException; class LoginController extends AbstractLoginController { @@ -21,31 +18,22 @@ class LoginController extends AbstractLoginController private const METHOD_TOTP = 'totp'; private const METHOD_WEBAUTHN = 'webauthn'; - private CacheRepository $cache; - private UserRepositoryInterface $repository; private ViewFactory $view; /** * LoginController constructor. */ - public function __construct( - AuthManager $auth, - Repository $config, - CacheRepository $cache, - UserRepositoryInterface $repository, - ViewFactory $view - ) { - parent::__construct($auth, $config); + public function __construct(ViewFactory $view) + { + parent::__construct(); - $this->cache = $cache; - $this->repository = $repository; $this->view = $view; } /** * Handle all incoming requests for the authentication routes and render the - * base authentication view component. React will take over at this point and - * turn the login area into a SPA. + * base authentication view component. React will take over at this point and + * turn the login area into an SPA. */ public function index(): View { @@ -62,9 +50,6 @@ class LoginController extends AbstractLoginController */ public function login(Request $request) { - $username = $request->input('user'); - $useColumn = $this->getField($username); - if ($this->hasTooManyLoginAttempts($request)) { $this->fireLockoutEvent($request); $this->sendLockoutResponse($request); @@ -72,13 +57,12 @@ class LoginController extends AbstractLoginController return; } - try { - /** @var \Pterodactyl\Models\User $user */ - $user = $this->repository->findFirstWhere([[$useColumn, '=', $username]]); - } catch (RecordNotFoundException $exception) { - $this->sendFailedLoginResponse($request); + $username = $request->input('user'); - return; + /** @var \Pterodactyl\Models\User|null $user */ + $user = User::query()->where($this->getField($username), $username)->first(); + if (is_null($user)) { + $this->sendFailedLoginResponse($request); } // Ensure that the account is using a valid username and password before trying to @@ -91,17 +75,44 @@ class LoginController extends AbstractLoginController return; } - if ($user->use_totp) { - $token = Str::random(64); - $this->cache->put($token, $user->id, CarbonImmutable::now()->addMinutes(5)); + $useTotp = $user->use_totp; + $webauthnKeys = $user->webauthnKeys()->get(); - return new JsonResponse([ - 'complete' => false, - 'methods' => [self::METHOD_TOTP], - 'confirmation_token' => $token, - ]); + if (!$useTotp && count($webauthnKeys) < 1) { + return $this->sendLoginResponse($user, $request); } - return $this->sendLoginResponse($user, $request); + $methods = []; + if ($useTotp) { + $methods[] = self::METHOD_TOTP; + } + if (count($webauthnKeys) > 0) { + $methods[] = self::METHOD_WEBAUTHN; + } + + $token = Str::random(64); + + $request->session()->put('auth_confirmation_token', [ + 'user_id' => $user->id, + 'token_value' => $token, + 'expires_at' => CarbonImmutable::now()->addMinutes(5), + ]); + + $response = [ + 'complete' => false, + 'methods' => $methods, + 'confirmation_token' => $token, + ]; + + if (count($webauthnKeys) > 0) { + $publicKey = Webauthn::getAuthenticateData($user); + $request->session()->put(self::SESSION_PUBLICKEY_REQUEST, $publicKey); + + $response['webauthn'] = [ + 'public_key' => $publicKey, + ]; + } + + return new JsonResponse($response); } } diff --git a/app/Http/Controllers/Auth/ResetPasswordController.php b/app/Http/Controllers/Auth/ResetPasswordController.php index 1107510a8..d074c0353 100644 --- a/app/Http/Controllers/Auth/ResetPasswordController.php +++ b/app/Http/Controllers/Auth/ResetPasswordController.php @@ -87,7 +87,7 @@ class ResetPasswordController extends Controller * account do not automatically log them in. In those cases, send the user back to the login * form with a note telling them their password was changed and to log back in. * - * @param \Illuminate\Contracts\Auth\CanResetPassword|\Pterodactyl\Models\User $user + * @param \Pterodactyl\Models\User $user * @param string $password * * @throws \Pterodactyl\Exceptions\Model\DataValidationException diff --git a/app/Http/Middleware/Api/Application/SubstituteApplicationApiBindings.php b/app/Http/Middleware/Api/Application/SubstituteApplicationApiBindings.php index 4ae63ff4a..e629f6ca6 100644 --- a/app/Http/Middleware/Api/Application/SubstituteApplicationApiBindings.php +++ b/app/Http/Middleware/Api/Application/SubstituteApplicationApiBindings.php @@ -54,7 +54,7 @@ class SubstituteApplicationApiBindings try { $this->router->substituteImplicitBindings($route = $request->route()); } catch (ModelNotFoundException $exception) { - if (isset($route) && $route->getMissing()) { + if (!empty($route) && $route->getMissing()) { $route->getMissing()($request); } diff --git a/app/Http/Middleware/Api/Client/SubstituteClientApiBindings.php b/app/Http/Middleware/Api/Client/SubstituteClientApiBindings.php index 3a1a80f49..954f1e0f5 100644 --- a/app/Http/Middleware/Api/Client/SubstituteClientApiBindings.php +++ b/app/Http/Middleware/Api/Client/SubstituteClientApiBindings.php @@ -25,7 +25,7 @@ class SubstituteClientApiBindings /** * Perform substitution of route parameters for the Client API. * - * @param \Illuminate\Http\Request + * @param \Illuminate\Http\Request $request * * @return mixed */ @@ -76,7 +76,7 @@ class SubstituteClientApiBindings /* @var \Illuminate\Routing\Route $route */ $this->router->substituteBindings($route = $request->route()); } catch (ModelNotFoundException $exception) { - if (isset($route) && $route->getMissing()) { + if (!empty($route) && $route->getMissing()) { $route->getMissing()($request); } diff --git a/app/Http/Middleware/RequireTwoFactorAuthentication.php b/app/Http/Middleware/RequireTwoFactorAuthentication.php index 724a11eb1..57c4dfc51 100644 --- a/app/Http/Middleware/RequireTwoFactorAuthentication.php +++ b/app/Http/Middleware/RequireTwoFactorAuthentication.php @@ -41,7 +41,7 @@ class RequireTwoFactorAuthentication */ public function handle(Request $request, Closure $next) { - /** @var \Pterodactyl\Models\User $user */ + /** @var \Pterodactyl\Models\User|null $user */ $user = $request->user(); $uri = rtrim($request->getRequestUri(), '/') . '/'; $current = $request->route()->getName(); @@ -66,6 +66,7 @@ class RequireTwoFactorAuthentication throw new TwoFactorAuthRequiredException(); } + // @phpstan-ignore-next-line $this->alert->danger(trans('auth.2fa_must_be_enabled'))->flash(); return redirect()->to($this->redirectRoute); diff --git a/app/Http/Requests/Api/Application/Eggs/ExportEggRequest.php b/app/Http/Requests/Api/Application/Eggs/ExportEggRequest.php new file mode 100644 index 000000000..63893df54 --- /dev/null +++ b/app/Http/Requests/Api/Application/Eggs/ExportEggRequest.php @@ -0,0 +1,9 @@ + 'required|bail|numeric|exists:nests,id', + 'name' => 'required|string|max:191', + 'description' => 'sometimes|string|nullable', + 'features' => 'sometimes|array', + 'docker_images' => 'required|array|min:1', + 'docker_images.*' => 'required|string', + 'file_denylist' => 'sometimes|array|nullable', + 'file_denylist.*' => 'sometimes|string', + 'config_files' => 'required|nullable|json', + 'config_startup' => 'required|nullable|json', + 'config_stop' => 'required|nullable|string|max:191', +// 'config_from' => 'sometimes|nullable|numeric|exists:eggs,id', + 'startup' => 'required|string', + 'script_container' => 'sometimes|string', + 'script_entry' => 'sometimes|string', + 'script_install' => 'sometimes|string', + ]; } } diff --git a/app/Http/Requests/Api/Application/Eggs/UpdateEggRequest.php b/app/Http/Requests/Api/Application/Eggs/UpdateEggRequest.php index 7eb76fa24..090f5b51c 100644 --- a/app/Http/Requests/Api/Application/Eggs/UpdateEggRequest.php +++ b/app/Http/Requests/Api/Application/Eggs/UpdateEggRequest.php @@ -10,16 +10,16 @@ class UpdateEggRequest extends StoreEggRequest 'nest_id' => 'sometimes|numeric|exists:nests,id', 'name' => 'sometimes|string|max:191', 'description' => 'sometimes|string|nullable', - 'features' => 'sometimes|array|nullable', - 'docker_images' => 'sometimes|required|array|min:1', + 'features' => 'sometimes|array', + 'docker_images' => 'sometimes|array|min:1', 'docker_images.*' => 'sometimes|string', 'file_denylist' => 'sometimes|array|nullable', 'file_denylist.*' => 'sometimes|string', 'config_files' => 'sometimes|nullable|json', 'config_startup' => 'sometimes|nullable|json', 'config_stop' => 'sometimes|nullable|string|max:191', - 'config_from' => 'sometimes|nullable|numeric|exists:eggs,id', - 'startup' => 'sometimes|nullable|string', +// 'config_from' => 'sometimes|nullable|numeric|exists:eggs,id', + 'startup' => 'sometimes|string', 'script_container' => 'sometimes|string', 'script_entry' => 'sometimes|string', 'script_install' => 'sometimes|string', diff --git a/app/Http/Requests/Api/Application/Eggs/Variables/StoreEggVariableRequest.php b/app/Http/Requests/Api/Application/Eggs/Variables/StoreEggVariableRequest.php new file mode 100644 index 000000000..9c674a9d8 --- /dev/null +++ b/app/Http/Requests/Api/Application/Eggs/Variables/StoreEggVariableRequest.php @@ -0,0 +1,22 @@ + 'required|string|min:1|max:191', + 'description' => 'sometimes|string|nullable', + 'env_variable' => 'required|regex:/^[\w]{1,191}$/|notIn:' . EggVariable::RESERVED_ENV_NAMES, + 'default_value' => 'present', + 'user_viewable' => 'required|boolean', + 'user_editable' => 'required|boolean', + 'rules' => 'bail|required|string', + ]; + } +} diff --git a/app/Http/Requests/Api/Application/Eggs/Variables/UpdateEggVariablesRequest.php b/app/Http/Requests/Api/Application/Eggs/Variables/UpdateEggVariablesRequest.php new file mode 100644 index 000000000..c15de2ce3 --- /dev/null +++ b/app/Http/Requests/Api/Application/Eggs/Variables/UpdateEggVariablesRequest.php @@ -0,0 +1,24 @@ + 'array', + '*.id' => 'required|integer', + '*.name' => 'sometimes|string|min:1|max:191', + '*.description' => 'sometimes|string|nullable', + '*.env_variable' => 'sometimes|regex:/^[\w]{1,191}$/|notIn:' . EggVariable::RESERVED_ENV_NAMES, + '*.default_value' => 'sometimes|present', + '*.user_viewable' => 'sometimes|boolean', + '*.user_editable' => 'sometimes|boolean', + '*.rules' => 'sometimes|string', + ]; + } +} diff --git a/app/Http/Requests/Api/Application/Servers/StoreServerRequest.php b/app/Http/Requests/Api/Application/Servers/StoreServerRequest.php index 3e42ab62a..452909abe 100644 --- a/app/Http/Requests/Api/Application/Servers/StoreServerRequest.php +++ b/app/Http/Requests/Api/Application/Servers/StoreServerRequest.php @@ -3,9 +3,6 @@ namespace Pterodactyl\Http\Requests\Api\Application\Servers; use Pterodactyl\Models\Server; -use Illuminate\Validation\Rule; -use Illuminate\Contracts\Validation\Validator; -use Pterodactyl\Models\Objects\DeploymentObject; use Pterodactyl\Http\Requests\Api\Application\ApplicationApiRequest; class StoreServerRequest extends ApplicationApiRequest @@ -18,15 +15,9 @@ class StoreServerRequest extends ApplicationApiRequest 'external_id' => $rules['external_id'], 'name' => $rules['name'], 'description' => array_merge(['nullable'], $rules['description']), - 'user' => $rules['owner_id'], - 'egg' => $rules['egg_id'], - 'docker_image' => $rules['image'], - 'startup' => $rules['startup'], - 'environment' => 'present|array', - 'skip_scripts' => 'sometimes|boolean', - 'oom_disabled' => 'sometimes|boolean', + 'owner_id' => $rules['owner_id'], + 'node_id' => $rules['node_id'], - // Resource limitations 'limits' => 'required|array', 'limits.memory' => $rules['memory'], 'limits.swap' => $rules['swap'], @@ -34,26 +25,21 @@ class StoreServerRequest extends ApplicationApiRequest 'limits.io' => $rules['io'], 'limits.threads' => $rules['threads'], 'limits.cpu' => $rules['cpu'], + 'limits.oom_killer' => 'required|boolean', - // Application Resource Limits 'feature_limits' => 'required|array', - 'feature_limits.databases' => $rules['database_limit'], 'feature_limits.allocations' => $rules['allocation_limit'], 'feature_limits.backups' => $rules['backup_limit'], + 'feature_limits.databases' => $rules['database_limit'], - // Placeholders for rules added in withValidator() function. - 'allocation.default' => '', - 'allocation.additional.*' => '', + 'allocation.default' => 'required|bail|integer|exists:allocations,id', + 'allocation.additional.*' => 'integer|exists:allocations,id', - // Automatic deployment rules - 'deploy' => 'sometimes|required|array', - 'deploy.locations' => 'array', - 'deploy.locations.*' => 'integer|min:1', - 'deploy.dedicated_ip' => 'required_with:deploy,boolean', - 'deploy.port_range' => 'array', - 'deploy.port_range.*' => 'string', - - 'start_on_completion' => 'sometimes|boolean', + 'startup' => $rules['startup'], + 'environment' => 'present|array', + 'egg_id' => $rules['egg_id'], + 'image' => $rules['image'], + 'skip_scripts' => 'present|boolean', ]; } @@ -65,69 +51,30 @@ class StoreServerRequest extends ApplicationApiRequest 'external_id' => array_get($data, 'external_id'), 'name' => array_get($data, 'name'), 'description' => array_get($data, 'description'), - 'owner_id' => array_get($data, 'user'), - 'egg_id' => array_get($data, 'egg'), - 'image' => array_get($data, 'docker_image'), - 'startup' => array_get($data, 'startup'), - 'environment' => array_get($data, 'environment'), + 'owner_id' => array_get($data, 'owner_id'), + 'node_id' => array_get($data, 'node_id'), + 'memory' => array_get($data, 'limits.memory'), 'swap' => array_get($data, 'limits.swap'), 'disk' => array_get($data, 'limits.disk'), 'io' => array_get($data, 'limits.io'), - 'cpu' => array_get($data, 'limits.cpu'), 'threads' => array_get($data, 'limits.threads'), - 'skip_scripts' => array_get($data, 'skip_scripts', false), - 'allocation_id' => array_get($data, 'allocation.default'), - 'allocation_additional' => array_get($data, 'allocation.additional'), - 'start_on_completion' => array_get($data, 'start_on_completion', false), - 'database_limit' => array_get($data, 'feature_limits.databases'), + 'cpu' => array_get($data, 'limits.cpu'), + 'oom_disabled' => !array_get($data, 'limits.oom_killer'), + 'allocation_limit' => array_get($data, 'feature_limits.allocations'), 'backup_limit' => array_get($data, 'feature_limits.backups'), + 'database_limit' => array_get($data, 'feature_limits.databases'), + + 'allocation_id' => array_get($data, 'allocation.default'), + 'allocation_additional' => array_get($data, 'allocation.additional'), + + 'startup' => array_get($data, 'startup'), + 'environment' => array_get($data, 'environment'), + 'egg_id' => array_get($data, 'egg_id'), + 'image' => array_get($data, 'image'), + 'skip_scripts' => array_get($data, 'skip_scripts'), + 'start_on_completion' => array_get($data, 'start_on_completion', false), ]; } - - public function withValidator(Validator $validator) - { - $validator->sometimes('allocation.default', [ - 'required', - 'integer', - 'bail', - Rule::exists('allocations', 'id')->where(function ($query) { - $query->whereNull('server_id'); - }), - ], function ($input) { - return !($input->deploy); - }); - - $validator->sometimes('allocation.additional.*', [ - 'integer', - Rule::exists('allocations', 'id')->where(function ($query) { - $query->whereNull('server_id'); - }), - ], function ($input) { - return !($input->deploy); - }); - - $validator->sometimes('deploy.locations', 'present', function ($input) { - return $input->deploy; - }); - - $validator->sometimes('deploy.port_range', 'present', function ($input) { - return $input->deploy; - }); - } - - public function getDeploymentObject(): ?DeploymentObject - { - if (is_null($this->input('deploy'))) { - return null; - } - - $object = new DeploymentObject(); - $object->setDedicated($this->input('deploy.dedicated_ip', false)); - $object->setLocations($this->input('deploy.locations', [])); - $object->setPorts($this->input('deploy.port_range', [])); - - return $object; - } } diff --git a/app/Http/Requests/Api/Application/Servers/UpdateServerRequest.php b/app/Http/Requests/Api/Application/Servers/UpdateServerRequest.php index 37637d664..9b254d047 100644 --- a/app/Http/Requests/Api/Application/Servers/UpdateServerRequest.php +++ b/app/Http/Requests/Api/Application/Servers/UpdateServerRequest.php @@ -55,7 +55,7 @@ class UpdateServerRequest extends ApplicationApiRequest 'io' => array_get($data, 'limits.io'), 'threads' => array_get($data, 'limits.threads'), 'cpu' => array_get($data, 'limits.cpu'), - 'oom_disabled' => array_get($data, 'limits.oom_disabled'), + 'oom_disabled' => !array_get($data, 'limits.oom_killer'), 'allocation_limit' => array_get($data, 'feature_limits.allocations'), 'backup_limit' => array_get($data, 'feature_limits.backups'), diff --git a/app/Http/Requests/Api/Client/Servers/Subusers/SubuserRequest.php b/app/Http/Requests/Api/Client/Servers/Subusers/SubuserRequest.php index 5a20084d3..af16d4113 100644 --- a/app/Http/Requests/Api/Client/Servers/Subusers/SubuserRequest.php +++ b/app/Http/Requests/Api/Client/Servers/Subusers/SubuserRequest.php @@ -65,8 +65,6 @@ abstract class SubuserRequest extends ClientApiRequest // Otherwise, get the current subuser's permission set, and ensure that the // permissions they are trying to assign are not _more_ than the ones they // already have. - /** @var \Pterodactyl\Models\Subuser|null $subuser */ - /** @var \Pterodactyl\Services\Servers\GetUserPermissionsService $service */ $service = $this->container->make(GetUserPermissionsService::class); if (count(array_diff($permissions, $service->handle($server, $user))) > 0) { diff --git a/app/Models/AuditLog.php b/app/Models/AuditLog.php index 3a25d7a77..7d64ad5f0 100644 --- a/app/Models/AuditLog.php +++ b/app/Models/AuditLog.php @@ -101,7 +101,7 @@ class AuditLog extends Model * currently authenticated user if available. This model is not saved at this point, so * you can always make modifications to it as needed before saving. * - * @return $this + * @return self */ public static function instance(string $action, array $metadata, bool $isSystem = false) { diff --git a/app/Models/DatabaseHost.php b/app/Models/DatabaseHost.php index 31dad3865..814032425 100644 --- a/app/Models/DatabaseHost.php +++ b/app/Models/DatabaseHost.php @@ -2,17 +2,6 @@ namespace Pterodactyl\Models; -/** - * @property int $id - * @property string $name - * @property string $host - * @property int $port - * @property string $username - * @property string $password - * @property int|null $max_databases - * @property \Carbon\CarbonImmutable $created_at - * @property \Carbon\CarbonImmutable $updated_at - */ class DatabaseHost extends Model { /** diff --git a/app/Models/Egg.php b/app/Models/Egg.php index 992a54f91..f6efde05e 100644 --- a/app/Models/Egg.php +++ b/app/Models/Egg.php @@ -2,46 +2,6 @@ namespace Pterodactyl\Models; -/** - * @property int $id - * @property string $uuid - * @property int $nest_id - * @property string $author - * @property string $name - * @property string|null $description - * @property array|null $features - * @property string $docker_image -- deprecated, use $docker_images - * @property string $update_url - * @property array $docker_images - * @property array|null $file_denylist - * @property string|null $config_files - * @property string|null $config_startup - * @property string|null $config_logs - * @property string|null $config_stop - * @property int|null $config_from - * @property string|null $startup - * @property bool $script_is_privileged - * @property string|null $script_install - * @property string $script_entry - * @property string $script_container - * @property int|null $copy_script_from - * @property \Carbon\Carbon $created_at - * @property \Carbon\Carbon $updated_at - * @property string|null $copy_script_install - * @property string $copy_script_entry - * @property string $copy_script_container - * @property string|null $inherit_config_files - * @property string|null $inherit_config_startup - * @property string|null $inherit_config_logs - * @property string|null $inherit_config_stop - * @property string $inherit_file_denylist - * @property array|null $inherit_features - * @property \Pterodactyl\Models\Nest $nest - * @property \Illuminate\Database\Eloquent\Collection|\Pterodactyl\Models\Server[] $servers - * @property \Illuminate\Database\Eloquent\Collection|\Pterodactyl\Models\EggVariable[] $variables - * @property \Pterodactyl\Models\Egg|null $scriptFrom - * @property \Pterodactyl\Models\Egg|null $configFrom - */ class Egg extends Model { /** @@ -75,14 +35,16 @@ class Egg extends Model * @var array */ protected $fillable = [ + 'nest_id', + 'uuid', 'name', 'description', 'features', + 'author', 'docker_images', 'file_denylist', 'config_files', 'config_startup', - 'config_logs', 'config_stop', 'config_from', 'startup', @@ -123,7 +85,6 @@ class Egg extends Model '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', ]; @@ -136,7 +97,6 @@ class Egg extends Model 'file_denylist' => null, 'config_stop' => null, 'config_startup' => null, - 'config_logs' => null, 'config_files' => null, 'update_url' => null, ]; @@ -164,10 +124,12 @@ class Egg extends Model */ public function getCopyScriptEntryAttribute() { + // @phpstan-ignore-next-line if (!is_null($this->script_entry) || is_null($this->copy_script_from)) { return $this->script_entry; } + // @phpstan-ignore-next-line return $this->scriptFrom->script_entry; } @@ -179,10 +141,12 @@ class Egg extends Model */ public function getCopyScriptContainerAttribute() { + // @phpstan-ignore-next-line if (!is_null($this->script_container) || is_null($this->copy_script_from)) { return $this->script_container; } + // @phpstan-ignore-next-line return $this->scriptFrom->script_container; } @@ -214,20 +178,6 @@ class Egg extends Model return $this->configFrom->config_startup; } - /** - * Return the log reading configuration for an egg. - * - * @return string - */ - public function getInheritConfigLogsAttribute() - { - if (!is_null($this->config_logs) || is_null($this->config_from)) { - return $this->config_logs; - } - - return $this->configFrom->config_logs; - } - /** * Return the stop command configuration for an egg. * diff --git a/app/Models/EggVariable.php b/app/Models/EggVariable.php index 98e3da2c9..43d550ebf 100644 --- a/app/Models/EggVariable.php +++ b/app/Models/EggVariable.php @@ -2,26 +2,6 @@ namespace Pterodactyl\Models; -/** - * @property int $id - * @property int $egg_id - * @property string $name - * @property string $description - * @property string $env_variable - * @property string $default_value - * @property bool $user_viewable - * @property bool $user_editable - * @property string $rules - * @property \Carbon\CarbonImmutable $created_at - * @property \Carbon\CarbonImmutable $updated_at - * @property bool $required - * @property \Pterodactyl\Models\Egg $egg - * @property \Pterodactyl\Models\ServerVariable $serverVariable - * - * The "server_value" variable is only present on the object if you've loaded this model - * using the server relationship. - * @property string|null $server_value - */ class EggVariable extends Model { /** diff --git a/app/Models/Filters/MultiFieldServerFilter.php b/app/Models/Filters/MultiFieldServerFilter.php index 2aac64e81..b3bdcc830 100644 --- a/app/Models/Filters/MultiFieldServerFilter.php +++ b/app/Models/Filters/MultiFieldServerFilter.php @@ -48,6 +48,7 @@ class MultiFieldServerFilter implements Filter } }, // Otherwise, just try to search for that specific port in the allocations. + // @phpstan-ignore-next-line function (Builder $builder) use ($value) { $builder->orWhere('allocations.port', 'LIKE', substr($value, 1) . '%'); } diff --git a/app/Models/Model.php b/app/Models/Model.php index 8e762801a..a62dec655 100644 --- a/app/Models/Model.php +++ b/app/Models/Model.php @@ -23,22 +23,15 @@ abstract class Model extends IlluminateModel /** * Determines if the model should undergo data validation before it is saved * to the database. - * - * @var bool */ - protected $skipValidation = false; + protected bool $skipValidation = false; /** * The validator instance used by this model. - * - * @var \Illuminate\Validation\Validator */ - protected $validator; + protected ?Validator $validator = null; - /** - * @var \Illuminate\Contracts\Validation\Factory - */ - protected static $validatorFactory; + protected static Factory $validatorFactory; public static array $validationRules = []; @@ -82,6 +75,7 @@ abstract class Model extends IlluminateModel { $rules = $this->getKey() ? static::getRulesForUpdate($this) : static::getRules(); + // @phpstan-ignore-next-line return $this->validator ?: $this->validator = static::$validatorFactory->make( [], $rules, diff --git a/app/Models/Node.php b/app/Models/Node.php index f0bee4d67..51af2697c 100644 --- a/app/Models/Node.php +++ b/app/Models/Node.php @@ -8,38 +8,6 @@ use Illuminate\Container\Container; use Illuminate\Notifications\Notifiable; use Illuminate\Contracts\Encryption\Encrypter; -/** - * @property int $id - * @property string $uuid - * @property bool $public - * @property string $name - * @property string|null $description - * @property int $location_id - * @property int|null $database_host_id - * @property string $fqdn - * @property int $listen_port_http - * @property int $public_port_http - * @property int $listen_port_sftp - * @property int $public_port_sftp - * @property string $scheme - * @property bool $behind_proxy - * @property bool $maintenance_mode - * @property int $memory - * @property int $memory_overallocate - * @property int $disk - * @property int $disk_overallocate - * @property int $upload_size - * @property string $daemon_token_id - * @property string $daemon_token - * @property string $daemon_base - * @property \Carbon\Carbon $created_at - * @property \Carbon\Carbon $updated_at - * @property \Pterodactyl\Models\Location $location - * @property \Pterodactyl\Models\Mount[]|\Illuminate\Database\Eloquent\Collection $mounts - * @property \Pterodactyl\Models\Server[]|\Illuminate\Database\Eloquent\Collection $servers - * @property \Pterodactyl\Models\Allocation[]|\Illuminate\Database\Eloquent\Collection $allocations - * @property \Pterodactyl\Models\DatabaseHost $databaseHost - */ class Node extends Model { use Notifiable; @@ -275,6 +243,7 @@ class Node extends Model $memoryLimit = $this->memory * (1 + ($this->memory_overallocate / 100)); $diskLimit = $this->disk * (1 + ($this->disk_overallocate / 100)); + // @phpstan-ignore-next-line return ($this->sum_memory + $memory) <= $memoryLimit && ($this->sum_disk + $disk) <= $diskLimit; } } diff --git a/app/Models/Permission.php b/app/Models/Permission.php index b3b127486..7f2c31f81 100644 --- a/app/Models/Permission.php +++ b/app/Models/Permission.php @@ -213,7 +213,7 @@ class Permission extends Model * Returns all of the permissions available on the system for a user to * have when controlling a server. * - * @return \Illuminate\Database\Eloquent\Collection + * @phpstan-return \Illuminate\Support\Collection}> */ public static function permissions(): Collection { diff --git a/app/Models/Server.php b/app/Models/Server.php index dca0e20d5..ecc6253c5 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -8,50 +8,6 @@ use Illuminate\Database\Query\JoinClause; use Znck\Eloquent\Traits\BelongsToThrough; use Pterodactyl\Exceptions\Http\Server\ServerStateConflictException; -/** - * @property int $id - * @property string|null $external_id - * @property string $uuid - * @property string $uuidShort - * @property int $node_id - * @property string $name - * @property string $description - * @property string|null $status - * @property bool $skip_scripts - * @property int $owner_id - * @property int $memory - * @property int $swap - * @property int $disk - * @property int $io - * @property int $cpu - * @property string $threads - * @property bool $oom_disabled - * @property int $allocation_id - * @property int $nest_id - * @property int $egg_id - * @property string $startup - * @property string $image - * @property int $allocation_limit - * @property int $database_limit - * @property int $backup_limit - * @property \Carbon\Carbon $created_at - * @property \Carbon\Carbon $updated_at - * @property \Pterodactyl\Models\User $user - * @property \Pterodactyl\Models\Subuser[]|\Illuminate\Database\Eloquent\Collection $subusers - * @property \Pterodactyl\Models\Allocation $allocation - * @property \Pterodactyl\Models\Allocation[]|\Illuminate\Database\Eloquent\Collection $allocations - * @property \Pterodactyl\Models\Node $node - * @property \Pterodactyl\Models\Nest $nest - * @property \Pterodactyl\Models\Egg $egg - * @property \Pterodactyl\Models\EggVariable[]|\Illuminate\Database\Eloquent\Collection $variables - * @property \Pterodactyl\Models\Schedule[]|\Illuminate\Database\Eloquent\Collection $schedule - * @property \Pterodactyl\Models\Database[]|\Illuminate\Database\Eloquent\Collection $databases - * @property \Pterodactyl\Models\Location $location - * @property \Pterodactyl\Models\ServerTransfer $transfer - * @property \Pterodactyl\Models\Backup[]|\Illuminate\Database\Eloquent\Collection $backups - * @property \Pterodactyl\Models\Mount[]|\Illuminate\Database\Eloquent\Collection $mounts - * @property \Pterodactyl\Models\AuditLog[] $audits - */ class Server extends Model { use BelongsToThrough; @@ -84,6 +40,7 @@ class Server extends Model protected $attributes = [ 'status' => self::STATUS_INSTALLING, 'oom_disabled' => true, + 'startup' => null, ]; /** @@ -124,7 +81,7 @@ class Server extends Model 'allocation_id' => 'required|bail|unique:servers|exists:allocations,id', 'nest_id' => 'required|exists:nests,id', 'egg_id' => 'required|exists:eggs,id', - 'startup' => 'required|string', + 'startup' => 'nullable|string', 'skip_scripts' => 'sometimes|boolean', 'image' => 'required|string|max:191', 'database_limit' => 'present|nullable|integer|min:0', @@ -239,6 +196,7 @@ class Server extends Model * Gets information for the service variables associated with this server. * * @return \Illuminate\Database\Eloquent\Relations\HasMany + * @phpstan-return \Illuminate\Database\Eloquent\Relations\HasMany<\Pterodactyl\Models\EggVariable> */ public function variables() { diff --git a/app/Notifications/AccountCreated.php b/app/Notifications/AccountCreated.php index e52bed5fe..c2fd145d3 100644 --- a/app/Notifications/AccountCreated.php +++ b/app/Notifications/AccountCreated.php @@ -58,7 +58,7 @@ class AccountCreated extends Notification implements ShouldQueue public function toMail($notifiable) { $message = (new MailMessage()) - ->greeting('Hello ' . $this->user->name . '!') + ->greeting('Hello ' . $this->user->name_first . '!') ->line('You are receiving this email because an account has been created for you on ' . config('app.name') . '.') ->line('Username: ' . $this->user->username) ->line('Email: ' . $this->user->email); diff --git a/app/Notifications/MailTested.php b/app/Notifications/MailTested.php index d0f083acc..ab2cc7a71 100644 --- a/app/Notifications/MailTested.php +++ b/app/Notifications/MailTested.php @@ -27,7 +27,7 @@ class MailTested extends Notification { return (new MailMessage()) ->subject('Pterodactyl Test Message') - ->greeting('Hello ' . $this->user->name . '!') + ->greeting('Hello ' . $this->user->name_first . '!') ->line('This is a test of the Pterodactyl mail system. You\'re good to go!'); } } diff --git a/app/Notifications/ServerInstalled.php b/app/Notifications/ServerInstalled.php index cc5a94313..a115633ac 100644 --- a/app/Notifications/ServerInstalled.php +++ b/app/Notifications/ServerInstalled.php @@ -2,6 +2,7 @@ namespace Pterodactyl\Notifications; +use Webmozart\Assert\Assert; use Illuminate\Bus\Queueable; use Pterodactyl\Events\Event; use Illuminate\Container\Container; @@ -33,6 +34,8 @@ class ServerInstalled extends Notification implements ShouldQueue, ReceivesEvent */ public function handle(Event $event): void { + Assert::propertyExists($event, 'server'); + $event->server->loadMissing('user'); $this->server = $event->server; diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index f7a18ac5c..cceb7e4ca 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,10 +2,12 @@ namespace Pterodactyl\Providers; +use Illuminate\Support\Str; use Laravel\Sanctum\Sanctum; use Pterodactyl\Models\User; use Pterodactyl\Models\Server; use Pterodactyl\Models\Subuser; +use Illuminate\Support\Facades\URL; use Illuminate\Support\Facades\Schema; use Illuminate\Support\ServiceProvider; use Pterodactyl\Observers\UserObserver; @@ -30,6 +32,15 @@ class AppServiceProvider extends ServiceProvider * @see https://laravel.com/docs/8.x/sanctum#overriding-default-models */ Sanctum::usePersonalAccessTokenModel(PersonalAccessToken::class); + + // If the APP_URL value is set with https:// make sure we force it here. Theoretically + // this should just work with the proxy logic, but there are a lot of cases where it + // doesn't, and it triggers a lot of support requests, so lets just head it off here. + // + // @see https://github.com/pterodactyl/panel/issues/3623 + if (Str::startsWith(config('app.url') ?? '', 'https://')) { + URL::forceScheme('https'); + } } /** diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index db07b1627..06100b572 100644 --- a/app/Providers/RouteServiceProvider.php +++ b/app/Providers/RouteServiceProvider.php @@ -2,6 +2,7 @@ namespace Pterodactyl\Providers; +use Illuminate\Http\Request; use Illuminate\Support\Facades\Route; use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Support\Facades\RateLimiter; @@ -19,44 +20,87 @@ class RouteServiceProvider extends ServiceProvider protected $namespace = 'Pterodactyl\Http\Controllers'; /** - * Define the routes for the application. + * Define your route model bindings, pattern filters, etc. */ - public function map() + public function boot() { - Route::middleware(['web', 'auth', 'csrf']) - ->namespace($this->namespace . '\Base') - ->group(base_path('routes/base.php')); + $this->configureRateLimiting(); - Route::middleware(['web', 'auth', 'admin', 'csrf'])->prefix('/admin') - ->namespace($this->namespace . '\Admin') - ->group(base_path('routes/admin.php')); + $this->routes(function () { + Route::middleware(['web', 'auth', 'csrf']) + ->namespace("$this->namespace\\Base") + ->group(base_path('routes/base.php')); - Route::middleware(['web', 'csrf'])->prefix('/auth') - ->namespace($this->namespace . '\Auth') - ->group(base_path('routes/auth.php')); + Route::middleware(['web', 'auth', 'admin', 'csrf'])->prefix('/admin') + ->namespace("$this->namespace\\Admin") + ->group(base_path('routes/admin.php')); - Route::middleware(['web', 'csrf', 'auth', 'server', 'node.maintenance']) - ->prefix('/api/server/{server}') - ->namespace($this->namespace . '\Server') - ->group(base_path('routes/server.php')); + Route::middleware(['web', 'csrf'])->prefix('/auth') + ->namespace("$this->namespace\\Auth") + ->group(base_path('routes/auth.php')); - Route::middleware([ - sprintf('throttle:%s,%s', config('http.rate_limit.application'), config('http.rate_limit.application_period')), - 'api', - ])->prefix('/api/application') - ->namespace($this->namespace . '\Api\Application') - ->group(base_path('routes/api-application.php')); + Route::middleware(['web', 'csrf', 'auth', 'server', 'node.maintenance']) + ->prefix('/api/server/{server}') + ->namespace("$this->namespace\\Server") + ->group(base_path('routes/server.php')); - Route::middleware([ - //sprintf('throttle:%s,%s', config('http.rate_limit.client'), config('http.rate_limit.client_period')), - 'client-api', - ])->prefix('/api/client') - ->namespace($this->namespace . '\Api\Client') - ->group(base_path('routes/api-client.php')); + Route::middleware(['api', 'throttle:api.application']) + ->prefix('/api/application') + ->namespace("$this->namespace\\Api\\Application") + ->group(base_path('routes/api-application.php')); - Route::middleware(['daemon'])->prefix('/api/remote') - ->namespace($this->namespace . '\Api\Remote') - ->group(base_path('routes/api-remote.php')); + Route::middleware(['client-api', 'throttle:api.client']) + ->prefix('/api/client') + ->namespace("$this->namespace\\Api\\Client") + ->group(base_path('routes/api-client.php')); + + Route::middleware(['daemon'])->prefix('/api/remote') + ->namespace("$this->namespace\\Api\\Remote") + ->group(base_path('routes/api-remote.php')); + }); + } + + /** + * Configure the rate limiters for the application. + */ + protected function configureRateLimiting() + { + // Authentication rate limiting. For login and checkpoint endpoints we'll apply + // a limit of 10 requests per minute, for the forgot password endpoint apply a + // limit of two per minute for the requester so that there is less ability to + // trigger email spam. + RateLimiter::for('authentication', function (Request $request) { + if ($request->route()->named('auth.post.forgot-password')) { + return Limit::perMinute(2)->by($request->ip()); + } + + return Limit::perMinute(10); + }); + + // Configure the throttles for both the application and client APIs below. + // This is configurable per-instance in "config/http.php". By default this + // limiter will be tied to the specific request user, and falls back to the + // request IP if there is no request user present for the key. + // + // This means that an authenticated API user cannot use IP switching to get + // around the limits. + RateLimiter::for('api.client', function (Request $request) { + $key = optional($request->user())->uuid ?: $request->ip(); + + return Limit::perMinutes( + config('http.rate_limit.client_period'), + config('http.rate_limit.client') + )->by($key); + }); + + RateLimiter::for('api.application', function (Request $request) { + $key = optional($request->user())->uuid ?: $request->ip(); + + return Limit::perMinutes( + config('http.rate_limit.application_period'), + config('http.rate_limit.application') + )->by($key); + }); RateLimiter::for('pull', function () { return Limit::perMinute(10); diff --git a/app/Providers/SettingsServiceProvider.php b/app/Providers/SettingsServiceProvider.php index 71fda215e..447ac3db1 100644 --- a/app/Providers/SettingsServiceProvider.php +++ b/app/Providers/SettingsServiceProvider.php @@ -21,7 +21,6 @@ class SettingsServiceProvider extends ServiceProvider protected $keys = [ 'app:name', 'app:locale', - 'app:analytics', 'recaptcha:enabled', 'recaptcha:secret_key', 'recaptcha:website_key', diff --git a/app/Repositories/Eloquent/BackupRepository.php b/app/Repositories/Eloquent/BackupRepository.php index 051d0ce42..ef4a1d792 100644 --- a/app/Repositories/Eloquent/BackupRepository.php +++ b/app/Repositories/Eloquent/BackupRepository.php @@ -20,10 +20,12 @@ class BackupRepository extends EloquentRepository /** * Determines if too many backups have been generated by the server. * - * @return \Pterodactyl\Models\Backup[]|\Illuminate\Support\Collection + * @return \Illuminate\Support\Collection + * @phpstan-return \Illuminate\Support\Collection<\Pterodactyl\Models\Backup> */ public function getBackupsGeneratedDuringTimespan(int $server, int $seconds = 600) { + // @phpstan-ignore-next-line return $this->getBuilder() ->withTrashed() ->where('server_id', $server) diff --git a/app/Repositories/Eloquent/DatabaseRepository.php b/app/Repositories/Eloquent/DatabaseRepository.php index c42cb91b2..16e024ea6 100644 --- a/app/Repositories/Eloquent/DatabaseRepository.php +++ b/app/Repositories/Eloquent/DatabaseRepository.php @@ -89,10 +89,8 @@ class DatabaseRepository extends EloquentRepository implements DatabaseRepositor /** * Create a new database user on a given connection. - * - * @param $max_connections */ - public function createUser(string $username, string $remote, string $password, $max_connections): bool + public function createUser(string $username, string $remote, string $password, int $max_connections): bool { if (!$max_connections) { return $this->run(sprintf('CREATE USER `%s`@`%s` IDENTIFIED BY \'%s\'', $username, $remote, $password)); @@ -132,8 +130,6 @@ class DatabaseRepository extends EloquentRepository implements DatabaseRepositor /** * Drop a given user on a specific connection. - * - * @return mixed */ public function dropUser(string $username, string $remote): bool { diff --git a/app/Repositories/Eloquent/EggRepository.php b/app/Repositories/Eloquent/EggRepository.php index 98b7db453..29307b396 100644 --- a/app/Repositories/Eloquent/EggRepository.php +++ b/app/Repositories/Eloquent/EggRepository.php @@ -29,6 +29,8 @@ class EggRepository extends EloquentRepository implements EggRepositoryInterface public function getWithVariables(int $id): Egg { try { + /* @noinspection PhpIncompatibleReturnTypeInspection */ + // @phpstan-ignore-next-line return $this->getBuilder()->with('variables')->findOrFail($id, $this->getColumns()); } catch (ModelNotFoundException $exception) { throw new RecordNotFoundException(); @@ -55,6 +57,8 @@ class EggRepository extends EloquentRepository implements EggRepositoryInterface Assert::true((is_digit($value) || is_string($value)), 'First argument passed to getWithCopyAttributes must be an integer or string, received %s.'); try { + /* @noinspection PhpIncompatibleReturnTypeInspection */ + // @phpstan-ignore-next-line return $this->getBuilder()->with('scriptFrom', 'configFrom')->where($column, '=', $value)->firstOrFail($this->getColumns()); } catch (ModelNotFoundException $exception) { throw new RecordNotFoundException(); @@ -69,6 +73,8 @@ class EggRepository extends EloquentRepository implements EggRepositoryInterface public function getWithExportAttributes(int $id): Egg { try { + /* @noinspection PhpIncompatibleReturnTypeInspection */ + // @phpstan-ignore-next-line return $this->getBuilder()->with('scriptFrom', 'configFrom', 'variables')->findOrFail($id, $this->getColumns()); } catch (ModelNotFoundException $exception) { throw new RecordNotFoundException(); diff --git a/app/Repositories/Eloquent/EloquentRepository.php b/app/Repositories/Eloquent/EloquentRepository.php index 3d3e09cdc..25a7ae8e4 100644 --- a/app/Repositories/Eloquent/EloquentRepository.php +++ b/app/Repositories/Eloquent/EloquentRepository.php @@ -4,6 +4,7 @@ namespace Pterodactyl\Repositories\Eloquent; use Illuminate\Http\Request; use Webmozart\Assert\Assert; +use Pterodactyl\Models\Model; use Illuminate\Support\Collection; use Pterodactyl\Repositories\Repository; use Illuminate\Database\Eloquent\Builder; @@ -25,12 +26,8 @@ abstract class EloquentRepository extends Repository implements RepositoryInterf * Determines if the repository function should use filters off the request object * present when returning results. This allows repository methods to be called in API * context's such that we can pass through ?filter[name]=Dane&sort=desc for example. - * - * @param bool $usingFilters - * - * @return $this */ - public function usingRequestFilters($usingFilters = true) + public function usingRequestFilters(bool $usingFilters = true): self { $this->useRequestFilters = $usingFilters; @@ -39,26 +36,22 @@ abstract class EloquentRepository extends Repository implements RepositoryInterf /** * Returns the request instance. - * - * @return \Illuminate\Http\Request */ - protected function request() + protected function request(): Request { return $this->app->make(Request::class); } /** * Paginate the response data based on the page para. - * - * @return \Illuminate\Contracts\Pagination\LengthAwarePaginator */ - protected function paginate(Builder $instance, int $default = 50) + protected function paginate(Builder $instance, int $default = 50): LengthAwarePaginator { if (!$this->useRequestFilters) { return $instance->paginate($default); } - return $instance->paginate($this->request()->query('per_page', $default)); + return $instance->paginate((int) $this->request()->query('per_page', (string) $default)); } /** @@ -91,15 +84,20 @@ abstract class EloquentRepository extends Repository implements RepositoryInterf */ public function create(array $fields, bool $validate = true, bool $force = false) { + /** @phpstan-var \Illuminate\Database\Eloquent\Model $instance */ $instance = $this->getBuilder()->newModelInstance(); ($force) ? $instance->forceFill($fields) : $instance->fill($fields); - if (!$validate) { - $saved = $instance->skipValidation()->save(); - } else { - if (!$saved = $instance->save()) { - throw new DataValidationException($instance->getValidator()); + if ($instance instanceof Model) { + if (!$validate) { + $saved = $instance->skipValidation()->save(); + } else { + if (!$saved = $instance->save()) { + throw new DataValidationException($instance->getValidator()); + } } + } else { + $saved = $instance->save(); } return ($this->withFresh) ? $instance->fresh() : $saved; @@ -150,6 +148,7 @@ abstract class EloquentRepository extends Repository implements RepositoryInterf */ public function findCountWhere(array $fields): int { + // @phpstan-ignore-next-line return $this->getBuilder()->where($fields)->count($this->getColumns()); } @@ -191,12 +190,16 @@ abstract class EloquentRepository extends Repository implements RepositoryInterf ($force) ? $instance->forceFill($fields) : $instance->fill($fields); - if (!$validate) { - $saved = $instance->skipValidation()->save(); - } else { - if (!$saved = $instance->save()) { - throw new DataValidationException($instance->getValidator()); + if ($instance instanceof Model) { + if (!$validate) { + $saved = $instance->skipValidation()->save(); + } else { + if (!$saved = $instance->save()) { + throw new DataValidationException($instance->getValidator()); + } } + } else { + $saved = $instance->save(); } return ($this->withFresh) ? $instance->fresh() : $saved; @@ -245,6 +248,7 @@ abstract class EloquentRepository extends Repository implements RepositoryInterf return $this->create(array_merge($where, $fields), $validate, $force); } + // @phpstan-ignore-next-line return $this->update($instance->id, $fields, $validate, $force); } diff --git a/app/Repositories/Eloquent/LocationRepository.php b/app/Repositories/Eloquent/LocationRepository.php index c06d10a9f..151c72bcf 100644 --- a/app/Repositories/Eloquent/LocationRepository.php +++ b/app/Repositories/Eloquent/LocationRepository.php @@ -39,13 +39,13 @@ class LocationRepository extends EloquentRepository implements LocationRepositor /** * Return all of the nodes and their respective count of servers for a location. * - * @return mixed - * * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException */ public function getWithNodes(int $id): Location { try { + /* @noinspection PhpIncompatibleReturnTypeInspection */ + // @phpstan-ignore-next-line return $this->getBuilder()->with('nodes.servers')->findOrFail($id, $this->getColumns()); } catch (ModelNotFoundException $exception) { throw new RecordNotFoundException(); @@ -55,13 +55,13 @@ class LocationRepository extends EloquentRepository implements LocationRepositor /** * Return a location and the count of nodes in that location. * - * @return mixed - * * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException */ public function getWithNodeCount(int $id): Location { try { + /* @noinspection PhpIncompatibleReturnTypeInspection */ + // @phpstan-ignore-next-line return $this->getBuilder()->withCount('nodes')->findOrFail($id, $this->getColumns()); } catch (ModelNotFoundException $exception) { throw new RecordNotFoundException(); diff --git a/app/Repositories/Eloquent/MountRepository.php b/app/Repositories/Eloquent/MountRepository.php index 490f76b64..58d870ecd 100644 --- a/app/Repositories/Eloquent/MountRepository.php +++ b/app/Repositories/Eloquent/MountRepository.php @@ -31,13 +31,13 @@ class MountRepository extends EloquentRepository /** * Return all of the mounts and their respective relations. * - * @return mixed - * * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException */ public function getWithRelations(string $id): Mount { try { + /* @noinspection PhpIncompatibleReturnTypeInspection */ + // @phpstan-ignore-next-line return $this->getBuilder()->with('eggs', 'nodes')->findOrFail($id, $this->getColumns()); } catch (ModelNotFoundException $exception) { throw new RecordNotFoundException(); diff --git a/app/Repositories/Eloquent/NestRepository.php b/app/Repositories/Eloquent/NestRepository.php index 7e4884255..131a0d862 100644 --- a/app/Repositories/Eloquent/NestRepository.php +++ b/app/Repositories/Eloquent/NestRepository.php @@ -28,15 +28,14 @@ class NestRepository extends EloquentRepository implements NestRepositoryInterfa /** * Return a nest or all nests with their associated eggs and variables. * - * @return \Illuminate\Database\Eloquent\Collection|\Pterodactyl\Models\Nest - * * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException */ - public function getWithEggs(int $id = null) + public function getWithEggs(int $id = null): Nest { $instance = $this->getBuilder()->with('eggs', 'eggs.variables'); if (!is_null($id)) { + /** @var \Pterodactyl\Models\Nest|null $instance */ $instance = $instance->find($id, $this->getColumns()); if (!$instance) { throw new RecordNotFoundException(); @@ -45,45 +44,8 @@ class NestRepository extends EloquentRepository implements NestRepositoryInterfa return $instance; } + /* @noinspection PhpIncompatibleReturnTypeInspection */ + // @phpstan-ignore-next-line return $instance->get($this->getColumns()); } - - /** - * Return a nest or all nests and the count of eggs and servers for that nest. - * - * @return \Pterodactyl\Models\Nest|\Illuminate\Database\Eloquent\Collection - * - * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException - */ - public function getWithCounts(int $id = null) - { - $instance = $this->getBuilder()->withCount(['eggs', 'servers']); - - if (!is_null($id)) { - $instance = $instance->find($id, $this->getColumns()); - if (!$instance) { - throw new RecordNotFoundException(); - } - - return $instance; - } - - return $instance->get($this->getColumns()); - } - - /** - * Return a nest along with its associated eggs and the servers relation on those eggs. - * - * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException - */ - public function getWithEggServers(int $id): Nest - { - $instance = $this->getBuilder()->with('eggs.servers')->find($id, $this->getColumns()); - if (!$instance) { - throw new RecordNotFoundException(); - } - - /* @var Nest $instance */ - return $instance; - } } diff --git a/app/Repositories/Eloquent/NodeRepository.php b/app/Repositories/Eloquent/NodeRepository.php index 125a21fb3..39a1beac2 100644 --- a/app/Repositories/Eloquent/NodeRepository.php +++ b/app/Repositories/Eloquent/NodeRepository.php @@ -59,7 +59,10 @@ class NodeRepository extends EloquentRepository implements NodeRepositoryInterfa $this->getBuilder()->raw('IFNULL(SUM(servers.memory), 0) as sum_memory, IFNULL(SUM(servers.disk), 0) as sum_disk') )->join('servers', 'servers.node_id', '=', 'nodes.id')->where('node_id', $node->id)->first(); - return collect(['disk' => $stats->sum_disk, 'memory' => $stats->sum_memory])->mapWithKeys(function ($value, $key) use ($node) { + return collect([ + 'disk' => $stats->sum_disk, + 'memory' => $stats->sum_memory, + ])->mapWithKeys(function ($value, $key) use ($node) { $maxUsage = $node->{$key}; if ($node->{$key . '_overallocate'} > 0) { $maxUsage = $node->{$key} * (1 + ($node->{$key . '_overallocate'} / 100)); @@ -85,6 +88,7 @@ class NodeRepository extends EloquentRepository implements NodeRepositoryInterfa // This is quite ugly and can probably be improved down the road. // And by probably, I mean it should. + // @phpstan-ignore-next-line if (is_null($node->servers_count) || $refresh) { $node->load('servers'); $node->setRelation('servers_count', count($node->getRelation('servers'))); @@ -118,22 +122,28 @@ class NodeRepository extends EloquentRepository implements NodeRepositoryInterfa */ public function getNodesForServerCreation(): Collection { - return $this->getBuilder()->with('allocations')->get()->map(function (Node $item) { + /** @phpstan-var \Illuminate\Database\Eloquent\Collection<\Pterodactyl\Models\Node> $collection */ + $collection = $this->getBuilder()->with('allocations')->get(); + + return $collection->map(function (Node $item) { + /** @phpstan-var \Illuminate\Support\Collection $filtered */ $filtered = $item->getRelation('allocations')->where('server_id', null)->map(function ($map) { return collect($map)->only(['id', 'ip', 'port']); }); - $item->ports = $filtered->map(function ($map) { + $ports = $filtered->map(function ($map) { return [ 'id' => $map['id'], 'text' => sprintf('%s:%s', $map['ip'], $map['port']), ]; })->values(); + $item->setAttribute('ports', $ports); + return [ 'id' => $item->id, 'text' => $item->name, - 'allocations' => $item->ports, + 'allocations' => $ports, ]; })->values(); } @@ -144,11 +154,22 @@ class NodeRepository extends EloquentRepository implements NodeRepositoryInterfa public function getNodeWithResourceUsage(int $node_id): Node { $instance = $this->getBuilder() - ->select(['nodes.id', 'nodes.fqdn', 'nodes.public_port_http', 'nodes.scheme', 'nodes.daemon_token', 'nodes.memory', 'nodes.disk', 'nodes.memory_overallocate', 'nodes.disk_overallocate']) + ->select([ + 'nodes.id', + 'nodes.fqdn', + 'nodes.public_port_http', + 'nodes.scheme', + 'nodes.daemon_token', + 'nodes.memory', + 'nodes.disk', + 'nodes.memory_overallocate', + 'nodes.disk_overallocate', + ]) ->selectRaw('IFNULL(SUM(servers.memory), 0) as sum_memory, IFNULL(SUM(servers.disk), 0) as sum_disk') ->leftJoin('servers', 'servers.node_id', '=', 'nodes.id') ->where('nodes.id', $node_id); + /* @noinspection PhpIncompatibleReturnTypeInspection */ return $instance->first(); } } diff --git a/app/Repositories/Eloquent/ScheduleRepository.php b/app/Repositories/Eloquent/ScheduleRepository.php index 5c999df87..b39343bda 100644 --- a/app/Repositories/Eloquent/ScheduleRepository.php +++ b/app/Repositories/Eloquent/ScheduleRepository.php @@ -36,6 +36,8 @@ class ScheduleRepository extends EloquentRepository implements ScheduleRepositor public function getScheduleWithTasks(int $schedule): Schedule { try { + /* @noinspection PhpIncompatibleReturnTypeInspection */ + // @phpstan-ignore-next-line return $this->getBuilder()->with('tasks')->findOrFail($schedule, $this->getColumns()); } catch (ModelNotFoundException $exception) { throw new RecordNotFoundException(); diff --git a/app/Repositories/Eloquent/ServerRepository.php b/app/Repositories/Eloquent/ServerRepository.php index 8bb79c10f..8caeeaee1 100644 --- a/app/Repositories/Eloquent/ServerRepository.php +++ b/app/Repositories/Eloquent/ServerRepository.php @@ -74,6 +74,8 @@ class ServerRepository extends EloquentRepository implements ServerRepositoryInt public function findWithVariables(int $id): Server { try { + /* @noinspection PhpIncompatibleReturnTypeInspection */ + // @phpstan-ignore-next-line return $this->getBuilder()->with('egg.variables', 'variables') ->where($this->getModel()->getKeyName(), '=', $id) ->firstOrFail($this->getColumns()); diff --git a/app/Repositories/Eloquent/TaskRepository.php b/app/Repositories/Eloquent/TaskRepository.php index 718f99490..35a24e8ac 100644 --- a/app/Repositories/Eloquent/TaskRepository.php +++ b/app/Repositories/Eloquent/TaskRepository.php @@ -27,6 +27,8 @@ class TaskRepository extends EloquentRepository implements TaskRepositoryInterfa public function getTaskForJobProcess(int $id): Task { try { + /* @noinspection PhpIncompatibleReturnTypeInspection */ + // @phpstan-ignore-next-line return $this->getBuilder()->with('server.user', 'schedule')->findOrFail($id, $this->getColumns()); } catch (ModelNotFoundException $exception) { throw new RecordNotFoundException(); diff --git a/app/Repositories/Repository.php b/app/Repositories/Repository.php index a80051942..2b13e4874 100644 --- a/app/Repositories/Repository.php +++ b/app/Repositories/Repository.php @@ -118,7 +118,8 @@ abstract class Repository implements RepositoryInterface /** * Take the provided model and make it accessible to the rest of the repository. * - * @param array $model + * @param string[] $model + * @phpstan-param class-string<\Illuminate\Database\Eloquent\Model> $model * * @return mixed */ @@ -128,6 +129,7 @@ abstract class Repository implements RepositoryInterface case 1: return $this->model = $this->app->make($model[0]); case 2: + // @phpstan-ignore-next-line return $this->model = call_user_func([$this->app->make($model[0]), $model[1]]); default: throw new InvalidArgumentException('Model must be a FQDN or an array with a count of two.'); diff --git a/app/Rules/Username.php b/app/Rules/Username.php index bae204952..a466d3cb2 100644 --- a/app/Rules/Username.php +++ b/app/Rules/Username.php @@ -22,7 +22,7 @@ class Username implements Rule */ public function passes($attribute, $value): bool { - return preg_match(self::VALIDATION_REGEX, mb_strtolower($value)); + return preg_match(self::VALIDATION_REGEX, mb_strtolower($value)) === 1; } /** diff --git a/app/Services/Allocations/AssignmentService.php b/app/Services/Allocations/AssignmentService.php index 878c7307a..f18ccb922 100644 --- a/app/Services/Allocations/AssignmentService.php +++ b/app/Services/Allocations/AssignmentService.php @@ -64,6 +64,7 @@ class AssignmentService $parsed = Network::parse($underlying); } catch (Exception $exception) { /* @noinspection PhpUndefinedVariableInspection */ + // @phpstan-ignore-next-line throw new DisplayException("Could not parse provided allocation IP address ({$underlying}): {$exception->getMessage()}", $exception); } diff --git a/app/Services/Backups/DeleteBackupService.php b/app/Services/Backups/DeleteBackupService.php index 66eefe675..6ca87ebbb 100644 --- a/app/Services/Backups/DeleteBackupService.php +++ b/app/Services/Backups/DeleteBackupService.php @@ -102,6 +102,7 @@ class DeleteBackupService /** @var \League\Flysystem\AwsS3v3\AwsS3Adapter $adapter */ $adapter = $this->manager->adapter(Backup::ADAPTER_AWS_S3); + // @phpstan-ignore-next-line this is defined on the actual S3Client class, just not on the interface. $adapter->getClient()->deleteObject([ 'Bucket' => $adapter->getBucket(), 'Key' => sprintf('%s/%s.tar.gz', $backup->server->uuid, $backup->uuid), diff --git a/app/Services/Backups/InitiateBackupService.php b/app/Services/Backups/InitiateBackupService.php index d368fb609..d3f0480bc 100644 --- a/app/Services/Backups/InitiateBackupService.php +++ b/app/Services/Backups/InitiateBackupService.php @@ -9,6 +9,7 @@ use Pterodactyl\Models\Backup; use Pterodactyl\Models\Server; use Illuminate\Database\ConnectionInterface; use Pterodactyl\Extensions\Backups\BackupManager; +use Illuminate\Database\Eloquent\Relations\HasMany; use Pterodactyl\Repositories\Eloquent\BackupRepository; use Pterodactyl\Repositories\Wings\DaemonBackupRepository; use Pterodactyl\Exceptions\Service\Backup\TooManyBackupsException; @@ -53,8 +54,6 @@ class InitiateBackupService /** * InitiateBackupService constructor. - * - * @param \Pterodactyl\Services\Backups\DeleteBackupService $deleteBackupService */ public function __construct( BackupRepository $repository, @@ -140,7 +139,7 @@ class InitiateBackupService // Get the oldest backup the server has that is not "locked" (indicating a backup that should // never be automatically purged). If we find a backup we will delete it and then continue with // this process. If no backup is found that can be used an exception is thrown. - /** @var \Pterodactyl\Models\Backup $oldest */ + /** @var \Pterodactyl\Models\Backup|null $oldest */ $oldest = $successful->where('is_locked', false)->orderBy('created_at')->first(); if (!$oldest) { throw new TooManyBackupsException($server->backup_limit); diff --git a/app/Services/Databases/DatabaseManagementService.php b/app/Services/Databases/DatabaseManagementService.php index a10b22719..ed2c9e148 100644 --- a/app/Services/Databases/DatabaseManagementService.php +++ b/app/Services/Databases/DatabaseManagementService.php @@ -152,6 +152,7 @@ class DatabaseManagementService }); } catch (Exception $exception) { try { + // @phpstan-ignore-next-line doesn't understand the pass-by-reference above if ($database instanceof Database) { $this->repository->dropDatabase($database->database); $this->repository->dropUser($database->username, $database->remote); diff --git a/app/Services/Databases/DatabasePasswordService.php b/app/Services/Databases/DatabasePasswordService.php index aabe98388..882b1c124 100644 --- a/app/Services/Databases/DatabasePasswordService.php +++ b/app/Services/Databases/DatabasePasswordService.php @@ -49,8 +49,6 @@ class DatabasePasswordService /** * Updates a password for a given database. * - * @param \Pterodactyl\Models\Database|int $database - * * @throws \Throwable */ public function handle(Database $database): string diff --git a/app/Services/Eggs/EggConfigurationService.php b/app/Services/Eggs/EggConfigurationService.php index 4dce8fd25..ebe119c1f 100644 --- a/app/Services/Eggs/EggConfigurationService.php +++ b/app/Services/Eggs/EggConfigurationService.php @@ -205,6 +205,7 @@ class EggConfigurationService // Replace anything starting with "server." with the value out of the server configuration // array that used to be created for the old daemon. if (Str::startsWith($key, 'server.')) { + // @phpstan-ignore-next-line $plucked = Arr::get($structure, preg_replace('/^server\./', '', $key), ''); $value = str_replace("{{{$key}}}", $plucked, $value); @@ -215,6 +216,7 @@ class EggConfigurationService // variable from the server configuration. $plucked = Arr::get( $structure, + // @phpstan-ignore-next-line preg_replace('/^env\./', 'build.env.', $key), '' ); diff --git a/app/Services/Eggs/Scripts/InstallScriptService.php b/app/Services/Eggs/Scripts/InstallScriptService.php index ecd1dc1f3..5273698fe 100644 --- a/app/Services/Eggs/Scripts/InstallScriptService.php +++ b/app/Services/Eggs/Scripts/InstallScriptService.php @@ -24,13 +24,11 @@ class InstallScriptService /** * Modify the install script for a given Egg. * - * @param int|\Pterodactyl\Models\Egg $egg - * * @throws \Pterodactyl\Exceptions\Model\DataValidationException * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException * @throws \Pterodactyl\Exceptions\Service\Egg\InvalidCopyFromException */ - public function handle(Egg $egg, array $data) + public function handle(Egg $egg, array $data): void { if (!is_null(array_get($data, 'copy_script_from'))) { if (!$this->repository->isCopyableScript(array_get($data, 'copy_script_from'), $egg->nest_id)) { diff --git a/app/Services/Eggs/Sharing/EggExporterService.php b/app/Services/Eggs/Sharing/EggExporterService.php index f64656150..38d91d9d4 100644 --- a/app/Services/Eggs/Sharing/EggExporterService.php +++ b/app/Services/Eggs/Sharing/EggExporterService.php @@ -27,11 +27,11 @@ class EggExporterService * * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException */ - public function handle(int $egg): string + public function handle(int $egg): array { $egg = $this->repository->getWithExportAttributes($egg); - $struct = [ + return [ '_comment' => 'DO NOT EDIT: FILE GENERATED AUTOMATICALLY BY PTERODACTYL PANEL - PTERODACTYL.IO', 'meta' => [ 'version' => 'PTDL_v1', @@ -50,7 +50,6 @@ class EggExporterService 'config' => [ 'files' => $egg->inherit_config_files, 'startup' => $egg->inherit_config_startup, - 'logs' => $egg->inherit_config_logs, 'stop' => $egg->inherit_config_stop, ], 'scripts' => [ @@ -66,7 +65,5 @@ class EggExporterService ->toArray(); }), ]; - - return json_encode($struct, JSON_PRETTY_PRINT); } } diff --git a/app/Services/Eggs/Sharing/EggImporterService.php b/app/Services/Eggs/Sharing/EggImporterService.php index b6561f63d..a9c750ba0 100644 --- a/app/Services/Eggs/Sharing/EggImporterService.php +++ b/app/Services/Eggs/Sharing/EggImporterService.php @@ -14,8 +14,8 @@ use Symfony\Component\Yaml\Exception\ParseException; use Pterodactyl\Contracts\Repository\EggRepositoryInterface; use Pterodactyl\Contracts\Repository\NestRepositoryInterface; use Pterodactyl\Exceptions\Service\Egg\BadJsonFormatException; -use Pterodactyl\Exceptions\Service\InvalidFileUploadException; use Pterodactyl\Exceptions\Service\Egg\BadYamlFormatException; +use Pterodactyl\Exceptions\Service\InvalidFileUploadException; use Pterodactyl\Contracts\Repository\EggVariableRepositoryInterface; class EggImporterService @@ -58,7 +58,7 @@ class EggImporterService /** * Take an uploaded JSON file and parse it into a new egg. * - * @deprecated Use `handleFile` or `handleContent` instead. + * @deprecated use `handleFile` or `handleContent` instead * * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException * @throws \Pterodactyl\Exceptions\Service\Egg\BadJsonFormatException @@ -151,7 +151,6 @@ class EggImporterService 'update_url' => Arr::get($parsed, 'meta.update_url'), 'config_files' => Arr::get($parsed, 'config.files'), 'config_startup' => Arr::get($parsed, 'config.startup'), - 'config_logs' => Arr::get($parsed, 'config.logs'), 'config_stop' => Arr::get($parsed, 'config.stop'), 'startup' => Arr::get($parsed, 'startup'), 'script_install' => Arr::get($parsed, 'scripts.installation.script'), diff --git a/app/Services/Eggs/Sharing/EggUpdateImporterService.php b/app/Services/Eggs/Sharing/EggUpdateImporterService.php index 205314314..4b860a0b3 100644 --- a/app/Services/Eggs/Sharing/EggUpdateImporterService.php +++ b/app/Services/Eggs/Sharing/EggUpdateImporterService.php @@ -74,7 +74,6 @@ class EggUpdateImporterService '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'), 'config_stop' => object_get($parsed, 'config.stop'), 'startup' => object_get($parsed, 'startup'), 'script_install' => object_get($parsed, 'scripts.installation.script'), diff --git a/app/Services/Eggs/Variables/VariableCreationService.php b/app/Services/Eggs/Variables/VariableCreationService.php index fa758265b..79010d4f5 100644 --- a/app/Services/Eggs/Variables/VariableCreationService.php +++ b/app/Services/Eggs/Variables/VariableCreationService.php @@ -3,31 +3,21 @@ namespace Pterodactyl\Services\Eggs\Variables; use Pterodactyl\Models\EggVariable; -use Illuminate\Contracts\Validation\Factory; +use Illuminate\Contracts\Validation\Factory as Validator; use Pterodactyl\Traits\Services\ValidatesValidationRules; -use Pterodactyl\Contracts\Repository\EggVariableRepositoryInterface; use Pterodactyl\Exceptions\Service\Egg\Variable\ReservedVariableNameException; class VariableCreationService { use ValidatesValidationRules; - /** - * @var \Pterodactyl\Contracts\Repository\EggVariableRepositoryInterface - */ - private $repository; - - /** - * @var \Illuminate\Contracts\Validation\Factory - */ - private $validator; + private Validator $validator; /** * VariableCreationService constructor. */ - public function __construct(EggVariableRepositoryInterface $repository, Factory $validator) + public function __construct(Validator $validator) { - $this->repository = $repository; $this->validator = $validator; } @@ -35,7 +25,7 @@ class VariableCreationService * Return the validation factory instance to be used by rule validation * checking in the trait. */ - protected function getValidator(): Factory + protected function getValidator(): Validator { return $this->validator; } @@ -43,7 +33,6 @@ class VariableCreationService /** * Create a new variable for a given Egg. * - * @throws \Pterodactyl\Exceptions\Model\DataValidationException * @throws \Pterodactyl\Exceptions\Service\Egg\Variable\BadValidationRuleException * @throws \Pterodactyl\Exceptions\Service\Egg\Variable\ReservedVariableNameException */ @@ -59,15 +48,18 @@ class VariableCreationService $options = array_get($data, 'options') ?? []; - return $this->repository->create([ + /** @var \Pterodactyl\Models\EggVariable $model */ + $model = EggVariable::query()->create([ 'egg_id' => $egg, 'name' => $data['name'] ?? '', 'description' => $data['description'] ?? '', 'env_variable' => $data['env_variable'] ?? '', 'default_value' => $data['default_value'] ?? '', - 'user_viewable' => in_array('user_viewable', $options), - 'user_editable' => in_array('user_editable', $options), + 'user_viewable' => $data['user_viewable'], + 'user_editable' => $data['user_editable'], 'rules' => $data['rules'] ?? '', ]); + + return $model; } } diff --git a/app/Services/Eggs/Variables/VariableUpdateService.php b/app/Services/Eggs/Variables/VariableUpdateService.php index da4426c33..49abb0f13 100644 --- a/app/Services/Eggs/Variables/VariableUpdateService.php +++ b/app/Services/Eggs/Variables/VariableUpdateService.php @@ -3,22 +3,17 @@ namespace Pterodactyl\Services\Eggs\Variables; use Illuminate\Support\Str; +use Pterodactyl\Models\Egg; use Pterodactyl\Models\EggVariable; use Illuminate\Contracts\Validation\Factory; use Pterodactyl\Exceptions\DisplayException; use Pterodactyl\Traits\Services\ValidatesValidationRules; -use Pterodactyl\Contracts\Repository\EggVariableRepositoryInterface; use Pterodactyl\Exceptions\Service\Egg\Variable\ReservedVariableNameException; class VariableUpdateService { use ValidatesValidationRules; - /** - * @var \Pterodactyl\Contracts\Repository\EggVariableRepositoryInterface - */ - private $repository; - /** * @var \Illuminate\Contracts\Validation\Factory */ @@ -27,9 +22,8 @@ class VariableUpdateService /** * VariableUpdateService constructor. */ - public function __construct(EggVariableRepositoryInterface $repository, Factory $validator) + public function __construct(Factory $validator) { - $this->repository = $repository; $this->validator = $validator; } @@ -45,27 +39,22 @@ class VariableUpdateService /** * Update a specific egg variable. * - * @return mixed - * * @throws \Pterodactyl\Exceptions\DisplayException - * @throws \Pterodactyl\Exceptions\Model\DataValidationException - * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException * @throws \Pterodactyl\Exceptions\Service\Egg\Variable\ReservedVariableNameException */ - public function handle(EggVariable $variable, array $data) + public function handle(Egg $egg, array $data) { if (!is_null(array_get($data, 'env_variable'))) { if (in_array(strtoupper(array_get($data, 'env_variable')), explode(',', EggVariable::RESERVED_ENV_NAMES))) { throw new ReservedVariableNameException(trans('exceptions.service.variables.reserved_name', ['name' => array_get($data, 'env_variable')])); } - $search = $this->repository->setColumns('id')->findCountWhere([ - ['env_variable', '=', $data['env_variable']], - ['egg_id', '=', $variable->egg_id], - ['id', '!=', $variable->id], - ]); + $count = $egg->variables() + ->where('egg_variables.env_variable', $data['env_variable']) + ->where('egg_variables.id', '!=', $data['id']) + ->count(); - if ($search > 0) { + if ($count > 0) { throw new DisplayException(trans('exceptions.service.variables.env_not_unique', ['name' => array_get($data, 'env_variable')])); } } @@ -80,13 +69,13 @@ class VariableUpdateService $options = array_get($data, 'options') ?? []; - return $this->repository->withoutFreshModel()->update($variable->id, [ + $egg->variables()->where('egg_variables.id', $data['id'])->update([ 'name' => $data['name'] ?? '', 'description' => $data['description'] ?? '', 'env_variable' => $data['env_variable'] ?? '', 'default_value' => $data['default_value'] ?? '', - 'user_viewable' => in_array('user_viewable', $options), - 'user_editable' => in_array('user_editable', $options), + 'user_viewable' => $data['user_viewable'], + 'user_editable' => $data['user_editable'], 'rules' => $data['rules'] ?? '', ]); } diff --git a/app/Services/Helpers/SoftwareVersionService.php b/app/Services/Helpers/SoftwareVersionService.php index f601e6f20..83a47c510 100644 --- a/app/Services/Helpers/SoftwareVersionService.php +++ b/app/Services/Helpers/SoftwareVersionService.php @@ -6,6 +6,7 @@ use Exception; use GuzzleHttp\Client; use Carbon\CarbonImmutable; use Illuminate\Support\Arr; +use Illuminate\Support\Str; use Illuminate\Contracts\Cache\Repository as CacheRepository; use Pterodactyl\Exceptions\Service\Helper\CdnVersionFetchingException; diff --git a/app/Services/Nodes/NodeDeletionService.php b/app/Services/Nodes/NodeDeletionService.php index 30483a826..e8a1dc887 100644 --- a/app/Services/Nodes/NodeDeletionService.php +++ b/app/Services/Nodes/NodeDeletionService.php @@ -1,11 +1,4 @@ . - * - * This software is licensed under the terms of the MIT license. - * https://opensource.org/licenses/MIT - */ namespace Pterodactyl\Services\Nodes; @@ -48,23 +41,15 @@ class NodeDeletionService /** * Delete a node from the panel if no servers are attached to it. * - * @param int|\Pterodactyl\Models\Node $node - * - * @return bool|null - * * @throws \Pterodactyl\Exceptions\Service\HasActiveServersException */ - public function handle($node) + public function handle(Node $node): void { - if ($node instanceof Node) { - $node = $node->id; - } - - $servers = $this->serverRepository->setColumns('id')->findCountWhere([['node_id', '=', $node]]); + $servers = $this->serverRepository->setColumns('id')->findCountWhere([['node_id', '=', $node->id]]); if ($servers > 0) { - throw new HasActiveServersException($this->translator->trans('exceptions.node.servers_attached')); + throw new HasActiveServersException($this->translator->get('exceptions.node.servers_attached')); } - return $this->repository->delete($node); + $this->repository->delete($node->id); } } diff --git a/app/Services/Nodes/NodeJWTService.php b/app/Services/Nodes/NodeJWTService.php index 1b52479ba..9a72bf21d 100644 --- a/app/Services/Nodes/NodeJWTService.php +++ b/app/Services/Nodes/NodeJWTService.php @@ -63,8 +63,6 @@ class NodeJWTService /** * Generate a new JWT for a given node. * - * @param string|null $identifiedBy - * * @return \Lcobucci\JWT\Token\Plain */ public function handle(Node $node, string $identifiedBy, string $algo = 'md5') diff --git a/app/Services/Schedules/ProcessScheduleService.php b/app/Services/Schedules/ProcessScheduleService.php index a131ad573..9559dc129 100644 --- a/app/Services/Schedules/ProcessScheduleService.php +++ b/app/Services/Schedules/ProcessScheduleService.php @@ -45,7 +45,7 @@ class ProcessScheduleService */ public function handle(Schedule $schedule, bool $now = false) { - /** @var \Pterodactyl\Models\Task $task */ + /** @var \Pterodactyl\Models\Task|null $task */ $task = $schedule->tasks()->orderBy('sequence_id')->first(); if (is_null($task)) { diff --git a/app/Services/Servers/BuildModificationService.php b/app/Services/Servers/BuildModificationService.php index 66eb52235..152ea9d6e 100644 --- a/app/Services/Servers/BuildModificationService.php +++ b/app/Services/Servers/BuildModificationService.php @@ -33,8 +33,6 @@ class BuildModificationService * BuildModificationService constructor. * * @param \Pterodactyl\Services\Servers\ServerConfigurationStructureService $structureService - * @param \Illuminate\Database\ConnectionInterface $connection - * @param \Pterodactyl\Repositories\Wings\DaemonServerRepository $daemonServerRepository */ public function __construct( ServerConfigurationStructureService $structureService, @@ -57,7 +55,7 @@ class BuildModificationService public function handle(Server $server, array $data) { /** @var \Pterodactyl\Models\Server $server */ - $server = $this->connection->transaction(function() use ($server, $data) { + $server = $this->connection->transaction(function () use ($server, $data) { $this->processAllocations($server, $data); if (isset($data['allocation_id']) && $data['allocation_id'] != $server->allocation_id) { @@ -115,11 +113,12 @@ class BuildModificationService $query = Allocation::query() ->where('node_id', $server->node_id) ->whereIn('id', $data['add_allocations']) - ->whereNull('server_id'); + ->whereNull('server_id') + ->first(); // Keep track of all the allocations we're just now adding so that we can use the first // one to reset the default allocation to. - $freshlyAllocated = $query->pluck('id')->first(); + $freshlyAllocated = optional($query)->id; $query->update(['server_id' => $server->id, 'notes' => null]); } diff --git a/app/Services/Servers/EnvironmentService.php b/app/Services/Servers/EnvironmentService.php index 54b82bab3..18593e2c2 100644 --- a/app/Services/Servers/EnvironmentService.php +++ b/app/Services/Servers/EnvironmentService.php @@ -36,6 +36,7 @@ class EnvironmentService public function handle(Server $server): array { $variables = $server->variables->toBase()->mapWithKeys(function (EggVariable $variable) { + // @phpstan-ignore-next-line return [$variable->env_variable => $variable->server_value ?? $variable->default_value]; }); diff --git a/app/Services/Servers/ServerConfigurationStructureService.php b/app/Services/Servers/ServerConfigurationStructureService.php index 8d68d7de8..c675f855e 100644 --- a/app/Services/Servers/ServerConfigurationStructureService.php +++ b/app/Services/Servers/ServerConfigurationStructureService.php @@ -49,7 +49,7 @@ class ServerConfigurationStructureService 'uuid' => $server->uuid, 'suspended' => $server->isSuspended(), 'environment' => $this->environment->handle($server), - 'invocation' => $server->startup, + 'invocation' => !is_null($server->startup) ? $server->startup : $server->egg->startup, 'skip_egg_scripts' => $server->skip_scripts, 'build' => [ 'memory_limit' => $server->memory, @@ -62,11 +62,6 @@ class ServerConfigurationStructureService ], 'container' => [ 'image' => $server->image, - // This field is deprecated — use the value in the "build" block. - // - // TODO: remove this key in V2. - 'oom_disabled' => $server->oom_disabled, - 'requires_rebuild' => false, ], 'allocations' => [ 'default' => [ diff --git a/app/Services/Servers/StartupCommandService.php b/app/Services/Servers/StartupCommandService.php index efdbbc5c4..b8e2d210a 100644 --- a/app/Services/Servers/StartupCommandService.php +++ b/app/Services/Servers/StartupCommandService.php @@ -16,9 +16,10 @@ class StartupCommandService foreach ($server->variables as $variable) { $find[] = '{{' . $variable->env_variable . '}}'; + // @phpstan-ignore-next-line $replace[] = ($variable->user_viewable && !$hideAllValues) ? ($variable->server_value ?? $variable->default_value) : '[hidden]'; } - return str_replace($find, $replace, $server->startup); + return str_replace($find, $replace, !is_null($server->startup) ? $server->startup : $server->egg->startup); } } diff --git a/app/Services/Servers/StartupModificationService.php b/app/Services/Servers/StartupModificationService.php index 66645e4f2..d9aee68b8 100644 --- a/app/Services/Servers/StartupModificationService.php +++ b/app/Services/Servers/StartupModificationService.php @@ -93,10 +93,15 @@ class StartupModificationService ]); } + $startup = $server->startup; + if (Arr::exists($data, 'startup')) { + $startup = $data['startup']; + } + $server->fill([ - 'startup' => $data['startup'] ?? $server->startup, + 'startup' => $startup, 'skip_scripts' => $data['skip_scripts'] ?? isset($data['skip_scripts']), - 'image' => $data['docker_image'] ?? $server->image, + 'image' => $data['image'] ?? $server->image, ])->save(); } } diff --git a/app/Services/Servers/SuspensionService.php b/app/Services/Servers/SuspensionService.php index f7d0f77b1..27bc622f5 100644 --- a/app/Services/Servers/SuspensionService.php +++ b/app/Services/Servers/SuspensionService.php @@ -58,15 +58,20 @@ class SuspensionService throw new ConflictHttpException('Cannot toggle suspension status on a server that is currently being transferred.'); } - $this->connection->transaction(function () use ($action, $server, $isSuspending) { - $server->update([ - 'status' => $isSuspending ? Server::STATUS_SUSPENDED : null, - ]); + // Update the server's suspension status. + $server->update([ + 'status' => $isSuspending ? Server::STATUS_SUSPENDED : null, + ]); - // Only trigger a Wings server sync if it is not currently being transferred. - if (is_null($server->transfer)) { - $this->daemonServerRepository->setServer($server)->sync(); - } - }); + try { + // Tell wings to re-sync the server state. + $this->daemonServerRepository->setServer($server)->sync(); + } catch (\Exception $exception) { + // Rollback the server's suspension status if wings fails to sync the server. + $server->update([ + 'status' => $isSuspending ? null : Server::STATUS_SUSPENDED, + ]); + throw $exception; + } } } diff --git a/app/Services/Servers/TransferService.php b/app/Services/Servers/TransferService.php index 35aed0659..c2db504c3 100644 --- a/app/Services/Servers/TransferService.php +++ b/app/Services/Servers/TransferService.php @@ -4,15 +4,9 @@ namespace Pterodactyl\Services\Servers; use Pterodactyl\Models\Server; use Pterodactyl\Repositories\Wings\DaemonServerRepository; -use Pterodactyl\Contracts\Repository\ServerRepositoryInterface; class TransferService { - /** - * @var \Pterodactyl\Contracts\Repository\ServerRepositoryInterface - */ - private $repository; - /** * @var \Pterodactyl\Repositories\Wings\DaemonServerRepository */ @@ -21,19 +15,14 @@ class TransferService /** * TransferService constructor. */ - public function __construct( - DaemonServerRepository $daemonServerRepository, - ServerRepositoryInterface $repository - ) { - $this->repository = $repository; + public function __construct(DaemonServerRepository $daemonServerRepository) + { $this->daemonServerRepository = $daemonServerRepository; } /** * Requests an archive from the daemon. * - * @param int|\Pterodactyl\Models\Server $server - * * @throws \Throwable */ public function requestArchive(Server $server) diff --git a/app/Services/Users/UserDeletionService.php b/app/Services/Users/UserDeletionService.php index 87459773e..f00b57341 100644 --- a/app/Services/Users/UserDeletionService.php +++ b/app/Services/Users/UserDeletionService.php @@ -1,11 +1,4 @@ . - * - * This software is licensed under the terms of the MIT license. - * https://opensource.org/licenses/MIT - */ namespace Pterodactyl\Services\Users; @@ -48,23 +41,15 @@ class UserDeletionService /** * Delete a user from the panel only if they have no servers attached to their account. * - * @param int|\Pterodactyl\Models\User $user - * - * @return bool|null - * * @throws \Pterodactyl\Exceptions\DisplayException */ - public function handle($user) + public function handle(User $user): void { - if ($user instanceof User) { - $user = $user->id; - } - - $servers = $this->serverRepository->setColumns('id')->findCountWhere([['owner_id', '=', $user]]); + $servers = $this->serverRepository->setColumns('id')->findCountWhere([['owner_id', '=', $user->id]]); if ($servers > 0) { - throw new DisplayException($this->translator->trans('admin/user.exceptions.user_has_servers')); + throw new DisplayException($this->translator->get('admin/user.exceptions.user_has_servers')); } - return $this->repository->delete($user); + $this->repository->delete($user->id); } } diff --git a/app/Services/Users/UserUpdateService.php b/app/Services/Users/UserUpdateService.php index 31f4010b6..a09a0a1a1 100644 --- a/app/Services/Users/UserUpdateService.php +++ b/app/Services/Users/UserUpdateService.php @@ -25,8 +25,6 @@ class UserUpdateService /** * Update the user model instance and return the updated model. - * - * @throws \Throwable */ public function handle(User $user, array $data): User { diff --git a/app/Traits/Helpers/AvailableLanguages.php b/app/Traits/Helpers/AvailableLanguages.php index 479722976..e9884dbc0 100644 --- a/app/Traits/Helpers/AvailableLanguages.php +++ b/app/Traits/Helpers/AvailableLanguages.php @@ -8,14 +8,14 @@ use Illuminate\Filesystem\Filesystem; trait AvailableLanguages { /** - * @var \Illuminate\Filesystem\Filesystem + * @var \Illuminate\Filesystem\Filesystem|null; */ - private $filesystem; + private $filesystem = null; /** - * @var \Matriphe\ISO639\ISO639 + * @var \Matriphe\ISO639\ISO639|null */ - private $iso639; + private $iso639 = null; /** * Return all of the available languages on the Panel based on those diff --git a/app/Transformers/Api/Application/DatabaseHostTransformer.php b/app/Transformers/Api/Application/DatabaseHostTransformer.php index dd45c3614..dd272a7ea 100644 --- a/app/Transformers/Api/Application/DatabaseHostTransformer.php +++ b/app/Transformers/Api/Application/DatabaseHostTransformer.php @@ -28,6 +28,7 @@ class DatabaseHostTransformer extends Transformer 'host' => $model->host, 'port' => $model->port, 'username' => $model->username, + // @phpstan-ignore-next-line no clue why it can't find this. 'node' => $model->node_id, 'created_at' => self::formatTimestamp($model->created_at), 'updated_at' => self::formatTimestamp($model->updated_at), diff --git a/app/Transformers/Api/Application/EggTransformer.php b/app/Transformers/Api/Application/EggTransformer.php index b6ebbc97d..90680152f 100644 --- a/app/Transformers/Api/Application/EggTransformer.php +++ b/app/Transformers/Api/Application/EggTransformer.php @@ -41,10 +41,9 @@ class EggTransformer extends Transformer '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), + 'files' => json_decode($model->config_files), + 'startup' => json_decode($model->config_startup), 'stop' => $model->config_stop, - 'logs' => json_decode($model->config_logs, true), 'file_denylist' => $model->file_denylist, 'extends' => $model->config_from, ], @@ -106,7 +105,6 @@ class EggTransformer extends Transformer 'files' => json_decode($model->inherit_config_files), 'startup' => json_decode($model->inherit_config_startup), 'stop' => $model->inherit_config_stop, - 'logs' => json_decode($model->inherit_config_logs), ]; }); } diff --git a/app/Transformers/Api/Application/ServerVariableTransformer.php b/app/Transformers/Api/Application/ServerVariableTransformer.php index 8a9b910db..172519f11 100644 --- a/app/Transformers/Api/Application/ServerVariableTransformer.php +++ b/app/Transformers/Api/Application/ServerVariableTransformer.php @@ -40,6 +40,8 @@ class ServerVariableTransformer extends Transformer return $this->null(); } + // TODO: confirm this code? + // @phpstan-ignore-next-line This might actually be wrong, not sure? return $this->item($variable->variable, new EggVariableTransformer()); } } diff --git a/app/Transformers/Api/Client/EggVariableTransformer.php b/app/Transformers/Api/Client/EggVariableTransformer.php index f37153e7a..50635e3df 100644 --- a/app/Transformers/Api/Client/EggVariableTransformer.php +++ b/app/Transformers/Api/Client/EggVariableTransformer.php @@ -27,7 +27,7 @@ class EggVariableTransformer extends Transformer 'description' => $variable->description, 'env_variable' => $variable->env_variable, 'default_value' => $variable->default_value, - 'server_value' => $variable->server_value, + 'server_value' => property_exists($variable, 'server_value') ? $variable->server_value : null, 'is_editable' => $variable->user_editable, 'rules' => $variable->rules, ]; diff --git a/app/Transformers/Api/Client/StatsTransformer.php b/app/Transformers/Api/Client/StatsTransformer.php index b438a8e1f..1d428a204 100644 --- a/app/Transformers/Api/Client/StatsTransformer.php +++ b/app/Transformers/Api/Client/StatsTransformer.php @@ -23,6 +23,7 @@ class StatsTransformer extends Transformer 'disk_bytes' => Arr::get($data, 'utilization.disk_bytes', 0), 'network_rx_bytes' => Arr::get($data, 'utilization.network.rx_bytes', 0), 'network_tx_bytes' => Arr::get($data, 'utilization.network.tx_bytes', 0), + 'uptime' => Arr::get($data, 'utilization.uptime', 0), ], ]; } diff --git a/app/Transformers/Api/Transformer.php b/app/Transformers/Api/Transformer.php index 91fdc0a3e..413d61de5 100644 --- a/app/Transformers/Api/Transformer.php +++ b/app/Transformers/Api/Transformer.php @@ -5,6 +5,7 @@ namespace Pterodactyl\Transformers\Api; use Closure; use DateTimeInterface; use Carbon\CarbonImmutable; +use Carbon\CarbonInterface; use Illuminate\Http\Request; use Pterodactyl\Models\User; use Webmozart\Assert\Assert; @@ -64,7 +65,7 @@ abstract class Transformer extends TransformerAbstract * * @param mixed $data * @param callable|\League\Fractal\TransformerAbstract $transformer - * @param null $resourceKey + * @param string|null $resourceKey * * @return \League\Fractal\Resource\Item */ @@ -76,7 +77,7 @@ abstract class Transformer extends TransformerAbstract $item = parent::item($data, $transformer, $resourceKey); - if (!$item->getResourceKey()) { + if (!$item->getResourceKey() && method_exists($transformer, 'getResourceName')) { $item->setResourceKey($transformer->getResourceName()); } @@ -88,7 +89,7 @@ abstract class Transformer extends TransformerAbstract * * @param mixed $data * @param callable|\League\Fractal\TransformerAbstract $transformer - * @param null $resourceKey + * @param string|null $resourceKey * * @return \League\Fractal\Resource\Collection */ @@ -100,7 +101,7 @@ abstract class Transformer extends TransformerAbstract $collection = parent::collection($data, $transformer, $resourceKey); - if (!$collection->getResourceKey()) { + if (!$collection->getResourceKey() && method_exists($transformer, 'getResourceName')) { $collection->setResourceKey($transformer->getResourceName()); } @@ -151,7 +152,7 @@ abstract class Transformer extends TransformerAbstract if ($timestamp instanceof DateTimeInterface) { $value = CarbonImmutable::instance($timestamp); } else { - $value = CarbonImmutable::createFromFormat(CarbonImmutable::DEFAULT_TO_STRING_FORMAT, $timestamp); + $value = CarbonImmutable::createFromFormat(CarbonInterface::DEFAULT_TO_STRING_FORMAT, $timestamp); } return $value->setTimezone($tz ?? self::$timezone)->toIso8601String(); diff --git a/app/helpers.php b/app/helpers.php index 9c1b4ed1b..484aacec6 100644 --- a/app/helpers.php +++ b/app/helpers.php @@ -21,8 +21,8 @@ if (!function_exists('object_get_strict')) { * and will not trigger the response of a default value (unlike object_get). * * @param object $object - * @param string $key - * @param null $default + * @param string|null $key + * @param mixed|null $default * * @return mixed */ diff --git a/composer.json b/composer.json index 03d821a1e..04303a415 100644 --- a/composer.json +++ b/composer.json @@ -23,7 +23,7 @@ "guzzlehttp/guzzle": "^7.3", "hashids/hashids": "^4.1", "laracasts/utilities": "^3.2", - "laravel/framework": "^8.58", + "laravel/framework": "^8.68", "laravel/helpers": "^1.4", "laravel/sanctum": "^2.11", "laravel/tinker": "^2.6", @@ -55,7 +55,9 @@ "laravel/dusk": "^6.18", "mockery/mockery": "^1.4", "nunomaduro/collision": "^5.9", + "nunomaduro/larastan": "^0.7.15", "php-mock/php-mock-phpunit": "^2.6", + "phpstan/phpstan-webmozart-assert": "^0.12.16", "phpunit/phpunit": "^9.5" }, "autoload": { diff --git a/config/cache.php b/config/cache.php index 056522262..27276c200 100644 --- a/config/cache.php +++ b/config/cache.php @@ -14,7 +14,7 @@ return [ | */ - 'default' => env('CACHE_DRIVER', 'file'), + 'default' => env('CACHE_DRIVER', 'redis'), /* |-------------------------------------------------------------------------- diff --git a/config/pterodactyl.php b/config/pterodactyl.php index b7cd12559..e21d1859f 100644 --- a/config/pterodactyl.php +++ b/config/pterodactyl.php @@ -84,8 +84,8 @@ return [ | Configure the timeout to be used for Guzzle connections here. */ 'guzzle' => [ - 'timeout' => env('GUZZLE_TIMEOUT', 30), - 'connect_timeout' => env('GUZZLE_CONNECT_TIMEOUT', 10), + 'timeout' => env('GUZZLE_TIMEOUT', 15), + 'connect_timeout' => env('GUZZLE_CONNECT_TIMEOUT', 5), ], /* diff --git a/config/queue.php b/config/queue.php index 326a575dd..02588bb3e 100644 --- a/config/queue.php +++ b/config/queue.php @@ -14,7 +14,7 @@ return [ | */ - 'default' => env('QUEUE_CONNECTION', env('QUEUE_DRIVER', 'database')), + 'default' => env('QUEUE_CONNECTION', env('QUEUE_DRIVER', 'redis')), /* |-------------------------------------------------------------------------- diff --git a/config/session.php b/config/session.php index 9d99eaf80..058528e5f 100644 --- a/config/session.php +++ b/config/session.php @@ -14,7 +14,7 @@ return [ | */ - 'driver' => env('SESSION_DRIVER', 'database'), + 'driver' => env('SESSION_DRIVER', 'redis'), /* |-------------------------------------------------------------------------- diff --git a/database/Seeders/eggs/minecraft/egg-bungeecord.json b/database/Seeders/eggs/minecraft/egg-bungeecord.json index 0df945cf0..ac3749c8c 100644 --- a/database/Seeders/eggs/minecraft/egg-bungeecord.json +++ b/database/Seeders/eggs/minecraft/egg-bungeecord.json @@ -4,25 +4,27 @@ "version": "PTDL_v1", "update_url": null }, - "exported_at": "2021-07-04T19:18:34-04:00", + "exported_at": "2021-11-14T19:23:12+00: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.", "features": [ "eula", - "java_version" + "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_16", + "ghcr.io\/pterodactyl\/yolks:java_17" ], "file_denylist": [], "startup": "java -Xms128M -Xmx{{SERVER_MEMORY}}M -jar {{SERVER_JARFILE}}", "config": { "files": "{\r\n \"config.yml\": {\r\n \"parser\": \"yaml\",\r\n \"find\": {\r\n \"listeners[0].query_port\": \"{{server.build.default.port}}\",\r\n \"listeners[0].host\": \"0.0.0.0:{{server.build.default.port}}\",\r\n \"servers.*.address\": {\r\n \"regex:^(127\\\\.0\\\\.0\\\\.1|localhost)(:\\\\d{1,5})?$\": \"{{config.docker.interface}}$2\"\r\n }\r\n }\r\n }\r\n}", "startup": "{\r\n \"done\": \"Listening on \"\r\n}", - "logs": "{\r\n \"custom\": false,\r\n \"location\": \"proxy.log.0\"\r\n}", + "logs": "{}", "stop": "end" }, "scripts": { diff --git a/database/Seeders/eggs/minecraft/egg-forge-minecraft.json b/database/Seeders/eggs/minecraft/egg-forge-minecraft.json index fa750a449..a077df6d1 100644 --- a/database/Seeders/eggs/minecraft/egg-forge-minecraft.json +++ b/database/Seeders/eggs/minecraft/egg-forge-minecraft.json @@ -4,30 +4,32 @@ "version": "PTDL_v1", "update_url": null }, - "exported_at": "2021-07-04T19:18:55-04:00", + "exported_at": "2021-12-11T22:51:29+00: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.", "features": [ "eula", - "java_version" + "java_version", + "pid_limit" ], "images": [ - "ghcr.io\/pterodactyl\/yolks:java_8", + "ghcr.io\/pterodactyl\/yolks:java_17", + "ghcr.io\/pterodactyl\/yolks:java_16", "ghcr.io\/pterodactyl\/yolks:java_11", - "ghcr.io\/pterodactyl\/yolks:java_16" + "ghcr.io\/pterodactyl\/yolks:java_8" ], "file_denylist": [], - "startup": "java -Xms128M -Xmx{{SERVER_MEMORY}}M -jar {{SERVER_JARFILE}}", + "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": { - "files": "{\r\n \"server.properties\": {\r\n \"parser\": \"properties\",\r\n \"find\": {\r\n \"server-ip\": \"0.0.0.0\",\r\n \"enable-query\": \"true\",\r\n \"server-port\": \"{{server.build.default.port}}\",\r\n \"query.port\": \"{{server.build.default.port}}\"\r\n }\r\n }\r\n}", + "files": "{\r\n \"server.properties\": {\r\n \"parser\": \"properties\",\r\n \"find\": {\r\n \"server-ip\": \"0.0.0.0\",\r\n \"server-port\": \"{{server.build.default.port}}\",\r\n \"query.port\": \"{{server.build.default.port}}\"\r\n }\r\n }\r\n}", "startup": "{\r\n \"done\": \")! For help, type \"\r\n}", - "logs": "{\r\n \"custom\": false,\r\n \"location\": \"logs\/latest.log\"\r\n}", + "logs": "{}", "stop": "stop" }, "scripts": { "installation": { - "script": "#!\/bin\/bash\r\n# Forge Installation Script\r\n#\r\n# Server Files: \/mnt\/server\r\napt update\r\napt install -y curl jq\r\n\r\n#Go into main direction\r\nif [ ! -d \/mnt\/server ]; then\r\n mkdir \/mnt\/server\r\nfi\r\n\r\ncd \/mnt\/server\r\n\r\nif [ ! -z ${FORGE_VERSION} ]; then\r\n DOWNLOAD_LINK=https:\/\/maven.minecraftforge.net\/net\/minecraftforge\/forge\/${FORGE_VERSION}\/forge-${FORGE_VERSION}\r\n FORGE_JAR=forge-${FORGE_VERSION}*.jar\r\nelse\r\n JSON_DATA=$(curl -sSL https:\/\/files.minecraftforge.net\/maven\/net\/minecraftforge\/forge\/promotions_slim.json)\r\n\r\n if [ \"${MC_VERSION}\" == \"latest\" ] || [ \"${MC_VERSION}\" == \"\" ] ; then\r\n echo -e \"getting latest recommended version of forge.\"\r\n MC_VERSION=$(echo -e ${JSON_DATA} | jq -r '.promos | del(.\"latest-1.7.10\") | del(.\"1.7.10-latest-1.7.10\") | to_entries[] | .key | select(contains(\"recommended\")) | split(\"-\")[0]' | sort -t. -k 1,1n -k 2,2n -k 3,3n -k 4,4n | tail -1)\r\n \tBUILD_TYPE=recommended\r\n fi\r\n\r\n if [ \"${BUILD_TYPE}\" != \"recommended\" ] && [ \"${BUILD_TYPE}\" != \"latest\" ]; then\r\n BUILD_TYPE=recommended\r\n fi\r\n\r\n echo -e \"minecraft version: ${MC_VERSION}\"\r\n echo -e \"build type: ${BUILD_TYPE}\"\r\n\r\n ## some variables for getting versions and things\r\n\tFILE_SITE=https:\/\/maven.minecraftforge.net\/net\/minecraftforge\/forge\/\r\n VERSION_KEY=$(echo -e ${JSON_DATA} | jq -r --arg MC_VERSION \"${MC_VERSION}\" --arg BUILD_TYPE \"${BUILD_TYPE}\" '.promos | del(.\"latest-1.7.10\") | del(.\"1.7.10-latest-1.7.10\") | to_entries[] | .key | select(contains($MC_VERSION)) | select(contains($BUILD_TYPE))')\r\n\r\n ## locating the forge version\r\n if [ \"${VERSION_KEY}\" == \"\" ] && [ \"${BUILD_TYPE}\" == \"recommended\" ]; then\r\n echo -e \"dropping back to latest from recommended due to there not being a recommended version of forge for the mc version requested.\"\r\n VERSION_KEY=$(echo -e ${JSON_DATA} | jq -r --arg MC_VERSION \"${MC_VERSION}\" '.promos | del(.\"latest-1.7.10\") | del(.\"1.7.10-latest-1.7.10\") | to_entries[] | .key | select(contains($MC_VERSION)) | select(contains(\"recommended\"))')\r\n fi\r\n\r\n ## Error if the mc version set wasn't valid.\r\n if [ \"${VERSION_KEY}\" == \"\" ] || [ \"${VERSION_KEY}\" == \"null\" ]; then\r\n \techo -e \"The install failed because there is no valid version of forge for the version on minecraft selected.\"\r\n \texit 1\r\n fi\r\n\r\n FORGE_VERSION=$(echo -e ${JSON_DATA} | jq -r --arg VERSION_KEY \"$VERSION_KEY\" '.promos | .[$VERSION_KEY]')\r\n\r\n if [ \"${MC_VERSION}\" == \"1.7.10\" ] || [ \"${MC_VERSION}\" == \"1.8.9\" ]; then\r\n DOWNLOAD_LINK=${FILE_SITE}${MC_VERSION}-${FORGE_VERSION}-${MC_VERSION}\/forge-${MC_VERSION}-${FORGE_VERSION}-${MC_VERSION}\r\n FORGE_JAR=forge-${MC_VERSION}-${FORGE_VERSION}-${MC_VERSION}.jar\r\n if [ \"${MC_VERSION}\" == \"1.7.10\" ]; then\r\n FORGE_JAR=forge-${MC_VERSION}-${FORGE_VERSION}-${MC_VERSION}-universal.jar\r\n fi\r\n else\r\n DOWNLOAD_LINK=${FILE_SITE}${MC_VERSION}-${FORGE_VERSION}\/forge-${MC_VERSION}-${FORGE_VERSION}\r\n FORGE_JAR=forge-${MC_VERSION}-${FORGE_VERSION}.jar\r\n fi\r\nfi\r\n\r\n\r\n#Adding .jar when not eding by SERVER_JARFILE\r\nif [[ ! $SERVER_JARFILE = *\\.jar ]]; then\r\n SERVER_JARFILE=\"$SERVER_JARFILE.jar\"\r\nfi\r\n\r\n#Downloading jars\r\necho -e \"Downloading forge version ${FORGE_VERSION}\"\r\necho -e \"Download link is ${DOWNLOAD_LINK}\"\r\nif [ ! -z \"${DOWNLOAD_LINK}\" ]; then \r\n if curl --output \/dev\/null --silent --head --fail ${DOWNLOAD_LINK}-installer.jar; then\r\n echo -e \"installer jar download link is valid.\"\r\n else\r\n echo -e \"link is invalid closing out\"\r\n exit 2\r\n fi\r\nelse\r\n echo -e \"no download link closing out\"\r\n exit 3\r\nfi\r\n\r\ncurl -s -o installer.jar -sS ${DOWNLOAD_LINK}-installer.jar\r\n\r\n#Checking if downloaded jars exist\r\nif [ ! -f .\/installer.jar ]; then\r\n echo \"!!! Error by downloading forge version ${FORGE_VERSION} !!!\"\r\n exit\r\nfi\r\n\r\n#Installing server\r\necho -e \"Installing forge server.\\n\"\r\njava -jar installer.jar --installServer || { echo -e \"install failed\"; exit 4; }\r\n\r\nmv $FORGE_JAR $SERVER_JARFILE\r\n\r\n#Deleting installer.jar\r\necho -e \"Deleting installer.jar file.\\n\"\r\nrm -rf installer.jar", + "script": "#!\/bin\/bash\r\n# Forge Installation Script\r\n#\r\n# Server Files: \/mnt\/server\r\napt update\r\napt install -y curl jq\r\n\r\nif [[ ! -d \/mnt\/server ]]; then\r\n mkdir \/mnt\/server\r\nfi\r\n\r\ncd \/mnt\/server\r\n\r\n# Remove spaces from the version number to avoid issues with curl\r\nFORGE_VERSION=\"$(echo \"$FORGE_VERSION\" | tr -d ' ')\"\r\nMC_VERSION=\"$(echo \"$MC_VERSION\" | tr -d ' ')\"\r\n\r\nif [[ ! -z ${FORGE_VERSION} ]]; then\r\n DOWNLOAD_LINK=https:\/\/maven.minecraftforge.net\/net\/minecraftforge\/forge\/${FORGE_VERSION}\/forge-${FORGE_VERSION}\r\n FORGE_JAR=forge-${FORGE_VERSION}*.jar\r\nelse\r\n JSON_DATA=$(curl -sSL https:\/\/files.minecraftforge.net\/maven\/net\/minecraftforge\/forge\/promotions_slim.json)\r\n\r\n if [[ \"${MC_VERSION}\" == \"latest\" ]] || [[ \"${MC_VERSION}\" == \"\" ]]; then\r\n echo -e \"getting latest version of forge.\"\r\n MC_VERSION=$(echo -e ${JSON_DATA} | jq -r '.promos | del(.\"latest-1.7.10\") | del(.\"1.7.10-latest-1.7.10\") | to_entries[] | .key | select(contains(\"latest\")) | split(\"-\")[0]' | sort -t. -k 1,1n -k 2,2n -k 3,3n -k 4,4n | tail -1)\r\n BUILD_TYPE=latest\r\n fi\r\n\r\n if [[ \"${BUILD_TYPE}\" != \"recommended\" ]] && [[ \"${BUILD_TYPE}\" != \"latest\" ]]; then\r\n BUILD_TYPE=recommended\r\n fi\r\n\r\n echo -e \"minecraft version: ${MC_VERSION}\"\r\n echo -e \"build type: ${BUILD_TYPE}\"\r\n\r\n ## some variables for getting versions and things\r\n FILE_SITE=https:\/\/maven.minecraftforge.net\/net\/minecraftforge\/forge\/\r\n VERSION_KEY=$(echo -e ${JSON_DATA} | jq -r --arg MC_VERSION \"${MC_VERSION}\" --arg BUILD_TYPE \"${BUILD_TYPE}\" '.promos | del(.\"latest-1.7.10\") | del(.\"1.7.10-latest-1.7.10\") | to_entries[] | .key | select(contains($MC_VERSION)) | select(contains($BUILD_TYPE))')\r\n\r\n ## locating the forge version\r\n if [[ \"${VERSION_KEY}\" == \"\" ]] && [[ \"${BUILD_TYPE}\" == \"recommended\" ]]; then\r\n echo -e \"dropping back to latest from recommended due to there not being a recommended version of forge for the mc version requested.\"\r\n VERSION_KEY=$(echo -e ${JSON_DATA} | jq -r --arg MC_VERSION \"${MC_VERSION}\" '.promos | del(.\"latest-1.7.10\") | del(.\"1.7.10-latest-1.7.10\") | to_entries[] | .key | select(contains($MC_VERSION)) | select(contains(\"latest\"))')\r\n fi\r\n\r\n ## Error if the mc version set wasn't valid.\r\n if [ \"${VERSION_KEY}\" == \"\" ] || [ \"${VERSION_KEY}\" == \"null\" ]; then\r\n echo -e \"The install failed because there is no valid version of forge for the version of minecraft selected.\"\r\n exit 1\r\n fi\r\n\r\n FORGE_VERSION=$(echo -e ${JSON_DATA} | jq -r --arg VERSION_KEY \"$VERSION_KEY\" '.promos | .[$VERSION_KEY]')\r\n\r\n if [[ \"${MC_VERSION}\" == \"1.7.10\" ]] || [[ \"${MC_VERSION}\" == \"1.8.9\" ]]; then\r\n DOWNLOAD_LINK=${FILE_SITE}${MC_VERSION}-${FORGE_VERSION}-${MC_VERSION}\/forge-${MC_VERSION}-${FORGE_VERSION}-${MC_VERSION}\r\n FORGE_JAR=forge-${MC_VERSION}-${FORGE_VERSION}-${MC_VERSION}.jar\r\n if [[ \"${MC_VERSION}\" == \"1.7.10\" ]]; then\r\n FORGE_JAR=forge-${MC_VERSION}-${FORGE_VERSION}-${MC_VERSION}-universal.jar\r\n fi\r\n else\r\n DOWNLOAD_LINK=${FILE_SITE}${MC_VERSION}-${FORGE_VERSION}\/forge-${MC_VERSION}-${FORGE_VERSION}\r\n FORGE_JAR=forge-${MC_VERSION}-${FORGE_VERSION}.jar\r\n fi\r\nfi\r\n\r\n#Adding .jar when not eding by SERVER_JARFILE\r\nif [[ ! $SERVER_JARFILE = *\\.jar ]]; then\r\n SERVER_JARFILE=\"$SERVER_JARFILE.jar\"\r\nfi\r\n\r\n#Downloading jars\r\necho -e \"Downloading forge version ${FORGE_VERSION}\"\r\necho -e \"Download link is ${DOWNLOAD_LINK}\"\r\n\r\nif [[ ! -z \"${DOWNLOAD_LINK}\" ]]; then\r\n if curl --output \/dev\/null --silent --head --fail ${DOWNLOAD_LINK}-installer.jar; then\r\n echo -e \"installer jar download link is valid.\"\r\n else\r\n echo -e \"link is invalid. Exiting now\"\r\n exit 2\r\n fi\r\nelse\r\n echo -e \"no download link provided. Exiting now\"\r\n exit 3\r\nfi\r\n\r\ncurl -s -o installer.jar -sS ${DOWNLOAD_LINK}-installer.jar\r\n\r\n#Checking if downloaded jars exist\r\nif [[ ! -f .\/installer.jar ]]; then\r\n echo \"!!! Error downloading forge version ${FORGE_VERSION} !!!\"\r\n exit\r\nfi\r\n\r\nfunction unix_args {\r\n echo -e \"Detected Forge 1.17 or newer version. Setting up forge unix args.\"\r\n ln -sf libraries\/net\/minecraftforge\/forge\/*\/unix_args.txt unix_args.txt\r\n}\r\n\r\n# Delete args to support downgrading\/upgrading\r\nrm -rf libraries\/net\/minecraftforge\/forge\r\nrm unix_args.txt\r\n\r\n#Installing server\r\necho -e \"Installing forge server.\\n\"\r\njava -jar installer.jar --installServer || { echo -e \"install failed using Forge version ${FORGE_VERSION} and Minecraft version ${MINECRAFT_VERSION}\"; exit 4; }\r\n\r\n# Check if we need a symlink for 1.17+ Forge JPMS args\r\nif [[ $MC_VERSION =~ ^1\\.(17|18|19|20|21|22|23) || $FORGE_VERSION =~ ^1\\.(17|18|19|20|21|22|23) ]]; then\r\n unix_args\r\n\r\n# Check if someone has set MC to latest but overwrote it with older Forge version, otherwise we would have false positives\r\nelif [[ $MC_VERSION == \"latest\" && $FORGE_VERSION =~ ^1\\.(17|18|19|20|21|22|23) ]]; then\r\n unix_args\r\nelse\r\n # For versions below 1.17 that ship with jar\r\n mv $FORGE_JAR $SERVER_JARFILE\r\nfi\r\n\r\necho -e \"Deleting installer.jar file.\\n\"\r\nrm -rf installer.jar", "container": "openjdk:8-jdk-slim", "entrypoint": "bash" } @@ -35,7 +37,7 @@ "variables": [ { "name": "Server Jar File", - "description": "The name of the Jarfile to use when running Forge Mod.", + "description": "The name of the Jarfile to use when running Forge version below 1.17.", "env_variable": "SERVER_JARFILE", "default_value": "server.jar", "user_viewable": true, @@ -58,7 +60,7 @@ "default_value": "recommended", "user_viewable": true, "user_editable": true, - "rules": "required|string|max:20" + "rules": "required|string|in:recommended,latest" }, { "name": "Forge Version", @@ -67,7 +69,7 @@ "default_value": "", "user_viewable": true, "user_editable": true, - "rules": "nullable|string|max:20" + "rules": "nullable|string|max:25" } ] -} +} \ No newline at end of file diff --git a/database/Seeders/eggs/minecraft/egg-paper.json b/database/Seeders/eggs/minecraft/egg-paper.json index 4153227a4..afd3715d8 100644 --- a/database/Seeders/eggs/minecraft/egg-paper.json +++ b/database/Seeders/eggs/minecraft/egg-paper.json @@ -4,23 +4,25 @@ "version": "PTDL_v1", "update_url": null }, - "exported_at": "2021-08-01T03:54:45+03:00", + "exported_at": "2021-11-14T19:21:07+00:00", "name": "Paper", "author": "parker@pterodactyl.io", "description": "High performance Spigot fork that aims to fix gameplay and mechanics inconsistencies.", "features": [ "eula", - "java_version" + "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_16", + "ghcr.io\/pterodactyl\/yolks:java_17" ], "file_denylist": [], "startup": "java -Xms128M -Xmx{{SERVER_MEMORY}}M -Dterminal.jline=false -Dterminal.ansi=true -jar {{SERVER_JARFILE}}", "config": { - "files": "{\r\n \"server.properties\": {\r\n \"parser\": \"properties\",\r\n \"find\": {\r\n \"server-ip\": \"0.0.0.0\",\r\n \"server-port\": \"{{server.build.default.port}}\"\r\n }\r\n }\r\n}", + "files": "{\r\n \"server.properties\": {\r\n \"parser\": \"properties\",\r\n \"find\": {\r\n \"server-ip\": \"0.0.0.0\",\r\n \"server-port\": \"{{server.build.default.port}}\",\r\n \"query.port\": \"{{server.build.default.port}}\"\r\n }\r\n }\r\n}", "startup": "{\r\n \"done\": \")! For help, type \"\r\n}", "logs": "{}", "stop": "stop" diff --git a/database/Seeders/eggs/minecraft/egg-sponge--sponge-vanilla.json b/database/Seeders/eggs/minecraft/egg-sponge--sponge-vanilla.json index c25fda1c2..908c1f4e5 100644 --- a/database/Seeders/eggs/minecraft/egg-sponge--sponge-vanilla.json +++ b/database/Seeders/eggs/minecraft/egg-sponge--sponge-vanilla.json @@ -4,13 +4,14 @@ "version": "PTDL_v1", "update_url": null }, - "exported_at": "2021-08-01T03:55:24+03:00", + "exported_at": "2021-10-22T19:19:17+02:00", "name": "Sponge (SpongeVanilla)", "author": "support@pterodactyl.io", "description": "SpongeVanilla is the SpongeAPI implementation for Vanilla Minecraft.", "features": [ "eula", - "java_version" + "java_version", + "pid_limit" ], "images": [ "ghcr.io\/pterodactyl\/yolks:java_8", @@ -20,7 +21,7 @@ "file_denylist": [], "startup": "java -Xms128M -Xmx{{SERVER_MEMORY}}M -jar {{SERVER_JARFILE}}", "config": { - "files": "{\r\n \"server.properties\": {\r\n \"parser\": \"properties\",\r\n \"find\": {\r\n \"server-ip\": \"0.0.0.0\",\r\n \"enable-query\": \"true\",\r\n \"server-port\": \"{{server.build.default.port}}\",\r\n \"query.port\": \"{{server.build.default.port}}\"\r\n }\r\n }\r\n}", + "files": "{\r\n \"server.properties\": {\r\n \"parser\": \"properties\",\r\n \"find\": {\r\n \"server-ip\": \"0.0.0.0\",\r\n \"server-port\": \"{{server.build.default.port}}\",\r\n \"query.port\": \"{{server.build.default.port}}\"\r\n }\r\n }\r\n}", "startup": "{\r\n \"done\": \")! For help, type \"\r\n}", "logs": "{}", "stop": "stop" diff --git a/database/Seeders/eggs/minecraft/egg-vanilla-minecraft.json b/database/Seeders/eggs/minecraft/egg-vanilla-minecraft.json index 236a0cff3..2361a2974 100644 --- a/database/Seeders/eggs/minecraft/egg-vanilla-minecraft.json +++ b/database/Seeders/eggs/minecraft/egg-vanilla-minecraft.json @@ -4,25 +4,27 @@ "version": "PTDL_v1", "update_url": null }, - "exported_at": "2021-07-04T19:19:24-04:00", + "exported_at": "2021-11-14T19:18:30+00: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.", "features": [ "eula", - "java_version" + "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_16", + "ghcr.io\/pterodactyl\/yolks:java_17" ], "file_denylist": [], "startup": "java -Xms128M -Xmx{{SERVER_MEMORY}}M -jar {{SERVER_JARFILE}}", "config": { - "files": "{\r\n \"server.properties\": {\r\n \"parser\": \"properties\",\r\n \"find\": {\r\n \"server-ip\": \"0.0.0.0\",\r\n \"enable-query\": \"true\",\r\n \"server-port\": \"{{server.build.default.port}}\"\r\n }\r\n }\r\n}", + "files": "{\r\n \"server.properties\": {\r\n \"parser\": \"properties\",\r\n \"find\": {\r\n \"server-ip\": \"0.0.0.0\",\r\n \"server-port\": \"{{server.build.default.port}}\",\r\n \"query.port\": \"{{server.build.default.port}}\"\r\n }\r\n }\r\n}", "startup": "{\r\n \"done\": \")! For help, type \"\r\n}", - "logs": "{\r\n \"custom\": false,\r\n \"location\": \"logs\/latest.log\"\r\n}", + "logs": "{}", "stop": "stop" }, "scripts": { diff --git a/database/Seeders/eggs/rust/egg-rust.json b/database/Seeders/eggs/rust/egg-rust.json index 70eaadbad..ddbf88cdb 100644 --- a/database/Seeders/eggs/rust/egg-rust.json +++ b/database/Seeders/eggs/rust/egg-rust.json @@ -4,26 +4,28 @@ "version": "PTDL_v1", "update_url": null }, - "exported_at": "2021-05-29T19:02:43-04:00", + "exported_at": "2022-01-18T11:44:55-05:00", "name": "Rust", "author": "support@pterodactyl.io", "description": "The only aim in Rust is to survive. To do this you will need to overcome struggles such as hunger, thirst and cold. Build a fire. Build a shelter. Kill animals for meat. Protect yourself from other players, and kill them for meat. Create alliances with other players and form a town. Do whatever it takes to survive.", - "features": null, + "features": [ + "steam_disk_space" + ], "images": [ "quay.io\/pterodactyl\/core:rust" ], "file_denylist": [], - "startup": ".\/RustDedicated -batchmode +server.port {{SERVER_PORT}} +server.identity \"rust\" +rcon.port {{RCON_PORT}} +rcon.web true +server.hostname \\\"{{HOSTNAME}}\\\" +server.level \\\"{{LEVEL}}\\\" +server.description \\\"{{DESCRIPTION}}\\\" +server.url \\\"{{SERVER_URL}}\\\" +server.headerimage \\\"{{SERVER_IMG}}\\\" +server.logoimage \\\"{{SERVER_LOGO}}\\\" +server.worldsize \\\"{{WORLD_SIZE}}\\\" +server.seed \\\"{{WORLD_SEED}}\\\" +server.maxplayers {{MAX_PLAYERS}} +rcon.password \\\"{{RCON_PASS}}\\\" +server.saveinterval {{SAVEINTERVAL}} +app.port {{APP_PORT}} {{ADDITIONAL_ARGS}}", + "startup": ".\/RustDedicated -batchmode +server.port {{SERVER_PORT}} +server.identity \"rust\" +rcon.port {{RCON_PORT}} +rcon.web true +server.hostname \\\"{{HOSTNAME}}\\\" +server.level \\\"{{LEVEL}}\\\" +server.description \\\"{{DESCRIPTION}}\\\" +server.url \\\"{{SERVER_URL}}\\\" +server.headerimage \\\"{{SERVER_IMG}}\\\" +server.logoimage \\\"{{SERVER_LOGO}}\\\" +server.maxplayers {{MAX_PLAYERS}} +rcon.password \\\"{{RCON_PASS}}\\\" +server.saveinterval {{SAVEINTERVAL}} +app.port {{APP_PORT}} $( [ -z ${MAP_URL} ] && printf %s \"+server.worldsize \\\"{{WORLD_SIZE}}\\\" +server.seed \\\"{{WORLD_SEED}}\\\"\" || printf %s \"+server.levelurl {{MAP_URL}}\" ) {{ADDITIONAL_ARGS}}", "config": { "files": "{}", - "startup": "{\r\n \"done\": \"Server startup complete\",\r\n \"userInteraction\": []\r\n}", - "logs": "{\r\n \"custom\": false,\r\n \"location\": \"latest.log\"\r\n}", + "startup": "{\r\n \"done\": \"Server startup complete\"\r\n}", + "logs": "{}", "stop": "quit" }, "scripts": { "installation": { - "script": "#!\/bin\/bash\r\n# steamcmd Base Installation Script\r\n#\r\n# Server Files: \/mnt\/server\r\n# Image to install with is 'debian:buster-slim'\r\napt -y update\r\napt -y --no-install-recommends install curl lib32gcc1 ca-certificates\r\n\r\nSRCDS_APPID=258550\r\n\r\n## just in case someone removed the defaults.\r\nif [ \"${STEAM_USER}\" == \"\" ]; then\r\n echo -e \"steam user is not set.\\n\"\r\n echo -e \"Using anonymous user.\\n\"\r\n STEAM_USER=anonymous\r\n STEAM_PASS=\"\"\r\n STEAM_AUTH=\"\"\r\nelse\r\n echo -e \"user set to ${STEAM_USER}\"\r\nfi\r\n\r\n## download and install steamcmd\r\ncd \/tmp\r\nmkdir -p \/mnt\/server\/steamcmd\r\ncurl -sSL -o steamcmd.tar.gz https:\/\/steamcdn-a.akamaihd.net\/client\/installer\/steamcmd_linux.tar.gz\r\ntar -xzvf steamcmd.tar.gz -C \/mnt\/server\/steamcmd\r\nmkdir -p \/mnt\/server\/steamapps # Fix steamcmd disk write error when this folder is missing\r\ncd \/mnt\/server\/steamcmd\r\n\r\n# SteamCMD fails otherwise for some reason, even running as root.\r\n# This is changed at the end of the install process anyways.\r\nchown -R root:root \/mnt\r\nexport HOME=\/mnt\/server\r\n\r\n## install game using steamcmd\r\n.\/steamcmd.sh +login ${STEAM_USER} ${STEAM_PASS} ${STEAM_AUTH} +force_install_dir \/mnt\/server +app_update ${SRCDS_APPID} ${EXTRA_FLAGS} validate +quit ## other flags may be needed depending on install. looking at you cs 1.6\r\n\r\n## set up 32 bit libraries\r\nmkdir -p \/mnt\/server\/.steam\/sdk32\r\ncp -v linux32\/steamclient.so ..\/.steam\/sdk32\/steamclient.so\r\n\r\n## set up 64 bit libraries\r\nmkdir -p \/mnt\/server\/.steam\/sdk64\r\ncp -v linux64\/steamclient.so ..\/.steam\/sdk64\/steamclient.so", - "container": "debian:buster-slim", + "script": "#!\/bin\/bash\r\n# steamcmd Base Installation Script\r\n#\r\n# Server Files: \/mnt\/server\r\n\r\nSRCDS_APPID=258550\r\n\r\n## just in case someone removed the defaults.\r\nif [ \"${STEAM_USER}\" == \"\" ]; then\r\n echo -e \"steam user is not set.\\n\"\r\n echo -e \"Using anonymous user.\\n\"\r\n STEAM_USER=anonymous\r\n STEAM_PASS=\"\"\r\n STEAM_AUTH=\"\"\r\nelse\r\n echo -e \"user set to ${STEAM_USER}\"\r\nfi\r\n\r\n## download and install steamcmd\r\ncd \/tmp\r\nmkdir -p \/mnt\/server\/steamcmd\r\ncurl -sSL -o steamcmd.tar.gz https:\/\/steamcdn-a.akamaihd.net\/client\/installer\/steamcmd_linux.tar.gz\r\ntar -xzvf steamcmd.tar.gz -C \/mnt\/server\/steamcmd\r\nmkdir -p \/mnt\/server\/steamapps # Fix steamcmd disk write error when this folder is missing\r\ncd \/mnt\/server\/steamcmd\r\n\r\n# SteamCMD fails otherwise for some reason, even running as root.\r\n# This is changed at the end of the install process anyways.\r\nchown -R root:root \/mnt\r\nexport HOME=\/mnt\/server\r\n\r\n## install game using steamcmd\r\n.\/steamcmd.sh +force_install_dir \/mnt\/server +login ${STEAM_USER} ${STEAM_PASS} ${STEAM_AUTH} +app_update ${SRCDS_APPID} ${EXTRA_FLAGS} validate +quit ## other flags may be needed depending on install. looking at you cs 1.6\r\n\r\n## set up 32 bit libraries\r\nmkdir -p \/mnt\/server\/.steam\/sdk32\r\ncp -v linux32\/steamclient.so ..\/.steam\/sdk32\/steamclient.so\r\n\r\n## set up 64 bit libraries\r\nmkdir -p \/mnt\/server\/.steam\/sdk64\r\ncp -v linux64\/steamclient.so ..\/.steam\/sdk64\/steamclient.so", + "container": "ghcr.io\/pterodactyl\/installers:debian", "entrypoint": "bash" } }, @@ -162,6 +164,15 @@ "user_viewable": true, "user_editable": true, "rules": "nullable|url" + }, + { + "name": "Custom Map URL", + "description": "Overwrites the map with the one from the direct download URL. Invalid URLs will cause the server to crash.", + "env_variable": "MAP_URL", + "default_value": "", + "user_viewable": true, + "user_editable": true, + "rules": "nullable|url" } ] } diff --git a/database/Seeders/eggs/source-engine/egg-ark--survival-evolved.json b/database/Seeders/eggs/source-engine/egg-ark--survival-evolved.json index 012bf62f7..584ffe37c 100644 --- a/database/Seeders/eggs/source-engine/egg-ark--survival-evolved.json +++ b/database/Seeders/eggs/source-engine/egg-ark--survival-evolved.json @@ -4,11 +4,13 @@ "version": "PTDL_v1", "update_url": null }, - "exported_at": "2021-09-11T14:35:10-04:00", + "exported_at": "2022-01-18T07:01:38-05:00", "name": "Ark: Survival Evolved", "author": "dev@shepper.fr", "description": "As a man or woman stranded, naked, freezing, and starving on the unforgiving shores of a mysterious island called ARK, use your skill and cunning to kill or tame and ride the plethora of leviathan dinosaurs and other primeval creatures roaming the land. Hunt, harvest resources, craft items, grow crops, research technologies, and build shelters to withstand the elements and store valuables, all while teaming up with (or preying upon) hundreds of other players to survive, dominate... and escape! \u2014 Gamepedia: ARK", - "features": null, + "features": [ + "steam_disk_space" + ], "images": [ "quay.io\/parkervcp\/pterodactyl-images:debian_source" ], @@ -22,8 +24,8 @@ }, "scripts": { "installation": { - "script": "#!\/bin\/bash\r\n# steamcmd Base Installation Script\r\n#\r\n# Server Files: \/mnt\/server\r\n# Image to install with is 'ubuntu:18.04'\r\napt -y update\r\napt -y --no-install-recommends --no-install-suggests install curl lib32gcc1 ca-certificates\r\n\r\n## just in case someone removed the defaults.\r\nif [ \"${STEAM_USER}\" == \"\" ]; then\r\n STEAM_USER=anonymous\r\n STEAM_PASS=\"\"\r\n STEAM_AUTH=\"\"\r\nfi\r\n\r\n## download and install steamcmd\r\ncd \/tmp\r\nmkdir -p \/mnt\/server\/steamcmd\r\ncurl -sSL -o steamcmd.tar.gz https:\/\/steamcdn-a.akamaihd.net\/client\/installer\/steamcmd_linux.tar.gz\r\ntar -xzvf steamcmd.tar.gz -C \/mnt\/server\/steamcmd\r\n\r\nmkdir -p \/mnt\/server\/Engine\/Binaries\/ThirdParty\/SteamCMD\/Linux\r\ntar -xzvf steamcmd.tar.gz -C \/mnt\/server\/Engine\/Binaries\/ThirdParty\/SteamCMD\/Linux\r\nmkdir -p \/mnt\/server\/steamapps # Fix steamcmd disk write error when this folder is missing\r\ncd \/mnt\/server\/steamcmd\r\n\r\n# SteamCMD fails otherwise for some reason, even running as root.\r\n# This is changed at the end of the install process anyways.\r\nchown -R root:root \/mnt\r\nexport HOME=\/mnt\/server\r\n\r\n## install game using steamcmd\r\n.\/steamcmd.sh +login ${STEAM_USER} ${STEAM_PASS} ${STEAM_AUTH} +force_install_dir \/mnt\/server +app_update ${SRCDS_APPID} ${EXTRA_FLAGS} +quit ## other flags may be needed depending on install. looking at you cs 1.6\r\n\r\n## set up 32 bit libraries\r\nmkdir -p \/mnt\/server\/.steam\/sdk32\r\ncp -v linux32\/steamclient.so ..\/.steam\/sdk32\/steamclient.so\r\n\r\n## set up 64 bit libraries\r\nmkdir -p \/mnt\/server\/.steam\/sdk64\r\ncp -v linux64\/steamclient.so ..\/.steam\/sdk64\/steamclient.so\r\n\r\n## create a symbolic link for loading mods\r\ncd \/mnt\/server\/Engine\/Binaries\/ThirdParty\/SteamCMD\/Linux\r\nln -sf ..\/..\/..\/..\/..\/Steam\/steamapps steamapps\r\ncd \/mnt\/server", - "container": "debian:buster-slim", + "script": "#!\/bin\/bash\r\n# steamcmd Base Installation Script\r\n#\r\n# Server Files: \/mnt\/server\r\n# Image to install with is 'ubuntu:18.04'\r\n\r\n## just in case someone removed the defaults.\r\nif [ \"${STEAM_USER}\" == \"\" ]; then\r\n STEAM_USER=anonymous\r\n STEAM_PASS=\"\"\r\n STEAM_AUTH=\"\"\r\nfi\r\n\r\n## download and install steamcmd\r\ncd \/tmp\r\nmkdir -p \/mnt\/server\/steamcmd\r\ncurl -sSL -o steamcmd.tar.gz https:\/\/steamcdn-a.akamaihd.net\/client\/installer\/steamcmd_linux.tar.gz\r\ntar -xzvf steamcmd.tar.gz -C \/mnt\/server\/steamcmd\r\n\r\nmkdir -p \/mnt\/server\/Engine\/Binaries\/ThirdParty\/SteamCMD\/Linux\r\ntar -xzvf steamcmd.tar.gz -C \/mnt\/server\/Engine\/Binaries\/ThirdParty\/SteamCMD\/Linux\r\nmkdir -p \/mnt\/server\/steamapps # Fix steamcmd disk write error when this folder is missing\r\ncd \/mnt\/server\/steamcmd\r\n\r\n# SteamCMD fails otherwise for some reason, even running as root.\r\n# This is changed at the end of the install process anyways.\r\nchown -R root:root \/mnt\r\nexport HOME=\/mnt\/server\r\n\r\n## install game using steamcmd\r\n.\/steamcmd.sh +force_install_dir \/mnt\/server +login ${STEAM_USER} ${STEAM_PASS} ${STEAM_AUTH} +app_update ${SRCDS_APPID} ${EXTRA_FLAGS} +quit ## other flags may be needed depending on install. looking at you cs 1.6\r\n\r\n## set up 32 bit libraries\r\nmkdir -p \/mnt\/server\/.steam\/sdk32\r\ncp -v linux32\/steamclient.so ..\/.steam\/sdk32\/steamclient.so\r\n\r\n## set up 64 bit libraries\r\nmkdir -p \/mnt\/server\/.steam\/sdk64\r\ncp -v linux64\/steamclient.so ..\/.steam\/sdk64\/steamclient.so\r\n\r\n## create a symbolic link for loading mods\r\ncd \/mnt\/server\/Engine\/Binaries\/ThirdParty\/SteamCMD\/Linux\r\nln -sf ..\/..\/..\/..\/..\/Steam\/steamapps steamapps\r\ncd \/mnt\/server", + "container": "ghcr.io\/pterodactyl\/installers:debian", "entrypoint": "bash" } }, @@ -48,7 +50,7 @@ }, { "name": "Server Map", - "description": "Available Maps: TheIsland, TheCenter, Ragnarok, ScorchedEarth_P, Aberration_P, Extinction, Valguero_P, Genesis, CrystalIsles, Gen2", + "description": "Available Maps: TheIsland, TheCenter, Ragnarok, ScorchedEarth_P, Aberration_P, Extinction, Valguero_P, Genesis, CrystalIsles, Gen2, LostIsland", "env_variable": "SERVER_MAP", "default_value": "TheIsland", "user_viewable": true, @@ -119,4 +121,4 @@ "rules": "nullable|string" } ] -} \ No newline at end of file +} diff --git a/database/Seeders/eggs/source-engine/egg-counter--strike--global-offensive.json b/database/Seeders/eggs/source-engine/egg-counter--strike--global-offensive.json index a9371a3ea..7c9a9779f 100644 --- a/database/Seeders/eggs/source-engine/egg-counter--strike--global-offensive.json +++ b/database/Seeders/eggs/source-engine/egg-counter--strike--global-offensive.json @@ -4,11 +4,14 @@ "version": "PTDL_v1", "update_url": null }, - "exported_at": "2021-06-05T16:19:30-04:00", + "exported_at": "2022-01-18T07:01:54-05:00", "name": "Counter-Strike: Global Offensive", "author": "support@pterodactyl.io", "description": "Counter-Strike: Global Offensive is a multiplayer first-person shooter video game developed by Hidden Path Entertainment and Valve Corporation.", - "features": null, + "features": [ + "gsl_token", + "steam_disk_space" + ], "images": [ "ghcr.io\/pterodactyl\/games:source" ], @@ -16,13 +19,13 @@ "startup": ".\/srcds_run -game csgo -console -port {{SERVER_PORT}} +ip 0.0.0.0 +map {{SRCDS_MAP}} -strictportbind -norestart +sv_setsteamaccount {{STEAM_ACC}}", "config": { "files": "{}", - "startup": "{\r\n \"done\": \"Connection to Steam servers successful\",\r\n \"userInteraction\": []\r\n}", - "logs": "{\r\n \"custom\": true,\r\n \"location\": \"logs\/latest.log\"\r\n}", + "startup": "{\r\n \"done\": \"Connection to Steam servers successful\"\r\n}", + "logs": "{}", "stop": "quit" }, "scripts": { "installation": { - "script": "#!\/bin\/bash\r\n# steamcmd Base Installation Script\r\n#\r\n# Server Files: \/mnt\/server\r\n# Image to install with is 'ubuntu:18.04'\r\napt -y update\r\napt -y --no-install-recommends install curl lib32gcc1 ca-certificates\r\n\r\n## just in case someone removed the defaults.\r\nif [ \"${STEAM_USER}\" == \"\" ]; then\r\n STEAM_USER=anonymous\r\n STEAM_PASS=\"\"\r\n STEAM_AUTH=\"\"\r\nfi\r\n\r\n## download and install steamcmd\r\ncd \/tmp\r\nmkdir -p \/mnt\/server\/steamcmd\r\ncurl -sSL -o steamcmd.tar.gz https:\/\/steamcdn-a.akamaihd.net\/client\/installer\/steamcmd_linux.tar.gz\r\ntar -xzvf steamcmd.tar.gz -C \/mnt\/server\/steamcmd\r\nmkdir -p \/mnt\/server\/steamapps # Fix steamcmd disk write error when this folder is missing\r\ncd \/mnt\/server\/steamcmd\r\n\r\n# SteamCMD fails otherwise for some reason, even running as root.\r\n# This is changed at the end of the install process anyways.\r\nchown -R root:root \/mnt\r\nexport HOME=\/mnt\/server\r\n\r\n## install game using steamcmd\r\n.\/steamcmd.sh +login ${STEAM_USER} ${STEAM_PASS} ${STEAM_AUTH} +force_install_dir \/mnt\/server +app_update ${SRCDS_APPID} ${EXTRA_FLAGS} +quit ## other flags may be needed depending on install. looking at you cs 1.6\r\n\r\n## set up 32 bit libraries\r\nmkdir -p \/mnt\/server\/.steam\/sdk32\r\ncp -v linux32\/steamclient.so ..\/.steam\/sdk32\/steamclient.so\r\n\r\n## set up 64 bit libraries\r\nmkdir -p \/mnt\/server\/.steam\/sdk64\r\ncp -v linux64\/steamclient.so ..\/.steam\/sdk64\/steamclient.so", + "script": "#!\/bin\/bash\r\n# steamcmd Base Installation Script\r\n#\r\n# Server Files: \/mnt\/server\r\n\r\n## just in case someone removed the defaults.\r\nif [ \"${STEAM_USER}\" == \"\" ]; then\r\n STEAM_USER=anonymous\r\n STEAM_PASS=\"\"\r\n STEAM_AUTH=\"\"\r\nfi\r\n\r\n## download and install steamcmd\r\ncd \/tmp\r\nmkdir -p \/mnt\/server\/steamcmd\r\ncurl -sSL -o steamcmd.tar.gz https:\/\/steamcdn-a.akamaihd.net\/client\/installer\/steamcmd_linux.tar.gz\r\ntar -xzvf steamcmd.tar.gz -C \/mnt\/server\/steamcmd\r\nmkdir -p \/mnt\/server\/steamapps # Fix steamcmd disk write error when this folder is missing\r\ncd \/mnt\/server\/steamcmd\r\n\r\n# SteamCMD fails otherwise for some reason, even running as root.\r\n# This is changed at the end of the install process anyways.\r\nchown -R root:root \/mnt\r\nexport HOME=\/mnt\/server\r\n\r\n## install game using steamcmd\r\n.\/steamcmd.sh +force_install_dir \/mnt\/server +login ${STEAM_USER} ${STEAM_PASS} ${STEAM_AUTH} +app_update ${SRCDS_APPID} ${EXTRA_FLAGS} +quit ## other flags may be needed depending on install. looking at you cs 1.6\r\n\r\n## set up 32 bit libraries\r\nmkdir -p \/mnt\/server\/.steam\/sdk32\r\ncp -v linux32\/steamclient.so ..\/.steam\/sdk32\/steamclient.so\r\n\r\n## set up 64 bit libraries\r\nmkdir -p \/mnt\/server\/.steam\/sdk64\r\ncp -v linux64\/steamclient.so ..\/.steam\/sdk64\/steamclient.so", "container": "ghcr.io\/pterodactyl\/installers:debian", "entrypoint": "bash" } diff --git a/database/Seeders/eggs/source-engine/egg-custom-source-engine-game.json b/database/Seeders/eggs/source-engine/egg-custom-source-engine-game.json index 881d31ec6..6c93175f5 100644 --- a/database/Seeders/eggs/source-engine/egg-custom-source-engine-game.json +++ b/database/Seeders/eggs/source-engine/egg-custom-source-engine-game.json @@ -4,11 +4,13 @@ "version": "PTDL_v1", "update_url": null }, - "exported_at": "2021-06-05T16:24:05-04:00", + "exported_at": "2022-01-18T07:03:08-05:00", "name": "Custom Source Engine Game", "author": "support@pterodactyl.io", "description": "This option allows modifying the startup arguments and other details to run a custom SRCDS based game on the panel.", - "features": null, + "features": [ + "steam_disk_space" + ], "images": [ "ghcr.io\/pterodactyl\/games:source" ], @@ -16,13 +18,13 @@ "startup": ".\/srcds_run -game {{SRCDS_GAME}} -console -port {{SERVER_PORT}} +map {{SRCDS_MAP}} +ip 0.0.0.0 -strictportbind -norestart", "config": { "files": "{}", - "startup": "{\r\n \"done\": \"gameserver Steam ID\",\r\n \"userInteraction\": []\r\n}", - "logs": "{\r\n \"custom\": true,\r\n \"location\": \"logs\/latest.log\"\r\n}", + "startup": "{\r\n \"done\": \"gameserver Steam ID\"\r\n}", + "logs": "{}", "stop": "quit" }, "scripts": { "installation": { - "script": "#!\/bin\/bash\r\n# steamcmd Base Installation Script\r\n#\r\n# Server Files: \/mnt\/server\r\n# Image to install with is 'debian:buster-slim'\r\n\r\n##\r\n#\r\n# Variables\r\n# STEAM_USER, STEAM_PASS, STEAM_AUTH - Steam user setup. If a user has 2fa enabled it will most likely fail due to timeout. Leave blank for anon install.\r\n# WINDOWS_INSTALL - if it's a windows server you want to install set to 1\r\n# SRCDS_APPID - steam app id ffound here - https:\/\/developer.valvesoftware.com\/wiki\/Dedicated_Servers_List\r\n# EXTRA_FLAGS - when a server has extra glas for things like beta installs or updates.\r\n#\r\n##\r\n\r\napt -y update\r\napt -y --no-install-recommends install curl lib32gcc1 ca-certificates\r\n\r\n## just in case someone removed the defaults.\r\nif [ \"${STEAM_USER}\" == \"\" ]; then\r\n echo -e \"steam user is not set.\\n\"\r\n echo -e \"Using anonymous user.\\n\"\r\n STEAM_USER=anonymous\r\n STEAM_PASS=\"\"\r\n STEAM_AUTH=\"\"\r\nelse\r\n echo -e \"user set to ${STEAM_USER}\"\r\nfi\r\n\r\n## download and install steamcmd\r\ncd \/tmp\r\nmkdir -p \/mnt\/server\/steamcmd\r\ncurl -sSL -o steamcmd.tar.gz https:\/\/steamcdn-a.akamaihd.net\/client\/installer\/steamcmd_linux.tar.gz\r\ntar -xzvf steamcmd.tar.gz -C \/mnt\/server\/steamcmd\r\nmkdir -p \/mnt\/server\/steamapps # Fix steamcmd disk write error when this folder is missing\r\ncd \/mnt\/server\/steamcmd\r\n\r\n# SteamCMD fails otherwise for some reason, even running as root.\r\n# This is changed at the end of the install process anyways.\r\nchown -R root:root \/mnt\r\nexport HOME=\/mnt\/server\r\n\r\n## install game using steamcmd\r\n.\/steamcmd.sh +login ${STEAM_USER} ${STEAM_PASS} ${STEAM_AUTH} $( [[ \"${WINDOWS_INSTALL}\" == \"1\" ]] && printf %s '+@sSteamCmdForcePlatformType windows' ) +force_install_dir \/mnt\/server +app_update ${SRCDS_APPID} ${EXTRA_FLAGS} validate +quit ## other flags may be needed depending on install. looking at you cs 1.6\r\n\r\n## set up 32 bit libraries\r\nmkdir -p \/mnt\/server\/.steam\/sdk32\r\ncp -v linux32\/steamclient.so ..\/.steam\/sdk32\/steamclient.so\r\n\r\n## set up 64 bit libraries\r\nmkdir -p \/mnt\/server\/.steam\/sdk64\r\ncp -v linux64\/steamclient.so ..\/.steam\/sdk64\/steamclient.so", + "script": "#!\/bin\/bash\r\n# steamcmd Base Installation Script\r\n#\r\n# Server Files: \/mnt\/server\r\n\r\n##\r\n#\r\n# Variables\r\n# STEAM_USER, STEAM_PASS, STEAM_AUTH - Steam user setup. If a user has 2fa enabled it will most likely fail due to timeout. Leave blank for anon install.\r\n# WINDOWS_INSTALL - if it's a windows server you want to install set to 1\r\n# SRCDS_APPID - steam app id ffound here - https:\/\/developer.valvesoftware.com\/wiki\/Dedicated_Servers_List\r\n# EXTRA_FLAGS - when a server has extra glas for things like beta installs or updates.\r\n#\r\n##\r\n\r\n\r\n## just in case someone removed the defaults.\r\nif [ \"${STEAM_USER}\" == \"\" ]; then\r\n echo -e \"steam user is not set.\\n\"\r\n echo -e \"Using anonymous user.\\n\"\r\n STEAM_USER=anonymous\r\n STEAM_PASS=\"\"\r\n STEAM_AUTH=\"\"\r\nelse\r\n echo -e \"user set to ${STEAM_USER}\"\r\nfi\r\n\r\n## download and install steamcmd\r\ncd \/tmp\r\nmkdir -p \/mnt\/server\/steamcmd\r\ncurl -sSL -o steamcmd.tar.gz https:\/\/steamcdn-a.akamaihd.net\/client\/installer\/steamcmd_linux.tar.gz\r\ntar -xzvf steamcmd.tar.gz -C \/mnt\/server\/steamcmd\r\nmkdir -p \/mnt\/server\/steamapps # Fix steamcmd disk write error when this folder is missing\r\ncd \/mnt\/server\/steamcmd\r\n\r\n# SteamCMD fails otherwise for some reason, even running as root.\r\n# This is changed at the end of the install process anyways.\r\nchown -R root:root \/mnt\r\nexport HOME=\/mnt\/server\r\n\r\n## install game using steamcmd\r\n.\/steamcmd.sh +force_install_dir \/mnt\/server +login ${STEAM_USER} ${STEAM_PASS} ${STEAM_AUTH} $( [[ \"${WINDOWS_INSTALL}\" == \"1\" ]] && printf %s '+@sSteamCmdForcePlatformType windows' ) +app_update ${SRCDS_APPID} ${EXTRA_FLAGS} validate +quit ## other flags may be needed depending on install. looking at you cs 1.6\r\n\r\n## set up 32 bit libraries\r\nmkdir -p \/mnt\/server\/.steam\/sdk32\r\ncp -v linux32\/steamclient.so ..\/.steam\/sdk32\/steamclient.so\r\n\r\n## set up 64 bit libraries\r\nmkdir -p \/mnt\/server\/.steam\/sdk64\r\ncp -v linux64\/steamclient.so ..\/.steam\/sdk64\/steamclient.so", "container": "ghcr.io\/pterodactyl\/installers:debian", "entrypoint": "bash" } @@ -83,4 +85,4 @@ "rules": "nullable|string" } ] -} \ No newline at end of file +} diff --git a/database/Seeders/eggs/source-engine/egg-garrys-mod.json b/database/Seeders/eggs/source-engine/egg-garrys-mod.json index 93de416a6..82ec08fee 100644 --- a/database/Seeders/eggs/source-engine/egg-garrys-mod.json +++ b/database/Seeders/eggs/source-engine/egg-garrys-mod.json @@ -4,11 +4,14 @@ "version": "PTDL_v1", "update_url": null }, - "exported_at": "2021-08-27T00:12:31-04:00", + "exported_at": "2022-01-18T07:04:20-05:00", "name": "Garrys Mod", "author": "support@pterodactyl.io", "description": "Garrys Mod, is a sandbox physics game created by Garry Newman, and developed by his company, Facepunch Studios.", - "features": null, + "features": [ + "gsl_token", + "steam_disk_space" + ], "images": [ "ghcr.io\/pterodactyl\/games:source" ], @@ -22,7 +25,7 @@ }, "scripts": { "installation": { - "script": "#!\/bin\/bash\r\n# steamcmd Base Installation Script\r\n#\r\n# Server Files: \/mnt\/server\r\n# Image to install with is 'debian:buster-slim'\r\napt -y update\r\napt -y --no-install-recommends install curl lib32gcc1 ca-certificates\r\n\r\n## just in case someone removed the defaults.\r\nif [ \"${STEAM_USER}\" == \"\" ]; then\r\n echo -e \"steam user is not set.\\n\"\r\n echo -e \"Using anonymous user.\\n\"\r\n STEAM_USER=anonymous\r\n STEAM_PASS=\"\"\r\n STEAM_AUTH=\"\"\r\nelse\r\n echo -e \"user set to ${STEAM_USER}\"\r\nfi\r\n\r\n## download and install steamcmd\r\ncd \/tmp\r\nmkdir -p \/mnt\/server\/steamcmd\r\ncurl -sSL -o steamcmd.tar.gz https:\/\/steamcdn-a.akamaihd.net\/client\/installer\/steamcmd_linux.tar.gz\r\ntar -xzvf steamcmd.tar.gz -C \/mnt\/server\/steamcmd\r\nmkdir -p \/mnt\/server\/steamapps # Fix steamcmd disk write error when this folder is missing\r\ncd \/mnt\/server\/steamcmd\r\n\r\n# SteamCMD fails otherwise for some reason, even running as root.\r\n# This is changed at the end of the install process anyways.\r\nchown -R root:root \/mnt\r\nexport HOME=\/mnt\/server\r\n\r\n## install game using steamcmd\r\n.\/steamcmd.sh +login ${STEAM_USER} ${STEAM_PASS} ${STEAM_AUTH} $( [[ \"${WINDOWS_INSTALL}\" == \"1\" ]] && printf %s '+@sSteamCmdForcePlatformType windows' ) +force_install_dir \/mnt\/server +app_update ${SRCDS_APPID} ${EXTRA_FLAGS} validate +quit ## other flags may be needed depending on install. looking at you cs 1.6\r\n\r\n## set up 32 bit libraries\r\nmkdir -p \/mnt\/server\/.steam\/sdk32\r\ncp -v linux32\/steamclient.so ..\/.steam\/sdk32\/steamclient.so\r\n\r\n## set up 64 bit libraries\r\nmkdir -p \/mnt\/server\/.steam\/sdk64\r\ncp -v linux64\/steamclient.so ..\/.steam\/sdk64\/steamclient.so\r\n\r\n# Creating needed default files for the game\r\ncd \/mnt\/server\/garrysmod\/lua\/autorun\/server\r\necho '\r\n-- Docs: https:\/\/wiki.garrysmod.com\/page\/resource\/AddWorkshop\r\n-- Place the ID of the workshop addon you want to be downloaded to people who join your server, not the collection ID\r\n-- Use https:\/\/beta.configcreator.com\/create\/gmod\/resources.lua to easily create a list based on your collection ID\r\n\r\nresource.AddWorkshop( \"\" )\r\n' > workshop.lua\r\n\r\ncd \/mnt\/server\/garrysmod\/cfg\r\necho '\r\n\/\/ Please do not set RCon in here, use the startup parameters.\r\n\r\nhostname\t\t\"New Gmod Server\"\r\nsv_password\t\t\"\"\r\nsv_loadingurl \"\"\r\nsv_downloadurl \"\"\r\n\r\n\/\/ Steam Server List Settings\r\n\/\/ sv_location \"eu\"\r\nsv_region \"255\"\r\nsv_lan \"0\"\r\nsv_max_queries_sec_global \"30000\"\r\nsv_max_queries_window \"45\"\r\nsv_max_queries_sec \"5\"\r\n\r\n\/\/ Server Limits\r\nsbox_maxprops\t\t100\r\nsbox_maxragdolls\t5\r\nsbox_maxnpcs\t\t10\r\nsbox_maxballoons\t10\r\nsbox_maxeffects\t\t10\r\nsbox_maxdynamite\t10\r\nsbox_maxlamps\t\t10\r\nsbox_maxthrusters\t10\r\nsbox_maxwheels\t\t10\r\nsbox_maxhoverballs\t10\r\nsbox_maxvehicles\t20\r\nsbox_maxbuttons\t\t10\r\nsbox_maxsents\t\t20\r\nsbox_maxemitters\t5\r\nsbox_godmode\t\t0\r\nsbox_noclip\t\t 0\r\n\r\n\/\/ Network Settings - Please keep these set to default.\r\n\r\nsv_minrate\t\t75000\r\nsv_maxrate\t\t0\r\ngmod_physiterations\t2\r\nnet_splitpacket_maxrate\t45000\r\ndecalfrequency\t\t12 \r\n\r\n\/\/ Execute Ban Files - Please do not edit\r\nexec banned_ip.cfg \r\nexec banned_user.cfg \r\n\r\n\/\/ Add custom lines under here\r\n' > server.cfg", + "script": "#!\/bin\/bash\r\n# steamcmd Base Installation Script\r\n#\r\n# Server Files: \/mnt\/server\r\n\r\n## just in case someone removed the defaults.\r\nif [ \"${STEAM_USER}\" == \"\" ]; then\r\n echo -e \"steam user is not set.\\n\"\r\n echo -e \"Using anonymous user.\\n\"\r\n STEAM_USER=anonymous\r\n STEAM_PASS=\"\"\r\n STEAM_AUTH=\"\"\r\nelse\r\n echo -e \"user set to ${STEAM_USER}\"\r\nfi\r\n\r\n## download and install steamcmd\r\ncd \/tmp\r\nmkdir -p \/mnt\/server\/steamcmd\r\ncurl -sSL -o steamcmd.tar.gz https:\/\/steamcdn-a.akamaihd.net\/client\/installer\/steamcmd_linux.tar.gz\r\ntar -xzvf steamcmd.tar.gz -C \/mnt\/server\/steamcmd\r\nmkdir -p \/mnt\/server\/steamapps # Fix steamcmd disk write error when this folder is missing\r\ncd \/mnt\/server\/steamcmd\r\n\r\n# SteamCMD fails otherwise for some reason, even running as root.\r\n# This is changed at the end of the install process anyways.\r\nchown -R root:root \/mnt\r\nexport HOME=\/mnt\/server\r\n\r\n## install game using steamcmd\r\n.\/steamcmd.sh +force_install_dir \/mnt\/server +login ${STEAM_USER} ${STEAM_PASS} ${STEAM_AUTH} $( [[ \"${WINDOWS_INSTALL}\" == \"1\" ]] && printf %s '+@sSteamCmdForcePlatformType windows' ) +app_update ${SRCDS_APPID} ${EXTRA_FLAGS} validate +quit ## other flags may be needed depending on install. looking at you cs 1.6\r\n\r\n## set up 32 bit libraries\r\nmkdir -p \/mnt\/server\/.steam\/sdk32\r\ncp -v linux32\/steamclient.so ..\/.steam\/sdk32\/steamclient.so\r\n\r\n## set up 64 bit libraries\r\nmkdir -p \/mnt\/server\/.steam\/sdk64\r\ncp -v linux64\/steamclient.so ..\/.steam\/sdk64\/steamclient.so\r\n\r\n# Creating needed default files for the game\r\ncd \/mnt\/server\/garrysmod\/lua\/autorun\/server\r\necho '\r\n-- Docs: https:\/\/wiki.garrysmod.com\/page\/resource\/AddWorkshop\r\n-- Place the ID of the workshop addon you want to be downloaded to people who join your server, not the collection ID\r\n-- Use https:\/\/beta.configcreator.com\/create\/gmod\/resources.lua to easily create a list based on your collection ID\r\n\r\nresource.AddWorkshop( \"\" )\r\n' > workshop.lua\r\n\r\ncd \/mnt\/server\/garrysmod\/cfg\r\necho '\r\n\/\/ Please do not set RCon in here, use the startup parameters.\r\n\r\nhostname\t\t\"New Gmod Server\"\r\nsv_password\t\t\"\"\r\nsv_loadingurl \"\"\r\nsv_downloadurl \"\"\r\n\r\n\/\/ Steam Server List Settings\r\n\/\/ sv_location \"eu\"\r\nsv_region \"255\"\r\nsv_lan \"0\"\r\nsv_max_queries_sec_global \"30000\"\r\nsv_max_queries_window \"45\"\r\nsv_max_queries_sec \"5\"\r\n\r\n\/\/ Server Limits\r\nsbox_maxprops\t\t100\r\nsbox_maxragdolls\t5\r\nsbox_maxnpcs\t\t10\r\nsbox_maxballoons\t10\r\nsbox_maxeffects\t\t10\r\nsbox_maxdynamite\t10\r\nsbox_maxlamps\t\t10\r\nsbox_maxthrusters\t10\r\nsbox_maxwheels\t\t10\r\nsbox_maxhoverballs\t10\r\nsbox_maxvehicles\t20\r\nsbox_maxbuttons\t\t10\r\nsbox_maxsents\t\t20\r\nsbox_maxemitters\t5\r\nsbox_godmode\t\t0\r\nsbox_noclip\t\t 0\r\n\r\n\/\/ Network Settings - Please keep these set to default.\r\n\r\nsv_minrate\t\t75000\r\nsv_maxrate\t\t0\r\ngmod_physiterations\t2\r\nnet_splitpacket_maxrate\t45000\r\ndecalfrequency\t\t12 \r\n\r\n\/\/ Execute Ban Files - Please do not edit\r\nexec banned_ip.cfg \r\nexec banned_user.cfg \r\n\r\n\/\/ Add custom lines under here\r\n' > server.cfg", "container": "ghcr.io\/pterodactyl\/installers:debian", "entrypoint": "bash" } diff --git a/database/Seeders/eggs/source-engine/egg-insurgency.json b/database/Seeders/eggs/source-engine/egg-insurgency.json index eed4b9e40..69d182f3d 100644 --- a/database/Seeders/eggs/source-engine/egg-insurgency.json +++ b/database/Seeders/eggs/source-engine/egg-insurgency.json @@ -4,11 +4,13 @@ "version": "PTDL_v1", "update_url": null }, - "exported_at": "2021-06-06T10:34:11-04:00", + "exported_at": "2022-01-18T07:07:27-05:00", "name": "Insurgency", "author": "support@pterodactyl.io", "description": "Take to the streets for intense close quarters combat, where a team's survival depends upon securing crucial strongholds and destroying enemy supply in this multiplayer and cooperative Source Engine based experience.", - "features": null, + "features": [ + "steam_disk_space" + ], "images": [ "ghcr.io\/pterodactyl\/games:source" ], @@ -17,12 +19,12 @@ "config": { "files": "{}", "startup": "{\r\n \"done\": \"gameserver Steam ID\"\r\n}", - "logs": "{\r\n \"custom\": true,\r\n \"location\": \"logs\/latest.log\"\r\n}", + "logs": "{}", "stop": "quit" }, "scripts": { "installation": { - "script": "#!\/bin\/bash\r\n# steamcmd Base Installation Script\r\n#\r\n# Server Files: \/mnt\/server\r\n\r\n## download and install steamcmd\r\ncd \/tmp\r\nmkdir -p \/mnt\/server\/steamcmd\r\ncurl -sSL -o steamcmd.tar.gz https:\/\/steamcdn-a.akamaihd.net\/client\/installer\/steamcmd_linux.tar.gz\r\ntar -xzvf steamcmd.tar.gz -C \/mnt\/server\/steamcmd\r\ncd \/mnt\/server\/steamcmd\r\n\r\n# SteamCMD fails otherwise for some reason, even running as root.\r\n# This is changed at the end of the install process anyways.\r\nchown -R root:root \/mnt\r\nexport HOME=\/mnt\/server\r\n\r\n## install game using steamcmd\r\n.\/steamcmd.sh +login anonymous +force_install_dir \/mnt\/server +app_update ${SRCDS_APPID} ${EXTRA_FLAGS} +quit\r\n\r\n## set up 32 bit libraries\r\nmkdir -p \/mnt\/server\/.steam\/sdk32\r\ncp -v linux32\/steamclient.so ..\/.steam\/sdk32\/steamclient.so\r\n\r\n## set up 64 bit libraries\r\nmkdir -p \/mnt\/server\/.steam\/sdk64\r\ncp -v linux64\/steamclient.so ..\/.steam\/sdk64\/steamclient.so", + "script": "#!\/bin\/bash\r\n# steamcmd Base Installation Script\r\n#\r\n# Server Files: \/mnt\/server\r\n\r\n## download and install steamcmd\r\ncd \/tmp\r\nmkdir -p \/mnt\/server\/steamcmd\r\ncurl -sSL -o steamcmd.tar.gz https:\/\/steamcdn-a.akamaihd.net\/client\/installer\/steamcmd_linux.tar.gz\r\ntar -xzvf steamcmd.tar.gz -C \/mnt\/server\/steamcmd\r\ncd \/mnt\/server\/steamcmd\r\n\r\n# SteamCMD fails otherwise for some reason, even running as root.\r\n# This is changed at the end of the install process anyways.\r\nchown -R root:root \/mnt\r\nexport HOME=\/mnt\/server\r\n\r\n## install game using steamcmd\r\n.\/steamcmd.sh +force_install_dir \/mnt\/server +login anonymous +app_update ${SRCDS_APPID} ${EXTRA_FLAGS} +quit\r\n\r\n## set up 32 bit libraries\r\nmkdir -p \/mnt\/server\/.steam\/sdk32\r\ncp -v linux32\/steamclient.so ..\/.steam\/sdk32\/steamclient.so\r\n\r\n## set up 64 bit libraries\r\nmkdir -p \/mnt\/server\/.steam\/sdk64\r\ncp -v linux64\/steamclient.so ..\/.steam\/sdk64\/steamclient.so", "container": "ghcr.io\/pterodactyl\/installers:debian", "entrypoint": "bash" } @@ -47,4 +49,4 @@ "rules": "required|regex:\/^(\\w{1,20})$\/" } ] -} \ No newline at end of file +} diff --git a/database/Seeders/eggs/source-engine/egg-team-fortress2.json b/database/Seeders/eggs/source-engine/egg-team-fortress2.json index c1bceaf89..6785984ed 100644 --- a/database/Seeders/eggs/source-engine/egg-team-fortress2.json +++ b/database/Seeders/eggs/source-engine/egg-team-fortress2.json @@ -4,25 +4,28 @@ "version": "PTDL_v1", "update_url": null }, - "exported_at": "2021-06-05T16:34:53-04:00", + "exported_at": "2022-01-30T14:09:22-05:00", "name": "Team Fortress 2", "author": "support@pterodactyl.io", "description": "Team Fortress 2 is a team-based first-person shooter multiplayer video game developed and published by Valve Corporation. It is the sequel to the 1996 mod Team Fortress for Quake and its 1999 remake.", - "features": null, + "features": [ + "gsl_token", + "steam_disk_space" + ], "images": [ "ghcr.io\/pterodactyl\/games:source" ], "file_denylist": [], - "startup": ".\/srcds_run -game tf -console -port {{SERVER_PORT}} +map {{SRCDS_MAP}} +ip 0.0.0.0 -strictportbind -norestart", + "startup": ".\/srcds_run -game tf -console -port {{SERVER_PORT}} +map {{SRCDS_MAP}} +ip 0.0.0.0 -strictportbind -norestart +sv_setsteamaccount {{STEAM_ACC}}", "config": { "files": "{}", - "startup": "{\r\n \"done\": \"gameserver Steam ID\",\r\n \"userInteraction\": []\r\n}", - "logs": "{\r\n \"custom\": true,\r\n \"location\": \"logs\/latest.log\"\r\n}", + "startup": "{\r\n \"done\": \"gameserver Steam ID\"\r\n}", + "logs": "{}", "stop": "quit" }, "scripts": { "installation": { - "script": "#!\/bin\/bash\r\n# steamcmd Base Installation Script\r\n#\r\n# Server Files: \/mnt\/server\r\n# Image to install with is 'debian:buster-slim'\r\n\r\n##\r\n#\r\n# Variables\r\n# STEAM_USER, STEAM_PASS, STEAM_AUTH - Steam user setup. If a user has 2fa enabled it will most likely fail due to timeout. Leave blank for anon install.\r\n# WINDOWS_INSTALL - if it's a windows server you want to install set to 1\r\n# SRCDS_APPID - steam app id ffound here - https:\/\/developer.valvesoftware.com\/wiki\/Dedicated_Servers_List\r\n# EXTRA_FLAGS - when a server has extra glas for things like beta installs or updates.\r\n#\r\n##\r\n\r\napt -y update\r\napt -y --no-install-recommends install curl lib32gcc1 ca-certificates\r\n\r\n## just in case someone removed the defaults.\r\nif [ \"${STEAM_USER}\" == \"\" ]; then\r\n echo -e \"steam user is not set.\\n\"\r\n echo -e \"Using anonymous user.\\n\"\r\n STEAM_USER=anonymous\r\n STEAM_PASS=\"\"\r\n STEAM_AUTH=\"\"\r\nelse\r\n echo -e \"user set to ${STEAM_USER}\"\r\nfi\r\n\r\n## download and install steamcmd\r\ncd \/tmp\r\nmkdir -p \/mnt\/server\/steamcmd\r\ncurl -sSL -o steamcmd.tar.gz https:\/\/steamcdn-a.akamaihd.net\/client\/installer\/steamcmd_linux.tar.gz\r\ntar -xzvf steamcmd.tar.gz -C \/mnt\/server\/steamcmd\r\nmkdir -p \/mnt\/server\/steamapps # Fix steamcmd disk write error when this folder is missing\r\ncd \/mnt\/server\/steamcmd\r\n\r\n# SteamCMD fails otherwise for some reason, even running as root.\r\n# This is changed at the end of the install process anyways.\r\nchown -R root:root \/mnt\r\nexport HOME=\/mnt\/server\r\n\r\n## install game using steamcmd\r\n.\/steamcmd.sh +login ${STEAM_USER} ${STEAM_PASS} ${STEAM_AUTH} $( [[ \"${WINDOWS_INSTALL}\" == \"1\" ]] && printf %s '+@sSteamCmdForcePlatformType windows' ) +force_install_dir \/mnt\/server +app_update ${SRCDS_APPID} ${EXTRA_FLAGS} validate +quit ## other flags may be needed depending on install. looking at you cs 1.6\r\n\r\n## set up 32 bit libraries\r\nmkdir -p \/mnt\/server\/.steam\/sdk32\r\ncp -v linux32\/steamclient.so ..\/.steam\/sdk32\/steamclient.so\r\n\r\n## set up 64 bit libraries\r\nmkdir -p \/mnt\/server\/.steam\/sdk64\r\ncp -v linux64\/steamclient.so ..\/.steam\/sdk64\/steamclient.so", + "script": "#!\/bin\/bash\r\n# steamcmd Base Installation Script\r\n#\r\n# Server Files: \/mnt\/server\r\n# Image to install with is 'debian:buster-slim'\r\n\r\n##\r\n#\r\n# Variables\r\n# STEAM_USER, STEAM_PASS, STEAM_AUTH - Steam user setup. If a user has 2fa enabled it will most likely fail due to timeout. Leave blank for anon install.\r\n# WINDOWS_INSTALL - if it's a windows server you want to install set to 1\r\n# SRCDS_APPID - steam app id ffound here - https:\/\/developer.valvesoftware.com\/wiki\/Dedicated_Servers_List\r\n# EXTRA_FLAGS - when a server has extra glas for things like beta installs or updates.\r\n#\r\n##\r\n\r\n## just in case someone removed the defaults.\r\nif [ \"${STEAM_USER}\" == \"\" ]; then\r\n echo -e \"steam user is not set.\\n\"\r\n echo -e \"Using anonymous user.\\n\"\r\n STEAM_USER=anonymous\r\n STEAM_PASS=\"\"\r\n STEAM_AUTH=\"\"\r\nelse\r\n echo -e \"user set to ${STEAM_USER}\"\r\nfi\r\n\r\n## download and install steamcmd\r\ncd \/tmp\r\nmkdir -p \/mnt\/server\/steamcmd\r\ncurl -sSL -o steamcmd.tar.gz https:\/\/steamcdn-a.akamaihd.net\/client\/installer\/steamcmd_linux.tar.gz\r\ntar -xzvf steamcmd.tar.gz -C \/mnt\/server\/steamcmd\r\nmkdir -p \/mnt\/server\/steamapps # Fix steamcmd disk write error when this folder is missing\r\ncd \/mnt\/server\/steamcmd\r\n\r\n# SteamCMD fails otherwise for some reason, even running as root.\r\n# This is changed at the end of the install process anyways.\r\nchown -R root:root \/mnt\r\nexport HOME=\/mnt\/server\r\n\r\n## install game using steamcmd\r\n.\/steamcmd.sh +force_install_dir \/mnt\/server +login ${STEAM_USER} ${STEAM_PASS} ${STEAM_AUTH} $( [[ \"${WINDOWS_INSTALL}\" == \"1\" ]] && printf %s '+@sSteamCmdForcePlatformType windows' ) +app_update ${SRCDS_APPID} ${EXTRA_FLAGS} validate +quit ## other flags may be needed depending on install. looking at you cs 1.6\r\n\r\n## set up 32 bit libraries\r\nmkdir -p \/mnt\/server\/.steam\/sdk32\r\ncp -v linux32\/steamclient.so ..\/.steam\/sdk32\/steamclient.so\r\n\r\n## set up 64 bit libraries\r\nmkdir -p \/mnt\/server\/.steam\/sdk64\r\ncp -v linux64\/steamclient.so ..\/.steam\/sdk64\/steamclient.so", "container": "ghcr.io\/pterodactyl\/installers:debian", "entrypoint": "bash" } @@ -45,6 +48,15 @@ "user_viewable": true, "user_editable": true, "rules": "required|regex:\/^(\\w{1,20})$\/" + }, + { + "name": "Steam", + "description": "The Steam Game Server Login Token to display servers publicly. Generate one at https:\/\/steamcommunity.com\/dev\/managegameservers", + "env_variable": "STEAM_ACC", + "default_value": "", + "user_viewable": true, + "user_editable": true, + "rules": "required|string|alpha_num|size:32" } ] } diff --git a/database/migrations/2021_10_23_185304_drop_config_logs_column_from_eggs_table.php b/database/migrations/2021_10_23_185304_drop_config_logs_column_from_eggs_table.php new file mode 100644 index 000000000..aa9e7511c --- /dev/null +++ b/database/migrations/2021_10_23_185304_drop_config_logs_column_from_eggs_table.php @@ -0,0 +1,32 @@ +dropColumn('config_logs'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('eggs', function (Blueprint $table) { + $table->text('config_logs')->nullable()->after('docker_image'); + }); + } +} diff --git a/database/migrations/2021_10_23_202643_update_default_values_for_eggs.php b/database/migrations/2021_10_23_202643_update_default_values_for_eggs.php new file mode 100644 index 000000000..b35fe83e9 --- /dev/null +++ b/database/migrations/2021_10_23_202643_update_default_values_for_eggs.php @@ -0,0 +1,33 @@ +string('script_container')->default('ghcr.io/pterodactyl/installers:alpine')->after('startup')->change(); + $table->string('script_entry')->default('/bin/ash')->after('copy_script_from')->change(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('eggs', function (Blueprint $table) { + // You are stuck with the new values because I am too lazy to revert them :) + }); + } +} diff --git a/database/migrations/2021_11_01_180130_make_startup_field_nullable_on_servers_table.php b/database/migrations/2021_11_01_180130_make_startup_field_nullable_on_servers_table.php new file mode 100644 index 000000000..7cd0f1002 --- /dev/null +++ b/database/migrations/2021_11_01_180130_make_startup_field_nullable_on_servers_table.php @@ -0,0 +1,32 @@ +text('startup')->default(null)->nullable()->change(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('servers', function (Blueprint $table) { + $table->text('startup')->change(); + }); + } +} diff --git a/database/migrations/2022_01_25_030847_drop_google_analytics.php b/database/migrations/2022_01_25_030847_drop_google_analytics.php new file mode 100644 index 000000000..5daf0bc39 --- /dev/null +++ b/database/migrations/2022_01_25_030847_drop_google_analytics.php @@ -0,0 +1,31 @@ +where('key', 'settings::app:analytics')->delete(); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + DB::table('settings')->insert( + [ + 'key' => 'settings::app:analytics', + ] + ); + } +} diff --git a/docker-compose.example.yml b/docker-compose.example.yml index 42b02dbad..1a0159ffe 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -34,7 +34,7 @@ x-common: # services: database: - image: library/mysql:8.0 + image: mariadb:10.5 restart: always command: --default-authentication-plugin=mysql_native_password volumes: @@ -57,7 +57,7 @@ services: - cache volumes: - "/srv/pterodactyl/var/:/app/var/" - - "/srv/pterodactyl/nginx/:/etc/nginx/conf.d/" + - "/srv/pterodactyl/nginx/:/etc/nginx/http.d/" - "/srv/pterodactyl/certs/:/etc/letsencrypt/" - "/srv/pterodactyl/logs/:/app/storage/logs" environment: @@ -70,6 +70,7 @@ services: QUEUE_DRIVER: "redis" REDIS_HOST: "cache" DB_HOST: "database" + DB_PORT: "3306" networks: default: ipam: diff --git a/package.json b/package.json index b583f1d26..c7ed805f3 100644 --- a/package.json +++ b/package.json @@ -7,50 +7,49 @@ "watch": "$npm_execpath cross-env NODE_ENV=development $npm_execpath webpack --watch --progress", "build": "$npm_execpath cross-env NODE_ENV=development $npm_execpath webpack --progress", "build:production": "$npm_execpath run clean && $npm_execpath cross-env NODE_ENV=production $npm_execpath webpack --mode production", - "serve": "$npm_execpath run clean && $npm_execpath cross-env PUBLIC_PATH=https://pterodactyl.test:8080 NODE_ENV=development $npm_execpath webpack-dev-server --host 0.0.0.0 --hot --https --key /etc/ssl/private/pterodactyl.test-key.pem --cert /etc/ssl/private/pterodactyl.test.pem" + "serve": "$npm_execpath run clean && $npm_execpath cross-env WEBPACK_PUBLIC_PATH=/webpack@hmr/ NODE_ENV=development $npm_execpath webpack-dev-server --host 0.0.0.0 --port 8080 --public https://pterodactyl.test --hot" }, "dependencies": { - "@": "link:./resources/scripts", - "@codemirror/autocomplete": "^0.19.3", + "@codemirror/autocomplete": "^0.19.0", "@codemirror/closebrackets": "^0.19.0", - "@codemirror/commands": "^0.19.2", + "@codemirror/commands": "^0.19.0", "@codemirror/comment": "^0.19.0", "@codemirror/fold": "^0.19.0", - "@codemirror/gutter": "^0.19.1", - "@codemirror/highlight": "^0.19.4", + "@codemirror/gutter": "^0.19.0", + "@codemirror/highlight": "^0.19.0", "@codemirror/history": "^0.19.0", - "@codemirror/lang-cpp": "^0.19.1", - "@codemirror/lang-css": "^0.19.1", - "@codemirror/lang-html": "^0.19.1", - "@codemirror/lang-java": "^0.19.1", - "@codemirror/lang-javascript": "^0.19.1", - "@codemirror/lang-json": "^0.19.1", - "@codemirror/lang-markdown": "^0.19.1", - "@codemirror/lang-rust": "^0.19.1", - "@codemirror/lang-sql": "^0.19.3", - "@codemirror/lang-xml": "^0.19.1", - "@codemirror/language": "^0.19.2", + "@codemirror/lang-cpp": "^0.19.0", + "@codemirror/lang-css": "^0.19.0", + "@codemirror/lang-html": "^0.19.0", + "@codemirror/lang-java": "^0.19.0", + "@codemirror/lang-javascript": "^0.19.0", + "@codemirror/lang-json": "^0.19.0", + "@codemirror/lang-markdown": "^0.19.0", + "@codemirror/lang-rust": "^0.19.0", + "@codemirror/lang-sql": "^0.19.0", + "@codemirror/lang-xml": "^0.19.0", + "@codemirror/language": "^0.19.0", "@codemirror/legacy-modes": "^0.19.0", "@codemirror/lint": "^0.19.0", - "@codemirror/matchbrackets": "^0.19.1", + "@codemirror/matchbrackets": "^0.19.0", "@codemirror/rectangular-selection": "^0.19.0", "@codemirror/search": "^0.19.0", - "@codemirror/state": "^0.19.1", - "@codemirror/stream-parser": "^0.19.1", - "@codemirror/theme-one-dark": "^0.19.0", - "@codemirror/view": "^0.19.4", + "@codemirror/state": "^0.19.0", + "@codemirror/stream-parser": "^0.19.0", + "@codemirror/view": "^0.19.0", "@fortawesome/fontawesome-svg-core": "^1.2.36", "@fortawesome/free-brands-svg-icons": "^5.15.4", "@fortawesome/free-regular-svg-icons": "^5.15.4", "@fortawesome/free-solid-svg-icons": "^5.15.4", - "@fortawesome/react-fontawesome": "^0.1.15", + "@fortawesome/react-fontawesome": "^0.1.16", + "@heroicons/react": "^1.0.5", "@hot-loader/react-dom": "^16.14.0", "axios": "^0.21.4", "chart.js": "^2.9.4", - "date-fns": "^2.23.0", + "date-fns": "^2.25.0", "debounce": "^1.2.1", "deepmerge": "^4.2.2", - "easy-peasy": "^5.0.3", + "easy-peasy": "^5.0.4", "events": "^3.3.0", "feature": "link:./resources/scripts/components/server/features", "formik": "^2.2.9", @@ -68,85 +67,85 @@ "react-ga": "^3.3.0", "react-google-recaptcha": "^2.1.0", "react-hot-loader": "^4.13.0", - "react-i18next": "^11.11.4", + "react-i18next": "^11.14.2", "react-router": "^5.2.1", - "react-router-dom": "^5.2.1", + "react-router-dom": "^5.3.0", "react-select": "^4.3.1", "react-transition-group": "^4.4.2", "reaptcha": "^1.7.3", "sockette": "^2.0.6", - "styled-components": "^5.3.1", + "styled-components": "^5.3.3", "styled-components-breakpoint": "^3.0.0-preview.20", "swr": "^1.0.1", "uuid": "^3.4.0", - "xterm": "^4.14.1", + "xterm": "^4.15.0", "xterm-addon-attach": "^0.6.0", "xterm-addon-fit": "^0.5.0", "xterm-addon-search": "^0.8.1", "xterm-addon-search-bar": "^0.2.0", "xterm-addon-web-links": "^0.4.0", - "yup": "^0.32.9" + "yup": "^0.32.11" }, "devDependencies": { - "@babel/core": "^7.14.5", - "@babel/plugin-proposal-class-properties": "^7.14.5", - "@babel/plugin-proposal-nullish-coalescing-operator": "^7.14.5", - "@babel/plugin-proposal-object-rest-spread": "^7.14.5", - "@babel/plugin-proposal-optional-chaining": "^7.14.5", + "@babel/core": "^7.16.0", + "@babel/plugin-proposal-class-properties": "^7.16.0", + "@babel/plugin-proposal-nullish-coalescing-operator": "^7.16.0", + "@babel/plugin-proposal-object-rest-spread": "^7.16.0", + "@babel/plugin-proposal-optional-chaining": "^7.16.0", "@babel/plugin-syntax-dynamic-import": "^7.8.3", - "@babel/plugin-transform-react-jsx": "^7.14.5", - "@babel/plugin-transform-runtime": "^7.14.5", - "@babel/preset-env": "^7.14.5", - "@babel/preset-react": "^7.14.5", - "@babel/preset-typescript": "^7.14.5", - "@babel/runtime": "^7.14.5", - "@tailwindcss/forms": "^0.3.3", + "@babel/plugin-transform-react-jsx": "^7.16.0", + "@babel/plugin-transform-runtime": "^7.16.0", + "@babel/preset-env": "^7.16.0", + "@babel/preset-react": "^7.16.0", + "@babel/preset-typescript": "^7.16.0", + "@babel/runtime": "^7.16.3", + "@tailwindcss/forms": "^0.3.4", "@types/chart.js": "^2.9.34", - "@types/debounce": "^1.2.0", + "@types/debounce": "^1.2.1", "@types/events": "^3.0.0", "@types/history": "^4.7.9", - "@types/node": "^16.9.1", + "@types/node": "^16.11.7", "@types/qrcode.react": "^1.0.2", "@types/query-string": "^6.3.0", - "@types/react": "^16.14.15", - "@types/react-copy-to-clipboard": "^5.0.1", + "@types/react": "^16.14.20", + "@types/react-copy-to-clipboard": "^5.0.2", "@types/react-dom": "^16.9.14", - "@types/react-redux": "^7.1.18", - "@types/react-router": "^5.1.16", - "@types/react-router-dom": "^5.1.8", - "@types/react-select": "^4.0.17", - "@types/react-transition-group": "^4.4.2", - "@types/styled-components": "^5.1.14", + "@types/react-redux": "^7.1.20", + "@types/react-router": "^5.1.17", + "@types/react-router-dom": "^5.3.2", + "@types/react-select": "^4.0.18", + "@types/react-transition-group": "^4.4.4", + "@types/styled-components": "^5.1.15", "@types/uuid": "^3.4.10", - "@types/webappsec-credential-management": "^0.6.1", - "@types/webpack-env": "^1.16.2", + "@types/webappsec-credential-management": "^0.6.2", + "@types/webpack-env": "^1.16.3", "@types/yup": "^0.29.13", - "@typescript-eslint/eslint-plugin": "^4.30.0", - "@typescript-eslint/parser": "^4.30.0", - "autoprefixer": "^10.3.4", - "babel-loader": "^8.2.2", + "@typescript-eslint/eslint-plugin": "^4.33.0", + "@typescript-eslint/parser": "^4.33.0", + "autoprefixer": "^10.4.0", + "babel-loader": "^8.2.3", "babel-plugin-macros": "^3.1.0", - "babel-plugin-styled-components": "^1.13.2", - "browserslist": "^4.17.0", + "babel-plugin-styled-components": "^1.13.3", + "browserslist": "^4.17.6", "cross-env": "^7.0.3", "css-loader": "^5.2.7", "eslint": "^7.32.0", "eslint-config-standard": "^16.0.3", - "eslint-plugin-import": "^2.23.4", + "eslint-plugin-import": "^2.25.3", "eslint-plugin-node": "^11.1.0", - "eslint-plugin-promise": "^5.1.0", - "eslint-plugin-react": "^7.24.0", - "eslint-plugin-react-hooks": "^4.2.0", - "fork-ts-checker-webpack-plugin": "^6.3.3", - "postcss": "^8.3.6", + "eslint-plugin-promise": "^5.1.1", + "eslint-plugin-react": "^7.27.0", + "eslint-plugin-react-hooks": "^4.3.0", + "fork-ts-checker-webpack-plugin": "^6.4.0", + "postcss": "^8.3.11", "redux-devtools-extension": "^2.13.9", "source-map-loader": "^1.1.3", "style-loader": "^2.0.0", "svg-url-loader": "^7.1.1", "tailwindcss": "^2.2.7", "terser-webpack-plugin": "^4.2.3", - "twin.macro": "^2.7.0", - "typescript": "^4.4.3", + "twin.macro": "^2.8.1", + "typescript": "^4.4.4", "webpack": "^4.46.0", "webpack-assets-manifest": "^4.0.6", "webpack-bundle-analyzer": "^4.4.2", @@ -170,8 +169,8 @@ }, "styledComponents": { "pure": true, - "displayName": false, - "fileName": false + "displayName": true, + "fileName": true } }, "packageManager": "yarn@3.0.2" diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon new file mode 100644 index 000000000..c26650da5 --- /dev/null +++ b/phpstan-baseline.neon @@ -0,0 +1,257 @@ +parameters: + ignoreErrors: + - + message: "#^Parameter \\#1 \\$string of function strtoupper expects string, int\\|string given\\.$#" + count: 1 + path: app/Console/Commands/Environment/AppSettingsCommand.php + + - + message: "#^Parameter \\#2 \\$subject of function preg_match_all expects string, string\\|false\\|null given\\.$#" + count: 1 + path: app/Console/Commands/Environment/AppSettingsCommand.php + + - + message: "#^Parameter \\#3 \\$subject of function preg_replace expects array\\|string, string\\|false\\|null given\\.$#" + count: 1 + path: app/Console/Commands/Environment/AppSettingsCommand.php + + - + message: "#^Parameter \\#1 \\$string of function strtoupper expects string, int\\|string given\\.$#" + count: 1 + path: app/Console/Commands/Environment/DatabaseSettingsCommand.php + + - + message: "#^Parameter \\#2 \\$subject of function preg_match_all expects string, string\\|false\\|null given\\.$#" + count: 1 + path: app/Console/Commands/Environment/DatabaseSettingsCommand.php + + - + message: "#^Parameter \\#3 \\$subject of function preg_replace expects array\\|string, string\\|false\\|null given\\.$#" + count: 1 + path: app/Console/Commands/Environment/DatabaseSettingsCommand.php + + - + message: "#^Parameter \\#1 \\$string of function strtoupper expects string, int\\|string given\\.$#" + count: 1 + path: app/Console/Commands/Environment/EmailSettingsCommand.php + + - + message: "#^Parameter \\#1 \\$value of function studly_case expects string, array\\|bool\\|string given\\.$#" + count: 1 + path: app/Console/Commands/Environment/EmailSettingsCommand.php + + - + message: "#^Parameter \\#2 \\$subject of function preg_match_all expects string, string\\|false\\|null given\\.$#" + count: 1 + path: app/Console/Commands/Environment/EmailSettingsCommand.php + + - + message: "#^Parameter \\#3 \\$subject of function preg_replace expects array\\|string, string\\|false\\|null given\\.$#" + count: 1 + path: app/Console/Commands/Environment/EmailSettingsCommand.php + + - + message: "#^Parameter \\#1 \\$action of method Pterodactyl\\\\Repositories\\\\Wings\\\\DaemonPowerRepository\\:\\:send\\(\\) expects string, array\\|string\\|null given\\.$#" + count: 1 + path: app/Console/Commands/Server/BulkPowerActionCommand.php + + - + message: "#^Parameter \\#2 \\$string of function explode expects string, array\\|bool\\|string given\\.$#" + count: 2 + path: app/Console/Commands/Server/BulkPowerActionCommand.php + + - + message: "#^Binary operation \"\\.\" between 'download/v' and array\\|bool\\|string\\|null results in an error\\.$#" + count: 1 + path: app/Console/Commands/UpgradeCommand.php + + - + message: "#^Cannot access offset 'name' on array\\|false\\.$#" + count: 2 + path: app/Console/Commands/UpgradeCommand.php + + - + message: "#^Method Pterodactyl\\\\Console\\\\Commands\\\\UpgradeCommand\\:\\:getUrl\\(\\) should return string but returns array\\|bool\\|string\\|null\\.$#" + count: 1 + path: app/Console/Commands/UpgradeCommand.php + + - + message: "#^Parameter \\#1 \\$group_id of function posix_getgrgid expects int, int\\|false given\\.$#" + count: 1 + path: app/Console/Commands/UpgradeCommand.php + + - + message: "#^Parameter \\#1 \\$user_id of function posix_getpwuid expects int, int\\|false given\\.$#" + count: 1 + path: app/Console/Commands/UpgradeCommand.php + + - + message: "#^Binary operation \"\\.\" between array\\|string and '\\.' results in an error\\.$#" + count: 1 + path: app/Exceptions/Handler.php + + - + message: "#^Possibly invalid array key type array\\|string\\.$#" + count: 1 + path: app/Exceptions/Handler.php + + - + message: "#^Unable to resolve the template type TReturn in call to method Illuminate\\\\Support\\\\Collection\\\\>\\:\\:flatMap\\(\\)$#" + count: 1 + path: app/Exceptions/Handler.php + + - + message: "#^Possibly invalid array key type array\\\\|string\\.$#" + count: 1 + path: app/Extensions/Backups/BackupManager.php + + - + message: "#^Parameter \\#1 \\$message of static method Illuminate\\\\Log\\\\Logger\\:\\:info\\(\\) expects string, string\\|false given\\.$#" + count: 2 + path: app/Http/Controllers/Api/Application/Eggs/EggController.php + + - + message: "#^Parameter \\#2 \\$content of method Pterodactyl\\\\Services\\\\Eggs\\\\Sharing\\\\EggImporterService\\:\\:handleContent\\(\\) expects string, resource\\|string given\\.$#" + count: 1 + path: app/Http/Controllers/Api/Application/Nests/NestController.php + + - + message: "#^Parameter \\#1 \\$perPage of method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\HasMany\\\\:\\:paginate\\(\\) expects int\\|null, array\\|int\\|string given\\.$#" + count: 1 + path: app/Http/Controllers/Api/Client/Servers/BackupController.php + + - + message: "#^Parameter \\#2 \\$content of method Pterodactyl\\\\Repositories\\\\Wings\\\\DaemonFileRepository\\:\\:putContent\\(\\) expects string, resource\\|string given\\.$#" + count: 1 + path: app/Http/Controllers/Api/Client/Servers/FileController.php + + - + message: "#^Parameter \\#1 \\$string of function md5 expects string, string\\|false given\\.$#" + count: 1 + path: app/Http/Controllers/Base/LocaleController.php + + - + message: "#^Parameter \\#1 \\$key of method Illuminate\\\\Routing\\\\Router\\:\\:bind\\(\\) expects string, int\\|string given\\.$#" + count: 1 + path: app/Http/Middleware/Api/Application/SubstituteApplicationApiBindings.php + + - + message: "#^Parameter \\#1 \\$json of function json_decode expects string, resource\\|string given\\.$#" + count: 1 + path: app/Http/Middleware/Api/IsValidJson.php + + - + message: "#^Parameter \\#1 \\$array of function array_get expects array\\|ArrayAccess, array\\\\|false given\\.$#" + count: 1 + path: app/Http/Middleware/VerifyReCaptcha.php + + - + message: "#^Parameter \\#1 \\$value of function snake_case expects string, int\\|string given\\.$#" + count: 1 + path: app/Http/Requests/Api/Application/Nodes/StoreNodeRequest.php + + - + message: "#^Method Pterodactyl\\\\Jobs\\\\Schedule\\\\RunTaskJob\\:\\:__construct\\(\\) has parameter \\$manualRun with no typehint specified\\.$#" + count: 1 + path: app/Jobs/Schedule/RunTaskJob.php + + - + message: "#^Parameter \\#2 \\$column of static method Illuminate\\\\Validation\\\\Rule\\:\\:unique\\(\\) expects string, int\\|string given\\.$#" + count: 1 + path: app/Models/Model.php + + - + message: "#^Method Pterodactyl\\\\Models\\\\Node\\:\\:getJsonConfiguration\\(\\) should return string but returns string\\|false\\.$#" + count: 1 + path: app/Models/Node.php + + - + message: "#^Method Pterodactyl\\\\Repositories\\\\Eloquent\\\\EloquentRepository\\:\\:updateOrCreate\\(\\) should return Illuminate\\\\Database\\\\Eloquent\\\\Model but returns bool\\|Illuminate\\\\Database\\\\Eloquent\\\\Model\\.$#" + count: 1 + path: app/Repositories/Eloquent/EloquentRepository.php + + - + message: "#^Parameter \\#1 \\.\\.\\.\\$model of method Pterodactyl\\\\Repositories\\\\Repository\\:\\:initializeModel\\(\\) expects class\\-string\\, mixed given\\.$#" + count: 1 + path: app/Repositories/Repository.php + + - + message: "#^Parameter \\#2 \\$content of method Pterodactyl\\\\Services\\\\Eggs\\\\Sharing\\\\EggImporterService\\:\\:handleContent\\(\\) expects string, string\\|false given\\.$#" + count: 1 + path: app/Services/Eggs/Sharing/EggImporterService.php + + - + message: "#^Parameter \\#1 \\$json of function json_decode expects string, string\\|false given\\.$#" + count: 1 + path: app/Services/Eggs/Sharing/EggUpdateImporterService.php + + - + message: "#^Parameter \\#1 \\$value of static method Illuminate\\\\Support\\\\Str\\:\\:snake\\(\\) expects string, array\\|string given\\.$#" + count: 1 + path: app/Services/Eggs/Variables/VariableCreationService.php + + - + message: "#^Parameter \\#1 \\$value of static method Illuminate\\\\Support\\\\Str\\:\\:snake\\(\\) expects string, array\\|string given\\.$#" + count: 1 + path: app/Services/Eggs/Variables/VariableUpdateService.php + + - + message: "#^Parameter \\#1 \\$string of function substr expects string, string\\|false given\\.$#" + count: 1 + path: app/Services/Helpers/SoftwareVersionService.php + + - + message: "#^Parameter \\#2 \\$string of function explode expects string, string\\|false given\\.$#" + count: 1 + path: app/Services/Helpers/SoftwareVersionService.php + + - + message: "#^Parameter \\#1 \\$id of method Lcobucci\\\\JWT\\\\Builder\\:\\:identifiedBy\\(\\) expects string, string\\|false given\\.$#" + count: 1 + path: app/Services/Nodes/NodeJWTService.php + + - + message: "#^Parameter \\#1 \\$name of method Lcobucci\\\\JWT\\\\Builder\\:\\:withClaim\\(\\) expects string, int\\|string given\\.$#" + count: 1 + path: app/Services/Nodes/NodeJWTService.php + + - + message: "#^Parameter \\#1 \\$key of method Illuminate\\\\Database\\\\Eloquent\\\\Model\\:\\:setAttribute\\(\\) expects string, int\\|string given\\.$#" + count: 1 + path: app/Services/Servers/ServerConfigurationStructureService.php + + - + message: "#^Access to an undefined property Illuminate\\\\Database\\\\Eloquent\\\\Collection\\\\|Pterodactyl\\\\Models\\\\Allocation\\:\\:\\$node_id\\.$#" + count: 1 + path: app/Services/Servers/ServerCreationService.php + + - + message: "#^Access to an undefined property Illuminate\\\\Database\\\\Eloquent\\\\Collection\\\\|Pterodactyl\\\\Models\\\\Egg\\:\\:\\$nest_id\\.$#" + count: 1 + path: app/Services/Servers/ServerCreationService.php + + - + message: "#^Parameter \\#3 \\$subject of function preg_replace expects array\\|string, string\\|false given\\.$#" + count: 1 + path: app/Services/Subusers/SubuserCreationService.php + + - + message: "#^Parameter \\#1 \\$string of function urlencode expects string, array\\|string\\|null given\\.$#" + count: 1 + path: app/Services/Users/TwoFactorSetupService.php + + - + message: "#^Cannot call method getResourceName\\(\\) on \\(callable&class\\-string\\)\\|\\(callable&object\\)\\|League\\\\Fractal\\\\TransformerAbstract\\.$#" + count: 2 + path: app/Transformers/Api/Transformer.php + + - + message: "#^Cannot call method setTimezone\\(\\) on Carbon\\\\CarbonImmutable\\|false\\.$#" + count: 1 + path: app/Transformers/Api/Transformer.php + + - + message: "#^Parameter \\#1 \\$object_or_class of function method_exists expects object\\|string, \\(callable\\)\\|League\\\\Fractal\\\\TransformerAbstract given\\.$#" + count: 2 + path: app/Transformers/Api/Transformer.php + diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 000000000..3fb3a5bb3 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,15 @@ +includes: + - ./phpstan-baseline.neon + - ./vendor/nunomaduro/larastan/extension.neon + - ./vendor/phpstan/phpstan-webmozart-assert/extension.neon + +parameters: + paths: + - app + - database/Seeders + level: 7 + ignoreErrors: + - '#Unsafe usage of new static#' + - '#has no return typehint specified.#' + - '#has no typehint specified.#' + checkMissingIterableValueType: false diff --git a/phpunit.xml b/phpunit.xml index 227dadb9e..a14cb82be 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -33,6 +33,7 @@ + diff --git a/public/robots.txt b/public/robots.txt index eb0536286..1f53798bb 100644 --- a/public/robots.txt +++ b/public/robots.txt @@ -1,2 +1,2 @@ User-agent: * -Disallow: +Disallow: / diff --git a/resources/scripts/api/admin/egg.ts b/resources/scripts/api/admin/egg.ts new file mode 100644 index 000000000..4fd5e7844 --- /dev/null +++ b/resources/scripts/api/admin/egg.ts @@ -0,0 +1,97 @@ +import { Model, UUID, WithRelationships, withRelationships } from '@/api/admin/index'; +import { Nest } from '@/api/admin/nest'; +import http, { QueryBuilderParams, withQueryBuilderParams } from '@/api/http'; +import { AdminTransformers } from '@/api/admin/transformers'; +import { AxiosError } from 'axios'; +import { useRouteMatch } from 'react-router-dom'; +import useSWR, { SWRResponse } from 'swr'; + +export interface Egg extends Model { + id: number; + uuid: UUID; + nestId: number; + author: string; + name: string; + description: string | null; + features: string[] | null; + dockerImages: string[]; + configFiles: Record | null; + configStartup: Record | null; + configStop: string | null; + configFrom: number | null; + startup: string; + scriptContainer: string; + copyScriptFrom: number | null; + scriptEntry: string; + scriptIsPrivileged: boolean; + scriptInstall: string | null; + createdAt: Date; + updatedAt: Date; + relationships: { + nest?: Nest; + variables?: EggVariable[]; + }; +} + +export interface EggVariable extends Model { + id: number; + eggId: number; + name: string; + description: string; + environmentVariable: string; + defaultValue: string; + isUserViewable: boolean; + isUserEditable: boolean; + // isRequired: boolean; + rules: string; + createdAt: Date; + updatedAt: Date; +} + +/** + * A standard API response with the minimum viable details for the frontend + * to correctly render a egg. + */ +type LoadedEgg = WithRelationships; + +/** + * Gets a single egg from the database and returns it. + */ +export const getEgg = async (id: number | string): Promise => { + const { data } = await http.get(`/api/application/eggs/${id}`, { + params: { + include: [ 'nest', 'variables' ], + }, + }); + + return withRelationships(AdminTransformers.toEgg(data), 'nest', 'variables'); +}; + +export const searchEggs = async (nestId: number, params: QueryBuilderParams<'name'>): Promise[]> => { + const { data } = await http.get(`/api/application/nests/${nestId}/eggs`, { + params: { + ...withQueryBuilderParams(params), + include: [ 'variables' ], + }, + }); + + return data.data.map(AdminTransformers.toEgg); +}; + +export const exportEgg = async (eggId: number): Promise> => { + const { data } = await http.get(`/api/application/eggs/${eggId}/export`); + return data; +}; + +/** + * Returns an SWR instance by automatically loading in the server for the currently + * loaded route match in the admin area. + */ +export const useEggFromRoute = (): SWRResponse => { + const { params } = useRouteMatch<{ id: string }>(); + + return useSWR(`/api/application/eggs/${params.id}`, async () => getEgg(params.id), { + revalidateOnMount: false, + revalidateOnFocus: false, + }); +}; diff --git a/resources/scripts/api/admin/eggs/createEgg.ts b/resources/scripts/api/admin/eggs/createEgg.ts index 5a2f8c964..0ad08d9ee 100644 --- a/resources/scripts/api/admin/eggs/createEgg.ts +++ b/resources/scripts/api/admin/eggs/createEgg.ts @@ -1,10 +1,12 @@ import http from '@/api/http'; import { Egg, rawDataToEgg } from '@/api/admin/eggs/getEgg'; -export default (egg: Partial): Promise => { +type Egg2 = Omit, 'configFiles'>, 'configStartup'> & { configFiles: string, configStartup: string }; + +export default (egg: Partial): Promise => { return new Promise((resolve, reject) => { http.post( - '/api/application/eggs/', + '/api/application/eggs', { nest_id: egg.nestId, name: egg.name, diff --git a/resources/scripts/api/admin/eggs/createEggVariable.ts b/resources/scripts/api/admin/eggs/createEggVariable.ts new file mode 100644 index 000000000..72f2c314d --- /dev/null +++ b/resources/scripts/api/admin/eggs/createEggVariable.ts @@ -0,0 +1,22 @@ +import http from '@/api/http'; +import { EggVariable } from '@/api/admin/egg'; +import { AdminTransformers } from '@/api/admin/transformers'; + +export type CreateEggVariable = Omit; + +export default async (eggId: number, variable: CreateEggVariable): Promise => { + const { data } = await http.post( + `/api/application/eggs/${eggId}/variables`, + { + name: variable.name, + description: variable.description, + env_variable: variable.environmentVariable, + default_value: variable.defaultValue, + user_viewable: variable.isUserViewable, + user_editable: variable.isUserEditable, + rules: variable.rules, + }, + ); + + return AdminTransformers.toEggVariable(data); +}; diff --git a/resources/scripts/api/admin/eggs/deleteEggVariable.ts b/resources/scripts/api/admin/eggs/deleteEggVariable.ts new file mode 100644 index 000000000..967798f55 --- /dev/null +++ b/resources/scripts/api/admin/eggs/deleteEggVariable.ts @@ -0,0 +1,9 @@ +import http from '@/api/http'; + +export default (eggId: number, variableId: number): Promise => { + return new Promise((resolve, reject) => { + http.delete(`/api/application/eggs/${eggId}/variables/${variableId}`) + .then(() => resolve()) + .catch(reject); + }); +}; diff --git a/resources/scripts/api/admin/eggs/getEgg.ts b/resources/scripts/api/admin/eggs/getEgg.ts index 48e139301..5e380e113 100644 --- a/resources/scripts/api/admin/eggs/getEgg.ts +++ b/resources/scripts/api/admin/eggs/getEgg.ts @@ -1,6 +1,7 @@ import { Nest } from '@/api/admin/nests/getNests'; import { rawDataToServer, Server } from '@/api/admin/servers/getServers'; import http, { FractalResponseData, FractalResponseList } from '@/api/http'; +import useSWR from 'swr'; export interface EggVariable { id: number; @@ -12,12 +13,11 @@ export interface EggVariable { userViewable: boolean; userEditable: boolean; rules: string; - required: boolean; createdAt: Date; updatedAt: Date; } -const rawDataToEggVariable = ({ attributes }: FractalResponseData): EggVariable => ({ +export const rawDataToEggVariable = ({ attributes }: FractalResponseData): EggVariable => ({ id: attributes.id, eggId: attributes.egg_id, name: attributes.name, @@ -27,7 +27,6 @@ const rawDataToEggVariable = ({ attributes }: FractalResponseData): EggVariable userViewable: attributes.user_viewable, userEditable: attributes.user_editable, rules: attributes.rules, - required: attributes.required, createdAt: new Date(attributes.created_at), updatedAt: new Date(attributes.updated_at), }); @@ -90,10 +89,16 @@ export const rawDataToEgg = ({ attributes }: FractalResponseData): Egg => ({ }, }); -export default (id: number, include: string[] = []): Promise => { - return new Promise((resolve, reject) => { - http.get(`/api/application/eggs/${id}`, { params: { include: include.join(',') } }) - .then(({ data }) => resolve(rawDataToEgg(data))) - .catch(reject); +export const getEgg = async (id: number): Promise => { + const { data } = await http.get(`/api/application/eggs/${id}`, { params: { include: [ 'variables' ] } }); + + return rawDataToEgg(data); +}; + +export default (id: number) => { + return useSWR(`egg:${id}`, async () => { + const { data } = await http.get(`/api/application/eggs/${id}`, { params: { include: [ 'variables' ] } }); + + return rawDataToEgg(data); }); }; diff --git a/resources/scripts/api/admin/eggs/updateEggVariables.ts b/resources/scripts/api/admin/eggs/updateEggVariables.ts new file mode 100644 index 000000000..b78273341 --- /dev/null +++ b/resources/scripts/api/admin/eggs/updateEggVariables.ts @@ -0,0 +1,21 @@ +import http from '@/api/http'; +import { EggVariable } from '@/api/admin/egg'; +import { AdminTransformers } from '@/api/admin/transformers'; + +export default async (eggId: number, variables: Omit[]): Promise => { + const { data } = await http.patch( + `/api/application/eggs/${eggId}/variables`, + variables.map(variable => ({ + id: variable.id, + name: variable.name, + description: variable.description, + env_variable: variable.environmentVariable, + default_value: variable.defaultValue, + user_viewable: variable.isUserViewable, + user_editable: variable.isUserEditable, + rules: variable.rules, + })), + ); + + return data.data.map(AdminTransformers.toEggVariable); +}; diff --git a/resources/scripts/api/admin/index.ts b/resources/scripts/api/admin/index.ts index 54161f2b0..014a207a7 100644 --- a/resources/scripts/api/admin/index.ts +++ b/resources/scripts/api/admin/index.ts @@ -1,5 +1,38 @@ import { createContext } from 'react'; +export interface Model { + relationships: Record; +} + +export type UUID = string; + +/** + * Marks the provided relationships keys as present in the given model + * rather than being optional to improve typing responses. + */ +export type WithRelationships = Omit & { + relationships: Omit & { + [K in R]: NonNullable; + } +} + +/** + * Helper type that allows you to infer the type of an object by giving + * it the specific API request function with a return type. For example: + * + * type EggT = InferModel; + */ +export type InferModel any> = ReturnType extends Promise ? U : T; + +/** + * Helper function that just returns the model you pass in, but types the model + * such that TypeScript understands the relationships on it. This is just to help + * reduce the amount of duplicated type casting all over the codebase. + */ +export const withRelationships = (model: M, ..._keys: R[]) => { + return model as unknown as WithRelationships; +}; + export interface ListContext { page: number; setPage: (page: ((p: number) => number) | number) => void; diff --git a/resources/scripts/api/admin/location.ts b/resources/scripts/api/admin/location.ts new file mode 100644 index 000000000..82ff394f8 --- /dev/null +++ b/resources/scripts/api/admin/location.ts @@ -0,0 +1,13 @@ +import { Model } from '@/api/admin/index'; +import { Node } from '@/api/admin/node'; + +export interface Location extends Model { + id: number; + short: string; + long: string; + createdAt: Date; + updatedAt: Date; + relationships: { + nodes?: Node[]; + }; +} diff --git a/resources/scripts/api/admin/nest.ts b/resources/scripts/api/admin/nest.ts new file mode 100644 index 000000000..c808637fc --- /dev/null +++ b/resources/scripts/api/admin/nest.ts @@ -0,0 +1,25 @@ +import { Model, UUID } from '@/api/admin/index'; +import { Egg } from '@/api/admin/egg'; +import http, { QueryBuilderParams, withQueryBuilderParams } from '@/api/http'; +import { AdminTransformers } from '@/api/admin/transformers'; + +export interface Nest extends Model { + id: number; + uuid: UUID; + author: string; + name: string; + description?: string; + createdAt: Date; + updatedAt: Date; + relationships: { + eggs?: Egg[]; + }; +} + +export const searchNests = async (params: QueryBuilderParams<'name'>): Promise => { + const { data } = await http.get('/api/application/nests', { + params: withQueryBuilderParams(params), + }); + + return data.data.map(AdminTransformers.toNest); +}; diff --git a/resources/scripts/api/admin/nests/searchEggs.ts b/resources/scripts/api/admin/nests/searchEggs.ts deleted file mode 100644 index 8bb0f3185..000000000 --- a/resources/scripts/api/admin/nests/searchEggs.ts +++ /dev/null @@ -1,24 +0,0 @@ -import http from '@/api/http'; -import { Egg, rawDataToEgg } from '@/api/admin/eggs/getEgg'; - -interface Filters { - name?: string; -} - -export default (nestId: number, filters?: Filters, include: string[] = []): Promise => { - const params = {}; - if (filters !== undefined) { - Object.keys(filters).forEach(key => { - // @ts-ignore - params['filter[' + key + ']'] = filters[key]; - }); - } - - return new Promise((resolve, reject) => { - http.get(`/api/application/nests/${nestId}/eggs`, { params: { include: include.join(','), ...params } }) - .then(response => resolve( - (response.data.data || []).map(rawDataToEgg) - )) - .catch(reject); - }); -}; diff --git a/resources/scripts/api/admin/nests/searchNests.ts b/resources/scripts/api/admin/nests/searchNests.ts deleted file mode 100644 index 65cc41f36..000000000 --- a/resources/scripts/api/admin/nests/searchNests.ts +++ /dev/null @@ -1,24 +0,0 @@ -import http from '@/api/http'; -import { Nest, rawDataToNest } from '@/api/admin/nests/getNests'; - -interface Filters { - name?: string; -} - -export default (filters?: Filters): Promise => { - const params = {}; - if (filters !== undefined) { - Object.keys(filters).forEach(key => { - // @ts-ignore - params['filter[' + key + ']'] = filters[key]; - }); - } - - return new Promise((resolve, reject) => { - http.get('/api/application/nests', { params: { ...params } }) - .then(response => resolve( - (response.data.data || []).map(rawDataToNest) - )) - .catch(reject); - }); -}; diff --git a/resources/scripts/api/admin/node.ts b/resources/scripts/api/admin/node.ts new file mode 100644 index 000000000..cac2e96b2 --- /dev/null +++ b/resources/scripts/api/admin/node.ts @@ -0,0 +1,84 @@ +import { Model, UUID, WithRelationships, withRelationships } from '@/api/admin/index'; +import { Location } from '@/api/admin/location'; +import http, { QueryBuilderParams, withQueryBuilderParams } from '@/api/http'; +import { AdminTransformers } from '@/api/admin/transformers'; +import { Server } from '@/api/admin/server'; + +interface NodePorts { + http: { + listen: number; + public: number; + }; + sftp: { + listen: number; + public: number; + }; +} + +export interface Allocation extends Model { + id: number; + ip: string; + port: number; + alias: string | null; + isAssigned: boolean; + relationships: { + node?: Node; + server?: Server | null; + }; + getDisplayText(): string; +} + +export interface Node extends Model { + id: number; + uuid: UUID; + isPublic: boolean; + locationId: number; + databaseHostId: number; + name: string; + description: string | null; + fqdn: string; + ports: NodePorts; + scheme: 'http' | 'https'; + isBehindProxy: boolean; + isMaintenanceMode: boolean; + memory: number; + memoryOverallocate: number; + disk: number; + diskOverallocate: number; + uploadSize: number; + daemonBase: string; + createdAt: Date; + updatedAt: Date; + relationships: { + location?: Location; + }; +} + +/** + * Gets a single node and returns it. + */ +export const getNode = async (id: string | number): Promise> => { + const { data } = await http.get(`/api/application/nodes/${id}`, { + params: { + include: [ 'location' ], + }, + }); + + return withRelationships(AdminTransformers.toNode(data.data), 'location'); +}; + +export const searchNodes = async (params: QueryBuilderParams<'name'>): Promise => { + const { data } = await http.get('/api/application/nodes', { + params: withQueryBuilderParams(params), + }); + + return data.data.map(AdminTransformers.toNode); +}; + +export const getAllocations = async (id: string | number, params?: QueryBuilderParams<'ip' | 'server_id'>): Promise => { + const { data } = await http.get(`/api/application/nodes/${id}/allocations`, { + params: withQueryBuilderParams(params), + }); + + return data.data.map(AdminTransformers.toAllocation); +}; diff --git a/resources/scripts/api/admin/nodes/allocations/deleteAllocation.ts b/resources/scripts/api/admin/nodes/allocations/deleteAllocation.ts new file mode 100644 index 000000000..f4a775183 --- /dev/null +++ b/resources/scripts/api/admin/nodes/allocations/deleteAllocation.ts @@ -0,0 +1,9 @@ +import http from '@/api/http'; + +export default (nodeId: number, allocationId: number): Promise => { + return new Promise((resolve, reject) => { + http.delete(`/api/application/nodes/${nodeId}/allocations/${allocationId}`) + .then(() => resolve()) + .catch(reject); + }); +}; diff --git a/resources/scripts/api/admin/nodes/allocations/getAllocations.ts b/resources/scripts/api/admin/nodes/allocations/getAllocations.ts index 55d01152b..14c99c655 100644 --- a/resources/scripts/api/admin/nodes/allocations/getAllocations.ts +++ b/resources/scripts/api/admin/nodes/allocations/getAllocations.ts @@ -12,7 +12,7 @@ export interface Filters { export const Context = createContext(); -export default (id: string | number, include: string[] = []) => { +export default (id: number, include: string[] = []) => { const { page, filters, sort, sortDirection } = useContext(Context); const params = {}; diff --git a/resources/scripts/api/admin/server.ts b/resources/scripts/api/admin/server.ts new file mode 100644 index 000000000..f32a4fa59 --- /dev/null +++ b/resources/scripts/api/admin/server.ts @@ -0,0 +1,100 @@ +import useSWR, { SWRResponse } from 'swr'; +import { AxiosError } from 'axios'; +import { useRouteMatch } from 'react-router-dom'; +import http from '@/api/http'; +import { Model, UUID, withRelationships, WithRelationships } from '@/api/admin/index'; +import { AdminTransformers } from '@/api/admin/transformers'; +import { Allocation, Node } from '@/api/admin/node'; +import { User } from '@/api/admin/user'; +import { Egg, EggVariable } from '@/api/admin/egg'; +import { Nest } from '@/api/admin/nest'; + +/** + * Defines the limits for a server that exists on the Panel. + */ +interface ServerLimits { + memory: number; + swap: number; + disk: number; + io: number; + cpu: number; + threads: string | null; + oomDisabled: boolean; +} + +export interface ServerVariable extends EggVariable { + serverValue: string; +} + +/** + * Defines a single server instance that is returned from the Panel's admin + * API endpoints. + */ +export interface Server extends Model { + id: number; + uuid: UUID; + externalId: string | null; + identifier: string; + name: string; + description: string; + status: string; + userId: number; + nodeId: number; + allocationId: number; + eggId: number; + nestId: number; + limits: ServerLimits; + featureLimits: { + databases: number; + allocations: number; + backups: number; + }; + container: { + startup: string | null; + image: string; + environment: Record; + }; + createdAt: Date; + updatedAt: Date; + relationships: { + allocations?: Allocation[]; + nest?: Nest; + egg?: Egg; + node?: Node; + user?: User; + variables?: ServerVariable[]; + }; +} + +/** + * A standard API response with the minimum viable details for the frontend + * to correctly render a server. + */ +type LoadedServer = WithRelationships; + +/** + * Fetches a server from the API and ensures that the allocations, user, and + * node data is loaded. + */ +export const getServer = async (id: number | string): Promise => { + const { data } = await http.get(`/api/application/servers/${id}`, { + params: { + include: [ 'allocations', 'user', 'node', 'variables' ], + }, + }); + + return withRelationships(AdminTransformers.toServer(data), 'allocations', 'user', 'node', 'variables'); +}; + +/** + * Returns an SWR instance by automatically loading in the server for the currently + * loaded route match in the admin area. + */ +export const useServerFromRoute = (): SWRResponse => { + const { params } = useRouteMatch<{ id: string }>(); + + return useSWR(`/api/application/servers/${params.id}`, async () => getServer(params.id), { + revalidateOnMount: false, + revalidateOnFocus: false, + }); +}; diff --git a/resources/scripts/api/admin/servers/createServer.ts b/resources/scripts/api/admin/servers/createServer.ts index 88021de80..3fd94ca62 100644 --- a/resources/scripts/api/admin/servers/createServer.ts +++ b/resources/scripts/api/admin/servers/createServer.ts @@ -1,57 +1,50 @@ import http from '@/api/http'; import { Server, rawDataToServer } from '@/api/admin/servers/getServers'; -interface CreateServerRequest { +export interface CreateServerRequest { + externalId: string; name: string; description: string | null; - user: number; - egg: number; - dockerImage: string; - startup: string; - skipScripts: boolean; - oomDisabled: boolean; - startOnCompletion: boolean; - environment: string[]; - - allocation: { - default: number; - additional: number[]; - }; + ownerId: number; + nodeId: number; limits: { - cpu: number; - disk: number; - io: number; memory: number; swap: number; + disk: number; + io: number; + cpu: number; threads: string; - }; + oomDisabled: boolean; + } featureLimits: { allocations: number; backups: number; databases: number; }; + + allocation: { + default: number; + additional: number[]; + }; + + startup: string; + environment: Record; + eggId: number; + image: string; + skipScripts: boolean; + startOnCompletion: boolean; } export default (r: CreateServerRequest, include: string[] = []): Promise => { return new Promise((resolve, reject) => { http.post('/api/application/servers', { + externalId: r.externalId, name: r.name, description: r.description, - user: r.user, - egg: r.egg, - docker_image: r.dockerImage, - startup: r.startup, - skip_scripts: r.skipScripts, - oom_disabled: r.oomDisabled, - start_on_completion: r.startOnCompletion, - environment: r.environment, - - allocation: { - default: r.allocation.default, - additional: r.allocation.additional, - }, + owner_id: r.ownerId, + node_id: r.nodeId, limits: { cpu: r.limits.cpu, @@ -60,13 +53,26 @@ export default (r: CreateServerRequest, include: string[] = []): Promise memory: r.limits.memory, swap: r.limits.swap, threads: r.limits.threads, + oom_killer: r.limits.oomDisabled, }, - featureLimits: { + feature_limits: { allocations: r.featureLimits.allocations, backups: r.featureLimits.backups, databases: r.featureLimits.databases, }, + + allocation: { + default: r.allocation.default, + additional: r.allocation.additional, + }, + + startup: r.startup, + environment: r.environment, + egg_id: r.eggId, + image: r.image, + skip_scripts: r.skipScripts, + start_on_completion: r.startOnCompletion, }, { params: { include: include.join(',') } }) .then(({ data }) => resolve(rawDataToServer(data))) .catch(reject); diff --git a/resources/scripts/api/admin/servers/getServers.ts b/resources/scripts/api/admin/servers/getServers.ts index 8f40a30f2..e34e0a566 100644 --- a/resources/scripts/api/admin/servers/getServers.ts +++ b/resources/scripts/api/admin/servers/getServers.ts @@ -23,7 +23,7 @@ export interface ServerVariable { updatedAt: Date; } -const rawDataToServerVariable = ({ attributes }: FractalResponseData): ServerVariable => ({ +export const rawDataToServerVariable = ({ attributes }: FractalResponseData): ServerVariable => ({ id: attributes.id, eggId: attributes.egg_id, name: attributes.name, diff --git a/resources/scripts/api/admin/servers/updateServer.ts b/resources/scripts/api/admin/servers/updateServer.ts index b8c59d6cc..e74b7422a 100644 --- a/resources/scripts/api/admin/servers/updateServer.ts +++ b/resources/scripts/api/admin/servers/updateServer.ts @@ -43,7 +43,7 @@ export default (id: number, server: Partial, include: string[] = []): Pr io: server.limits?.io, cpu: server.limits?.cpu, threads: server.limits?.threads, - oom_disabled: server.limits?.oomDisabled, + oom_killer: server.limits?.oomDisabled, }, feature_limits: { diff --git a/resources/scripts/api/admin/servers/updateServerStartup.ts b/resources/scripts/api/admin/servers/updateServerStartup.ts index 33f598f1d..7dec26e30 100644 --- a/resources/scripts/api/admin/servers/updateServerStartup.ts +++ b/resources/scripts/api/admin/servers/updateServerStartup.ts @@ -14,7 +14,7 @@ export default (id: number, values: Partial, include: string[] = []): Pr http.patch( `/api/application/servers/${id}/startup`, { - startup: values.startup, + startup: values.startup !== '' ? values.startup : null, environment: values.environment, egg_id: values.eggId, image: values.image, diff --git a/resources/scripts/api/admin/transformers.ts b/resources/scripts/api/admin/transformers.ts new file mode 100644 index 000000000..bed54fb8f --- /dev/null +++ b/resources/scripts/api/admin/transformers.ts @@ -0,0 +1,212 @@ +/* eslint-disable camelcase */ +import { Allocation, Node } from '@/api/admin/node'; +import { Server, ServerVariable } from '@/api/admin/server'; +import { FractalResponseData, FractalResponseList } from '@/api/http'; +import { User, UserRole } from '@/api/admin/user'; +import { Location } from '@/api/admin/location'; +import { Egg, EggVariable } from '@/api/admin/egg'; +import { Nest } from '@/api/admin/nest'; + +const isList = (data: FractalResponseList | FractalResponseData): data is FractalResponseList => data.object === 'list'; + +function transform (data: undefined, transformer: (callback: FractalResponseData) => T, missing?: M): undefined; +function transform (data: FractalResponseData | undefined, transformer: (callback: FractalResponseData) => T, missing?: M): T | M | undefined; +function transform (data: FractalResponseList | undefined, transformer: (callback: FractalResponseData) => T, missing?: M): T[] | undefined; +function transform (data: FractalResponseData | FractalResponseList | undefined, transformer: (callback: FractalResponseData) => T, missing = undefined) { + if (data === undefined) return undefined; + + if (isList(data)) { + return data.data.map(transformer); + } + + return !data ? missing : transformer(data); +} + +export class AdminTransformers { + static toServer = ({ attributes }: FractalResponseData): Server => { + const { oom_disabled, ...limits } = attributes.limits; + const { allocations, egg, nest, node, user, variables } = attributes.relationships || {}; + + return { + id: attributes.id, + uuid: attributes.uuid, + externalId: attributes.external_id, + identifier: attributes.identifier, + name: attributes.name, + description: attributes.description, + status: attributes.status, + userId: attributes.owner_id, + nodeId: attributes.node_id, + allocationId: attributes.allocation_id, + eggId: attributes.egg_id, + nestId: attributes.nest_id, + limits: { ...limits, oomDisabled: oom_disabled }, + featureLimits: attributes.feature_limits, + container: attributes.container, + createdAt: new Date(attributes.created_at), + updatedAt: new Date(attributes.updated_at), + relationships: { + allocations: transform(allocations as FractalResponseList | undefined, this.toAllocation), + nest: transform(nest as FractalResponseData | undefined, this.toNest), + egg: transform(egg as FractalResponseData | undefined, this.toEgg), + node: transform(node as FractalResponseData | undefined, this.toNode), + user: transform(user as FractalResponseData | undefined, this.toUser), + variables: transform(variables as FractalResponseList | undefined, this.toServerEggVariable), + }, + }; + }; + + static toNode = ({ attributes }: FractalResponseData): Node => { + return { + id: attributes.id, + uuid: attributes.uuid, + isPublic: attributes.public, + locationId: attributes.location_id, + databaseHostId: attributes.database_host_id, + name: attributes.name, + description: attributes.description, + fqdn: attributes.fqdn, + ports: { + http: { + public: attributes.publicPortHttp, + listen: attributes.listenPortHttp, + }, + sftp: { + public: attributes.publicPortSftp, + listen: attributes.listenPortSftp, + }, + }, + scheme: attributes.scheme, + isBehindProxy: attributes.behindProxy, + isMaintenanceMode: attributes.maintenance_mode, + memory: attributes.memory, + memoryOverallocate: attributes.memory_overallocate, + disk: attributes.disk, + diskOverallocate: attributes.disk_overallocate, + uploadSize: attributes.upload_size, + daemonBase: attributes.daemonBase, + createdAt: new Date(attributes.created_at), + updatedAt: new Date(attributes.updated_at), + relationships: { + location: transform(attributes.relationships?.location as FractalResponseData, this.toLocation), + }, + }; + }; + + static toUserRole = ({ attributes }: FractalResponseData): UserRole => ({ + id: attributes.id, + name: attributes.name, + description: attributes.description, + relationships: {}, + }); + + static toUser = ({ attributes }: FractalResponseData): User => { + return { + id: attributes.id, + uuid: attributes.uuid, + externalId: attributes.external_id, + username: attributes.username, + email: attributes.email, + language: attributes.language, + adminRoleId: attributes.adminRoleId || null, + roleName: attributes.role_name, + isRootAdmin: attributes.root_admin, + isUsingTwoFactor: attributes['2fa'] || false, + avatarUrl: attributes.avatar_url, + createdAt: new Date(attributes.created_at), + updatedAt: new Date(attributes.updated_at), + relationships: { + role: transform(attributes.relationships?.role as FractalResponseData, this.toUserRole) || null, + }, + }; + }; + + static toLocation = ({ attributes }: FractalResponseData): Location => ({ + id: attributes.id, + short: attributes.short, + long: attributes.long, + createdAt: new Date(attributes.created_at), + updatedAt: new Date(attributes.updated_at), + relationships: { + nodes: transform(attributes.relationships?.node as FractalResponseList, this.toNode), + }, + }); + + static toEgg = ({ attributes }: FractalResponseData): Egg => ({ + id: attributes.id, + uuid: attributes.uuid, + nestId: attributes.nest_id, + author: attributes.author, + name: attributes.name, + description: attributes.description, + features: attributes.features, + dockerImages: attributes.docker_images, + configFiles: attributes.config?.files, + configStartup: attributes.config?.startup, + configStop: attributes.config?.stop, + configFrom: attributes.config?.extends, + startup: attributes.startup, + copyScriptFrom: attributes.copy_script_from, + scriptContainer: attributes.script?.container, + scriptEntry: attributes.script?.entry, + scriptIsPrivileged: attributes.script?.privileged, + scriptInstall: attributes.script?.install, + createdAt: new Date(attributes.created_at), + updatedAt: new Date(attributes.updated_at), + relationships: { + nest: transform(attributes.relationships?.nest as FractalResponseData, this.toNest), + variables: transform(attributes.relationships?.variables as FractalResponseList, this.toEggVariable), + }, + }); + + static toEggVariable = ({ attributes }: FractalResponseData): EggVariable => ({ + id: attributes.id, + eggId: attributes.egg_id, + name: attributes.name, + description: attributes.description, + environmentVariable: attributes.env_variable, + defaultValue: attributes.default_value, + isUserViewable: attributes.user_viewable, + isUserEditable: attributes.user_editable, + // isRequired: attributes.required, + rules: attributes.rules, + createdAt: new Date(attributes.created_at), + updatedAt: new Date(attributes.updated_at), + relationships: {}, + }); + + static toServerEggVariable = (data: FractalResponseData): ServerVariable => ({ + ...this.toEggVariable(data), + serverValue: data.attributes.server_value, + }); + + static toAllocation = ({ attributes }: FractalResponseData): Allocation => ({ + id: attributes.id, + ip: attributes.ip, + port: attributes.port, + alias: attributes.alias || null, + isAssigned: attributes.assigned, + relationships: { + node: transform(attributes.relationships?.node as FractalResponseData, this.toNode), + server: transform(attributes.relationships?.server as FractalResponseData, this.toServer), + }, + getDisplayText (): string { + const raw = `${this.ip}:${this.port}`; + + return !this.alias ? raw : `${this.alias} (${raw})`; + }, + }); + + static toNest = ({ attributes }: FractalResponseData): Nest => ({ + id: attributes.id, + uuid: attributes.uuid, + author: attributes.author, + name: attributes.name, + description: attributes.description, + createdAt: new Date(attributes.created_at), + updatedAt: new Date(attributes.updated_at), + relationships: { + eggs: transform(attributes.relationships?.eggs as FractalResponseList, this.toEgg), + }, + }); +} diff --git a/resources/scripts/api/admin/user.ts b/resources/scripts/api/admin/user.ts new file mode 100644 index 000000000..b92315208 --- /dev/null +++ b/resources/scripts/api/admin/user.ts @@ -0,0 +1,44 @@ +import { Model, UUID } from '@/api/admin/index'; +import { Server } from '@/api/admin/server'; +import http, { QueryBuilderParams, withQueryBuilderParams } from '@/api/http'; +import { AdminTransformers } from '@/api/admin/transformers'; + +export interface User extends Model { + id: number; + uuid: UUID; + externalId: string; + username: string; + email: string; + language: string; + adminRoleId: number | null; + roleName: string; + isRootAdmin: boolean; + isUsingTwoFactor: boolean; + avatarUrl: string; + createdAt: Date; + updatedAt: Date; + relationships: { + role: UserRole | null; + servers?: Server[]; + }; +} + +export interface UserRole extends Model { + id: string; + name: string; + description: string; +} + +export const getUser = async (id: string | number): Promise => { + const { data } = await http.get(`/api/application/users/${id}`); + + return AdminTransformers.toUser(data.data); +}; + +export const searchUserAccounts = async (params: QueryBuilderParams<'username' | 'email'>): Promise => { + const { data } = await http.get('/api/application/users', { + params: withQueryBuilderParams(params), + }); + + return data.data.map(AdminTransformers.toUser); +}; diff --git a/resources/scripts/api/admin/users/searchUsers.ts b/resources/scripts/api/admin/users/searchUsers.ts deleted file mode 100644 index 450ca436c..000000000 --- a/resources/scripts/api/admin/users/searchUsers.ts +++ /dev/null @@ -1,25 +0,0 @@ -import http from '@/api/http'; -import { User, rawDataToUser } from '@/api/admin/users/getUsers'; - -interface Filters { - username?: string; - email?: string; -} - -export default (filters?: Filters): Promise => { - const params = {}; - if (filters !== undefined) { - Object.keys(filters).forEach(key => { - // @ts-ignore - params['filter[' + key + ']'] = filters[key]; - }); - } - - return new Promise((resolve, reject) => { - http.get('/api/application/users', { params: { ...params } }) - .then(response => resolve( - (response.data.data || []).map(rawDataToUser) - )) - .catch(reject); - }); -}; diff --git a/resources/scripts/api/admin/users/updateUser.ts b/resources/scripts/api/admin/users/updateUser.ts index 806a62618..449f7be15 100644 --- a/resources/scripts/api/admin/users/updateUser.ts +++ b/resources/scripts/api/admin/users/updateUser.ts @@ -2,6 +2,7 @@ import http from '@/api/http'; import { User, rawDataToUser } from '@/api/admin/users/getUsers'; export interface Values { + externalId: string; username: string; email: string; password: string; diff --git a/resources/scripts/api/http.ts b/resources/scripts/api/http.ts index 4e33541ab..ef2ed8956 100644 --- a/resources/scripts/api/http.ts +++ b/resources/scripts/api/http.ts @@ -7,10 +7,21 @@ const http: AxiosInstance = axios.create({ 'X-Requested-With': 'XMLHttpRequest', Accept: 'application/json', 'Content-Type': 'application/json', - 'X-CSRF-Token': (window as any).X_CSRF_TOKEN as string || '', }, }); +http.interceptors.request.use(req => { + const cookies = document.cookie.split(';').reduce((obj, val) => { + const [ key, value ] = val.trim().split('=').map(decodeURIComponent); + + return { ...obj, [key]: value }; + }, {} as Record); + + req.headers['X-XSRF-TOKEN'] = cookies['XSRF-TOKEN'] || 'nil'; + + return req; +}); + http.interceptors.request.use(req => { if (!req.url?.endsWith('/resources') && (req.url?.indexOf('_debugbar') || -1) < 0) { store.getActions().progress.startContinuous(); @@ -111,3 +122,43 @@ export function getPaginationSet (data: any): PaginationDataSet { totalPages: data.total_pages, }; } + +type QueryBuilderFilterValue = string | number | boolean | null; + +export interface QueryBuilderParams { + filters?: { + [K in FilterKeys]?: QueryBuilderFilterValue | Readonly; + }; + sorts?: { + [K in SortKeys]?: -1 | 0 | 1 | 'asc' | 'desc' | null; + }; +} + +/** + * Helper function that parses a data object provided and builds query parameters + * for the Laravel Query Builder package automatically. This will apply sorts and + * filters deterministically based on the provided values. + */ +export const withQueryBuilderParams = (data?: QueryBuilderParams): Record => { + if (!data) return {}; + + const filters = Object.keys(data.filters || {}).reduce((obj, key) => { + const value = data.filters?.[key]; + + return !value || value === '' ? obj : { ...obj, [`filter[${key}]`]: value }; + }, {} as NonNullable); + + const sorts = Object.keys(data.sorts || {}).reduce((arr, key) => { + const value = data.sorts?.[key]; + if (!value || ![ 'asc', 'desc', 1, -1 ].includes(value)) { + return arr; + } + + return [ ...arr, (value === -1 || value === 'desc' ? '-' : '') + key ]; + }, [] as string[]); + + return { + ...filters, + sorts: !sorts.length ? undefined : sorts.join(','), + }; +}; diff --git a/resources/scripts/api/server/getServerResourceUsage.ts b/resources/scripts/api/server/getServerResourceUsage.ts index 6b71dcf54..2a4c01cf6 100644 --- a/resources/scripts/api/server/getServerResourceUsage.ts +++ b/resources/scripts/api/server/getServerResourceUsage.ts @@ -10,6 +10,7 @@ export interface ServerStats { diskUsageInBytes: number; networkRxInBytes: number; networkTxInBytes: number; + uptime: number; } export default (server: string): Promise => { @@ -23,6 +24,7 @@ export default (server: string): Promise => { diskUsageInBytes: attributes.resources.disk_bytes, networkRxInBytes: attributes.resources.network_rx_bytes, networkTxInBytes: attributes.resources.network_tx_bytes, + uptime: attributes.resources.uptime, })) .catch(reject); }); diff --git a/resources/scripts/components/App.tsx b/resources/scripts/components/App.tsx index 6d73f5e66..0a22c5c4c 100644 --- a/resources/scripts/components/App.tsx +++ b/resources/scripts/components/App.tsx @@ -1,7 +1,6 @@ -import React, { lazy, useEffect, Suspense } from 'react'; -import ReactGA from 'react-ga'; +import React, { lazy, Suspense } from 'react'; import { hot } from 'react-hot-loader/root'; -import { Route, Router, Switch, useLocation } from 'react-router-dom'; +import { Route, Router, Switch } from 'react-router-dom'; import { StoreProvider } from 'easy-peasy'; import { store } from '@/state'; import DashboardRouter from '@/routers/DashboardRouter'; @@ -14,6 +13,7 @@ import tw from 'twin.macro'; import { history } from '@/components/history'; import { setupInterceptors } from '@/api/interceptors'; import GlobalStyles from '@/components/GlobalStyles'; +import Spinner from '@/components/elements/Spinner'; const ChunkedAdminRouter = lazy(() => import(/* webpackChunkName: "admin" */'@/routers/AdminRouter')); @@ -37,16 +37,6 @@ interface ExtendedWindow extends Window { setupInterceptors(history); -const Pageview = () => { - const { pathname } = useLocation(); - - useEffect(() => { - ReactGA.pageview(pathname); - }, [ pathname ]); - - return null; -}; - const App = () => { const { PterodactylUser, SiteConfiguration } = (window as ExtendedWindow); if (PterodactylUser && !store.getState().user.data) { @@ -68,12 +58,6 @@ const App = () => { store.getActions().settings.setSettings(SiteConfiguration!); } - useEffect(() => { - if (SiteConfiguration?.analytics) { - ReactGA.initialize(SiteConfiguration!.analytics); - } - }, []); - return ( <> @@ -81,8 +65,7 @@ const App = () => {
- Loading...
}> - {SiteConfiguration?.analytics && } + }> diff --git a/resources/scripts/components/NavigationBar.tsx b/resources/scripts/components/NavigationBar.tsx index 939c9ff07..b3620ba8b 100644 --- a/resources/scripts/components/NavigationBar.tsx +++ b/resources/scripts/components/NavigationBar.tsx @@ -6,6 +6,8 @@ import { useStoreState } from 'easy-peasy'; import { ApplicationStore } from '@/state'; import SearchContainer from '@/components/dashboard/search/SearchContainer'; import tw, { styled, theme } from 'twin.macro'; +import http from '@/api/http'; +import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; const Navigation = styled.div` ${tw`w-full bg-neutral-900 shadow-md overflow-x-auto`}; @@ -26,7 +28,7 @@ const Navigation = styled.div` const RightNavigation = styled.div` ${tw`flex h-full items-center justify-center`}; - & > a, & > .navigation-link { + & > a, & > button, & > .navigation-link { ${tw`flex items-center h-full no-underline text-neutral-300 px-6 cursor-pointer transition-all duration-150`}; &:active, &:hover { @@ -42,9 +44,19 @@ const RightNavigation = styled.div` export default () => { const name = useStoreState((state: ApplicationStore) => state.settings.data!.name); const rootAdmin = useStoreState((state: ApplicationStore) => state.user.data!.rootAdmin); + const [ isLoggingOut, setIsLoggingOut ] = React.useState(false); + + const onTriggerLogout = () => { + setIsLoggingOut(true); + http.post('/auth/logout').finally(() => { + // @ts-ignore + window.location = '/'; + }); + }; return ( +
@@ -60,16 +72,14 @@ export default () => { - {rootAdmin && - + - + } - - +
diff --git a/resources/scripts/components/admin/AdminBox.tsx b/resources/scripts/components/admin/AdminBox.tsx index 3c77e8180..da0543b7e 100644 --- a/resources/scripts/components/admin/AdminBox.tsx +++ b/resources/scripts/components/admin/AdminBox.tsx @@ -1,38 +1,36 @@ -import React, { memo } from 'react'; +import React from 'react'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { IconProp } from '@fortawesome/fontawesome-svg-core'; import tw from 'twin.macro'; -import isEqual from 'react-fast-compare'; +import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; interface Props { icon?: IconProp; + isLoading?: boolean; title: string | React.ReactNode; className?: string; - padding?: boolean; + noPadding?: boolean; children: React.ReactNode; + button?: React.ReactNode; } -const AdminBox = ({ icon, title, className, padding, children }: Props) => { - if (padding === undefined) { - padding = true; - } - - return ( -
-
- {typeof title === 'string' ? -

- {icon && }{title} -

- : - title - } -
-
- {children} -
+const AdminBox = ({ icon, title, className, isLoading, children, button, noPadding }: Props) => ( +
+ +
+ {typeof title === 'string' ? +

+ {icon && }{title} +

+ : + title + } + {button}
- ); -}; +
+ {children} +
+
+); -export default memo(AdminBox, isEqual); +export default AdminBox; diff --git a/resources/scripts/components/admin/AdminContentBlock.tsx b/resources/scripts/components/admin/AdminContentBlock.tsx index 5793014e4..d4317a330 100644 --- a/resources/scripts/components/admin/AdminContentBlock.tsx +++ b/resources/scripts/components/admin/AdminContentBlock.tsx @@ -15,11 +15,8 @@ const AdminContentBlock: React.FC<{ title?: string; showFlashKey?: string; class return ( // <> - {showFlashKey && - - } + {showFlashKey && } {children} - {/*

© 2015 - 2021  { ); }; -export const NoItems = () => { +export const NoItems = ({ className }: { className?: string }) => { return ( -

+
{'No
@@ -227,7 +227,8 @@ export const ContentWrapper = ({ checked, onSelectAllClick, onSearch, children } } setLoading(true); - onSearch(query).then(() => setLoading(false)); + onSearch(query) + .then(() => setLoading(false)); }, 200), [], ); diff --git a/resources/scripts/components/admin/Sidebar.tsx b/resources/scripts/components/admin/Sidebar.tsx new file mode 100644 index 000000000..b5ab0a047 --- /dev/null +++ b/resources/scripts/components/admin/Sidebar.tsx @@ -0,0 +1,84 @@ +import tw, { css } from 'twin.macro'; +import styled from 'styled-components/macro'; +import { withSubComponents } from '@/components/helpers'; + +const Wrapper = styled.div` + ${tw`w-full flex flex-col px-4`}; + + & > a { + ${tw`h-10 w-full flex flex-row items-center text-neutral-300 cursor-pointer select-none px-4`}; + ${tw`hover:text-neutral-50`}; + + & > svg { + ${tw`h-6 w-6 flex flex-shrink-0`}; + } + + & > span { + ${tw`font-header font-medium text-lg whitespace-nowrap leading-none ml-3`}; + } + + &:active, &.active { + ${tw`text-neutral-50 bg-neutral-800 rounded`}; + } + } +`; + +const Section = styled.div` + ${tw`h-[18px] font-header font-medium text-xs text-neutral-300 whitespace-nowrap uppercase ml-4 mb-1 select-none`}; + + &:not(:first-of-type) { + ${tw`mt-4`}; + } +`; + +const User = styled.div` + ${tw`h-16 w-full flex items-center bg-neutral-700 justify-center`}; +`; + +const Sidebar = styled.div<{ $collapsed?: boolean }>` + ${tw`h-screen hidden md:flex flex-col items-center flex-shrink-0 bg-neutral-900 overflow-x-hidden ease-linear`}; + ${tw`transition-[width] duration-150 ease-in`}; + ${tw`w-[17.5rem]`}; + + & > a { + ${tw`h-10 w-full flex flex-row items-center text-neutral-300 cursor-pointer select-none px-8`}; + ${tw`hover:text-neutral-50`}; + + & > svg { + ${tw`transition-none h-6 w-6 flex flex-shrink-0`}; + } + + & > span { + ${tw`font-header font-medium text-lg whitespace-nowrap leading-none ml-3`}; + } + } + + ${props => props.$collapsed && css` + ${tw`w-20`}; + + ${Section} { + ${tw`invisible`}; + } + + ${Wrapper} { + ${tw`px-5`}; + + & > a { + ${tw`justify-center px-0`}; + } + } + + & > a { + ${tw`justify-center px-4`}; + } + + & > a > span, + ${User} > div, + ${User} > a, + ${Wrapper} > a > span { + ${tw`hidden`}; + } + `}; +`; + +export default withSubComponents(Sidebar, { Section, Wrapper, User }); diff --git a/resources/scripts/components/admin/SubNavigation.tsx b/resources/scripts/components/admin/SubNavigation.tsx index e0dfe7091..42935c8b7 100644 --- a/resources/scripts/components/admin/SubNavigation.tsx +++ b/resources/scripts/components/admin/SubNavigation.tsx @@ -3,37 +3,38 @@ import { NavLink } from 'react-router-dom'; import tw, { styled } from 'twin.macro'; export const SubNavigation = styled.div` - ${tw`flex flex-row items-center flex-shrink-0 h-12 mb-4 border-b border-neutral-700`}; + ${tw`flex flex-row items-center flex-shrink-0 h-12 mb-4 border-b border-neutral-700`}; - & > div { - ${tw`flex flex-col justify-center flex-shrink-0 h-full`}; + & > a { + ${tw`flex flex-row items-center h-full px-4 border-b text-neutral-300 text-base whitespace-nowrap border-transparent`}; - & > a { - ${tw`flex flex-row items-center h-full px-4 border-t text-neutral-300`}; - border-top-color: transparent !important; - - & > svg { - ${tw`w-6 h-6 mr-2`}; - } - - & > span { - ${tw`text-base whitespace-nowrap`}; - } - - &:active, &.active { - ${tw`border-b text-primary-300 border-primary-300`}; - } - } + & > svg { + ${tw`w-6 h-6 mr-2`}; } + + &:active, &.active { + ${tw`text-primary-300 border-primary-300`}; + } + } `; -export const SubNavigationLink = ({ to, name, children }: { to: string, name: string, children: React.ReactNode }) => { - return ( -
- - {children} - {name} - -
- ); -}; +interface Props { + to: string; + name: string; +} + +interface PropsWithIcon extends Props { + icon: React.ComponentType; + children?: never; +} + +interface PropsWithoutIcon extends Props { + icon?: never; + children: React.ReactNode; +} + +export const SubNavigationLink = ({ to, name, icon: IconComponent, children }: PropsWithIcon | PropsWithoutIcon) => ( + + {IconComponent ? : children}{name} + +); diff --git a/resources/scripts/components/admin/databases/DatabaseEditContainer.tsx b/resources/scripts/components/admin/databases/DatabaseEditContainer.tsx index 35e95e958..bd58b1a11 100644 --- a/resources/scripts/components/admin/databases/DatabaseEditContainer.tsx +++ b/resources/scripts/components/admin/databases/DatabaseEditContainer.tsx @@ -134,7 +134,7 @@ export const InformationContainer = ({ title, initialValues, children, onSubmit {children}
diff --git a/resources/scripts/components/admin/locations/LocationEditContainer.tsx b/resources/scripts/components/admin/locations/LocationEditContainer.tsx index 98eeb061a..a465364bb 100644 --- a/resources/scripts/components/admin/locations/LocationEditContainer.tsx +++ b/resources/scripts/components/admin/locations/LocationEditContainer.tsx @@ -109,7 +109,7 @@ const EditInformationContainer = () => {
diff --git a/resources/scripts/components/admin/mounts/MountEditContainer.tsx b/resources/scripts/components/admin/mounts/MountEditContainer.tsx index 174720fa0..c7392e9a1 100644 --- a/resources/scripts/components/admin/mounts/MountEditContainer.tsx +++ b/resources/scripts/components/admin/mounts/MountEditContainer.tsx @@ -97,7 +97,7 @@ const MountEditContainer = () => { {
diff --git a/resources/scripts/components/admin/nests/NewEggContainer.tsx b/resources/scripts/components/admin/nests/NewEggContainer.tsx index 28c5b470a..0c48c70a5 100644 --- a/resources/scripts/components/admin/nests/NewEggContainer.tsx +++ b/resources/scripts/components/admin/nests/NewEggContainer.tsx @@ -1,8 +1,51 @@ -import React from 'react'; +import createEgg from '@/api/admin/eggs/createEgg'; +import { EggImageContainer, EggInformationContainer, EggLifecycleContainer, EggProcessContainer, EggProcessContainerRef, EggStartupContainer } from '@/components/admin/nests/eggs/EggSettingsContainer'; +import Button from '@/components/elements/Button'; +import FlashMessageRender from '@/components/FlashMessageRender'; +import useFlash from '@/plugins/useFlash'; +import { Form, Formik, FormikHelpers } from 'formik'; +import React, { useRef } from 'react'; +import { useParams } from 'react-router'; +import { useHistory } from 'react-router-dom'; import tw from 'twin.macro'; import AdminContentBlock from '@/components/admin/AdminContentBlock'; +import { object } from 'yup'; + +interface Values { + name: string; + description: string; + startup: string; + dockerImages: string; + configStop: string; + configStartup: string; + configFiles: string; +} export default () => { + const params = useParams<{ nestId: string }>(); + const history = useHistory(); + + const { clearFlashes, clearAndAddHttpError } = useFlash(); + + const ref = useRef(); + + const submit = async (values: Values, { setSubmitting }: FormikHelpers) => { + clearFlashes('egg:create'); + + const nestId = Number(params.nestId); + + values.configStartup = await ref.current?.getStartupConfiguration() || ''; + values.configFiles = await ref.current?.getFilesConfiguration() || ''; + + createEgg({ ...values, dockerImages: values.dockerImages.split('\n'), nestId }) + .then(egg => history.push(`/admin/nests/${nestId}/eggs/${egg.id}`)) + .catch(error => { + console.error(error); + clearAndAddHttpError({ key: 'egg:create', error }); + }) + .then(() => setSubmitting(false)); + }; + return (
@@ -11,6 +54,48 @@ export default () => {

Add a new egg to the panel.

+ + + + + {({ isSubmitting, isValid }) => ( +
+
+ +
+ + + +
+ + +
+ + + +
+
+ +
+
+ + )} +
); }; diff --git a/resources/scripts/components/admin/nests/eggs/EggExportButton.tsx b/resources/scripts/components/admin/nests/eggs/EggExportButton.tsx new file mode 100644 index 000000000..0a9442f05 --- /dev/null +++ b/resources/scripts/components/admin/nests/eggs/EggExportButton.tsx @@ -0,0 +1,85 @@ +import { exportEgg } from '@/api/admin/egg'; +import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; +import useFlash from '@/plugins/useFlash'; +import { jsonLanguage } from '@codemirror/lang-json'; +import Editor from '@/components/elements/Editor'; +import React, { useEffect, useState } from 'react'; +import Button from '@/components/elements/Button'; +import Modal from '@/components/elements/Modal'; +import FlashMessageRender from '@/components/FlashMessageRender'; +import { useRouteMatch } from 'react-router-dom'; +import tw from 'twin.macro'; + +export default ({ className }: { className?: string }) => { + const { params: { id: eggId } } = useRouteMatch<{ id: string }>(); + const { clearAndAddHttpError, clearFlashes } = useFlash(); + + const [ visible, setVisible ] = useState(false); + const [ loading, setLoading ] = useState(true); + const [ content, setContent ] = useState | null>(null); + + useEffect(() => { + if (!visible) { + return; + } + + clearFlashes('egg:export'); + setLoading(true); + + exportEgg(Number(eggId)) + .then(setContent) + .catch(error => clearAndAddHttpError({ key: 'egg:export', error })) + .then(() => setLoading(false)); + }, [ visible ]); + + return ( + <> + { + setVisible(false); + }} + css={tw`relative`} + > + +

Export Egg

+ + + + +
+ + +
+
+ + + + ); +}; diff --git a/resources/scripts/components/admin/nests/eggs/EggInstallContainer.tsx b/resources/scripts/components/admin/nests/eggs/EggInstallContainer.tsx index 878f682a7..5864a7567 100644 --- a/resources/scripts/components/admin/nests/eggs/EggInstallContainer.tsx +++ b/resources/scripts/components/admin/nests/eggs/EggInstallContainer.tsx @@ -1,4 +1,4 @@ -import { Egg } from '@/api/admin/eggs/getEgg'; +import { useEggFromRoute } from '@/api/admin/egg'; import updateEgg from '@/api/admin/eggs/updateEgg'; import Field from '@/components/elements/Field'; import useFlash from '@/plugins/useFlash'; @@ -18,9 +18,15 @@ interface Values { scriptInstall: string; } -export default function EggInstallContainer ({ egg }: { egg: Egg }) { +export default function EggInstallContainer () { const { clearFlashes, clearAndAddHttpError } = useFlash(); + const { data: egg } = useEggFromRoute(); + + if (!egg) { + return null; + } + let fetchFileContent: (() => Promise) | null = null; const submit = async (values: Values, { setSubmitting }: FormikHelpers) => { @@ -50,7 +56,7 @@ export default function EggInstallContainer ({ egg }: { egg: Egg }) { }} > {({ isSubmitting, isValid }) => ( - +
diff --git a/resources/scripts/components/admin/nests/eggs/EggRouter.tsx b/resources/scripts/components/admin/nests/eggs/EggRouter.tsx index 8ac6f15de..c2cee96fa 100644 --- a/resources/scripts/components/admin/nests/eggs/EggRouter.tsx +++ b/resources/scripts/components/admin/nests/eggs/EggRouter.tsx @@ -1,61 +1,37 @@ +import { useEggFromRoute } from '@/api/admin/egg'; import EggInstallContainer from '@/components/admin/nests/eggs/EggInstallContainer'; import EggVariablesContainer from '@/components/admin/nests/eggs/EggVariablesContainer'; -import React, { useEffect, useState } from 'react'; +import useFlash from '@/plugins/useFlash'; +import React, { useEffect } from 'react'; import { useLocation } from 'react-router'; import tw from 'twin.macro'; import { Route, Switch, useRouteMatch } from 'react-router-dom'; -import { action, Action, Actions, createContextStore, useStoreActions } from 'easy-peasy'; -import getEgg, { Egg } from '@/api/admin/eggs/getEgg'; import AdminContentBlock from '@/components/admin/AdminContentBlock'; import Spinner from '@/components/elements/Spinner'; import FlashMessageRender from '@/components/FlashMessageRender'; -import { ApplicationStore } from '@/state'; import { SubNavigation, SubNavigationLink } from '@/components/admin/SubNavigation'; import EggSettingsContainer from '@/components/admin/nests/eggs/EggSettingsContainer'; -interface ctx { - egg: Egg | undefined; - setEgg: Action; -} - -export const Context = createContextStore({ - egg: undefined, - - setEgg: action((state, payload) => { - state.egg = payload; - }), -}); - const EggRouter = () => { const location = useLocation(); - const match = useRouteMatch<{ id?: string }>(); + const match = useRouteMatch(); - const { clearFlashes, clearAndAddHttpError } = useStoreActions((actions: Actions) => actions.flashes); - const [ loading, setLoading ] = useState(true); - - const egg = Context.useStoreState(state => state.egg); - const setEgg = Context.useStoreActions(actions => actions.setEgg); + const { clearFlashes, clearAndAddHttpError } = useFlash(); + const { data: egg, error, isValidating, mutate } = useEggFromRoute(); useEffect(() => { - clearFlashes('egg'); - - getEgg(Number(match.params?.id)) - .then(egg => setEgg(egg)) - .catch(error => { - console.error(error); - clearAndAddHttpError({ key: 'egg', error }); - }) - .then(() => setLoading(false)); + mutate(); }, []); - if (loading || egg === undefined) { - return ( - - + useEffect(() => { + if (!error) clearFlashes('egg'); + if (error) clearAndAddHttpError({ key: 'egg', error }); + }, [ error ]); -
- -
+ if (!egg || (error && isValidating)) { + return ( + + ); } @@ -93,15 +69,15 @@ const EggRouter = () => { - + - + - +
@@ -109,9 +85,5 @@ const EggRouter = () => { }; export default () => { - return ( - - - - ); + return ; }; diff --git a/resources/scripts/components/admin/nests/eggs/EggSettingsContainer.tsx b/resources/scripts/components/admin/nests/eggs/EggSettingsContainer.tsx index f8dc1c365..70faac949 100644 --- a/resources/scripts/components/admin/nests/eggs/EggSettingsContainer.tsx +++ b/resources/scripts/components/admin/nests/eggs/EggSettingsContainer.tsx @@ -1,5 +1,7 @@ +import { useEggFromRoute } from '@/api/admin/egg'; import updateEgg from '@/api/admin/eggs/updateEgg'; import EggDeleteButton from '@/components/admin/nests/eggs/EggDeleteButton'; +import EggExportButton from '@/components/admin/nests/eggs/EggExportButton'; import Button from '@/components/elements/Button'; import Editor from '@/components/elements/Editor'; import Field, { TextareaField } from '@/components/elements/Field'; @@ -12,13 +14,12 @@ import { faDocker } from '@fortawesome/free-brands-svg-icons'; import { faEgg, faFireAlt, faMicrochip, faTerminal } from '@fortawesome/free-solid-svg-icons'; import React, { forwardRef, useImperativeHandle, useRef } from 'react'; import AdminBox from '@/components/admin/AdminBox'; -import { Egg } from '@/api/admin/eggs/getEgg'; import { useHistory } from 'react-router-dom'; import tw from 'twin.macro'; import { object } from 'yup'; import { Form, Formik, FormikHelpers, useFormikContext } from 'formik'; -function EggInformationContainer () { +export function EggInformationContainer () { const { isSubmitting } = useFormikContext(); return ( @@ -44,7 +45,13 @@ function EggInformationContainer () { ); } -function EggDetailsContainer ({ egg }: { egg: Egg }) { +function EggDetailsContainer () { + const { data: egg } = useEggFromRoute(); + + if (!egg) { + return null; + } + return (
@@ -72,7 +79,7 @@ function EggDetailsContainer ({ egg }: { egg: Egg }) { ); } -function EggStartupContainer ({ className }: { className?: string }) { +export function EggStartupContainer ({ className }: { className?: string }) { const { isSubmitting } = useFormikContext(); return ( @@ -90,7 +97,7 @@ function EggStartupContainer ({ className }: { className?: string }) { ); } -function EggImageContainer () { +export function EggImageContainer () { const { isSubmitting } = useFormikContext(); return ( @@ -107,7 +114,7 @@ function EggImageContainer () { ); } -function EggLifecycleContainer () { +export function EggLifecycleContainer () { const { isSubmitting } = useFormikContext(); return ( @@ -115,8 +122,8 @@ function EggLifecycleContainer () { Promise; getFilesConfiguration: () => Promise; } -const EggProcessContainer = forwardRef( - function EggProcessContainer ({ className, egg }, ref) { - const { isSubmitting } = useFormikContext(); +export const EggProcessContainer = forwardRef( + function EggProcessContainer ({ className }, ref) { + const { isSubmitting, values } = useFormikContext(); let fetchStartupConfiguration: (() => Promise) | null = null; let fetchFilesConfiguration: (() => Promise) | null = null; @@ -162,11 +168,11 @@ const EggProcessContainer = forwardRef( -
+
{ fetchStartupConfiguration = value; @@ -178,7 +184,7 @@ const EggProcessContainer = forwardRef( { fetchFilesConfiguration = value; @@ -195,17 +201,23 @@ interface Values { description: string; startup: string; dockerImages: string; - stopCommand: string; + configStop: string; configStartup: string; configFiles: string; } -export default function EggSettingsContainer ({ egg }: { egg: Egg }) { +export default function EggSettingsContainer () { const history = useHistory(); + const ref = useRef(); + const { clearFlashes, clearAndAddHttpError } = useFlash(); - const ref = useRef(); + const { data: egg } = useEggFromRoute(); + + if (!egg) { + return null; + } const submit = async (values: Values, { setSubmitting }: FormikHelpers) => { clearFlashes('egg'); @@ -229,9 +241,9 @@ export default function EggSettingsContainer ({ egg }: { egg: Egg }) { description: egg.description || '', startup: egg.startup, dockerImages: egg.dockerImages.join('\n'), - stopCommand: egg.configStop || '', - configStartup: '', - configFiles: '', + configStop: egg.configStop || '', + configStartup: JSON.stringify(egg.configStartup, null, '\t') || '', + configFiles: JSON.stringify(egg.configFiles, null, '\t') || '', }} validationSchema={object().shape({ })} @@ -240,7 +252,7 @@ export default function EggSettingsContainer ({ egg }: { egg: Egg }) {
- +
@@ -250,19 +262,16 @@ export default function EggSettingsContainer ({ egg }: { egg: Egg }) {
- + -
+
history.push('/admin/nests')} /> -
diff --git a/resources/scripts/components/admin/nests/eggs/EggVariablesContainer.tsx b/resources/scripts/components/admin/nests/eggs/EggVariablesContainer.tsx index 76a88ed33..0ddef6616 100644 --- a/resources/scripts/components/admin/nests/eggs/EggVariablesContainer.tsx +++ b/resources/scripts/components/admin/nests/eggs/EggVariablesContainer.tsx @@ -1,11 +1,210 @@ -import React from 'react'; +import deleteEggVariable from '@/api/admin/eggs/deleteEggVariable'; +import { NoItems } from '@/components/admin/AdminTable'; +import ConfirmationModal from '@/components/elements/ConfirmationModal'; +import { Form, Formik, FormikHelpers, useFormikContext } from 'formik'; +import React, { useState } from 'react'; +import tw from 'twin.macro'; +import { array, boolean, object, string } from 'yup'; +import { EggVariable, useEggFromRoute } from '@/api/admin/egg'; +import updateEggVariables from '@/api/admin/eggs/updateEggVariables'; +import NewVariableButton from '@/components/admin/nests/eggs/NewVariableButton'; import AdminBox from '@/components/admin/AdminBox'; -import { Egg } from '@/api/admin/eggs/getEgg'; +import Button from '@/components/elements/Button'; +import Checkbox from '@/components/elements/Checkbox'; +import Field, { FieldRow, TextareaField } from '@/components/elements/Field'; +import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; +import useFlash from '@/plugins/useFlash'; +import { TrashIcon } from '@heroicons/react/outline'; -export default ({ egg }: { egg: Egg }) => { +export const validationSchema = object().shape({ + name: string().required().min(1).max(191), + description: string(), + environmentVariable: string().required().min(1).max(191), + defaultValue: string(), + isUserViewable: boolean().required(), + isUserEditable: boolean().required(), + rules: string().required(), +}); + +export function EggVariableForm ({ prefix }: { prefix: string }) { return ( - - {egg.name} + <> + + + + + + + + + + +
+ + + +
+ + + + ); +} + +function EggVariableDeleteButton ({ onClick }: { onClick: (success: () => void) => void }) { + const [ visible, setVisible ] = useState(false); + const [ loading, setLoading ] = useState(false); + + const onDelete = () => { + setLoading(true); + + onClick(() => { + //setLoading(false); + }); + }; + + return ( + <> + setVisible(false)} + > + Are you sure you want to delete this variable? Deleting this variable will delete it from every server + using this egg. + + + + + ); +} + +function EggVariableBox ({ onDeleteClick, variable, prefix }: { onDeleteClick: (success: () => void) => void, variable: EggVariable, prefix: string }) { + const { isSubmitting } = useFormikContext(); + + return ( + {variable.name}

} + button={} + > + + +
); -}; +} + +export default function EggVariablesContainer () { + const { clearAndAddHttpError } = useFlash(); + + const { data: egg, mutate } = useEggFromRoute(); + + if (!egg) { + return null; + } + + const submit = (values: EggVariable[], { setSubmitting }: FormikHelpers) => { + updateEggVariables(egg.id, values) + .then(async () => await mutate()) + .catch(error => clearAndAddHttpError({ key: 'egg', error })) + .then(() => setSubmitting(false)); + }; + + return ( + + {({ isSubmitting, isValid }) => ( + +
+ {egg.relationships.variables?.length === 0 ? + + : +
+ {egg.relationships.variables.map((v, i) => ( + { + deleteEggVariable(egg.id, v.id) + .then(async () => { + await mutate(egg => ({ + ...egg!, + relationships: { + ...egg!.relationships, + variables: egg!.relationships.variables!.filter(v2 => v.id === v2.id), + }, + })); + success(); + }) + .catch(error => clearAndAddHttpError({ key: 'egg', error })); + }} + /> + ))} +
+ } + +
+
+ + + +
+
+
+ + )} +
+ ); +} diff --git a/resources/scripts/components/admin/nests/eggs/NewVariableButton.tsx b/resources/scripts/components/admin/nests/eggs/NewVariableButton.tsx new file mode 100644 index 000000000..018912abf --- /dev/null +++ b/resources/scripts/components/admin/nests/eggs/NewVariableButton.tsx @@ -0,0 +1,93 @@ +import { Form, Formik, FormikHelpers, useFormikContext } from 'formik'; +import React, { useState } from 'react'; +import tw from 'twin.macro'; +import createEggVariable, { CreateEggVariable } from '@/api/admin/eggs/createEggVariable'; +import { useEggFromRoute } from '@/api/admin/egg'; +import { EggVariableForm, validationSchema } from '@/components/admin/nests/eggs/EggVariablesContainer'; +import Modal from '@/components/elements/Modal'; +import FlashMessageRender from '@/components/FlashMessageRender'; +import Button from '@/components/elements/Button'; +import useFlash from '@/plugins/useFlash'; + +export default function NewVariableButton () { + const { setValues } = useFormikContext(); + const [ visible, setVisible ] = useState(false); + const { clearFlashes, clearAndAddHttpError } = useFlash(); + + const { data: egg, mutate } = useEggFromRoute(); + + if (!egg) { + return null; + } + + const submit = (values: CreateEggVariable, { setSubmitting }: FormikHelpers) => { + clearFlashes('variable:create'); + + createEggVariable(egg.id, values) + .then(async (variable) => { + setValues([ ...egg.relationships.variables, variable ]); + await mutate(egg => ({ ...egg!, relationships: { ...egg!.relationships, variables: [ ...egg!.relationships.variables, variable ] } })); + setVisible(false); + }) + .catch(error => { + clearAndAddHttpError({ key: 'variable:create', error }); + setSubmitting(false); + }); + }; + + return ( + <> + + {({ isSubmitting, isValid, resetForm }) => ( + { + resetForm(); + setVisible(false); + }} + > + + +

New Variable

+ +
+ + +
+ + +
+ +
+ )} +
+ + + + ); +} diff --git a/resources/scripts/components/admin/nodes/NodeAllocationContainer.tsx b/resources/scripts/components/admin/nodes/NodeAllocationContainer.tsx index eef598835..9e6c2f494 100644 --- a/resources/scripts/components/admin/nodes/NodeAllocationContainer.tsx +++ b/resources/scripts/components/admin/nodes/NodeAllocationContainer.tsx @@ -13,12 +13,12 @@ export default () => { <>
- +
- +
diff --git a/resources/scripts/components/admin/nodes/allocations/AllocationTable.tsx b/resources/scripts/components/admin/nodes/allocations/AllocationTable.tsx index 57b4132dc..aa93b37bc 100644 --- a/resources/scripts/components/admin/nodes/allocations/AllocationTable.tsx +++ b/resources/scripts/components/admin/nodes/allocations/AllocationTable.tsx @@ -5,6 +5,7 @@ import tw from 'twin.macro'; import getAllocations, { Context as AllocationsContext, Filters } from '@/api/admin/nodes/allocations/getAllocations'; import AdminCheckbox from '@/components/admin/AdminCheckbox'; import AdminTable, { ContentWrapper, Loading, NoItems, Pagination, TableBody, TableHead, TableHeader, useTableHooks } from '@/components/admin/AdminTable'; +import DeleteAllocationButton from '@/components/admin/nodes/allocations/DeleteAllocationButton'; import CopyOnClick from '@/components/elements/CopyOnClick'; import useFlash from '@/plugins/useFlash'; @@ -29,7 +30,7 @@ function RowCheckbox ({ id }: { id: number }) { } interface Props { - nodeId: string; + nodeId: number; filters?: Filters; } @@ -37,7 +38,7 @@ function AllocationsTable ({ nodeId, filters }: Props) { const { clearFlashes, clearAndAddHttpError } = useFlash(); const { page, setPage, setFilters, sort, setSort, sortDirection } = useContext(AllocationsContext); - const { data: allocations, error, isValidating } = getAllocations(nodeId, [ 'server' ]); + const { data: allocations, error, isValidating, mutate } = getAllocations(nodeId, [ 'server' ]); const length = allocations?.items?.length || 0; @@ -49,7 +50,7 @@ function AllocationsTable ({ nodeId, filters }: Props) { }; const onSearch = (query: string): Promise => { - return new Promise((resolve) => { + return new Promise(resolve => { if (query.length < 2) { setFilters(filters || null); } else { @@ -87,6 +88,7 @@ function AllocationsTable ({ nodeId, filters }: Props) { setSort('port')}/> + @@ -128,6 +130,24 @@ function AllocationsTable ({ nodeId, filters }: Props) { : } + + + { + await mutate(allocations => ({ + pagination: allocations!.pagination, + items: allocations!.items.filter(a => a.id === allocation.id), + })); + + // Go back a page if no more items will exist on the current page. + if (allocations?.items.length - 1 % 10 === 0) { + setPage(p => p - 1); + } + }} + /> + )) } diff --git a/resources/scripts/components/admin/nodes/allocations/CreateAllocationForm.tsx b/resources/scripts/components/admin/nodes/allocations/CreateAllocationForm.tsx index dc95d73f1..ac3704564 100644 --- a/resources/scripts/components/admin/nodes/allocations/CreateAllocationForm.tsx +++ b/resources/scripts/components/admin/nodes/allocations/CreateAllocationForm.tsx @@ -19,7 +19,7 @@ const distinct = (value: any, index: any, self: any) => { return self.indexOf(value) === index; }; -function CreateAllocationForm ({ nodeId }: { nodeId: string | number }) { +function CreateAllocationForm ({ nodeId }: { nodeId: number }) { const [ ips, setIPs ] = useState([]); const [ ports ] = useState([]); @@ -66,51 +66,49 @@ function CreateAllocationForm ({ nodeId }: { nodeId: string | number }) { ports: array(number()).min(1, 'You must select at least one port.'), })} > - { - ({ isSubmitting, isValid }) => ( -
- ( + + + + + +
+ +
- - -
- +
+
+
- -
-
- -
-
- - ) - } +
+ + )} ); } diff --git a/resources/scripts/components/admin/nodes/allocations/DeleteAllocationButton.tsx b/resources/scripts/components/admin/nodes/allocations/DeleteAllocationButton.tsx new file mode 100644 index 000000000..322def5d1 --- /dev/null +++ b/resources/scripts/components/admin/nodes/allocations/DeleteAllocationButton.tsx @@ -0,0 +1,62 @@ +import React, { useState } from 'react'; +import { Actions, useStoreActions } from 'easy-peasy'; +import { ApplicationStore } from '@/state'; +import tw from 'twin.macro'; +import Button from '@/components/elements/Button'; +import ConfirmationModal from '@/components/elements/ConfirmationModal'; +import deleteAllocation from '@/api/admin/nodes/allocations/deleteAllocation'; + +interface Props { + nodeId: number; + allocationId: number; + onDeleted?: () => void; +} + +export default ({ nodeId, allocationId, onDeleted }: Props) => { + const [ visible, setVisible ] = useState(false); + const [ loading, setLoading ] = useState(false); + + const { clearFlashes, clearAndAddHttpError } = useStoreActions((actions: Actions) => actions.flashes); + + const onDelete = () => { + setLoading(true); + clearFlashes('allocation'); + + deleteAllocation(nodeId, allocationId) + .then(() => { + setLoading(false); + setVisible(false); + if (onDeleted !== undefined) { + onDeleted(); + } + }) + .catch(error => { + console.error(error); + clearAndAddHttpError({ key: 'allocation', error }); + + setLoading(false); + setVisible(false); + }); + }; + + return ( + <> + setVisible(false)} + > + Are you sure you want to delete this allocation? + + + + + ); +}; diff --git a/resources/scripts/components/admin/roles/RoleEditContainer.tsx b/resources/scripts/components/admin/roles/RoleEditContainer.tsx index 912da287c..747c9a828 100644 --- a/resources/scripts/components/admin/roles/RoleEditContainer.tsx +++ b/resources/scripts/components/admin/roles/RoleEditContainer.tsx @@ -109,7 +109,7 @@ const EditInformationContainer = () => {
diff --git a/resources/scripts/components/admin/servers/EggSelect.tsx b/resources/scripts/components/admin/servers/EggSelect.tsx index 115633ca0..00aabd7d1 100644 --- a/resources/scripts/components/admin/servers/EggSelect.tsx +++ b/resources/scripts/components/admin/servers/EggSelect.tsx @@ -1,70 +1,69 @@ +import { useField } from 'formik'; +import React, { useEffect, useState } from 'react'; +import { WithRelationships } from '@/api/admin'; +import { Egg, searchEggs } from '@/api/admin/egg'; import Label from '@/components/elements/Label'; import Select from '@/components/elements/Select'; -import { useFormikContext } from 'formik'; -import React, { useEffect, useState } from 'react'; -import { Egg } from '@/api/admin/eggs/getEgg'; -import searchEggs from '@/api/admin/nests/searchEggs'; -export default ({ nestId, egg, setEgg }: { nestId: number | null; egg: Egg | null, setEgg: (value: Egg | null) => void }) => { - const { setFieldValue } = useFormikContext(); +interface Props { + nestId?: number; + selectedEggId?: number; + onEggSelect: (egg: Egg | null) => void; +} - const [ eggs, setEggs ] = useState([]); +export default ({ nestId, selectedEggId, onEggSelect }: Props) => { + const [ , , { setValue, setTouched } ] = useField>('environment'); + const [ eggs, setEggs ] = useState[] | null>(null); - /** - * So you may be asking yourself, "what cluster-fuck of code is this?" - * - * Well, this code makes sure that when the egg changes, that the environment - * object has empty string values instead of undefined so React doesn't think - * the variable fields are uncontrolled. - */ - const setEgg2 = (newEgg: Egg | null) => { - if (newEgg === null) { - setEgg(null); + const selectEgg = (egg: Egg | null) => { + if (egg === null) { + onEggSelect(null); return; } - // Reset all variables to be empty, don't inherit the previous values. - const newVariables = newEgg?.relations.variables; - newVariables?.forEach(v => setFieldValue('environment.' + v.envVariable, '')); - const variables = egg?.relations.variables?.filter(v => newVariables?.find(v2 => v2.envVariable === v.envVariable) === undefined); + // Clear values + setValue({}); + setTouched(true); - setEgg(newEgg); + onEggSelect(egg); - // Clear any variables that don't exist on the new egg. - variables?.forEach(v => setFieldValue('environment.' + v.envVariable, undefined)); + const values: Record = {}; + egg.relationships.variables?.forEach(v => { values[v.environmentVariable] = v.defaultValue; }); + setValue(values); + setTouched(true); }; useEffect(() => { - if (nestId === null) { + if (!nestId) { + setEggs(null); return; } - searchEggs(nestId, {}, [ 'variables' ]) + searchEggs(nestId, {}) .then(eggs => { setEggs(eggs); - if (eggs.length < 1) { - setEgg2(null); - return; - } - setEgg2(eggs[0]); + selectEgg(eggs[0] || null); }) .catch(error => console.error(error)); }, [ nestId ]); + const onSelectChange = (e: React.ChangeEvent) => { + selectEgg(eggs?.find(egg => egg.id.toString() === e.currentTarget.value) || null); + }; + return ( <> - + {!eggs ? + + : + eggs.map(v => ( + + )) + } ); diff --git a/resources/scripts/components/admin/servers/NestSelect.tsx b/resources/scripts/components/admin/servers/NestSelect.tsx deleted file mode 100644 index 26e6d8b97..000000000 --- a/resources/scripts/components/admin/servers/NestSelect.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import Label from '@/components/elements/Label'; -import Select from '@/components/elements/Select'; -import React, { useEffect, useState } from 'react'; -import { Nest } from '@/api/admin/nests/getNests'; -import searchNests from '@/api/admin/nests/searchNests'; - -export default ({ nestId, setNestId }: { nestId: number | null; setNestId: (value: number | null) => void }) => { - const [ nests, setNests ] = useState(null); - - useEffect(() => { - searchNests({}) - .then(nests => setNests(nests)) - .catch(error => console.error(error)); - }, []); - - return ( - <> - - - - ); -}; diff --git a/resources/scripts/components/admin/servers/NestSelector.tsx b/resources/scripts/components/admin/servers/NestSelector.tsx new file mode 100644 index 000000000..86adf4d5b --- /dev/null +++ b/resources/scripts/components/admin/servers/NestSelector.tsx @@ -0,0 +1,41 @@ +import Label from '@/components/elements/Label'; +import Select from '@/components/elements/Select'; +import React, { useEffect, useState } from 'react'; +import { Nest, searchNests } from '@/api/admin/nest'; + +interface Props { + selectedNestId?: number; + onNestSelect: (nest: number) => void; +} + +export default ({ selectedNestId, onNestSelect }: Props) => { + const [ nests, setNests ] = useState(null); + + useEffect(() => { + searchNests({}) + .then(nests => { + setNests(nests); + if (selectedNestId === 0 && nests.length > 0) { + onNestSelect(nests[0].id); + } + }) + .catch(error => console.error(error)); + }, []); + + return ( + <> + + + + ); +}; diff --git a/resources/scripts/components/admin/servers/NewServerContainer.tsx b/resources/scripts/components/admin/servers/NewServerContainer.tsx index 2b121915c..e92f46cd6 100644 --- a/resources/scripts/components/admin/servers/NewServerContainer.tsx +++ b/resources/scripts/components/admin/servers/NewServerContainer.tsx @@ -1,8 +1,159 @@ -import React from 'react'; +import { Egg } from '@/api/admin/egg'; +import AdminBox from '@/components/admin/AdminBox'; +import NodeSelect from '@/components/admin/servers/NodeSelect'; +import { ServerImageContainer, ServerServiceContainer, ServerVariableContainer } from '@/components/admin/servers/ServerStartupContainer'; +import BaseSettingsBox from '@/components/admin/servers/settings/BaseSettingsBox'; +import FeatureLimitsBox from '@/components/admin/servers/settings/FeatureLimitsBox'; +import ServerResourceBox from '@/components/admin/servers/settings/ServerResourceBox'; +import Button from '@/components/elements/Button'; +import Field from '@/components/elements/Field'; +import FormikSwitch from '@/components/elements/FormikSwitch'; +import Label from '@/components/elements/Label'; +import Select from '@/components/elements/Select'; +import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; +import FlashMessageRender from '@/components/FlashMessageRender'; +import useFlash from '@/plugins/useFlash'; +import { faNetworkWired } from '@fortawesome/free-solid-svg-icons'; +import { Form, Formik, FormikHelpers, useFormikContext } from 'formik'; +import React, { useEffect, useState } from 'react'; +import { useHistory } from 'react-router-dom'; import tw from 'twin.macro'; import AdminContentBlock from '@/components/admin/AdminContentBlock'; +import { object } from 'yup'; +import createServer, { CreateServerRequest } from '@/api/admin/servers/createServer'; +import { Allocation, Node, getAllocations } from '@/api/admin/node'; + +function InternalForm () { + const { isSubmitting, isValid, setFieldValue, values: { environment } } = useFormikContext(); + + const [ egg, setEgg ] = useState(null); + const [ node, setNode ] = useState(null); + const [ allocations, setAllocations ] = useState(null); + + useEffect(() => { + if (egg === null) { + return; + } + + setFieldValue('eggId', egg.id); + setFieldValue('startup', ''); + setFieldValue('image', egg.dockerImages.length > 0 ? egg.dockerImages[0] : ''); + }, [ egg ]); + + useEffect(() => { + if (node === null) { + return; + } + + // server_id: 0 filters out assigned allocations + getAllocations(node.id, { filters: { server_id: '0' } }) + .then(setAllocations); + }, [ node ]); + + return ( +
+
+
+ + +
+ +
+
+ + +
+
+ +
+
+ + +
+ {/*
*/} + {/* /!* TODO: Multi-select *!/*/} + {/* */} + {/* */} + {/*
*/} +
+
+ + +
+ + + + + + + +
+ {/* This ensures that no variables are rendered unless the environment has a value for the variable. */} + {egg?.relationships.variables?.filter(v => Object.keys(environment).find(e => e === v.environmentVariable) !== undefined).map((v, i) => ( + + ))} +
+ +
+
+ +
+
+
+
+ ); +} export default () => { + const history = useHistory(); + + const { clearFlashes, clearAndAddHttpError } = useFlash(); + + const submit = (r: CreateServerRequest, { setSubmitting }: FormikHelpers) => { + clearFlashes('server:create'); + + createServer(r) + .then(s => history.push(`/admin/servers/${s.id}`)) + .catch(error => clearAndAddHttpError({ key: 'server:create', error })) + .then(() => setSubmitting(false)); + }; + return (
@@ -11,6 +162,48 @@ export default () => {

Add a new server to the panel.

+ + + + + + ); }; diff --git a/resources/scripts/components/admin/servers/NodeSelect.tsx b/resources/scripts/components/admin/servers/NodeSelect.tsx new file mode 100644 index 000000000..3188eb921 --- /dev/null +++ b/resources/scripts/components/admin/servers/NodeSelect.tsx @@ -0,0 +1,44 @@ +import React, { useState } from 'react'; +import { useFormikContext } from 'formik'; +import SearchableSelect, { Option } from '@/components/elements/SearchableSelect'; +import { Node, searchNodes } from '@/api/admin/node'; + +export default ({ node, setNode }: { node: Node | null, setNode: (_: Node | null) => void }) => { + const { setFieldValue } = useFormikContext(); + + const [ nodes, setNodes ] = useState(null); + + const onSearch = async (query: string) => { + setNodes(await searchNodes({ filters: { name: query } })); + }; + + const onSelect = (node: Node | null) => { + setNode(node); + setFieldValue('nodeId', node?.id || null); + }; + + const getSelectedText = (node: Node | null): string => node?.name || ''; + + return ( + + {nodes?.map(d => ( + + ))} + + ); +}; diff --git a/resources/scripts/components/admin/servers/OwnerSelect.tsx b/resources/scripts/components/admin/servers/OwnerSelect.tsx index c29348c69..e97f3b0b2 100644 --- a/resources/scripts/components/admin/servers/OwnerSelect.tsx +++ b/resources/scripts/components/admin/servers/OwnerSelect.tsx @@ -1,34 +1,26 @@ import React, { useState } from 'react'; import { useFormikContext } from 'formik'; -import { User } from '@/api/admin/users/getUsers'; -import searchUsers from '@/api/admin/users/searchUsers'; import SearchableSelect, { Option } from '@/components/elements/SearchableSelect'; +import { User, searchUserAccounts } from '@/api/admin/user'; -export default ({ selected }: { selected: User | null }) => { - const context = useFormikContext(); +export default ({ selected }: { selected?: User }) => { + const { setFieldValue } = useFormikContext(); - const [ user, setUser ] = useState(selected); + const [ user, setUser ] = useState(selected || null); const [ users, setUsers ] = useState(null); - const onSearch = (query: string): Promise => { - return new Promise((resolve, reject) => { - searchUsers({ username: query, email: query }) - .then((users) => { - setUsers(users); - return resolve(); - }) - .catch(reject); - }); + const onSearch = async (query: string) => { + setUsers( + await searchUserAccounts({ filters: { username: query, email: query } }) + ); }; const onSelect = (user: User | null) => { setUser(user); - context.setFieldValue('ownerId', user?.id || null); + setFieldValue('ownerId', user?.id || null); }; - const getSelectedText = (user: User | null): string => { - return user?.email || ''; - }; + const getSelectedText = (user: User | null): string => user?.email || ''; return ( void; -} - -export default ({ serverId, onDeleted }: Props) => { +export default () => { + const history = useHistory(); const [ visible, setVisible ] = useState(false); const [ loading, setLoading ] = useState(false); + const { data: server } = useServerFromRoute(); - const { clearFlashes, clearAndAddHttpError } = useStoreActions((actions: Actions) => actions.flashes); + const { + clearFlashes, + clearAndAddHttpError, + } = useStoreActions((actions: Actions) => actions.flashes); const onDelete = () => { + if (!server) return; + setLoading(true); clearFlashes('server'); - deleteServer(serverId) - .then(() => { - setLoading(false); - onDeleted(); - }) + deleteServer(server.id) + .then(() => history.push('/admin/servers')) .catch(error => { console.error(error); clearAndAddHttpError({ key: 'server', error }); @@ -35,6 +37,8 @@ export default ({ serverId, onDeleted }: Props) => { }); }; + if (!server) return null; + return ( <> { > Are you sure you want to delete this server? - - ); diff --git a/resources/scripts/components/admin/servers/ServerManageContainer.tsx b/resources/scripts/components/admin/servers/ServerManageContainer.tsx index 56c82b289..fba2f6d49 100644 --- a/resources/scripts/components/admin/servers/ServerManageContainer.tsx +++ b/resources/scripts/components/admin/servers/ServerManageContainer.tsx @@ -1,17 +1,13 @@ import React from 'react'; import AdminBox from '@/components/admin/AdminBox'; import tw from 'twin.macro'; -import { Context } from '@/components/admin/servers/ServerRouter'; import Button from '@/components/elements/Button'; +import { useServerFromRoute } from '@/api/admin/server'; -const ServerManageContainer = () => { - const server = Context.useStoreState(state => state.server); +export default () => { + const { data: server } = useServerFromRoute(); - if (server === undefined) { - return ( - <> - ); - } + if (!server) return null; return (
@@ -52,17 +48,3 @@ const ServerManageContainer = () => {
); }; - -export default () => { - const server = Context.useStoreState(state => state.server); - - if (server === undefined) { - return ( - <> - ); - } - - return ( - - ); -}; diff --git a/resources/scripts/components/admin/servers/ServerRouter.tsx b/resources/scripts/components/admin/servers/ServerRouter.tsx index 3439b703c..b7ba294e6 100644 --- a/resources/scripts/components/admin/servers/ServerRouter.tsx +++ b/resources/scripts/components/admin/servers/ServerRouter.tsx @@ -1,119 +1,65 @@ import ServerManageContainer from '@/components/admin/servers/ServerManageContainer'; import ServerStartupContainer from '@/components/admin/servers/ServerStartupContainer'; -import React, { useEffect, useState } from 'react'; +import React, { useEffect } from 'react'; import { useLocation } from 'react-router'; import tw from 'twin.macro'; import { Route, Switch, useRouteMatch } from 'react-router-dom'; -import { action, Action, Actions, createContextStore, useStoreActions } from 'easy-peasy'; -import { Server } from '@/api/admin/servers/getServers'; -import getServer from '@/api/admin/servers/getServer'; import AdminContentBlock from '@/components/admin/AdminContentBlock'; import Spinner from '@/components/elements/Spinner'; import FlashMessageRender from '@/components/FlashMessageRender'; -import { ApplicationStore } from '@/state'; import { SubNavigation, SubNavigationLink } from '@/components/admin/SubNavigation'; import ServerSettingsContainer from '@/components/admin/servers/ServerSettingsContainer'; +import useFlash from '@/plugins/useFlash'; +import { useServerFromRoute } from '@/api/admin/server'; +import { AdjustmentsIcon, CogIcon, DatabaseIcon, FolderIcon, ShieldExclamationIcon } from '@heroicons/react/outline'; -export const ServerIncludes = [ 'allocations', 'user', 'variables' ]; - -interface ctx { - server: Server | undefined; - setServer: Action; -} - -export const Context = createContextStore({ - server: undefined, - - setServer: action((state, payload) => { - state.server = payload; - }), -}); - -const ServerRouter = () => { +export default () => { const location = useLocation(); const match = useRouteMatch<{ id?: string }>(); - const { clearFlashes, clearAndAddHttpError } = useStoreActions((actions: Actions) => actions.flashes); - const [ loading, setLoading ] = useState(true); - - const server = Context.useStoreState(state => state.server); - const setServer = Context.useStoreActions(actions => actions.setServer); + const { clearFlashes, clearAndAddHttpError } = useFlash(); + const { data: server, error, isValidating, mutate } = useServerFromRoute(); useEffect(() => { - clearFlashes('server'); - - getServer(Number(match.params?.id), ServerIncludes) - .then(server => setServer(server)) - .catch(error => { - console.error(error); - clearAndAddHttpError({ key: 'server', error }); - }) - .then(() => setLoading(false)); + mutate(); }, []); - if (loading || server === undefined) { - return ( - - + useEffect(() => { + if (!error) clearFlashes('server'); + if (error) clearAndAddHttpError({ key: 'server', error }); + }, [ error ]); -
- -
+ if (!server || (error && isValidating)) { + return ( + + ); } return ( +

{server.name}

{server.uuid}

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + - - + - + @@ -122,11 +68,3 @@ const ServerRouter = () => {
); }; - -export default () => { - return ( - - - - ); -}; diff --git a/resources/scripts/components/admin/servers/ServerSettingsContainer.tsx b/resources/scripts/components/admin/servers/ServerSettingsContainer.tsx index d1c890c55..e46fe2473 100644 --- a/resources/scripts/components/admin/servers/ServerSettingsContainer.tsx +++ b/resources/scripts/components/admin/servers/ServerSettingsContainer.tsx @@ -1,239 +1,22 @@ -import getAllocations from '@/api/admin/nodes/getAllocations'; -import { Server } from '@/api/admin/servers/getServers'; +import { useServerFromRoute } from '@/api/admin/server'; import ServerDeleteButton from '@/components/admin/servers/ServerDeleteButton'; -import Label from '@/components/elements/Label'; -import Select from '@/components/elements/Select'; -import SelectField, { AsyncSelectField, Option } from '@/components/elements/SelectField'; -import { faBalanceScale, faCogs, faConciergeBell, faNetworkWired } from '@fortawesome/free-solid-svg-icons'; import React from 'react'; -import AdminBox from '@/components/admin/AdminBox'; -import { useHistory } from 'react-router-dom'; import tw from 'twin.macro'; import { object } from 'yup'; import updateServer, { Values } from '@/api/admin/servers/updateServer'; -import Field from '@/components/elements/Field'; -import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; -import { Form, Formik, FormikHelpers, useFormikContext } from 'formik'; -import { Context, ServerIncludes } from '@/components/admin/servers/ServerRouter'; -import { ApplicationStore } from '@/state'; -import { Actions, useStoreActions } from 'easy-peasy'; -import OwnerSelect from '@/components/admin/servers/OwnerSelect'; +import { Form, Formik, FormikHelpers } from 'formik'; +import { useStoreActions } from 'easy-peasy'; import Button from '@/components/elements/Button'; -import FormikSwitch from '@/components/elements/FormikSwitch'; +import BaseSettingsBox from '@/components/admin/servers/settings/BaseSettingsBox'; +import FeatureLimitsBox from '@/components/admin/servers/settings/FeatureLimitsBox'; +import NetworkingBox from '@/components/admin/servers/settings/NetworkingBox'; +import ServerResourceBox from '@/components/admin/servers/settings/ServerResourceBox'; -export function ServerSettingsContainer ({ server }: { server?: Server }) { - const { isSubmitting } = useFormikContext(); +export default () => { + const { data: server } = useServerFromRoute(); + const { clearFlashes, clearAndAddHttpError } = useStoreActions(actions => actions.flashes); - return ( - - - -
-
- -
- -
- -
-
- -
-
- -
-
-
- ); -} - -export function ServerFeatureContainer () { - const { isSubmitting } = useFormikContext(); - - return ( - - - -
- - - - - -
-
- ); -} - -export function ServerAllocationsContainer ({ server }: { server: Server }) { - const { isSubmitting } = useFormikContext(); - - const loadOptions = async (inputValue: string, callback: (options: Option[]) => void) => { - const allocations = await getAllocations(server.nodeId, { ip: inputValue, server_id: '0' }); - callback(allocations.map(a => { - return { value: a.id.toString(), label: a.getDisplayText() }; - })); - }; - - return ( - - - -
- - -
- - - - { - return { value: a.id.toString(), label: a.getDisplayText() }; - }) || []} - isMulti - isSearchable - css={tw`mb-2`} - /> -
- ); -} - -export function ServerResourceContainer () { - const { isSubmitting } = useFormikContext(); - - return ( - - - -
-
- -
- -
- -
-
- -
-
- -
- -
- -
-
- -
-
- -
- -
- -
-
- -
-
- -
-
-
- ); -} - -export default function ServerSettingsContainer2 ({ server }: { server: Server }) { - const history = useHistory(); - - const { clearFlashes, clearAndAddHttpError } = useStoreActions((actions: Actions) => actions.flashes); - - const setServer = Context.useStoreActions(actions => actions.setServer); + if (!server) return null; const submit = (values: Values, { setSubmitting, setFieldValue }: FormikHelpers) => { clearFlashes('server'); @@ -242,9 +25,9 @@ export default function ServerSettingsContainer2 ({ server }: { server: Server } // OOM Killer is enabled, rather than when disabled. values.limits.oomDisabled = !values.limits.oomDisabled; - updateServer(server.id, values, ServerIncludes) - .then(s => { - setServer({ ...server, ...s }); + updateServer(server.id, values) + .then(() => { + // setServer({ ...server, ...s }); // TODO: Figure out how to properly clear react-selects for allocations. setFieldValue('addAllocations', []); @@ -263,8 +46,7 @@ export default function ServerSettingsContainer2 ({ server }: { server: Server } initialValues={{ externalId: server.externalId || '', name: server.name, - ownerId: server.ownerId, - + ownerId: server.userId, limits: { memory: server.limits.memory, swap: server.limits.swap, @@ -276,49 +58,36 @@ export default function ServerSettingsContainer2 ({ server }: { server: Server } // OOM Killer is enabled, rather than when disabled. oomDisabled: !server.limits.oomDisabled, }, - featureLimits: { allocations: server.featureLimits.allocations, backups: server.featureLimits.backups, databases: server.featureLimits.databases, }, - allocationId: server.allocationId, addAllocations: [] as number[], removeAllocations: [] as number[], }} - validationSchema={object().shape({ - })} + validationSchema={object().shape({})} > {({ isSubmitting, isValid }) => (
-
-
- -
- -
- -
- -
- -
+
+ + +
-
-
- -
- -
+ +
- history.push('/admin/servers')} - /> -
@@ -329,4 +98,4 @@ export default function ServerSettingsContainer2 ({ server }: { server: Server } )} ); -} +}; diff --git a/resources/scripts/components/admin/servers/ServerStartupContainer.tsx b/resources/scripts/components/admin/servers/ServerStartupContainer.tsx index d89230974..f7fae7beb 100644 --- a/resources/scripts/components/admin/servers/ServerStartupContainer.tsx +++ b/resources/scripts/components/admin/servers/ServerStartupContainer.tsx @@ -1,9 +1,7 @@ -import getEgg, { Egg, EggVariable } from '@/api/admin/eggs/getEgg'; -import { Server } from '@/api/admin/servers/getServers'; +import { Egg, EggVariable, getEgg } from '@/api/admin/egg'; import updateServerStartup, { Values } from '@/api/admin/servers/updateServerStartup'; import EggSelect from '@/components/admin/servers/EggSelect'; -import NestSelect from '@/components/admin/servers/NestSelect'; -import { Context, ServerIncludes } from '@/components/admin/servers/ServerRouter'; +import NestSelector from '@/components/admin/servers/NestSelector'; import FormikSwitch from '@/components/elements/FormikSwitch'; import React, { useEffect, useState } from 'react'; import Button from '@/components/elements/Button'; @@ -12,11 +10,13 @@ import AdminBox from '@/components/admin/AdminBox'; import tw from 'twin.macro'; import Field from '@/components/elements/Field'; import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; -import { Form, Formik, FormikHelpers, useFormikContext } from 'formik'; +import { Form, Formik, FormikHelpers, useField, useFormikContext } from 'formik'; import { ApplicationStore } from '@/state'; import { Actions, useStoreActions } from 'easy-peasy'; import Label from '@/components/elements/Label'; import { object } from 'yup'; +import { Server, useServerFromRoute } from '@/api/admin/server'; +import { InferModel } from '@/api/admin'; function ServerStartupLineContainer ({ egg, server }: { egg: Egg | null; server: Server }) { const { isSubmitting, setFieldValue } = useFormikContext(); @@ -27,12 +27,14 @@ function ServerStartupLineContainer ({ egg, server }: { egg: Egg | null; server: } if (server.eggId === egg.id) { - setFieldValue('startup', server.container.startup); + setFieldValue('image', server.container.image); + setFieldValue('startup', server.container.startup || ''); return; } // Whenever the egg is changed, set the server's startup command to the egg's default. - setFieldValue('startup', egg.startup); + setFieldValue('image', egg.dockerImages.length > 0 ? egg.dockerImages[0] : ''); + setFieldValue('startup', ''); }, [ egg ]); return ( @@ -46,6 +48,7 @@ function ServerStartupLineContainer ({ egg, server }: { egg: Egg | null; server: label={'Startup Command'} type={'text'} description={'Edit your server\'s startup command here. The following variables are available by default: {{SERVER_MEMORY}}, {{SERVER_IP}}, and {{SERVER_PORT}}.'} + placeholder={egg?.startup || ''} />
@@ -57,35 +60,27 @@ function ServerStartupLineContainer ({ egg, server }: { egg: Egg | null; server: ); } -function ServerServiceContainer ({ egg, setEgg, server }: { egg: Egg | null, setEgg: (value: Egg | null) => void, server: Server }) { +export function ServerServiceContainer ({ egg, setEgg, nestId: _nestId }: { egg: Egg | null, setEgg: (value: Egg | null) => void, nestId: number }) { const { isSubmitting } = useFormikContext(); - const [ nestId, setNestId ] = useState(server.nestId); + const [ nestId, setNestId ] = useState(_nestId); return ( - - - +
- +
-
- +
-
- +
); } -function ServerImageContainer () { +export function ServerImageContainer () { const { isSubmitting } = useFormikContext(); return ( @@ -106,14 +101,21 @@ function ServerImageContainer () { ); } -function ServerVariableContainer ({ variable, defaultValue }: { variable: EggVariable, defaultValue: string }) { - const key = 'environment.' + variable.envVariable; +export function ServerVariableContainer ({ variable, value }: { variable: EggVariable, value?: string }) { + const key = 'environment.' + variable.environmentVariable; - const { isSubmitting, setFieldValue } = useFormikContext(); + const [ , , { setValue, setTouched } ] = useField(key); + + const { isSubmitting } = useFormikContext(); useEffect(() => { - setFieldValue(key, defaultValue); - }, [ variable, defaultValue ]); + if (value === undefined) { + return; + } + + setValue(value); + setTouched(true); + }, [ value ]); return ( {variable.name}

}> @@ -131,7 +133,7 @@ function ServerVariableContainer ({ variable, defaultValue }: { variable: EggVar } function ServerStartupForm ({ egg, setEgg, server }: { egg: Egg | null, setEgg: (value: Egg | null) => void; server: Server }) { - const { isSubmitting, isValid } = useFormikContext(); + const { isSubmitting, isValid, values: { environment } } = useFormikContext(); return ( @@ -148,7 +150,7 @@ function ServerStartupForm ({ egg, setEgg, server }: { egg: Egg | null, setEgg:
@@ -158,11 +160,12 @@ function ServerStartupForm ({ egg, setEgg, server }: { egg: Egg | null, setEgg:
- {egg?.relations.variables?.map((v, i) => ( + {/* This ensures that no variables are rendered unless the environment has a value for the variable. */} + {egg?.relationships.variables?.filter(v => Object.keys(environment).find(e => e === v.environmentVariable) !== undefined).map((v, i) => ( v.eggId === v2.eggId && v.envVariable === v2.envVariable)?.serverValue || v.defaultValue} + value={server.relationships.variables?.find(v2 => v.eggId === v2.eggId && v.environmentVariable === v2.environmentVariable)?.serverValue} /> ))}
@@ -179,26 +182,28 @@ function ServerStartupForm ({ egg, setEgg, server }: { egg: Egg | null, setEgg: ); } -export default function ServerStartupContainer ({ server }: { server: Server }) { +export default () => { + const { data: server } = useServerFromRoute(); const { clearFlashes, clearAndAddHttpError } = useStoreActions((actions: Actions) => actions.flashes); - - const [ egg, setEgg ] = useState(null); - - const setServer = Context.useStoreActions(actions => actions.setServer); + const [ egg, setEgg ] = useState | null>(null); useEffect(() => { - getEgg(server.eggId, [ 'variables' ]) + if (!server) return; + + getEgg(server.eggId) .then(egg => setEgg(egg)) .catch(error => console.error(error)); - }, []); + }, [ server?.eggId ]); + + if (!server) return null; const submit = (values: Values, { setSubmitting }: FormikHelpers) => { clearFlashes('server'); - updateServerStartup(server.id, values, ServerIncludes) - .then(s => { - setServer({ ...server, ...s }); - }) + updateServerStartup(server.id, values) + // .then(s => { + // mutate(data => { ...data, ...s }); + // }) .catch(error => { console.error(error); clearAndAddHttpError({ key: 'server', error }); @@ -210,21 +215,20 @@ export default function ServerStartupContainer ({ server }: { server: Server }) [ v.envVariable, '' ]) || []), + startup: server.container.startup || '', + environment: [] as Record, image: server.container.image, eggId: server.eggId, skipScripts: false, }} - validationSchema={object().shape({ - })} + validationSchema={object().shape({})} > ); -} +}; diff --git a/resources/scripts/components/admin/servers/settings/BaseSettingsBox.tsx b/resources/scripts/components/admin/servers/settings/BaseSettingsBox.tsx new file mode 100644 index 000000000..f0060375b --- /dev/null +++ b/resources/scripts/components/admin/servers/settings/BaseSettingsBox.tsx @@ -0,0 +1,24 @@ +import React, { ReactNode } from 'react'; +import tw from 'twin.macro'; +import { useFormikContext } from 'formik'; +import AdminBox from '@/components/admin/AdminBox'; +import { faCogs } from '@fortawesome/free-solid-svg-icons'; +import Field from '@/components/elements/Field'; +import OwnerSelect from '@/components/admin/servers/OwnerSelect'; +import { useServerFromRoute } from '@/api/admin/server'; + +export default ({ children }: { children?: ReactNode }) => { + const { data: server } = useServerFromRoute(); + const { isSubmitting } = useFormikContext(); + + return ( + +
+ + + + {children} +
+
+ ); +}; diff --git a/resources/scripts/components/admin/servers/settings/FeatureLimitsBox.tsx b/resources/scripts/components/admin/servers/settings/FeatureLimitsBox.tsx new file mode 100644 index 000000000..ea312fb81 --- /dev/null +++ b/resources/scripts/components/admin/servers/settings/FeatureLimitsBox.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { useFormikContext } from 'formik'; +import AdminBox from '@/components/admin/AdminBox'; +import { faConciergeBell } from '@fortawesome/free-solid-svg-icons'; +import tw from 'twin.macro'; +import Field from '@/components/elements/Field'; + +export default () => { + const { isSubmitting } = useFormikContext(); + + return ( + +
+ + + +
+
+ ); +}; diff --git a/resources/scripts/components/admin/servers/settings/NetworkingBox.tsx b/resources/scripts/components/admin/servers/settings/NetworkingBox.tsx new file mode 100644 index 000000000..a75a321a5 --- /dev/null +++ b/resources/scripts/components/admin/servers/settings/NetworkingBox.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { useFormikContext } from 'formik'; +import SelectField, { AsyncSelectField, Option } from '@/components/elements/SelectField'; +import getAllocations from '@/api/admin/nodes/getAllocations'; +import AdminBox from '@/components/admin/AdminBox'; +import { faNetworkWired } from '@fortawesome/free-solid-svg-icons'; +import tw from 'twin.macro'; +import Label from '@/components/elements/Label'; +import Select from '@/components/elements/Select'; +import { useServerFromRoute } from '@/api/admin/server'; + +export default () => { + const { isSubmitting } = useFormikContext(); + const { data: server } = useServerFromRoute(); + + const loadOptions = async (inputValue: string, callback: (options: Option[]) => void) => { + if (!server) { + // eslint-disable-next-line node/no-callback-literal + callback([] as Option[]); + return; + } + + const allocations = await getAllocations(server.nodeId, { ip: inputValue, server_id: '0' }); + + callback(allocations.map(a => { + return { value: a.id.toString(), label: a.getDisplayText() }; + })); + }; + + return ( + +
+
+ + +
+ + { + return { value: a.id.toString(), label: a.getDisplayText() }; + }) || []} + isMulti + isSearchable + /> +
+
+ ); +}; diff --git a/resources/scripts/components/admin/servers/settings/ServerResourceBox.tsx b/resources/scripts/components/admin/servers/settings/ServerResourceBox.tsx new file mode 100644 index 000000000..c715c5939 --- /dev/null +++ b/resources/scripts/components/admin/servers/settings/ServerResourceBox.tsx @@ -0,0 +1,66 @@ +import { useFormikContext } from 'formik'; +import AdminBox from '@/components/admin/AdminBox'; +import { faBalanceScale } from '@fortawesome/free-solid-svg-icons'; +import tw from 'twin.macro'; +import Field from '@/components/elements/Field'; +import FormikSwitch from '@/components/elements/FormikSwitch'; +import React from 'react'; + +export default () => { + const { isSubmitting } = useFormikContext(); + + return ( + +
+ + + + + + +
+ +
+
+
+ ); +}; diff --git a/resources/scripts/components/admin/settings/GeneralSettings.tsx b/resources/scripts/components/admin/settings/GeneralSettings.tsx new file mode 100644 index 000000000..41316152c --- /dev/null +++ b/resources/scripts/components/admin/settings/GeneralSettings.tsx @@ -0,0 +1,46 @@ +import Field, { FieldRow } from '@/components/elements/Field'; +import { Form, Formik } from 'formik'; +import React from 'react'; +import AdminBox from '@/components/admin/AdminBox'; +import tw from 'twin.macro'; + +export default () => { + const submit = () => { + // + }; + + return ( + + +
+ + + + + + + + + + + +
+ +
+ ); +}; diff --git a/resources/scripts/components/admin/settings/MailSettings.tsx b/resources/scripts/components/admin/settings/MailSettings.tsx new file mode 100644 index 000000000..e79d18cf8 --- /dev/null +++ b/resources/scripts/components/admin/settings/MailSettings.tsx @@ -0,0 +1,111 @@ +import Button from '@/components/elements/Button'; +import Label from '@/components/elements/Label'; +import { Form, Formik } from 'formik'; +import React from 'react'; +import tw from 'twin.macro'; +import AdminBox from '@/components/admin/AdminBox'; +import Field, { FieldRow } from '@/components/elements/Field'; +import Select from '@/components/elements/Select'; + +export default () => { + const submit = () => { + // + }; + + return ( + + {({ isSubmitting, isValid }) => ( +
+ + + + +
+ + +
+
+ + + + + + + + + + +
+ +
+
+ +
+
+
+ )} +
+ ); +}; diff --git a/resources/scripts/components/admin/settings/SettingsContainer.tsx b/resources/scripts/components/admin/settings/SettingsContainer.tsx index 3e2fc2066..29f4c07bb 100644 --- a/resources/scripts/components/admin/settings/SettingsContainer.tsx +++ b/resources/scripts/components/admin/settings/SettingsContainer.tsx @@ -1,8 +1,17 @@ +import MailSettings from '@/components/admin/settings/MailSettings'; +import { AdjustmentsIcon, ChipIcon, CodeIcon, MailIcon, ShieldCheckIcon } from '@heroicons/react/outline'; import React from 'react'; +import { Route, useLocation } from 'react-router'; +import { Switch } from 'react-router-dom'; import tw from 'twin.macro'; +import FlashMessageRender from '@/components/FlashMessageRender'; import AdminContentBlock from '@/components/admin/AdminContentBlock'; +import { SubNavigation, SubNavigationLink } from '@/components/admin/SubNavigation'; +import GeneralSettings from '@/components/admin/settings/GeneralSettings'; export default () => { + const location = useLocation(); + return (
@@ -11,6 +20,44 @@ export default () => {

Configure and manage settings for Pterodactyl.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

Security

+
+ +

Features

+
+ +

Advanced

+
+
); }; diff --git a/resources/scripts/components/admin/users/UserAboutContainer.tsx b/resources/scripts/components/admin/users/UserAboutContainer.tsx index 0449c9d46..ac186f4da 100644 --- a/resources/scripts/components/admin/users/UserAboutContainer.tsx +++ b/resources/scripts/components/admin/users/UserAboutContainer.tsx @@ -39,6 +39,7 @@ const UserAboutContainer = () => { { rootAdmin: user.rootAdmin, }} onSubmit={submit} - role={user?.relationships.role || null} + uuid={user.uuid} + role={user.relationships.role || null} >
) => void; + uuid?: string; role: Role | null; } -export default function UserForm ({ title, initialValues, children, onSubmit, role }: Params) { +export default function UserForm ({ title, initialValues, children, onSubmit, uuid, role }: Params) { const submit = (values: Values, helpers: FormikHelpers) => { onSubmit(values, helpers); }; if (!initialValues) { initialValues = { + externalId: '', username: '', email: '', password: '', @@ -68,45 +73,56 @@ export default function UserForm ({ title, initialValues, children, onSubmit, ro
-
-
- -
- -
- -
-
- -
-
- -
- -
- -
-
+ + {uuid && +
+ + + + +
+ } + + + + + +
+ {/* TODO: Remove toggle once role permissions are implemented. */}
-
+
diff --git a/resources/scripts/components/dashboard/AccountOverviewContainer.tsx b/resources/scripts/components/dashboard/AccountOverviewContainer.tsx index 5f6653f71..f6d23cc8b 100644 --- a/resources/scripts/components/dashboard/AccountOverviewContainer.tsx +++ b/resources/scripts/components/dashboard/AccountOverviewContainer.tsx @@ -10,19 +10,19 @@ import { breakpoint } from '@/theme'; import MessageBox from '@/components/MessageBox'; const Container = styled.div` - ${tw`flex flex-wrap`}; + ${tw`flex flex-wrap`}; - & > div { - ${tw`w-full`}; + & > div { + ${tw`w-full`}; - ${breakpoint('md')` - width: calc(50% - 1rem); - `} + ${breakpoint('sm')` + width: calc(50% - 1rem); + `} - ${breakpoint('xl')` - ${tw`w-auto flex-1`}; - `} - } + ${breakpoint('md')` + ${tw`w-auto flex-1`}; + `} + } `; export default () => { @@ -35,21 +35,23 @@ export default () => { Your account must have two-factor authentication enabled in order to continue. } - + + - + + ); }; diff --git a/resources/scripts/components/dashboard/ServerRow.tsx b/resources/scripts/components/dashboard/ServerRow.tsx index ab8c4562e..60922df06 100644 --- a/resources/scripts/components/dashboard/ServerRow.tsx +++ b/resources/scripts/components/dashboard/ServerRow.tsx @@ -4,7 +4,7 @@ import { faEthernet, faHdd, faMemory, faMicrochip, faServer } from '@fortawesome import { Link } from 'react-router-dom'; import { Server } from '@/api/server/getServer'; import getServerResourceUsage, { ServerPowerState, ServerStats } from '@/api/server/getServerResourceUsage'; -import { bytesToHuman, megabytesToHuman } from '@/helpers'; +import { bytesToHuman, megabytesToHuman, formatIp } from '@/helpers'; import tw, { styled } from 'twin.macro'; import GreyRowBox from '@/components/elements/GreyRowBox'; import Spinner from '@/components/elements/Spinner'; @@ -96,7 +96,7 @@ export default ({ server, className }: { server: Server; className?: string }) = { server.allocations.filter(alloc => alloc.isDefault).map(allocation => ( - {allocation.alias || allocation.ip}:{allocation.port} + {allocation.alias || formatIp(allocation.ip)}:{allocation.port} )) } diff --git a/resources/scripts/components/dashboard/forms/DisableTwoFactorModal.tsx b/resources/scripts/components/dashboard/forms/DisableTwoFactorModal.tsx index 854d0837a..60659fc3d 100644 --- a/resources/scripts/components/dashboard/forms/DisableTwoFactorModal.tsx +++ b/resources/scripts/components/dashboard/forms/DisableTwoFactorModal.tsx @@ -30,7 +30,7 @@ const DisableTwoFactorModal = () => { .catch(error => { console.error(error); - clearAndAddHttpError({ error, key: 'account:two-factor' }); + clearAndAddHttpError({ key: 'account:two-factor', error }); setSubmitting(false); setPropOverrides(null); }); diff --git a/resources/scripts/components/dashboard/forms/SetupTwoFactorModal.tsx b/resources/scripts/components/dashboard/forms/SetupTwoFactorModal.tsx index aea603995..87f10d438 100644 --- a/resources/scripts/components/dashboard/forms/SetupTwoFactorModal.tsx +++ b/resources/scripts/components/dashboard/forms/SetupTwoFactorModal.tsx @@ -31,7 +31,7 @@ const SetupTwoFactorModal = () => { .then(setToken) .catch(error => { console.error(error); - clearAndAddHttpError({ error, key: 'account:two-factor' }); + clearAndAddHttpError({ key: 'account:two-factor', error }); }); }, []); @@ -44,7 +44,7 @@ const SetupTwoFactorModal = () => { .catch(error => { console.error(error); - clearAndAddHttpError({ error, key: 'account:two-factor' }); + clearAndAddHttpError({ key: 'account:two-factor', error }); }) .then(() => { setSubmitting(false); diff --git a/resources/scripts/components/dashboard/search/SearchModal.tsx b/resources/scripts/components/dashboard/search/SearchModal.tsx index 598001af3..c7ce58008 100644 --- a/resources/scripts/components/dashboard/search/SearchModal.tsx +++ b/resources/scripts/components/dashboard/search/SearchModal.tsx @@ -12,7 +12,7 @@ import { ApplicationStore } from '@/state'; import { Link } from 'react-router-dom'; import tw, { styled } from 'twin.macro'; import Input from '@/components/elements/Input'; - +import { formatIp } from '@/helpers'; type Props = RequiredModalProps; interface Values { @@ -108,7 +108,7 @@ export default ({ ...props }: Props) => {

{ server.allocations.filter(alloc => alloc.isDefault).map(allocation => ( - {allocation.alias || allocation.ip}:{allocation.port} + {allocation.alias || formatIp(allocation.ip)}:{allocation.port} )) }

diff --git a/resources/scripts/components/elements/Button.tsx b/resources/scripts/components/elements/Button.tsx index 3b0b820df..e4804c8a8 100644 --- a/resources/scripts/components/elements/Button.tsx +++ b/resources/scripts/components/elements/Button.tsx @@ -4,18 +4,14 @@ import Spinner from '@/components/elements/Spinner'; interface Props { isLoading?: boolean; - size?: 'xsmall' | 'small' | 'large' | 'xlarge'; + size?: 'inline' | 'xsmall' | 'small' | 'large' | 'xlarge'; color?: 'green' | 'red' | 'primary' | 'grey'; isSecondary?: boolean; } -const ButtonStyle = styled.button>` +const ButtonStyle = styled.button` ${tw`relative inline-block rounded p-2 tracking-wide text-sm transition-all duration-150 border`}; - - & > span { - ${tw`select-none`}; - } - + ${props => ((!props.isSecondary && !props.color) || props.color === 'primary') && css` ${props => !props.isSecondary && tw`bg-primary-500 border-primary-600 border text-primary-50`}; @@ -60,6 +56,7 @@ const ButtonStyle = styled.button>` `}; `}; + ${props => props.size === 'inline' && tw`p-1 text-xs`}; ${props => props.size === 'xsmall' && tw`p-2 text-xs`}; ${props => (!props.size || props.size === 'small') && tw`p-3`}; ${props => props.size === 'large' && tw`p-4 text-sm`}; @@ -75,22 +72,24 @@ const ButtonStyle = styled.button>` ${props => props.color === 'green' && tw`bg-green-500 border-green-600 text-green-50`}; } `}; + + ${props => props.isLoading && tw`text-transparent`}; - &:disabled { opacity: 0.55; cursor: default } + &:disabled { + ${tw`opacity-75 cursor-not-allowed`}; + } `; type ComponentProps = Omit & Props; -const Button: React.FC = ({ children, isLoading, ...props }) => ( - +const Button: React.FC = ({ children, isLoading, disabled, ...props }) => ( + {isLoading &&
} - - {children} - + {children}
); diff --git a/resources/scripts/components/elements/Checkbox.tsx b/resources/scripts/components/elements/Checkbox.tsx index 790536489..25c9da037 100644 --- a/resources/scripts/components/elements/Checkbox.tsx +++ b/resources/scripts/components/elements/Checkbox.tsx @@ -1,45 +1,23 @@ +import { Field } from 'formik'; import React from 'react'; -import { Field, FieldProps } from 'formik'; -import Input from '@/components/elements/Input'; +import tw from 'twin.macro'; +import Label from '@/components/elements/Label'; interface Props { + id: string; name: string; - value: string; + label?: string; className?: string; } -type OmitFields = 'ref' | 'name' | 'value' | 'type' | 'checked' | 'onClick' | 'onChange'; - -type InputProps = Omit; - -const Checkbox = ({ name, value, className, ...props }: Props & InputProps) => ( - - {({ field, form }: FieldProps) => { - if (!Array.isArray(field.value)) { - console.error('Attempting to mount a checkbox using a field value that is not an array.'); - - return null; - } - - return ( - form.setFieldTouched(field.name, true)} - onChange={e => { - const set = new Set(field.value); - set.has(value) ? set.delete(value) : set.add(value); - - field.onChange(e); - form.setFieldValue(field.name, Array.from(set)); - }} - /> - ); - }} - +const Checkbox = ({ id, name, label, className }: Props) => ( +
+ + {label && +
+ +
} +
); export default Checkbox; diff --git a/resources/scripts/components/elements/Editor.tsx b/resources/scripts/components/elements/Editor.tsx index 5fa2dfe81..c3a77b9c6 100644 --- a/resources/scripts/components/elements/Editor.tsx +++ b/resources/scripts/components/elements/Editor.tsx @@ -15,25 +15,25 @@ import { Compartment, Extension, EditorState } from '@codemirror/state'; import { StreamLanguage, StreamParser } from '@codemirror/stream-parser'; import { keymap, highlightSpecialChars, drawSelection, highlightActiveLine, EditorView } from '@codemirror/view'; import { clike } from '@codemirror/legacy-modes/mode/clike'; -import { cppLanguage } from '@codemirror/lang-cpp'; -import { cssLanguage } from '@codemirror/lang-css'; +import { cpp } from '@codemirror/lang-cpp'; +import { css } from '@codemirror/lang-css'; import { Cassandra, MariaSQL, MSSQL, MySQL, PostgreSQL, sql, SQLite, StandardSQL } from '@codemirror/lang-sql'; import { diff } from '@codemirror/legacy-modes/mode/diff'; import { dockerFile } from '@codemirror/legacy-modes/mode/dockerfile'; import { markdown, markdownLanguage } from '@codemirror/lang-markdown'; import { go } from '@codemirror/legacy-modes/mode/go'; -import { htmlLanguage } from '@codemirror/lang-html'; +import { html } from '@codemirror/lang-html'; import { http } from '@codemirror/legacy-modes/mode/http'; -import { javascriptLanguage, typescriptLanguage } from '@codemirror/lang-javascript'; -import { jsonLanguage } from '@codemirror/lang-json'; +import { javascript, typescriptLanguage } from '@codemirror/lang-javascript'; +import { json } from '@codemirror/lang-json'; import { lua } from '@codemirror/legacy-modes/mode/lua'; import { properties } from '@codemirror/legacy-modes/mode/properties'; import { python } from '@codemirror/legacy-modes/mode/python'; import { ruby } from '@codemirror/legacy-modes/mode/ruby'; -import { rustLanguage } from '@codemirror/lang-rust'; +import { rust } from '@codemirror/lang-rust'; import { shell } from '@codemirror/legacy-modes/mode/shell'; import { toml } from '@codemirror/legacy-modes/mode/toml'; -import { xmlLanguage } from '@codemirror/lang-xml'; +import { xml } from '@codemirror/lang-xml'; import { yaml } from '@codemirror/legacy-modes/mode/yaml'; import React, { useCallback, useEffect, useState } from 'react'; import tw, { styled, TwStyle } from 'twin.macro'; @@ -42,29 +42,29 @@ import { ayuMirage } from '@/components/elements/EditorTheme'; type EditorMode = LanguageSupport | LRLanguage | StreamParser; export interface Mode { - name: string, - mime: string, - mimes?: string[], - mode?: EditorMode, - ext?: string[], - alias?: string[], - file?: RegExp, + name: string; + mime: string; + mimes?: string[]; + mode?: EditorMode; + ext?: string[]; + alias?: string[]; + file?: RegExp; } export const modes: Mode[] = [ { name: 'C', mime: 'text/x-csrc', mode: clike({}), ext: [ 'c', 'h', 'ino' ] }, - { name: 'C++', mime: 'text/x-c++src', mode: cppLanguage, ext: [ 'cpp', 'c++', 'cc', 'cxx', 'hpp', 'h++', 'hh', 'hxx' ], alias: [ 'cpp' ] }, + { name: 'C++', mime: 'text/x-c++src', mode: cpp(), ext: [ 'cpp', 'c++', 'cc', 'cxx', 'hpp', 'h++', 'hh', 'hxx' ], alias: [ 'cpp' ] }, { name: 'C#', mime: 'text/x-csharp', mode: clike({}), ext: [ 'cs' ], alias: [ 'csharp', 'cs' ] }, - { name: 'CSS', mime: 'text/css', mode: cssLanguage, ext: [ 'css' ] }, + { name: 'CSS', mime: 'text/css', mode: css(), ext: [ 'css' ] }, { name: 'CQL', mime: 'text/x-cassandra', mode: sql({ dialect: Cassandra }), ext: [ 'cql' ] }, { name: 'Diff', mime: 'text/x-diff', mode: diff, ext: [ 'diff', 'patch' ] }, { name: 'Dockerfile', mime: 'text/x-dockerfile', mode: dockerFile, file: /^Dockerfile$/ }, { name: 'Git Markdown', mime: 'text/x-gfm', mode: markdown({ defaultCodeLanguage: markdownLanguage }), file: /^(readme|contributing|history|license).md$/i }, { name: 'Golang', mime: 'text/x-go', mode: go, ext: [ 'go' ] }, - { name: 'HTML', mime: 'text/html', mode: htmlLanguage, ext: [ 'html', 'htm', 'handlebars', 'hbs' ], alias: [ 'xhtml' ] }, + { name: 'HTML', mime: 'text/html', mode: html(), ext: [ 'html', 'htm', 'handlebars', 'hbs' ], alias: [ 'xhtml' ] }, { name: 'HTTP', mime: 'message/http', mode: http }, - { name: 'JavaScript', mime: 'text/javascript', mimes: [ 'text/javascript', 'text/ecmascript', 'application/javascript', 'application/x-javascript', 'application/ecmascript' ], mode: javascriptLanguage, ext: [ 'js' ], alias: [ 'ecmascript', 'js', 'node' ] }, - { name: 'JSON', mime: 'application/json', mimes: [ 'application/json', 'application/x-json' ], mode: jsonLanguage, ext: [ 'json', 'map' ], alias: [ 'json5' ] }, + { name: 'JavaScript', mime: 'text/javascript', mimes: [ 'text/javascript', 'text/ecmascript', 'application/javascript', 'application/x-javascript', 'application/ecmascript' ], mode: javascript(), ext: [ 'js' ], alias: [ 'ecmascript', 'js', 'node' ] }, + { name: 'JSON', mime: 'application/json', mimes: [ 'application/json', 'application/x-json' ], mode: json(), ext: [ 'json', 'json5', 'map' ], alias: [ 'json5' ] }, { name: 'Lua', mime: 'text/x-lua', mode: lua, ext: [ 'lua' ] }, { name: 'Markdown', mime: 'text/x-markdown', mode: markdown({ defaultCodeLanguage: markdownLanguage }), ext: [ 'markdown', 'md', 'mkd' ] }, { name: 'MariaDB', mime: 'text/x-mariadb', mode: sql({ dialect: MariaSQL }) }, @@ -75,15 +75,15 @@ export const modes: Mode[] = [ { name: 'Properties', mime: 'text/x-properties', mode: properties, ext: [ 'properties', 'ini', 'in' ], alias: [ 'ini', 'properties' ] }, { name: 'Python', mime: 'text/x-python', mode: python, ext: [ 'BUILD', 'bzl', 'py', 'pyw' ], file: /^(BUCK|BUILD)$/ }, { name: 'Ruby', mime: 'text/x-ruby', mode: ruby, ext: [ 'rb' ], alias: [ 'jruby', 'macruby', 'rake', 'rb', 'rbx' ] }, - { name: 'Rust', mime: 'text/x-rustsrc', mode: rustLanguage, ext: [ 'rs' ] }, - { name: 'Sass', mime: 'text/x-sass', mode: cssLanguage, ext: [ 'sass' ] }, - { name: 'SCSS', mime: 'text/x-scss', mode: cssLanguage, ext: [ 'scss' ] }, + { name: 'Rust', mime: 'text/x-rustsrc', mode: rust(), ext: [ 'rs' ] }, + { name: 'Sass', mime: 'text/x-sass', mode: css(), ext: [ 'sass' ] }, + { name: 'SCSS', mime: 'text/x-scss', mode: css(), ext: [ 'scss' ] }, { name: 'Shell', mime: 'text/x-sh', mimes: [ 'text/x-sh', 'application/x-sh' ], mode: shell, ext: [ 'sh', 'ksh', 'bash' ], alias: [ 'bash', 'sh', 'zsh' ], file: /^PKGBUILD$/ }, { name: 'SQL', mime: 'text/x-sql', mode: sql({ dialect: StandardSQL }), ext: [ 'sql' ] }, { name: 'SQLite', mime: 'text/x-sqlite', mode: sql({ dialect: SQLite }) }, { name: 'TOML', mime: 'text/x-toml', mode: toml, ext: [ 'toml' ] }, { name: 'TypeScript', mime: 'application/typescript', mode: typescriptLanguage, ext: [ 'ts' ], alias: [ 'ts' ] }, - { name: 'XML', mime: 'application/xml', mimes: [ 'application/xml', 'text/xml' ], mode: xmlLanguage, ext: [ 'xml', 'xsl', 'xsd', 'svg' ], alias: [ 'rss', 'wsdl', 'xsd' ] }, + { name: 'XML', mime: 'application/xml', mimes: [ 'application/xml', 'text/xml' ], mode: xml(), ext: [ 'xml', 'xsl', 'xsd', 'svg' ], alias: [ 'rss', 'wsdl', 'xsd' ] }, { name: 'YAML', mime: 'text/x-yaml', mimes: [ 'text/x-yaml', 'text/yaml' ], mode: yaml, ext: [ 'yaml', 'yml' ], alias: [ 'yml' ] }, ]; @@ -203,25 +203,28 @@ export interface Props { export default ({ className, style, overrides, initialContent, extensions, mode, filename, onModeChanged, fetchContent, onContentSaved }: Props) => { const [ languageConfig ] = useState(new Compartment()); const [ keybinds ] = useState(new Compartment()); - const [ state ] = useState(EditorState.create({ - doc: initialContent, - extensions: [ - ...defaultExtensions, - ...(extensions !== undefined ? extensions : []), - - languageConfig.of(mode !== undefined ? modeToExtension(mode) : findLanguageExtensionByMode(findModeByFilename(filename || ''))), - keybinds.of([]), - ], - })); const [ view, setView ] = useState(); + const createEditorState = () => { + return EditorState.create({ + doc: initialContent, + extensions: [ + ...defaultExtensions, + ...(extensions !== undefined ? extensions : []), + + languageConfig.of(mode !== undefined ? modeToExtension(mode) : findLanguageExtensionByMode(findModeByFilename(filename || ''))), + keybinds.of([]), + ], + }); + }; + const ref = useCallback((node) => { if (!node) { return; } const view = new EditorView({ - state: state, + state: createEditorState(), parent: node, }); setView(view); @@ -277,9 +280,9 @@ export default ({ className, style, overrides, initialContent, extensions, mode, return; } - view.dispatch({ - changes: { from: 0, insert: initialContent }, - }); + // We could dispatch a view update to replace the content, but this would keep the edit history, + // and previously would duplicate the content of the editor. + view.setState(createEditorState()); }, [ initialContent ]); useEffect(() => { diff --git a/resources/scripts/components/elements/Field.tsx b/resources/scripts/components/elements/Field.tsx index 5840983ed..2d86529fd 100644 --- a/resources/scripts/components/elements/Field.tsx +++ b/resources/scripts/components/elements/Field.tsx @@ -1,9 +1,9 @@ -import React, { forwardRef } from 'react'; import { Field as FormikField, FieldProps } from 'formik'; +import React, { forwardRef } from 'react'; +import tw, { styled } from 'twin.macro'; import Input, { Textarea } from '@/components/elements/Input'; -import Label from '@/components/elements/Label'; import InputError from '@/components/elements/InputError'; -import tw from 'twin.macro'; +import Label from '@/components/elements/Label'; interface OwnProps { name: string; @@ -11,15 +11,17 @@ interface OwnProps { label?: string; description?: string; validate?: (value: any) => undefined | string | Promise; + + className?: string; } type Props = OwnProps & Omit, 'name'>; -const Field = forwardRef(({ id, name, light = false, label, description, validate, ...props }, ref) => ( +const Field = forwardRef(({ id, name, light = false, label, description, validate, className, ...props }, ref) => ( { ({ field, form: { errors, touched } }: FieldProps) => ( -
+
{label &&
@@ -43,15 +45,17 @@ const Field = forwardRef(({ id, name, light = false, la )); Field.displayName = 'Field'; -type Props2 = OwnProps & Omit, 'name'>; +export default Field; -export const TextareaField = forwardRef( - function TextareaField ({ id, name, light = false, label, description, validate, ...props }, ref) { +type TextareaProps = OwnProps & Omit, 'name'>; + +export const TextareaField = forwardRef( + function TextareaField ({ id, name, light = false, label, description, validate, className, ...props }, ref) { return ( { ({ field, form: { errors, touched } }: FieldProps) => ( -
+
{label && }