diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..a8addd6 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,6 @@ +[*] +end_of_line = lf +insert_final_newline = true +charset = utf-8 +indent_style = space +indent_size = 4 diff --git a/CHANGELOG.md b/CHANGELOG.md index e56b267..f27854f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,18 +3,22 @@ ## 2.2.4 - UNRELEASED ## 2.2.3 - 2021/01/08 + * Change docker base to alpine ## 2.2.2 - 2020/03/22 + * Stop auto-sorting tables * Add bandwidth formatter * Check PHP 7.4 compatibility * Increase docker image base to PHP 7.4 ## 2.2.1 - 2019/11/10 + * Use separate JavaScript file to initialize DataTables ## 2.2.0 - 2019/11/10 + * Add version tag to footer * Add sortable tables * Add search on tables @@ -23,32 +27,38 @@ * Fix dependency version ## 2.1.4 - 2019/11/08 + * Use autofocus on username input field instead of the password field * Fill missing cells on incorrect cell count in table views when only partial data is available ## 2.1.3 - 2019/08/08 + * Fixed false rendering of forms * Fixed channel tree view showing the wrong virtual server after selection * Minor code refactor ## 2.1.2 - 2019/08/07 + * Minor refactoring * Update documentation ## 2.1.1 - 2019/08/07 + * Updated translation ## 2.1.0 - 2019/08/07 + * Fixed file handling on snapshots * Cleaned up template links * Updated documentation * Added application log view -* Fixed files view +* Fixed files view * Added show total used space for files in a channel * Added file delete action * Removed about modal ## 2.0.0 - 2019/08/06 + * Replace material design with bootstrap4 theme * Add an about modal * Add tree view for online clients @@ -56,34 +66,45 @@ * Update dependencies and force PHP 7.3 ## 1.2.4 - 2019/01/18 + * No info text ## 1.2.3 - 2019/01/18 + * No info text ## 1.2.2 - 2018/09/01 + * No info text ## 1.2.1 - 2018/06/04 + * No info text ## 1.2.0 - 2018/06/04 + * No info text ## 1.1.1 - 2018/05/04 + * No info text ## 1.1.0 - 2018/05/04 + * No info text ## 1.0.3 - 2018/04/04 + * No info text ## 1.0.2 - 2018/04/04 + * No info text ## 1.0.1 - 2018/04/03 + * No info text ## 1.0.0 - 2018/04/03 -* No info text \ No newline at end of file + +* No info text diff --git a/README.md b/README.md index f30b236..794c407 100755 --- a/README.md +++ b/README.md @@ -1,45 +1,54 @@ # README + ts3web is a free and open-source web interface for TeamSpeak 3 instances. - + The minimalistic approach of this application is intentional. * Docker images available on [https://hub.docker.com/r/varakh/ts3web](https://hub.docker.com/r/varakh/ts3web) * Sources are hosted on [https://github.com/v4rakh/ts3web](https://github.com/v4rakh/ts3web) -There are many TeamSpeak 3 web interfaces out. Why should I pick ts3web? -Free, simple, stateless, easy to extend, standard bootstrap theme. +There are many TeamSpeak 3 web interfaces out. Why should I pick ts3web? Free, simple, stateless, easy to extend, +standard bootstrap theme. ## F.A.Q Questions? Here you'll hopefully get the answer. Feel free to read before starting. + ###### I always get `flood client` message when clicking anywhere in the web interface. + ts3web makes heavy use of query commands. When your instance is up and running, you should be able to change `serverinstance_serverquery_flood_commands` to a high value, e.g. `100` and `serverinstance_serverquery_flood_time` to `1` which is enough. + ###### I always get `TSException: Error: host isn't a ts3 instance!` when selecting a server. -You probably got query banned from your server. You need to properly define your [`whitelist.txt` file](#whitelisttxtexample) + +You probably got query banned from your server. You need to properly define +your [`whitelist.txt` file](#whitelisttxtexample) and include it in your TeamSpeak application. + ###### I always get `no write permissions` or something similar when trying to save snapshots or when a log entry is created. -This probably happens when you're in the docker setup. Ensure that host binds have permissions set up properly. -The user which is used in the docker container is `nobody` with id `65534`. If, e.g. logs are host bound, then execute + +This probably happens when you're in the docker setup. Ensure that host binds have permissions set up properly. The user +which is used in the docker container is `nobody` with id `65534`. If, e.g. logs are host bound, then execute `chown -R 65534:65534 host/path/to/log`. The same holds true for snapshots. ## Configuration -The main configuration file for the *web interface* is the `env` file located in `config/`. There's an example file -called `env.example` which you **need** to copy to `config/env`. Defaults will assume you're running your TeamSpeak +The main configuration file for the *web interface* is the `env` file located in `config/`. There's an example file +called `env.example` which you **need** to copy to `config/env`. Defaults will assume you're running your TeamSpeak server on `localhost` with default port. Docker deployments can and *should* host bind this file into the container directly and just maintain the `env` file. ## Deployment -The application can be deployed two different ways. See below for more information. For each deployment type a running -TeamSpeak 3 instance is a prerequisite. + +The application can be deployed two different ways. See below for more information. For each deployment type a running +TeamSpeak 3 instance is a prerequisite. In the `docker-compose.yml` [example](#dockercompose), a setup together with a teamspeak server instance is shown. @@ -50,17 +59,19 @@ In the `docker-compose.yml` [example](#dockercompose), a setup together with a t 1. [Setup write permissions if you're using host binds](#dockerperms) 2. [Ensure that you set `flood commands` to a higher value in your TeamSpeak](#flood). 3. [Use a `whitelist.txt` to ensure the web interface will not be query banned](#whitelist) -4. Be aware that the web interface will not be able to use `localhost` as TeamSpeak 3 server address because it's not +4. Be aware that the web interface will not be able to use `localhost` as TeamSpeak 3 server address because it's not available in a docker container. The public address also has to match the environment variable `teamspeak_host=your-public-address` within the `env` file. + #### docker run + The following section outlines a manual setup. Feel free to use the provided `docker-compose.yml` as quick setup. 1. Create docker volumes for `snapshots`, `log` and `env`. Alternative is to host bind them into your containers. 2. Create a docker network with a fixed IP range or later use host network. -3. Depending on your setup, you need to change `teamspeak_host` of your `env` file to point either to `your IP` or to a +3. Depending on your setup, you need to change `teamspeak_host` of your `env` file to point either to `your IP` or to a `fixed docker IP` which your teamspeak uses. `localhost` is not valid if you're using it in docker. If you're unsure, please take a look at the example `docker-compose.yml` files. 4. Start a container using the docker image `varakh/ts3web` and provide the following bindings for volumes: @@ -68,19 +79,24 @@ The following section outlines a manual setup. Feel free to use the provided `do * `{snapshot_volume|host_folder}:/var/www/html/application/data/snapshots` * `{log_volume|host_folder}:/var/www/html/application/log` 5. [Ensure that you're whitelisting the IP from which the webinterface will issue commands.](#whitelist) -6. Run the `docker run` command including your settings, volumes and networks (if any): `docker run --name teamspeak_web -v ./env:/var/www/html/application/config/env -p 8181:80 varakh/ts3web:latest`. +6. Run the `docker run` command including your settings, volumes and networks (if + any): `docker run --name teamspeak_web -v ./env:/var/www/html/application/config/env -p 8181:80 varakh/ts3web:latest` + . -#### docker-compose -In order for TeamSpeak to show correct IP and country flags, the `network_mode = "host"` is advised. It's also -possible to set everything up [without using the host network mode and use fixed IPs](#withouthostmode). -The examples will use host binds for volumes. Feel free to adapt the `docker-compose.yml` template and use docker volumes -instead if you like. +#### docker-compose + +In order for TeamSpeak to show correct IP and country flags, the `network_mode = "host"` is advised. It's also possible +to set everything up [without using the host network mode and use fixed IPs](#withouthostmode). + +The examples will use host binds for volumes. Feel free to adapt the `docker-compose.yml` template and use docker +volumes instead if you like. Ensure to [apply permissions](#dockerperms) for volumes though. + #### With 'host' mode ``` @@ -121,6 +137,7 @@ services: ``` + #### Without 'host' mode ``` @@ -169,13 +186,13 @@ services: ``` - + #### whitelist.txt -The following illustrates a valid `whitelist.txt` file which can be used for the above `docker-compose` setups. You -need to replace `your-public-ip` with the TeamSpeak's public IP address if required or remove the fixed internal -docker IP if you're on 'host' mode. +The following illustrates a valid `whitelist.txt` file which can be used for the above `docker-compose` setups. You need +to replace `your-public-ip` with the TeamSpeak's public IP address if required or remove the fixed internal docker IP if +you're on 'host' mode. ``` 127.0.0.1 @@ -184,20 +201,23 @@ docker IP if you're on 'host' mode. your-public-ip ``` -Now execute `docker-compose up -d` to start those containers. If you like to update, do `docker-compose down`, +Now execute `docker-compose up -d` to start those containers. If you like to update, do `docker-compose down`, `docker-compose pull` and then `docker-compose up -d` again. Your TeamSpeak 3 Server will be available under `public-server-ip:9987`. The web interface will be available on -`127.0.0.1:8181`. You need to add a [reverse proxy](#reverseproxy) and probably you also want SSL configured if you expose it via domain. -For testing purposes, change `- 127.0.0.1:8181:80` to `- 8181:80`. The web interface will then be available under +`127.0.0.1:8181`. You need to add a [reverse proxy](#reverseproxy) and probably you also want SSL configured if you +expose it via domain. For testing purposes, change `- 127.0.0.1:8181:80` to `- 8181:80`. The web interface will then be +available under `public-server-ip:8181`. This is **not recommended**! Secure your setup properly via [reverse proxy and SSL](#reverseproxy). ### As native PHP application + **Prerequisite**: `php`, `composer` and probably `php-fpm` installed on the server. #### Install: + * Clone repository * Change directory to project home * Execute `composer install` @@ -205,15 +225,19 @@ This is **not recommended**! Secure your setup properly via [reverse proxy and S * Do the configuration by coping the `env.example` file (see information above) * Use a web server _or_ run directly via the embedded PHP server: `php -S localhost:8080 -t public public/index.php`. * Point your browser to [localhost:8080](http://localhost:8080) -* Apply any [whitelist.txt](#whitelisttxtexample) changes if you configured `teamspeak_host` differently than `localhost` +* Apply any [whitelist.txt](#whitelisttxtexample) changes if you configured `teamspeak_host` differently + than `localhost` #### Upgrade: + * Change directory to project home * `git pull` * `composer update` + ### Reverse proxy + Here's an example on how to configure a reverse proxy for the web interface docker container ``` @@ -256,12 +280,13 @@ supported: If you're willing to contribute, here's some information. ### Release + * Set a date in the `CHANGELOG.md` file * Remove `SNAPSHOT` from the version in `Constants.php` * Build the docker image from the project - * if necessary, add GitHub access token to let composer pull dependencies within the image correctly: - add `&& composer config --global --auth github-oauth.github.com \` before the `composer install` command, - where `` can be retrieved from [GitHub settings](https://github.com/settings/tokens) + * if necessary, add GitHub access token to let composer pull dependencies within the image correctly: + add `&& composer config --global --auth github-oauth.github.com \` before the `composer install` command, + where `` can be retrieved from [GitHub settings](https://github.com/settings/tokens) * execute `sudo docker build --no-cache -t varakh/ts3web:latest .` to build * publish it * Tag the release git commit and create a new release in the VCS web interface @@ -274,7 +299,9 @@ If you're willing to contribute, here's some information. 4. Don't forget to clean up all created branches ### Helpers -Attributes can be defined when including `table`, `keyvalues` and `form` templates of twig. This helps to generate tables and forms without the need to specify all attributes. + +Attributes can be defined when including `table`, `keyvalues` and `form` templates of twig. This helps to generate +tables and forms without the need to specify all attributes. ``` hiddenDependingOnAttribute // hides a row depending on a value in a table @@ -289,10 +316,15 @@ fields // define fields for a form See example usage in the folder `View/bootstrap4`. ### Translations -- This app uses Symfony Translator. It's bootstrapped in `Util\BootstrapHelper` and locales are placed under `data/locale/` and the data table `.json` file, e.g. `en_dataTable.json`. Adjust to your needs or help translating. -- Form fields (name/id should be the same) are also translated. For a field named `content` or `ConT enT` translate `form_field_content`. + +- This app uses Symfony Translator. It's bootstrapped in `Util\BootstrapHelper` and locales are placed + under `data/locale/` and the data table `.json` file, e.g. `en_dataTable.json`. Adjust to your needs or help + translating. +- Form fields (name/id should be the same) are also translated. For a field named `content` or `ConT enT` + translate `form_field_content`. ### Theme -Themes can be chosen in the `env` file by editing the `theme` variable. Templates are mapped to the corresponding view -folder in `src/View/`. `.css`, `.js` and other style files like `.ttf` or `.woff2` for fonts should be placed + +Themes can be chosen in the `env` file by editing the `theme` variable. Templates are mapped to the corresponding view +folder in `src/View/`. `.css`, `.js` and other style files like `.ttf` or `.woff2` for fonts should be placed in `public/theme/` and accessed accordingly. See an example in `src/View/boostrap4/layout.twig`. diff --git a/src/Control/Actions/AuthLoginAction.php b/src/Control/Actions/AuthLoginAction.php index e59a7f3..0e89eb1 100644 --- a/src/Control/Actions/AuthLoginAction.php +++ b/src/Control/Actions/AuthLoginAction.php @@ -15,11 +15,11 @@ final class AuthLoginAction extends AbstractAction $validator = new Validator(); $body = $validator->sanitize($body); $validator->filter_rules([ - 'username' => 'trim', + 'username' => 'trim', ]); $validator->validation_rules([ - 'username' => 'required|min_len,1', - 'password' => 'required|min_len,1', + 'username' => 'required|min_len,1', + 'password' => 'required|min_len,1', ]); if (!$validator->run($body)) { $validator->addErrorsToFlashMessage($this->flash); @@ -42,7 +42,7 @@ final class AuthLoginAction extends AbstractAction } $this->view->render($response, 'login.twig', [ - 'title' => $this->translator->trans('login.title'), + 'title' => $this->translator->trans('login.title'), ]); } -} \ No newline at end of file +} diff --git a/src/Control/Actions/AuthLogoutAction.php b/src/Control/Actions/AuthLogoutAction.php index 203cf06..9122f85 100644 --- a/src/Control/Actions/AuthLogoutAction.php +++ b/src/Control/Actions/AuthLogoutAction.php @@ -8,7 +8,7 @@ final class AuthLogoutAction extends AbstractAction public function __invoke(Request $request, Response $response, $args) { $this->flash->addMessage('success', $this->translator->trans('logout.flash.success')); - + $this->ts->login($this->auth->getIdentity()['user'], $this->auth->getIdentity()['password']); $this->ts->getInstance()->logout(); $this->auth->logout(); @@ -16,4 +16,4 @@ final class AuthLogoutAction extends AbstractAction return $response->withRedirect('/login'); } -} \ No newline at end of file +} diff --git a/src/Control/Actions/ForbiddenAction.php b/src/Control/Actions/ForbiddenAction.php index f8fb5c6..3bcfd5e 100644 --- a/src/Control/Actions/ForbiddenAction.php +++ b/src/Control/Actions/ForbiddenAction.php @@ -8,8 +8,8 @@ final class ForbiddenAction extends AbstractAction public function __invoke(Request $request, Response $response, $args) { return $this->view->render($response, 'error.twig', [ - 'title' => $this->translator->trans('error.403.title'), - 'content' => $this->translator->trans('error.403.content') + 'title' => $this->translator->trans('error.403.title'), + 'content' => $this->translator->trans('error.403.content') ]); } -} \ No newline at end of file +} diff --git a/src/Control/Actions/InstanceAction.php b/src/Control/Actions/InstanceAction.php index cd3f2e1..08bd432 100644 --- a/src/Control/Actions/InstanceAction.php +++ b/src/Control/Actions/InstanceAction.php @@ -14,8 +14,8 @@ final class InstanceAction extends AbstractAction $data['data'] = array_merge($hostResult['data'], $instanceResult['data']); $this->view->render($response, 'instance.twig', [ - 'title' => $this->translator->trans('instance.title'), + 'title' => $this->translator->trans('instance.title'), 'data' => $this->ts->getInstance()->getElement('data', $data) ]); } -} \ No newline at end of file +} diff --git a/src/Control/Actions/InternalApplicationError.php b/src/Control/Actions/InternalApplicationError.php index c984dfb..19d6538 100644 --- a/src/Control/Actions/InternalApplicationError.php +++ b/src/Control/Actions/InternalApplicationError.php @@ -8,8 +8,8 @@ final class InternalApplicationError extends AbstractAction public function __invoke(Request $request, Response $response, $args) { return $this->view->render($response, 'error.twig', [ - 'title' => $this->translator->trans('error.500.title'), - 'content' => $this->translator->trans('error.500.content') + 'title' => $this->translator->trans('error.500.title'), + 'content' => $this->translator->trans('error.500.content') ]); } -} \ No newline at end of file +} diff --git a/src/Control/Actions/NotAuthorizedAction.php b/src/Control/Actions/NotAuthorizedAction.php index 5b14024..66da452 100644 --- a/src/Control/Actions/NotAuthorizedAction.php +++ b/src/Control/Actions/NotAuthorizedAction.php @@ -8,8 +8,8 @@ final class NotAuthorizedAction extends AbstractAction public function __invoke(Request $request, Response $response, $args) { return $this->view->render($response, 'error.twig', [ - 'title' => $this->translator->trans('error.401.title'), - 'content' => $this->translator->trans('error.401.content') + 'title' => $this->translator->trans('error.401.title'), + 'content' => $this->translator->trans('error.401.content') ]); } -} \ No newline at end of file +} diff --git a/src/Control/Actions/NotFoundAction.php b/src/Control/Actions/NotFoundAction.php index 63784d1..56989ca 100644 --- a/src/Control/Actions/NotFoundAction.php +++ b/src/Control/Actions/NotFoundAction.php @@ -8,8 +8,8 @@ final class NotFoundAction extends AbstractAction public function __invoke(Request $request, Response $response, $args) { return $this->view->render($response, 'error.twig', [ - 'title' => $this->translator->trans('error.404.title'), - 'content' => $this->translator->trans('error.404.content') + 'title' => $this->translator->trans('error.404.title'), + 'content' => $this->translator->trans('error.404.content') ]); } -} \ No newline at end of file +} diff --git a/src/Control/Actions/ProfileAction.php b/src/Control/Actions/ProfileAction.php index 1f2ce0a..eeab5c5 100644 --- a/src/Control/Actions/ProfileAction.php +++ b/src/Control/Actions/ProfileAction.php @@ -16,8 +16,8 @@ final class ProfileAction extends AbstractAction $whoisResult = $this->ts->getInstance()->whoAmI(); $this->view->render($response, 'profile.twig', [ - 'title' => $this->translator->trans('profile.title'), + 'title' => $this->translator->trans('profile.title'), 'whois' => $this->ts->getInstance()->getElement('data', $whoisResult), ]); } -} \ No newline at end of file +} diff --git a/src/Control/Actions/SnapshotDeleteAction.php b/src/Control/Actions/SnapshotDeleteAction.php index 98643a6..380680e 100644 --- a/src/Control/Actions/SnapshotDeleteAction.php +++ b/src/Control/Actions/SnapshotDeleteAction.php @@ -1,6 +1,5 @@ withRedirect('/snapshots/' . $sid); } -} \ No newline at end of file +} diff --git a/src/Util/Auth/TSAuthAdapter.php b/src/Util/Auth/TSAuthAdapter.php index add8aa6..b7cd2ad 100644 --- a/src/Util/Auth/TSAuthAdapter.php +++ b/src/Util/Auth/TSAuthAdapter.php @@ -47,7 +47,7 @@ class TSAuthAdapter extends \Zend\Authentication\Adapter\AbstractAdapter if ($this->ts->login($user, $password)) { $this->logger->debug(sprintf('Authenticated as %s', $user)); - $user = ['identity' => $user, 'user' => $user, 'password'=> $password, 'role' => ACL::ACL_DEFAULT_ROLE_ADMIN]; + $user = ['identity' => $user, 'user' => $user, 'password' => $password, 'role' => ACL::ACL_DEFAULT_ROLE_ADMIN]; return new Result(Result::SUCCESS, $user, array()); } else { return new Result( @@ -57,4 +57,4 @@ class TSAuthAdapter extends \Zend\Authentication\Adapter\AbstractAdapter ); } } -} \ No newline at end of file +} diff --git a/src/Util/FileHelper.php b/src/Util/FileHelper.php index 50dc824..9071da9 100644 --- a/src/Util/FileHelper.php +++ b/src/Util/FileHelper.php @@ -44,7 +44,8 @@ class FileHelper * @param int $decimals * @return string */ - public static function humanFileSize($bytes, $decimals = 2) { + public static function humanFileSize($bytes, $decimals = 2) + { $sz = 'BKMGTP'; $factor = floor((strlen($bytes) - 1) / 3); return sprintf("%.{$decimals}f", $bytes / pow(1024, $factor)) . @$sz[$factor]; @@ -57,9 +58,10 @@ class FileHelper * @param int $decimals * @return string */ - public static function humanBandwidth($bytes, $decimals = 2) { + public static function humanBandwidth($bytes, $decimals = 2) + { $sz = 'BKMGTP'; $factor = floor((strlen($bytes) - 1) / 3); return sprintf("%.{$decimals}f", $bytes / pow(1024, $factor)) . @$sz[$factor] . '/s'; } -} \ No newline at end of file +} diff --git a/src/Util/TS3AdminProxy.php b/src/Util/TS3AdminProxy.php index 8b832af..3bcf674 100644 --- a/src/Util/TS3AdminProxy.php +++ b/src/Util/TS3AdminProxy.php @@ -12,12 +12,14 @@ class TS3AdminProxy * @param ts3admin $object * @param $logger LoggerInterface */ - public function __construct(ts3admin $object, $logger) { + public function __construct(ts3admin $object, $logger) + { $this->object = $object; $this->logger = $logger; } - public function __call($method, $args) { + public function __call($method, $args) + { // hide sensitive args if (in_array($method, ['login'])) { @@ -67,4 +69,4 @@ class TS3AdminProxy return $result; } -} \ No newline at end of file +} diff --git a/src/Util/TSInstance.php b/src/Util/TSInstance.php index 3f8ef7e..b346968 100644 --- a/src/Util/TSInstance.php +++ b/src/Util/TSInstance.php @@ -51,49 +51,6 @@ class TSInstance } } - /** - * Login - * - * @param $user - * @param $password - * @return bool - */ - public function login($user, $password) - { - if (!empty($user) && !empty($password)) { - $this->ts->login($user, $password); - $this->logger->debug(sprintf('Logged in as %s', $user)); - } else { - throw new InvalidArgumentException('User and password not provided'); - } - - return true; - } - - /** - * @return string - */ - public function getHost() - { - return $this->host; - } - - /** - * @return int - */ - public function getQueryPort() - { - return $this->queryPort; - } - - /** - * @return ts3admin - */ - public function getInstance() - { - return $this->ts; - } - /** * @return array */ @@ -224,4 +181,47 @@ class TSInstance return $arr; } -} \ No newline at end of file + + /** + * Login + * + * @param $user + * @param $password + * @return bool + */ + public function login($user, $password) + { + if (!empty($user) && !empty($password)) { + $this->ts->login($user, $password); + $this->logger->debug(sprintf('Logged in as %s', $user)); + } else { + throw new InvalidArgumentException('User and password not provided'); + } + + return true; + } + + /** + * @return string + */ + public function getHost() + { + return $this->host; + } + + /** + * @return int + */ + public function getQueryPort() + { + return $this->queryPort; + } + + /** + * @return ts3admin + */ + public function getInstance() + { + return $this->ts; + } +} diff --git a/src/Util/Validator.php b/src/Util/Validator.php index ce3df6e..ae0e206 100644 --- a/src/Util/Validator.php +++ b/src/Util/Validator.php @@ -13,52 +13,110 @@ class Validator extends GUMP $this->translator = BootstrapHelper::bootTranslator(); } - protected function validate_set_min_len($field, $input, $param = NULL) + /** + * Perform data validation against the provided ruleset + * + * Arrays as FIELDS are added here as a custom feature + * + * @access public + * @param mixed $input + * @param array $ruleset + * @return mixed + * @throws \Exception + */ + public function validate(array $input, array $ruleset) { + $this->errors = []; - $err = [ - 'field' => $field, - 'value' => $input[$field], - 'rule' => __FUNCTION__, - 'param' => $param, - ]; + foreach ($ruleset as $field => $rules) { + #if(!array_key_exists($field, $input)) + #{ + # continue; + #} - if (!is_array($input[$field])) { - return $err; + $rules = explode('|', $rules); + + if (in_array("required", $rules) || (isset($input[$field]) && (is_array($input[$field]) || trim($input[$field]) != ''))) { + + foreach ($rules as $rule) { + $method = NULL; + $param = NULL; + + if (strstr($rule, ',') !== false) // has params + { + $rule = explode(',', $rule); + $method = 'validate_' . $rule[0]; + $param = $rule[1]; + $rule = $rule[0]; + } else { + $method = 'validate_' . $rule; + } + + // array required + if ($rule === "required" && !isset($input[$field])) { + $result = $this->$method($field, $input, $param); + $this->errors[] = $result; + + return; + } + + if (is_callable([$this, $method])) { + $result = $this->$method($field, $input, $param); + + if (is_array($result)) // Validation Failed + { + $this->errors[] = $result; + + return $this->errors; + } + } else { + if (isset(self::$validation_methods[$rule])) { + if (isset($input[$field])) { + $result = call_user_func(self::$validation_methods[$rule], $field, $input, $param); + + $result = $this->$method($field, $input, $param); + + if (is_array($result)) // Validation Failed + { + $this->errors[] = $result; + + return $this->errors; + } + } + } else { + throw new \Exception("Validator method '$method' does not exist."); + } + } + } + } } - // default value - if (empty($param)) $param = 1; - - if (count($input[$field]) < $param) return $err; - - return true; + return (count($this->errors) > 0) ? $this->errors : true; } - /** Validates if $field content is equal to $param - * @param $field - * @param $input - * @param $param - * @return bool - */ - protected function validate_equals($field, $input, $param) + public function filter_upper($value, $param = NULL) { - $err = [ - 'field' => $field, - 'value' => $input[$field], - 'rule' => __FUNCTION__, - 'param' => $param, - ]; + return strtoupper($value); + } - if (!isset($input[$field]) || empty($input[$field]) || empty($param) || !isset($param)) { - return $err; + public function filter_lower($value, $param = NULL) + { + return strtolower($value); + } + + /** + * Converts all error array into a single string + * @return void + */ + public function addErrorsToFlashMessage($flash) + { + $errors = $this->get_errors_array(true); + + if (!empty($errors)) { + foreach ($errors as $error) { + $flash->addMessage('error', $error); + } } - - if ($input[$field] != $param || $input[$field] !== $param) { - return $err; - } - - return true; } /** @@ -171,112 +229,6 @@ class Validator extends GUMP return $resp; } - /** - * Perform data validation against the provided ruleset - * - * Arrays as FIELDS are added here as a custom feature - * - * @access public - * @param mixed $input - * @param array $ruleset - * @return mixed - * @throws \Exception - */ - public function validate(array $input, array $ruleset) - { - $this->errors = []; - - foreach ($ruleset as $field => $rules) { - #if(!array_key_exists($field, $input)) - #{ - # continue; - #} - - $rules = explode('|', $rules); - - if (in_array("required", $rules) || (isset($input[$field]) && (is_array($input[$field]) || trim($input[$field]) != ''))) { - - foreach ($rules as $rule) { - $method = NULL; - $param = NULL; - - if (strstr($rule, ',') !== false) // has params - { - $rule = explode(',', $rule); - $method = 'validate_' . $rule[0]; - $param = $rule[1]; - $rule = $rule[0]; - } else { - $method = 'validate_' . $rule; - } - - // array required - if ($rule === "required" && !isset($input[$field])) { - $result = $this->$method($field, $input, $param); - $this->errors[] = $result; - - return; - } - - if (is_callable([$this, $method])) { - $result = $this->$method($field, $input, $param); - - if (is_array($result)) // Validation Failed - { - $this->errors[] = $result; - - return $this->errors; - } - } else { - if (isset(self::$validation_methods[$rule])) { - if (isset($input[$field])) { - $result = call_user_func(self::$validation_methods[$rule], $field, $input, $param); - - $result = $this->$method($field, $input, $param); - - if (is_array($result)) // Validation Failed - { - $this->errors[] = $result; - - return $this->errors; - } - } - } else { - throw new \Exception("Validator method '$method' does not exist."); - } - } - } - } - } - - return (count($this->errors) > 0) ? $this->errors : true; - } - - public function filter_upper($value, $param = NULL) - { - return strtoupper($value); - } - - public function filter_lower($value, $param = NULL) - { - return strtolower($value); - } - - /** - * Converts all error array into a single string - * @return void - */ - public function addErrorsToFlashMessage($flash) - { - $errors = $this->get_errors_array(true); - - if (!empty($errors)) { - foreach ($errors as $error) { - $flash->addMessage('error', $error); - } - } - } - /** * @param array $input * @param array $fields @@ -285,7 +237,7 @@ class Validator extends GUMP */ public function sanitize(array $input, array $fields = array(), $utf8_encode = true) { - $magic_quotes = (bool) get_magic_quotes_gpc(); + $magic_quotes = (bool)get_magic_quotes_gpc(); if (empty($fields)) { $fields = array_keys($input); @@ -324,4 +276,52 @@ class Validator extends GUMP return $return; } -} \ No newline at end of file + + protected function validate_set_min_len($field, $input, $param = NULL) + { + + $err = [ + 'field' => $field, + 'value' => $input[$field], + 'rule' => __FUNCTION__, + 'param' => $param, + ]; + + if (!is_array($input[$field])) { + return $err; + } + + // default value + if (empty($param)) $param = 1; + + if (count($input[$field]) < $param) return $err; + + return true; + } + + /** Validates if $field content is equal to $param + * @param $field + * @param $input + * @param $param + * @return bool + */ + protected function validate_equals($field, $input, $param) + { + $err = [ + 'field' => $field, + 'value' => $input[$field], + 'rule' => __FUNCTION__, + 'param' => $param, + ]; + + if (!isset($input[$field]) || empty($input[$field]) || empty($param) || !isset($param)) { + return $err; + } + + if ($input[$field] != $param || $input[$field] !== $param) { + return $err; + } + + return true; + } +} diff --git a/src/View/bootstrap4/client_info.twig b/src/View/bootstrap4/client_info.twig index 606beba..7af37df 100644 --- a/src/View/bootstrap4/client_info.twig +++ b/src/View/bootstrap4/client_info.twig @@ -11,9 +11,9 @@ 'uri': '/clients/ban/' ~ sid ~ '/' ~ cldbid, 'uri_method': 'post', 'fields': [ - {'type': 'text', 'key': 'reason', 'label': 'client_info.ban.reason'|trans}, - {'type': 'number', 'key': 'time', 'label': 'client_info.ban.time'|trans} - ] + {'type': 'text', 'key': 'reason', 'label': 'client_info.ban.reason'|trans}, + {'type': 'number', 'key': 'time', 'label': 'client_info.ban.time'|trans} + ] }, { 'header_label': 'client_info.send'|trans, @@ -37,7 +37,7 @@ {% endif %} {% if channelGroups|length > 0 %} -
{% trans %}client_info.h.channelgroups{% endtrans %}
+
{% trans %}client_info.h.channelgroups{% endtrans %}
{% include 'table.twig' with {'data': channelGroups, 'hiddenColumns': ['cldbid', 'cid', 'cgid'], 'additional_links': [ @@ -71,4 +71,4 @@
{% trans %}client_info.h.permissions{% endtrans %}
{% include 'table.twig' with {'data': permissions} %} {% endif %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/src/View/bootstrap4/files.twig b/src/View/bootstrap4/files.twig index 0f0d380..6918988 100644 --- a/src/View/bootstrap4/files.twig +++ b/src/View/bootstrap4/files.twig @@ -27,7 +27,8 @@ {{ path }} {{ file.size == 0 ? '' : file.size|file }} {{ file.datetime|timestamp }} - + + {% endfor %} @@ -39,4 +40,4 @@ - \ No newline at end of file + diff --git a/src/View/bootstrap4/form.twig b/src/View/bootstrap4/form.twig index 725952c..6270ee8 100644 --- a/src/View/bootstrap4/form.twig +++ b/src/View/bootstrap4/form.twig @@ -27,7 +27,7 @@ {% elseif field.type == 'checkbox' %}
- +
@@ -40,10 +40,10 @@
+ class="form-control form-control-sm" + name="{{ field.key }}" + id="{{ field.key }}" + type="{{ field.type }}"/>
{% endif %} @@ -53,4 +53,4 @@
-{% endfor %} \ No newline at end of file +{% endfor %} diff --git a/src/View/bootstrap4/form_inline.twig b/src/View/bootstrap4/form_inline.twig index a6c48e8..0e2ce55 100644 --- a/src/View/bootstrap4/form_inline.twig +++ b/src/View/bootstrap4/form_inline.twig @@ -18,39 +18,39 @@ {% else %} {% endif %} {% elseif editable.type == 'select' %} {% endif %} @@ -79,4 +79,4 @@ type="submit">{{ editable.submit_label|raw }} - \ No newline at end of file + diff --git a/src/View/bootstrap4/groups.twig b/src/View/bootstrap4/groups.twig index 9e59678..b2da285 100644 --- a/src/View/bootstrap4/groups.twig +++ b/src/View/bootstrap4/groups.twig @@ -13,11 +13,11 @@ 'uri': '/servergroups/create/' ~ sid, 'uri_method': 'post', 'fields': [ - {'type': 'text', 'key': 'name', 'label': 'groups.create.name'|trans}, - {'type': 'select', 'key': 'type', 'options': groupTypes, 'label': 'groups.create.type'|trans}, - {'type': 'checkbox', 'key': 'copy', 'label': 'groups.create.copy'|trans}, - {'type': 'select', 'key': 'template', 'options': serverGroupsTemplate, 'label': 'groups.create.template'|trans} - ] + {'type': 'text', 'key': 'name', 'label': 'groups.create.name'|trans}, + {'type': 'select', 'key': 'type', 'options': groupTypes, 'label': 'groups.create.type'|trans}, + {'type': 'checkbox', 'key': 'copy', 'label': 'groups.create.copy'|trans}, + {'type': 'select', 'key': 'template', 'options': serverGroupsTemplate, 'label': 'groups.create.template'|trans} + ] }, ] } %} @@ -104,4 +104,4 @@ {% else %} {% include 'no_entities.twig' %} {% endif %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/src/View/bootstrap4/instance.twig b/src/View/bootstrap4/instance.twig index 7d4c98b..4633a10 100644 --- a/src/View/bootstrap4/instance.twig +++ b/src/View/bootstrap4/instance.twig @@ -1,7 +1,7 @@ {% extends 'layout.twig' %} {% block content %} -

{{ title }}

+

{{ title }}

{% include 'keyvalue.twig' with {'data': data, 'filters': [ {'key': 'instance_uptime', 'apply': 'timeInSeconds'}, @@ -28,4 +28,4 @@ {'key': 'serverinstance_serverquery_flood_ban_time', 'type': 'number', 'uri': '/instance/edit', 'uri_method': 'post', 'submit_label': ''}, ] } %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/src/View/bootstrap4/keyvalue.twig b/src/View/bootstrap4/keyvalue.twig index fdb3f87..b6faaa5 100644 --- a/src/View/bootstrap4/keyvalue.twig +++ b/src/View/bootstrap4/keyvalue.twig @@ -33,7 +33,7 @@ {% if editable is not empty %} {% include 'form_inline.twig' with {'editable': editable} %} - + {% else %} {{ item }} {% endif %} @@ -43,4 +43,4 @@ - \ No newline at end of file + diff --git a/src/View/bootstrap4/login.twig b/src/View/bootstrap4/login.twig index 67a4866..9f65cbb 100644 --- a/src/View/bootstrap4/login.twig +++ b/src/View/bootstrap4/login.twig @@ -20,7 +20,8 @@
- +
- + @@ -44,4 +46,4 @@
-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/src/View/bootstrap4/logs.twig b/src/View/bootstrap4/logs.twig index 05113dd..6b716bc 100644 --- a/src/View/bootstrap4/logs.twig +++ b/src/View/bootstrap4/logs.twig @@ -16,7 +16,7 @@ {% endif %} {% if appLog|length > 0 %} -
+

{% trans %}app_log.title{% endtrans %}

{% for line in appLog %} @@ -24,4 +24,4 @@ {% endfor %}
{% endif %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/src/View/bootstrap4/menu.twig b/src/View/bootstrap4/menu.twig index a97a412..b370b32 100644 --- a/src/View/bootstrap4/menu.twig +++ b/src/View/bootstrap4/menu.twig @@ -9,49 +9,67 @@ - \ No newline at end of file + diff --git a/src/View/bootstrap4/online.twig b/src/View/bootstrap4/online.twig index 564019b..b90203d 100644 --- a/src/View/bootstrap4/online.twig +++ b/src/View/bootstrap4/online.twig @@ -13,8 +13,8 @@
{% else %} -
- {% endif %} +
+ {% endif %} {% if data|length >0 %} {% include 'table.twig' with {'data': data, 'hiddenDependingOnAttribute': [{'key': 'client_type', 'values': ['1']}], @@ -49,4 +49,4 @@
-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/src/View/bootstrap4/online_info.twig b/src/View/bootstrap4/online_info.twig index c175aaf..a2cce72 100644 --- a/src/View/bootstrap4/online_info.twig +++ b/src/View/bootstrap4/online_info.twig @@ -12,8 +12,8 @@ 'uri': '/online/poke/' ~ sid ~ '/' ~ clid, 'uri_method': 'post', 'fields': [ - {'type': 'text', 'key': 'message', 'label': 'online.poke.message'|trans}, - ] + {'type': 'text', 'key': 'message', 'label': 'online.poke.message'|trans}, + ] }, { 'header_label': 'online.kick'|trans, @@ -21,8 +21,8 @@ 'uri': '/online/kick/' ~ sid ~ '/' ~ clid, 'uri_method': 'post', 'fields': [ - {'type': 'text', 'key': 'reason', 'label': 'online.kick.reason'|trans}, - ] + {'type': 'text', 'key': 'reason', 'label': 'online.kick.reason'|trans}, + ] }, { 'header_label': 'online.ban'|trans, @@ -30,9 +30,9 @@ 'uri': '/online/ban/' ~ sid ~ '/' ~ clid, 'uri_method': 'post', 'fields': [ - {'type': 'text', 'key': 'reason', 'label': 'online.ban.reason'|trans}, - {'type': 'number', 'key': 'time', 'label': 'online.ban.time'|trans} - ] + {'type': 'text', 'key': 'reason', 'label': 'online.ban.reason'|trans}, + {'type': 'number', 'key': 'time', 'label': 'online.ban.time'|trans} + ] }, { 'header_label': 'online.move'|trans, @@ -40,9 +40,9 @@ 'uri': '/online/move/' ~ sid ~ '/' ~ clid, 'uri_method': 'post', 'fields': [ - {'type': 'select', 'key': 'channel', 'options': channels,'label': 'online.move.channel'|trans}, - {'type': 'text', 'key': 'channel_password', 'label': 'online.move.channel_password'|trans} - ] + {'type': 'select', 'key': 'channel', 'options': channels,'label': 'online.move.channel'|trans}, + {'type': 'text', 'key': 'channel_password', 'label': 'online.move.channel_password'|trans} + ] }, { 'header_label': 'online.send'|trans, @@ -50,8 +50,8 @@ 'uri': '/online/send/' ~ sid ~ '/' ~ clid, 'uri_method': 'post', 'fields': [ - {'type': 'text', 'key': 'message', 'label': 'online.send.message'|trans}, - ] + {'type': 'text', 'key': 'message', 'label': 'online.send.message'|trans}, + ] } ] } %} @@ -78,4 +78,4 @@ {'key': 'connection_bytes_sent_total', 'apply': 'file'}, {'key': 'connection_bytes_received_total', 'apply': 'file'}, ]} %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/src/View/bootstrap4/passwords.twig b/src/View/bootstrap4/passwords.twig index 969b5be..672e7b8 100644 --- a/src/View/bootstrap4/passwords.twig +++ b/src/View/bootstrap4/passwords.twig @@ -13,12 +13,12 @@ 'uri': '/passwords/add/' ~ sid, 'uri_method': 'post', 'fields': [ - {'type': 'text', 'key': 'password', 'label': 'passwords.add.password'|trans}, - {'type': 'number', 'key': 'duration', 'label': 'passwords.add.duration'|trans}, - {'type': 'text', 'key': 'description', 'label': 'passwords.add.description'|trans}, - {'type': 'select', 'key': 'channel', 'options': channels,'label': 'passwords.add.channel'|trans}, - {'type': 'text', 'key': 'channel_password', 'label': 'passwords.add.channel_password'|trans}, - ] + {'type': 'text', 'key': 'password', 'label': 'passwords.add.password'|trans}, + {'type': 'number', 'key': 'duration', 'label': 'passwords.add.duration'|trans}, + {'type': 'text', 'key': 'description', 'label': 'passwords.add.description'|trans}, + {'type': 'select', 'key': 'channel', 'options': channels,'label': 'passwords.add.channel'|trans}, + {'type': 'text', 'key': 'channel_password', 'label': 'passwords.add.channel_password'|trans}, + ] }, ] } %} @@ -49,4 +49,4 @@ ] } %} {% endif %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/src/View/bootstrap4/server_info.twig b/src/View/bootstrap4/server_info.twig index 9602422..07193f1 100644 --- a/src/View/bootstrap4/server_info.twig +++ b/src/View/bootstrap4/server_info.twig @@ -11,15 +11,15 @@
{% trans %}server_info.h.actions{% endtrans %}
{% include 'form.twig' with { 'fields': [ - { - 'header_label': 'server_info.send'|trans, - 'label': '', - 'uri': '/servers/send/' ~ sid, - 'uri_method': 'post', - 'fields': [ + { + 'header_label': 'server_info.send'|trans, + 'label': '', + 'uri': '/servers/send/' ~ sid, + 'uri_method': 'post', + 'fields': [ {'type': 'text', 'key': 'message', 'label': 'server_info.send.message'|trans}, ] - }] + }] } %}
{% trans %}server_info.h.details{% endtrans %}
@@ -90,4 +90,4 @@ ] } %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/src/View/bootstrap4/servergroup_info.twig b/src/View/bootstrap4/servergroup_info.twig index 9293116..23e0173 100644 --- a/src/View/bootstrap4/servergroup_info.twig +++ b/src/View/bootstrap4/servergroup_info.twig @@ -12,8 +12,8 @@ 'uri': '/servergroups/add/' ~ sid ~ '/' ~ sgid, 'uri_method': 'post', 'fields': [ - {'type': 'text', 'key': 'cldbid', 'label': 'servergroup_info.add.cldbid'|trans}, - ] + {'type': 'text', 'key': 'cldbid', 'label': 'servergroup_info.add.cldbid'|trans}, + ] } ] } %} @@ -41,4 +41,4 @@ {% include 'table.twig' with {'data': permissions} %} {% endif %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/src/View/bootstrap4/servers.twig b/src/View/bootstrap4/servers.twig index c5f7ead..eef2873 100644 --- a/src/View/bootstrap4/servers.twig +++ b/src/View/bootstrap4/servers.twig @@ -5,42 +5,42 @@
{% trans %}servers.h.details{% endtrans %}
{% if data|length > 0 %} - {% include 'table.twig' with {'data': data, - 'filters': [ - {'key': 'virtualserver_uptime', 'apply': 'timeInSeconds'}, - ], - 'links': [ - {'key': 'virtualserver_port', 'uri': '/servers', 'uri_param': 'virtualserver_id'}, - {'key': 'virtualserver_clientsonline', 'uri': '/online', 'uri_param': 'virtualserver_id'} - ], - 'hiddenColumns': ['virtualserver_id', 'virtualserver_queryclientsonline', 'virtualserver_autostart', 'virtualserver_machine_id'], - 'additional_links': [ - { - 'header_label': 'servers.select'|trans, - 'label': '', - 'uri': '/servers/select', - 'uri_param': 'virtualserver_id' - }, - { - 'header_label': 'servers.start'|trans, - 'label': '', - 'uri': '/servers/start', - 'uri_param': 'virtualserver_id' - }, - { - 'header_label': 'servers.stop'|trans, - 'label': '', - 'uri': '/servers/stop', - 'uri_param': 'virtualserver_id' - }, - { - 'header_label': 'servers.delete'|trans, - 'label': '', - 'uri': '/servers/delete', - 'uri_param': 'virtualserver_id' - } - ], - } %} + {% include 'table.twig' with {'data': data, + 'filters': [ + {'key': 'virtualserver_uptime', 'apply': 'timeInSeconds'}, + ], + 'links': [ + {'key': 'virtualserver_port', 'uri': '/servers', 'uri_param': 'virtualserver_id'}, + {'key': 'virtualserver_clientsonline', 'uri': '/online', 'uri_param': 'virtualserver_id'} + ], + 'hiddenColumns': ['virtualserver_id', 'virtualserver_queryclientsonline', 'virtualserver_autostart', 'virtualserver_machine_id'], + 'additional_links': [ + { + 'header_label': 'servers.select'|trans, + 'label': '', + 'uri': '/servers/select', + 'uri_param': 'virtualserver_id' + }, + { + 'header_label': 'servers.start'|trans, + 'label': '', + 'uri': '/servers/start', + 'uri_param': 'virtualserver_id' + }, + { + 'header_label': 'servers.stop'|trans, + 'label': '', + 'uri': '/servers/stop', + 'uri_param': 'virtualserver_id' + }, + { + 'header_label': 'servers.delete'|trans, + 'label': '', + 'uri': '/servers/delete', + 'uri_param': 'virtualserver_id' + } + ], + } %} {% else %} {% include 'no_entities.twig' %} {% endif %} @@ -54,11 +54,11 @@ 'uri': '/servers/create', 'uri_method': 'post', 'fields': [ - {'type': 'text', 'key': 'VIRTUALSERVER_NAME', 'label': 'server_create.VIRTUALSERVER_NAME'|trans}, - {'type': 'text', 'key': 'VIRTUALSERVER_PASSWORD', 'label': 'server_create.VIRTUALSERVER_PASSWORD'|trans}, - {'type': 'number', 'key': 'VIRTUALSERVER_PORT', 'label': 'server_create.VIRTUALSERVER_PORT'|trans}, - {'type': 'number', 'key': 'VIRTUALSERVER_MAXCLIENTS', 'label': 'server_create.VIRTUALSERVER_MAXCLIENTS'|trans}, - ] + {'type': 'text', 'key': 'VIRTUALSERVER_NAME', 'label': 'server_create.VIRTUALSERVER_NAME'|trans}, + {'type': 'text', 'key': 'VIRTUALSERVER_PASSWORD', 'label': 'server_create.VIRTUALSERVER_PASSWORD'|trans}, + {'type': 'number', 'key': 'VIRTUALSERVER_PORT', 'label': 'server_create.VIRTUALSERVER_PORT'|trans}, + {'type': 'number', 'key': 'VIRTUALSERVER_MAXCLIENTS', 'label': 'server_create.VIRTUALSERVER_MAXCLIENTS'|trans}, + ] }] } %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/src/View/bootstrap4/snapshots.twig b/src/View/bootstrap4/snapshots.twig index e50c6cc..afc4f0d 100644 --- a/src/View/bootstrap4/snapshots.twig +++ b/src/View/bootstrap4/snapshots.twig @@ -11,21 +11,21 @@ {% include 'table.twig' with {'data': data, - 'additional_links': [ - { - 'header_label': 'snapshots.deploy'|trans, - 'label': '', - 'uri': '/snapshots/deploy/' ~ sid, - 'uri_param': 'name' - }, - { - 'header_label': 'snapshots.delete'|trans, - 'label': '', - 'uri': '/snapshots/delete/' ~ sid, - 'uri_param': 'name' - } - ], + 'additional_links': [ + { + 'header_label': 'snapshots.deploy'|trans, + 'label': '', + 'uri': '/snapshots/deploy/' ~ sid, + 'uri_param': 'name' + }, + { + 'header_label': 'snapshots.delete'|trans, + 'label': '', + 'uri': '/snapshots/delete/' ~ sid, + 'uri_param': 'name' + } + ], } %} {% endif %} -{% endblock %} \ No newline at end of file +{% endblock %}