diff --git a/CHANGELOG.md b/CHANGELOG.md index b7b5710..b5bb611 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # CHANGELOG +## 2.1.0 - 2019/08/07 +* Fixed file handling on snapshots +* Cleaned up template links +* Updated documentation +* Added application log 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 diff --git a/README.md b/README.md index b56adc9..bf8b48b 100755 --- a/README.md +++ b/README.md @@ -1,28 +1,46 @@ # README - -ts3web is a web interface for one TeamSpeak 3 Server. It's using serverquery to login. +ts3web is a free and open-source web interface for TeamSpeak 3 instances. -This web interface aims to be as simple as possible. The minimalistic approach is intentional. +The minimalistic approach of this application is intentional. -Feel free to submit pull requests if you like to help. More information are here: [https://hub.docker.com/r/varakh/ts3web](https://hub.docker.com/r/varakh/ts3web) +* Docker images available on https://hub.docker.com/r/varakh/ts3web +* Sources are hosted on https://git.myservermanager.com/alexander.schaeferdiek/ts3web -Features which are currently **not supported**: +## Limitations +Features which are currently not supported: +* upload files (only viewing and deleting) * modify permissions (only viewing) -* modify files (only viewing) -**ts3web** can be deployed in different ways. See below for more information. For each deployment type a running -TeamSpeak 3 server is a prerequisite (except for the `docker-compose.yml` type which will start also the server if -needed). +## F.A.Q + +###### There are lots of TeamSpeak 3 web interfaces out. Why should I pick ts3web? +Free, simple, stateless, easy to extend, standard bootstrap theme. + +###### 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 and include it in +your TeamSpeak application. ## Configuration - The main configuration file is the `env` file located in `config/`. There's an example file called `env.example` -which you can copy to `config/env`. Defaults will assume you're running your TeamSpeak server on `localhost` with +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 host bind this file into the container directly and just maintain the `env` file. -## Usage with docker-compose +## Deployment +The application can be deployed in different ways. See below for more information. For each deployment type a running +TeamSpeak 3 instance is a prerequisite (except for the `docker-compose.yml` type which will start also the server if +needed). +### Exposed volumes on docker images +* Snapshots are saved in `/var/www/html/application/data/snapshots`. You should create a volume for this location if +you're using docker as deployment type. +* Logs are saved in `/var/www/html/application/log` for docker containers. You should create a volume +for this location if you're using docker as deployment type. + +**Important**: Ensure that host binds have permissions set up properly. The user which is used in the docker container is `www-data` with +id `82`. If, e.g. logs are host bound, then execute `chown -R 82:82 host/path/to/log`. The same holds true for snapshots. + +### Usage with docker-compose The recommended way is to use docker-compose. The `network_mode = "host"` is required in order to show correct IP addresses of connected users. @@ -59,6 +77,7 @@ services: volumes: - ./env:/var/www/html/application/config/env - ./snapshots:/var/www/html/application/data/snapshots + - ./log:/var/www/html/application/log ports: - 127.0.0.1:8181:80 depends_on: @@ -76,16 +95,13 @@ Your TeamSpeak 3 Server will be available under `public-server-ip:9987`. The web 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. -Snapshots are saved in `/var/www/html/application/data/snapshots`. You should create a volume for this location. - -## Usage as single docker container - +### Usage as single docker container * Copy `env.example` to `env` and adjust to your needs. It's recommended to make it persistent outside of the container. * Create a container with the image, e.g. `docker run --name teamspeak_web -v ./env:/var/www/html/application/config/env -p 8181:80 varakh/ts3web:latest`. * Make sure that if teamspeak and ts3web share the same docker instance they should be put into one network and the subnet **needs be added to teamspeak's query whitelist**. * Point your browser to `8181` to see the web interface. -## Usage as native application +### Usage as native application **Prerequisite**: `php`, `composer` and probably `php-fpm` installed on the server. To install: @@ -100,7 +116,7 @@ To upgrade: * `git pull` * `composer update` -## Web server setup +### Web server setup * Example `nginx.conf` for **standalone** deployment without SSL: ``` @@ -153,7 +169,6 @@ To upgrade: * Tag the release git commit and create a new release in the VCS web interface ### 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. ``` diff --git a/config/ACL.php b/config/ACL.php index b38b9de..bc69977 100644 --- a/config/ACL.php +++ b/config/ACL.php @@ -70,6 +70,7 @@ class ACL extends \Zend\Permissions\Acl\Acl '/channels/edit/{sid}/{cid}', '/channels/delete/{sid}/{cid}', '/channels/send/{sid}/{cid}', + '/channels/files/delete/{sid}/{cid}', '/groups/{sid}', diff --git a/config/EnvConstants.php b/config/EnvConstants.php index 94a1516..2273e51 100644 --- a/config/EnvConstants.php +++ b/config/EnvConstants.php @@ -50,6 +50,11 @@ class EnvConstants */ const TEAMSPEAK_USER = "teamspeak_user"; + /** + * TeamSpeak log lines + */ + const TEAMSPEAK_LOG_LINES = "teamspeak_log_lines"; + /** * Log name */ diff --git a/config/env.example b/config/env.example index 4703e66..7276a34 100644 --- a/config/env.example +++ b/config/env.example @@ -12,6 +12,7 @@ teamspeak_host="localhost" # 'localhost' or 'name_of_docker_container' if runnin teamspeak_query_port=10011 teamspeak_user="serveradmin" teamspeak_tree_view="true" # show a tree view in the details of online clients if a server has been selected +teamspeak_log_lines=100 # show this amount of latest log lines # log log_name="ts3web" # values: all strings diff --git a/config/routes.php b/config/routes.php index 87c9eb3..a553994 100644 --- a/config/routes.php +++ b/config/routes.php @@ -294,6 +294,11 @@ $container[ChannelSendAction::class] = function ($container) { }; $app->post('/channels/send/{sid}/{cid}', ChannelSendAction::class); +$container[ChannelFilesDeleteAction::class] = function ($container) { + return new ChannelFilesDeleteAction($container); +}; +$app->get('/channels/files/delete/{sid}/{cid}', ChannelFilesDeleteAction::class); + // tokens $container[TokensAction::class] = function ($container) { return new TokensAction($container); diff --git a/data/locale/en.yml b/data/locale/en.yml index c456791..21b57fa 100644 --- a/data/locale/en.yml +++ b/data/locale/en.yml @@ -58,7 +58,7 @@ validate_valid_url: "The %field% field is required to be a valid URL." # menu menu.instance: "Instance" menu.servers: "Servers" -menu.logs: "Instance Log" +menu.logs: "Logs" menu.profile: "Profile" menu.servers.info: "Info" @@ -76,7 +76,6 @@ menu.servers.logs: "Log" # titles instance.title: "Instance" servers.title: "Servers" -logs.title: "Latest 100 Log Entries" server_info.title: "Server Info" online.title: "Online Clients" online_info.title: "Online Info" @@ -93,6 +92,9 @@ profile.title: "Profile" tokens.title: "Tokens" snapshots.title: "Snapshots" passwords.title: "Passwords" +instance_logs.title: "Instance log: latest 100 entries" +server_logs.title: "Server log: latest 100 entries" +app_log.title: "Application log" # dynamic render of key value pairs key: "Attribute" @@ -157,16 +159,23 @@ channels.create.parent: "Parent" channel_info.h.files: "Files" channel_info.h.actions: "Actions" channel_info.h.details: "Details" -channel_info.h.clients: "Clients" +channel_info.h.clients: "Current clients" channel_info.send: "Send a message" channel_info.send.message: "Message" channel_info.client: "Client" channel_info.files.delete: "Delete" - +channel_info.files.h.path: "Path" +channel_info.files.h.type: "Type" +channel_info.files.h.size: "Size" +channel_info.files.h.datetime: "Datetime" +channel_info.files.h.delete: "Delete" +channel_info.files.delete.success: "Deleted %file%." # groups groups.delete: "Delete" groups.h.servergroups: "Server Groups" groups.h.channelgroups: "Channel Groups" +groups.servergroup: "Server Group" +groups.channelgroup: "Channel Group" # groups create/copy groups.create: "Create or copy" @@ -262,6 +271,9 @@ snapshots.create: "Create a new snapshot" snapshots.h.details: "Details" snapshots.deploy: "Deploy" snapshots.delete: "Delete" +snapshots.error.create: "An error occurred when creating a snapshot. Please view the application log." +snapshots.error.delete: "An error occurred when deleting a snapshot. Please view the application log." +snapshots.error.deploy: "An error occurred when deploying a snapshot. Please view the application log." # passwords passwords.h.actions: "Actions" @@ -272,10 +284,4 @@ passwords.add.duration: "Duration (in seconds)" passwords.add.description: "Description" passwords.add.channel: "Channel (user joins)" passwords.add.channel_password: "Channelpassword" - -# about -about.header: "About %name%" -about.body: | - ts3web is a web interface for one TeamSpeak 3 Server. It's using serverquery to login. This web interface aims to be as simple as possible. The minimalistic approach is intentional. - - Feel free to submit pull requests if you like to help. More information are here: https://hub.docker.com/r/varakh/ts3web \ No newline at end of file +passwords.channel: "Channel" \ No newline at end of file diff --git a/docker/Dockerfile b/docker/Dockerfile index 8ce1622..a3a56f4 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -19,7 +19,8 @@ RUN mkdir -p /var/www/html/application/bin/ \ && chmod -R 777 /var/www/html/application/data/ \ && mkdir -p /var/www/html/application/log/ \ && touch /var/www/html/application/log/application.log \ - && chmod 777 /var/www/html/application/log/application.log + && chmod 777 /var/www/html/application/log/application.log \ + && chown -R www-data:www-data /var/www/html/application # initialize app RUN cd /var/www/html/application/ \ diff --git a/public/index.php b/public/index.php index 999a774..e1270e5 100644 --- a/public/index.php +++ b/public/index.php @@ -10,8 +10,13 @@ error_reporting(E_ALL); */ use Carbon\Carbon; +use JeremyKendall\Slim\Auth\ServiceProvider\SlimAuthProvider; use Slim\Http\Request; use Slim\Http\Response; +use Slim\Middleware\Session; +use Slim\Views\Twig; +use Slim\Views\TwigExtension; +use SlimSession\Helper; // To help the built-in PHP dev server, check if the request was actually for // something which should probably be served as a static file @@ -77,17 +82,17 @@ $container['authAdapter'] = function ($container) { $container['acl'] = function () { return new ACL(); }; -$container->register(new \JeremyKendall\Slim\Auth\ServiceProvider\SlimAuthProvider()); +$container->register(new SlimAuthProvider()); $app->add($app->getContainer()->get('slimAuthRedirectMiddleware')); // session -$app->add(new \Slim\Middleware\Session([ +$app->add(new Session([ 'name' => 'dummy_session', 'autorefresh' => true, 'lifetime' => '1 hour' ])); $container['session'] = function () { - return new \SlimSession\Helper; + return new Helper; }; // view @@ -103,8 +108,8 @@ $container['view'] = function ($container) use ($app) { $themeCacheDir = false; } - $view = new \Slim\Views\Twig($themeDir, ['cache' => $themeCacheDir]); - $view->addExtension(new \Slim\Views\TwigExtension( + $view = new Twig($themeDir, ['cache' => $themeCacheDir]); + $view->addExtension(new TwigExtension( $container['router'], $container['request']->getUri() )); diff --git a/src/Control/Actions/ChannelFilesDeleteAction.php b/src/Control/Actions/ChannelFilesDeleteAction.php new file mode 100644 index 0000000..b9ae881 --- /dev/null +++ b/src/Control/Actions/ChannelFilesDeleteAction.php @@ -0,0 +1,27 @@ +getQueryParams())) { + $file = urldecode($request->getQueryParams()['file']); + } + + $this->ts->login($this->auth->getIdentity()['user'], $this->auth->getIdentity()['password']); + $this->ts->getInstance()->selectServer($sid, 'serverId'); + + $files = [$file]; + $this->ts->getInstance()->ftDeleteFile($cid, '', $files); + $this->flash->addMessage('success', $this->translator->trans('channel_info.files.delete.success', ['%file%' => $file])); + + return $response->withRedirect('/channels/' . $sid . '/' . $cid); + } +} \ No newline at end of file diff --git a/src/Control/Actions/ChannelInfoAction.php b/src/Control/Actions/ChannelInfoAction.php index f8c1d05..a9ca161 100644 --- a/src/Control/Actions/ChannelInfoAction.php +++ b/src/Control/Actions/ChannelInfoAction.php @@ -45,12 +45,12 @@ final class ChannelInfoAction extends AbstractAction if (!empty($foundFiles)) { foreach ($foundFiles as $file) { - if ($file['type'] !== "0") { + if ($file['type'] !== "0") { // a file $file['path'] = $path; $files[] = $file; } - if ($file['type'] === "0") { + if ($file['type'] === "0") { // a directory if ($path === '/') { $newPath = $path . $file['name']; @@ -58,6 +58,9 @@ final class ChannelInfoAction extends AbstractAction $newPath = $path . '/' . $file['name']; } + $file['path'] = $path; + $files[] = $file; + $files = $this->getAllFilesIn($sid, $cid, $newPath, $files); } } diff --git a/src/Control/Actions/LogsAction.php b/src/Control/Actions/LogsAction.php index 0247c9b..7270c9b 100644 --- a/src/Control/Actions/LogsAction.php +++ b/src/Control/Actions/LogsAction.php @@ -11,17 +11,21 @@ final class LogsAction extends AbstractAction if (array_key_exists('sid', $args)) $sid = $args['sid']; $this->ts->login($this->auth->getIdentity()['user'], $this->auth->getIdentity()['password']); + + $appLog = []; if (empty($sid)) { - $dataResult = $this->ts->getInstance()->logView(100, 1, 1); + $dataResult = $this->ts->getInstance()->logView(getenv(EnvConstants::TEAMSPEAK_LOG_LINES), 1, 1); + $appLog = explode("\n", file_get_contents(BootstrapHelper::getLogFile())); } else { $selectResult = $this->ts->getInstance()->selectServer($sid, 'serverId'); - $dataResult = $this->ts->getInstance()->logView(100, 1, 0); + $dataResult = $this->ts->getInstance()->logView(getenv(EnvConstants::TEAMSPEAK_LOG_LINES), 1, 0); } // render GET $this->view->render($response, 'logs.twig', [ - 'title' => $this->translator->trans('logs.title'), - 'data' => $this->ts->getInstance()->getElement('data', $dataResult), + 'title' => empty($sid) ? $this->translator->trans('instance_logs.title') : $this->translator->trans('server_logs.title'), + 'log' => $this->ts->getInstance()->getElement('data', $dataResult), + 'appLog' => $appLog, ]); } } \ No newline at end of file diff --git a/src/Control/Actions/SnapshotCreateAction.php b/src/Control/Actions/SnapshotCreateAction.php index 6c67dd6..cd2c4eb 100644 --- a/src/Control/Actions/SnapshotCreateAction.php +++ b/src/Control/Actions/SnapshotCreateAction.php @@ -3,6 +3,7 @@ use Carbon\Carbon; use Slim\Http\Request; use Slim\Http\Response; +use Symfony\Component\Filesystem\Exception\IOException; use Symfony\Component\Filesystem\Filesystem; final class SnapshotCreateAction extends AbstractAction @@ -23,8 +24,13 @@ final class SnapshotCreateAction extends AbstractAction if ($fileSystem->exists($path)) { $this->flash->addMessage('error', $this->translator->trans('file.exists')); } else { - $fileSystem->appendToFile($path, trim($snapshotCreateResult['data'])); - $this->flash->addMessage('success', $this->translator->trans('done')); + try { + $fileSystem->appendToFile($path, trim($snapshotCreateResult['data'])); + $this->flash->addMessage('success', $this->translator->trans('done')); + } catch (IOException $e) { + $this->logger->error('Could not write to ' . $path . '. Cause: ' . $e->getMessage()); + $this->flash->addMessage('error', $this->translator->trans('snapshots.error.create')); + } } return $response->withRedirect('/snapshots/' . $sid); diff --git a/src/Control/Actions/SnapshotDeleteAction.php b/src/Control/Actions/SnapshotDeleteAction.php index a76addc..5d4d981 100644 --- a/src/Control/Actions/SnapshotDeleteAction.php +++ b/src/Control/Actions/SnapshotDeleteAction.php @@ -3,6 +3,7 @@ use Carbon\Carbon; use Slim\Http\Request; use Slim\Http\Response; +use Symfony\Component\Filesystem\Exception\IOException; use Symfony\Component\Filesystem\Filesystem; final class SnapshotDeleteAction extends AbstractAction @@ -21,15 +22,19 @@ final class SnapshotDeleteAction extends AbstractAction if (!$fileSystem->exists($path)) { $this->flash->addMessage('error', $this->translator->trans('file.notexists')); } else { - $fileSystem->remove($path); + try { + $fileSystem->remove($path); + $serverPath = FileHelper::SNAPSHOTS_PATH . DIRECTORY_SEPARATOR . $sid; - $serverPath = FileHelper::SNAPSHOTS_PATH . DIRECTORY_SEPARATOR . $sid; + if (count(FileHelper::getFiles($serverPath)) == 0) { + $fileSystem->remove($serverPath); + } - if (count(FileHelper::getFiles($serverPath)) == 0) { - $fileSystem->remove($serverPath); + $this->flash->addMessage('success', $this->translator->trans('done')); + } catch (IOException $e) { + $this->logger->error('Could not delete ' . $path . '. Cause: ' . $e->getMessage()); + $this->flash->addMessage('error', $this->translator->trans('snapshots.error.delete')); } - - $this->flash->addMessage('success', $this->translator->trans('done')); } return $response->withRedirect('/snapshots/' . $sid); diff --git a/src/Control/Actions/SnapshotDeployAction.php b/src/Control/Actions/SnapshotDeployAction.php index 123e279..2fe8ea8 100644 --- a/src/Control/Actions/SnapshotDeployAction.php +++ b/src/Control/Actions/SnapshotDeployAction.php @@ -1,6 +1,5 @@ exists($path)) { $this->flash->addMessage('error', $this->translator->trans('file.notexists')); } else { - $snapshotData = file_get_contents($path); - $this->ts->getInstance()->serverSnapshotDeploy($snapshotData, true); - $this->flash->addMessage('success', $this->translator->trans('done')); + try { + $snapshotData = file_get_contents($path); + $this->ts->getInstance()->serverSnapshotDeploy($snapshotData, true); + $this->flash->addMessage('success', $this->translator->trans('done')); + } catch (Exception $e) { + $this->logger->error('Could not deploy ' . $path . '. Cause: ' . $e->getMessage()); + $this->flash->addMessage('error', $this->translator->trans('snapshots.error.deploy')); + } } return $response->withRedirect('/snapshots/' . $sid); diff --git a/src/Util/Auth/TSAuthAdapter.php b/src/Util/Auth/TSAuthAdapter.php index ff8dec2..add8aa6 100644 --- a/src/Util/Auth/TSAuthAdapter.php +++ b/src/Util/Auth/TSAuthAdapter.php @@ -45,7 +45,7 @@ class TSAuthAdapter extends \Zend\Authentication\Adapter\AbstractAdapter $password = $this->getCredential(); if ($this->ts->login($user, $password)) { - $this->logger->info(sprintf('Authenticated as %s', $user)); + $this->logger->debug(sprintf('Authenticated as %s', $user)); $user = ['identity' => $user, 'user' => $user, 'password'=> $password, 'role' => ACL::ACL_DEFAULT_ROLE_ADMIN]; return new Result(Result::SUCCESS, $user, array()); diff --git a/src/Util/BootstrapHelper.php b/src/Util/BootstrapHelper.php index f18ea24..2ea011b 100644 --- a/src/Util/BootstrapHelper.php +++ b/src/Util/BootstrapHelper.php @@ -5,6 +5,8 @@ use Monolog\Handler\ErrorLogHandler; use Monolog\Handler\StreamHandler; use Monolog\Logger; use Monolog\Processor\UidProcessor; +use Symfony\Component\Filesystem\Exception\IOException; +use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Translation\Loader\YamlFileLoader; use Symfony\Component\Translation\MessageSelector; use Symfony\Component\Translation\Translator; @@ -34,6 +36,7 @@ class BootstrapHelper EnvConstants::TEAMSPEAK_HOST, EnvConstants::TEAMSPEAK_QUERY_PORT, EnvConstants::TEAMSPEAK_USER, + EnvConstants::TEAMSPEAK_LOG_LINES, EnvConstants::LOG_NAME, EnvConstants::LOG_LEVEL ]); @@ -109,9 +112,43 @@ class BootstrapHelper $logger->pushProcessor(new UidProcessor()); $logger->pushHandler(new ErrorLogHandler(NULL, $logLevelTranslated)); - $logPath = __DIR__ . DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR . 'log' . DIRECTORY_SEPARATOR . 'application.log'; - $logger->pushHandler(new StreamHandler($logPath, $logLevelTranslated)); + $dir = self::getLogDir(); + $path = self::getLogFile(); + + try { + $fileSystem = new Filesystem(); + + if (!$fileSystem->exists($dir)) { + $fileSystem->mkdir($dir); + } + + if (!$fileSystem->exists($path)) { + $fileSystem->touch($path); + } + } catch (IOException $e) { + die('Could not create logger. Cause: ' . $e->getMessage() . '. Trace: ' . $e->getTraceAsString()); + } + + $logger->pushHandler(new StreamHandler($path, $logLevelTranslated)); return $logger; } + + /** + * Returns log dir + * + * @return string + */ + public static function getLogDir() { + return __DIR__ . DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR . 'log'; + } + + /** + * Returns log file + * + * @return string + */ + public static function getLogFile() { + return self::getLogDir() . DIRECTORY_SEPARATOR . 'application.log'; + } } \ No newline at end of file diff --git a/src/View/bootstrap4/about.twig b/src/View/bootstrap4/about.twig deleted file mode 100644 index 5dff821..0000000 --- a/src/View/bootstrap4/about.twig +++ /dev/null @@ -1,15 +0,0 @@ -