First release of 2.0! :D

This commit is contained in:
Wruczek
2018-12-27 18:59:49 +01:00
parent 7396a76816
commit 628af52b54
293 changed files with 12641 additions and 3 deletions

0
src/private/cache/.gitkeep vendored Normal file
View File

View File

@@ -0,0 +1,143 @@
<?php
namespace Wruczek\TSWebsite;
use Wruczek\PhpFileCache\PhpFileCache;
use Wruczek\TSWebsite\Utils\TeamSpeakUtils;
/**
* Class used to generate an array with statuses of admins, used
* later to power the "Admin status" feature in the sidebar
*/
class AdminStatus {
use Utils\SingletonTait;
private $cache;
const STATUS_STYLE_GROUPED = 1;
const STATUS_STYLE_GROUPED_HIDE_EMPTY_GROUPS = 2;
const STATUS_STYLE_LIST = 3;
const STATUS_STYLE_LIST_ONLINE_FIRST = 4;
public function __construct() {
$this->cache = new PhpFileCache(__CACHE_DIR, "adminstatus");
}
public function getCachedAdminClients(array $adminGroups) {
return $this->cache->refreshIfExpired("adminstatus", function () use ($adminGroups) {
if(TeamSpeakUtils::i()->checkTSConnection()) {
try {
$nodeServer = TeamSpeakUtils::i()->getTSNodeServer();
$clients = [];
foreach ($adminGroups as $groupId) {
$clients[$groupId] = $nodeServer->serverGroupClientList($groupId);
}
return $clients;
} catch (\TeamSpeak3_Exception $e) {
TeamSpeakUtils::i()->addExceptionToExceptionsList($e);
}
}
return null;
}, Config::get("cache_adminstatus"));
}
public function getStatus(array $adminGroups, $format, $hideOffline = false, array $ignoredUsersDbids = []) {
if ($format !== self::STATUS_STYLE_GROUPED
&& $format !== self::STATUS_STYLE_GROUPED_HIDE_EMPTY_GROUPS
&& $format !== self::STATUS_STYLE_LIST
&& $format !== self::STATUS_STYLE_LIST_ONLINE_FIRST) {
throw new \InvalidArgumentException("Invalid format specified");
}
$serverGroupList = CacheManager::i()->getServerGroupList();
$adminStatus = $this->getCachedAdminClients($adminGroups);
$data = [];
if ($serverGroupList === null || $adminStatus === null) {
// if we dont have a server group list or the
// cached admin clients, we cannot do anything
// (its probably a connection issue)
// false means "data problem"
return false;
}
foreach ($adminGroups as $adminGroupId) {
// try to get that group from server group list
$serverGroup = @$serverGroupList[$adminGroupId];
// skip if we cant get that group
if ($serverGroup === null) {
continue;
}
$groupClients = [];
$cachedClients = $adminStatus[$adminGroupId];
foreach ($cachedClients as $client) {
$cldbid = $client["cldbid"];
if (in_array($cldbid, $ignoredUsersDbids)) {
continue;
}
$onlineClient = CacheManager::i()->getClient($cldbid);
if ($format === self::STATUS_STYLE_LIST_ONLINE_FIRST) {
// in list style, inside of data we have
// 2 additional arrays: online and offline
// we add online users to online, and offline users to offline
// at the end, we combine both arrays
$data[$onlineClient ? "online" : "offline"][] = [
"client" => $onlineClient ?: $client,
"group" => $serverGroup
];
} else {
// when dealing with other formats...
if ($onlineClient !== null) {
// if online, add everything from the $onlineClient
$groupClients[] = $onlineClient;
} else if (!$hideOffline) {
// if offline, we only have info from $client returned by the server group list
$groupClients[] = $client;
}
}
}
// sort clients, always show online first
if ($format !== self::STATUS_STYLE_LIST_ONLINE_FIRST) {
uasort($groupClients, function ($a, $b) {
if (isset($a["clid"], $b["clid"])) {
return 0;
}
return isset($a["clid"]) ? -1 : 1;
});
// add sorted data to our results
$data[$adminGroupId] = $serverGroup + ["clients" => $groupClients];
}
}
// in the online first format...
if ($format === self::STATUS_STYLE_LIST_ONLINE_FIRST) {
if ($hideOffline) {
// we dont care about the offline users if hideOffline is true
$data = @$data["online"];
} else {
// ...combine online and offline arrays
// see line #89 for explanation
// online users go before offline users
// NOTE: we are using array_merge instead of the "+"
// operator, because we have default numeric keys.
// Using "+" would make us loose some data
$data = array_merge(@$data["online"], @$data["offline"]);
}
}
return ["format" => $format, "data" => $data];
}
}

View File

@@ -0,0 +1,136 @@
<?php
namespace Wruczek\TSWebsite;
use Wruczek\TSWebsite\Utils\TeamSpeakUtils;
class Assigner {
public static function getAssignerConfig() {
return Config::get("assignerconfig");
}
public static function getAssignerArray() {
$assignerConfig = self::getAssignerConfig();
if (empty($assignerConfig)) {
return []; // not configured, do not get more data and just return an empty array
}
$userGroups = Auth::getUserServerGroupIds();
$serverGroups = CacheManager::i()->getServerGroupList();
foreach ($assignerConfig as $index => $category) {
$assignedCount = 0;
$groups = [];
foreach ($category["groups"] as $sgid) {
$serverGroup = @$serverGroups[$sgid];
if ($serverGroup === null) {
continue;
}
$assigned = in_array($sgid, $userGroups);
$groups[$sgid] = $serverGroup + ["assigned" => $assigned];
if ($assigned) {
$assignedCount++;
}
}
$assignerConfig[$index]["assignedCount"] = $assignedCount;
$assignerConfig[$index]["groups"] = $groups;
}
return $assignerConfig;
}
public static function isAssignable($sgid) {
foreach (self::getAssignerConfig() as $category) {
if (in_array($sgid, $category["groups"], true)) {
return true;
}
}
return false;
}
/**
* Attempts to change user groups with the provided $newGroups
* @param array $newGroups array of new SGIDs that the user should
* have. any assigner groups not present in this array will
* be removed from the user
* @return int status code that shows result of the group change.
* 0 - groups have been successfully changed
* 1 - no change between current groups and submitted groups
* 2 - group assigner is not configured, stopping
* 3 - reached category group limit
* @throws UserNotAuthenticatedException
* @throws \TeamSpeak3_Exception
*/
public static function changeGroups($newGroups) {
$assignerConfig = self::getAssignerConfig();
if (empty($assignerConfig)) {
return 2; // if the assigner is not configured, stop there
}
$userGroups = Auth::getUserServerGroupIds();
$groupsToAdd = [];
$groupsToRemove = [];
foreach ($assignerConfig as $config) {
$groupsToAssign = 0;
foreach ($config["groups"] as $group) {
// true if the $group is currently assigned to the user
$isAssigned = in_array($group, $userGroups);
// true if the user wants to be added to $group
$wantToAssign = in_array($group, $newGroups);
// if the group is already assigned, or is to be assigned,
// check for the max group limit in this category:
// - add 1 to the "groupsToAssign", and
// - check if its bigger than the max limit
if ($wantToAssign && (++$groupsToAssign > $config["max"])) {
return 3;
}
// ADD GROUP if the group is not assigned, but the user wants to be added
if (!$isAssigned && $wantToAssign) {
// ok, seems like we can add this group!
$groupsToAdd[] = $group;
}
// REMOVE GROUP if the group is currently assigned, but the user does not want to be inside it
if ($isAssigned && !$wantToAssign) {
$groupsToRemove[] = $group;
}
}
}
// empty arrays - nothing to change
if (!$groupsToAdd && !$groupsToRemove) {
return 1;
}
// finally, add or remove the groups
$tsServer = TeamSpeakUtils::i()->getTSNodeServer();
foreach ($groupsToAdd as $sgid) {
try {
$tsServer->serverGroupClientAdd($sgid, Auth::getCldbid());
} catch (\TeamSpeak3_Exception $e) {} // TODO log it to the admin panel?
}
foreach ($groupsToRemove as $sgid) {
try {
$tsServer->serverGroupClientDel($sgid, Auth::getCldbid());
} catch (\TeamSpeak3_Exception $e) {} // TODO log it to the admin panel?
}
return 0;
}
}

288
src/private/php/Auth.php Normal file
View File

@@ -0,0 +1,288 @@
<?php
namespace Wruczek\TSWebsite;
use function array_filter;
use function array_keys;
use Exception;
use function in_array;
use function time;
use function var_dump;
use Wruczek\PhpFileCache\PhpFileCache;
use Wruczek\TSWebsite\Utils\Language\LanguageUtils;
use Wruczek\TSWebsite\Utils\TeamSpeakUtils;
use Wruczek\TSWebsite\Utils\Utils;
class Auth {
public static function isLoggedIn() {
return !empty(self::getCldbid()) && !empty(self::getUid());
}
public static function getUid() {
return @$_SESSION["tsuser"]["uid"];
}
public static function getCldbid() {
return @$_SESSION["tsuser"]["cldbid"];
}
public static function getNickname() {
return @$_SESSION["tsuser"]["nickname"];
}
public static function logout() {
unset($_SESSION["tsuser"]);
}
public static function getTsUsersByIp($ip = null) {
if ($ip === null) {
$ip = Utils::getClientIp();
}
$clientList = CacheManager::i()->getClientList();
if ($clientList === null) {
return null;
}
$ret = [];
foreach ($clientList as $client) {
// Skip query clients
if ($client["client_type"]) continue;
if ((string) $client["connection_client_ip"] === $ip) {
$ret[$client["client_database_id"]] = (string) $client["client_nickname"];
}
}
return $ret;
}
/**
* Returns true if the $cldbid is connected with the same IP address as $ip
* @param $cldbid int cldbid to check
* @param $ip string optional, defaults to Utils::getClientIp
* @return bool true if the cldbid have the same IP address as $ip
*/
public static function checkClientIp($cldbid, $ip = null) {
if ($ip === null) {
$ip = Utils::getClientIp();
}
$users = self::getTsUsersByIp($ip);
if ($users === null) {
return false;
}
return array_key_exists($cldbid, $users);
}
/**
* Tries to generate and send confirmation code to the TS client
* @param $cldbid int
* @param $poke bool|null true = poke user, false = send a message, null = default value from config
* @return string|null|false Returns code as string on success, null when
* client cannot be found and false when other error occurs.
*/
public static function generateConfirmationCode($cldbid, $poke = null) {
if ($poke === null) {
$poke = (bool) Config::get("loginpokeclient");
}
if (TeamSpeakUtils::i()->checkTSConnection()) {
try {
$client = TeamSpeakUtils::i()->getTSNodeServer()->clientGetByDbid($cldbid);
$code = (string) mt_rand(100000, 999999); // TODO: replace it with a CSPRNG
$msg = LanguageUtils::tl("LOGIN_CONFIRMATION_CODE", $code);
if ($poke) {
$client->poke(mb_substr($msg, 0, 100)); // Max 100 characters for pokes
} else {
$client->message(mb_substr($msg, 0, 1024)); // Max 1024 characters for messages
}
self::saveConfirmationCode($cldbid, $code);
return $code;
} catch (\TeamSpeak3_Adapter_ServerQuery_Exception $e) {
if ($e->getCode() === 512) {
return null;
}
}
}
return false;
}
/**
* Checks if there is already a confirmation code cached for this user.
* Returns the code of found, otherwise NULL.
* @param $cldbid int
* @return string|null Confirmation code, null if not found
*/
public static function getConfirmationCode($cldbid) {
return (new PhpFileCache(__CACHE_DIR, "confirmationcodes"))->retrieve("c_$cldbid");
}
/**
* Saves confirmation code for the user
* @param $cldbid int
* @param $code string
*/
public static function saveConfirmationCode($cldbid, $code) {
(new PhpFileCache(__CACHE_DIR, "confirmationcodes"))->store("c_$cldbid", $code, (int) Config::get("cache_logincode"));
}
/**
* Deletes confirmation code for the user
* @param $cldbid int
*/
public static function deleteConfirmationCode($cldbid) {
(new PhpFileCache(__CACHE_DIR, "confirmationcodes"))->eraseKey("c_$cldbid");
}
/**
* Checks confirmation code and logs user in if its correct.
* @param $cldbid
* @param $userCode
* @return bool true if authentication was successful
*/
public static function checkCodeAndLogin($cldbid, $userCode) {
if (!is_int($cldbid)) {
throw new \InvalidArgumentException("cldbid must be an int");
}
$codeCheck = self::checkConfirmationCode($cldbid, $userCode);
if ($codeCheck !== true) {
return false;
}
$login = self::loginUser($cldbid);
if ($login) {
self::deleteConfirmationCode($cldbid);
return true;
}
return false;
}
/**
* Checks if the provided confirmation code matches the saved one and returns true on success.
* @param $cldbid int
* @param $userCode string
* @return bool
*/
public static function checkConfirmationCode($cldbid, $userCode) {
$knownCode = self::getConfirmationCode($cldbid);
if ($knownCode === null) {
return false;
}
return hash_equals($knownCode, $userCode);
}
/**
* Logins user to this account
* @param $cldbid int
* @return bool true on success, false otherwise
*/
public static function loginUser($cldbid) {
$clientList = CacheManager::i()->getClientList();
foreach ($clientList as $client) {
if ($client["client_database_id"] === $cldbid) {
$_SESSION["tsuser"]["uid"] = (string) $client["client_unique_identifier"];
$_SESSION["tsuser"]["cldbid"] = $client["client_database_id"];
$_SESSION["tsuser"]["nickname"] = (string) $client["client_nickname"];
return true;
}
}
return false;
}
public static function invalidateUserGroupCache() {
unset($_SESSION["tsuser"]["servergroups"]);
}
/**
* Returns an array containing cached array with group IDs of the user
* @param $cacheTime int for how long we should cache the IDs?
* @return array array with server group IDs of the user
* @throws UserNotAuthenticatedException if user is not logged in
* @throws \TeamSpeak3_Exception when we cannot get data from the TS server
*/
public static function getUserServerGroupIds($cacheTime = 60) {
if (!self::isLoggedIn()) {
throw new UserNotAuthenticatedException("User is not authenticated");
}
// Check if we data is already cached and if we can use it
if (isset($_SESSION["tsuser"]["servergroups"])) {
$cached = $_SESSION["tsuser"]["servergroups"];
// Calculate how old is the cached data (in seconds)
$secondsSinceCache = time() - $cached["timestamp"];
// If we dont need to refresh it, return the data
if ($secondsSinceCache <= $cacheTime) {
return $cached["data"];
}
}
// If we end up here, it means we need to refresh the cache
if (!TeamSpeakUtils::i()->checkTSConnection()) {
throw new \TeamSpeak3_Exception("Cannot connect to the TeamSpeak server");
}
try {
$tsServer = TeamSpeakUtils::i()->getTSNodeServer();
// Get all user groups from TS server
$serverGroups = $tsServer->clientGetServerGroupsByDbid(self::getCldbid());
} catch (\TeamSpeak3_Exception $e) {
TeamSpeakUtils::i()->addExceptionToExceptionsList($e);
throw $e;
}
// Since the array in indexed with server group ID's, we can just separate the keys
// That gives us an array with ID's if user groups
$serverGroupIds = array_keys($serverGroups);
// Cache it in session with current time for later cachebusting
$_SESSION["tsuser"]["servergroups"] = [
"timestamp" => time(),
"data" => $serverGroupIds
];
return $serverGroupIds;
}
/**
* Combines sever group ID's from getUserServerGroupIds() with cached
* server group list and returns full array with user's server groups
* @see self::getUserServerGroupIds
* @param int $cacheTime value passed to getUserServerGroupIds()
* @return array array with user server groups
* @throws UserNotAuthenticatedException if user is not logged in
* @throws \TeamSpeak3_Exception when we cannot get data from the TS server
*/
public static function getUserServerGroups($cacheTime = 60) {
$serverGroupIds = self::getUserServerGroupIds($cacheTime);
$serverGroups = CacheManager::i()->getServerGroupList();
$resut = array_filter($serverGroups, function ($serverGroup) use ($serverGroupIds) {
// If the group id is inside $serverGroupIds,
// keep that group. Otherwise filter it out.
return in_array($serverGroup["sgid"], $serverGroupIds);
});
return $resut;
}
}
class UserNotAuthenticatedException extends \Exception {}

View File

@@ -0,0 +1,160 @@
<?php
namespace Wruczek\TSWebsite;
use Wruczek\PhpFileCache\PhpFileCache;
use Wruczek\TSWebsite\Utils\SingletonTait;
use Wruczek\TSWebsite\Utils\TeamSpeakUtils;
class CacheManager {
use SingletonTait;
private $cache;
private $serverInfo;
private $banList;
private $clientList;
private $channelList;
private $serverGroupList;
private $channelGroupList;
public function __construct() {
$this->cache = new PhpFileCache(__CACHE_DIR, "cachemanager");
}
private function tsNodeObjectToArray(array $object, $extendInfo = false) {
if (!is_array($object)) {
throw new \Exception("object must be a array filled with TeamSpeak3_Node_Abstract objects");
}
$data = [];
foreach ($object as $obj) {
$data[$obj->getId()] = $obj->getInfo($extendInfo);
}
return $data;
}
public function getServerInfo($meta = false) {
if ($this->serverInfo) {
return $this->serverInfo;
}
return $this->serverInfo = $this->cache->refreshIfExpired("serverinfo", function () {
if(TeamSpeakUtils::i()->checkTSConnection()) {
try {
return TeamSpeakUtils::i()->getTSNodeServer()->getInfo();
} catch (\TeamSpeak3_Exception $e) {
TeamSpeakUtils::i()->addExceptionToExceptionsList($e);
}
}
return null;
}, Config::get("cache_serverinfo"), $meta);
}
public function getBanList($meta = false) {
if ($this->banList) {
return $this->banList;
}
return $this->banList = $this->cache->refreshIfExpired("banlist", function () {
if(TeamSpeakUtils::i()->checkTSConnection()) {
try {
return TeamSpeakUtils::i()->getTSNodeServer()->banList();
} catch (\TeamSpeak3_Exception $e) {
if ($e->getCode() === 1281) { // database empty result set
return [];
}
TeamSpeakUtils::i()->addExceptionToExceptionsList($e);
}
}
return null;
}, Config::get("cache_banlist"), $meta);
}
public function getClientList($meta = false) {
if ($this->clientList) {
return $this->clientList;
}
return $this->clientList = $this->cache->refreshIfExpired("clientlist", function () {
if(TeamSpeakUtils::i()->checkTSConnection()) {
try {
return $this->tsNodeObjectToArray(TeamSpeakUtils::i()->getTSNodeServer()->clientList());
} catch (\TeamSpeak3_Exception $e) {
TeamSpeakUtils::i()->addExceptionToExceptionsList($e);
}
}
return null;
}, Config::get("cache_clientlist"), $meta); // Lower cache time because of login system
}
public function getClient($cldbid) {
$clients = $this->getClientList();
if ($clients === null) {
return null;
}
foreach ($clients as $client) {
if ($client["client_database_id"] === $cldbid) {
return $client;
}
}
return null;
}
public function getChannelList($meta = false) {
if ($this->channelList) {
return $this->channelList;
}
return $this->channelList = $this->cache->refreshIfExpired("channellist", function () {
if(TeamSpeakUtils::i()->checkTSConnection()) {
try {
return $this->tsNodeObjectToArray(TeamSpeakUtils::i()->getTSNodeServer()->channelList());
} catch (\TeamSpeak3_Exception $e) {
TeamSpeakUtils::i()->addExceptionToExceptionsList($e);
}
}
return null;
}, Config::get("cache_channelist"), $meta);
}
public function getServerGroupList($meta = false) {
if ($this->serverGroupList) {
return $this->serverGroupList;
}
return $this->serverGroupList = $this->cache->refreshIfExpired("servergrouplist", function () {
if(TeamSpeakUtils::i()->checkTSConnection()) {
try {
return $this->tsNodeObjectToArray(TeamSpeakUtils::i()->getTSNodeServer()->serverGroupList());
} catch (\TeamSpeak3_Exception $e) {
TeamSpeakUtils::i()->addExceptionToExceptionsList($e);
}
}
return null;
}, Config::get("cache_servergroups"), $meta);
}
public function getChannelGroupList($meta = false) {
if ($this->channelGroupList) {
return $this->channelGroupList;
}
return $this->channelGroupList = $this->cache->refreshIfExpired("channelgrouplist", function () {
if(TeamSpeakUtils::i()->checkTSConnection()) {
try {
return $this->tsNodeObjectToArray(TeamSpeakUtils::i()->getTSNodeServer()->channelGroupList());
} catch (\TeamSpeak3_Exception $e) {
TeamSpeakUtils::i()->addExceptionToExceptionsList($e);
}
}
return null;
}, Config::get("cache_channelgroups"), $meta);
}
}

179
src/private/php/Config.php Normal file
View File

@@ -0,0 +1,179 @@
<?php
namespace Wruczek\TSWebsite;
use Wruczek\PhpFileCache\PhpFileCache;
use Wruczek\TSWebsite\Utils\DatabaseUtils;
use Wruczek\TSWebsite\Utils\TemplateUtils;
/**
* Class Config
* @package Wruczek\TSWebsite\Utils
* @author Wruczek 2017
*/
class Config {
use Utils\SingletonTait;
protected $databaseConfig;
protected $config;
protected $cache;
public static function get($key, $default = null) {
return self::i()->getValue($key, $default);
}
private function __construct() {
if(!defined("__CONFIG_FILE")) {
die("__CONFIG_FILE is not defined");
}
$config = require __CONFIG_FILE;
if($config === null || !is_array($config)) {
die("Cannot read the db config file! (" . __CONFIG_FILE . ")");
}
$this->databaseConfig = $config;
$this->cache = new PhpFileCache(__CACHE_DIR, "config");
}
/**
* Returns config used to connect to the database
* @return array Config as an array
*/
public function getDatabaseConfig() {
return $this->databaseConfig;
}
/**
* Returns configuration saved in database
* @return array Config file as an key => value array
*/
public function getConfig() {
if($this->config === null) {
$this->config = $this->cache->refreshIfExpired("config", function () {
try {
$db = DatabaseUtils::i()->getDb();
$data = $db->select("config", ["identifier", "type", "value"]);
} catch (\Exception $e) {
TemplateUtils::i()->renderErrorTemplate("DB error", "Cannot get config data from database", $e->getMessage());
exit;
}
if(!empty($db->error()[1])) {
return null;
}
$cfg = [];
foreach ($data as $item) {
$key = $item["identifier"];
$type = $item["type"];
$val = $item["value"];
switch ($type) {
case "STRING":
$val = (string) $val;
break;
case "INT":
$val = (int) $val;
break;
case "FLOAT":
$val = (float) $val;
break;
case "BOOL":
$val = strtolower($val) === "true";
break;
case "JSON":
$json = json_decode((string) $val, true);
if ($json === false) {
throw new \Exception("Error loading config from db: cannot parse JSON from $key");
}
$val = $json;
break;
default:
throw new \Exception("Error loading config from db: unrecognised data type $type");
}
$cfg[$key] = $val;
}
return $cfg;
}, 60);
}
return $this->config;
}
/**
* Resets current config cache
*/
public function clearConfigCache() {
$this->config = null;
$this->cache->eraseKey("config");
}
/**
* Returns value associated with given key
* @param string $key
* @param null $default
* @return mixed value Returns string with
* the value if key exists, null otherwise
*/
public function getValue($key, $default = null) {
return isset($this->getConfig()[$key]) ? $this->getConfig()[$key] : $default;
}
/**
* Saves key => value combo in config table
* @param string $key
* @param string|int|float|bool|array|object $value
* @return bool true on success, false otherwise
* @throws \Exception
*/
public function setValue($key, $value) {
$db = DatabaseUtils::i()->getDb();
switch (gettype($value)) {
case "string":
$type = "STRING";
break;
case "integer":
$type = "INT";
break;
case "double":
$type = "FLOAT";
break;
case "boolean":
$type = "BOOL";
$value = $value ? "true" : "false";
break;
case "array":
case "object":
$type = "JSON";
$value = json_encode($value);
break;
default:
throw new \Exception("Unsupported data type");
}
$data = [
"identifier" => $key,
"type" => $type,
"value" => $value
];
if($db->has("config", ["identifier" => $key])) {
$ret = $db->update("config", $data, ["identifier" => $key]);
} else {
$ret = $db->insert("config", $data);
}
$this->clearConfigCache();
return $ret;
}
}

View File

@@ -0,0 +1,106 @@
<?php
namespace Wruczek\TSWebsite\News;
use function mb_substr;
use function time;
use Wruczek\TSWebsite\Utils\DatabaseUtils;
/**
* Class DefaultNewsStore.
* Reads news from the database, they might be added via the admin panel.
* @package Wruczek\TSWebsite\News
*/
class DefaultNewsStore implements INewsStore {
private $db;
private $newsTable = "news";
public function __construct() {
$this->db = DatabaseUtils::i()->getDb();
}
public function getNewsList($limit, $offset = null) {
if ($limit !== null && !\is_int($limit)) {
throw new \InvalidArgumentException("limit must be an integer");
}
if ($offset !== null && !\is_int($offset)) {
throw new \InvalidArgumentException("offset must be an integer");
}
$options = []; // Medoo: [$offset, $limit]
// If we have both limit and offset
if ($limit !== null && $offset !== null) {
$options = [$offset, $limit];
} else if ($limit !== null) { // if we have only limit
$options = $limit;
}
$data = $this->db->select($this->newsTable, "*", [
"ORDER" => ["added" => "DESC"],
"LIMIT" => $options
]);
$newsList = [];
foreach ($data as $row) {
$newsId = $row["newsid"];
$newsList[$newsId] = [
"newsId" => $newsId,
"title" => $row["title"],
// There is no separate news pages for now, so we show the entire content as the description
// "description" => mb_substr($row["content"], 0, 200),
"description" => $row["content"],
"added" => $row["added"],
"edited" => $row["edited"],
// "link" => "news.php?id=$newsId",
"external" => false,
];
}
return $newsList;
}
public function getNews($newsId) {
return $this->db->get($this->newsTable, "*", [
"newsId" => $newsId,
]);
}
public function getNewsCount() {
return $this->db->count($this->newsTable);
}
public function addNews($title, $content, $addDate = null, $editDate = null) {
if ($addDate === null) {
$addDate = time();
}
$this->db->insert($this->newsTable, [
"title" => $title,
"added" => $addDate,
"edited" => $editDate,
"content" => $content,
]);
return $this->db->id();
}
public function editNews($newsId, $title = null, $content = null, $addDate = null, $editDate = null) {
$data = [];
if ($title !== null) $data["title"] = $title;
if ($content !== null) $data["content"] = $content;
if ($addDate !== null) $data["added"] = $addDate;
if ($editDate !== null) $data["edited"] = $editDate;
$update = $this->db->update($this->newsTable, $data, [
"newsId" => $newsId
]);
return $update->rowCount() !== 0;
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace Wruczek\TSWebsite\News;
interface INewsStore {
/**
* Returns array with news, sorted by the latest first. Format:
* <code>
* [
* $newsId = [
* "newsId" => int,
* "title" => "title of the news",
* "description" => "short description",
* "added" => int creation timestamp,
* "edited" => int last edit timestamp, null if never edited,
* "link" => "clicking on the news header will redirect here",
* "external" => true|false if true, the link will open in new tab
* ]
* ]
* </code>
* @param int $limit Number of results to return
* @param int $offset From where to start the list
* @return array array with the news
* @throws \Exception when we cannot get the news
*/
public function getNewsList($limit, $offset = null);
/**
* Returns full information about this particular news
* @param int $newsId
* @return array|null array with the news details or null if news was not found
*/
public function getNews($newsId);
/**
* Returns a number of news in the database
* @return int
*/
public function getNewsCount();
/**
* Adds a new news and return its new id
* @param string $title
* @param string $content
* @param null|int $addDate if null, the implementation will use the current timestamp
* @param null|int $editDate
* @return int newsId of the inserted news
*/
public function addNews($title, $content, $addDate = null, $editDate = null);
/**
* Edit the news selected by $newsId. All parameters are optional, and only the provided ones will be changed
* @param int $newsId
* @param string|null $title
* @param string|null $content
* @param int|null $addDate
* @param int|null $editDate
*/
public function editNews($newsId, $title = null, $content = null, $addDate = null, $editDate = null);
}

View File

@@ -0,0 +1,105 @@
<?php
namespace Wruczek\TSWebsite;
use Wruczek\PhpFileCache\PhpFileCache;
use Wruczek\TSWebsite\Utils\TeamSpeakUtils;
class ServerIconCache {
private static $iconsCacheDir = __CACHE_DIR . "/servericons";
public static function getIconBytes($iconId) {
if (!is_numeric($iconId)) {
throw new \Exception("iconid need to be an int or numeric string");
}
$file = @file_get_contents(self::$iconsCacheDir . "/" . $iconId);
return $file === false ? null : $file; // return null on error
}
public static function hasIcon($iconId) {
return self::getIconBytes($iconId) !== null;
}
public static function syncIcons() {
if (!file_exists(self::$iconsCacheDir) && !mkdir(self::$iconsCacheDir, true)) {
throw new \Exception("Cannot create icons cache directory at " . self::$iconsCacheDir);
}
foreach (self::ftDownloadIconList() as $iconElement) {
$iconName = (string) $iconElement["name"];
$iconId = self::iconIdFromName($iconName);
if (self::hasIcon($iconId)) {
continue;
}
try {
$iconData = self::downloadIcon($iconId);
} catch (\Exception $e) {
trigger_error("Cannot download icon $iconId");
continue;
}
$createFile = file_put_contents(self::$iconsCacheDir . "/$iconId", $iconData);
if ($createFile === false) {
throw new \Exception("Cannot create icon file for icon $iconId, check folder permissions");
}
}
}
public static function syncIfNeeded() {
(new PhpFileCache(__CACHE_DIR))->refreshIfExpired("lasticonsync", function () {
// Do not sync icons if we cannot connect the the TS server
if (!TeamSpeakUtils::i()->checkTSConnection()) {
return null;
}
ServerIconCache::syncIcons();
return true;
}, Config::get("cache_servericons", 300));
}
public static function isLocal($iconId) {
return $iconId > 0 && $iconId < 1000;
}
public static function iconIdFromName($iconName) {
return substr($iconName, 5);
}
/**
* Converts a 32-bit int to a unsigned int
* 32-bit int is obtained for example from the servergroup details (iconid)
* Returned value can be used with ServerIconCache's methods like getIconBytes
* @see http://yat.qa/resources/tools/ (Icon Filename Tool)
* @param $iconId int
* @return int
*/
public static function unsignIcon($iconId) {
if (!is_int($iconId)) {
throw new \InvalidArgumentException("iconId must be an integer");
}
return ($iconId < 0) ? (2 ** 32) - ($iconId * -1) : $iconId;
}
public static function downloadIcon($iconId) {
return TeamSpeakUtils::i()->ftDownloadFile("/icon_$iconId");
}
public static function ftDownloadIconList() {
try {
return TeamSpeakUtils::i()->getTSNodeServer()->channelFileList(0, "", "/icons/");
} catch (\TeamSpeak3_Adapter_ServerQuery_Exception $e) {
if ($e->getCode() === 1281) { // database empty result set
return [];
}
throw $e;
}
}
}

View File

@@ -0,0 +1,232 @@
<?php
namespace Wruczek\TSWebsite;
use TeamSpeak3;
use TeamSpeak3_Helper_String;
/**
* Class TeamSpeakChannel
* Some functions copied from ts3-php-framework and modified
*/
class TeamSpeakChannel {
private $channelList;
private $info;
private $clientList;
public function __construct($cid) {
if (is_array($cid)) {
$cid = (int) $cid["cid"];
}
if (!is_int($cid)) {
throw new \InvalidArgumentException("cid needs to be either an channel id (int)" .
" or an array containing key named cid");
}
$this->channelList = CacheManager::i()->getChannelList();
if (!isset($this->channelList[$cid])) {
throw new \InvalidArgumentException("Channel with ID $cid was not found in the channel cache");
}
$this->info = $this->channelList[$cid];
}
private function getChannelList() {
return $this->channelList;
}
private function getClientList() {
if ($this->clientList === null) {
$this->clientList = CacheManager::i()->getClientList();
}
return $this->clientList;
}
public function getInfo() {
return $this->info;
}
public function getId() {
return (int) $this->info["cid"];
}
public function getName() {
return (string) $this->info["channel_name"];
}
public function getDisplayName() {
if ($this->isSpacer()) {
// If its a spacer, remove everything before the
// first "]", and then the "]" itself.
return mb_substr(mb_strstr($this->getName(), "]"), 1);
}
return $this->getName();
}
public function isPermanent() {
return (bool) $this->info["channel_flag_permanent"];
}
public function getParentId() {
return (int) $this->info["pid"];
}
public function isOccupied($checkChildrens = false, $includeQuery = false) {
if ($checkChildrens) {
// Loop through all the children channels, and check if they are occupied
foreach ($this->getChildChannels(true) as $channel) {
if ($channel->isOccupied(false, $includeQuery)) {
return true;
}
}
return false;
}
// We could use the getChannelMembers method:
// return count($this->getChannelMembers($includeQuery)) > 0;
// But its much faster to return on the first instance then to
// count up all users and then compare their number.
foreach ($this->getClientList() as $client) {
if (!$client["client_type"] && $client["cid"] === $this->getId()) {
return true;
}
}
return false;
}
public function hasPassword() {
return $this->info["channel_flag_password"] === 1;
}
public function getTotalClients() {
return (int) $this->info["total_clients"];
}
public function isFullyOccupied() {
return $this->info["channel_maxclients"] !== -1 &&
$this->info["channel_maxclients"] <= $this->info["total_clients"];
}
public function isDefaultChannel() {
return $this->info["channel_flag_default"] === 1;
}
public function isTopChannel() {
return $this->getParentId() === 0;
}
public function getParentChannels($max = -1) {
$pid = (int) $this->info["pid"];
$parents = [];
while ($pid !== 0 && ($max < 0 || count($parents) < $max)) {
$parent = new TeamSpeakChannel($pid);
$parents[$pid] = $parent;
$pid = $parent->getParentId();
}
return $parents;
}
public function getClosestParentChannel() {
$parentChannels = $this->getParentChannels(1);
return isset($parentChannels[0]) ? $parentChannels[0] : null;
}
public function getChildChannels($resursive = false) {
$childList = [];
foreach ($this->getChannelList() as $child) {
if ($child["pid"] === $this->getId()) {
$childTSC = new TeamSpeakChannel($child);
$childList[$childTSC->getId()] = $childTSC;
if ($resursive) {
$childList += $childTSC->getChildChannels(true);
}
}
}
return $childList;
}
public function getClosestChildChannel() {
$childChannels = $this->getChildChannels(1);
return isset($childChannels[0]) ? $childChannels[0] : null;
}
public function getChannelMembers($includeQuery = false) {
$clientList = [];
foreach ($this->getClientList() as $client) {
if ($client["cid"] === $this->getId() && ($includeQuery || !$client["client_type"])) {
// $childTSC = new TeamSpeakClient($child["clid"]);
$clientList[$client["clid"]] = $client;
}
}
return $clientList;
}
public function isSpacer() {
return preg_match("/\[[^\]]*spacer[^\]]*\]/", $this->getName()) && $this->isPermanent() && !$this->getParentId();
}
/**
* Returns the possible alignment of a channel spacer
* @return int|false
*/
public function getSpacerAlign() {
if(!$this->isSpacer() || !preg_match("/\[(.*)spacer.*\]/", $this->getName(), $matches) || !isset($matches[1])) {
return false;
}
if ($this->getSpacerType() !== TeamSpeak3::SPACER_CUSTOM) {
return TeamSpeak3::SPACER_ALIGN_REPEAT;
}
switch($matches[1]) {
case "*":
return TeamSpeak3::SPACER_ALIGN_REPEAT;
case "c":
return TeamSpeak3::SPACER_ALIGN_CENTER;
case "r":
return TeamSpeak3::SPACER_ALIGN_RIGHT;
default:
return TeamSpeak3::SPACER_ALIGN_LEFT;
}
}
public function getSpacerType() {
if(!$this->isSpacer()) {
return false;
}
switch((new TeamSpeak3_Helper_String($this->getName()))->section("]", 1)) {
case "___":
return TeamSpeak3::SPACER_SOLIDLINE;
case "---":
return TeamSpeak3::SPACER_DASHLINE;
case "...":
return TeamSpeak3::SPACER_DOTLINE;
case "-.-":
return TeamSpeak3::SPACER_DASHDOTLINE;
case "-..":
return TeamSpeak3::SPACER_DASHDOTDOTLINE;
default:
return TeamSpeak3::SPACER_CUSTOM;
}
}
public function __toString() {
return $this->getName();
}
}

View File

@@ -0,0 +1,108 @@
<?php
namespace Wruczek\TSWebsite\Utils;
use function is_array;
use Wruczek\TSWebsite\Auth;
class ApiUtils {
/**
* Checks if the user is logged in, and if not outputs a JSON error and terminates the script
*/
public static function checkAuth() {
if (!Auth::isLoggedIn()) {
self::jsonError("You must be logged in to perform this action", "NOT_AUTHENTICATED", 401);
exit;
}
}
/**
* Calls jsonResponse with true as success parameter
*/
public static function jsonSuccess($data = null, $code = null, $statusCode = null) {
self::jsonResponse(true, $data, $code, $statusCode);
}
/**
* Calls jsonResponse with false as success parameter
*/
public static function jsonError($data = null, $code = null, $statusCode = null) {
self::jsonResponse(false, $data, $code, $statusCode);
}
/**
* Outputs json with key "success" set to the $success parameter.
* If $data is null, it skips it. If data is a string, it adds
* it to the json with key "message".
* If $data is an array, it merges it with the success key.
* Else it sets key "data" to $data
* @param $success bool
* @param $data null|string|array
* @param $code int|string error code
* @param $statusCode int Status code to return. null to not change
*/
public static function jsonResponse($success, $data = null, $code = null, $statusCode = null) {
if (!is_bool($success)) {
throw new \InvalidArgumentException("success must be a boolean");
}
$json = ["success" => $success];
if ($code !== null) {
$json["code"] = $code;
}
if (is_string($data)) {
$json["message"] = $data;
} else if (is_array($data)) {
$json = array_merge($json, $data);
} else if($data !== null) {
$json["data"] = $data;
}
if (is_int($statusCode)) {
@http_response_code($statusCode);
}
self::outputJson($json);
}
public static function outputJson($array) {
@header("Content-Type: application/json");
echo json_encode($array);
}
public static function getPostParam($key) {
return self::getParam($_POST, $key);
}
public static function getGetParam($key) {
return self::getParam($_GET, $key);
}
/**
* Returns $array[$key] if exists, otherwise throws an jsonerror and
* terminates the script
* @param $array array
* @param $key string
* @param $canBeArray bool whenever the data can be an array
* @return mixed
*/
public static function getParam($array, $key, $canBeArray = false) {
if (!isset($array[$key])) {
self::jsonError("Parameter $key is not provided", 400);
exit;
}
$data = $array[$key];
if (is_array($data) && !$canBeArray) {
self::jsonError("Parameter $key cannot be an array", 400);
exit;
}
return $data;
}
}

View File

@@ -0,0 +1,80 @@
<?php
namespace Wruczek\TSWebsite\Utils;
class CsrfUtils {
/**
* Generates and returns a new CSRF token
* @param $length int length in bytes
* @return string generated CSRF token
* @throws \Exception when unable to generate a new token
*/
public static function generateToken($length) {
if (function_exists("random_bytes")) {
$token = bin2hex(random_bytes($length));
} else if (function_exists("mcrypt_create_iv")) {
$token = bin2hex(mcrypt_create_iv($length, MCRYPT_DEV_URANDOM));
} else {
$token = bin2hex(openssl_random_pseudo_bytes($length));
}
if (!is_string($token) || empty($token)) {
throw new \Exception("Cannot generate new CSRF token");
}
return $token;
}
/**
* Returns the current CSRF Token or creates a new one if needed.
* @return string CSRF token
* @throws \Exception When we cannot generate a new CSRF token
*/
public static function getToken() {
if (isset($_SESSION["csrfToken"])) {
return $_SESSION["csrfToken"];
}
$length = 16; // in bytes
$token = self::generateToken($length);
$_SESSION["csrfToken"] = $token;
return $token;
}
/**
* Compares user-provided $token against the one we have.
* @param $toCheck string token to be checked
* @return bool true if tokens match, false otherwise.
*/
public static function validateToken($toCheck) {
$knownToken = @$_SESSION["csrfToken"];
if ($knownToken === null) {
return false;
}
return hash_equals($knownToken, $toCheck);
}
/**
* Tries to get CSRF token from the request and then compares it.
* If it fails, it returns the error page with message and exits the script.
*/
public static function validateRequest() {
if (isset($_POST["csrf-token"])) {
$csrfToken = $_POST["csrf-token"];
} else if (isset($_SERVER["HTTP_X_CSRF_TOKEN"])) {
$csrfToken = $_SERVER["HTTP_X_CSRF_TOKEN"];
}
if (empty($csrfToken) || !self::validateToken($csrfToken)) {
http_response_code(400);
TemplateUtils::i()->renderErrorTemplate("", "Security error. Please go to the previous page and try again.", "CSRF token mismatch");
exit;
}
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace Wruczek\TSWebsite\Utils;
use Medoo\Medoo;
use Wruczek\TSWebsite\Config;
/**
* Class DatabaseUtils
* @package Wruczek\TSWebsite\Utils
* @author Wruczek 2017
*/
class DatabaseUtils {
use SingletonTait;
protected $configUtils;
protected $db;
private function __construct() {
$this->configUtils = Config::i();
}
/**
* Returns database object created with data from
* database config. Stores connection for reuse.
* @return \Medoo\Medoo database object
*/
public function getDb() {
if($this->db === null) {
try {
$db = new Medoo($this->configUtils->getDatabaseConfig());
} catch (\Exception $e) {
TemplateUtils::i()->renderErrorTemplate("DB error", "Connection to database failed", $e->getMessage());
exit;
}
$this->db = $db;
}
return $this->db;
}
/**
* Returns true if MysqliDb has been ever initialised. Useful
* for checking if there was a database connection attempt.
* @return bool
*/
public function isInitialised() {
return !empty($this->db);
}
}

View File

@@ -0,0 +1,117 @@
<?php
namespace Wruczek\TSWebsite\Utils;
use Wruczek\TSWebsite\Utils\Language\LanguageUtils;
class DateUtils {
/**
* Returns current date format based on current user language. If it cannot
* be retrieved, default value is returned
* @return string date format
*/
public function getDateFormat() {
try {
return LanguageUtils::i()->translate("DATE_FORMAT");
} catch (\Exception $e) {
return "d.m.Y";
}
}
/**
* Returns current time format based on current user language. If it cannot
* be retrieved, default value is returned
* @return string time format
*/
public function getTimeFormat() {
try {
return LanguageUtils::i()->translate("TIME_FORMAT");
} catch (\Exception $e) {
return "H:i:s";
}
}
/**
* Returns timestamp formatted to string with format from getDateFormat()
* @param $timestamp
* @return false|string
*/
public function formatToDate($timestamp) {
return date($this->getDateFormat(), $timestamp);
}
/**
* Returns timestamp formatted to string with format from getTimeFormat()
* @param $timestamp
* @return false|string
*/
public function formatToTime($timestamp) {
return date($this->getTimeFormat(), $timestamp);
}
/**
* Returns timestamp formatted with formatToDate() and formatToTime()
* @param $timestamp
* @param string $additional additional date format
* @return false|string
*/
public function formatToDateTime($timestamp, $additional = "") {
return date("{$this->getDateFormat()} {$this->getTimeFormat()} $additional", $timestamp);
}
/**
* Formats timestamp into "time ago" string
* For example, timestamp set to 60 seconds ago will return "1 minute ago"
*
* Taken from StackOverflow: https://stackoverflow.com/a/18602474
* @param $timestamp int timestamp with past date
* @param bool $full if true, full date will be returned. For example "5 hours, 2 minutes, 8 seconds"
* @return string timestamp formatted to fuzzy date. Marf.
*/
public function fuzzyDate($timestamp, $full = false) {
$now = new \DateTime;
$ago = (new \DateTime)->setTimestamp($timestamp);
$diff = $now->diff($ago);
$diff->w = floor($diff->d / 7);
$diff->d -= $diff->w * 7;
$string = [
'y' => 'year',
'm' => 'month',
'w' => 'week',
'd' => 'day',
'h' => 'hour',
'i' => 'minute',
's' => 'second'
];
foreach ($string as $k => &$v) {
if ($diff->$k) {
$v = $diff->$k . ' ' . $v . ($diff->$k > 1 ? 's' : '');
} else {
unset($string[$k]);
}
}
if (!$full) $string = array_slice($string, 0, 1);
return $string ? implode(', ', $string) . ' ago' : 'just now';
}
/**
* Returns fuzzy date with abbreviation showing precise date
* @see fuzzyDate
* @param $timestamp
* @param bool $full
* @return string
*/
public function fuzzyDateHTML($timestamp, $full = false) {
$fuzzyDate = $this->fuzzyDate($timestamp, $full);
$fullDate = $this->formatToDateTime($timestamp, "T");
return '<abbr data-fuzzydate="' . $timestamp . '"></abbr>';
// return '<abbr data-toggle="tooltip" title="' . htmlentities($fullDate) . '">' . htmlentities($fuzzyDate) . '</abbr>';
}
}

View File

@@ -0,0 +1,114 @@
<?php
namespace Wruczek\TSWebsite\Utils\Language;
class Language {
private $languageId;
private $languageNameEnglish;
private $languageNameNative;
private $languageCode;
private $isDefault;
private $languageItems;
/**
* Language constructor.
* @param $languageId
* @param $languageNameEnglish
* @param $languageNameNative
* @param $languageCode
* @param $isDefault
* @param $languageItems
*/
public function __construct($languageId, $languageNameEnglish, $languageNameNative, $languageCode, $isDefault, $languageItems) {
$this->languageId = $languageId;
$this->languageNameEnglish = $languageNameEnglish;
$this->languageNameNative = $languageNameNative;
$this->languageCode = $languageCode;
$this->isDefault = $isDefault;
$this->languageItems = $languageItems;
}
/**
* Returns language ID
* @return int language ID
*/
public function getLanguageId() {
return $this->languageId;
}
/**
* Returns language name in English
* @return string language name in English
*/
public function getLanguageNameEnglish() {
return $this->languageNameEnglish;
}
/**
* Returns language name in its native form
* @return string language name in its native form
*/
public function getLanguageNameNative() {
return $this->languageNameNative;
}
/**
* Returns language code
* @return string language code
*/
public function getLanguageCode() {
return $this->languageCode;
}
/**
* Returns true when this language is set as default site language
* @return boolean true when default, false otherwise
*/
public function isDefault() {
return $this->isDefault;
}
/**
* Sets this language as default language of the site
* @return boolean true on success, false otherwise
*/
public function setAsDefaultLanguage() {
return LanguageUtils::i()->setDefaultLanguage($this);
}
/**
* Returns simple array with identifier -> value mapping, created from getLanguageItems()
* @return array
*/
public function getSimpleItemsArray() {
$ret = [];
foreach ($this->getLanguageItems() as $item) {
$ret[$item->getIdentifier()] = $item->getValue();
}
return $ret;
}
/**
* Returns language item
* @param $identifier string identifier
* @return LanguageItem LanguageItem if found, null otherwise
*/
public function getLanguageItem($identifier) {
foreach ($this->getLanguageItems() as $item) {
if(strcasecmp($item->getIdentifier(), $identifier) === 0)
return $item;
}
return null;
}
/**
* Returns language strings
* @return array array filled with LanguageItem
*/
public function getLanguageItems() {
return $this->languageItems;
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace Wruczek\TSWebsite\Utils\Language;
class LanguageItem {
private $identifier;
private $value;
private $comment;
/**
* LanguageItem constructor.
* @param $identifier
* @param $value
* @param $comment
*/
public function __construct($identifier, $value, $comment) {
$this->identifier = $identifier;
$this->value = $value;
$this->comment = $comment;
}
/**
* Returns item identifier
* @return string
*/
public function getIdentifier() {
return $this->identifier;
}
/**
* Returns item value
* @return string
*/
public function getValue() {
return $this->value;
}
/**
* Returns item comment, can be null
* @return string
*/
public function getComment() {
return $this->comment;
}
public function __toString() {
return $this->getValue();
}
}

View File

@@ -0,0 +1,201 @@
<?php
namespace Wruczek\TSWebsite\Utils\Language;
use function htmlspecialchars;
use Wruczek\PhpFileCache\PhpFileCache;
use Wruczek\TSWebsite\Utils\DatabaseUtils;
use Wruczek\TSWebsite\Utils\SingletonTait;
/**
* Class LanguageUtils
* @package Wruczek\TSWebsite\Utils
* @author Wruczek 2017
*/
class LanguageUtils {
use SingletonTait;
private $cache;
private $languages;
/**
* Short function for translate
*/
public static function tl($identifier, $args = []) {
return self::i()->translate($identifier, $args);
}
private function __construct() {
$this->cache = new PhpFileCache(__CACHE_DIR, "translations");
$this->languages = $this->cache->refreshIfExpired("languages", function () {
return $this->refreshLanguageCache(false);
}, 300);
}
/**
* Returns language by its ID
* @param $languageId int Language ID
* @return Language|boolean returns Language when found, false otherwise
*/
public function getLanguageById($languageId) {
foreach ($this->getLanguages() as $lang) {
if($lang->getLanguageId() === $languageId)
return $lang;
}
return false;
}
/**
* Returns language by its Language Code
* @param $languageCode string Language Code
* @return Language|boolean returns Language when found, false otherwise
*/
public function getLanguageByCode($languageCode) {
foreach ($this->getLanguages() as $lang) {
if(strcasecmp($lang->getLanguageCode(), $languageCode) === 0)
return $lang;
}
return false;
}
/**
* Returns all available languages
* @return array|mixed
*/
public function getLanguages() {
return $this->languages;
}
/**
* Returns default language
* @return Language default language
*/
public function getDefaultLanguage() {
foreach ($this->getLanguages() as $lang) {
if($lang->isDefault())
return $lang;
}
return null;
}
/**
* Sets language as default
* @param $language Language
* @return boolean true on success, false otherwise
*/
public function setDefaultLanguage($language) {
$db = DatabaseUtils::i()->getDb();
// set all languages as non-default, if this succeeds...
if($db->update("languages", ["isdefault" => 0])) {
// ...then set only this language to default
$success = $db->update("languages", ["isdefault" => 1], ["langid", $language->getLanguageId()]);
$this->refreshLanguageCache();
return $success;
}
return false;
}
/**
* Tried to determine user language and returns it
* @return Language user language if determined, null otherwise
*/
public function detectUserLanguage() {
if (isset($_COOKIE["tswebsite_language"])) { // check cookie
$langcode = $_COOKIE["tswebsite_language"];
} else if (isset($_SERVER["HTTP_ACCEPT_LANGUAGE"])) { // check http headers
$langcode = substr($_SERVER["HTTP_ACCEPT_LANGUAGE"], 0, 2);
}
// if language with that code exists, return it
if(!empty($langcode) && ($lang = $this->getLanguageByCode($langcode)))
return $lang;
return null;
}
/**
* Refreshes language cache, loads and returns new data
* @param bool $updateCache true if the file cache should also be updated
* @return array
*/
public function refreshLanguageCache($updateCache = true) {
$db = DatabaseUtils::i()->getDb();
$data = $db->select("languages", ["langid", "englishname", "nativename", "langcode", "isdefault"]);
$langs = [];
foreach ($data as $lang) {
$langid = $lang["langid"];
$englishname = $lang["englishname"];
$nativename = $lang["nativename"];
$langcode = $lang["langcode"];
$isdefault = $lang["isdefault"];
$strings = $db->select("translations", ["identifier", "value", "comment"], ["langid" => $langid]);
$languageItems = [];
foreach ($strings as $str)
$languageItems[] = new LanguageItem($str["identifier"], $str["value"], $str["comment"]);
$langs[] = new Language($langid, $englishname, $nativename, $langcode, $isdefault, $languageItems);
}
$this->languages = $langs;
if($updateCache)
$this->cache->store("languages", $langs, 300);
return $langs;
}
/**
* Returns translated text. If identifier is not found in the current
* language, it tries to get it from the default language.
* User language is determined with getDefaultLanguage() function.
* @param $identifier string Translation identifier
* @param array $args Arguments that will replace placeholders
* @return string Translated text
* @throws \Exception When default site or user language cannot
* be found, and/or if $identifier is not found
*/
public function translate($identifier, $args = []) {
if (!is_array($args)) {
$args = [$args];
}
$defaultlang = $this->getDefaultLanguage();
$lang = $this->getLanguageById(@$_SESSION["userlanguageid"]);
if(!$lang && !$defaultlang)
throw new \Exception("Cannot get user or default language");
$item = $lang->getLanguageItem($identifier);
if(!$item)
$item = $defaultlang->getLanguageItem($identifier);
if(!$item)
throw new \Exception("Cannot get translation for $identifier");
$val = $item->getValue();
// Replace placeholders with values from $args
foreach ($args as $i => $iValue) {
// Prevent argument placeholder injection
$iValue = str_replace(["{", "}"], ["&#123;", "&#125;"], $iValue);
$val = str_ireplace('{' . $i . '}', $iValue, $val);
}
return $val;
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace Wruczek\TSWebsite\Utils;
trait SingletonTait {
/**
* Call this method to get singleton
* @return self
*/
public static function Instance() {
static $inst = null;
if ($inst === null)
$inst = new self();
return $inst;
}
/**
* A shorthand to get the singleton
* @return self
*/
public static function i() {
return self::Instance();
}
}

View File

@@ -0,0 +1,148 @@
<?php
namespace Wruczek\TSWebsite\Utils;
use function mt_rand;
use function var_dump;
use Wruczek\TSWebsite\Config;
/**
* Class TeamSpeakUtils
* @package Wruczek\TSWebsite\Utils
* @author Wruczek 2017
*/
class TeamSpeakUtils {
use SingletonTait;
protected $configUtils;
protected $tsNodeHost;
protected $tsNodeServer;
protected $exceptionsList = [];
private function __construct() {
$this->configUtils = Config::i();
}
/**
* Returns TeamSpeak3_Node_Host object created using
* data from config database
* @return \TeamSpeak3_Node_Host
*/
public function getTSNodeHost() {
if($this->tsNodeHost === null) {
$hostname = $this->configUtils->getValue("query_hostname");
$queryport = $this->configUtils->getValue("query_port");
$username = $this->configUtils->getValue("query_username");
$password = $this->configUtils->getValue("query_password");
try {
$tsNodeHost = \TeamSpeak3::factory("serverquery://$hostname:$queryport/?timeout=3");
$tsNodeHost->login($username, $password);
$this->tsNodeHost = $tsNodeHost;
} catch (\Exception $e) {
$this->addExceptionToExceptionsList($e);
}
}
return $this->tsNodeHost;
}
/**
* Returns TeamSpeak3_Node_Server object created
* using getTSNodeHost() method.
* @return \TeamSpeak3_Node_Server
*/
public function getTSNodeServer() {
// Don't continue if TSNodeHost is NULL (not working / not initialised)
if($this->tsNodeServer === null && $this->getTSNodeHost()) {
$port = $this->configUtils->getValue("tsserver_port");
try {
$this->tsNodeServer = $this->getTSNodeHost()->serverGetByPort($port);
$newNickname = Config::get("query_nickname");
// if available, set the query nickname. add random numbers to the end, so
// the bot will work even with a user/query of the same nickname online
if (isset($newNickname)) {
// try 5 times to change the nickname if the previous is already in use
for($i = 0; $i < 5; $i++) {
try {
$this->tsNodeServer->selfUpdate(["client_nickname" => $newNickname]);
break; // success - we have set the nickname
} catch (\TeamSpeak3_Exception $e) {
// error nickname in use
if ($e->getCode() === 513) {
// add something random to the name and try again
$newNickname .= mt_rand(0, 9);
} else {
// if thats other error than nickname in use, re-throw it
throw $e;
}
}
}
}
} catch (\Exception $e) {
$this->addExceptionToExceptionsList($e);
}
}
return $this->tsNodeServer;
}
/**
* Tries to download file from the TS3 server. It might be an actual file,
* icon or avatar. Returns downloaded data. Might throw exceptions when filetransfer fails.
* @param $filename
* @param int $cid Channel Id (defaults to 0 - server)
* @param string $cpw Channel password (defaults to empty)
* @return mixed
* @throws \TeamSpeak3_Adapter_ServerQuery_Exception
*/
public function ftDownloadFile($filename, $cid = 0, $cpw = "") {
$dl = $this->getTSNodeServer()->transferInitDownload(mt_rand(0x0000, 0xFFFF), $cid, $filename, $cpw);
$host = (false !== strpos($dl["host"], ":") ? "[" . $dl["host"] . "]" : $dl["host"]);
$filetransfer = \TeamSpeak3::factory("filetransfer://$host:" . $dl["port"]);
return $filetransfer->download($dl["ftkey"], $dl["size"]);
}
/**
* Resets current connection, forces to reconnect to the TeamSpeak server
* next time you call getTSNodeHost or getTSNodeServer
*/
public function reset() {
$this->tsNodeHost = null;
$this->tsNodeServer = null;
}
/**
* Checks TeamSpeak server connection
* Warning: it will connect to the TeamSpeak server to check the status.
* Use it just before accessing the server, preferably after checking cache.
* @return bool true if TeamSpeak connection succeeded, false otherwise
*/
public function checkTSConnection() {
return $this->getTSNodeHost() !== null
&& $this->getTSNodeServer() !== null
&& empty($this->getExceptionsList());
}
/**
* Adds exception to the exceptions list
* @param \Exception $exception
*/
public function addExceptionToExceptionsList($exception) {
$this->exceptionsList[$exception->getCode()] = $exception;
}
/**
* Returns array filled with connection exceptions collected
* when calling getTSNodeServer(), getTSNodeServer() and other methods
* @return array Array filled with exceptions. Empty if no exceptions where thrown.
*/
public function getExceptionsList() {
return $this->exceptionsList;
}
}

View File

@@ -0,0 +1,193 @@
<?php
namespace Wruczek\TSWebsite\Utils;
use Latte\Engine;
use Latte\Runtime\Html;
use Wruczek\TSWebsite\AdminStatus;
use Wruczek\TSWebsite\Config;
use Wruczek\TSWebsite\Utils\Language\LanguageUtils;
/**
* Class TemplateUtils
* @package Wruczek\TSWebsite\Utils
* @author Wruczek 2017
*/
class TemplateUtils {
use SingletonTait;
protected $latte;
private $oldestCache;
private function __construct() {
$this->latte = new Engine();
$this->getLatte()->setTempDirectory(__CACHE_DIR . "/templates");
// Add custom filters...
$this->getLatte()->addFilter("fuzzyDateAbbr", function ($s) {
return new Html('<span data-relativetime="fuzzydate" data-timestamp="' . $s . '">{cannot convert ' . $s . '}</span>');
});
$this->getLatte()->addFilter("fullDate", function ($s) {
return new Html('<span data-relativetime="fulldate" data-timestamp="' . $s . '">{cannot convert ' . $s . '}</span>');
});
$this->getLatte()->addFilter("translate", function ($s, ...$args) {
return new Html(__get($s, $args));
});
}
/**
* Returns latte object
* @return \Latte\Engine Latte object
*/
public function getLatte() {
return $this->latte;
}
/**
* Echoes rendered template
* @see renderTemplateToString
*/
public function renderTemplate($templateName, $data = [], $loadLangs = true) {
echo $this->renderTemplateToString($templateName, $data, $loadLangs);
}
/**
* Renders and outputs the error template
* @param string $errorcode Error code
* @param string $errorname Error title
* @param string $description Error description
*/
public function renderErrorTemplate($errorcode = "", $errorname = "", $description = "") {
$data = ["errorcode" => $errorcode, "errorname" => $errorname, "description" => $description];
$this->renderTemplate("errorpage", $data, false);
}
/**
* @param $templateName string Name of the template file, without path and extension
* @param $data array Data passed to the template
* @param bool $loadLangs true if the languages should be loaded (requires working database connection)
* @return string Rendered template
* @throws \Exception when we cannot get the CSRF token
*/
public function renderTemplateToString($templateName, $data = [], $loadLangs = true) {
$dbutils = DatabaseUtils::i();
if($loadLangs) {
$userlang = LanguageUtils::i()->getLanguageById($_SESSION["userlanguageid"]);
$data["languageList"] = LanguageUtils::i()->getLanguages();
$data["userLanguage"] = $userlang;
}
if ($timestamp = $this->getOldestCacheTimestamp())
$data["oldestTimestamp"] = $timestamp;
$data["tsExceptions"] = TeamSpeakUtils::i()->getExceptionsList();
if(@$dbutils->isInitialised())
$data["sqlCount"] = @$dbutils->getDb()->query("SHOW SESSION STATUS LIKE 'Questions'")->fetch()["Value"];
else
$data["sqlCount"] = "none";
$data["config"] = Config::i()->getConfig();
$csrfToken = CsrfUtils::getToken();
$data["csrfToken"] = $csrfToken;
$data["csrfField"] = new Html('<input type="hidden" name="csrf-token" value="' . $csrfToken . '">');
if (Config::get("adminstatus_enabled")) {
$data["adminStatus"] = AdminStatus::i()->getStatus(
Config::get("adminstatus_groups"),
Config::get("adminstatus_mode"),
Config::get("adminstatus_hideoffline"),
Config::get("adminstatus_ignoredusers")
);
}
return $this->getLatte()->renderToString(__TEMPLATES_DIR . "/$templateName.latte", $data);
}
/**
* Returns time elapsed from website load start until now
* @param bool $raw If true, returns elapsed time in
* milliseconds. Defaults to false.
* @return string
*/
public static function getRenderTime($raw = false) {
if($raw) {
return microtime(true) - __RENDER_START;
} else {
return number_format(self::getRenderTime(true), 5);
}
}
/**
* Stores information about the oldest cached page element
* for later to be displayed in a warning
* @see getOldestCacheTimestamp
* @param $data
*/
public function storeOldestCache($data) {
if ($data["expired"] && (!$this->oldestCache || $this->oldestCache > $data["time"]))
$this->oldestCache = $data["time"];
}
/**
* @see storeOldestCache
* @return int Oldest cache timestamp, null if not set
*/
public function getOldestCacheTimestamp() {
return $this->oldestCache;
}
/**
* Outputs either script or link with all parameters needed
* @param $resourceType string must be either "stylesheet" or "script"
* @param $url string Relative or absolute path to the resource. {cdnjs} will be
* replaced with "https://cdnjs.cloudflare.com/ajax/libs"
* @param $parameter string|bool|null If boolean, its gonna treat it as a local
* resource and add a version timestamp. If string, its gonna treat it as a
* integrity hash and add it along with crossorigin="anonymous" tag.
*/
public static function includeResource($resourceType, $url, $parameter = null) {
$url = str_replace('{cdnjs}', 'https://cdnjs.cloudflare.com/ajax/libs', $url);
$attributes = "";
if (is_bool($parameter)) {
$filemtime = @filemtime(__BASE_DIR . "/" . $url);
if ($filemtime !== false) {
$url .= "?v=$filemtime";
}
} else if (is_string($parameter)) {
// NEEDS to start with a space!
$attributes = ' integrity="' . htmlspecialchars($parameter) . '" crossorigin="anonymous"';
}
if ($resourceType === "stylesheet") {
echo '<link rel="stylesheet" href="' . htmlspecialchars($url) . '"' . $attributes . '>';
} else if ($resourceType === "script") {
echo '<script src="' . htmlspecialchars($url) . '"' . $attributes . '></script>';
} else {
throw new \InvalidArgumentException("$resourceType is not a valid resource type");
}
}
/**
* @see includeResource
*/
public static function includeStylesheet($url, $parameter = null) {
self::includeResource("stylesheet", $url, $parameter);
}
/**
* @see includeResource
*/
public static function includeScript($url, $parameter = null) {
self::includeResource("script", $url, $parameter);
}
}

View File

@@ -0,0 +1,132 @@
<?php
namespace Wruczek\TSWebsite\Utils;
use Wruczek\TSWebsite\Config;
use Wruczek\TSWebsite\News\DefaultNewsStore;
use Wruczek\TSWebsite\News\INewsStore;
/**
* Class Utils
* @package Wruczek\TSWebsite\Utils
* @author Wruczek 2017
*/
class Utils {
private function __construct() {}
/**
* Strips the first line from string
* https://stackoverflow.com/a/7740485
* @param $str
* @return bool|string stripped text without the first line or false on failure
*/
public static function stripFirstLine($str) {
$position = strpos($str, "\n");
if($position === false)
return $str;
return substr($str, $position + 1);
}
/**
* Checks if $haystack starts with $needle
* https://stackoverflow.com/a/860509
* @param $haystack string
* @param $needle string
* @param bool $case set to false for case-insensitivity (default true)
* @return bool true if $haystack starts with $needle, false otherwise
*/
public static function startsWith($haystack, $needle, $case = true) {
if ($case)
return strpos($haystack, $needle, 0) === 0;
return stripos($haystack, $needle, 0) === 0;
}
/**
* Checks if $haystack ends with $needle
* https://stackoverflow.com/a/860509
* @param $haystack string
* @param $needle string
* @param bool $case set to false for case-insensitivity (default true)
* @return bool true if $haystack ends with $needle, false otherwise
*/
public static function endsWith($haystack, $needle, $case = true) {
$expectedPosition = strlen($haystack) - strlen($needle);
if ($case)
return strrpos($haystack, $needle, 0) === $expectedPosition;
return strripos($haystack, $needle, 0) === $expectedPosition;
}
/**
* Returns IP address with last two octets replaced with "***"
* @param $ip string IP to censor
* @return bool|string Censored IP on success, false on failure
* @throws \Exception When the IP address is invalid
*/
public static function censorIpAddress($ip) {
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
$ip = explode(".", $ip);
if (count($ip) >= 2) {
return "{$ip[0]}.{$ip[1]}.***.***";
}
return "(IPv4)";
}
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
$ip = explode(":", $ip);
if (count($ip) >= 2) {
return "{$ip[0]}:{$ip[1]}:***:***";
}
return "(IPv6)";
}
throw new \Exception("Invalid IP address $ip");
}
/**
* Returns client IP from REMOTE_ADDR or from HTTP_CF_CONNECTING_IP if using CF IP
* @param bool $useCfip if true, check and use HTTP_CF_CONNECTING_IP header if present.
* Falls back to REMOTE_ADDR if empty
* @return string IP address
*/
public static function getClientIp($useCfip = null) {
if ($useCfip === null) {
$useCfip = (bool) Config::get("usingcloudflare");
}
// If IPv6 localhost, return IPv4 localhost
if ($_SERVER["REMOTE_ADDR"] === "::1") {
return "127.0.0.1";
}
if (!empty($_SERVER["HTTP_CF_CONNECTING_IP"]) && $useCfip) {
return $_SERVER["HTTP_CF_CONNECTING_IP"];
}
return $_SERVER["REMOTE_ADDR"];
}
/**
* Returns currently used news store
* @return INewsStore|null
*/
public static function getNewsStore() {
$newsStore = null;
// if the current implementation is default
if (true) {
$newsStore = new DefaultNewsStore();
}
return $newsStore;
}
}

View File

@@ -0,0 +1,9 @@
<?php
namespace Wruczek\TSWebsite\Utils;
class ValidationUtils {
}

View File

@@ -0,0 +1,359 @@
<?php
namespace Wruczek\TSWebsite;
use function __get;
use TeamSpeak3;
class ViewerRenderer {
private $imgPath;
private $resultHtml;
private $serverInfo;
private $channelList;
private $clientList;
private $serverGroupList;
private $channelGroupList;
private $renderQueryClients = false;
private $hiddenChannels = [];
public function __construct($imgPath) {
$this->imgPath = $imgPath;
$cm = CacheManager::i();
$this->serverInfo = $cm->getServerInfo();
$this->channelList = $cm->getChannelList();
$this->clientList = $cm->getClientList();
$this->serverGroupList = $cm->getServerGroupList();
$this->channelGroupList = $cm->getChannelGroupList();
}
/**
* Checks if we have successfully loaded all required data from cache.
* Loading data from CacheManager might fail for example when the server is offline,
* or when we dont have required permissions to check for a specific item.
* @return bool true on success, false otherwise
*/
public function checkRequiredData() {
return isset($this->channelList, $this->clientList, $this->serverGroupList, $this->channelGroupList);
}
private function add($html, ...$args) {
foreach ($args as $i => $iValue) {
// Prevent argument placeholder injection
$iValue = str_replace(["{", "}"], ["&#123;", "&#125;"], $iValue);
$html = str_ireplace('{' . $i . '}', $iValue, $html);
}
$this->resultHtml .= $html;
}
public function renderViewer() {
if (!$this->checkRequiredData()) {
throw new \Exception("Failed to load required data from the cache. " .
"Is the server online? Do we have enough permissions?");
}
$suffixIcons = "";
if ($icon = $this->serverInfo["virtualserver_icon_id"]) {
$suffixIcons = $this->getIcon($icon, __get("VIEWER_SERVER_ICON"));
}
$html = <<<EOD
<div class="channel-container is-server">
<div class="channel" data-channelid="0" tabindex="0">
<span class="channel-name">{0}{1}</span>
<span class="channel-icons">{2}</span>
</div>
</div>
EOD;
$this->add(
$html,
$this->getIcon("server_green.svg"),
htmlspecialchars($this->serverInfo["virtualserver_name"]),
$suffixIcons
);
foreach ($this->channelList as $channel) {
// Start rendering the top channels, they are gonna
// render all the childrens recursively
if ($channel["pid"] === 0) {
$this->renderChannel(new TeamSpeakChannel($channel));
}
}
return $this->resultHtml;
}
public function getIcon($name, $tooltip = null, $alt = "Icon") {
if (is_string($name)) {
$path = "{$this->imgPath}/$name";
} else {
$path = "api/geticon.php?iconid=" . (int) $name;
}
$ttip = $tooltip ? ' data-toggle="tooltip" title="' . htmlspecialchars($tooltip) . '"' : "";
return '<img class="icon" src="' . $path . '" alt="' . htmlspecialchars($alt) . '"' . $ttip . '>';
}
/**
* @param $channel TeamSpeakChannel
*/
public function renderChannel($channel) {
$hasParent = $channel->getParentId();
$isHidden = in_array($channel->getId(), $this->hiddenChannels);
$channelDisplayName = $channel->getDisplayName();
$channelClasses = $hasParent ? "has-parent" : "no-parent";
$channelIcon = "";
$suffixIcons = "";
// If this channel is occupied
if ($channel->isOccupied(false, $this->renderQueryClients) && !$isHidden) {
$channelClasses .= " is-occupied";
} else if ($channel->isOccupied(true, $this->renderQueryClients) && !$isHidden) {
$channelClasses .= " occupied-childs";
} else {
$channelClasses .= " not-occupied";
}
if ($channel->isSpacer()) {
$channelClasses .= " is-spacer";
switch($channel->getSpacerAlign()) {
case TeamSpeak3::SPACER_ALIGN_REPEAT:
$channelClasses .= " spacer-repeat";
$channelDisplayName = str_repeat($channelDisplayName, 200);
break;
case TeamSpeak3::SPACER_ALIGN_CENTER:
$channelClasses .= " spacer-center";
break;
case TeamSpeak3::SPACER_ALIGN_RIGHT:
$channelClasses .= " spacer-right";
break;
case TeamSpeak3::SPACER_ALIGN_LEFT:
$channelClasses .= " spacer-left";
break;
}
} else {
$channelIcon = $this->getChannelIcon($channel, $isHidden);
$suffixIcons = $this->getChannelSuffixIcons($channel);
}
$html = <<<EOD
<div class="channel-container {0}">
<div class="channel" data-channelid="{1}"{2}>
<span class="channel-name">{3}{4}</span>
<span class="channel-icons">{5}</span>
</div>
EOD;
$this->add(
$html,
$channelClasses,
$channel->getId(),
$channel->isSpacer() ? "" : ' tabindex="0"',
$channelIcon,
htmlspecialchars($channelDisplayName),
$suffixIcons
);
if (!$isHidden) {
foreach ($channel->getChannelMembers($this->renderQueryClients) as $member) {
$this->renderClient($member);
}
}
foreach ($channel->getChildChannels() as $member) {
$this->renderChannel($member);
}
$this->add('</div>' . PHP_EOL . PHP_EOL);
}
public function renderClient($client) {
$isQuery = (bool) $client["client_type"];
$clientSGIDs = explode(",", $client["client_servergroups"]);
$clientServerGroups = [];
$beforeName = [];
$afterName = [];
if (isset($client["client_away_message"])) {
$afterName[] = "[{$client["client_away_message"]}]";
}
foreach ($this->serverGroupList as $servergroup) {
$groupid = $servergroup["sgid"];
if (in_array($groupid, $clientSGIDs)) {
$clientServerGroups[$groupid] = $servergroup;
if ($servergroup["namemode"] === TeamSpeak3::GROUP_NAMEMODE_BEFORE) {
$beforeName[] = "[{$servergroup["name"]}]";
}
if ($servergroup["namemode"] === TeamSpeak3::GROUP_NAMEMODE_BEHIND) {
$afterName[] = "[{$servergroup["name"]}]";
}
}
}
$clientIcon = $this->getClientIcon($client);
$suffixIcons = $this->getClientSuffixIcons($client, $clientServerGroups, 0);
$html = <<<EOD
<div class="client-container{0}" data-clientdbid="{1}" tabindex="0">
<span class="client-name">{2}{3}</span>
<span class="client-icons">{4}</span>
</div>
EOD;
$clientName = implode(" ", $beforeName); // prefix groups
$clientName .= " {$client["client_nickname"]} "; // nickname
$clientName .= implode(" ", $afterName); // suffix groups
$clientName = htmlspecialchars(trim($clientName)); // trim and sanitize
$this->add(
$html,
$isQuery ? " is-query" : "", $client["client_database_id"],
$clientIcon,
$clientName,
$suffixIcons
);
}
private function getChannelIcon(TeamSpeakChannel $channel, $isHidden) {
$subscribed = $isHidden ? "" : "_subscribed";
$unsub = $isHidden ? __get("VIEWER_CHANNEL_UNSUB1") : "";
if ($channel->isDefaultChannel()) {
return $this->getIcon("channel_default.svg", __get("VIEWER_DEFAULT_CHANNEL"));
}
if ($channel->isFullyOccupied()) {
return $this->getIcon("channel_red{$subscribed}.svg", __get("VIEWER_CHANNEL_OCCUPIED") . $unsub);
}
if ($channel->hasPassword()) {
return $this->getIcon("channel_yellow{$subscribed}.svg", __get("VIEWER_CHANNEL_PASSWORD") . $unsub);
}
return $this->getIcon("channel_green{$subscribed}.svg", $isHidden ? __get("VIEWER_CHANNEL_UNSUB2") : null);
}
private function getChannelSuffixIcons(TeamSpeakChannel $channel) {
$info = $channel->getInfo();
$html = "";
if($channel->isDefaultChannel()) {
$html .= $this->getIcon("default.svg", __get("VIEWER_DEFAULT_CHANNEL"));
}
if($info["channel_flag_password"]) {
$html .= $this->getIcon("channel_private.svg", __get("VIEWER_CHANNEL_PASSWORD"));
}
$codec = $info["channel_codec"];
if($codec === TeamSpeak3::CODEC_CELT_MONO || $codec === TeamSpeak3::CODEC_OPUS_MUSIC) {
$html .= $this->getIcon("music.svg", __get("VIEWER_CHANNEL_MUSIC_CODED"));
}
if($info["channel_needed_talk_power"]) {
$html .= $this->getIcon("moderated.svg", __get("VIEWER_CHANNEL_MODERATED"));
}
if($info["channel_icon_id"]) {
$html .= $this->getIcon($info["channel_icon_id"], __get("VIEWER_CHANNEL_ICON"));
}
return $html;
}
public function getClientIcon($client) {
if($client["client_type"]) {
return $this->getIcon("server_query.svg");
}
if($client["client_away"]) {
return $this->getIcon("away.svg", htmlspecialchars($client["client_away_message"]) ?: __get("VIEWER_CLIENT_AWAY"));
}
if(!$client["client_output_hardware"]) {
return $this->getIcon("hardware_output_muted.svg", __get("VIEWER_CLIENT_OUTPUT_DISABLED"));
}
if($client["client_output_muted"]) {
return $this->getIcon("output_muted.svg", __get("VIEWER_CLIENT_OUTPUT_MUTED"));
}
if(!$client["client_input_hardware"]) {
return $this->getIcon("hardware_input_muted.svg", __get("VIEWER_CLIENT_MIC_DISABLED"));
}
if($client["client_input_muted"]) {
return $this->getIcon("input_muted.svg", __get("VIEWER_CLIENT_MIC_MUTED"));
}
if($client["client_is_channel_commander"]) {
return $this->getIcon("player_commander_off.svg", __get("VIEWER_CLIENT_COMMANDER"));
}
return $this->getIcon("player_off.svg");
}
public function getClientSuffixIcons($client, $groups, $cntp) {
$html = "";
if($client["client_is_priority_speaker"]) {
$html .= $this->getIcon("microphone.svg", __get("VIEWER_CLIENT_PRIORITY_SPEAKER"));
}
if($client["client_is_channel_commander"]) {
$html .= $this->getIcon("channel_commander.svg", __get("VIEWER_CLIENT_COMMANDER"));
}
if($client["client_is_talker"]) {
$html .= $this->getIcon("talk_power_grant.svg", __get("VIEWER_CLIENT_TALK_POWER_GRANTED"));
} else if($cntp && $cntp > $client["client_talk_power"]) {
$html .= $this->getIcon("input_muted.svg", __get("VIEWER_CLIENT_TALK_POWER_INSUFFICIENT"));
}
foreach ($groups as $group) {
if ($group["iconid"]) {
$icon = $group["iconid"];
} else {
$icon = "broken_image.svg";
continue;
// If the group does not have an icon, we skip this group.
// However, you can comment out the above "continue" statement
// to show the group with a "broken-image" icons.
}
$html .= $this->getIcon($icon, htmlspecialchars($group["name"]));
}
if($client["client_icon_id"]) {
$html .= $this->getIcon($client["client_icon_id"], __get("VIEWER_CLIENT_ICON"));
}
if($client["client_country"]) {
$country = $client["client_country"];
$countryLower = strtolower($country);
$html .= '<i class="icon-flag famfamfam-flags ' . $countryLower . '" ' .
'data-toggle="tooltip" title="' . $country . '" aria-hidden="true"></i>';
}
return $html;
}
}

View File

@@ -0,0 +1,28 @@
<?php
define("__TSWEBSITE_VERSION", "dev-2.0.0");
define("__BASE_DIR", __DIR__ . "/../..");
define("__PRIVATE_DIR", __BASE_DIR . "/private");
define("__CACHE_DIR", __PRIVATE_DIR . "/cache");
define("__TEMPLATES_DIR", __PRIVATE_DIR . "/templates");
define("__CONFIG_FILE", __PRIVATE_DIR . "/dbconfig.php");
define("__LOCALDB_FILE", __PRIVATE_DIR . "/.sqlite.db.php");
define("__INSTALLER_LOCK_FILE", __PRIVATE_DIR . "/INSTALLER_LOCK");
define("__DEV_MODE", defined("DEV_MODE") || getenv("DEV_MODE") || file_exists(__PRIVATE_DIR . "/dev_mode"));
// utf8_encode polyfill - function required by TS3PHPFramework
// Taken from: https://github.com/symfony/polyfill (MIT License)
if (!function_exists("utf8_encode")) {
define("__USING_U8ENC_POLYFILL", true);
function utf8_encode($s) {
$s .= $s;
$len = strlen($s);
for ($i = $len >> 1, $j = 0; $i < $len; ++$i, ++$j) {
switch (true) {
case $s[$i] < "\x80": $s[$j] = $s[$i]; break;
case $s[$i] < "\xC0": $s[$j] = "\xC2"; $s[++$j] = $s[$i]; break;
default: $s[$j] = "\xC3"; $s[++$j] = chr(ord($s[$i]) - 64); break;
}
}
return substr($s, 0, $j);
}
}

87
src/private/php/load.php Normal file
View File

@@ -0,0 +1,87 @@
<?php
use Wruczek\TSWebsite\Config;
use Wruczek\TSWebsite\ServerIconCache;
use Wruczek\TSWebsite\Utils\CsrfUtils;
use Wruczek\TSWebsite\Utils\Language\LanguageUtils;
session_name("tswebsite_sessionid");
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
define("__RENDER_START", microtime(true));
require_once __DIR__ . "/constants.php";
@header("TSW_DevMode: " . (__DEV_MODE ? "enabled" : "disabled"));
if(__DEV_MODE) {
ini_set("display_errors", 1);
ini_set("display_startup_errors", 1);
error_reporting(E_ALL);
}
if(!file_exists(__INSTALLER_LOCK_FILE)) {
if(file_exists(__BASE_DIR . "/installer")) {
header("Location: installer/index.php");
} else {
echo '&#129300; Something is not right! Looks like the website is not installed ("private/INSTALLER_LOCK" not found or is empty), but ' .
'installation wizard folder "installer" cannot be found! Please start the installation again and follow installation guide step-by-step.';
}
exit;
}
require_once __PRIVATE_DIR . "/vendor/autoload.php";
// Check CSRF token if needed and validate it
if (!defined("DISABLE_CSRF_CHECK") &&
in_array($_SERVER["REQUEST_METHOD"], ["POST", "PUT", "DELETE", "PATCH"])
) {
CsrfUtils::validateRequest();
}
// Try to guess user language and store it
// If the current language is not defined, or is invalid then return to default
if(!isset($_SESSION["userlanguageid"])) {
$lang = LanguageUtils::i()->detectUserLanguage();
if(!$lang) {
$lang = LanguageUtils::i()->getDefaultLanguage();
}
$_SESSION["userlanguageid"] = $lang->getLanguageId();
}
// Shortcut to language functions
{
/**
* Shortcut to translate and output the result
*/
function __($identifier, $args = []) {
echo __get($identifier, $args);
}
/**
* Shortcut to translate and return the result
*/
function __get($identifier, $args = []) {
try {
return LanguageUtils::i()->translate($identifier, $args);
} catch (\Exception $e) {
return "(unknown translation for " . htmlspecialchars($identifier) . ")";
}
}
}
// Set timezone
date_default_timezone_set(Config::get("timezone"));
// Init TS3 library
// This makes it possible to cache TS3 library objects
TeamSpeak3::init();
// Sync server icon cache if needed
ServerIconCache::syncIfNeeded();

View File

@@ -0,0 +1,126 @@
{* This file is a little hard to read... sorry! :( *}
{define admin-status-template}
{var $isOnline = isset($client["clid"])}
<div n:class="status-container, !$isOnline ? status-offline">
<span class="nickname">
{if $iconBeforeName}
{if $group["iconid"]}
<img src="api/geticon.php?iconid={$group["iconid"]}" alt="{$group["name"]}" data-toggle="tooltip" title="{$group["name"]}">
{else}
{$group["name"]}
{/if}
{/if}
{$client["client_nickname"]}
</span>
<span class="status">
{if $isOnline}
{if $client["client_away"]}
{ifset $client["client_away_message"]}
<span class="badge badge-info" data-toggle="tooltip" title="{$client["client_away_message"]}">
{_"ADMIN_STATUS_AWAY"}
<i class="far fa-comment-dots mr-0"></i>
</span>
{else}
<span class="badge badge-info">{_"ADMIN_STATUS_AWAY"}</span>
{/ifset}
{else}
<span class="badge badge-success">{_"ADMIN_STATUS_ONLINE"}</span>
{/if}
{else}
<span class="badge badge-secondary">{_"ADMIN_STATUS_OFFLINE"}</span>
{/if}
</span>
</div>
{/define}
{* STATUS_STYLE_GROUPED or STATUS_STYLE_GROUPED_HIDE_EMPTY_GROUPS *}
{if $format === 1 || $format === 2}
<div class="admin-status admin-status-grouped" n:attr="data-hidebydefault => $defaultHide ? 'true' : 'false'">
{if !$data}
<div class="text-center">
<span class="badge badge-info error-badge">
<i class="fas fa-info-circle"></i>{_"ADMIN_STATUS_EMPTY_STATUS"}
</span>
</div>
{/if}
{foreach $data as $sgid => $group}
{* additional div, mainly used for additional *}
{* styling with css for the first group *}
<div n:ifcontent>
{* Only show group header when there are clients *}
{* in the group OR if the format allows us *}
{* to show empty groups *}
{if $format !== 2 || $group["clients"]}
<div class="group-name">
{if $group["iconid"]}
<img src="api/geticon.php?iconid={$group["iconid"]}" alt="{$group["name"]}">
{/if}
{$group["name"]}
</div>
{if !$group["clients"]}
<div class="empty-group">{_"ADMIN_STATUS_EMPTY_GROUP"}</div>
{/if}
{/if}
{foreach $group["clients"] as $client}
{include admin-status-template, iconBeforeName => false, client => $client, group => $group}
{/foreach}
</div>
{/foreach}
</div>
{/if}
{* STATUS_STYLE_LIST *}
{if $format === 3}
<div class="admin-status admin-status-list">
{if !$data}
<div class="text-center">
<span class="badge badge-info error-badge">
<i class="fas fa-info-circle"></i>{_"ADMIN_STATUS_EMPTY_STATUS"}
</span>
</div>
{/if}
{var $lastGroup = null}
{foreach $data as $sgid => $group}
{foreach $group["clients"] as $client}
{* This thing detects when we are looping though a new group *}
{* and before we start printing clients from it, it inserts a *}
{* group separator that creates a little space between different groups *}
{if $lastGroup !== $group}
{* here we check if $lastGroup is not NULL *}
{* this way we dont put a spacer before the first group *}
{if $lastGroup}
<div class="group-separator"></div>
{/if}
{var $lastGroup = $group}
{/if}
{include admin-status-template, iconBeforeName => true, client => $client, group => $group}
{/foreach}
{/foreach}
</div>
{/if}
{* STATUS_STYLE_LIST_ONLINE_FIRST *}
{if $format === 4}
<div class="admin-status admin-status-list">
{if !$data}
<div class="text-center">
<span class="badge badge-info error-badge">
<i class="fas fa-info-circle"></i>{_"ADMIN_STATUS_EMPTY_STATUS"}
</span>
</div>
{/if}
{foreach $data as $entry}
{include admin-status-template, iconBeforeName => true, client => $entry["client"], group => $entry["group"]}
{/foreach}
</div>
{/if}

View File

@@ -0,0 +1,122 @@
{extends "body.latte"}
{var title = __get("ASSIGNER_TITLE")}
{var navActiveIndex = 2}
{block content}
<div class="card card-accent">
<div class="card-header bigger-title">
<i class="fas fa-gamepad"></i>{_"ASSIGNER_PANEL_TITLE"}
</div>
<div class="card-body group-assigner">
{if !$isLoggedIn}
<div class="text-center">
<h4 class="mb-4">{_"ASSIGNER_NOT_LOGGED_IN"}</h4>
<button type="button" class="btn btn-primary btn-lg" data-openLoginModal>
<i class="fas fa-sign-in-alt"></i>{_"ASSIGNER_LOGIN_BUTTON"}
</button>
</div>
{elseif $assignerConfig === null}
{include "utils/data-problem.latte", message => __get("CANNOT_GET_DATA", "assigner")}
{elseif empty($assignerConfig)}
<div class="alert alert-info">
<i class="fas fa-info-circle"></i>{_"ASSIGNER_NOT_CONFIGURED"}
</div>
{else}
{ifset $groupChangeStatus}
{if $groupChangeStatus === 0} {* saved *}
<div class="alert alert-success alert-dismissible fade show" role="alert">
<i class="far fa-check-circle"></i>{_"ASSIGNER_SAVE_SUCCESS"}
<button type="button" class="close" data-dismiss="alert" aria-label="{_"ARIA_CLOSE"}">
<span aria-hidden="true">&times;</span>
</button>
</div>
{elseif $groupChangeStatus === 1} {* no change *}
<div class="alert alert-info alert-dismissible fade show" role="alert">
<i class="fas fa-info-circle"></i>{_"ASSIGNER_SAVE_NO_CHANGE"}
<button type="button" class="close" data-dismiss="alert" aria-label="{_"ARIA_CLOSE"}">
<span aria-hidden="true">&times;</span>
</button>
</div>
{else} {* something went wrong *}
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="fas fa-exclamation-circle"></i>{_"ASSIGNER_SAVE_ERROR"}
<button type="button" class="close" data-dismiss="alert" aria-label="{_"ARIA_CLOSE"}">
<span aria-hidden="true">&times;</span>
</button>
</div>
{/if}
{/ifset}
<form id="assigner-form" method="post">
{foreach $assignerConfig as $row}
<div class="row justify-content-md-center">
{foreach $row as $assignerCategory}
<div class="col-md-6">
<ul class="list-group assigner-category" data-maxgroups="{$assignerCategory["max"]}">
<li class="list-group-item assigner-header">
<div>
<i class="{$assignerCategory["icon"]}"></i>{$assignerCategory["name"]}
</div>
<div>
<span class="badge badge-primary badge-pill"></span>
</div>
</li>
{foreach $assignerCategory["groups"] as $group}
<button type="button" class="list-group-item list-group-item-action">
<div>
{if $group["iconid"]}
<img src="api/geticon.php?iconid={$group["iconid"]}" alt="{$group["name"]} icon" class="assigner-icon">
{else}
<span class="assigner-icon-margin"></span>
{/if}
{$group["name"]}
</div>
<div>
<div class="custom-control custom-checkbox">
<input type="checkbox" class="custom-control-input"
id="assignerCheck{$group["sgid"]}"
name="assigner[{$group["sgid"]}]"
n:attr="checked => $group['assigned']">
<label class="custom-control-label" for="assignerCheck{$group["sgid"]}">&nbsp;</label>
</div>
</div>
</button>
{/foreach}
</ul>
</div>
{/foreach}
</div>
{/foreach}
{$csrfField}
<!-- Pass this field so even when someone un-checks all of the groups -->
<!-- there will still be something send to the server, and script will remove -->
<!-- all of the user's groups. the "dummy" will get filtered out with array_filter -->
<input type="hidden" name="assigner[dummy]">
<div class="text-center">
<button type="submit" class="btn btn-primary btn-lg assigner-save">
<i class="far fa-save"></i>{_"ASSIGNER_SAVE_BUTTON"}
</button>
<div>
<div class="alert alert-danger mt-3 invalid-groups-alert" style="display: none">
<i class="fa fa-exclamation-circle"></i>{_"ASSIGNER_INVALID_GROUPS"}
</div>
</div>
</div>
</form>
{/if}
</div>
</div>
{/block}
{block footerbottom}
{$tplutils::includeScript("js/assigner.js", true)}
{/block}

View File

@@ -0,0 +1,72 @@
{extends "body.latte"}
{var title = __get("BANS_TITLE")}
{var navActiveIndex = 3}
{block content}
<div class="card card-accent">
<div class="card-header bigger-title">
<i class="fas fa-ban"></i>{_"BANS_PANEL_TITLE"}
</div>
<div class="card-body">
{if $banlist === null || $banlist === false}
{include "utils/data-problem.latte", message => __get("CANNOT_GET_DATA", "banlist")}
{elseif empty($banlist)}
<div class="alert alert-info text-center" role="alert">
{_"BANS_EMPTY"}
</div>
{else}
<div id="responsive-table-details-tip" class="alert alert-info alert-dismissible fade show" style="display: none" role="alert">
<i class="fas fa-info-circle"></i>{_"BANS_VIEW_MORE_TIP"}
<button type="button" class="close" data-dismiss="alert" aria-label="{_"ARIA_CLOSE"}">
<span aria-hidden="true">&times;</span>
</button>
</div>
{if $ipbanned !== false}
<div class="alert alert-danger ban-alert banned" role="alert">
<i class="fas fa-exclamation-circle fa-2x"></i>
<div>
<p><b>{_"BANS_BANNED_ALERT_TITLE", $ipbanned["invoker"]}</b></p>
{if $ipbanned["reason"]}
{_"BANS_BANNED_ALERT_REASON", $ipbanned["reason"]}
{/if}
</div>
</div>
{/if}
<table id="banlist" class="table table-responsive">
<thead>
<tr>
<th data-priority="1">{_"BANS_HEADER_NAME"}</th>
<th data-priority="3">{_"BANS_HEADER_REASON"}</th>
<th>{_"BANS_HEADER_INVOKER"}</th>
<th>{_"BANS_HEADER_BANDATE"}</th>
<th data-priority="2">{_"BANS_HEADER_EXPIRES"}</th>
</tr>
</thead>
<tbody>
{foreach $banlist as $ban}
{var $expiretime = $ban["created"] + $ban["duration"]}
<tr>
<td>{$ban["name"]}</td>
<td>{$ban["reason"]}</td>
<td>{$ban["invoker"]}</td>
<td data-order="{$ban["created"]}">{$ban["created"]|fullDate}</td>
<td data-order="{$ban["duration"]}">{$ban["duration"] ? ($expiretime|fuzzyDateAbbr) : ("BANS_NEVEREXPIRES"|translate)}</td>
</tr>
{/foreach}
</tbody>
</table>
{/if}
</div>
</div>
{/block}
{block footerbottom}
<script>
var DATATABLES_LANGUAGE_NAME = {_"DATATABLES_LANGUAGE_NAME"}
</script>
{$tplutils::includeScript("js/bans.js", true)}
{/block}

View File

@@ -0,0 +1,219 @@
{php
$utils = Wruczek\TSWebsite\Utils\Utils::class;
$tplutils = Wruczek\TSWebsite\Utils\TemplateUtils::i();
$auth = Wruczek\TSWebsite\Auth::class;
$userlangcode = $userLanguage->getLanguageCode();
$navActiveIndex = isset($navActiveIndex) ? (int) $navActiveIndex : 0;
}<!DOCTYPE html>
<html n:attr="lang => $userlangcode">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="generator" content="TS-website {\__TSWEBSITE_VERSION} by Wruczek / https://github.com/Wruczek/ts-website">
<meta name="csrf-token" content="{$csrfToken}">
<!-- TODO make it use the language variable "WEBSITE_TITLE" after the ACP update -->
<title>{$title} | {$config["website_title"]}</title>
<!-- Bootstrap 4.1.3 -->
{$tplutils::includeStylesheet("{cdnjs}/twitter-bootstrap/4.1.3/css/bootstrap.min.css", "sha256-eSi1q2PG6J7g7ib17yAaWMcrr5GrtohYChqibrV7PBE=")}
<!-- FontAwesome CSS 5.2.0 -->
{$tplutils::includeStylesheet("https://use.fontawesome.com/releases/v5.2.0/css/all.css", "sha256-iJGhYPiir7gd5SWfn2jlrzeCNI6iknrZ6Wm8iMfTmYQ=")}
<!-- DataTables 1.10.19 -->
{$tplutils::includeStylesheet("{cdnjs}/datatables/1.10.19/css/dataTables.bootstrap4.min.css", "sha256-F+DaKAClQut87heMIC6oThARMuWne8+WzxIDT7jXuPA=")}
<!-- Internal scripts -->
{$tplutils::includeStylesheet("css/flags/famfamfam-flags.min.css", true)}
{$tplutils::includeStylesheet("css/style.css", true)}
{$tplutils::includeStylesheet("css/loader.css", true)}
{$tplutils::includeStylesheet("css/cookiealert.css", true)}
{$tplutils::includeStylesheet("css/themes/dark.css", true)}
{if __DEV_MODE}
<!-- Dev script - added when dev mode is on -->
{$tplutils::includeStylesheet("css/dev.css", true)}
{/if}
</head>
<body n:attr="class => 'lang ' . $userlangcode">
<noscript>
<div class="alert alert-danger noscript-alert bottom-error-alert" role="alert">
{_"NO_JAVASCRIPT_ENABLED"}
</div>
</noscript>
<div class="alert alert-danger oldbrowser-alert bottom-error-alert" role="alert" style="display: none">
{_"UNSUPPORTED_BROWSER"}
</div>
{if !$auth::isLoggedIn()}
{include "utils/modal-login.latte"}
{/if}
<nav id="main-navbar" class="navbar navbar-expand-lg fixed-top nav-fix-scroll navbar-dark bg-primary">
<div class="container">
<a class="navbar-brand" href=".">
<img src="img/icon/defaulticon-64.png" width="32" height="32" class="d-inline-block align-top mr-1" alt="Brand image">
{$config["nav_brand"]}
</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#mainNavbarNavDropdown"
aria-controls="mainNavbarNavDropdown" aria-expanded="false" aria-label="{_"NAV_TOGGLE"}">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="mainNavbarNavDropdown">
<ul class="navbar-nav">
<li class="nav-item{if $navActiveIndex === 1} active{/if}">
<a class="nav-link" href="viewer.php"><i class="fas fa-eye"></i>{_"NAV_VIEWER"}</a>
</li>
<li class="nav-item{if $navActiveIndex === 2} active{/if}">
<a class="nav-link" href="assigner.php"><i class="fas fa-gamepad"></i>{_"NAV_ASSIGNER"}</a>
</li>
<li class="nav-item{if $navActiveIndex === 3} active{/if}">
<a class="nav-link" href="bans.php"><i class="fas fa-ban"></i>{_"NAV_BANS"}</a>
</li>
<li class="nav-item{if $navActiveIndex === 4} active{/if}">
<a class="nav-link" href="rules.php"><i class="fas fa-book"></i>{_"NAV_RULES"}</a>
</li>
<li class="nav-item{if $navActiveIndex === 5} active{/if}">
<a class="nav-link" href="faq.php"><i class="far fa-question-circle"></i>{_"NAV_FAQ"}</a>
</li>
</ul>
<ul class="navbar-nav ml-md-auto">
<li class="nav-item">
{if $auth::isLoggedIn()}
<li class="nav-item dropdown">
<a class="nav-link login-button text-truncate dropdown-toggle" href="#" id="navbarDropdownUser" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<i class="fas fa-user"></i>{$auth::getNickname()}
</a>
<div class="dropdown-menu language-switcher" aria-labelledby="navbarDropdownUser">
<button class="dropdown-item logoutUser"><i class="fas fa-sign-out-alt"></i>{_"NAV_ACCOUNT_LOGOUT"}</button>
</div>
</li>
{else}
<a class="nav-link" href="#" data-openLoginModal><i class="fas fa-sign-in-alt"></i>{_"NAV_ACCOUNT_LOGIN"}</a>
{/if}
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdownLanguageChooser" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<i class="fas fa-language"></i>{$userLanguage->getLanguageNameNative()}
</a>
<form method="post" action="api/setlang.php">
{$csrfField}
<input type="hidden" name="return-to" value="{$_SERVER['PHP_SELF']}">
<div class="dropdown-menu language-switcher" aria-labelledby="navbarDropdownLanguageChooser">
<button name="lang" class="dropdown-item{if $lang->getLanguageCode() === $userlangcode} active{/if}"
n:foreach="$languageList as $lang"
n:attr="value => $lang->getLanguageCode()">{$lang->getLanguageNameNative()}</button>
</div>
</form>
</li>
</ul>
</div>
</div>
</nav>
<div class="container">
{ifset $oldestTimestamp}
{include "utils/data-problem.latte", message => ("OUTDATED_DATA"|translate,($oldestTimestamp|fuzzyDateAbbr))}
{/ifset}
{if $tsExceptions}
{include "utils/data-problem.latte", message => "We are having trouble communicating with the server. Some parts of the website might not be working as expected."}
{/if}
<div class="row">
<!-- 1st column -->
<div class="col-lg-9">
{block content}{/block}
</div>
<!-- 2nd column -->
<div class="col-lg-3">
{include "sidebar.latte"}
</div>
</div>
</div>
<!-- COOKIES -->
{include "utils/cookiealert.latte"}
<!-- /COOKIES -->
<footer class="footer d-md-flex">
<div>
&copy; WebsiteTitle.tech {date("Y")}
{if $config["imprint_enabled"]}3
&mdash; <a href="{$config["imprint_url"]}">imprint</a>
{/if}
</div>
<div class="ml-auto footer-copyright">
<!-- Please respect the amount of work we've put for free into this project and leave the authors in the footer. Thanks! -->
<a href="https://github.com/Wruczek/ts-website" target="_blank">ts-website</a> v {\__TSWEBSITE_VERSION} &mdash;
&copy; <a href="https://wruczek.tech/?source=tsw" target="_blank">Wruczek</a> 2017 - 2019
</div>
</footer>
<!-- JAVASCRIPT -->
<script>
// Simple error handler
window.onerror = function (msg, url, linenumber) {
// TODO: log to acp and deal with it, also better display (notification?)
alert('Javascript error occurred: ' + msg + '\nURL: ' + url + '\nLine Number: ' + linenumber);
return false // still run the default handler
}
</script>
<!-- jQuery 3.3.1 -->
{$tplutils::includeScript("{cdnjs}/jquery/3.3.1/jquery.min.js", "sha256-FgpCb/KJQlLNfOu91ta32o/NMZxltwRo8QtmkMRdAu8=")}
<!-- Popper.js UMD 1.14.4 -->
{$tplutils::includeScript("{cdnjs}/popper.js/1.14.4/umd/popper.min.js", "sha256-EGs9T1xMHdvM1geM8jPpoo8EZ1V1VRsmcJz8OByENLA=")}
<!-- Bootstrap 4.1.3 -->
{$tplutils::includeScript("{cdnjs}/twitter-bootstrap/4.1.3/js/bootstrap.min.js", "sha256-VsEqElsCHSGmnmHXGQzvoWjWwoznFSZc6hs7ARLRacQ=")}
<!-- JS-Cookie 2.2.0 -->
{$tplutils::includeScript("{cdnjs}/js-cookie/2.2.0/js.cookie.min.js", "sha256-9Nt2r+tJnSd2A2CRUvnjgsD+ES1ExvjbjBNqidm9doI=")}
<!-- TODO: as soon as day.js matures with more languages, replace this obese library with it -->
<!-- Moment.js 2.22.2 -->
{$tplutils::includeScript("{cdnjs}/moment.js/2.22.2/moment.min.js", "sha256-CutOzxCRucUsn6C6TcEYsauvvYilEniTXldPa6/wu0k=")}
{var momentjslang = Wruczek\TSWebsite\Utils\Language\LanguageUtils::tl("MOMENTJS_LANG")}
{if $momentjslang !== "en-us"}
{$tplutils::includeScript("{cdnjs}/moment.js/2.22.2/locale/$momentjslang.js")}
<script>
moment.locale({$momentjslang} || navigator.languages || navigator.language)
</script>
{/if}
<!-- DataTables 1.10.19 + Support for Bootstrap 4 -->
{$tplutils::includeScript("{cdnjs}/datatables/1.10.19/js/jquery.dataTables.min.js", "sha256-t5ZQTZsbQi8NxszC10CseKjJ5QeMw5NINtOXQrESGSU=")}
{$tplutils::includeScript("{cdnjs}/datatables/1.10.19/js/dataTables.bootstrap4.min.js", "sha256-hJ44ymhBmRPJKIaKRf3DSX5uiFEZ9xB/qx8cNbJvIMU=")}
<!-- DataTables responsive plugin + Bootstrap 4 support -->
{$tplutils::includeScript("https://cdn.datatables.net/responsive/2.2.3/js/dataTables.responsive.min.js", "sha256-7Tbik5KSODuGiOLIOFfhP47p5UK6h1wzw8CFSI/TKhc=")}
{$tplutils::includeScript("https://cdn.datatables.net/responsive/2.2.3/js/responsive.bootstrap4.min.js", "sha256-aXVO47Rb7s58FhMTCwbM39en/1XcmzGkClRzBe5txKs=")}
<!-- script.js -->
{$tplutils::includeScript("js/script.js", true)}
{$tplutils::includeScript("js/status.js", true)}
{$tplutils::includeScript("js/login.js", true)}
{block footerbottom}{/block}
<!--
TS-website {\__TSWEBSITE_VERSION} by Wruczek
Generated in {@$tplutils::getRenderTime()} s
MySQL queries: {$sqlCount}
-->
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,131 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="generator" content="TS-website {\__TSWEBSITE_VERSION} by Wruczek / https://github.com/Wruczek/ts-website">
<title>{$errorname}</title>
<!-- FontAwesome CSS 5.2.0 -->
<!-- Used for the warning triangle in the background. Probably overkill, svg would be enough... -->
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.2.0/css/all.css"
integrity="sha256-iJGhYPiir7gd5SWfn2jlrzeCNI6iknrZ6Wm8iMfTmYQ=" crossorigin="anonymous">
<style>
html, body {
margin: 0;
padding: 0;
}
body {
background: #2c3e50;
color: #ecf0f1;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
line-height: 1.42857143;
font-weight: 300;
font-size: 16px;
}
.flex {
display: flex;
height: 100vh;
}
.container {
margin: auto;
text-align: center;
max-width: 450px;
}
.errorcode {
font-size: 96px;
}
.errorname {
font-size: 24px;
margin-bottom: 10px;
}
.errordescription {
font-family: monospace, sans-serif;
}
.dark-color {
color: #9E9E9E;
}
.actions-container {
margin-top: 20px;
}
.actions-container > a {
text-decoration: none;
color: #42a5f5;
}
.actions-container > a:hover {
text-decoration: underline;
}
.horizontal-links-margin {
margin: 0 0.55rem;
}
.horizontal-links-margin:before {
content: "|";
}
/* Icon */
.warning-icon-container {
position: absolute;
display: flex;
align-items: center;
height: 100vh;
}
.warning-icon {
position: absolute;
left: -20vh;
opacity: .1;
font-size: 70vh;
z-index: -10;
}
</style>
</head>
<body>
<div class="flex">
<div class="warning-icon-container">
<i class="fas fa-exclamation-triangle warning-icon"></i>
</div>
<div class="container">
<div class="errorcode" n:ifcontent>
{$errorcode}
</div>
{* Add dark-color when description is not set *}
<div n:class="errorname, !$description ? dark-color" n:ifcontent>
{$errorname}
</div>
<div class="errordescription dark-color" n:ifcontent>
{$description}
</div>
<div class="actions-container">
<a href="#" onclick="location.reload()">Try again</a>
<span class="horizontal-links-margin"></span>
<a href="#" onclick="history.back()">Go back</a>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,43 @@
{extends "body.latte"}
{var title = __get("FAQ_TITLE")}
{var navActiveIndex = 5}
{block content}
<div class="card card-accent">
<div class="card-header bigger-title">
<i class="far fa-question-circle"></i>{_"FAQ_PANEL_TITLE"}
</div>
<div class="card-body">
{$additionaltext|noescape}
<div class="accordion" id="faqaccordion">
{foreach $qa as $id => $value}
<div class="card">
<div class="card-header" id="faqcollapse-heading-{$id}">
<button class="btn btn-link" type="button" data-toggle="collapse" data-target="#faqcollapse-{$id}" aria-expanded="true" aria-controls="faqcollapse-{$id}">
{$id + 1}. {$value["question"]|noescape}
</button>
<i class="fas fa-link copy-faq-url" data-faqid="{$id}" data-toggle="tooltip" title="{_"FAQ_COPY_LINK"}"></i>
</div>
<div id="faqcollapse-{$id}" class="collapse" aria-labelledby="faqcollapse-heading-{$id}" data-parent="#faqaccordion">
<div class="card-body">
{$value["answer"]|noescape}
</div>
</div>
</div>
{/foreach}
</div>
</div>
</div>
{/block}
{block footerbottom}
<script>
var FAQ_LANG = {
copy_success: {_"FAQ_COPY_LINK_SUCCESS"},
copy_error: {_"FAQ_COPY_LINK_ERROR"}
}
</script>
{$tplutils::includeScript("js/faq.js", true)}
{/block}

View File

@@ -0,0 +1,82 @@
{extends "body.latte"}
{var title = __get("HOME_TITLE")}
{block content}
<div class="card card-titleblock card-accent">
<div class="card-header">
<i class="far fa-newspaper"></i>{_"HOME_PANEL_TITLE"}
</div>
</div>
{if $newsList === null || $newsList === false}
{include "utils/data-problem.latte", message => __get("CANNOT_GET_DATA", "news")}
{elseif $newsCount === 0}
<div class="alert alert-info text-center" role="alert">
{_"HOME_EMPTY"}
</div>
{elseif !$newsList}
<div class="alert alert-danger" role="alert">
<i class="fas fa-exclamation-circle"></i>{_"HOME_INVALID_PAGE"}
</div>
{else}
{foreach $newsList as $news}
<div class="card">
<div class="card-header">
<h5>
{ifset $news["link"]}
<a href="{$news["link"]}" n:attr="target => $news['external'] ? '_blank'">
{$news["title"]}
</a>
{else}
{$news["title"]}
{/ifset}
</h5>
</div>
<div class="card-body">
<p class="card-text">{$news["description"]|noescape}</p>
</div>
</div>
{/foreach}
{* Pagination logic *}
{* show the pagination only if we have more than 1 page *}
<div n:if="$pageCount > 1" class="text-center mt-4">
<nav>
<ul class="pagination justify-content-center">
{* If we are not on the first page, show the "previous page" button *}
{if $currentPage !== 1}
{* If the previous page is page #1, link directly to the index, otherwise to "index?page=id" *}
<li class="page-item">
<a class="page-link light-hover" href="{if $currentPage -1 !== 1}?page={$currentPage - 1}{else}.{/if}" aria-label="{_"HOME_PREVIOUS_NEWS"}">
<span aria-hidden="true"><i class="fas fa-chevron-left mr-0"></i></span>
<span class="sr-only">{_"HOME_PREVIOUS_NEWS"}</span>
</a>
</li>
{/if}
{* Loop through the pages to be displayed. Display 5 buttons: *}
{* page -2, page -1, current page, page + 1, page + 2 *}
{* using max / min, limit the range to the minimum: 1, and maximum: total number of pages *}
{* so that we never show page 0 or -1, and also never show more pages that we actually have *}
{* Shot out from Wruczek to anyone reading this xD *}
{foreach range(max($currentPage - 2, 1), min($currentPage + 2, $pageCount)) as $page}
{* If the previous page is page #1, link directly to "/news", otherwise to "/news/PageNum" *}
<li class="page-item{if $page === $currentPage} active{/if}">
<a class="page-link light-hover" href="{if $page !== 1}?page={$page}{else}.{/if}">{$page}</a>
</li>
{/foreach}
{* If we are not on the last page, show the "next page" button *}
{if $currentPage !== $pageCount}
<li class="page-item">
<a class="page-link light-hover" href="?page={$currentPage + 1}" aria-label="{_"HOME_NEXT_NEWS"}">
<span aria-hidden="true"><i class="fas fa-chevron-right mr-0"></i></span>
<span class="sr-only">{_"HOME_NEXT_NEWS"}</span>
</a>
</li>
{/if}
</ul>
</nav>
</div>
{/if}
{/block}

View File

@@ -0,0 +1,91 @@
<div class="card card-accent">
<div class="card-header">
<i class="fas fa-signal"></i>{_"STATUS_PANEL_TITLE"}
</div>
<div class="card-body server-status">
<div class="status-loader position-relative p-3">
<div class="loader"></div>
</div>
<div class="data" style="display: none">
<!-- The NBSP is here to preserve a space when the text gets truncated -->
<p>
<span><i class="fas fa-globe fa-fw"></i>{_"STATUS_ADDRESS"}&nbsp;</span>
<span><a href="ts3server://{$config["query_displayip"]}">{$config["query_displayip"]}</a></span>
</p>
<p>
<span><i class="fas fa-power-off fa-fw"></i>{_"STATUS_CLIENTS_ONLINE"}&nbsp;</span>
<span class="badge badge-secondary" data-toggle="tooltip" data-translation="{_"STATUS_RESERVED_SLOTS"}"></span>
</p>
<p>
<span><i class="fas fa-medal fa-fw"></i>{_"STATUS_TOP_ONLINE"}&nbsp;</span>
<span class="badge badge-secondary" data-toggle="tooltip" data-translation="{_"STATUS_TOP_ONLINE_DESC"}"></span>
</p>
<p>
<span><i class="far fa-clock fa-fw"></i>{_"STATUS_UPTIME"}&nbsp;</span>
<span class="badge badge-secondary"></span></p>
<p>
<span><i class="fas fa-info-circle fa-fw"></i>{_"STATUS_VERSION"}&nbsp;</span>
<span class="badge badge-secondary" data-toggle="tooltip" data-translation="{_"STATUS_VERSION_DESC"}"></span>
</p>
<p>
<span><i class="fas fa-signal fa-fw"></i>{_"STATUS_PING"}&nbsp;</span>
<span class="badge badge-secondary"></span>
</p>
<p>
<span><i class="fas fa-bolt fa-fw"></i>{_"STATUS_PACKETLOSS"}&nbsp;</span>
<span class="badge badge-secondary"></span>
</p>
</div>
<div class="error text-center" style="display: none">
<span class="badge badge-danger error-badge"><i class="fas fa-exclamation-circle"></i>{_"STATUS_ERROR"}</span>
</div>
</div>
</div>
{ifset $adminStatus}
{var offlineHiddenByDefault = $config["adminstatus_offlinehiddenbydefault"]}
<div class="card card-accent">
<div class="card-header d-flex justify-content-between align-items-center">
<span>
<i class="fas fa-user-shield"></i>{_"ADMIN_STATUS_PANEL_TITLE"}
</span>
<span>
<button type="button"
class="btn btn-primary btn-xs"
data-adminstatusoffline="hide"
data-toggle="tooltip"
title="{_"ADMIN_STATUS_HIDE_OFFLINE_TIP"}"
n:attr="style => $offlineHiddenByDefault ? 'display: none'"
>
<i class="far fa-eye mr-0"></i>
</button>
<button type="button"
class="btn btn-primary btn-xs"
data-adminstatusoffline="show"
data-toggle="tooltip"
title="{_"ADMIN_STATUS_SHOW_OFFLINE_TIP"}"
n:attr="style => !$offlineHiddenByDefault ? 'display: none'"
>
<i class="far fa-eye-slash mr-0"></i>
</button>
</span>
</div>
<div class="card-body">
{if $adminStatus !== false}
{include "admin-status.latte",
data => $adminStatus["data"],
format => $adminStatus["format"],
defaultHide => $offlineHiddenByDefault
}
{else}
<div class="text-center">
<span class="badge badge-danger error-badge">
<i class="fas fa-exclamation-circle"></i>{_"ADMIN_STATUS_ERROR"}
</span>
</div>
{/if}
</div>
</div>
{/ifset}

View File

@@ -0,0 +1,13 @@
{extends "body.latte"}
{var title = $pagetitle}
{block content}
<div class="card card-accent">
<div class="card-header bigger-title">
{$paneltitle|noescape}
</div>
<div class="card-body">
{$panelcontent|noescape}
</div>
</div>
{/block}

View File

@@ -0,0 +1,7 @@
<div class="alert text-center cookiealert" role="alert">
{_"COOKIEALERT_MESSAGE"}
<button type="button" class="btn btn-primary btn-sm acceptcookies" aria-label="{_"ARIA_CLOSE"}">
{_"COOKIEALERT_AGREE"}
</button>
</div>

View File

@@ -0,0 +1,16 @@
<div class="alert alert-danger clearfix" role="alert">
<i class="fas fa-exclamation-circle"></i>{$message}
{if $tsExceptions}
<a href="#" data-connectionproblem="trigger" class="float-right">
<i class="fas fa-bug" style="color: #000"></i>{_"SHOW_PROBLEMS"}
</a>
<div class="connectionproblems" data-connectionproblem="hidden" style="display: none">
{_"PROBLEMS_DESCRIPTION"}<br>
{foreach $tsExceptions as $e}
<li><code>#{$e->getCode()}: {$e->getMessage()}</code></li>
{/foreach}
</div>
{/if}
</div>

View File

@@ -0,0 +1,11 @@
<!-- Google Analytics -->
<script>
(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
})(window,document,'script','https://www.google-analytics.com/analytics.js','ga');
ga('create', {$GA_TRACKINGID}, 'auto');
ga('send', 'pageview');
</script>
<!-- End GA Code -->

View File

@@ -0,0 +1,82 @@
<!-- Login modal -->
<div class="modal fade" id="loginModal" tabindex="-1" role="dialog" aria-labelledby="loginModalLabel" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="loginModalLabel"><i class="fas fa-sign-in-alt"></i>Login</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="{_"ARIA_CLOSE"}">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
{* Error loading data alert *}
<div class="error-generic alert alert-danger" role="alert" style="display: none">
<i class="fas fa-exclamation-circle"></i>
Error loading login data. Is the server offline?
</div>
{* Error sending code alert *}
<div class="error-sendingcode alert alert-danger" role="alert" style="display: none">
<i class="fas fa-exclamation-circle"></i>
Error sending you the code. Are you online? Does the bot have permissions to poke you?
</div>
{* Loader *}
<div class="status-loader position-relative p-3">
<div class="loader"></div>
</div>
{* Not connected to the server message *}
<div class="not-connected text-center" style="display: none">
<h3>Not connected</h3>
<p>Connect to <a href="ts3server://{$config["query_displayip"]}">{$config["query_displayip"]}</a> and wait ±30 seconds.<br>The website will auto-refresh.</p>
<a href="ts3server://{$config["query_displayip"]}" class="btn btn-primary">
<i class="fas fa-sign-in-alt"></i>Connect
</a>
<p class="mb-3 mt-3 waiting-connect">
<i class="fas fa-sync fa-spin"></i>Waiting for you to connect
</p>
<!-- <p class="mb-0">Still not working? Try <a href="#">other login methods</a></p> -->
</div>
{* Account selector, if we have more than 2 accounts on the same IP address *}
<div class="select-account text-center" style="display: none">
<h3 class="mb-3">Select your account</h3>
<script id="select-account-template" type="text/template">
<button type="button" class="list-group-item list-group-item-action" data-selectaccount="[1]">
[0]<span class="badge badge-secondary badge-pill">ID [1]</span>
</button>
</script>
<div class="list-group">
Loading...
</div>
</div>
{* Confirmation code form *}
<div class="confirmation-code text-center" style="display: none">
<h3>Enter your code</h3>
<p>We have send a confirmation code to <b class="selected-nickname"></b>.</p>
<form id="loginModal-codeconfirm">
<div class="input-group">
<input type="number" class="form-control" placeholder="Confirmation code">
<div class="input-group-append">
<button class="btn btn-secondary">Send</button>
</div>
</div>
<div class="invalid-feedback">
Invalid confirmation code, try again.
</div>
</form>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,14 @@
<!-- Piwik -->
<script type="text/javascript">
var _paq = _paq || [];
_paq.push(['trackPageView']);
_paq.push(['enableLinkTracking']);
(function() {
var u={$PIWIK_URL};
_paq.push(['setTrackerUrl', u+'piwik.php']);
_paq.push(['setSiteId', {$PIWIK_SITE_ID}]);
var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0];
g.type='text/javascript'; g.async=true; g.defer=true; g.src=u+'piwik.js'; s.parentNode.insertBefore(g,s);
})();
</script>
<!-- End Piwik Code -->

View File

@@ -0,0 +1,52 @@
{extends "body.latte"}
{var title = __get("VIEWER_TITLE")}
{var navActiveIndex = 1}
{block content}
<div class="card card-accent">
<div class="card-header bigger-title d-flex justify-content-between align-items-center">
<span>
<i class="fas fa-eye"></i>{_"VIEWER_PANEL_TITLE"}
</span>
<span>
<button type="button" class="btn btn-primary btn-sm" data-emptychannels="hide" data-toggle="tooltip" title="{_"VIEWER_HIDE_EMPTY"}">
<i class="far fa-eye mr-0"></i>
</button>
<button type="button" class="btn btn-primary btn-sm" data-emptychannels="show" style="display: none" data-toggle="tooltip" title="{_"VIEWER_SHOW_EMPTY"}">
<i class="far fa-eye-slash mr-0"></i>
</button>
</span>
</div>
<div class="card-body">
{if $html === null}
{include "utils/data-problem.latte", message => __get("CANNOT_GET_DATA", "viewer")}
{else}
<div id="server-viewer-tip" class="alert alert-info alert-dismissible fade show" style="display: none" role="alert">
<i class="fas fa-info-circle"></i>{_"VIEWER_TIP_ALERT"}
<button type="button" class="close" data-dismiss="alert" aria-label="{_"ARIA_CLOSE"}">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="viewer-container">
{$html|noescape}
</div>
{/if}
</div>
</div>
{/block}
{block footerbottom}
<script>
var TS3_DISPLAY_IP = {$config["query_displayip"]}
var VIEWER_LANG = {
connection_alert: {_"VIEWER_CONNECTION_CONFIRMATION"},
client_info: {_"VIEWER_CLIENT_TITLE"},
last_active: {_"VIEWER_CLIENT_LASTACTIVE"},
online_time: {_"VIEWER_CLIENT_ONLINE"},
first_joined: {_"VIEWER_CLIENT_JOINED"},
viewer_error: {_"VIEWER_ERROR"}
}
</script>
{$tplutils::includeScript("js/viewer.js", true)}
{/block}