Merge branch 'develop' into feature/react-admin

This commit is contained in:
Matthew Penner 2021-05-18 17:07:25 -06:00
commit a3b59f24af
95 changed files with 2671 additions and 1777 deletions

View File

@ -25,6 +25,11 @@ MAIL_USERNAME=
MAIL_PASSWORD=
MAIL_ENCRYPTION=tls
MAIL_FROM=no-reply@example.com
# You should set this to your domain to prevent it defaulting to 'localhost', causing
# mail servers such as Gmail to reject your mail.
#
# @see: https://github.com/pterodactyl/panel/pull/3110
# SERVER_NAME=panel.yourdomain.com
QUEUE_HIGH=high
QUEUE_STANDARD=standard

View File

@ -1,2 +1,4 @@
public
node_modules
resources/views
webpack.config.js

View File

@ -76,9 +76,13 @@ rules:
- 1
- "line-aligned"
"react/jsx-closing-tag-location": 1
"no-use-before-define": 0
"@typescript-eslint/no-use-before-define": 1
"multiline-ternary": 0
# This setup is required to avoid a spam of errors when running eslint about React being
# used before it is defined.
#
# see https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/docs/rules/no-use-before-define.md#how-to-use
no-use-before-define: 0
"@typescript-eslint/no-use-before-define":
- warn
overrides:
- files:
- "**/*.tsx"

View File

@ -1,31 +1,38 @@
---
name: "\U0001F41B Bug Report"
about: For reporting code or design bugs with the software. DO NOT REPORT APACHE/NGINX/PHP CONFIGURATION ISSUES.
---
DO NOT REPORT ISSUES CONFIGURING: SSL, PHP, APACHE, NGINX, YOUR MACHINE, SSH, SFTP, ETC. ON THIS GITHUB TRACKER.
For assistance installing this software, as well as debugging issues with dependencies, please use our discord server: https://discord.gg/pterodactyl
You MUST complete all of the below information when reporting a bug, failure to do so will result in closure of your issue. PLEASE stop spamming our tracker with "bugs" that are not related to this project.
You MUST complete all of the below information when reporting a bug, failure to do so will result in the closure of your issue. PLEASE stop spamming our tracker with "bugs" that are not related to this project.
To obtain logs for the panel and wings the below commands should help with the retrieval of them.
Panel: tail -n 100 /var/www/pterodactyl/storage/logs/laravel-$(date +%F).log | nc bin.ptdl.co 99
Wings: sudo wings diagnostics
**STOP: READ FIRST, AND THEN DELETE THE ABOVE LINES**
**Background (please complete the following information):**
* Panel or Daemon:
* Version of Panel/Daemon:
* Server's OS:
* Your Computer's OS & Browser:
* Panel or Wings:
* Version of Panel/Wings:
* Panel Logs:
* Wings Logs:
* Server's OS:
* Your Computer's OS & Browser:
**Describe the bug**
A clear and concise description of what the bug is.
Please provide additional information too, depending on what you have issues with:
Panel: `php -v` (the php version in use).
Daemon: `uname -a` and `docker info` (your kernel version and information regarding docker)
Wings: `uname -a` and `docker info` (your kernel version and information regarding docker)
**To Reproduce**
Steps to reproduce the behavior:
Steps to reproduce this behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'

View File

@ -2,7 +2,7 @@
This is a ready to use docker image for the panel.
## Requirements
This docker image requires some additional software to function. The software can either be provided in other containers (see the [docker-compose.yml](docker-compose.yml) as an example) or as existing instances.
This docker image requires some additional software to function. The software can either be provided in other containers (see the [docker-compose.yml](https://github.com/pterodactyl/panel/blob/develop/docker-compose.example.yml) as an example) or as existing instances.
A mysql database is required. We recommend the stock [MariaDB Image](https://hub.docker.com/_/mariadb/) image if you prefer to run it in a docker container. As a non-containerized option we recommend mariadb.
@ -73,4 +73,4 @@ Every driver requires `MAIL_FROM` to be set.
| mandrill | [Mandrill](http://www.mandrill.com/) | `MAIL_USERNAME` |
| postmark | [Postmark](https://postmarkapp.com/) | `MAIL_USERNAME` |
| mailgun | [Mailgun](https://www.mailgun.com/) | `MAIL_USERNAME`, `MAIL_HOST` |
| smtp | Any SMTP server can be configured | `MAIL_USERNAME`, `MAIL_HOST`, `MAIL_PASSWORD`, `MAIL_PORT` |
| smtp | Any SMTP server can be configured | `MAIL_USERNAME`, `MAIL_HOST`, `MAIL_PASSWORD`, `MAIL_PORT` |

View File

@ -1,4 +1,4 @@
#!/bin/ash
#!/bin/ash -e
cd /app
mkdir -p /var/log/panel/logs/ /var/log/supervisord/ /var/log/nginx/ /var/log/php7/ \

View File

@ -1,17 +1,25 @@
name: Run Test Suite
name: run tests
on:
push:
branch-ignore:
- 'master'
- 'release/**'
branches-ignore:
- master
- "release/**"
pull_request:
jobs:
integration_tests:
tests:
runs-on: ubuntu-latest
if: "!contains(github.event.head_commit.message, 'skip ci') && !contains(github.event.head_commit.message, 'ci skip')"
services:
mariadb:
image: mariadb:10.2
env:
MYSQL_ALLOW_EMPTY_PASSWORD: yes
MYSQL_DATABASE: panel_test
ports:
- 3306
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
mysql:
image: mysql:5.7
image: mysql:8
env:
MYSQL_ALLOW_EMPTY_PASSWORD: yes
MYSQL_DATABASE: panel_test
@ -21,8 +29,9 @@ jobs:
strategy:
fail-fast: false
matrix:
php: [7.4, 8.0]
name: PHP ${{ matrix.php }}
php: [ 7.4, 8.0 ]
database: [ mysql, mariadb ]
name: "php-${{ matrix.php }} (engine: ${{ matrix.database }})"
steps:
- name: checkout
uses: actions/checkout@v2
@ -59,9 +68,15 @@ jobs:
env:
DB_CONNECTION: testing
TESTING_DB_HOST: UNIT_NO_DB
- name: execute integration tests
- name: execute integration tests (mysql)
run: vendor/bin/phpunit tests/Integration
if: ${{ always() }}
if: "${{ matrix.database }} == 'mysql'"
env:
TESTING_DB_PORT: ${{ job.services.mysql.ports[3306] }}
TESTING_DB_USERNAME: root
- name: execute integration tests (mariadb)
run: vendor/bin/phpunit tests/Integration
if: "${{ matrix.database }} == 'mariadb'"
env:
TESTING_DB_PORT: ${{ job.services.mariadb.ports[3306] }}
TESTING_DB_USERNAME: root

View File

@ -1,16 +0,0 @@
<?php
use Sami\Sami;
use Symfony\Component\Finder\Finder;
$iterator = Finder::create()
->files()
->name('*.php')
->in($dir = __DIR__ . '/app');
return new Sami($iterator, array(
'title' => 'Pterodactyl',
'build_dir' => __DIR__ . '/.sami/build',
'cache_dir' => __DIR__ . '/.sami/cache',
'default_opened_level' => 2,
));

View File

@ -1,7 +0,0 @@
preset: laravel
risky: false
disabled:
- concat_without_spaces
enabled:
- concat_with_spaces
- no_unused_imports

View File

@ -1,36 +0,0 @@
language: php
dist: trusty
git:
depth: 3
quiet: true
matrix:
fast_finish: true
allow_failures:
- env: TEST_SUITE=Coverage
env:
matrix:
- TEST_SUITE=Unit
- TEST_SUITE=Coverage
- TEST_SUITE=Integration
php:
- 7.4
sudo: false
cache:
directories:
- $HOME/.composer/cache
services:
- mysql
before_install:
- mysql -e 'CREATE DATABASE IF NOT EXISTS travis;'
before_script:
- echo 'opcache.enable_cli=1' >> ~/.phpenv/versions/$(phpenv version-name)/etc/conf.d/travis.ini
- cp .env.travis .env
- travis_retry composer install --no-interaction --prefer-dist --no-suggest
script:
- if [ "$TEST_SUITE" = "Unit" ]; then vendor/bin/phpunit --bootstrap vendor/autoload.php tests/Unit; fi;
- if [ "$TEST_SUITE" = "Coverage" ]; then vendor/bin/phpunit --bootstrap vendor/autoload.php --coverage-clover coverage.xml tests/Unit; fi;
- if [ "$TEST_SUITE" = "Integration" ]; then vendor/bin/phpunit tests/Integration; fi;
notifications:
email: false
after_success:
- bash <(curl -s https://codecov.io/bash)

View File

@ -1,10 +1,13 @@
# Local Development
Pterodactyl is now powered by Vuejs and Tailwindcss and uses webpack at its core to generate compiled assets. Release
versions of Pterodactyl will include pre-compiled, minified, and hashed assets ready-to-go.
Pterodactyl is now powered by React, Typescript, and Tailwindcss using webpack at its core to generate compiled assets.
Release versions of Pterodactyl will include pre-compiled, minified, and hashed assets ready-to-go.
However, if you are interested in running custom themes or making modifications to the Vue files you'll need a build
system in place to generate these compiled assets. To get your environment setup, you'll first need to install at least Nodejs
`8`, and it is _highly_ recommended that you also install [Yarn](https://yarnpkg.com) to manage your `node_modules`.
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.
### Install Dependencies
```bash
@ -12,17 +15,19 @@ yarn install
```
The command above will download all of the dependencies necessary to get Pterodactyl assets building. After that, its as
simple as running the command below to generate assets while you're developing.
simple as running the command below to generate assets while you're developing. Until you've run this command at least
once you'll likely see a 500 error on your Panel about a missing `manifest.json` file. This is generated by the commands
below.
```bash
# build the compiled assets for development
# Build the compiled set of assets for development.
yarn run build
# build the assets automatically when files are modified
# Build the assets automatically as they are changed. This allows you to refresh
# the page and see the changes immediately.
yarn run watch
```
### Hot Module Reloading
For more advanced users, we also support 'Hot Module Reloading', allowing you to quickly see changes you're making
to the Vue template files without having to reload the page you're on. To Get started with this, you just need
@ -37,21 +42,22 @@ is the `--host` flag, which is required and should point to the machine where th
The second is the `PUBLIC_PATH` environment variable which is the URL pointing to the HMR server and is appended to all of
the asset URLs used in Pterodactyl.
#### Vagrant
If you want to use HMR with our Vagrant image, you can use `yarn run v:serve` as a shortcut for the correct parameters.
In order to have proper file change detection you can use the [`vagrant-notify-forwarder`](https://github.com/mhallin/vagrant-notify-forwarder) to notify file events from the host to the VM.
```sh
vagrant plugin install vagrant-notify-forwarder
vagrant reload
```
#### Development Environment
If you're using the [`pterodactyl/development`](https://github.com/pterodactyl/development) environments, which are
highly recommended, you can just run `yarn run serve` to run the HMR server, no additional configuration is necessary.
### Building for Production
Once you have your files squared away and ready for the live server, you'll be needing to generate compiled, minified, and
hashed assets to push live. To do so, run the command below:
Once you have your files squared away and ready for the live server, you'll be needing to generate compiled, minified,
and hashed assets to push live. To do so, run the command below:
```bash
yarn run build:production
```
This will generate a production ready `bundle.js` and `bundle.css` as well as a `manifest.json` and store them in
the `/public/assets` directory where they can then be access by clients, and read by the Panel.
This will generate a production JS bundle and associated assets, all located in `public/assets/` which will need to
be uploaded to your server or CDN for clients to use.
### Running Wings
To run `wings` in development all you need to do is set up the configuration file as normal when adding a new node, and
then you can build and run a local version of Wings by executing `make debug` in the Wings code directory. This must
be run on a Linux VM of some sort, you cannot run this locally on macOS or Windows.

View File

@ -3,6 +3,40 @@ 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.4.1
### Added
* Adds support for only running a schedule if the server is currently in an online state.
* Adds support for ignoring errors during task execution and continuing on to the next item in the sequence. For example, continuing to a server restart even if sending a command beforehand failed.
* Adds the ability to specify the group to use for file permissions when using the `p:upgrade` command.
* Adds the ability to manually run a schedule even if it is currently disabled.
## v1.4.0
### Fixed
* Removes the use of tagging when storing server resource usage in the cache. This addresses errors encountered when using the `file` driver.
* Fixes Wings response handling if Wings returns an error response with a 200-level status code that would improperly be passed back to the client as a successful request.
* Fixes use of JSON specific functions in SQL queries to better support MariaDB users.
* Fixes a migration that could fail on some MySQL/MariaDB setups when trying to encrypt node token values.
### Changed
* Increases the maximum length allowed for a server name using the Rust egg.
* Updated server resource utilization API call to Wings to use new API response format used by `Wings@1.4.0`.
## v1.3.2
### Fixed
* Fixes self-upgrade incorrectly executing the command to un-tar downloaded archives.
* Fixes the checkbox to delete all files when restoring a backup not actually passing that along in the API call. Files will now properly be deleted when restoring if selected.
* Fixes some keybindings not working correctly in the server console on Windows machines.
* Fixes mobile UI incorrectly squishing the Docker image selector on the server settings page.
* Fixes recovery tokens not having a `created_at` value set on them properly when they are created.
* Fixes flawed migration that would not correctly set the month value into schedule crons.
* Fixes incorrect mounting for Docker compose file that would cause error logs to be missing.
### Changed
* Server resource lookups are now cached on the Panel for 20 seconds at a time to reduce the load from multiple clients requesting the same server's stats.
* Bungeecord egg no longer force-enables the query listener.
* Adds page to the dashboard URL to allow easy loading of a specific pagination page rather than resetting back to the first page when refreshing.
* All application API endpoints now correctly support the `?per_page=N` query parameter to specify how many resources to return at once.
## v1.3.1
### Fixed
* Fixes the Rust egg not properly seeding during the upgrade & installation process.

View File

@ -1,44 +1,55 @@
# Contributing
We're glad you want to help us out and make this panel the best that it can be! We have a few simple things to follow when making changes to files and adding new features.
We're glad you want to help us out and make this panel the best that it can be! We have a few simple things to follow
when making changes to files and adding new features.
### Development Environment
Please check the [`pterodactyl/development`](https://github.com/pterodactyl/development) repository for a Vagrant &
Docker setup that should run on most macOS and Linux distributions. In the event that your platform is not supported
you're welcome to open a PR, or just take a look at our setup scripts to see what you'll need to successfully develop
with Pterodactyl.
#### Building Assets
Please see [`BUILDING.md`](https://github.com/pterodactyl/panel/blob/develop/BUILDING.md) for details on how to actually
build and run the development server.
### Project Branches
This section mainly applies to those with read/write access to our repositories, but can be helpful for others.
The `develop` branch should always be in a runnable state, and not contain any major breaking features. For the most part, this means you will need to create `feature/` branches in order to add new functionality or change how things work. When making a feature branch, if it is referencing something in the issue tracker, please title the branch `feature/PTDL-###` where `###` is the issue number.
The `develop` branch should always be in a runnable state, and not contain any major breaking features. For the most
part, this means you will need to create `feature/` branches in order to add new functionality or change how things
work. When making a feature branch, if it is referencing something in the issue tracker, please title the branch
`feature/PTDL-###` where `###` is the issue number.
Moving forward all commits from contributors should be in the form of a PR, unless it is something we have previously discussed as being able to be pushed right into `develop`.
All new code should contain tests to ensure their functionality is not unintentionally changed down the road. This
is especially important for any API actions or authentication based controls.
All new code should contain unit tests at a minimum (where applicable). There is a lot of uncovered code currently, so as you are doing things please be looking for places that you can write tests.
### Update the CHANGELOG
When adding something that is new, fixed, changed, or security-related for the next release you should be adding a note to the CHANGELOG. If something is changing within the same version (i.e. fixing a bug introduced but not released) it should _not_ go into the CHANGELOG.
### The CHANGELOG
You should not make any changes to the `CHANGELOG.md` file during your code updates. This is updated by the maintainers
at the time of deployment to include the relevant changes that were made.
### Code Guidelines
We are a `PSR-4` and `PSR-0` compliant project, so please follow those guidelines at a minimum. In addition, StyleCI runs on all of our code to ensure the formatting is standardized across everything. When a PR is made StyleCI will analyze your code and make a pull to that branch if necessary to fix any formatting issues. This project also ships with a PHP-CS configuration file and you are welcome to configure your local environment to make use of that.
We are a `PSR-4` and `PSR-0` compliant project, so please follow those guidelines at a minimum. In addition we run
`php-cs-fixer` on all PRs and releases to enforce a consistent code style. The following command executed on your machine
should show any areas where the code style does not line up correctly.
All class variable declarations should be in alphabetical order, and constructor arguments should be in alphabetical order based on the classname. See the example below for how this should look, or check out any of the `app/Service` files for examples.
```php
class ProcessScheduleService
{
protected $repository;
protected $runnerService;
public function __construct(RunTaskService $runnerService, ScheduleRepositoryInterface $repository)
{
$this->repository = $repository;
$this->runnerService = $runnerService;
}
```
vendor/bin/php-cs-fixer fix --dry-run --diff --diff-format=udiff --config .php_cs.dist
```
### Responsible Disclosure
This is a fairly in-depth project and makes use of a lot of parts. We strive to keep everything as secure as possible and welcome you to take a look at the code provided in this project yourself. We do ask that you be considerate of others who are using the software and not publicly disclose security issues without contacting us first by email.
This is a fairly in-depth project and makes use of a lot of parts. We strive to keep everything as secure as possible
and welcome you to take a look at the code provided in this project yourself. We do ask that you be considerate of
others who are using the software and not publicly disclose security issues without contacting us first by email.
We'll make a deal with you: if you contact us by email and we fail to respond to you within a week you are welcome to publicly disclose whatever issue you have found. We understand how frustrating it is when you find something big and no one will respond to you. This holds us to a standard of providing prompt attention to any issues that arise and keeping this community safe.
We'll make a deal with you: if you contact us by email and we fail to respond to you within a week you are welcome to
publicly disclose whatever issue you have found. We understand how frustrating it is when you find something big and
no one will respond to you. This holds us to a standard of providing prompt attention to any issues that arise and
keeping this community safe.
If you've found what you believe is a security issue please email us at `support@pterodactyl.io`.
If you've found what you believe is a security issue please email `dane åt pterodactyl døt io`.
### Where to find Us
You can find us in a couple places online. First and foremost, we're active right here on Github. If you encounter a bug or other problems, open an issue on here for us to take a look at it. We also accept feature requests here as well.
### Contact Us
You can find us in a couple places online. First and foremost, we're active right here on Github. If you encounter a
bug or other problems, open an issue on here for us to take a look at it. We also accept feature requests here as well.
You can also find us on [Discord](https://pterodactyl.io/discord). In the event that you need to get in contact with us privately feel free to contact us at `support@pterodactyl.io`. Try not to email us with requests for support regarding the panel, we'll probably just direct you to our Discord.
You can also find us on [Discord](https://discord.gg/pterodactyl).

View File

@ -1,14 +0,0 @@
# Pterodactyl Panel Contributors
This panel would not be possible without the support of our wonderful community of
developers who provide code enhancements, new features, and bug fixes to make this panel
the best that is can be. You can view a full listing of contributors [here](https://github.com/Pterodactyl/Panel/graphs/contributors).
Dane Everitt [@DaneEveritt](https://github.com/Pterodactyl/Panel/commits?author=DaneEveritt)
Dylan Seidt [@DDynamic](https://github.com/Pterodactyl/Panel/commits?author=DDynamic)
[@nikkiii](https://github.com/Pterodactyl/Panel/commits?author=nikkiii)
# Get Involved
See our `CONTRIBUTING.md` document for information on how to get started. Once you've submitted some code feel free to
modify this file and add your name to the list. Please follow the format above for your name and linking to your contributions.

View File

@ -1,7 +1,7 @@
# The MIT License (MIT)
```
Copyright (c) 2015 - 2020 Dane Everitt <dane@daneeveritt.com>
Copyright (c) 2015 - 2021 Dane Everitt <dane@daneeveritt.com> 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

View File

@ -21,6 +21,8 @@ I would like to extend my sincere thanks to the following sponsors for helping f
| Company | About |
| ------- | ----- |
| [**WISP**](https://wisp.gg) | Extra features. |
| [**MixmlHosting**](https://mixmlhosting.com) | MixmlHosting provides high quality Virtual Private Servers along with game servers, all at a affordable price. |
| [**BisectHosting**](https://www.bisecthosting.com/) | BisectHosting provides Minecraft, Valheim and other server hosting services with the highest reliability and lightning fast support since 2012. |
| [**Bloom.host**](https://bloom.host) | Bloom.host offers dedicated core VPS and Minecraft hosting with Ryzen 9 processors. With owned-hardware, we offer truly unbeatable prices on high-performance hosting. |
| [**MineStrator**](https://minestrator.com/) | Looking for a French highend hosting company for you minecraft server? More than 14,000 members on our discord, trust us. |
| [**DedicatedMC**](https://dedicatedmc.io/) | DedicatedMC provides Raw Power hosting at affordable pricing, making sure to never compromise on your performance and giving you the best performance money can buy. |
@ -32,6 +34,7 @@ I would like to extend my sincere thanks to the following sponsors for helping f
| [**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! |
| [**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. |
## Documentation
* [Panel Documentation](https://pterodactyl.io/panel/1.0/getting_started.html)
@ -70,7 +73,7 @@ and there are plenty more games available provided by the community. Some of the
## License
```
Copyright (c) 2015 - 2020 Dane Everitt <dane@daneeveritt.com> & Contributors
Copyright (c) 2015 - 2021 Dane Everitt <dane@daneeveritt.com> 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

View File

@ -15,6 +15,7 @@ class UpgradeCommand extends Command
/** @var string */
protected $signature = 'p:upgrade
{--user= : The user that PHP runs under. All files will be owned by this user.}
{--group= : The group that PHP runs under. All files will be owned by this group.}
{--url= : The specific archive to download.}
{--release= : A specific Pterodactyl version to download from GitHub. Leave blank to use latest.}
{--skip-download : If set no archive will be downloaded.}';
@ -46,22 +47,39 @@ class UpgradeCommand extends Command
}
$user = 'www-data';
$group = 'www-data';
if ($this->input->isInteractive()) {
if (!$skipDownload) {
$skipDownload = !$this->confirm('Would you like to download and unpack the archive files for the latest version?', true);
}
if (is_null($this->option('user'))) {
$details = posix_getpwuid(fileowner('public'));
$user = $details['name'] ?? 'www-data';
$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)) {
$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".',
[
'www-data',
'apache',
'nginx',
'apache',
]
);
}
}
if (is_null($this->option('group'))) {
$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)) {
$group = $this->anticipate(
'Please enter the name of the group running your webserver process. Normally this is the same as your user.',
[
'www-data',
'nginx',
'apache',
]
);
}
@ -136,9 +154,9 @@ class UpgradeCommand extends Command
$this->call('migrate', ['--seed' => '', '--force' => '']);
});
$this->withProgress($bar, function () use ($user) {
$this->line("\$upgrader> chown -R {$user}:{$user} *");
$process = Process::fromShellCommandline("chown -R {$user}:{$user} *", $this->getLaravel()->basePath());
$this->withProgress($bar, function () use ($user, $group) {
$this->line("\$upgrader> chown -R {$user}:{$group} *");
$process = Process::fromShellCommandline("chown -R {$user}:{$group} *", $this->getLaravel()->basePath());
$process->setTimeout(10 * 60);
$process->run(function ($type, $buffer) {
$this->{$type === Process::ERR ? 'error' : 'line'}($buffer);

View File

@ -38,6 +38,15 @@ class DaemonConnectionException extends DisplayException
if ($useStatusCode) {
$this->statusCode = is_null($response) ? $this->statusCode : $response->getStatusCode();
// There are rare conditions where wings encounters a panic condition and crashes the
// request being made after content has already been sent over the wire. In these cases
// you can end up with a "successful" response code that is actual an error.
//
// Handle those better here since we shouldn't ever end up in this exception state and
// be returning a 2XX level response.
if ($this->statusCode < 400) {
$this->statusCode = Response::HTTP_BAD_GATEWAY;
}
}
if (is_null($response)) {

View File

@ -0,0 +1,16 @@
<?php
namespace Pterodactyl\Exceptions\Service\Backup;
use Pterodactyl\Exceptions\DisplayException;
class BackupLockedException extends DisplayException
{
/**
* TooManyBackupsException constructor.
*/
public function __construct()
{
parent::__construct('Cannot delete a backup that is marked as locked.');
}
}

View File

@ -108,7 +108,7 @@ class NodeController extends ApplicationApiController
$node = $this->updateService->handle(
$node,
$request->validated(),
$request->input('reset_secret')
$request->input('reset_secret'),
);
return $this->fractal->item($node)

View File

@ -35,7 +35,7 @@ class NodeDeploymentController extends ApplicationApiController
$nodes = $this->viableNodesService->setLocations($data['location_ids'] ?? [])
->setMemory($data['memory'])
->setDisk($data['disk'])
->handle($request->input('page') ?? 0);
->handle($request->query('per_page'), $request->query('page'));
return $this->fractal->collection($nodes)
->transformWith($this->getTransformer(NodeTransformer::class))

View File

@ -72,11 +72,18 @@ class BackupController extends ClientApiController
{
/** @var \Pterodactyl\Models\Backup $backup */
$backup = $server->audit(AuditLog::SERVER__BACKUP_STARTED, function (AuditLog $model, Server $server) use ($request) {
$backup = $this->initiateBackupService
->setIgnoredFiles(
explode(PHP_EOL, $request->input('ignored') ?? '')
)
->handle($server, $request->input('name'));
$action = $this->initiateBackupService
->setIgnoredFiles(explode(PHP_EOL, $request->input('ignored') ?? ''));
// Only set the lock status if the user even has permission to delete backups,
// otherwise ignore this status. This gets a little funky since it isn't clear
// how best to allow a user to create a backup that is locked without also preventing
// them from just filling up a server with backups that can never be deleted?
if ($request->user()->can(Permission::ACTION_BACKUP_DELETE, $server)) {
$action->setIsLocked((bool) $request->input('is_locked'));
}
$backup = $action->handle($server, $request->input('name'));
$model->metadata = ['backup_uuid' => $backup->uuid];
@ -88,6 +95,32 @@ class BackupController extends ClientApiController
->toArray();
}
/**
* Toggles the lock status of a given backup for a server.
*
* @throws \Throwable
* @throws \Illuminate\Auth\Access\AuthorizationException
*/
public function toggleLock(Request $request, Server $server, Backup $backup): array
{
if (!$request->user()->can(Permission::ACTION_BACKUP_DELETE, $server)) {
throw new AuthorizationException();
}
$action = $backup->is_locked ? AuditLog::SERVER__BACKUP_UNLOCKED : AuditLog::SERVER__BACKUP_LOCKED;
$server->audit($action, function (AuditLog $audit) use ($backup) {
$audit->metadata = ['backup_uuid' => $backup->uuid];
$backup->update(['is_locked' => !$backup->is_locked]);
});
$backup->refresh();
return $this->fractal->item($backup)
->transformWith($this->getTransformer(BackupTransformer::class))
->toArray();
}
/**
* Returns information about a single backup.
*

View File

@ -36,11 +36,10 @@ class ResourceUtilizationController extends ClientApiController
*/
public function __invoke(GetServerRequest $request, Server $server): array
{
$stats = $this->cache
->tags(['resources'])
->remember($server->uuid, Carbon::now()->addSeconds(20), function () use ($server) {
return $this->repository->setServer($server)->getDetails();
});
$key = "resources:{$server->uuid}";
$stats = $this->cache->remember($key, Carbon::now()->addSeconds(20), function () use ($server) {
return $this->repository->setServer($server)->getDetails();
});
return $this->fractal->item($stats)
->transformWith($this->getTransformer(StatsTransformer::class))

View File

@ -15,7 +15,6 @@ use Pterodactyl\Services\Schedules\ProcessScheduleService;
use Pterodactyl\Transformers\Api\Client\ScheduleTransformer;
use Pterodactyl\Http\Controllers\Api\Client\ClientApiController;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Pterodactyl\Http\Requests\Api\Client\Servers\Schedules\ViewScheduleRequest;
use Pterodactyl\Http\Requests\Api\Client\Servers\Schedules\StoreScheduleRequest;
use Pterodactyl\Http\Requests\Api\Client\Servers\Schedules\DeleteScheduleRequest;
@ -72,6 +71,7 @@ class ScheduleController extends ClientApiController
'cron_hour' => $request->input('hour'),
'cron_minute' => $request->input('minute'),
'is_active' => (bool) $request->input('is_active'),
'only_when_online' => (bool) $request->input('only_when_online'),
'next_run_at' => $this->getNextRunAt($request),
]);
@ -118,6 +118,7 @@ class ScheduleController extends ClientApiController
'cron_hour' => $request->input('hour'),
'cron_minute' => $request->input('minute'),
'is_active' => $active,
'only_when_online' => (bool) $request->input('only_when_online'),
'next_run_at' => $this->getNextRunAt($request),
];

View File

@ -33,6 +33,7 @@ class ScheduleTaskController extends ClientApiController
/**
* Create a new task for a given schedule and store it in the database.
*
* @throws \Pterodactyl\Exceptions\Http\HttpForbiddenException
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
* @throws \Pterodactyl\Exceptions\Service\ServiceLimitExceededException
* @throws \Illuminate\Contracts\Container\BindingResolutionException
@ -44,6 +45,10 @@ class ScheduleTaskController extends ClientApiController
throw new ServiceLimitExceededException("Schedules may not have more than {$limit} tasks associated with them. Creating this task would put this schedule over the limit.");
}
if ($server->backup_limit === 0 && $request->action === 'backup') {
throw new HttpForbiddenException("A backup task cannot be created when the server's backup limit is set to 0.");
}
/** @var \Pterodactyl\Models\Task|null $lastTask */
$lastTask = $schedule->tasks()->orderByDesc('sequence_id')->first();
@ -54,6 +59,7 @@ class ScheduleTaskController extends ClientApiController
'action' => $request->input('action'),
'payload' => $request->input('payload') ?? '',
'time_offset' => $request->input('time_offset'),
'continue_on_failure' => (bool) $request->input('continue_on_failure'),
]);
return $this->fractal->item($task)
@ -64,6 +70,7 @@ class ScheduleTaskController extends ClientApiController
/**
* Updates a given task for a server.
*
* @throws \Pterodactyl\Exceptions\Http\HttpForbiddenException
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
* @throws \Illuminate\Contracts\Container\BindingResolutionException
@ -74,10 +81,15 @@ class ScheduleTaskController extends ClientApiController
throw new NotFoundHttpException();
}
if ($server->backup_limit === 0 && $request->action === 'backup') {
throw new HttpForbiddenException("A backup task cannot be created when the server's backup limit is set to 0.");
}
$this->repository->update($task->id, [
'action' => $request->input('action'),
'payload' => $request->input('payload') ?? '',
'time_offset' => $request->input('time_offset'),
'continue_on_failure' => (bool) $request->input('continue_on_failure'),
]);
return $this->fractal->item($task->refresh())

View File

@ -90,7 +90,7 @@ class ServerDetailsController extends Controller
/** @var \Pterodactyl\Models\Server[] $servers */
$servers = Server::query()
->select('servers.*')
->selectRaw('started.metadata->>"$.backup_uuid" as backup_uuid')
->selectRaw('JSON_UNQUOTE(JSON_EXTRACT(started.metadata, "$.backup_uuid")) as backup_uuid')
->leftJoinSub(function (Builder $builder) {
$builder->select('*')->from('audit_logs')
->where('action', AuditLog::SERVER__BACKUP_RESTORE_STARTED)

View File

@ -19,6 +19,7 @@ class StoreBackupRequest extends ClientApiRequest
{
return [
'name' => 'nullable|string|max:191',
'is_locked' => 'nullable|boolean',
'ignored' => 'nullable|string',
];
}

View File

@ -7,7 +7,6 @@ use Pterodactyl\Jobs\Job;
use Carbon\CarbonImmutable;
use Pterodactyl\Models\Task;
use InvalidArgumentException;
use Pterodactyl\Models\Schedule;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Contracts\Queue\ShouldQueue;
@ -15,6 +14,7 @@ use Illuminate\Foundation\Bus\DispatchesJobs;
use Pterodactyl\Services\Backups\InitiateBackupService;
use Pterodactyl\Repositories\Wings\DaemonPowerRepository;
use Pterodactyl\Repositories\Wings\DaemonCommandRepository;
use Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException;
class RunTaskJob extends Job implements ShouldQueue
{
@ -27,13 +27,19 @@ class RunTaskJob extends Job implements ShouldQueue
*/
public $task;
/**
* @var bool
*/
public $manualRun;
/**
* RunTaskJob constructor.
*/
public function __construct(Task $task)
public function __construct(Task $task, $manualRun = false)
{
$this->queue = config('pterodactyl.queues.standard');
$this->task = $task;
$this->manualRun = $manualRun;
}
/**
@ -46,8 +52,8 @@ class RunTaskJob extends Job implements ShouldQueue
InitiateBackupService $backupService,
DaemonPowerRepository $powerRepository
) {
// Do not process a task that is not set to active.
if (!$this->task->schedule->is_active) {
// Do not process a task that is not set to active, unless it's been manually triggered.
if (!$this->task->schedule->is_active && !$this->manualRun) {
$this->markTaskNotQueued();
$this->markScheduleComplete();
@ -56,18 +62,26 @@ class RunTaskJob extends Job implements ShouldQueue
$server = $this->task->server;
// Perform the provided task against the daemon.
switch ($this->task->action) {
case 'power':
$powerRepository->setServer($server)->send($this->task->payload);
break;
case 'command':
$commandRepository->setServer($server)->send($this->task->payload);
break;
case 'backup':
$backupService->setIgnoredFiles(explode(PHP_EOL, $this->task->payload))->handle($server, null, true);
break;
default:
throw new InvalidArgumentException('Cannot run a task that points to a non-existent action.');
try {
switch ($this->task->action) {
case Task::ACTION_POWER:
$powerRepository->setServer($server)->send($this->task->payload);
break;
case Task::ACTION_COMMAND:
$commandRepository->setServer($server)->send($this->task->payload);
break;
case Task::ACTION_BACKUP:
$backupService->setIgnoredFiles(explode(PHP_EOL, $this->task->payload))->handle($server, null, true);
break;
default:
throw new InvalidArgumentException('Invalid task action provided: ' . $this->task->action);
}
} catch (Exception $exception) {
// If this isn't a DaemonConnectionException on a task that allows for failures
// throw the exception back up the chain so that the task is stopped.
if (!($this->task->continue_on_failure && $exception instanceof DaemonConnectionException)) {
throw $exception;
}
}
$this->markTaskNotQueued();
@ -101,7 +115,7 @@ class RunTaskJob extends Job implements ShouldQueue
$nextTask->update(['is_queued' => true]);
$this->dispatch((new self($nextTask))->delay($nextTask->time_offset));
$this->dispatch((new self($nextTask, $this->manualRun))->delay($nextTask->time_offset));
}
/**

View File

@ -7,17 +7,17 @@ use Illuminate\Http\Request;
use Illuminate\Container\Container;
/**
* @property int $id
* @property string $uuid
* @property bool $is_system
* @property int|null $user_id
* @property int|null $server_id
* @property string $action
* @property string|null $subaction
* @property array $device
* @property array $metadata
* @property \Carbon\CarbonImmutable $created_at
* @property \Pterodactyl\Models\User|null $user
* @property int $id
* @property string $uuid
* @property bool $is_system
* @property int|null $user_id
* @property int|null $server_id
* @property string $action
* @property string|null $subaction
* @property array $device
* @property array $metadata
* @property \Carbon\CarbonImmutable $created_at
* @property \Pterodactyl\Models\User|null $user
* @property \Pterodactyl\Models\Server|null $server
*/
class AuditLog extends Model
@ -36,6 +36,8 @@ class AuditLog extends Model
public const SERVER__BACKUP_COMPELTED = 'server:backup.completed';
public const SERVER__BACKUP_DELETED = 'server:backup.deleted';
public const SERVER__BACKUP_DOWNLOADED = 'server:backup.downloaded';
public const SERVER__BACKUP_LOCKED = 'server:backup.locked';
public const SERVER__BACKUP_UNLOCKED = 'server:backup.unlocked';
public const SERVER__BACKUP_RESTORE_STARTED = 'server:backup.restore.started';
public const SERVER__BACKUP_RESTORE_COMPLETED = 'server:backup.restore.completed';
public const SERVER__BACKUP_RESTORE_FAILED = 'server:backup.restore.failed';

View File

@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
* @property int $server_id
* @property string $uuid
* @property bool $is_successful
* @property bool $is_locked
* @property string $name
* @property string[] $ignored_files
* @property string $disk
@ -46,6 +47,7 @@ class Backup extends Model
protected $casts = [
'id' => 'int',
'is_successful' => 'bool',
'is_locked' => 'bool',
'ignored_files' => 'array',
'bytes' => 'int',
];
@ -62,6 +64,7 @@ class Backup extends Model
*/
protected $attributes = [
'is_successful' => true,
'is_locked' => false,
'checksum' => null,
'bytes' => 0,
'upload_id' => null,
@ -79,6 +82,7 @@ class Backup extends Model
'server_id' => 'bail|required|numeric|exists:servers,id',
'uuid' => 'required|uuid',
'is_successful' => 'boolean',
'is_locked' => 'boolean',
'name' => 'required|string',
'ignored_files' => 'array',
'disk' => 'required|string',

View File

@ -18,6 +18,7 @@ use Pterodactyl\Contracts\Extensions\HashidsInterface;
* @property string $cron_minute
* @property bool $is_active
* @property bool $is_processing
* @property bool $only_when_online
* @property \Carbon\Carbon|null $last_run_at
* @property \Carbon\Carbon|null $next_run_at
* @property \Carbon\Carbon $created_at
@ -63,6 +64,7 @@ class Schedule extends Model
'cron_minute',
'is_active',
'is_processing',
'only_when_online',
'last_run_at',
'next_run_at',
];
@ -75,6 +77,7 @@ class Schedule extends Model
'server_id' => 'integer',
'is_active' => 'boolean',
'is_processing' => 'boolean',
'only_when_online' => 'boolean',
];
/**
@ -99,6 +102,7 @@ class Schedule extends Model
'cron_minute' => '*',
'is_active' => true,
'is_processing' => false,
'only_when_online' => false,
];
/**
@ -114,6 +118,7 @@ class Schedule extends Model
'cron_minute' => 'required|string',
'is_active' => 'boolean',
'is_processing' => 'boolean',
'only_when_online' => 'boolean',
'last_run_at' => 'nullable|date',
'next_run_at' => 'nullable|date',
];
@ -122,6 +127,7 @@ class Schedule extends Model
* Returns the schedule's execution crontab entry as a string.
*
* @return \Carbon\CarbonImmutable
*
* @throws \Exception
*/
public function getNextRunDate()

View File

@ -14,6 +14,7 @@ use Pterodactyl\Contracts\Extensions\HashidsInterface;
* @property string $payload
* @property int $time_offset
* @property bool $is_queued
* @property bool $continue_on_failure
* @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon $updated_at
* @property string $hashid
@ -30,6 +31,13 @@ class Task extends Model
*/
public const RESOURCE_NAME = 'schedule_task';
/**
* The default actions that can exist for a task in Pterodactyl.
*/
public const ACTION_POWER = 'power';
public const ACTION_COMMAND = 'command';
public const ACTION_BACKUP = 'backup';
/**
* The table associated with the model.
*
@ -56,6 +64,7 @@ class Task extends Model
'payload',
'time_offset',
'is_queued',
'continue_on_failure',
];
/**
@ -69,6 +78,7 @@ class Task extends Model
'sequence_id' => 'integer',
'time_offset' => 'integer',
'is_queued' => 'boolean',
'continue_on_failure' => 'boolean',
];
/**
@ -79,6 +89,7 @@ class Task extends Model
protected $attributes = [
'time_offset' => 0,
'is_queued' => false,
'continue_on_failure' => false,
];
/**
@ -91,6 +102,7 @@ class Task extends Model
'payload' => 'required_unless:action,backup|string',
'time_offset' => 'required|numeric|between:0,900',
'is_queued' => 'boolean',
'continue_on_failure' => 'boolean',
];
/**

View File

@ -9,6 +9,7 @@ use Illuminate\Database\ConnectionInterface;
use Pterodactyl\Extensions\Backups\BackupManager;
use Pterodactyl\Repositories\Eloquent\BackupRepository;
use Pterodactyl\Repositories\Wings\DaemonBackupRepository;
use Pterodactyl\Exceptions\Service\Backup\BackupLockedException;
use Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException;
class DeleteBackupService
@ -55,6 +56,10 @@ class DeleteBackupService
*/
public function handle(Backup $backup)
{
if ($backup->is_locked) {
throw new BackupLockedException();
}
if ($backup->disk === Backup::ADAPTER_AWS_S3) {
$this->deleteFromS3($backup);

View File

@ -21,6 +21,11 @@ class InitiateBackupService
*/
private $ignoredFiles;
/**
* @var bool
*/
private $isLocked = false;
/**
* @var \Pterodactyl\Repositories\Eloquent\BackupRepository
*/
@ -49,7 +54,11 @@ class InitiateBackupService
/**
* InitiateBackupService constructor.
*
* @param \Pterodactyl\Repositories\Eloquent\BackupRepository $repository
* @param \Illuminate\Database\ConnectionInterface $connection
* @param \Pterodactyl\Repositories\Wings\DaemonBackupRepository $daemonBackupRepository
* @param \Pterodactyl\Services\Backups\DeleteBackupService $deleteBackupService
* @param \Pterodactyl\Extensions\Backups\BackupManager $backupManager
*/
public function __construct(
BackupRepository $repository,
@ -65,6 +74,19 @@ class InitiateBackupService
$this->deleteBackupService = $deleteBackupService;
}
/**
* Set if the backup should be locked once it is created which will prevent
* its deletion by users or automated system processes.
*
* @return $this
*/
public function setIsLocked(bool $isLocked): self
{
$this->isLocked = $isLocked;
return $this;
}
/**
* Sets the files to be ignored by this backup.
*
@ -91,7 +113,7 @@ class InitiateBackupService
}
/**
* Initiates the backup process for a server on the daemon.
* Initiates the backup process for a server on Wings.
*
* @throws \Throwable
* @throws \Pterodactyl\Exceptions\Service\Backup\TooManyBackupsException
@ -104,23 +126,30 @@ class InitiateBackupService
if ($period > 0) {
$previous = $this->repository->getBackupsGeneratedDuringTimespan($server->id, $period);
if ($previous->count() >= $limit) {
throw new TooManyRequestsHttpException(CarbonImmutable::now()->diffInSeconds($previous->last()->created_at->addSeconds($period)), sprintf('Only %d backups may be generated within a %d second span of time.', $limit, $period));
$message = sprintf('Only %d backups may be generated within a %d second span of time.', $limit, $period);
throw new TooManyRequestsHttpException(CarbonImmutable::now()->diffInSeconds($previous->last()->created_at->addSeconds($period)), $message);
}
}
// Check if the server has reached or exceeded it's backup limit
if (!$server->backup_limit || $server->backups()->where('is_successful', true)->count() >= $server->backup_limit) {
// Check if the server has reached or exceeded it's backup limit.
$successful = $server->backups()->where('is_successful', true);
if (!$server->backup_limit || $successful->count() >= $server->backup_limit) {
// Do not allow the user to continue if this server is already at its limit and can't override.
if (!$override || $server->backup_limit <= 0) {
throw new TooManyBackupsException($server->backup_limit);
}
// Get the oldest backup the server has.
/** @var \Pterodactyl\Models\Backup $oldestBackup */
$oldestBackup = $server->backups()->where('is_successful', true)->orderBy('created_at')->first();
// 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 */
$oldest = $successful->where('is_locked', false)->orderBy('created_at')->first();
if (!$oldest) {
throw new TooManyBackupsException($server->backup_limit);
}
// Delete the oldest backup.
$this->deleteBackupService->handle($oldestBackup);
$this->deleteBackupService->handle($oldest);
}
return $this->connection->transaction(function () use ($server, $name) {
@ -131,6 +160,7 @@ class InitiateBackupService
'name' => trim($name) ?: sprintf('Backup at %s', CarbonImmutable::now()->toDateTimeString()),
'ignored_files' => array_values($this->ignoredFiles ?? []),
'disk' => $this->backupManager->getDefaultAdapter(),
'is_locked' => $this->isLocked,
], true, true);
$this->daemonBackupRepository->setServer($server)

View File

@ -83,7 +83,7 @@ class FindViableNodesService
*
* @throws \Pterodactyl\Exceptions\Service\Deployment\NoViableNodeException
*/
public function handle(int $page = null)
public function handle(int $perPage = null, int $page = null)
{
Assert::integer($this->disk, 'Disk space must be an int, got %s');
Assert::integer($this->memory, 'Memory usage must be an int, got %s');
@ -103,7 +103,7 @@ class FindViableNodesService
->havingRaw('(IFNULL(SUM(servers.disk), 0) + ?) <= (nodes.disk * (1 + (nodes.disk_overallocate / 100)))', [$this->disk]);
if (!is_null($page)) {
$results = $results->paginate(50, ['*'], 'page', $page);
$results = $results->paginate($perPage ?? 50, ['*'], 'page', $page);
} else {
$results = $results->get()->toBase();
}

View File

@ -8,6 +8,8 @@ use Illuminate\Contracts\Bus\Dispatcher;
use Pterodactyl\Jobs\Schedule\RunTaskJob;
use Illuminate\Database\ConnectionInterface;
use Pterodactyl\Exceptions\DisplayException;
use Pterodactyl\Repositories\Wings\DaemonServerRepository;
use Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException;
class ProcessScheduleService
{
@ -21,13 +23,19 @@ class ProcessScheduleService
*/
private $connection;
/**
* @var \Pterodactyl\Repositories\Wings\DaemonServerRepository
*/
private $serverRepository;
/**
* ProcessScheduleService constructor.
*/
public function __construct(ConnectionInterface $connection, Dispatcher $dispatcher)
public function __construct(ConnectionInterface $connection, DaemonServerRepository $serverRepository, Dispatcher $dispatcher)
{
$this->dispatcher = $dispatcher;
$this->connection = $connection;
$this->serverRepository = $serverRepository;
}
/**
@ -38,7 +46,7 @@ class ProcessScheduleService
public function handle(Schedule $schedule, bool $now = false)
{
/** @var \Pterodactyl\Models\Task $task */
$task = $schedule->tasks()->orderBy('sequence_id', 'asc')->first();
$task = $schedule->tasks()->orderBy('sequence_id')->first();
if (is_null($task)) {
throw new DisplayException('Cannot process schedule for task execution: no tasks are registered.');
@ -53,7 +61,31 @@ class ProcessScheduleService
$task->update(['is_queued' => true]);
});
$job = new RunTaskJob($task);
$job = new RunTaskJob($task, $now);
if ($schedule->only_when_online) {
// Check that the server is currently in a starting or running state before executing
// this schedule if this option has been set.
try {
$details = $this->serverRepository->setServer($schedule->server)->getDetails();
$state = $details['state'] ?? 'offline';
// If the server is stopping or offline just do nothing with this task.
if (in_array($state, ['offline', 'stopping'])) {
$job->failed();
return;
}
} catch (Exception $exception) {
if (!$exception instanceof DaemonConnectionException) {
// If we encountered some exception during this process that wasn't just an
// issue connecting to Wings run the failed sequence for a job. Otherwise we
// can just quietly mark the task as completed without actually running anything.
$job->failed($exception);
}
$job->failed();
return;
}
}
if (!$now) {
$this->dispatcher->dispatch($job->delay($task->time_offset));

View File

@ -1,11 +1,4 @@
<?php
/**
* Pterodactyl - Panel
* Copyright (c) 2015 - 2017 Dane Everitt <dane@daneeveritt.com>.
*
* This software is licensed under the terms of the MIT license.
* https://opensource.org/licenses/MIT
*/
namespace Pterodactyl\Traits\Commands;
@ -13,6 +6,20 @@ use Pterodactyl\Exceptions\PterodactylException;
trait EnvironmentWriterTrait
{
/**
* Escapes an environment value by looking for any characters that could
* reasonablly cause environment parsing issues. Those values are then wrapped
* in quotes before being returned.
*/
public function escapeEnvironmentValue(string $value): string
{
if (!preg_match('/^\"(.*)\"$/', $value) && preg_match('/([^\w.\-+\/])+/', $value)) {
return sprintf('"%s"', addslashes($value));
}
return $value;
}
/**
* Update the .env file for the application using the passed in values.
*
@ -28,14 +35,7 @@ trait EnvironmentWriterTrait
$saveContents = file_get_contents($path);
collect($values)->each(function ($value, $key) use (&$saveContents) {
$key = strtoupper($key);
// If the key value is not sorrounded by quotation marks, and contains anything that could reasonably
// cause environment parsing issues, wrap it in quotes before writing it. This also adds slashes to the
// value to ensure quotes within it don't cause us issues.
if (!preg_match('/^\"(.*)\"$/', $value) && preg_match('/([^\w.\-+\/])+/', $value)) {
$value = sprintf('"%s"', addslashes($value));
}
$saveValue = sprintf('%s=%s', $key, $value);
$saveValue = sprintf('%s=%s', $key, $this->escapeEnvironmentValue($value));
if (preg_match_all('/^' . $key . '=(.*)$/m', $saveContents) < 1) {
$saveContents = $saveContents . PHP_EOL . $saveValue;

View File

@ -19,6 +19,7 @@ class BackupTransformer extends BaseClientTransformer
return [
'uuid' => $backup->uuid,
'is_successful' => $backup->is_successful,
'is_locked' => $backup->is_locked,
'name' => $backup->name,
'ignored_files' => $backup->ignored_files,
'checksum' => $backup->checksum,

View File

@ -45,6 +45,7 @@ class ScheduleTransformer extends BaseClientTransformer
],
'is_active' => $model->is_active,
'is_processing' => $model->is_processing,
'only_when_online' => $model->only_when_online,
'last_run_at' => $model->last_run_at ? $model->last_run_at->toIso8601String() : null,
'next_run_at' => $model->next_run_at ? $model->next_run_at->toIso8601String() : null,
'created_at' => $model->created_at->toIso8601String(),

View File

@ -21,13 +21,13 @@ class StatsTransformer extends BaseClientTransformer
{
return [
'current_state' => Arr::get($data, 'state', 'stopped'),
'is_suspended' => Arr::get($data, 'suspended', false),
'is_suspended' => Arr::get($data, 'is_suspended', false),
'resources' => [
'memory_bytes' => Arr::get($data, 'memory_bytes', 0),
'cpu_absolute' => Arr::get($data, 'cpu_absolute', 0),
'disk_bytes' => Arr::get($data, 'disk_bytes', 0),
'network_rx_bytes' => Arr::get($data, 'network.rx_bytes', 0),
'network_tx_bytes' => Arr::get($data, 'network.tx_bytes', 0),
'memory_bytes' => Arr::get($data, 'utilization.memory_bytes', 0),
'cpu_absolute' => Arr::get($data, 'utilization.cpu_absolute', 0),
'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),
],
];
}

View File

@ -28,6 +28,7 @@ class TaskTransformer extends BaseClientTransformer
'payload' => $model->payload,
'time_offset' => $model->time_offset,
'is_queued' => $model->is_queued,
'continue_on_failure' => $model->continue_on_failure,
'created_at' => $model->created_at->toIso8601String(),
'updated_at' => $model->updated_at->toIso8601String(),
];

View File

@ -1,5 +0,0 @@
coverage:
status:
project: off
patch: off
comment: false

View File

@ -4,7 +4,7 @@
"version": "PTDL_v1",
"update_url": null
},
"exported_at": "2021-03-15T18:04:38+02:00",
"exported_at": "2021-04-21T23:01:35+03: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.",
@ -25,7 +25,7 @@
},
"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:\/\/files.minecraftforge.net\/maven\/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 FILE_SITE=$(echo -e ${JSON_DATA} | jq -r '.homepage' | sed \"s\/http:\/https:\/g\")\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\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",
"container": "openjdk:8-jdk-slim",
"entrypoint": "bash"
}

View File

@ -31,7 +31,7 @@
"default_value": "A Rust Server",
"user_viewable": true,
"user_editable": true,
"rules": "required|string|max:40"
"rules": "required|string|max:60"
},
{
"name": "OxideMod",

View File

@ -47,7 +47,7 @@
},
{
"name": "Server Map",
"description": "Available Maps: TheIsland, TheCenter, Ragnarok, ScorchedEarth_P, Aberration_P, Extinction, Valguero_P, Genesis",
"description": "Available Maps: TheIsland, TheCenter, Ragnarok, ScorchedEarth_P, Aberration_P, Extinction, Valguero_P, Genesis, CrystalIsles",
"env_variable": "SERVER_MAP",
"default_value": "TheIsland",
"user_viewable": true,

View File

@ -1,13 +1,18 @@
{
"_comment": "DO NOT EDIT: FILE GENERATED AUTOMATICALLY BY PTERODACTYL PANEL - PTERODACTYL.IO",
"meta": {
"version": "PTDL_v1"
"version": "PTDL_v1",
"update_url": null
},
"exported_at": "2020-10-25T22:34:06+00:00",
"exported_at": "2021-05-06T23:35:46+03:00",
"name": "Mumble Server",
"author": "support@pterodactyl.io",
"description": "Mumble is an open source, low-latency, high quality voice chat software primarily intended for use while gaming.",
"image": "quay.io\/pterodactyl\/core:glibc",
"features": null,
"images": [
"quay.io\/pterodactyl\/core:glibc"
],
"file_denylist": [],
"startup": ".\/murmur.x86 -fg",
"config": {
"files": "{\"murmur.ini\":{\"parser\": \"ini\", \"find\":{\"logfile\": \"murmur.log\", \"port\": \"{{server.build.default.port}}\", \"host\": \"0.0.0.0\", \"users\": \"{{server.build.env.MAX_USERS}}\"}}}",
@ -39,7 +44,7 @@
"default_value": "latest",
"user_viewable": true,
"user_editable": true,
"rules": "required|regex:\/^([0-9_\\.-]{5,8})$\/"
"rules": "required|string"
}
]
}

View File

@ -4,7 +4,7 @@
"version": "PTDL_v1",
"update_url": null
},
"exported_at": "2021-01-12T15:44:41+01:00",
"exported_at": "2021-05-06T23:38:28+03:00",
"name": "Teamspeak3 Server",
"author": "support@pterodactyl.io",
"description": "VoIP software designed with security in mind, featuring crystal clear voice quality, endless customization options, and scalabilty up to thousands of simultaneous users.",
@ -12,6 +12,7 @@
"images": [
"quay.io\/parkervcp\/pterodactyl-images:base_debian"
],
"file_denylist": [],
"startup": ".\/ts3server default_voice_port={{SERVER_PORT}} query_port={{QUERY_PORT}} filetransfer_ip=0.0.0.0 filetransfer_port={{FILE_TRANSFER}} license_accepted=1",
"config": {
"files": "{}",
@ -21,7 +22,7 @@
},
"scripts": {
"installation": {
"script": "#!\/bin\/bash\r\n# TS3 Installation Script\r\n#\r\n# Server Files: \/mnt\/server\r\napt update\r\napt install -y tar curl jq bzip2\r\n\r\nif [ -z ${TS_VERSION} ] || [ ${TS_VERSION} == latest ]; then\r\n TS_VERSION=$(curl -sSL https:\/\/teamspeak.com\/versions\/server.json | jq -r '.linux.x86_64.version')\r\nfi\r\n\r\ncd \/mnt\/server\r\n\r\necho -e \"getting files from http:\/\/files.teamspeak-services.com\/releases\/server\/${TS_VERSION}\/teamspeak3-server_linux_amd64-${TS_VERSION}.tar.bz2\" \r\ncurl -L http:\/\/files.teamspeak-services.com\/releases\/server\/${TS_VERSION}\/teamspeak3-server_linux_amd64-${TS_VERSION}.tar.bz2 | tar -xvj --strip-components=1\r\n\r\nrm teamspeak3-server_linux_amd64-${TS_VERSION}.tar.bz2",
"script": "#!\/bin\/bash\r\n# TS3 Installation Script\r\n#\r\n# Server Files: \/mnt\/server\r\napt update\r\napt install -y tar curl jq bzip2\r\n\r\nif [ -z ${TS_VERSION} ] || [ ${TS_VERSION} == latest ]; then\r\n TS_VERSION=$(curl -sSL https:\/\/teamspeak.com\/versions\/server.json | jq -r '.linux.x86_64.version')\r\nfi\r\n\r\ncd \/mnt\/server\r\n\r\necho -e \"getting files from http:\/\/files.teamspeak-services.com\/releases\/server\/${TS_VERSION}\/teamspeak3-server_linux_amd64-${TS_VERSION}.tar.bz2\" \r\ncurl -L http:\/\/files.teamspeak-services.com\/releases\/server\/${TS_VERSION}\/teamspeak3-server_linux_amd64-${TS_VERSION}.tar.bz2 | tar -xvj --strip-components=1",
"container": "debian:buster-slim",
"entrypoint": "bash"
}

View File

@ -33,19 +33,17 @@ class StoreNodeTokensAsEncryptedValue extends Migration
$table->text('daemon_token')->change();
});
DB::transaction(function () {
/** @var \Illuminate\Contracts\Encryption\Encrypter $encrypter */
$encrypter = Container::getInstance()->make(Encrypter::class);
/** @var \Illuminate\Contracts\Encryption\Encrypter $encrypter */
$encrypter = Container::getInstance()->make(Encrypter::class);
foreach (DB::select('SELECT id, daemon_token FROM nodes') as $datum) {
DB::update('UPDATE nodes SET uuid = ?, daemon_token_id = ?, daemon_token = ? WHERE id = ?', [
Uuid::uuid4()->toString(),
substr($datum->daemon_token, 0, 16),
$encrypter->encrypt(substr($datum->daemon_token, 16)),
$datum->id,
]);
}
});
foreach (DB::select('SELECT id, daemon_token FROM nodes') as $datum) {
DB::update('UPDATE nodes SET uuid = ?, daemon_token_id = ?, daemon_token = ? WHERE id = ?', [
Uuid::uuid4()->toString(),
substr($datum->daemon_token, 0, 16),
$encrypter->encrypt(substr($datum->daemon_token, 16)),
$datum->id,
]);
}
Schema::table('nodes', function (Blueprint $table) {
$table->unique(['uuid']);

View File

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

View File

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

View File

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

View File

@ -18,6 +18,7 @@
"i18next-chained-backend": "^2.0.0",
"i18next-localstorage-backend": "^3.0.0",
"i18next-xhr-backend": "^3.2.2",
"qrcode.react": "^1.0.1",
"query-string": "^6.7.0",
"react": "^16.13.1",
"react-copy-to-clipboard": "^5.0.3",
@ -62,6 +63,7 @@
"@types/debounce": "^1.2.0",
"@types/events": "^3.0.0",
"@types/node": "^14.11.10",
"@types/qrcode.react": "^1.0.1",
"@types/query-string": "^6.3.0",
"@types/react": "^16.9.41",
"@types/react-copy-to-clipboard": "^4.3.0",
@ -75,8 +77,8 @@
"@types/uuid": "^3.4.5",
"@types/webpack-env": "^1.15.2",
"@types/yup": "^0.29.3",
"@typescript-eslint/eslint-plugin": "^4.14.2",
"@typescript-eslint/parser": "^4.14.2",
"@typescript-eslint/eslint-plugin": "^4.22.1",
"@typescript-eslint/parser": "^4.22.1",
"autoprefixer": "^10.1.0",
"babel-loader": "^8.0.6",
"babel-plugin-styled-components": "^1.12.0",
@ -97,7 +99,7 @@
"svg-url-loader": "^6.0.0",
"terser-webpack-plugin": "^3.0.6",
"twin.macro": "^2.0.7",
"typescript": "^4.1.3",
"typescript": "^4.2.4",
"webpack": "^4.43.0",
"webpack-assets-manifest": "^3.1.1",
"webpack-bundle-analyzer": "^3.8.0",

View File

@ -2,12 +2,18 @@ import http from '@/api/http';
import { ServerBackup } from '@/api/server/types';
import { rawDataToServerBackup } from '@/api/transformers';
export default (uuid: string, name?: string, ignored?: string): Promise<ServerBackup> => {
return new Promise((resolve, reject) => {
http.post(`/api/client/servers/${uuid}/backups`, {
name, ignored,
})
.then(({ data }) => resolve(rawDataToServerBackup(data)))
.catch(reject);
interface RequestParameters {
name?: string;
ignored?: string;
isLocked: boolean;
}
export default async (uuid: string, params: RequestParameters): Promise<ServerBackup> => {
const { data } = await http.post(`/api/client/servers/${uuid}/backups`, {
name: params.name,
ignored: params.ignored,
is_locked: params.isLocked,
});
return rawDataToServerBackup(data);
};

View File

@ -1,20 +1,19 @@
import { rawDataToServerSchedule, Schedule } from '@/api/server/schedules/getServerSchedules';
import http from '@/api/http';
type Data = Pick<Schedule, 'cron' | 'name' | 'isActive'> & { id?: number }
type Data = Pick<Schedule, 'cron' | 'name' | 'onlyWhenOnline' | 'isActive'> & { id?: number }
export default (uuid: string, schedule: Data): Promise<Schedule> => {
return new Promise((resolve, reject) => {
http.post(`/api/client/servers/${uuid}/schedules${schedule.id ? `/${schedule.id}` : ''}`, {
is_active: schedule.isActive,
name: schedule.name,
minute: schedule.cron.minute,
hour: schedule.cron.hour,
day_of_month: schedule.cron.dayOfMonth,
month: schedule.cron.month,
day_of_week: schedule.cron.dayOfWeek,
})
.then(({ data }) => resolve(rawDataToServerSchedule(data.attributes)))
.catch(reject);
export default async (uuid: string, schedule: Data): Promise<Schedule> => {
const { data } = await http.post(`/api/client/servers/${uuid}/schedules${schedule.id ? `/${schedule.id}` : ''}`, {
is_active: schedule.isActive,
only_when_online: schedule.onlyWhenOnline,
name: schedule.name,
minute: schedule.cron.minute,
hour: schedule.cron.hour,
day_of_month: schedule.cron.dayOfMonth,
month: schedule.cron.month,
day_of_week: schedule.cron.dayOfWeek,
});
return rawDataToServerSchedule(data.attributes);
};

View File

@ -5,15 +5,16 @@ interface Data {
action: string;
payload: string;
timeOffset: string | number;
continueOnFailure: boolean;
}
export default (uuid: string, schedule: number, task: number | undefined, { timeOffset, ...data }: Data): Promise<Task> => {
return new Promise((resolve, reject) => {
http.post(`/api/client/servers/${uuid}/schedules/${schedule}/tasks${task ? `/${task}` : ''}`, {
...data,
time_offset: timeOffset,
})
.then(({ data }) => resolve(rawDataToServerTask(data.attributes)))
.catch(reject);
export default async (uuid: string, schedule: number, task: number | undefined, data: Data): Promise<Task> => {
const { data: response } = await http.post(`/api/client/servers/${uuid}/schedules/${schedule}/tasks${task ? `/${task}` : ''}`, {
action: data.action,
payload: data.payload,
continue_on_failure: data.continueOnFailure,
time_offset: data.timeOffset,
});
return rawDataToServerTask(response.attributes);
};

View File

@ -12,6 +12,7 @@ export interface Schedule {
};
isActive: boolean;
isProcessing: boolean;
onlyWhenOnline: boolean;
lastRunAt: Date | null;
nextRunAt: Date | null;
createdAt: Date;
@ -27,6 +28,7 @@ export interface Task {
payload: string;
timeOffset: number;
isQueued: boolean;
continueOnFailure: boolean;
createdAt: Date;
updatedAt: Date;
}
@ -38,6 +40,7 @@ export const rawDataToServerTask = (data: any): Task => ({
payload: data.payload,
timeOffset: data.time_offset,
isQueued: data.is_queued,
continueOnFailure: data.continue_on_failure,
createdAt: new Date(data.created_at),
updatedAt: new Date(data.updated_at),
});
@ -54,6 +57,7 @@ export const rawDataToServerSchedule = (data: any): Schedule => ({
},
isActive: data.is_active,
isProcessing: data.is_processing,
onlyWhenOnline: data.only_when_online,
lastRunAt: data.last_run_at ? new Date(data.last_run_at) : null,
nextRunAt: data.next_run_at ? new Date(data.next_run_at) : null,
createdAt: new Date(data.created_at),
@ -62,14 +66,12 @@ export const rawDataToServerSchedule = (data: any): Schedule => ({
tasks: (data.relationships?.tasks?.data || []).map((row: any) => rawDataToServerTask(row.attributes)),
});
export default (uuid: string): Promise<Schedule[]> => {
return new Promise((resolve, reject) => {
http.get(`/api/client/servers/${uuid}/schedules`, {
params: {
include: [ 'tasks' ],
},
})
.then(({ data }) => resolve((data.data || []).map((row: any) => rawDataToServerSchedule(row.attributes))))
.catch(reject);
export default async (uuid: string): Promise<Schedule[]> => {
const { data } = await http.get(`/api/client/servers/${uuid}/schedules`, {
params: {
include: [ 'tasks' ],
},
});
return (data.data || []).map((row: any) => rawDataToServerSchedule(row.attributes));
};

View File

@ -3,6 +3,7 @@ export type ServerStatus = 'installing' | 'install_failed' | 'suspended' | 'rest
export interface ServerBackup {
uuid: string;
isSuccessful: boolean;
isLocked: boolean;
name: string;
ignoredFiles: string;
checksum: string;

View File

@ -58,6 +58,7 @@ export const rawDataToFileObject = (data: FractalResponseData): FileObject => ({
export const rawDataToServerBackup = ({ attributes }: FractalResponseData): ServerBackup => ({
uuid: attributes.uuid,
isSuccessful: attributes.is_successful,
isLocked: attributes.is_locked,
name: attributes.name,
ignoredFiles: attributes.ignored_files,
checksum: attributes.checksum,

View File

@ -47,7 +47,7 @@ export default forwardRef<HTMLFormElement, Props>(({ title, ...props }, ref) =>
</div>
</Form>
<p css={tw`text-center text-neutral-500 text-xs mt-4`}>
&copy; 2015 - 2020&nbsp;
&copy; 2015 - {(new Date()).getFullYear()}&nbsp;
<a
rel={'noopener nofollow noreferrer'}
href={'https://pterodactyl.io'}

View File

@ -3,6 +3,7 @@ import tw from 'twin.macro';
import Button from '@/components/elements/Button';
import asModal from '@/hoc/asModal';
import ModalContext from '@/context/ModalContext';
import CopyOnClick from '@/components/elements/CopyOnClick';
interface Props {
apiKey: string;
@ -19,7 +20,7 @@ const ApiKeyModal = ({ apiKey }: Props) => {
shown again.
</p>
<pre css={tw`text-sm bg-neutral-900 rounded py-2 px-4 font-mono`}>
<code css={tw`font-mono`}>{apiKey}</code>
<CopyOnClick text={apiKey}><code css={tw`font-mono`}>{apiKey}</code></CopyOnClick>
</pre>
<div css={tw`flex justify-end mt-6`}>
<Button type={'button'} onClick={() => dismiss()}>

View File

@ -25,8 +25,8 @@ export default () => {
const [ showOnlyAdmin, setShowOnlyAdmin ] = usePersistedState(`${uuid}:show_all_servers`, false);
const { data: servers, error } = useSWR<PaginatedResult<Server>>(
[ '/api/client/servers', showOnlyAdmin, page ],
() => getServers({ page, type: showOnlyAdmin ? 'admin' : undefined }),
[ '/api/client/servers', (showOnlyAdmin && rootAdmin), page ],
() => getServers({ page, type: (showOnlyAdmin && rootAdmin) ? 'admin' : undefined }),
);
useEffect(() => {

View File

@ -7,53 +7,29 @@ import tw from 'twin.macro';
import Button from '@/components/elements/Button';
export default () => {
const user = useStoreState((state: ApplicationStore) => state.user.data!);
const [ visible, setVisible ] = useState(false);
const isEnabled = useStoreState((state: ApplicationStore) => state.user.data!.useTotp);
return user.useTotp ?
return (
<div>
{visible &&
<DisableTwoFactorModal
appear
visible={visible}
onDismissed={() => setVisible(false)}
/>
}
{visible && (
isEnabled ?
<DisableTwoFactorModal visible={visible} onModalDismissed={() => setVisible(false)}/>
:
<SetupTwoFactorModal visible={visible} onModalDismissed={() => setVisible(false)}/>
)}
<p css={tw`text-sm`}>
Two-factor authentication is currently enabled on your account.
{isEnabled ?
'Two-factor authentication is currently enabled on your account.'
:
'You do not currently have two-factor authentication enabled on your account. Click the button below to begin configuring it.'
}
</p>
<div css={tw`mt-6`}>
<Button
color={'red'}
isSecondary
onClick={() => setVisible(true)}
>
Disable
<Button color={'red'} isSecondary onClick={() => setVisible(true)}>
{isEnabled ? 'Disable' : 'Enable'}
</Button>
</div>
</div>
:
<div>
{visible &&
<SetupTwoFactorModal
appear
visible={visible}
onDismissed={() => setVisible(false)}
/>
}
<p css={tw`text-sm`}>
You do not currently have two-factor authentication enabled on your account. Click
the button below to begin configuring it.
</p>
<div css={tw`mt-6`}>
<Button
color={'green'}
isSecondary
onClick={() => setVisible(true)}
>
Begin Setup
</Button>
</div>
</div>
;
);
};

View File

@ -1,6 +1,5 @@
import React from 'react';
import React, { useContext } from 'react';
import { Form, Formik, FormikHelpers } from 'formik';
import Modal, { RequiredModalProps } from '@/components/elements/Modal';
import FlashMessageRender from '@/components/FlashMessageRender';
import Field from '@/components/elements/Field';
import { object, string } from 'yup';
@ -9,26 +8,31 @@ import { ApplicationStore } from '@/state';
import disableAccountTwoFactor from '@/api/account/disableAccountTwoFactor';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
import asModal from '@/hoc/asModal';
import ModalContext from '@/context/ModalContext';
interface Values {
password: string;
}
export default ({ ...props }: RequiredModalProps) => {
const DisableTwoFactorModal = () => {
const { dismiss, setPropOverrides } = useContext(ModalContext);
const { clearAndAddHttpError } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
const updateUserData = useStoreActions((actions: Actions<ApplicationStore>) => actions.user.updateUserData);
const submit = ({ password }: Values, { setSubmitting }: FormikHelpers<Values>) => {
setPropOverrides({ showSpinnerOverlay: true, dismissable: false });
disableAccountTwoFactor(password)
.then(() => {
updateUserData({ useTotp: false });
props.onDismissed();
dismiss();
})
.catch(error => {
console.error(error);
clearAndAddHttpError({ error, key: 'account:two-factor' });
setSubmitting(false);
setPropOverrides(null);
});
};
@ -42,29 +46,26 @@ export default ({ ...props }: RequiredModalProps) => {
password: string().required('You must provider your current password in order to continue.'),
})}
>
{({ isSubmitting, isValid }) => (
<Modal {...props} dismissable={!isSubmitting} showSpinnerOverlay={isSubmitting}>
<Form className={'mb-0'}>
<FlashMessageRender css={tw`mb-6`} byKey={'account:two-factor'}/>
<Field
id={'password'}
name={'password'}
type={'password'}
label={'Current Password'}
description={'In order to disable two-factor authentication you will need to provide your account password.'}
autoFocus
/>
<div css={tw`mt-6 text-right`}>
<Button
color={'red'}
disabled={!isValid}
>
Disable Two-Factor
</Button>
</div>
</Form>
</Modal>
{({ isValid }) => (
<Form className={'mb-0'}>
<FlashMessageRender css={tw`mb-6`} byKey={'account:two-factor'}/>
<Field
id={'password'}
name={'password'}
type={'password'}
label={'Current Password'}
description={'In order to disable two-factor authentication you will need to provide your account password.'}
autoFocus
/>
<div css={tw`mt-6 text-right`}>
<Button color={'red'} disabled={!isValid}>
Disable Two-Factor
</Button>
</div>
</Form>
)}
</Formik>
);
};
export default asModal()(DisableTwoFactorModal);

View File

@ -1,5 +1,4 @@
import React, { useEffect, useState } from 'react';
import Modal, { RequiredModalProps } from '@/components/elements/Modal';
import React, { useContext, useEffect, useState } from 'react';
import { Form, Formik, FormikHelpers } from 'formik';
import { object, string } from 'yup';
import getTwoFactorTokenUrl from '@/api/account/getTwoFactorTokenUrl';
@ -10,16 +9,19 @@ import FlashMessageRender from '@/components/FlashMessageRender';
import Field from '@/components/elements/Field';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
import asModal from '@/hoc/asModal';
import ModalContext from '@/context/ModalContext';
import QRCode from 'qrcode.react';
interface Values {
code: string;
}
export default ({ onDismissed, ...props }: RequiredModalProps) => {
const SetupTwoFactorModal = () => {
const [ token, setToken ] = useState('');
const [ loading, setLoading ] = useState(true);
const [ recoveryTokens, setRecoveryTokens ] = useState<string[]>([]);
const { dismiss, setPropOverrides } = useContext(ModalContext);
const updateUserData = useStoreActions((actions: Actions<ApplicationStore>) => actions.user.updateUserData);
const { clearAndAddHttpError } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
@ -33,6 +35,7 @@ export default ({ onDismissed, ...props }: RequiredModalProps) => {
}, []);
const submit = ({ code }: Values, { setSubmitting }: FormikHelpers<Values>) => {
setPropOverrides(state => ({ ...state, showSpinnerOverlay: true }));
enableAccountTwoFactor(code)
.then(tokens => {
setRecoveryTokens(tokens);
@ -42,16 +45,25 @@ export default ({ onDismissed, ...props }: RequiredModalProps) => {
clearAndAddHttpError({ error, key: 'account:two-factor' });
})
.then(() => setSubmitting(false));
.then(() => {
setSubmitting(false);
setPropOverrides(state => ({ ...state, showSpinnerOverlay: false }));
});
};
const dismiss = () => {
if (recoveryTokens.length > 0) {
updateUserData({ useTotp: true });
}
useEffect(() => {
setPropOverrides(state => ({
...state,
closeOnEscape: !recoveryTokens.length,
closeOnBackground: !recoveryTokens.length,
}));
onDismissed();
};
return () => {
if (recoveryTokens.length > 0) {
updateUserData({ useTotp: true });
}
};
}, [ recoveryTokens ]);
return (
<Formik
@ -63,79 +75,64 @@ export default ({ onDismissed, ...props }: RequiredModalProps) => {
.matches(/^(\d){6}$/, 'Authenticator code must be 6 digits.'),
})}
>
{({ isSubmitting }) => (
<Modal
{...props}
top={false}
onDismissed={dismiss}
dismissable={!isSubmitting}
showSpinnerOverlay={loading || isSubmitting}
closeOnEscape={!recoveryTokens}
closeOnBackground={!recoveryTokens}
>
{recoveryTokens.length > 0 ?
<>
<h2 css={tw`text-2xl mb-4`}>Two-factor authentication enabled</h2>
<p css={tw`text-neutral-300`}>
Two-factor authentication has been enabled on your account. Should you loose access to
this device you&apos;ll need to use one of the codes displayed below in order to access your
account.
</p>
<p css={tw`text-neutral-300 mt-4`}>
<strong>These codes will not be displayed again.</strong> Please take note of them now
by storing them in a secure repository such as a password manager.
</p>
<pre css={tw`text-sm mt-4 rounded font-mono bg-neutral-900 p-4`}>
{recoveryTokens.map(token => <code key={token} css={tw`block mb-1`}>{token}</code>)}
</pre>
<div css={tw`text-right`}>
<Button css={tw`mt-6`} onClick={dismiss}>
Close
{recoveryTokens.length > 0 ?
<>
<h2 css={tw`text-2xl mb-4`}>Two-factor authentication enabled</h2>
<p css={tw`text-neutral-300`}>
Two-factor authentication has been enabled on your account. Should you loose access to
this device you&apos;ll need to use one of the codes displayed below in order to access your
account.
</p>
<p css={tw`text-neutral-300 mt-4`}>
<strong>These codes will not be displayed again.</strong> Please take note of them now
by storing them in a secure repository such as a password manager.
</p>
<pre css={tw`text-sm mt-4 rounded font-mono bg-neutral-900 p-4`}>
{recoveryTokens.map(token => <code key={token} css={tw`block mb-1`}>{token}</code>)}
</pre>
<div css={tw`text-right`}>
<Button css={tw`mt-6`} onClick={dismiss}>
Close
</Button>
</div>
</>
:
<Form css={tw`mb-0`}>
<FlashMessageRender css={tw`mb-6`} byKey={'account:two-factor'}/>
<div css={tw`flex flex-wrap`}>
<div css={tw`w-full md:flex-1`}>
<div css={tw`w-32 h-32 md:w-64 md:h-64 bg-neutral-600 p-2 rounded mx-auto`}>
{!token || !token.length ?
<img
src={'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='}
css={tw`w-64 h-64 rounded`}
/>
:
<QRCode renderAs={'svg'} value={token} css={tw`w-full h-full shadow-none rounded-none`}/>
}
</div>
</div>
<div css={tw`w-full mt-6 md:mt-0 md:flex-1 md:flex md:flex-col`}>
<div css={tw`flex-1`}>
<Field
id={'code'}
name={'code'}
type={'text'}
title={'Code From Authenticator'}
description={'Enter the code from your authenticator device after scanning the QR image.'}
/>
</div>
<div css={tw`mt-6 md:mt-0 text-right`}>
<Button>
Setup
</Button>
</div>
</>
:
<Form css={tw`mb-0`}>
<FlashMessageRender css={tw`mb-6`} byKey={'account:two-factor'}/>
<div css={tw`flex flex-wrap`}>
<div css={tw`w-full md:flex-1`}>
<div css={tw`w-32 h-32 md:w-64 md:h-64 bg-neutral-600 p-2 rounded mx-auto`}>
{!token || !token.length ?
<img
src={'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='}
css={tw`w-64 h-64 rounded`}
/>
:
<img
src={`https://api.qrserver.com/v1/create-qr-code/?size=500x500&data=${token}`}
onLoad={() => setLoading(false)}
css={tw`w-full h-full shadow-none rounded-none`}
/>
}
</div>
</div>
<div css={tw`w-full mt-6 md:mt-0 md:flex-1 md:flex md:flex-col`}>
<div css={tw`flex-1`}>
<Field
id={'code'}
name={'code'}
type={'text'}
title={'Code From Authenticator'}
description={'Enter the code from your authenticator device after scanning the QR image.'}
autoFocus={!loading}
/>
</div>
<div css={tw`mt-6 md:mt-0 text-right`}>
<Button>
Setup
</Button>
</div>
</div>
</div>
</Form>
}
</Modal>
)}
</div>
</div>
</Form>
}
</Formik>
);
};
export default asModal()(SetupTwoFactorModal);

View File

@ -17,7 +17,7 @@ const Field = forwardRef<HTMLInputElement, Props>(({ id, name, light = false, la
<FormikField innerRef={ref} name={name} validate={validate}>
{
({ field, form: { errors, touched } }: FieldProps) => (
<>
<div>
{label &&
<Label htmlFor={id} isLight={light}>{label}</Label>
}
@ -35,7 +35,7 @@ const Field = forwardRef<HTMLInputElement, Props>(({ id, name, light = false, la
:
description ? <p className={'input-help'}>{description}</p> : null
}
</>
</div>
)
}
</FormikField>

View File

@ -28,7 +28,7 @@ const PageContentBlock: React.FC<PageContentBlockProps> = ({ title, showFlashKey
</ContentContainer>
<ContentContainer css={tw`mb-4`}>
<p css={tw`text-center text-neutral-500 text-xs`}>
&copy; 2015 - 2020&nbsp;
&copy; 2015 - {(new Date()).getFullYear()}&nbsp;
<a
rel={'noopener nofollow noreferrer'}
href={'https://pterodactyl.io'}

View File

@ -1,4 +1,4 @@
import React from 'react';
import React, { Suspense } from 'react';
import styled, { css, keyframes } from 'styled-components/macro';
import tw from 'twin.macro';
@ -10,6 +10,11 @@ interface Props {
isBlue?: boolean;
}
interface Spinner extends React.FC<Props> {
Size: Record<'SMALL' | 'BASE' | 'LARGE', SpinnerSize>;
Suspense: React.FC<Props>;
}
const spin = keyframes`
to { transform: rotate(360deg); }
`;
@ -30,7 +35,7 @@ const SpinnerComponent = styled.div<Props>`
border-top-color: ${props => !props.isBlue ? 'rgb(255, 255, 255)' : 'hsl(212, 92%, 43%)'};
`;
const Spinner = ({ centered, ...props }: Props) => (
const Spinner: Spinner = ({ centered, ...props }) => (
centered ?
<div
css={[
@ -43,12 +48,19 @@ const Spinner = ({ centered, ...props }: Props) => (
:
<SpinnerComponent {...props}/>
);
Spinner.DisplayName = 'Spinner';
Spinner.displayName = 'Spinner';
Spinner.Size = {
SMALL: 'small' as SpinnerSize,
BASE: 'base' as SpinnerSize,
LARGE: 'large' as SpinnerSize,
SMALL: 'small',
BASE: 'base',
LARGE: 'large',
};
Spinner.Suspense = ({ children, centered = true, size = Spinner.Size.LARGE, ...props }) => (
<Suspense fallback={<Spinner centered={centered} size={size} {...props}/>}>
{children}
</Suspense>
);
Spinner.Suspense.displayName = 'Spinner.Suspense';
export default Spinner;

View File

@ -1,10 +0,0 @@
import React, { Suspense } from 'react';
import Spinner from '@/components/elements/Spinner';
const SuspenseSpinner: React.FC = ({ children }) => (
<Suspense fallback={<Spinner size={'large'} centered/>}>
{children}
</Suspense>
);
export default SuspenseSpinner;

View File

@ -1,6 +1,5 @@
import React, { lazy, memo } from 'react';
import { ServerContext } from '@/state/server';
import SuspenseSpinner from '@/components/elements/SuspenseSpinner';
import Can from '@/components/elements/Can';
import ContentContainer from '@/components/elements/ContentContainer';
import tw from 'twin.macro';
@ -10,6 +9,7 @@ import isEqual from 'react-fast-compare';
import PowerControls from '@/components/server/PowerControls';
import { EulaModalFeature } from '@feature/index';
import ErrorBoundary from '@/components/elements/ErrorBoundary';
import Spinner from '@/components/elements/Spinner';
export type PowerAction = 'start' | 'stop' | 'restart' | 'kill';
@ -51,12 +51,12 @@ const ServerConsole = () => {
}
</div>
<div css={tw`w-full lg:w-3/4 mt-4 lg:mt-0 lg:pl-4`}>
<SuspenseSpinner>
<Spinner.Suspense>
<ErrorBoundary>
<ChunkedConsole/>
</ErrorBoundary>
<ChunkedStatGraphs/>
</SuspenseSpinner>
</Spinner.Suspense>
{eggFeatures.includes('eula') &&
<React.Suspense fallback={null}>
<EulaModalFeature/>

View File

@ -60,7 +60,7 @@ const BackupContainer = () => {
</Pagination>
{backupLimit === 0 &&
<p css={tw`text-center text-sm text-neutral-300`}>
Backups cannot be created for this server.
Backups cannot be created for this server because the backup limit is set to 0.
</p>
}
<Can action={'backup.create'}>

View File

@ -1,10 +1,16 @@
import React, { useState } from 'react';
import { faBoxOpen, faCloudDownloadAlt, faEllipsisH, faLock, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
import {
faBoxOpen,
faCloudDownloadAlt,
faEllipsisH,
faLock,
faTrashAlt,
faUnlock,
} from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import DropdownMenu, { DropdownButtonRow } from '@/components/elements/DropdownMenu';
import getBackupDownloadUrl from '@/api/server/backups/getBackupDownloadUrl';
import useFlash from '@/plugins/useFlash';
import ChecksumModal from '@/components/server/backups/ChecksumModal';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import deleteBackup from '@/api/server/backups/deleteBackup';
import ConfirmationModal from '@/components/elements/ConfirmationModal';
@ -15,6 +21,7 @@ import { ServerBackup } from '@/api/server/types';
import { ServerContext } from '@/state/server';
import Input from '@/components/elements/Input';
import { restoreServerBackup } from '@/api/server/backups';
import http, { httpErrorToHuman } from '@/api/http';
interface Props {
backup: ServerBackup;
@ -76,14 +83,35 @@ export default ({ backup }: Props) => {
.then(() => setModal(''));
};
const onLockToggle = () => {
if (backup.isLocked && modal !== 'unlock') {
return setModal('unlock');
}
http.post(`/api/client/servers/${uuid}/backups/${backup.uuid}/lock`)
.then(() => mutate(data => ({
...data,
items: data.items.map(b => b.uuid !== backup.uuid ? b : {
...b,
isLocked: !b.isLocked,
}),
}), false))
.catch(error => alert(httpErrorToHuman(error)))
.then(() => setModal(''));
};
return (
<>
<ChecksumModal
appear
visible={modal === 'checksum'}
onDismissed={() => setModal('')}
checksum={backup.checksum}
/>
<ConfirmationModal
visible={modal === 'unlock'}
title={'Unlock this backup?'}
onConfirmed={onLockToggle}
onModalDismissed={() => setModal('')}
buttonText={'Yes, unlock'}
>
Are you sure you want to unlock this backup? It will no longer be protected from automated or
accidental deletions.
</ConfirmationModal>
<ConfirmationModal
visible={modal === 'restore'}
title={'Restore this backup?'}
@ -151,15 +179,23 @@ export default ({ backup }: Props) => {
<span css={tw`ml-2`}>Restore</span>
</DropdownButtonRow>
</Can>
<DropdownButtonRow onClick={() => setModal('checksum')}>
<FontAwesomeIcon fixedWidth icon={faLock} css={tw`text-xs`}/>
<span css={tw`ml-2`}>Checksum</span>
</DropdownButtonRow>
<Can action={'backup.delete'}>
<DropdownButtonRow danger onClick={() => setModal('delete')}>
<FontAwesomeIcon fixedWidth icon={faTrashAlt} css={tw`text-xs`}/>
<span css={tw`ml-2`}>Delete</span>
</DropdownButtonRow>
<>
<DropdownButtonRow onClick={onLockToggle}>
<FontAwesomeIcon
fixedWidth
icon={backup.isLocked ? faUnlock : faLock}
css={tw`text-xs mr-2`}
/>
{backup.isLocked ? 'Unlock' : 'Lock'}
</DropdownButtonRow>
{!backup.isLocked &&
<DropdownButtonRow danger onClick={() => setModal('delete')}>
<FontAwesomeIcon fixedWidth icon={faTrashAlt} css={tw`text-xs`}/>
<span css={tw`ml-2`}>Delete</span>
</DropdownButtonRow>
}
</>
</Can>
</div>
</DropdownMenu>

View File

@ -1,6 +1,6 @@
import React from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faArchive, faEllipsisH } from '@fortawesome/free-solid-svg-icons';
import { faArchive, faEllipsisH, faLock } from '@fortawesome/free-solid-svg-icons';
import { format, formatDistanceToNow } from 'date-fns';
import Spinner from '@/components/elements/Spinner';
import { bytesToHuman } from '@/helpers';
@ -45,7 +45,10 @@ export default ({ backup, className }: Props) => {
<div css={tw`flex items-center truncate w-full md:flex-1`}>
<div css={tw`mr-4`}>
{backup.completedAt ?
<FontAwesomeIcon icon={faArchive} css={tw`text-neutral-300`}/>
backup.isLocked ?
<FontAwesomeIcon icon={faLock} css={tw`text-yellow-500`}/>
:
<FontAwesomeIcon icon={faArchive} css={tw`text-neutral-300`}/>
:
<Spinner size={'small'}/>
}
@ -65,7 +68,7 @@ export default ({ backup, className }: Props) => {
}
</div>
<p css={tw`mt-1 md:mt-0 text-xs text-neutral-400 font-mono truncate`}>
{backup.uuid}
{backup.checksum}
</p>
</div>
</div>

View File

@ -1,17 +0,0 @@
import React from 'react';
import Modal, { RequiredModalProps } from '@/components/elements/Modal';
import tw from 'twin.macro';
const ChecksumModal = ({ checksum, ...props }: RequiredModalProps & { checksum: string }) => (
<Modal {...props}>
<h3 css={tw`mb-6 text-2xl`}>Verify file checksum</h3>
<p css={tw`text-sm`}>
The checksum of this file is:
</p>
<pre css={tw`mt-2 text-sm p-2 bg-neutral-900 rounded`}>
<code css={tw`block font-mono overflow-auto`}>{checksum}</code>
</pre>
</Modal>
);
export default ChecksumModal;

View File

@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react';
import Modal, { RequiredModalProps } from '@/components/elements/Modal';
import { Field as FormikField, Form, Formik, FormikHelpers, useFormikContext } from 'formik';
import { object, string } from 'yup';
import { boolean, object, string } from 'yup';
import Field from '@/components/elements/Field';
import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper';
import useFlash from '@/plugins/useFlash';
@ -12,10 +12,13 @@ import tw from 'twin.macro';
import { Textarea } from '@/components/elements/Input';
import getServerBackups from '@/api/swr/getServerBackups';
import { ServerContext } from '@/state/server';
import FormikSwitch from '@/components/elements/FormikSwitch';
import Can from '@/components/elements/Can';
interface Values {
name: string;
ignored: string;
isLocked: boolean;
}
const ModalContent = ({ ...props }: RequiredModalProps) => {
@ -26,14 +29,12 @@ const ModalContent = ({ ...props }: RequiredModalProps) => {
<Form>
<FlashMessageRender byKey={'backups:create'} css={tw`mb-4`}/>
<h2 css={tw`text-2xl mb-6`}>Create server backup</h2>
<div css={tw`mb-6`}>
<Field
name={'name'}
label={'Backup name'}
description={'If provided, the name that should be used to reference this backup.'}
/>
</div>
<div css={tw`mb-6`}>
<Field
name={'name'}
label={'Backup name'}
description={'If provided, the name that should be used to reference this backup.'}
/>
<div css={tw`mt-6`}>
<FormikFieldWrapper
name={'ignored'}
label={'Ignored Files & Directories'}
@ -47,7 +48,16 @@ const ModalContent = ({ ...props }: RequiredModalProps) => {
<FormikField as={Textarea} name={'ignored'} rows={6}/>
</FormikFieldWrapper>
</div>
<div css={tw`flex justify-end`}>
<Can action={'backup.delete'}>
<div css={tw`mt-6 bg-neutral-700 border border-neutral-800 shadow-inner p-4 rounded`}>
<FormikSwitch
name={'isLocked'}
label={'Locked'}
description={'Prevents this backup from being deleted until explicitly unlocked.'}
/>
</div>
</Can>
<div css={tw`flex justify-end mt-6`}>
<Button type={'submit'} disabled={isSubmitting}>
Start backup
</Button>
@ -67,9 +77,9 @@ export default () => {
clearFlashes('backups:create');
}, [ visible ]);
const submit = ({ name, ignored }: Values, { setSubmitting }: FormikHelpers<Values>) => {
const submit = (values: Values, { setSubmitting }: FormikHelpers<Values>) => {
clearFlashes('backups:create');
createServerBackup(uuid, name, ignored)
createServerBackup(uuid, values)
.then(backup => {
mutate(data => ({ ...data, items: data.items.concat(backup) }), false);
setVisible(false);
@ -85,10 +95,11 @@ export default () => {
{visible &&
<Formik
onSubmit={submit}
initialValues={{ name: '', ignored: '' }}
initialValues={{ name: '', ignored: '', isLocked: false }}
validationSchema={object().shape({
name: string().max(191),
ignored: string(),
isLocked: boolean(),
})}
>
<ModalContent appear visible={visible} onDismissed={() => setVisible(false)}/>

View File

@ -53,7 +53,7 @@ export default () => {
/>
))
:
<p css={tw`text-center text-sm text-neutral-400`}>
<p css={tw`text-center text-sm text-neutral-300`}>
{databaseLimit > 0 ?
'It looks like you have no databases.'
:

View File

@ -24,7 +24,7 @@ const LazyCodemirrorEditor = lazy(() => import(/* webpackChunkName: "editor" */'
export default () => {
const [ error, setError ] = useState('');
const { action } = useParams<{ action: string }>();
const { action } = useParams<{ action: 'new' | string }>();
const [ loading, setLoading ] = useState(action === 'edit');
const [ content, setContent ] = useState('');
const [ modalVisible, setModalVisible ] = useState(false);

View File

@ -63,7 +63,7 @@ const MassActionsBar = () => {
return (
<Fade timeout={75} in={selectedFiles.length > 0} unmountOnExit>
<div css={tw`fixed bottom-0 z-50 left-0 right-0 flex justify-center`}>
<div css={tw`pointer-events-none fixed bottom-0 z-50 left-0 right-0 flex justify-center`}>
<SpinnerOverlay visible={loading} size={'large'} fixed>
{loadingMessage}
</SpinnerOverlay>
@ -74,7 +74,18 @@ const MassActionsBar = () => {
onConfirmed={onClickConfirmDeletion}
onModalDismissed={() => setShowConfirm(false)}
>
Deleting files is a permanent operation, you cannot undo this action.
Are you sure you want to delete {selectedFiles.length} file(s)?
<br/>
Deleting the file(s) listed below is a permanent operation, you cannot undo this action.
<br/>
<code>
{ selectedFiles.slice(0, 15).map(file => (
<li key={file}>{file}<br/></li>))
}
{ selectedFiles.length > 15 &&
<li> + {selectedFiles.length - 15} other(s) </li>
}
</code>
</ConfirmationModal>
{showMove &&
<RenameFileModal
@ -85,7 +96,7 @@ const MassActionsBar = () => {
onDismissed={() => setShowMove(false)}
/>
}
<div css={tw`rounded p-4 mb-6`} style={{ background: 'rgba(0, 0, 0, 0.35)' }}>
<div css={tw`pointer-events-auto rounded p-4 mb-6`} style={{ background: 'rgba(0, 0, 0, 0.35)' }}>
<Button size={'xsmall'} css={tw`mr-4`} onClick={() => setShowMove(true)}>
<FontAwesomeIcon icon={faLevelUpAlt} css={tw`mr-2`}/> Move
</Button>

View File

@ -11,6 +11,7 @@ import Can from '@/components/elements/Can';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import getServerAllocations from '@/api/swr/getServerAllocations';
import isEqual from 'react-fast-compare';
import { useDeepCompareEffect } from '@/plugins/useDeepCompareEffect';
const NetworkContainer = () => {
const [ loading, setLoading ] = useState(false);
@ -23,7 +24,7 @@ const NetworkContainer = () => {
const { data, error, mutate } = getServerAllocations();
useEffect(() => {
mutate(allocations, false);
mutate(allocations);
}, []);
useEffect(() => {
@ -32,6 +33,12 @@ const NetworkContainer = () => {
}
}, [ error ]);
useDeepCompareEffect(() => {
if (!data) return;
setServerFromState(state => ({ ...state, allocations: data }));
}, [ data ]);
const onCreateAllocation = () => {
clearFlashes('server:network');

View File

@ -1,8 +1,7 @@
import React, { useEffect, useState } from 'react';
import React, { useContext, useEffect } from 'react';
import { Schedule } from '@/api/server/schedules/getServerSchedules';
import Modal, { RequiredModalProps } from '@/components/elements/Modal';
import Field from '@/components/elements/Field';
import { Form, Formik, FormikHelpers, useFormikContext } from 'formik';
import { Form, Formik, FormikHelpers } from 'formik';
import FormikSwitch from '@/components/elements/FormikSwitch';
import createOrUpdateSchedule from '@/api/server/schedules/createOrUpdateSchedule';
import { ServerContext } from '@/state/server';
@ -11,10 +10,12 @@ import FlashMessageRender from '@/components/FlashMessageRender';
import useFlash from '@/plugins/useFlash';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
import ModalContext from '@/context/ModalContext';
import asModal from '@/hoc/asModal';
type Props = {
interface Props {
schedule?: Schedule;
} & RequiredModalProps;
}
interface Values {
name: string;
@ -24,70 +25,21 @@ interface Values {
hour: string;
minute: string;
enabled: boolean;
onlyWhenOnline: boolean;
}
const EditScheduleModal = ({ schedule, ...props }: Omit<Props, 'onScheduleUpdated'>) => {
const { isSubmitting } = useFormikContext();
return (
<Modal {...props} showSpinnerOverlay={isSubmitting}>
<h3 css={tw`text-2xl mb-6`}>{schedule ? 'Edit schedule' : 'Create new schedule'}</h3>
<FlashMessageRender byKey={'schedule:edit'} css={tw`mb-6`}/>
<Form>
<Field
name={'name'}
label={'Schedule name'}
description={'A human readable identifer for this schedule.'}
/>
<div css={tw`grid grid-cols-2 sm:grid-cols-5 gap-4 mt-6`}>
<div>
<Field name={'minute'} label={'Minute'}/>
</div>
<div>
<Field name={'hour'} label={'Hour'}/>
</div>
<div>
<Field name={'dayOfMonth'} label={'Day of month'}/>
</div>
<div>
<Field name={'month'} label={'Month'}/>
</div>
<div>
<Field name={'dayOfWeek'} label={'Day of week'}/>
</div>
</div>
<p css={tw`text-neutral-400 text-xs mt-2`}>
The schedule system supports the use of Cronjob syntax when defining when tasks should begin
running. Use the fields above to specify when these tasks should begin running.
</p>
<div css={tw`mt-6 bg-neutral-700 border border-neutral-800 shadow-inner p-4 rounded`}>
<FormikSwitch
name={'enabled'}
description={'If disabled, this schedule and it\'s associated tasks will not run.'}
label={'Enabled'}
/>
</div>
<div css={tw`mt-6 text-right`}>
<Button css={tw`w-full sm:w-auto`} type={'submit'} disabled={isSubmitting}>
{schedule ? 'Save changes' : 'Create schedule'}
</Button>
</div>
</Form>
</Modal>
);
};
export default ({ schedule, visible, ...props }: Props) => {
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
const EditScheduleModal = ({ schedule }: Props) => {
const { addError, clearFlashes } = useFlash();
const [ modalVisible, setModalVisible ] = useState(visible);
const { dismiss } = useContext(ModalContext);
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
const appendSchedule = ServerContext.useStoreActions(actions => actions.schedules.appendSchedule);
useEffect(() => {
setModalVisible(visible);
clearFlashes('schedule:edit');
}, [ visible ]);
return () => {
clearFlashes('schedule:edit');
};
}, []);
const submit = (values: Values, { setSubmitting }: FormikHelpers<Values>) => {
clearFlashes('schedule:edit');
@ -101,12 +53,13 @@ export default ({ schedule, visible, ...props }: Props) => {
month: values.month,
dayOfMonth: values.dayOfMonth,
},
onlyWhenOnline: values.onlyWhenOnline,
isActive: values.enabled,
})
.then(schedule => {
setSubmitting(false);
appendSchedule(schedule);
setModalVisible(false);
dismiss();
})
.catch(error => {
console.error(error);
@ -126,15 +79,53 @@ export default ({ schedule, visible, ...props }: Props) => {
dayOfMonth: schedule?.cron.dayOfMonth || '*',
month: schedule?.cron.month || '*',
dayOfWeek: schedule?.cron.dayOfWeek || '*',
enabled: schedule ? schedule.isActive : true,
enabled: schedule?.isActive ?? true,
onlyWhenOnline: schedule?.onlyWhenOnline ?? true,
} as Values}
validationSchema={null}
>
<EditScheduleModal
visible={modalVisible}
schedule={schedule}
{...props}
/>
{({ isSubmitting }) => (
<Form>
<h3 css={tw`text-2xl mb-6`}>{schedule ? 'Edit schedule' : 'Create new schedule'}</h3>
<FlashMessageRender byKey={'schedule:edit'} css={tw`mb-6`}/>
<Field
name={'name'}
label={'Schedule name'}
description={'A human readable identifer for this schedule.'}
/>
<div css={tw`grid grid-cols-2 sm:grid-cols-5 gap-4 mt-6`}>
<Field name={'minute'} label={'Minute'}/>
<Field name={'hour'} label={'Hour'}/>
<Field name={'dayOfMonth'} label={'Day of month'}/>
<Field name={'month'} label={'Month'}/>
<Field name={'dayOfWeek'} label={'Day of week'}/>
</div>
<p css={tw`text-neutral-400 text-xs mt-2`}>
The schedule system supports the use of Cronjob syntax when defining when tasks should begin
running. Use the fields above to specify when these tasks should begin running.
</p>
<div css={tw`mt-6 bg-neutral-700 border border-neutral-800 shadow-inner p-4 rounded`}>
<FormikSwitch
name={'onlyWhenOnline'}
description={'Only execute this schedule when the server is in a running state.'}
label={'Only When Server Is Online'}
/>
</div>
<div css={tw`mt-6 bg-neutral-700 border border-neutral-800 shadow-inner p-4 rounded`}>
<FormikSwitch
name={'enabled'}
description={'This schedule will be executed automatically if enabled.'}
label={'Schedule Enabled'}
/>
</div>
<div css={tw`mt-6 text-right`}>
<Button css={tw`w-full sm:w-auto`} type={'submit'} disabled={isSubmitting}>
{schedule ? 'Save changes' : 'Create schedule'}
</Button>
</div>
</Form>
)}
</Formik>
);
};
export default asModal<Props>()(EditScheduleModal);

View File

@ -13,12 +13,7 @@ export default ({ schedule }: Props) => {
return (
<>
{visible &&
<TaskDetailsModal
schedule={schedule}
onDismissed={() => setVisible(false)}
/>
}
<TaskDetailsModal schedule={schedule} visible={visible} onModalDismissed={() => setVisible(false)}/>
<Button onClick={() => setVisible(true)} css={tw`flex-1`}>
New Task
</Button>

View File

@ -67,7 +67,7 @@ export default () => {
}
<Can action={'schedule.create'}>
<div css={tw`mt-8 flex justify-end`}>
{visible && <EditScheduleModal appear visible onDismissed={() => setVisible(false)}/>}
<EditScheduleModal visible={visible} onModalDismissed={() => setVisible(false)}/>
<Button type={'button'} onClick={() => setVisible(true)}>
Create schedule
</Button>

View File

@ -46,9 +46,9 @@ const ActivePill = ({ active }: { active: boolean }) => (
);
export default () => {
const params = useParams() as Params;
const history = useHistory();
const state = useLocation<State>().state;
const { state } = useLocation<State>();
const { id: scheduleId } = useParams<Params>();
const id = ServerContext.useStoreState(state => state.server.data!.id);
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
@ -61,27 +61,27 @@ export default () => {
const appendSchedule = ServerContext.useStoreActions(actions => actions.schedules.appendSchedule);
useEffect(() => {
if (schedule?.id === Number(params.id)) {
if (schedule?.id === Number(scheduleId)) {
setIsLoading(false);
return;
}
clearFlashes('schedules');
getServerSchedule(uuid, Number(params.id))
getServerSchedule(uuid, Number(scheduleId))
.then(schedule => appendSchedule(schedule))
.catch(error => {
console.error(error);
clearAndAddHttpError({ error, key: 'schedules' });
})
.then(() => setIsLoading(false));
}, [ params ]);
}, [ scheduleId ]);
const toggleEditModal = useCallback(() => {
setShowEditModal(s => !s);
}, []);
return (
<PageContentBlock>
<PageContentBlock title={'Schedules'}>
<FlashMessageRender byKey={'schedules'} css={tw`mb-4`}/>
{!schedule || isLoading ?
<Spinner size={'large'} centered/>
@ -153,7 +153,7 @@ export default () => {
}
</div>
</div>
<EditScheduleModal visible={showEditModal} schedule={schedule} onDismissed={toggleEditModal}/>
<EditScheduleModal visible={showEditModal} schedule={schedule} onModalDismissed={toggleEditModal}/>
<div css={tw`mt-6 flex sm:justify-end`}>
<Can action={'schedule.delete'}>
<DeleteScheduleButton
@ -161,7 +161,7 @@ export default () => {
onDeleted={() => history.push(`/server/${id}/schedules`)}
/>
</Can>
{schedule.isActive && schedule.tasks.length > 0 &&
{schedule.tasks.length > 0 &&
<Can action={'schedule.update'}>
<RunScheduleButton schedule={schedule}/>
</Can>

View File

@ -1,7 +1,15 @@
import React, { useState } from 'react';
import { Schedule, Task } from '@/api/server/schedules/getServerSchedules';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faClock, faCode, faFileArchive, faPencilAlt, faToggleOn, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
import {
faArrowCircleDown,
faClock,
faCode,
faFileArchive,
faPencilAlt,
faToggleOn,
faTrashAlt,
} from '@fortawesome/free-solid-svg-icons';
import deleteScheduleTask from '@/api/server/schedules/deleteScheduleTask';
import { httpErrorToHuman } from '@/api/http';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
@ -59,11 +67,12 @@ export default ({ schedule, task }: Props) => {
return (
<div css={tw`sm:flex items-center p-3 sm:p-6 border-b border-neutral-800`}>
<SpinnerOverlay visible={isLoading} fixed size={'large'}/>
{isEditing && <TaskDetailsModal
<TaskDetailsModal
schedule={schedule}
task={task}
onDismissed={() => setIsEditing(false)}
/>}
visible={isEditing}
onModalDismissed={() => setIsEditing(false)}
/>
<ConfirmationModal
title={'Confirm task deletion'}
buttonText={'Delete Task'}
@ -89,6 +98,14 @@ export default ({ schedule, task }: Props) => {
}
</div>
<div css={tw`mt-3 sm:mt-0 flex items-center w-full sm:w-auto`}>
{task.continueOnFailure &&
<div css={tw`mr-6`}>
<div css={tw`flex items-center px-2 py-1 bg-yellow-500 text-yellow-800 text-sm rounded-full`}>
<Icon icon={faArrowCircleDown} css={tw`w-3 h-3 mr-2`}/>
Continues on Failure
</div>
</div>
}
{task.sequenceId > 1 && task.timeOffset > 0 &&
<div css={tw`mr-6`}>
<div css={tw`flex items-center px-2 py-1 bg-neutral-500 text-sm rounded-full`}>

View File

@ -1,13 +1,12 @@
import React, { useEffect } from 'react';
import Modal from '@/components/elements/Modal';
import React, { useContext, useEffect } from 'react';
import { Schedule, Task } from '@/api/server/schedules/getServerSchedules';
import { Field as FormikField, Form, Formik, FormikHelpers, useFormikContext } from 'formik';
import { Field as FormikField, Form, Formik, FormikHelpers, useField } from 'formik';
import { ServerContext } from '@/state/server';
import createOrUpdateScheduleTask from '@/api/server/schedules/createOrUpdateScheduleTask';
import { httpErrorToHuman } from '@/api/http';
import Field from '@/components/elements/Field';
import FlashMessageRender from '@/components/FlashMessageRender';
import { number, object, string } from 'yup';
import { boolean, number, object, string } from 'yup';
import useFlash from '@/plugins/useFlash';
import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper';
import tw from 'twin.macro';
@ -15,158 +14,177 @@ import Label from '@/components/elements/Label';
import { Textarea } from '@/components/elements/Input';
import Button from '@/components/elements/Button';
import Select from '@/components/elements/Select';
import ModalContext from '@/context/ModalContext';
import asModal from '@/hoc/asModal';
import FormikSwitch from '@/components/elements/FormikSwitch';
interface Props {
schedule: Schedule;
// If a task is provided we can assume we're editing it. If not provided,
// we are creating a new one.
task?: Task;
onDismissed: () => void;
}
interface Values {
action: string;
payload: string;
timeOffset: string;
continueOnFailure: boolean;
}
const TaskDetailsForm = ({ isEditingTask }: { isEditingTask: boolean }) => {
const { values: { action }, initialValues, setFieldValue, setFieldTouched, isSubmitting } = useFormikContext<Values>();
const schema = object().shape({
action: string().required().oneOf([ 'command', 'power', 'backup' ]),
payload: string().when('action', {
is: v => v !== 'backup',
then: string().required('A task payload must be provided.'),
otherwise: string(),
}),
continueOnFailure: boolean(),
timeOffset: number().typeError('The time offset must be a valid number between 0 and 900.')
.required('A time offset value must be provided.')
.min(0, 'The time offset must be at least 0 seconds.')
.max(900, 'The time offset must be less than 900 seconds.'),
});
const ActionListener = () => {
const [ { value }, { initialValue: initialAction } ] = useField<string>('action');
const [ , { initialValue: initialPayload }, { setValue, setTouched } ] = useField<string>('payload');
useEffect(() => {
if (action !== initialValues.action) {
setFieldValue('payload', action === 'power' ? 'start' : '');
setFieldTouched('payload', false);
if (value !== initialAction) {
setValue(value === 'power' ? 'start' : '');
setTouched(false);
} else {
setFieldValue('payload', initialValues.payload);
setFieldTouched('payload', false);
setValue(initialPayload || '');
setTouched(false);
}
}, [ action ]);
}, [ value ]);
return (
<Form css={tw`m-0`}>
<h2 css={tw`text-2xl mb-6`}>{isEditingTask ? 'Edit Task' : 'Create Task'}</h2>
<div css={tw`flex`}>
<div css={tw`mr-2 w-1/3`}>
<Label>Action</Label>
<FormikFieldWrapper name={'action'}>
<FormikField as={Select} name={'action'}>
<option value={'command'}>Send command</option>
<option value={'power'}>Send power action</option>
<option value={'backup'}>Create backup</option>
</FormikField>
</FormikFieldWrapper>
</div>
<div css={tw`flex-1 ml-6`}>
<Field
name={'timeOffset'}
label={'Time offset (in seconds)'}
description={'The amount of time to wait after the previous task executes before running this one. If this is the first task on a schedule this will not be applied.'}
/>
</div>
</div>
<div css={tw`mt-6`}>
{action === 'command' ?
<div>
<Label>Payload</Label>
<FormikFieldWrapper name={'payload'}>
<FormikField as={Textarea} name={'payload'} rows={6} />
</FormikFieldWrapper>
</div>
:
action === 'power' ?
<div>
<Label>Payload</Label>
<FormikFieldWrapper name={'payload'}>
<FormikField as={Select} name={'payload'}>
<option value={'start'}>Start the server</option>
<option value={'restart'}>Restart the server</option>
<option value={'stop'}>Stop the server</option>
<option value={'kill'}>Terminate the server</option>
</FormikField>
</FormikFieldWrapper>
</div>
:
<div>
<Label>Ignored Files</Label>
<FormikFieldWrapper
name={'payload'}
description={'Optional. Include the files and folders to be excluded in this backup. By default, the contents of your .pteroignore file will be used. If you have reached your backup limit, the oldest backup will be rotated.'}
>
<FormikField as={Textarea} name={'payload'} rows={6} />
</FormikFieldWrapper>
</div>
}
</div>
<div css={tw`flex justify-end mt-6`}>
<Button type={'submit'} disabled={isSubmitting}>
{isEditingTask ? 'Save Changes' : 'Create Task'}
</Button>
</div>
</Form>
);
return null;
};
export default ({ task, schedule, onDismissed }: Props) => {
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
const TaskDetailsModal = ({ schedule, task }: Props) => {
const { dismiss } = useContext(ModalContext);
const { clearFlashes, addError } = useFlash();
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
const appendSchedule = ServerContext.useStoreActions(actions => actions.schedules.appendSchedule);
const backupLimit = ServerContext.useStoreState(state => state.server.data!.featureLimits.backups);
useEffect(() => {
clearFlashes('schedule:task');
return () => {
clearFlashes('schedule:task');
};
}, []);
const submit = (values: Values, { setSubmitting }: FormikHelpers<Values>) => {
clearFlashes('schedule:task');
createOrUpdateScheduleTask(uuid, schedule.id, task?.id, values)
.then(task => {
let tasks = schedule.tasks.map(t => t.id === task.id ? task : t);
if (!schedule.tasks.find(t => t.id === task.id)) {
tasks = [ ...tasks, task ];
}
if (backupLimit === 0 && values.action === 'backup') {
setSubmitting(false);
addError({ message: 'A backup task cannot be created when the server\'s backup limit is set to 0.', key: 'schedule:task' });
} else {
createOrUpdateScheduleTask(uuid, schedule.id, task?.id, values)
.then(task => {
let tasks = schedule.tasks.map(t => t.id === task.id ? task : t);
if (!schedule.tasks.find(t => t.id === task.id)) {
tasks = [ ...tasks, task ];
}
appendSchedule({ ...schedule, tasks });
onDismissed();
})
.catch(error => {
console.error(error);
setSubmitting(false);
addError({ message: httpErrorToHuman(error), key: 'schedule:task' });
});
appendSchedule({ ...schedule, tasks });
dismiss();
})
.catch(error => {
console.error(error);
setSubmitting(false);
addError({ message: httpErrorToHuman(error), key: 'schedule:task' });
});
}
};
return (
<Formik
onSubmit={submit}
validationSchema={schema}
initialValues={{
action: task?.action || 'command',
payload: task?.payload || '',
timeOffset: task?.timeOffset.toString() || '0',
continueOnFailure: task?.continueOnFailure || false,
}}
validationSchema={object().shape({
action: string().required().oneOf([ 'command', 'power', 'backup' ]),
payload: string().when('action', {
is: v => v !== 'backup',
then: string().required('A task payload must be provided.'),
otherwise: string(),
}),
timeOffset: number().typeError('The time offset must be a valid number between 0 and 900.')
.required('A time offset value must be provided.')
.min(0, 'The time offset must be at least 0 seconds.')
.max(900, 'The time offset must be less than 900 seconds.'),
})}
>
{({ isSubmitting }) => (
<Modal
visible
appear
onDismissed={() => onDismissed()}
showSpinnerOverlay={isSubmitting}
>
<FlashMessageRender byKey={'schedule:task'} css={tw`mb-4`} />
<TaskDetailsForm isEditingTask={typeof task !== 'undefined'} />
</Modal>
{({ isSubmitting, values }) => (
<Form css={tw`m-0`}>
<FlashMessageRender byKey={'schedule:task'} css={tw`mb-4`}/>
<h2 css={tw`text-2xl mb-6`}>{task ? 'Edit Task' : 'Create Task'}</h2>
<div css={tw`flex`}>
<div css={tw`mr-2 w-1/3`}>
<Label>Action</Label>
<ActionListener/>
<FormikFieldWrapper name={'action'}>
<FormikField as={Select} name={'action'}>
<option value={'command'}>Send command</option>
<option value={'power'}>Send power action</option>
<option value={'backup'}>Create backup</option>
</FormikField>
</FormikFieldWrapper>
</div>
<div css={tw`flex-1 ml-6`}>
<Field
name={'timeOffset'}
label={'Time offset (in seconds)'}
description={'The amount of time to wait after the previous task executes before running this one. If this is the first task on a schedule this will not be applied.'}
/>
</div>
</div>
<div css={tw`mt-6`}>
{values.action === 'command' ?
<div>
<Label>Payload</Label>
<FormikFieldWrapper name={'payload'}>
<FormikField as={Textarea} name={'payload'} rows={6}/>
</FormikFieldWrapper>
</div>
:
values.action === 'power' ?
<div>
<Label>Payload</Label>
<FormikFieldWrapper name={'payload'}>
<FormikField as={Select} name={'payload'}>
<option value={'start'}>Start the server</option>
<option value={'restart'}>Restart the server</option>
<option value={'stop'}>Stop the server</option>
<option value={'kill'}>Terminate the server</option>
</FormikField>
</FormikFieldWrapper>
</div>
:
<div>
<Label>Ignored Files</Label>
<FormikFieldWrapper
name={'payload'}
description={'Optional. Include the files and folders to be excluded in this backup. By default, the contents of your .pteroignore file will be used. If you have reached your backup limit, the oldest backup will be rotated.'}
>
<FormikField as={Textarea} name={'payload'} rows={6}/>
</FormikFieldWrapper>
</div>
}
</div>
<div css={tw`mt-6 bg-neutral-700 border border-neutral-800 shadow-inner p-4 rounded`}>
<FormikSwitch
name={'continueOnFailure'}
description={'Future tasks will be run when this task fails.'}
label={'Continue on Failure'}
/>
</div>
<div css={tw`flex justify-end mt-6`}>
<Button type={'submit'} disabled={isSubmitting}>
{task ? 'Save Changes' : 'Create Task'}
</Button>
</div>
</Form>
)}
</Formik>
);
};
export default asModal<Props>()(TaskDetailsModal);

View File

@ -32,7 +32,7 @@ const EditSubuserModal = ({ subuser }: Props) => {
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
const appendSubuser = ServerContext.useStoreActions(actions => actions.subusers.appendSubuser);
const { clearFlashes, clearAndAddHttpError } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
const { dismiss, toggleSpinner } = useContext(ModalContext);
const { dismiss, setPropOverrides } = useContext(ModalContext);
const isRootAdmin = useStoreState(state => state.user.data!.rootAdmin);
const permissions = useStoreState(state => state.permissions.data);
@ -56,7 +56,7 @@ const EditSubuserModal = ({ subuser }: Props) => {
}, [ isRootAdmin, permissions, loggedInPermissions ]);
const submit = (values: Values) => {
toggleSpinner(true);
setPropOverrides({ showSpinnerOverlay: true });
clearFlashes('user:edit');
createOrUpdateSubuser(uuid, values, subuser)
@ -66,7 +66,7 @@ const EditSubuserModal = ({ subuser }: Props) => {
})
.catch(error => {
console.error(error);
toggleSpinner(false);
setPropOverrides(null);
clearAndAddHttpError({ key: 'user:edit', error });
if (ref.current) {

View File

@ -1,13 +1,14 @@
import React from 'react';
import { SettableModalProps } from '@/hoc/asModal';
export interface ModalContextValues {
dismiss: () => void;
toggleSpinner: (visible?: boolean) => void;
setPropOverrides: (value: ((current: Readonly<Partial<SettableModalProps>>) => Partial<SettableModalProps>) | Partial<SettableModalProps> | null) => void;
}
const ModalContext = React.createContext<ModalContextValues>({
dismiss: () => null,
toggleSpinner: () => null,
setPropOverrides: () => null,
});
ModalContext.displayName = 'ModalContext';

View File

@ -1,24 +1,25 @@
import React from 'react';
import Modal, { ModalProps } from '@/components/elements/Modal';
import ModalContext from '@/context/ModalContext';
import PortaledModal, { ModalProps } from '@/components/elements/Modal';
import ModalContext, { ModalContextValues } from '@/context/ModalContext';
export interface AsModalProps {
visible: boolean;
onModalDismissed?: () => void;
}
type ExtendedModalProps = Omit<ModalProps, 'appear' | 'visible' | 'onDismissed'>;
export type SettableModalProps = Omit<ModalProps, 'appear' | 'visible' | 'onDismissed'>;
interface State {
render: boolean;
visible: boolean;
showSpinnerOverlay?: boolean;
propOverrides: Partial<SettableModalProps>;
}
type ExtendedComponentType<T> = (C: React.ComponentType<T>) => React.ComponentType<T & AsModalProps>;
// eslint-disable-next-line @typescript-eslint/ban-types
function asModal<P extends object> (modalProps?: ExtendedModalProps | ((props: P) => ExtendedModalProps)): ExtendedComponentType<P> {
function asModal<P extends {}> (modalProps?: SettableModalProps | ((props: P) => SettableModalProps)): ExtendedComponentType<P> {
return function (Component) {
return class extends React.PureComponent <P & AsModalProps, State> {
static displayName = `asModal(${Component.displayName})`;
@ -30,54 +31,64 @@ function asModal<P extends object> (modalProps?: ExtendedModalProps | ((props: P
render: props.visible,
visible: props.visible,
showSpinnerOverlay: undefined,
propOverrides: {},
};
}
get modalProps () {
get computedModalProps (): Readonly<SettableModalProps & { visible: boolean }> {
return {
...(typeof modalProps === 'function' ? modalProps(this.props) : modalProps),
showSpinnerOverlay: this.state.showSpinnerOverlay,
...this.state.propOverrides,
visible: this.state.visible,
};
}
/**
* @this {React.PureComponent<P & AsModalProps, State>}
*/
componentDidUpdate (prevProps: Readonly<P & AsModalProps>) {
if (prevProps.visible && !this.props.visible) {
// noinspection JSPotentiallyInvalidUsageOfThis
this.setState({ visible: false, showSpinnerOverlay: false });
} else if (!prevProps.visible && this.props.visible) {
// noinspection JSPotentiallyInvalidUsageOfThis
this.setState({ render: true, visible: true });
}
if (!this.state.render) {
this.setState({ propOverrides: {} });
}
}
dismiss = () => this.setState({ visible: false });
toggleSpinner = (value?: boolean) => this.setState({ showSpinnerOverlay: value });
setPropOverrides: ModalContextValues['setPropOverrides'] = value => this.setState(state => ({
propOverrides: !value ? {} : (typeof value === 'function' ? value(state.propOverrides) : value),
}));
/**
* @this {React.PureComponent<P & AsModalProps, State>}
*/
render () {
if (!this.state.render) return null;
return (
this.state.render ?
<Modal
appear
visible={this.state.visible}
onDismissed={() => this.setState({ render: false }, () => {
if (typeof this.props.onModalDismissed === 'function') {
this.props.onModalDismissed();
}
})}
{...this.modalProps}
<PortaledModal
appear
onDismissed={() => this.setState({ render: false }, () => {
if (typeof this.props.onModalDismissed === 'function') {
this.props.onModalDismissed();
}
})}
{...this.computedModalProps}
>
<ModalContext.Provider
value={{
dismiss: this.dismiss.bind(this),
setPropOverrides: this.setPropOverrides.bind(this),
}}
>
<ModalContext.Provider
value={{
dismiss: this.dismiss.bind(this),
toggleSpinner: this.toggleSpinner.bind(this),
}}
>
<Component {...this.props}/>
</ModalContext.Provider>
</Modal>
:
null
<Component {...this.props}/>
</ModalContext.Provider>
</PortaledModal>
);
}
};

View File

@ -9,7 +9,6 @@ import { ServerContext } from '@/state/server';
import DatabasesContainer from '@/components/server/databases/DatabasesContainer';
import FileManagerContainer from '@/components/server/files/FileManagerContainer';
import { CSSTransition } from 'react-transition-group';
import SuspenseSpinner from '@/components/elements/SuspenseSpinner';
import FileEditContainer from '@/components/server/files/FileEditContainer';
import SettingsContainer from '@/components/server/settings/SettingsContainer';
import ScheduleContainer from '@/components/server/schedules/ScheduleContainer';
@ -151,9 +150,9 @@ const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>)
</RequireServerPermission>
</Route>
<Route path={`${match.path}/files/:action(edit|new)`} exact>
<SuspenseSpinner>
<Spinner.Suspense>
<FileEditContainer/>
</SuspenseSpinner>
</Spinner.Suspense>
</Route>
<Route path={`${match.path}/databases`} exact>
<RequireServerPermission permissions={'database.*'}>

View File

@ -103,6 +103,7 @@ Route::group(['prefix' => '/servers/{server}', 'middleware' => [AuthenticateServ
Route::post('/', 'Servers\BackupController@store');
Route::get('/{backup}', 'Servers\BackupController@view');
Route::get('/{backup}/download', 'Servers\BackupController@download');
Route::post('/{backup}/lock', 'Servers\BackupController@toggleLock');
Route::post('/{backup}/restore', 'Servers\BackupController@restore');
Route::delete('/{backup}', 'Servers\BackupController@delete');
});

View File

@ -89,9 +89,9 @@ class CreateServerScheduleTaskTest extends ClientApiIntegrationTestCase
}
/**
* Test that backups can be tasked out correctly since they do not require a payload.
* Test that backups can not be tasked when the backup limit is 0
*/
public function testBackupsCanBeTaskedCorrectly()
public function testBackupsCanNotBeTaskedIfLimit0()
{
[$user, $server] = $this->generateTestAccount();
@ -101,13 +101,17 @@ class CreateServerScheduleTaskTest extends ClientApiIntegrationTestCase
$this->actingAs($user)->postJson($this->link($schedule, '/tasks'), [
'action' => 'backup',
'time_offset' => 0,
])->assertOk();
])
->assertStatus(Response::HTTP_FORBIDDEN)
->assertJsonPath('errors.0.detail', 'A backup task cannot be created when the server\'s backup limit is set to 0.');
$this->actingAs($user)->postJson($this->link($schedule, '/tasks'), [
'action' => 'backup',
'payload' => "file.txt\nfile2.log",
'time_offset' => 0,
])->assertOk();
])
->assertStatus(Response::HTTP_FORBIDDEN)
->assertJsonPath('errors.0.detail', 'A backup task cannot be created when the server\'s backup limit is set to 0.');
}
/**

View File

@ -0,0 +1,157 @@
<?php
namespace Pterodactyl\Tests\Integration\Jobs\Schedule;
use Mockery;
use Exception;
use Carbon\CarbonImmutable;
use Pterodactyl\Models\Task;
use GuzzleHttp\Psr7\Request;
use InvalidArgumentException;
use GuzzleHttp\Psr7\Response;
use Pterodactyl\Models\Server;
use Pterodactyl\Models\Schedule;
use Illuminate\Support\Facades\Bus;
use Pterodactyl\Jobs\Schedule\RunTaskJob;
use GuzzleHttp\Exception\BadResponseException;
use Pterodactyl\Tests\Integration\IntegrationTestCase;
use Pterodactyl\Repositories\Wings\DaemonPowerRepository;
use Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException;
class RunTaskJobTest extends IntegrationTestCase
{
/**
* An inactive job should not be run by the system.
*/
public function testInactiveJobIsNotRun()
{
$server = $this->createServerModel();
/** @var \Pterodactyl\Models\Schedule $schedule */
$schedule = Schedule::factory()->create([
'server_id' => $server->id,
'is_processing' => true,
'last_run_at' => null,
'is_active' => false,
]);
/** @var \Pterodactyl\Models\Task $task */
$task = Task::factory()->create(['schedule_id' => $schedule->id, 'is_queued' => true]);
$job = new RunTaskJob($task);
Bus::dispatchNow($job);
$task->refresh();
$schedule->refresh();
$this->assertFalse($task->is_queued);
$this->assertFalse($schedule->is_processing);
$this->assertFalse($schedule->is_active);
$this->assertTrue(CarbonImmutable::now()->isSameAs(CarbonImmutable::ISO8601, $schedule->last_run_at));
}
public function testJobWithInvalidActionThrowsException()
{
$server = $this->createServerModel();
/** @var \Pterodactyl\Models\Schedule $schedule */
$schedule = Schedule::factory()->create(['server_id' => $server->id]);
/** @var \Pterodactyl\Models\Task $task */
$task = Task::factory()->create(['schedule_id' => $schedule->id, 'action' => 'foobar']);
$job = new RunTaskJob($task);
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Invalid task action provided: foobar');
Bus::dispatchNow($job);
}
/**
* @dataProvider isManualRunDataProvider
*/
public function testJobIsExecuted(bool $isManualRun)
{
$server = $this->createServerModel();
/** @var \Pterodactyl\Models\Schedule $schedule */
$schedule = Schedule::factory()->create([
'server_id' => $server->id,
'is_active' => !$isManualRun,
'is_processing' => true,
'last_run_at' => null,
]);
/** @var \Pterodactyl\Models\Task $task */
$task = Task::factory()->create([
'schedule_id' => $schedule->id,
'action' => Task::ACTION_POWER,
'payload' => 'start',
'is_queued' => true,
'continue_on_failure' => false,
]);
$mock = Mockery::mock(DaemonPowerRepository::class);
$this->instance(DaemonPowerRepository::class, $mock);
$mock->expects('setServer')->with(Mockery::on(function ($value) use ($server) {
return $value instanceof Server && $value->id === $server->id;
}))->andReturnSelf();
$mock->expects('send')->with('start')->andReturn(new Response());
Bus::dispatchNow(new RunTaskJob($task, $isManualRun));
$task->refresh();
$schedule->refresh();
$this->assertFalse($task->is_queued);
$this->assertFalse($schedule->is_processing);
$this->assertTrue(CarbonImmutable::now()->isSameAs(CarbonImmutable::ISO8601, $schedule->last_run_at));
}
/**
* @dataProvider isManualRunDataProvider
*/
public function testExceptionDuringRunIsHandledCorrectly(bool $continueOnFailure)
{
$server = $this->createServerModel();
/** @var \Pterodactyl\Models\Schedule $schedule */
$schedule = Schedule::factory()->create(['server_id' => $server->id]);
/** @var \Pterodactyl\Models\Task $task */
$task = Task::factory()->create([
'schedule_id' => $schedule->id,
'action' => Task::ACTION_POWER,
'payload' => 'start',
'continue_on_failure' => $continueOnFailure,
]);
$mock = Mockery::mock(DaemonPowerRepository::class);
$this->instance(DaemonPowerRepository::class, $mock);
$mock->expects('setServer->send')->andThrow(
new DaemonConnectionException(new BadResponseException('Bad request', new Request('GET', '/test'), new Response()))
);
if (!$continueOnFailure) {
$this->expectException(DaemonConnectionException::class);
}
Bus::dispatchNow(new RunTaskJob($task));
if ($continueOnFailure) {
$task->refresh();
$schedule->refresh();
$this->assertFalse($task->is_queued);
$this->assertFalse($schedule->is_processing);
$this->assertTrue(CarbonImmutable::now()->isSameAs(CarbonImmutable::ISO8601, $schedule->last_run_at));
}
}
/**
* @return array
*/
public function isManualRunDataProvider()
{
return [[true], [false]];
}
}

View File

@ -0,0 +1,43 @@
<?php
namespace Pterodactyl\Tests\Unit\Helpers;
use Pterodactyl\Tests\TestCase;
use Pterodactyl\Traits\Commands\EnvironmentWriterTrait;
class EnvironmentWriterTraitTest extends TestCase
{
/**
* @dataProvider variableDataProvider
*/
public function testVariableIsEscapedProperly($input, $expected)
{
$output = (new FooClass())->escapeEnvironmentValue($input);
$this->assertSame($expected, $output);
}
public function variableDataProvider(): array
{
return [
['foo', 'foo'],
['abc123', 'abc123'],
['val"ue', '"val\"ue"'],
['my test value', '"my test value"'],
['mysql_p@assword', '"mysql_p@assword"'],
['mysql_p#assword', '"mysql_p#assword"'],
['mysql p@$$word', '"mysql p@$$word"'],
['mysql p%word', '"mysql p%word"'],
['mysql p#word', '"mysql p#word"'],
['abc_@#test', '"abc_@#test"'],
['test 123 $$$', '"test 123 $$$"'],
['#password%', '"#password%"'],
['$pass ', '"$pass "'],
];
}
}
class FooClass
{
use EnvironmentWriterTrait;
}

View File

@ -3,18 +3,20 @@
"target": "es2015",
"module": "es2020",
"jsx": "react",
"strict": true,
"noEmit": true,
"noImplicitReturns": true,
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"sourceMap": true,
"baseUrl": ".",
"lib": [
"es2015",
"dom"
],
"strict": true,
"noEmit": true,
"sourceMap": true,
"noImplicitReturns": true,
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"baseUrl": ".",
"importsNotUsedAsValues": "preserve",
"paths": {
"@/*": [

View File

@ -71,10 +71,17 @@ module.exports = {
},
plugins: [
new AssetsManifestPlugin({ writeToDisk: true, publicPath: true, integrity: true, integrityHashes: ['sha384'] }),
new ForkTsCheckerWebpackPlugin(isProduction ? {} : {
eslint: {
files: `${path.join(__dirname, '/resources/scripts')}/**/*.{ts,tsx}`,
new ForkTsCheckerWebpackPlugin({
typescript: {
mode: 'write-references',
diagnosticOptions: {
semantic: true,
syntactic: true,
},
},
eslint: isProduction ? undefined : {
files: `${path.join(__dirname, '/resources/scripts')}/**/*.{ts,tsx}`,
}
}),
process.env.ANALYZE_BUNDLE ? new BundleAnalyzerPlugin({
analyzerHost: '0.0.0.0',

2294
yarn.lock

File diff suppressed because it is too large Load Diff