diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 000000000..bdb0cabc8
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,17 @@
+# Auto detect text files and perform LF normalization
+* text=auto
+
+# Custom for Visual Studio
+*.cs diff=csharp
+
+# Standard to msysgit
+*.doc diff=astextplain
+*.DOC diff=astextplain
+*.docx diff=astextplain
+*.DOCX diff=astextplain
+*.dot diff=astextplain
+*.DOT diff=astextplain
+*.pdf diff=astextplain
+*.PDF diff=astextplain
+*.rtf diff=astextplain
+*.RTF diff=astextplain
diff --git a/.gitignore b/.gitignore
index ab7a3e9cb..0f9021904 100644
--- a/.gitignore
+++ b/.gitignore
@@ -47,5 +47,12 @@ Temporary Items
# project files
# ========================
.idea
-.sass-cache
.usage
+*.gz
+composer-dev.lock
+package-lock.json
+/conf/
+/node_modules/
+/public/js/vX.X.X/
+/vendor/
+/history/
diff --git a/.htaccess b/.htaccess
index cface6a91..4efdc2625 100644
--- a/.htaccess
+++ b/.htaccess
@@ -1,8 +1,12 @@
-# Enable rewrite engine and route requests to framework
+# HTTPS over SSL version
+# Information: https://github.com/exodus4d/pathfinder/wiki/Apache
+
+# Enable rewrite engine and route requests to framework ===========================================
RewriteEngine On
-# HTTP to HTTPS ----------------------------------------------------------------
+# HTTP to HTTPS ===================================================================================
RewriteCond %{HTTPS} off
+RewriteCond %{HTTP_HOST} !^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$
RewriteCond %{HTTP_HOST} !=localhost
# First rewrite to HTTPS:
@@ -10,21 +14,25 @@ RewriteCond %{HTTP_HOST} !=localhost
# the subsequent rule will catch it.
RewriteRule .* https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
-# Now, rewrite any request to the wrong domain to use www.
+# Rewrite NONE www. to force www. =================================================================
RewriteCond %{HTTP_HOST} !^www\.
+# skip "localhost" (dev environment)...
RewriteCond %{HTTP_HOST} !=localhost
+# skip IP calls (dev environment)
+RewriteCond %{HTTP_HOST} !^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$
+# rewrite everything else to "https://" and "www."
RewriteRule .* https://www.%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
-
# Some servers require you to specify the `RewriteBase` directive
# In such cases, it should be the path (relative to the document root)
-# containing this .htaccess file
-#
-#RewriteBase /app/
+# containing this .htaccess file:
+# RewriteBase /app/
+# Protect system files ============================================================================
RewriteCond %{ENV:REDIRECT_STATUS} ^$
RewriteRule ^(lib|tmp)\/|\.(ini|php)$ - [R=404]
+# Rewrite "everything" to index.php (dispatcher) ==================================================
RewriteCond %{REQUEST_FILENAME} !-l
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
@@ -32,18 +40,19 @@ RewriteRule .* index.php [L,QSA]
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization},L]
-# PHP global Vars
+# PHP global Vars (can be set in php.ini as well,...) =============================================
php_value max_input_vars 5000
php_value suhosin.get.max_vars 5000
php_value suhosin.post.max_vars 5000
php_value suhosin.request.max_vars 5000
-
-# PHP error logs
+# Activate PHP error log ==========================================================================
php_flag log_errors on
-# php_value error_log "/www/htdocs/w0128162/www.pathfinder.exodus4d.de/logs/php_errors.log"
+# php_value error_log "/www/htdocs/www.pathfinder-w.space/logs/php_errors.log"
-# caching
+# Cache Header ====================================================================================
+# You should not change anything in here!
+# New versioned files come with a unique path (e.g. ../js/v1.0.0/..) to force client cache busting.
+ ─╮
+ ├─ app/ [0755] → PHP root
+ │ ├─ Controller/ → controller classes for app/ajax endpoints (see routes.ini)
+ │ ├─ Cron/ → controller classes cronjob endpoints (see cron.ini)
+ │ ├─ Data/ → classes for data handling
+ │ ├─ Db/ → classes for DB handling
+ │ ├─ Exception/ → custom exceptions
+ │ ├─ Lib/ → libs
+ │ ├─ Model/ → ORM
+ │ ├─ config.ini → config - F3 core config: SystemVariables
+ │ ├─ cron.ini → config - cronjobs
+ │ ├─ environment.ini → config - system environment
+ │ ├─ pathfinder.ini → config - pathfinder
+ │ ├─ plugin.ini → config - custom plugins
+ │ ├─ requirements.ini → config - system requirements
+ │ └─ routes.ini → config - routes
+ ├─ export/ [0755] → static data
+ │ ├─ csv/ → *.csv used by /setup page
+ │ └─ sql/ → DB dump for import (eve_universe.sql.zip)
+ ├─ favicon/ [0755] → favicons
+ ├─ history/ [0777] → log files (map history logs) [optional]
+ ├─ js/ [0755] → JS source files (not used for production)
+ │ ├─ app/ → "PATHFINDER" core files
+ │ ├─ lib/ → 3rd party libs
+ │ └─ app.js → require.js config
+ ├─ logs/ [0777] → log files
+ │ └─ …
+ ├─ public/ [0755] → static resources
+ │ ├─ css/ → CSS dist/build folder (minified)
+ │ ├─ fonts/ → icon-/fonts
+ │ ├─ img/ → images
+ │ ├─ js/ → JS dist/build folder and source maps (minified, uglified)
+ │ └─ templates/ → templates
+ ├─ sass/ → SCSS sources (not used for production)
+ ├─ tmp/ [0777] → cache folder (PHP templates)
+ │ └─ cache/ [0777] → cache folder (PHP cache)
+ ├─ .htaccess [0755] → reroute/caching rules ("Apache" only!)
+ └─ index.php [0755]
+
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━
+ CI/CD config files:
- Nginx and Lighttpd configurations are also possible.
- http://fatfreeframework.com/system-requirements
-#### Database
- - mysql: MySQL 5.x
- - sqlite: SQLite 3 and SQLite 2
- - pgsql: PostgreSQL
- - sqlsrv: Microsoft SQL Server / SQL Azure
- - mssql, dblib, sybase: FreeTDS / Microsoft SQL Server / Sybase
- - odbc: ODBC v3
- - oci: Oracle
+ ├─ .jshintrc → "JSHint" config (not used for production)
+ ├─ composer.json → "Composer" package definition
+ ├─ gulpfile.js → "Gulp" task config (not used for production)
+ ├─ package.json → "Node.js" dependency config (not used for production)
+ └─ README.md → This file :) (not used for production)
+
+
+***
+
+### Contributing
- Here is a list of links to DSN connection details for all currently supported engines in the SQL layer:
- http://fatfreeframework.com/sql
-#### Development Environment
- - t.b.a.
+[](https://sourcerer.io/fame/exodus4d/exodus4d/pathfinder/links/0)[](https://sourcerer.io/fame/exodus4d/exodus4d/pathfinder/links/1)[](https://sourcerer.io/fame/exodus4d/exodus4d/pathfinder/links/2)[](https://sourcerer.io/fame/exodus4d/exodus4d/pathfinder/links/3)[](https://sourcerer.io/fame/exodus4d/exodus4d/pathfinder/links/4)[](https://sourcerer.io/fame/exodus4d/exodus4d/pathfinder/links/5)[](https://sourcerer.io/fame/exodus4d/exodus4d/pathfinder/links/6)[](https://sourcerer.io/fame/exodus4d/exodus4d/pathfinder/links/7)
-### Folder structure (production) ----------------------------------------
-```
- |-- (0755) app --> backend [*.php]
- |-- app --> "Fat Free Framework" extensions
- |-- lib --> "Fat Free Framework"
- |-- main --> "PATHFINDER" root
- |-- config.ini --> config "f3" framework
- |-- cron.ini --> config cronjobs
- |-- pathfinder.ini --> config pathfinder
- |-- routes.ini --> config routes
- |-- (0755) build_js --> JS build folder and source maps (minified, uglified)
- |-- app --> "PATHFINDER" core files
- |-- lib --> 3rd partie extension/library
- |-- build.txt --> generated build summary
- |-- (0755) js --> JS source files (raw)
- |-- app --> "PASTHFINDER" core files (not used for production )
- |-- lib --> 3rd partie extension/library (not used for production )
- |-- app.js --> require.js config (!required for production!)
- |-- (0777) logs --> log files
- |-- ...
- | -- node_modules --> node.js modules (not used for production )
- |-- ...
- |-- (0755) public --> frontend source
- |-- css --> CSS build folder (minified)
- |-- fonts --> Web/Icon fonts
- |-- img --> images
- |-- templates --> templates
- |-- sass --> SCSS source (not used for production )
- |-- ...
- |-- (0777) tmp --> cache folder
- |-- ...
- |-- (0755) .htaccess --> reroute/caching rules
- |-- (0755) index.php
-```
diff --git a/app/Controller/AccessController.php b/app/Controller/AccessController.php
new file mode 100644
index 000000000..63453f37b
--- /dev/null
+++ b/app/Controller/AccessController.php
@@ -0,0 +1,123 @@
+isLoggedIn($f3) !== 'OK'){
+ // no character found or login timer expired
+ $this->logoutCharacter($f3);
+ // skip route handler and afterroute()
+ $return = false;
+ }
+ }
+
+ return $return;
+ }
+
+ /**
+ * check login status and look or a valid character
+ * @param \Base $f3
+ * @return string
+ * @throws \Exception
+ */
+ protected function isLoggedIn(\Base $f3) : string {
+ $loginStatus = 'UNKNOWN';
+ // disable ttl cache time here. Further getCharacter() calls should use a short ttl
+ if($character = $this->getCharacter(0)){
+ if($character->checkLoginTimer()){
+ if(( $authStatus = $character->isAuthorized()) === 'OK'){
+ $loginStatus = 'OK';
+ }else{
+ $loginStatus = $authStatus;
+ }
+ }else{
+ $loginStatus = 'MAX LOGIN TIME EXCEEDED';
+ }
+ }else{
+ $loginStatus = 'NO SESSION FOUND';
+ }
+
+ // log character access status in debug mode
+ if(
+ $loginStatus !== 'OK' &&
+ $f3->get('DEBUG') === 3
+ ){
+ self::getLogger('CHARACTER_ACCESS')->write(
+ sprintf(Pathfinder\CharacterModel::LOG_ACCESS,
+ $character->_id ,
+ $loginStatus,
+ $character->name
+ )
+ );
+ }
+
+ return $loginStatus;
+ }
+
+ /**
+ * broadcast MapModel to clients
+ * @param Pathfinder\MapModel $map
+ * @param bool $noCache
+ */
+ protected function broadcastMap(Pathfinder\MapModel $map, bool $noCache = false) : void {
+ $this->broadcastMapData($this->getFormattedMapData($map, $noCache));
+ }
+
+
+ /**
+ * broadcast map data to clients
+ * -> send over TCP Socket
+ * @param array|null $mapData
+ */
+ protected function broadcastMapData(?array $mapData) : void {
+ if(!empty($mapData)){
+ $this->getF3()->webSocket()->write('mapUpdate', $mapData);
+ }
+ }
+
+ /**
+ * get formatted Map Data
+ * @param Pathfinder\MapModel $map
+ * @param bool $noCache
+ * @return array|null
+ */
+ protected function getFormattedMapData(Pathfinder\MapModel $map, bool $noCache = false) : ?array {
+ $data = null;
+ try{
+ $mapData = $map->getData($noCache);
+ $data = [
+ 'config' => $mapData->mapData,
+ 'data' => [
+ 'systems' => $mapData->systems,
+ 'connections' => $mapData->connections,
+ ]
+ ];
+ }catch(\Exception $e){
+
+ }
+
+ return $data;
+ }
+
+}
\ No newline at end of file
diff --git a/app/Controller/Admin.php b/app/Controller/Admin.php
new file mode 100644
index 000000000..b419daa55
--- /dev/null
+++ b/app/Controller/Admin.php
@@ -0,0 +1,446 @@
+ '5m',
+ 60 => '1h',
+ 1440 => '24h'
+ ];
+
+ /**
+ * event handler for all "views"
+ * some global template variables are set in here
+ * @param \Base $f3
+ * @param $params
+ * @return bool
+ * @throws \Exception
+ */
+ function beforeroute(\Base $f3, $params): bool {
+ $return = parent::beforeroute($f3, $params);
+
+ $f3->set('tplPage', 'login');
+
+ if($character = $this->getAdminCharacter($f3)){
+ $f3->set('tplLogged', true);
+ $f3->set('character', $character);
+ $this->dispatch($f3, $params, $character);
+ }
+
+ $f3->set('tplAuthType', $f3->get('BASE') . $f3->alias( 'sso', ['action' => 'requestAdminAuthorization']));
+
+ // page title
+ $f3->set('tplPageTitle', 'Admin | ' . Config::getPathfinderData('name'));
+
+ // main page content
+ $f3->set('tplPageContent', Config::getPathfinderData('view.admin'));
+
+ // body element class
+ $f3->set('tplBodyClass', 'pf-landing');
+
+ return $return;
+ }
+
+ /**
+ * event handler after routing
+ * @param \Base $f3
+ */
+ public function afterroute(\Base $f3) {
+ // js view (file)
+ $f3->set('tplJsView', 'admin');
+
+ // render view
+ echo \Template::instance()->render( Config::getPathfinderData('view.index') );
+
+ // clear all SSO related temp data
+ if( $f3->exists(Sso::SESSION_KEY_SSO) ){
+ $f3->clear('SESSION.SSO.ERROR');
+ }
+ }
+
+ /**
+ * returns valid admin $characterModel for current user
+ * @param \Base $f3
+ * @return CharacterModel|null
+ * @throws \Exception
+ */
+ protected function getAdminCharacter(\Base $f3){
+ $adminCharacter = null;
+ if( !$f3->exists(Sso::SESSION_KEY_SSO_ERROR) ){
+ if( $character = $this->getCharacter(0) ){
+ if(in_array($character->roleId->name, ['SUPER', 'CORPORATION'], true)){
+ // current character is admin
+ $adminCharacter = $character;
+ }elseif( !$character->hasAdminScopes() ){
+ $f3->set(Sso::SESSION_KEY_SSO_ERROR,
+ sprintf(
+ self::ERROR_SSO_CHARACTER_SCOPES,
+ $character->name
+ ));
+ }else{
+ $f3->set(Sso::SESSION_KEY_SSO_ERROR,
+ sprintf(
+ self::ERROR_SSO_CHARACTER_ROLES,
+ $character->name,
+ implode(', ', CorporationModel::ADMIN_ROLES)
+ ));
+ }
+ }else{
+ $f3->set(Sso::SESSION_KEY_SSO_ERROR, self::ERROR_SSO_CHARACTER_EXISTS);
+ }
+ }
+
+ return $adminCharacter;
+ }
+
+ /**
+ * dispatch page events by URL $params
+ * @param \Base $f3
+ * @param $params
+ * @param null $character
+ * @throws \Exception
+ */
+ public function dispatch(\Base $f3, $params, $character = null){
+ if($character instanceof CharacterModel){
+ // user logged in
+ $parts = array_values(array_filter(array_map('strtolower', explode('/', $params['*']))));
+ $f3->set('tplPage', $parts[0]);
+
+ switch($parts[0]){
+ case 'settings':
+ switch($parts[1]){
+ case 'save':
+ $objectId = (int)$parts[2];
+ $values = (array)$f3->get('GET');
+ $this->saveSettings($character, $objectId, $values);
+
+ $f3->reroute('@admin(@*=/' . $parts[0] . ')');
+ break;
+ }
+ $f3->set('tplDefaultRole', RoleModel::getDefaultRole());
+ $f3->set('tplRoles', RoleModel::getAll());
+ $this->initSettings($f3, $character);
+ break;
+ case 'members':
+ switch($parts[1]){
+ case 'kick':
+ $objectId = (int)$parts[2];
+ $value = (int)$parts[3];
+ $this->kickCharacter($character, $objectId, $value);
+
+ $f3->reroute('@admin(@*=/' . $parts[0] . ')');
+ break;
+ case 'ban':
+ $objectId = (int)$parts[2];
+ $value = (int)$parts[3];
+ $this->banCharacter($character, $objectId, $value);
+ break;
+ }
+ $f3->set('tplKickOptions', self::KICK_OPTIONS);
+ $this->initMembers($f3, $character);
+ break;
+ case 'maps':
+ switch($parts[1]){
+ case 'active':
+ $objectId = (int)$parts[2];
+ $value = (int)$parts[3];
+ $this->activateMap($character, $objectId, $value);
+
+ $f3->reroute('@admin(@*=/' . $parts[0] . ')');
+ break;
+ case 'delete':
+ $objectId = (int)$parts[2];
+ $this->deleteMap($character, $objectId);
+ $f3->reroute('@admin(@*=/' . $parts[0] . ')');
+ break;
+ }
+ $this->initMaps($f3, $character);
+ break;
+ case 'login':
+ default:
+ $f3->set('tplPage', 'login');
+ break;
+ }
+ }
+ }
+
+ /**
+ * save or delete settings (e.g. corporation rights)
+ * @param CharacterModel $character
+ * @param int $corporationId
+ * @param array $settings
+ * @throws \Exception
+ */
+ protected function saveSettings(CharacterModel $character, int $corporationId, array $settings){
+ $defaultRole = RoleModel::getDefaultRole();
+
+ if($corporationId && $defaultRole){
+ $corporations = $this->getAccessibleCorporations($character);
+ foreach($corporations as $corporation){
+ if($corporation->_id === $corporationId){
+ // character has access to that corporation -> create/update/delete rights...
+ if($corporationRightsData = (array)$settings['rights']){
+ // get existing corp rights
+ foreach($corporation->getRights($corporation::RIGHTS, ['addInactive' => true]) as $corporationRight){
+ $corporationRightData = $corporationRightsData[$corporationRight->rightId->_id];
+ if(
+ $corporationRightData &&
+ $corporationRightData['roleId'] != $defaultRole->_id // default roles should not be saved
+ ){
+ $corporationRight->setData($corporationRightData);
+ $corporationRight->setActive(true);
+ $corporationRight->save();
+ }else{
+ // right not send by user -> delete existing right
+ $corporationRight->erase();
+ }
+ }
+ }
+ break;
+ }
+ }
+ }
+ }
+
+ /**
+ * kick or revoke a character
+ * @param CharacterModel $character
+ * @param int $kickCharacterId
+ * @param int $minutes
+ */
+ protected function kickCharacter(CharacterModel $character, $kickCharacterId, $minutes){
+ $kickOptions = self::KICK_OPTIONS;
+ $minKickTime = key($kickOptions) ;
+ end($kickOptions);
+ $maxKickTime = key($kickOptions);
+ $minutes = in_array($minutes, range($minKickTime, $maxKickTime)) ? $minutes : 0;
+
+ $kickCharacters = $this->filterValidCharacters($character, $kickCharacterId);
+ foreach($kickCharacters as $kickCharacter){
+ $kickCharacter->kick($minutes);
+ $kickCharacter->save();
+
+ self::getLogger()->write(
+ sprintf(
+ self::LOG_TEXT_KICK_BAN,
+ $minutes ? 'KICK' : 'KICK REVOKE',
+ $kickCharacter->name,
+ $kickCharacter->getCorporation()->name,
+ $character->name
+ )
+ );
+ }
+ }
+
+ /**
+ * @param CharacterModel $character
+ * @param int $banCharacterId
+ * @param int $value
+ */
+ protected function banCharacter(CharacterModel $character, $banCharacterId, $value){
+ $banCharacters = $this->filterValidCharacters($character, $banCharacterId);
+ foreach($banCharacters as $banCharacter){
+ $banCharacter->ban($value);
+ $banCharacter->save();
+
+ self::getLogger()->write(
+ sprintf(
+ self::LOG_TEXT_KICK_BAN,
+ $value ? 'BAN' : 'BAN REVOKE',
+ $banCharacter->name,
+ $banCharacter->getCorporation()->name,
+ $character->name
+ )
+ );
+ }
+ }
+
+ /**
+ * checks whether a $character has admin access rights for $characterId
+ * -> must be in same corporation
+ * @param CharacterModel $character
+ * @param int $characterId
+ * @return array|\DB\CortexCollection
+ */
+ protected function filterValidCharacters(CharacterModel $character, $characterId){
+ $characters = [];
+ // check if kickCharacters belong to same Corp as admin character
+ // -> remove admin char from valid characters...
+ if( !empty($characterIds = array_diff([$characterId], [$character->_id])) ){
+ if($character->roleId->name === 'SUPER'){
+ if($filterCharacters = CharacterModel::getAll($characterIds)){
+ $characters = $filterCharacters;
+ }
+ }else{
+ $characters = $character->getCorporation()->getCharacters($characterIds);
+ }
+ }
+ return $characters;
+ }
+
+ /**
+ * @param CharacterModel $character
+ * @param int $mapId
+ * @param int $value
+ */
+ protected function activateMap(CharacterModel $character, int $mapId, int $value){
+ $maps = $this->filterValidMaps($character, $mapId);
+ foreach($maps as $map){
+ $map->setActive((bool)$value);
+ $map->save($character);
+ }
+ }
+
+ /**
+ * @param CharacterModel $character
+ * @param int $mapId
+ */
+ protected function deleteMap(CharacterModel $character, int $mapId){
+ $maps = $this->filterValidMaps($character, $mapId);
+ foreach($maps as $map){
+ $map->erase();
+ }
+ }
+
+ /**
+ * checks whether a $character has admin access rights for $mapId
+ * @param CharacterModel $character
+ * @param int $mapId
+ * @return \DB\CortexCollection[]|MapModel[]
+ */
+ protected function filterValidMaps(CharacterModel $character, int $mapId) {
+ $maps = [];
+ if($character->roleId->name === 'SUPER'){
+ if($filterMaps = MapModel::getAll([$mapId], ['addInactive' => true])){
+ $maps = $filterMaps;
+ }
+ }else{
+ $maps = $character->getCorporation()->getMaps($mapId, ['addInactive' => true, 'ignoreMapCount' => true]);
+ }
+
+ return $maps;
+ }
+
+ /**
+ * get log file for "admin" logs
+ * @param string $type
+ * @return \Log
+ */
+ static function getLogger($type = 'ADMIN') : \Log {
+ return parent::getLogger('ADMIN');
+ }
+
+ /**
+ * init /settings page data
+ * @param \Base $f3
+ * @param CharacterModel $character
+ */
+ protected function initSettings(\Base $f3, CharacterModel $character){
+ $data = (object) [];
+ $corporations = $this->getAccessibleCorporations($character);
+
+ foreach($corporations as $corporation){
+ $data->corporations[$corporation->name] = $corporation;
+ }
+
+ $f3->set('tplSettings', $data);
+ }
+
+ /**
+ * init /member page data
+ * @param \Base $f3
+ * @param CharacterModel $character
+ */
+ protected function initMembers(\Base $f3, CharacterModel $character){
+ $data = (object) [];
+ if($characterCorporation = $character->getCorporation()){
+ $corporations = $this->getAccessibleCorporations($character);
+
+ foreach($corporations as $corporation){
+ if($characters = $corporation->getCharacters()){
+ $data->corpMembers[$corporation->name] = $characters;
+ }
+ }
+
+ // sort corporation from current user first
+ if( !empty($data->corpMembers[$characterCorporation->name]) ){
+ $data->corpMembers = array($characterCorporation->name => $data->corpMembers[$characterCorporation->name]) + $data->corpMembers;
+ }
+ }
+
+ $f3->set('tplMembers', $data);
+ }
+
+ /**
+ * init /maps page data
+ * @param \Base $f3
+ * @param CharacterModel $character
+ */
+ protected function initMaps(\Base $f3, CharacterModel $character){
+ $data = (object) [];
+ if($characterCorporation = $character->getCorporation()){
+ $corporations = $this->getAccessibleCorporations($character);
+
+ foreach($corporations as $corporation){
+ if($maps = $corporation->getMaps(null, ['addInactive' => true, 'ignoreMapCount' => true])){
+ $data->corpMaps[$corporation->name] = $maps;
+ }
+ }
+ }
+
+ $f3->set('tplMaps', $data);
+
+ if( !isset($data->corpMaps) ){
+ $f3->set('tplNotification', $this->getNotificationObject('No maps found',
+ 'Only corporation maps could get loaded' ,
+ 'info'
+ ));
+ }
+ }
+
+ /**
+ * get all corporations a characters has admin access for
+ * @param CharacterModel $character
+ * @return CorporationModel[]
+ */
+ protected function getAccessibleCorporations(CharacterModel $character) {
+ $corporations = [];
+ if($characterCorporation = $character->getCorporation()){
+ switch($character->roleId->name){
+ case 'SUPER':
+ if($accessCorporations = CorporationModel::getAll(['addNPC' => true])){
+ $corporations = $accessCorporations;
+ }
+ break;
+ case 'CORPORATION':
+ $corporations[] = $characterCorporation;
+ break;
+ }
+ }
+
+ return $corporations;
+ }
+
+}
\ No newline at end of file
diff --git a/app/Controller/Api/Access.php b/app/Controller/Api/Access.php
new file mode 100644
index 000000000..d1ca15f0c
--- /dev/null
+++ b/app/Controller/Api/Access.php
@@ -0,0 +1,67 @@
+find( [
+ "LOWER(name) LIKE :token AND " .
+ "active = 1 AND " .
+ "shared = 1 ",
+ ':token' => '%' . $searchToken . '%'
+ ]);
+
+ if($accessList){
+ foreach($accessList as $accessObject){
+ $accessData[] = $accessObject->getData();
+ }
+ }
+ }
+ }
+
+ echo json_encode($accessData);
+ }
+
+}
\ No newline at end of file
diff --git a/app/Controller/Api/GitHub.php b/app/Controller/Api/GitHub.php
new file mode 100644
index 000000000..9f3c8c227
--- /dev/null
+++ b/app/Controller/Api/GitHub.php
@@ -0,0 +1,85 @@
+releasesData = [];
+ $return->version = (object) [];
+ $return->version->current = Config::getPathfinderData('version');
+ $return->version->last = '';
+ $return->version->delta = null;
+ $return->version->dev = false;
+
+ $releases = $f3->gitHubClient()->send('getProjectReleases', 'exodus4d/pathfinder', $releaseCount);
+
+ foreach($releases as $key => &$release){
+ // check version ------------------------------------------------------------------------------------------
+ if($key === 0){
+ $return->version->last = $release['name'];
+ if(version_compare( $return->version->current, $return->version->last, '>')){
+ $return->version->dev = true;
+ }
+ }
+
+ if(
+ !$return->version->dev &&
+ version_compare($release['name'], $return->version->current, '>=')
+ ){
+ $return->version->delta = ($key === count($releases) - 1) ? '>= ' . $key : $key;
+ }
+
+ // format body ------------------------------------------------------------------------------------
+ $body = $release['body'];
+
+ // remove "update information" from release text
+ // -> keep everything until first "***" -> horizontal line
+ if( ($pos = strpos($body, '***')) !== false){
+ $body = substr($body, 0, $pos);
+ }
+
+ // convert list style
+ $body = str_replace(' - ', '* ', $body);
+
+ // convert Markdown to HTML -> use either gitHub API (in oder to create abs, issue links)
+ // -> or F3´s markdown as fallback
+ $html = $f3->gitHubClient()->send('markdownToHtml', 'exodus4d/pathfinder', $body);
+
+ if(!empty($html)){
+ $body = $html;
+ }else{
+ $body = \Markdown::instance()->convert(trim($body));
+ }
+
+ $release['body'] = $body;
+ }
+
+ $return->releasesData = $releases;
+
+ echo json_encode($return);
+ }
+}
\ No newline at end of file
diff --git a/app/Controller/Api/Map.php b/app/Controller/Api/Map.php
new file mode 100644
index 000000000..14611fcfd
--- /dev/null
+++ b/app/Controller/Api/Map.php
@@ -0,0 +1,1085 @@
+exists(self::CACHE_KEY_INIT, $return)){
+ $return = (object) [];
+ $return->error = [];
+
+ // static program data ------------------------------------------------------------------------------------
+ $return->timer = Config::getPathfinderData('timer');
+
+ // get all available map types ----------------------------------------------------------------------------
+ $mapType = Pathfinder\AbstractPathfinderModel::getNew('MapTypeModel');
+ $rows = $mapType->find('active = 1');
+
+ // default map type config
+ $mapsDefaultConfig = Config::getMapsDefaultConfig();
+ $mapTypeData = [];
+ foreach((array)$rows as $rowData){
+ $data = [
+ 'id' => $rowData->id,
+ 'label' => $rowData->label,
+ 'class' => $rowData->class,
+ 'classTab' => $rowData->classTab,
+ 'defaultConfig' => $mapsDefaultConfig[$rowData->name]
+ ];
+ $mapTypeData[$rowData->name] = $data;
+ }
+ $return->mapTypes = $mapTypeData;
+
+ $validInitData = $validInitData ? !empty($mapTypeData) : $validInitData;
+
+ // get all available map scopes ---------------------------------------------------------------------------
+ $mapScope = Pathfinder\AbstractPathfinderModel::getNew('MapScopeModel');
+ $rows = $mapScope->find('active = 1');
+ $mapScopeData = [];
+ foreach((array)$rows as $rowData){
+ $data = [
+ 'id' => $rowData->id,
+ 'label' => $rowData->label
+ ];
+ $mapScopeData[$rowData->name] = $data;
+ }
+ $return->mapScopes = $mapScopeData;
+
+ $validInitData = $validInitData ? !empty($mapScopeData) : $validInitData;
+
+ // get all available system status ------------------------------------------------------------------------
+ $systemStatus = Pathfinder\AbstractPathfinderModel::getNew('SystemStatusModel');
+ $rows = $systemStatus->find('active = 1');
+ $systemScopeData = [];
+ foreach((array)$rows as $rowData){
+ $data = [
+ 'id' => $rowData->id,
+ 'label' => $rowData->label,
+ 'class' => $rowData->class
+ ];
+ $systemScopeData[$rowData->name] = $data;
+ }
+ $return->systemStatus = $systemScopeData;
+
+ $validInitData = $validInitData ? !empty($systemScopeData) : $validInitData;
+
+ // get all available system types -------------------------------------------------------------------------
+ $systemType = Pathfinder\AbstractPathfinderModel::getNew('SystemTypeModel');
+ $rows = $systemType->find('active = 1');
+ $systemTypeData = [];
+ foreach((array)$rows as $rowData){
+ $data = [
+ 'id' => $rowData->id,
+ 'name' => $rowData->name
+ ];
+ $systemTypeData[$rowData->name] = $data;
+ }
+ $return->systemType = $systemTypeData;
+
+ $validInitData = $validInitData ? !empty($systemTypeData) : $validInitData;
+
+ // get available connection scopes ------------------------------------------------------------------------
+ $connectionScope = Pathfinder\AbstractPathfinderModel::getNew('ConnectionScopeModel');
+ $rows = $connectionScope->find('active = 1');
+ $connectionScopeData = [];
+ foreach((array)$rows as $rowData){
+ $data = [
+ 'id' => $rowData->id,
+ 'label' => $rowData->label,
+ 'connectorDefinition' => $rowData->connectorDefinition
+ ];
+ $connectionScopeData[$rowData->name] = $data;
+ }
+ $return->connectionScopes = $connectionScopeData;
+
+ $validInitData = $validInitData ? !empty($connectionScopeData) : $validInitData;
+
+ // get available character status -------------------------------------------------------------------------
+ $characterStatus = Pathfinder\AbstractPathfinderModel::getNew('CharacterStatusModel');
+ $rows = $characterStatus->find('active = 1');
+ $characterStatusData = [];
+ foreach((array)$rows as $rowData){
+ $data = [
+ 'id' => $rowData->id,
+ 'name' => $rowData->name,
+ 'class' => $rowData->class
+ ];
+ $characterStatusData[$rowData->name] = $data;
+ }
+ $return->characterStatus = $characterStatusData;
+
+ $validInitData = $validInitData ? !empty($characterStatusData) : $validInitData;
+
+ // route search config ------------------------------------------------------------------------------------
+ $return->routeSearch = [
+ 'defaultCount' => Config::getPathfinderData('route.search_default_count'),
+ 'maxDefaultCount' => Config::getPathfinderData('route.max_default_count'),
+ 'limit' => Config::getPathfinderData('route.limit')
+ ];
+
+ // get program routes -------------------------------------------------------------------------------------
+ $return->routes = [
+ 'ssoLogin' => $this->getF3()->alias('sso', ['action' => 'requestAuthorization'])
+ ];
+
+ // get third party APIs -----------------------------------------------------------------------------------
+ $return->url = [
+ 'ccpImageServer' => Config::getPathfinderData('api.ccp_image_server'),
+ 'zKillboard' => Config::getPathfinderData('api.z_killboard'),
+ 'eveeye' => Config::getPathfinderData('api.eveeye'),
+ 'dotlan' => Config::getPathfinderData('api.dotlan'),
+ 'anoik' => Config::getPathfinderData('api.anoik'),
+ 'eveScout' => Config::getPathfinderData('api.eve_scout')
+ ];
+
+ // get Plugin config --------------------------------------------------------------------------------------
+ $return->plugin = [
+ 'modules' => Config::getPluginConfig('modules')
+ ];
+
+ // Character default config -------------------------------------------------------------------------------
+ $return->character = [
+ 'autoLocationSelect' => (bool)Config::getPathfinderData('character.auto_location_select')
+ ];
+
+ // Slack integration status -------------------------------------------------------------------------------
+ $return->slack = [
+ 'status' => (bool)Config::getPathfinderData('slack.status')
+ ];
+
+ // Slack integration status -------------------------------------------------------------------------------
+ $return->discord = [
+ 'status' => (bool)Config::getPathfinderData('discord.status')
+ ];
+
+ // structure status ---------------------------------------------------------------------------------------
+ $structureStatus = Pathfinder\StructureStatusModel::getAll();
+ $structureStatusData = [];
+ foreach($structureStatus as $status){
+ $structureStatusData[$status->_id] = $status->getData();
+ }
+ $return->structureStatus = $structureStatusData;
+
+ $validInitData = $validInitData ? !empty($structureStatusData) : $validInitData;
+
+ // get available wormhole types ---------------------------------------------------------------------------
+ /**
+ * @var $groupUniverseModel Universe\GroupModel
+ */
+ $groupUniverseModel = Universe\AbstractUniverseModel::getNew('GroupModel');
+ $groupUniverseModel->getById(Config::ESI_GROUP_WORMHOLE_ID);
+ $wormholesData = [];
+ /**
+ * @var $typeModel Universe\TypeModel
+ */
+ foreach($types = $groupUniverseModel->getTypes(false) as $typeModel){
+ if(
+ ($wormholeData = $typeModel->getWormholeData()) &&
+ mb_strlen((string)$wormholeData->name) === 4
+ ){
+ $wormholesData[$wormholeData->name] = $wormholeData;
+ }
+ }
+ ksort($wormholesData);
+ $return->wormholes = $wormholesData;
+
+ $validInitData = $validInitData ? !empty($wormholesData) : $validInitData;
+
+ // universe category data ---------------------------------------------------------------------------------
+ /**
+ * @var $categoryUniverseModel Universe\CategoryModel
+ */
+ $categoryUniverseModel = Universe\AbstractUniverseModel::getNew('CategoryModel');
+ $return->universeCategories = [
+ Config::ESI_CATEGORY_SHIP_ID =>
+ ($categoryUniverseModel->getById(Config::ESI_CATEGORY_SHIP_ID) && $categoryUniverseModel->valid()) ? $categoryUniverseModel->getData(['mass']) : null,
+ Config::ESI_CATEGORY_STRUCTURE_ID =>
+ ($categoryUniverseModel->getById(Config::ESI_CATEGORY_STRUCTURE_ID) && $categoryUniverseModel->valid()) ? $categoryUniverseModel->getData() : null,
+ ];
+
+ $validInitData = $validInitData ? !count(array_filter($return->universeCategories, function($v){
+ return empty(array_filter((array)$v->groups));
+ })) : $validInitData;
+
+ // response should not be cached if invalid -> e.g. missing static data
+ if($validInitData){
+ $f3->set(self::CACHE_KEY_INIT, $return, $ttl);
+ }
+ }
+
+ // get SSO error messages that should be shown immediately ----------------------------------------------------
+ // -> e.g. errors while character switch from previous HTTP requests
+ if($f3->exists(Controller\Ccp\Sso::SESSION_KEY_SSO_ERROR, $text)){
+ $ssoError = (object) [];
+ $ssoError->type = 'error';
+ $ssoError->title = 'Login failed';
+ $ssoError->text = $text;
+ $return->error[] = $ssoError;
+ $f3->clear(Controller\Ccp\Sso::SESSION_KEY_SSO_ERROR);
+ }elseif($validInitData){
+ // no errors and valid data -> send Cache header
+ $f3->expire(Config::ttlLeft($exists, $ttl));
+ }
+
+ echo json_encode($return);
+ }
+
+ /**
+ * import new map data
+ * @param \Base $f3
+ * @throws \Exception
+ */
+ public function import(\Base $f3){
+ $importData = (array)$f3->get('POST');
+
+ $return = (object) [];
+ $return->error = [];
+ $return->warning = [];
+
+ if(
+ isset($importData['typeId']) &&
+ count($importData['mapData']) > 0
+ ){
+ $activeCharacter = $this->getCharacter();
+
+ /**
+ * @var $map Pathfinder\MapModel
+ */
+ $map = Pathfinder\AbstractPathfinderModel::getNew('MapModel');
+
+ /**
+ * @var $mapType Pathfinder\MapTypeModel
+ */
+ $mapType = Pathfinder\AbstractPathfinderModel::getNew('MapTypeModel');
+ $mapType->getById((int)$importData['typeId']);
+
+ if( !$mapType->dry() ){
+ $defaultConfig = Config::getMapsDefaultConfig($mapType->name);
+
+ foreach($importData['mapData'] as $mapData){
+ if(
+ isset($mapData['config']) &&
+ isset($mapData['data'])
+ ){
+ $mapDataConfig = (array)$mapData['config'];
+ $mapDataData = (array)$mapData['data'];
+
+ /**
+ * @var $mapScope Pathfinder\MapScopeModel
+ */
+ $mapScope = Pathfinder\AbstractPathfinderModel::getNew('MapScopeModel');
+ $mapScope->getById((int)$mapDataConfig['scope']['id']);
+
+ if( !$mapScope->dry() ){
+ if(
+ isset($mapDataData['systems']) &&
+ isset($mapDataData['connections'])
+ ){
+ $mapDataSystems = (array)$mapDataData['systems'];
+ $mapDataConnections = (array)$mapDataData['connections'];
+ $systemCount = count($mapDataSystems);
+ if($systemCount <= $defaultConfig['max_systems']){
+
+ $map->copyfrom($mapDataConfig, ['name', 'icon', 'position', 'locked', 'rallyUpdated', 'rallyPoke']);
+ $map->typeId = $mapType;
+ $map->scopeId = $mapScope;
+ $map->save($activeCharacter);
+
+ // new system IDs will be generated
+ // therefore we need to temp store a mapping between IDs
+ $tempSystemIdMapping = [];
+
+ foreach($mapDataSystems as $systemData){
+ if(
+ ($oldId = (int)$systemData['id']) &&
+ ($systemId = (int)$systemData['systemId'])
+ ){
+ $system = $map->getNewSystem($systemId);
+ $system->copyfrom($systemData, ['alias', 'status', 'locked', 'rallyUpdated', 'rallyPoke', 'position']);
+ $system = $map->saveSystem($system, $activeCharacter, $system->posX, $system->posY);
+
+ $tempSystemIdMapping[$oldId] = $system->_id;
+ }
+ }
+
+ /**
+ * @var $connection Pathfinder\ConnectionModel
+ */
+ $connection = Pathfinder\AbstractPathfinderModel::getNew('ConnectionModel');
+ $connection->setActivityLogging(false);
+
+ foreach($mapDataConnections as $connectionData){
+ // check if source and target IDs match with new system ID
+ if(
+ ($sourceSystemId = $tempSystemIdMapping[(int)$connectionData['source']]) &&
+ ($targetSystemId = $tempSystemIdMapping[(int)$connectionData['target']])
+ ){
+ $connection->source = $sourceSystemId;
+ $connection->target = $targetSystemId;
+ $connection->copyfrom($connectionData, ['scope', 'type']);
+ $map->saveConnection($connection, $activeCharacter);
+
+ $connection->reset();
+ }
+ }
+
+ // map access info should not automatically imported
+ if($map->isPrivate()){
+ $map->setAccess($activeCharacter);
+ }elseif($map->isCorporation()){
+ if($corporation = $activeCharacter->getCorporation()){
+ $map->setAccess($corporation);
+ }
+ }elseif($map->isAlliance()){
+ if($alliance = $activeCharacter->getAlliance()){
+ $map->setAccess($alliance);
+ }
+ }
+
+ // broadcast map Access -> and send map Data
+ $this->broadcastMapAccess($map);
+ }else{
+ $maxSystemsError = (object) [];
+ $maxSystemsError->type = 'error';
+ $maxSystemsError->text = 'Map has to many systems (' . $systemCount . ').'
+ .' Max system count is ' . $defaultConfig['max_systems'] . ' for ' . $mapType->name . ' maps.';
+ $return->error[] = $maxSystemsError;
+ }
+ }else{
+ // systems || connections missing
+ $missingConfigError = (object) [];
+ $missingConfigError->type = 'error';
+ $missingConfigError->text = 'Map data not valid (systems || connections) missing';
+ $return->error[] = $missingConfigError;
+ }
+ }else{
+ $unknownMapScope= (object) [];
+ $unknownMapScope->type = 'error';
+ $unknownMapScope->text = 'Map scope unknown!';
+ $return->error[] = $unknownMapScope;
+ }
+ }else{
+ // map config || systems/connections missing
+ $missingConfigError = (object) [];
+ $missingConfigError->type = 'error';
+ $missingConfigError->text = 'Map data not valid (config || data) missing';
+ $return->error[] = $missingConfigError;
+ }
+
+ $map->reset();
+ }
+ }else{
+ $unknownMapType = (object) [];
+ $unknownMapType->type = 'error';
+ $unknownMapType->text = 'Map type unknown!';
+ $return->error[] = $unknownMapType;
+ }
+ }else{
+ // map data missing
+ $missingDataError = (object) [];
+ $missingDataError->type = 'error';
+ $missingDataError->text = 'Map data missing';
+ $return->error[] = $missingDataError;
+ }
+
+ echo json_encode($return);
+ }
+
+ /**
+ * broadcast characters with map access rights to WebSocket server
+ * -> if characters with map access found -> broadcast mapData to them
+ * @param Pathfinder\MapModel $map
+ * @throws \Exception
+ */
+ protected function broadcastMapAccess(Pathfinder\MapModel $map){
+ $mapAccess = [
+ 'id' => $map->_id,
+ 'name' => $map->name,
+ 'characterIds' => array_map(function($data){
+ return $data->id;
+ }, $map->getCharactersData())
+ ];
+
+ $this->getF3()->webSocket()->write('mapAccess', $mapAccess);
+
+ // map has (probably) active connections that should receive map Data
+ $this->broadcastMap($map, true);
+ }
+
+ /**
+ * get map access tokens for current character
+ * -> send access tokens via TCP Socket for WebSocket auth
+ * @param \Base $f3
+ * @throws \Exception
+ */
+ public function getAccessData(\Base $f3){
+ $return = (object) [];
+
+ $activeCharacter = $this->getCharacter();
+ $characterData = $activeCharacter->getData(true);
+ $maps = $activeCharacter->getMaps();
+
+ // some character data is not required (in WebSocket) -> unset() and keep return small
+ if(isset($characterData->corporation->rights)){
+ unset($characterData->corporation->rights);
+ }
+
+ // access token
+ $token = bin2hex(random_bytes(16));
+
+ $return->data = [
+ 'id' => $activeCharacter->_id,
+ 'token' => $token, // character access
+ 'characterData' => $characterData,
+ 'mapData' => []
+ ];
+
+ if($maps){
+ foreach($maps as $map){
+ $return->data['mapData'][] = [
+ 'id' => $map->_id,
+ 'token' => $token, // map access
+ 'name' => $map->name
+ ];
+ }
+ }
+
+ // send Access Data to WebSocket Server and get response (status)
+ // if 'OK' -> Socket exists
+ $status = '';
+ $f3->webSocket()
+ ->write('mapConnectionAccess', $return->data)
+ ->then(
+ function($payload) use (&$status) {
+ $status = (string)$payload['load'];
+ });
+
+ $return->status = $status;
+
+ echo json_encode($return);
+ }
+
+ /**
+ * update maps with $mapsData where $character has access to
+ * @param Pathfinder\CharacterModel $character
+ * @param array $mapsData
+ * @return \stdClass
+ */
+ protected function updateMapsData(Pathfinder\CharacterModel $character, array $mapsData) : \stdClass {
+ $return = (object) [];
+ $return->error = [];
+ $return->mapData = [];
+
+ $mapIdsChanged = [];
+ $maps = $character->getMaps();
+
+ if(!empty($mapsData) && !empty($maps)){
+ // loop all $mapsData that should be saved
+ // -> currently there will only be ONE map data change submitted -> single loop
+ foreach($mapsData as $data){
+ $systems = [];
+ $connections = [];
+
+ // check whether system data and/or connection data is send
+ // empty arrays are not included in ajax requests
+ if(isset($data['data']['systems'])){
+ $systems = (array)$data['data']['systems'];
+ }
+
+ if(isset($data['data']['connections'])){
+ $connections = (array)$data['data']['connections'];
+ }
+
+ // check if system data or connection data is send
+ if(
+ count($systems) > 0 ||
+ count($connections) > 0
+ ){
+ // map changes expected ===========================================================================
+
+ // loop current user maps and check for changes
+ foreach($maps as $map){
+ // update system data -------------------------------------------------------------------------
+ foreach($systems as $i => $systemData){
+ // check if current system belongs to the current map
+ if($system = $map->getSystemById((int)$systemData['id'])){
+ $system->copyfrom($systemData, ['alias', 'status', 'position', 'locked', 'rallyUpdated', 'rallyPoke']);
+ if($system->save($character)){
+ if(!in_array($map->_id, $mapIdsChanged)){
+ $mapIdsChanged[] = $map->_id;
+ }
+ // one system belongs to ONE map -> speed up for multiple maps
+ unset($systemData[$i]);
+ }else{
+ $return->error = array_merge($return->error, $system->getErrors());
+ }
+ }
+ }
+
+ // update connection data ---------------------------------------------------------------------
+ foreach($connections as $i => $connectionData){
+ // check if the current connection belongs to the current map
+ if($connection = $map->getConnectionById((int)$connectionData['id'])){
+ $connection->copyfrom($connectionData, ['scope', 'type', 'endpoints']);
+ if($connection->save($character)){
+ if(!in_array($map->_id, $mapIdsChanged)){
+ $mapIdsChanged[] = $map->_id;
+ }
+ // one connection belongs to ONE map -> speed up for multiple maps
+ unset($connectionData[$i]);
+ }else{
+ $return->error = array_merge($return->error, $connection->getErrors());
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ foreach($maps as $map){
+ // format map Data for return/broadcast
+ if($mapData = $this->getFormattedMapData($map)){
+ if(in_array($map->_id, $mapIdsChanged)){
+ $this->broadcastMapData($mapData);
+ }
+
+ $return->mapData[] = $mapData;
+ }
+ }
+
+ return $return;
+ }
+
+ /**
+ * update map data
+ * -> function is called continuously (trigger) by any active client
+ * @param \Base $f3
+ * @throws \Exception
+ */
+ public function updateData(\Base $f3){
+ $postData = (array)$f3->get('POST');
+ $mapsData = (array)$postData['mapData'];
+ $userDataRequired = (bool)$postData['getUserData'];
+
+ $activeCharacter = $this->getCharacter();
+
+ $return = $this->updateMapsData($activeCharacter, $mapsData);
+
+ // if userData is requested -> add it as well
+ // -> Only first trigger call should request this data!
+ if($userDataRequired) {
+ $return->userData = $activeCharacter->getUser()->getData();
+ }
+
+ echo json_encode($return);
+ }
+
+ /**
+ * onUnload map sync
+ * @see https://developer.mozilla.org/docs/Web/API/Navigator/sendBeacon
+ * @param \Base $f3
+ * @throws \Exception
+ */
+ public function updateUnloadData(\Base $f3){
+ $postData = (array)$f3->get('POST');
+
+ if(!empty($mapsData = (string)$postData['mapData'])){
+ $mapsData = (array)json_decode($mapsData, true);
+ if(($jsonError = json_last_error()) === JSON_ERROR_NONE){
+ $activeCharacter = $this->getCharacter();
+
+ $this->updateMapsData($activeCharacter, $mapsData);
+ }
+ }
+ }
+
+ /**
+ * update map data api
+ * -> function is called continuously by any active client
+ * @param \Base $f3
+ * @throws \Exception
+ */
+ public function updateUserData(\Base $f3){
+ $postData = (array)$f3->get('POST');
+ $mapIds = (array)$postData['mapIds'];
+ $getMapUserData = (bool)$postData['getMapUserData'];
+ $mapTracking = (bool)$postData['mapTracking'];
+ $systemData = (array)$postData['systemData'];
+ $newSystemPositions = (array)$postData['newSystemPositions'];
+ $activeCharacter = $this->getCharacter();
+
+ $return = (object)[];
+
+ // update current location
+ // -> suppress temporary timeout errors
+ $activeCharacter = $activeCharacter->updateLog();
+
+ if( !empty($mapIds) ){
+ // IMPORTANT for now -> just update a single map (save performance)
+ $mapId = (int)reset($mapIds);
+ // get map and check map access
+ if( !is_null($map = $activeCharacter->getMap($mapId)) ){
+ // check character log (current system) and manipulate map (e.g. add new system)
+ if($mapTracking){
+ $map = $this->updateMapByCharacter($map, $activeCharacter, $newSystemPositions);
+ }
+
+ // mapUserData ----------------------------------------------------------------------------------------
+ if($getMapUserData){
+ $cacheKey = $this->getUserDataCacheKey($mapId);
+ if( !$f3->exists($cacheKey, $mapUserData) ){
+ $mapUserData = $map->getUserData();
+
+ // cache time (seconds) should be equal or less than request trigger time
+ // prevent request flooding
+ $responseTTL = (int)Config::getPathfinderData('timer.update_server_user_data.delay') / 1000;
+ $f3->set($cacheKey, $mapUserData, $responseTTL);
+ }
+ $return->mapUserData[] = $mapUserData;
+ }
+
+ // systemData -----------------------------------------------------------------------------------------
+ if(
+ $mapId === (int)$systemData['mapId'] &&
+ !is_null($system = $map->getSystemById((int)$systemData['id']))
+ ){
+ // data for currently selected system
+ $return->system = $system->getData();
+ $return->system->signatures = $system->getSignaturesData();
+ $return->system->sigHistory = $system->getSignaturesHistory();
+ $return->system->structures = $system->getStructuresData();
+ }
+ }
+ }
+
+ // get current user data -> this should not be cached because each user has different personal data
+ // even if they have multiple characters using the same map!
+ $return->userData = $activeCharacter->getUser()->getData();
+
+ // add error (if exists)
+ $return->error = [];
+
+ echo json_encode($return);
+ }
+
+ /**
+ * update map connections/systems based on $character´s location logs
+ * @param Pathfinder\MapModel $map
+ * @param Pathfinder\CharacterModel $character
+ * @param array $newSystemPositions
+ * @return Pathfinder\MapModel
+ * @throws \Exception
+ */
+ protected function updateMapByCharacter(Pathfinder\MapModel $map, Pathfinder\CharacterModel $character, array $newSystemPositions = []) : Pathfinder\MapModel {
+ // map changed. update cache (system/connection) changed
+ $mapDataChanged = false;
+
+ if(
+ ( $mapScope = $map->getScope() ) &&
+ ( $mapScope->name != 'none' ) && // tracking is disabled for map
+ ( $targetLog = $character->getLog() )
+ ){
+ // character is currently in a system
+ $targetSystemId = (int)$targetLog->systemId;
+
+ // get 'character log' from source system. If not log found -> assume $sourceLog == $targetLog
+ $sourceLog = $character->getLogPrevSystem($map->_id, $targetSystemId) ? : $targetLog;
+ $sourceSystemId = (int)$sourceLog->systemId;
+
+ if($sourceSystemId){
+ $defaultPositions = (array)$newSystemPositions['defaults'];
+ $currentPosition = (array)$newSystemPositions['location'];
+
+ $sourceSystem = null;
+ $targetSystem = null;
+
+ $sourceExists = false;
+ $targetExists = false;
+
+ $sameSystem = false;
+
+ // system coordinates for system tha might be added next
+ $systemOffsetX = 130;
+ $systemOffsetY = 0;
+ $systemPosX = ((int)$defaultPositions[0]['x']) ? : 0;
+ $systemPosY = ((int)$defaultPositions[0]['y']) ? : 30;
+
+ // check if previous (solo) system is already on the map ----------------------------------------------
+ $sourceSystem = $map->getSystemByCCPId($sourceSystemId, [AbstractModel::getFilter('active', true)]);
+
+ // if systems don´t already exists on map -> get "blank" system
+ // -> required for system type check (e.g. wormhole, k-space)
+ if($sourceSystem){
+ // system exists
+ $sourceExists = true;
+ }else{
+ // system not exists -> get"blank" system
+ $sourceSystem = $map->getNewSystem($sourceSystemId);
+ }
+
+ // check if source and target systems are equal -------------------------------------------------------
+ if($sourceSystemId === $targetSystemId){
+ $sameSystem = true;
+ $targetExists = $sourceExists;
+ $targetSystem = $sourceSystem;
+ }elseif($targetSystemId){
+ // check if target system is already on this map
+ $targetSystem = $map->getSystemByCCPId($targetSystemId, [AbstractModel::getFilter('active', true)]);
+
+ if($targetSystem){
+ $targetExists = true;
+
+ if($targetSystemId === (int)$currentPosition['systemId']){
+ $systemPosX = (int)$currentPosition['position']['x'];
+ $systemPosY = (int)$currentPosition['position']['y'];
+ }
+ }else{
+ $targetSystem = $map->getNewSystem($targetSystemId);
+ }
+ }
+
+ // make sure we have system objects to work with
+ // -> in case SDE does not have system they are null -> we can´t do anything
+ if(
+ $sourceSystem &&
+ $targetSystem
+ ){
+ $addSourceSystem = false;
+ $addTargetSystem = false;
+ $addConnection = false;
+ $route = [];
+
+ switch($mapScope->name){
+ case 'all':
+ if($sameSystem){
+ $addSourceSystem = true;
+ }else{
+ $addSourceSystem = true;
+ $addTargetSystem = true;
+ $addConnection = true;
+ }
+ break;
+ case 'k-space':
+ if($sameSystem){
+ if($sourceSystem->isKspace()){
+ $addSourceSystem = true;
+ }
+ }elseif(
+ $sourceSystem->isKspace() ||
+ $targetSystem->isKspace()
+ ){
+ $addSourceSystem = true;
+ $addTargetSystem = true;
+ $addConnection = true;
+ }
+ break;
+ case 'wh':
+ default:
+ if($sameSystem){
+ if($sourceSystem->isWormhole()){
+ $addSourceSystem = true;
+ }
+ }elseif(
+ $sourceSystem->isWormhole() ||
+ $targetSystem->isWormhole()
+ ){
+ $addSourceSystem = true;
+ $addTargetSystem = true;
+ $addConnection = true;
+ }elseif(
+ !$sourceSystem->isWormhole() &&
+ !$targetSystem->isWormhole()
+ ){
+ // check distance between systems (in jumps)
+ // -> if > 1 it is !very likely! a wormhole
+ $route = (new Controller\Api\Rest\Route())->searchRoute($sourceSystem->systemId, $targetSystem->systemId, 1);
+
+ if(!$route['routePossible']){
+ $addSourceSystem = true;
+ $addTargetSystem = true;
+ $addConnection = true;
+ }
+ }
+ break;
+ }
+
+ // check for "abyss" systems =====================================================================
+ if(!$map->trackAbyssalJumps){
+ if(
+ $sourceSystem->isAbyss() ||
+ $targetSystem->isAbyss()
+ ){
+ $addConnection = false;
+
+ if($sourceSystem->isAbyss()){
+ $addSourceSystem = false;
+ }
+
+ if($targetSystem->isAbyss()){
+ $addTargetSystem = false;
+ }
+ }
+ }
+
+ // save source system =============================================================================
+ if(
+ $addSourceSystem &&
+ $sourceSystem &&
+ !$sourceExists
+ ){
+ $sourceSystem = $map->saveSystem($sourceSystem, $character, $systemPosX, $systemPosY);
+ // get updated maps object
+ if($sourceSystem){
+ $map = $sourceSystem->mapId;
+ $sourceExists = true;
+ $mapDataChanged = true;
+
+ if(!empty($defaultPositions[1])){
+ $systemPosX = (int)$defaultPositions[1]['x'];
+ $systemPosY = (int)$defaultPositions[1]['y'];
+ }else{
+ // increase system position (prevent overlapping)
+ $systemPosX = $sourceSystem->posX + $systemOffsetX;
+ $systemPosY = $sourceSystem->posY + $systemOffsetY;
+ }
+ }
+ }
+
+ // save target system =============================================================================
+ if(
+ $addTargetSystem &&
+ $targetSystem &&
+ !$targetExists
+ ){
+ $targetSystem = $map->saveSystem($targetSystem, $character, $systemPosX, $systemPosY);
+ // get updated maps object
+ if($targetSystem){
+ $map = $targetSystem->mapId;
+ $mapDataChanged = true;
+ $targetExists = true;
+ }
+ }
+
+ if(
+ !$sameSystem &&
+ $sourceExists &&
+ $targetExists &&
+ $sourceSystem &&
+ $targetSystem
+ ){
+ $connection = $map->searchConnection($sourceSystem, $targetSystem);
+
+ // save connection ============================================================================
+ if(
+ $addConnection &&
+ !$connection
+ ){
+ // .. do not add connection if character got "podded" -------------------------------------
+ if(
+ $targetLog->shipTypeId == 670 &&
+ $character->cloneLocationId
+ ){
+ // .. current character location must be clone location
+ if(
+ (
+ 'station' == $character->cloneLocationType &&
+ $character->cloneLocationId == $targetLog->stationId
+ ) || (
+ 'structure' == $character->cloneLocationType &&
+ $character->cloneLocationId == $targetLog->structureId
+ )
+ ){
+ // .. now we need to check jump distance between systems
+ // -> if > 1 it is !very likely! podded jump
+ if(empty($route)){
+ $route = (new Controller\Api\Rest\Route())->searchRoute($sourceSystem->systemId, $targetSystem->systemId, 1);
+ }
+
+ if(!$route['routePossible']){
+ $addConnection = false;
+ }
+ }
+ }
+
+ if($addConnection){
+ $connection = $map->getNewConnection($sourceSystem, $targetSystem);
+ $connection = $map->saveConnection($connection, $character);
+ // get updated maps object
+ if($connection){
+ $map = $connection->mapId;
+ $mapDataChanged = true;
+ }
+ }
+ }
+
+ // log jump mass ==============================================================================
+ if(
+ $connection &&
+ $connection->isWormhole()
+ ){
+ $connection->logMass($targetLog);
+ }
+ }
+ }
+ }
+ }
+
+ if($mapDataChanged){
+ $this->broadcastMap($map);
+ }
+
+ return $map;
+ }
+
+ /**
+ * get connectionData
+ * @param \Base $f3
+ * @throws \Exception
+ */
+ public function getConnectionData(\Base $f3){
+ $postData = (array)$f3->get('POST');
+
+ $addData = (array)$postData['addData'];
+ $filterData = (array)$postData['filterData'];
+ $connectionData = [];
+
+ if($mapId = (int)$postData['mapId']){
+ $activeCharacter = $this->getCharacter();
+
+ /**
+ * @var $map Pathfinder\MapModel
+ */
+ $map = Pathfinder\AbstractPathfinderModel::getNew('MapModel');
+ $map->getById($mapId);
+
+ if($map->hasAccess($activeCharacter)){
+ // get specific connections by id
+ $connectionIds = null;
+ if(is_array($postData['connectionIds'])){
+ $connectionIds = array_map('intval', $postData['connectionIds']);
+ }
+
+ $connections = $map->getConnections($connectionIds, 'wh');
+ foreach($connections as $connection){
+ $check = true;
+ $data = $connection->getData(in_array('signatures', $addData), in_array('logs', $addData));
+ // filter result
+ if(in_array('signatures', $filterData) && !$data->signatures){
+ $check = false;
+ }
+
+ if(in_array('logs', $filterData) && !$data->logs){
+ $check = false;
+ }
+
+ if($check){
+ $connectionData[] = $data;
+ }
+ }
+ }
+ }
+
+ echo json_encode($connectionData);
+ }
+
+ /**
+ * get map log data
+ * @param \Base $f3
+ * @throws \Exception
+ */
+ public function getLogData(\Base $f3){
+ $postData = (array)$f3->get('POST');
+ $return = (object) [];
+ $return->data = [];
+
+ // validate query parameters
+ $return->query = [
+ 'mapId' => (int) $postData['mapId'],
+ 'offset' => FileHandler::validateOffset( (int)$postData['offset'] ),
+ 'limit' => FileHandler::validateLimit( (int)$postData['limit'] )
+ ];
+
+ if($mapId = (int)$postData['mapId']){
+ $activeCharacter = $this->getCharacter();
+
+ /**
+ * @var $map Pathfinder\MapModel
+ */
+ $map = Pathfinder\AbstractPathfinderModel::getNew('MapModel');
+ $map->getById($mapId);
+
+ if($map->hasAccess($activeCharacter)){
+ $cacheKey = $this->getHistoryDataCacheKey($mapId);
+ if($return->query['offset'] === 0){
+ // check cache
+ $return->data = $f3->get($cacheKey);
+ }
+
+ if(empty($return->data)){
+ $return->data = $map->getLogData($return->query['offset'], $return->query['limit']);
+ if(
+ $return->query['offset'] === 0 &&
+ !empty($return->data))
+ {
+ $f3->set($cacheKey, $return->data, (int)Config::getPathfinderData('history.cache'));
+ }
+ }
+ }
+ }
+
+ echo json_encode($return);
+ }
+
+}
diff --git a/app/Controller/Api/Rest/AbstractRestController.php b/app/Controller/Api/Rest/AbstractRestController.php
new file mode 100644
index 000000000..5bf26d22a
--- /dev/null
+++ b/app/Controller/Api/Rest/AbstractRestController.php
@@ -0,0 +1,49 @@
+ $_POST does not include request data -> request BODY might contain JSON
+ * @param \Base $f3
+ * @return array
+ */
+ protected function getRequestData(\Base $f3) : array {
+ $data = [];
+ if($f3->get('VERB') == 'GET'){
+ // get data from URL parameters
+ $data = (array)$f3->get('GET');
+ }elseif( !empty($body = $f3->get('BODY')) ){
+ // get data from HTTP body
+ $bodyDecode = json_decode($body, true);
+ if(($jsonError = json_last_error()) === JSON_ERROR_NONE){
+ $data = $bodyDecode;
+ }else{
+ $f3->set('HALT', true);
+ $f3->error(400, 'Request data: ' . json_last_error_msg());
+ }
+ }
+
+ return $data;
+ }
+
+ /**
+ * render API response to client
+ * @param $output
+ */
+ protected function out($output){
+ echo json_encode($output);
+ }
+
+}
\ No newline at end of file
diff --git a/app/Controller/Api/Rest/Connection.php b/app/Controller/Api/Rest/Connection.php
new file mode 100644
index 000000000..3b430aafd
--- /dev/null
+++ b/app/Controller/Api/Rest/Connection.php
@@ -0,0 +1,157 @@
+getRequestData($f3);
+ $connectionIds = array_map('intval', explode(',', (string)$params['id']));
+ $addData = (array)$requestData['addData'];
+ $filterData = (array)$requestData['filterData'];
+ $connectionData = [];
+
+ if($mapId = (int)$requestData['mapId']){
+ $activeCharacter = $this->getCharacter();
+
+ /**
+ * @var $map Pathfinder\MapModel
+ */
+ $map = Pathfinder\AbstractPathfinderModel::getNew('MapModel');
+ $map->getById($mapId);
+
+ if($map->hasAccess($activeCharacter)){
+ $connections = $map->getConnections($connectionIds, 'wh');
+ foreach($connections as $connection){
+ $check = true;
+ $data = $connection->getData(in_array('signatures', $addData), in_array('logs', $addData));
+ // filter result
+ if(in_array('signatures', $filterData) && !$data->signatures){
+ $check = false;
+ }
+
+ if(in_array('logs', $filterData) && !$data->logs){
+ $check = false;
+ }
+
+ if($check){
+ $connectionData[] = $data;
+ }
+ }
+ }
+ }
+
+ $this->out($connectionData);
+ }
+
+ /**
+ * save a new connection or updates an existing (drag/drop) between two systems
+ * if a connection is changed (drag&drop) to another system. -> this function is called for update
+ * @param \Base $f3
+ * @throws \Exception
+ */
+ public function put(\Base $f3){
+ $requestData = $this->getRequestData($f3);
+ $connectionData = [];
+
+ if($mapId = (int)$requestData['mapId']){
+ $activeCharacter = $this->getCharacter();
+
+ /**
+ * @var $map Pathfinder\MapModel
+ */
+ $map = Pathfinder\AbstractPathfinderModel::getNew('MapModel');
+ $map->getById($mapId);
+ if($map->hasAccess($activeCharacter)){
+ $source = $map->getSystemById((int)$requestData['source']);
+ $target = $map->getSystemById((int)$requestData['target']);
+
+ if(
+ !is_null($source) &&
+ !is_null($target)
+ ){
+ /**
+ * @var $connection Pathfinder\ConnectionModel
+ */
+ $connection = Pathfinder\AbstractPathfinderModel::getNew('ConnectionModel');
+ $connection->getById((int)$requestData['id']);
+
+ $connection->mapId = $map;
+ $connection->source = $source;
+ $connection->target = $target;
+
+ // if scope + type data send -> use them ...
+ if($requestData['scope'] && !empty($requestData['type'])){
+ $connection->copyfrom($requestData, ['scope', 'type']);
+ }
+
+ // ... set/change default scope + type
+ if(!$requestData['disableAutoScope']){
+ $connection->setAutoScopeAndType();
+ }
+
+ if($connection->save($activeCharacter)){
+ $connectionData = $connection->getData();
+
+ // broadcast map changes
+ $this->broadcastMap($connection->mapId);
+ }
+ }
+ }
+ }
+
+ $this->out($connectionData);
+ }
+
+ /**
+ * @param \Base $f3
+ * @param $params
+ * @throws \Exception
+ */
+ public function delete(\Base $f3, $params){
+ $requestData = $this->getRequestData($f3);
+ $connectionIds = array_map('intval', explode(',', (string)$params['id']));
+ $deletedConnectionIds = [];
+
+ if($mapId = (int)$requestData['mapId']){
+ $activeCharacter = $this->getCharacter();
+
+ /**
+ * @var $map Pathfinder\MapModel
+ */
+ $map = Pathfinder\AbstractPathfinderModel::getNew('MapModel');
+ $map->getById($mapId);
+ if($map->hasAccess($activeCharacter)){
+ foreach($connectionIds as $connectionId){
+ if($connection = $map->getConnectionById($connectionId)){
+ if($connection->delete($activeCharacter)){
+ $deletedConnectionIds[] = $connectionId;
+ }
+ $connection->reset();
+ }
+ }
+
+ // broadcast map changes
+ if(count($deletedConnectionIds)){
+ $this->broadcastMap($map);
+ }
+ }
+ }
+
+ $this->out($deletedConnectionIds);
+ }
+}
\ No newline at end of file
diff --git a/app/Controller/Api/Rest/Log.php b/app/Controller/Api/Rest/Log.php
new file mode 100644
index 000000000..9696ce00b
--- /dev/null
+++ b/app/Controller/Api/Rest/Log.php
@@ -0,0 +1,112 @@
+getRequestData($f3);
+ $connectionData = [];
+
+ if($connectionId = (int)$requestData['connectionId']){
+ $activeCharacter = $this->getCharacter();
+
+ /**
+ * @var $connection Pathfinder\ConnectionModel
+ */
+ $connection = Pathfinder\AbstractPathfinderModel::getNew('ConnectionModel');
+ $connection->getById($connectionId);
+
+ if($connection->hasAccess($activeCharacter)){
+ $log = $connection->getNewLog();
+ $log->setData($requestData);
+ $log->record = false; // log not recorded by ESI
+ $log->save();
+
+ $connectionData[] = $log->getConnection()->getData(true, true);
+ }
+ }
+
+ $this->out($connectionData);
+ }
+
+ /**
+ * delete (deactivate) log data
+ * @param \Base $f3
+ * @param $params
+ * @throws \Exception
+ */
+ public function delete(\Base $f3, $params){
+ $logId = (int)$params['id'];
+ $connectionData = [];
+
+ if($log = $this->update($logId, ['active' => false])){
+ $connectionData[] = $log->getConnection()->getData(true, true);
+ }
+
+ $this->out($connectionData);
+ }
+
+ /**
+ * update log data
+ * @param \Base $f3
+ * @param $params
+ * @throws \Exception
+ */
+ public function patch(\Base $f3, $params){
+ $logId = (int)$params['id'];
+ $requestData = $this->getRequestData($f3);
+ $connectionData = [];
+
+ if($log = $this->update($logId, $requestData)){
+ $connectionData[] = $log->getConnection()->getData(true, true);
+ }
+
+ $this->out($connectionData);
+ }
+
+ // ----------------------------------------------------------------------------------------------------------------
+
+ /**
+ * update existing connectionLog with new data
+ * @param int $logId
+ * @param array $logData
+ * @return bool|Pathfinder\ConnectionLogModel
+ * @throws \Exception
+ */
+ private function update(int $logId, array $logData){
+ $log = false;
+ if($logId){
+ $activeCharacter = $this->getCharacter();
+ /**
+ * @var $log Pathfinder\ConnectionLogModel
+ */
+ $log = Pathfinder\AbstractPathfinderModel::getNew('ConnectionLogModel');
+ $log->getById($logId, 0, false);
+
+ if($log->hasAccess($activeCharacter)){
+ $log->setData($logData);
+
+ if(isset($logData['active'])){
+ $log->setActive((bool)$logData['active']);
+ }
+ $log->save();
+ }
+ }
+ return $log;
+ }
+}
\ No newline at end of file
diff --git a/app/Controller/Api/Rest/Map.php b/app/Controller/Api/Rest/Map.php
new file mode 100644
index 000000000..05c64cc75
--- /dev/null
+++ b/app/Controller/Api/Rest/Map.php
@@ -0,0 +1,239 @@
+getRequestData($f3);
+
+ /**
+ * @var $map Pathfinder\MapModel
+ */
+ $map = Pathfinder\AbstractPathfinderModel::getNew('MapModel');
+ $mapData = $this->update($map, $requestData)->getData();
+
+ $this->out($mapData);
+ }
+
+ /**
+ * @param \Base $f3
+ * @param $params
+ * @throws \Exception
+ */
+ public function patch(\Base $f3, $params){
+ $requestData = $this->getRequestData($f3);
+ $mapData = [];
+
+ if($mapId = (int)$params['id']){
+ $activeCharacter = $this->getCharacter();
+
+ /**
+ * @var $map Pathfinder\MapModel
+ */
+ $map = Pathfinder\AbstractPathfinderModel::getNew('MapModel');
+ $map->getById($mapId);
+ if($map->hasAccess($activeCharacter)){
+ $mapData = $this->update($map, $requestData)->getData(true);
+ }
+ }
+
+ $this->out($mapData);
+ }
+
+ /**
+ * @param \Base $f3
+ * @param $params
+ * @throws \Exception
+ */
+ public function delete(\Base $f3, $params){
+ $deletedMapIds = [];
+
+ if($mapId = (int)$params['id']){
+ $activeCharacter = $this->getCharacter();
+
+ /**
+ * @var $map Pathfinder\MapModel
+ */
+ $map = Pathfinder\AbstractPathfinderModel::getNew('MapModel');
+ $map->getById($mapId);
+
+ if($map->hasAccess($activeCharacter)){
+ // check if character has delete right for map type
+ $hasRight = true;
+ if($map->isCorporation()){
+ if($corpRight = $activeCharacter->getCorporation()->getRights(['map_delete'])){
+ if($corpRight[0]->get('roleId', true) !== $activeCharacter->get('roleId', true)){
+ $hasRight = false;
+ }
+ }
+ }
+
+ if($hasRight){
+ $map->setActive(false);
+ $map->save($activeCharacter);
+ $deletedMapIds[] = $mapId;
+ // broadcast map delete
+ $this->broadcastMapDeleted($mapId);
+ }else{
+ $f3->set('HALT', true);
+ $f3->error(401, sprintf(self::ERROR_MAP_DELETE, $activeCharacter->name));
+ }
+ }
+ }
+
+ $this->out($deletedMapIds);
+ }
+
+ /**
+ * @param Pathfinder\MapModel $map
+ * @param array $mapData
+ * @return Pathfinder\MapModel
+ * @throws \Exception
+ */
+ private function update(Pathfinder\MapModel $map, array $mapData) : Pathfinder\MapModel {
+ $activeCharacter = $this->getCharacter();
+
+ $map->setData($mapData);
+ $typeChange = $map->changed('typeId');
+ $map->save($activeCharacter);
+
+ // save global map access. Depends on map "type" --------------------------------------------------------------
+ /**
+ * @param Pathfinder\AbstractPathfinderModel $primaryModel
+ * @param array|null $modelIds
+ * @param int $maxShared
+ * @return int
+ */
+ $setMapAccess = function(Pathfinder\AbstractPathfinderModel &$primaryModel, ?array $modelIds = [], int $maxShared = 3) use (&$map) : int {
+ $added = 0;
+ $deleted = 0;
+ if(is_array($modelIds)){
+ // remove primaryModel id (-> re-add later)
+ $modelIds = array_diff(array_map('intval', $modelIds), [$primaryModel->_id]);
+
+ // avoid abuse -> respect share limits (-1 is because the primaryModel has also access)
+ $modelIds = array_slice($modelIds, 0, max($maxShared - 1, 0));
+
+ // add the primaryModel id back (again)
+ $modelIds[] = $primaryModel->_id;
+
+ // clear map access for entities that do not match the map "mapType"
+ $deleted += $map->clearAccessByType();
+
+ $compare = $map->compareAccess($modelIds);
+
+ foreach((array)$compare['old'] as $modelId) {
+ $deleted += $map->removeFromAccess($modelId);
+ }
+
+ $modelClass = (new \ReflectionClass($primaryModel))->getShortName();
+ $tempModel = Pathfinder\AbstractPathfinderModel::getNew($modelClass);
+ foreach((array)$compare['new'] as $modelId) {
+ $tempModel->getById($modelId);
+ if(
+ $tempModel->valid() &&
+ (
+ $modelId == $primaryModel->_id || // primary model has always access (regardless of "shared" value)
+ $tempModel->shared == 1 // check if map shared is enabled
+ )
+ ){
+ $added += (int)$map->setAccess($tempModel);
+ }
+
+ $tempModel->reset();
+ }
+ }
+ return $added + $deleted;
+ };
+
+ $accessChangeCount = 0;
+ $mapDefaultConf = Config::getMapsDefaultConfig();
+ if($map->isPrivate()){
+ $accessChangeCount = $setMapAccess(
+ $activeCharacter,
+ $typeChange ? [$activeCharacter->_id] : $mapData['mapCharacters'],
+ (int)$mapDefaultConf['private']['max_shared']
+ );
+ }elseif($map->isCorporation()){
+ if($corporation = $activeCharacter->getCorporation()){
+ $accessChangeCount = $setMapAccess(
+ $corporation,
+ $typeChange ? [$corporation->_id] : $mapData['mapCorporations'],
+ (int)$mapDefaultConf['corporation']['max_shared']
+ );
+ }
+ }elseif($map->isAlliance()){
+ if($alliance = $activeCharacter->getAlliance()){
+ $accessChangeCount = $setMapAccess(
+ $alliance,
+ $typeChange ? [$alliance->_id] : $mapData['mapAlliances'],
+ (int)$mapDefaultConf['alliance']['max_shared']
+ );
+ }
+ }
+
+ if($accessChangeCount){
+ $map->touch('updated');
+ $map->save($activeCharacter);
+ }
+
+ // reload the same map model (refresh)
+ // this makes sure all data is up2date
+ $map->getById($map->_id, 0);
+
+ // broadcast map Access -> and send map Data
+ $this->broadcastMapAccess($map);
+
+ return $map;
+ }
+
+ /**
+ * broadcast characters with map access rights to WebSocket server
+ * -> if characters with map access found -> broadcast mapData to them
+ * @param Pathfinder\MapModel $map
+ * @throws \Exception
+ */
+ protected function broadcastMapAccess(Pathfinder\MapModel $map){
+ $mapAccess = [
+ 'id' => $map->_id,
+ 'name' => $map->name,
+ 'characterIds' => array_map(function($data){
+ return $data->id;
+ }, $map->getCharactersData())
+ ];
+
+ $this->getF3()->webSocket()->write('mapAccess', $mapAccess);
+
+ // map has (probably) active connections that should receive map Data
+ $this->broadcastMap($map, true);
+ }
+
+ /**
+ * broadcast map delete information to clients
+ * @param int $mapId
+ */
+ private function broadcastMapDeleted(int $mapId){
+ $this->getF3()->webSocket()->write('mapDeleted', $mapId);
+ }
+}
\ No newline at end of file
diff --git a/app/Controller/Api/Rest/Route.php b/app/Controller/Api/Rest/Route.php
new file mode 100644
index 000000000..af7998a4e
--- /dev/null
+++ b/app/Controller/Api/Rest/Route.php
@@ -0,0 +1,857 @@
+ each connection has a A->B and B->A entry. So we have 50 "real connections"
+ */
+ const MAX_CONNECTION_COUNT = 100;
+
+ /**
+ * cache time for static jump data (e.g. K-Space stargates)
+ * @var int
+ */
+ private $staticJumpDataCacheTime = 86400;
+
+ /**
+ * cache time for dynamic jump data (e.g. W-Space systems, Jumpbridges. ...)
+ * @var int
+ */
+ private $dynamicJumpDataCacheTime = 10;
+
+ /**
+ * cache time for Thera connections from eve-scout.com
+ * @var int
+ */
+ private $theraJumpDataCacheTime = 60;
+
+ /**
+ * array system information grouped by systemId
+ * @var array
+ */
+ private $nameArray = [];
+
+ /**
+ * array neighbour systems grouped by systemName
+ * @var array
+ */
+ private $jumpArray = [];
+
+ /**
+ * array with systemName => systemId matching
+ * @var array
+ */
+ private $idArray = [];
+
+ /**
+ * template for routeData payload
+ * @var array
+ */
+ private $defaultRouteData = [
+ 'routePossible' => false,
+ 'routeJumps' => 0,
+ 'maxDepth' => self::ROUTE_SEARCH_DEPTH_DEFAULT,
+ 'depthSearched' => 0,
+ 'searchType' => '',
+ 'route' => [],
+ 'error' => ''
+ ];
+
+ /**
+ * reset all jump data
+ */
+ protected function resetJumpData(){
+ $this->nameArray = [];
+ $this->jumpArray = [];
+ $this->idArray = [];
+ }
+
+ /**
+ * set static system jump data for this instance
+ * the data is fixed and should not change
+ * -> jump data includes JUST "static" connections (Stargates)
+ * -> this data is equal for EACH route search (does not depend on map data)
+ */
+ private function setStaticJumpData(){
+ if($universeDB = $this->getDB('UNIVERSE')){
+ $query = "SELECT * FROM system_neighbour";
+ $rows = $universeDB->exec($query, null, $this->staticJumpDataCacheTime);
+
+ if(count($rows) > 0){
+ array_walk($rows, function(&$row){
+ $row['jumpNodes'] = array_map('intval', explode(':', $row['jumpNodes']));
+ });
+ $this->updateJumpData($rows);
+ }
+ }
+ }
+
+ /**
+ * set/add dynamic system jump data for specific "mapId"´s
+ * -> this data is dynamic and could change on any map change
+ * -> (e.g. new system added, connection added/updated, ...)
+ * @param array $mapIds
+ * @param array $filterData
+ * @throws \Exception
+ */
+ private function setDynamicJumpData($mapIds = [], $filterData = []){
+ // make sure, mapIds are integers (protect against SQL injections)
+ $mapIds = array_unique( array_map('intval', $mapIds), SORT_NUMERIC);
+
+ if( !empty($mapIds) ){
+ // map filter ---------------------------------------------------------------------------------------------
+ $whereMapIdsQuery = (count($mapIds) == 1) ? " = " . reset($mapIds) : " IN (" . implode(', ', $mapIds) . ")";
+
+ // connection filter --------------------------------------------------------------------------------------
+ $whereQuery = "";
+ $includeScopes = [];
+ $includeTypes = [];
+ $excludeTypes = [];
+ $includeEOL = true;
+
+ $excludeEndpointTypes = [];
+
+ if( $filterData['stargates'] === true){
+ // include "stargates" for search
+ $includeScopes[] = 'stargate';
+ $includeTypes[] = 'stargate';
+
+ }
+
+ if( $filterData['jumpbridges'] === true ){
+ // add jumpbridge connections for search
+ $includeScopes[] = 'jumpbridge';
+ $includeTypes[] = 'jumpbridge';
+ }
+
+ if( $filterData['wormholes'] === true ){
+ // add wormhole connections for search
+ $includeScopes[] = 'wh';
+ $includeTypes[] = 'wh_fresh';
+
+
+ if( $filterData['wormholesReduced'] === true ){
+ $includeTypes[] = 'wh_reduced';
+ }
+
+ if( $filterData['wormholesCritical'] === true ){
+ $includeTypes[] = 'wh_critical';
+ }
+
+ if( $filterData['wormholesEOL'] === false ){
+ $includeEOL = false;
+ }
+
+ if(!empty($filterData['excludeTypes'])){
+ $excludeTypes = $filterData['excludeTypes'];
+ }
+ }
+
+ if( $filterData['endpointsBubble'] !== true ){
+ $excludeEndpointTypes[] = 'bubble';
+ }
+
+ // search connections -------------------------------------------------------------------------------------
+
+ if( !empty($includeScopes) ){
+ $whereQuery .= " `connection`.`scope` IN ('" . implode("', '", $includeScopes) . "') AND ";
+
+ if( !empty($excludeTypes) ){
+ $whereQuery .= " `connection`.`type` NOT REGEXP '" . implode("|", $excludeTypes) . "' AND ";
+ }
+
+ if( !empty($includeTypes) ){
+ $whereQuery .= " `connection`.`type` REGEXP '" . implode("|", $includeTypes) . "' AND ";
+ }
+
+ if(!$includeEOL){
+ $whereQuery .= " `connection`.`eolUpdated` IS NULL AND ";
+ }
+
+ if( !empty($excludeEndpointTypes) ){
+ $whereQuery .= " CONCAT_WS(' ', `connection`.`sourceEndpointType`, `connection`.`targetEndpointType`) ";
+ $whereQuery .= " NOT REGEXP '" . implode("|", $excludeEndpointTypes) . "' AND ";
+ }
+
+ $query = "SELECT
+ `system_src`.`systemId` systemSourceId,
+ `system_tar`.`systemId` systemTargetId
+ FROM
+ `connection` INNER JOIN
+ `map` ON
+ `map`.`id` = `connection`.`mapId` AND
+ `map`.`active` = 1 INNER JOIN
+ `system` `system_src` ON
+ `system_src`.`id` = `connection`.`source` AND
+ `system_src`.`active` = 1 INNER JOIN
+ `system` `system_tar` ON
+ `system_tar`.`id` = `connection`.`target` AND
+ `system_tar`.`active` = 1
+ WHERE
+ " . $whereQuery . "
+ `connection`.`active` = 1 AND
+ `connection`.`mapId` " . $whereMapIdsQuery . "
+ ";
+
+ $rows = $this->getDB()->exec($query, null, $this->dynamicJumpDataCacheTime);
+
+ if(count($rows) > 0){
+ $jumpData = [];
+ $universe = new Universe();
+
+ /**
+ * enrich dynamic jump data with static system data (from universe DB)
+ * @param array $row
+ * @param string $systemSourceKey
+ * @param string $systemTargetKey
+ */
+ $enrichJumpData = function(array &$row, string $systemSourceKey, string $systemTargetKey) use (&$jumpData, &$universe) {
+ if(
+ !array_key_exists($row[$systemSourceKey], $jumpData) &&
+ !is_null($staticData = $universe->getSystemData($row[$systemSourceKey]))
+ ){
+ $jumpData[$row[$systemSourceKey]] = [
+ 'systemId' => (int)$row[$systemSourceKey],
+ 'systemName' => $staticData->name,
+ 'constellationId' => $staticData->constellation->id,
+ 'regionId' => $staticData->constellation->region->id,
+ 'trueSec' => $staticData->trueSec,
+ ];
+ }
+
+ if( !in_array($row[$systemTargetKey], (array)$jumpData[$row[$systemSourceKey]]['jumpNodes']) ){
+ $jumpData[$row[$systemSourceKey]]['jumpNodes'][] = (int)$row[$systemTargetKey];
+ }
+ };
+
+ for($i = 0; $i < count($rows); $i++){
+ $enrichJumpData($rows[$i], 'systemSourceId', 'systemTargetId');
+ $enrichJumpData($rows[$i], 'systemTargetId', 'systemSourceId');
+ }
+
+ $this->updateJumpData($jumpData);
+ }
+ }
+ }
+ }
+
+ /**
+ * set current Thera connections jump data for this instance
+ * -> Connected wormholes pulled from eve-scout.com
+ */
+ private function setTheraJumpData(){
+ if(!$this->getF3()->exists(self::CACHE_KEY_THERA_JUMP_DATA, $jumpData)){
+ $jumpData = [];
+ $connectionsData = $this->getF3()->eveScoutClient()->send('getTheraConnections');
+
+ if(!empty($connectionsData) && !isset($connectionsData['error'])){
+ /**
+ * map Thera jump data to Pathfinder format
+ * @param array $row
+ * @param string $systemSourceKey
+ * @param string $systemTargetKey
+ */
+ $enrichJumpData = function(array &$row, string $systemSourceKey, string $systemTargetKey) use (&$jumpData) {
+ // check if response data is valid
+ if(
+ is_object($systemSource = $row[$systemSourceKey]) && !empty((array)$systemSource) &&
+ is_object($systemTarget = $row[$systemTargetKey]) && !empty((array)$systemTarget)
+ ){
+ if(!array_key_exists($systemSource->id, $jumpData)){
+ $jumpData[$systemSource->id] = [
+ 'systemId' => (int)$systemSource->id,
+ 'systemName' => $systemSource->name,
+ 'constellationId' => (int)$systemSource->constellationID,
+ 'regionId' => (int)$systemSource->regionId,
+ 'trueSec' => $systemSource->security,
+ ];
+ }
+
+ if( !in_array((int)$systemTarget->id, (array)$jumpData[$systemSource->id]['jumpNodes']) ){
+ $jumpData[$systemSource->id]['jumpNodes'][] = (int)$systemTarget->id;
+ }
+ }
+ };
+
+ foreach((array)$connectionsData['connections'] as $connectionData){
+ $enrichJumpData($connectionData, 'source', 'target');
+ $enrichJumpData($connectionData, 'target', 'source');
+ }
+
+ if(!empty($jumpData)){
+ $this->getF3()->set(self::CACHE_KEY_THERA_JUMP_DATA, $jumpData, $this->theraJumpDataCacheTime);
+ }
+ }
+ }
+
+ $this->updateJumpData($jumpData);
+ }
+
+ /**
+ * update jump data for this instance
+ * -> data is either coming from CCPs [SDE] OR from map specific data
+ * @param array $rows
+ */
+ private function updateJumpData(&$rows = []){
+ foreach($rows as &$row){
+ $regionId = (int)$row['regionId'];
+ $constId = (int)$row['constellationId'];
+ $systemName = (string)($row['systemName']);
+ $systemId = (int)$row['systemId'];
+ $secStatus = (float)$row['trueSec'];
+
+ // fill "nameArray" data ----------------------------------------------------------------------------------
+ if( !isset($this->nameArray[$systemId]) ){
+ $this->nameArray[$systemId][0] = $systemName;
+ $this->nameArray[$systemId][1] = $regionId;
+ $this->nameArray[$systemId][2] = $constId;
+ $this->nameArray[$systemId][3] = $secStatus;
+ }
+
+ // fill "idArray" data ------------------------------------------------------------------------------------
+ if( !isset($this->idArray[$systemId]) ){
+ $this->idArray[$systemId] = $systemName;
+ }
+
+ // fill "jumpArray" data ----------------------------------------------------------------------------------
+ if( !is_array($this->jumpArray[$systemId]) ){
+ $this->jumpArray[$systemId] = [];
+ }
+ $this->jumpArray[$systemId] = array_merge((array)$row['jumpNodes'], $this->jumpArray[$systemId]);
+
+ // add systemName to end (if not already there)
+ if(end($this->jumpArray[$systemId]) != $systemName){
+ array_push($this->jumpArray[$systemId], $systemName);
+ }
+ }
+ }
+
+ /**
+ * filter systems (remove some systems) e.g. WH,LS,0.0 for "secure search"
+ * @param array $filterData
+ * @param array $keepSystems
+ */
+ private function filterJumpData($filterData = [], $keepSystems = []){
+ if($filterData['flag'] == 'secure'){
+ // remove all systems (TrueSec < 0.5) from search arrays
+ $this->jumpArray = array_filter($this->jumpArray, function($systemId) use ($keepSystems) {
+ $systemNameData = $this->nameArray[$systemId];
+ $systemSec = $systemNameData[3];
+
+ if(
+ $systemSec < 0.45 &&
+ !in_array($systemId, $keepSystems) &&
+ !preg_match('/^j\d+$/i', $this->idArray[$systemId]) // WHs are supposed to be "secure"
+ ){
+ // remove system from nameArray and idArray
+ unset($this->nameArray[$systemId]);
+ unset($this->idArray[$systemId]);
+ return false;
+ }else{
+ return true;
+ }
+ }, ARRAY_FILTER_USE_KEY );
+ }
+ }
+
+ /**
+ * get system data by systemId and dataName
+ * @param $systemId
+ * @param $option
+ * @return null
+ */
+ private function getSystemInfoBySystemId($systemId, $option){
+ $info = null;
+ switch($option){
+ case 'systemName':
+ $info = $this->nameArray[$systemId][0];
+ break;
+ case 'regionId':
+ $info = $this->nameArray[$systemId][1];
+ break;
+ case 'constellationId':
+ $info = $this->nameArray[$systemId][2];
+ break;
+ case 'trueSec':
+ $info = $this->nameArray[$systemId][3];
+ break;
+ }
+
+ return $info;
+ }
+
+ /**
+ * recursive search function within a undirected graph
+ * @param $G
+ * @param $A
+ * @param $B
+ * @param int $M
+ * @return array
+ */
+ private function graph_find_path(&$G, $A, $B, $M = 50000){
+ $maxDepth = $M;
+
+ // $P will hold the result path at the end.
+ // Remains empty if no path was found.
+ $P = [];
+
+ // For each Node ID create a "visit information",
+ // initially set as 0 (meaning not yet visited)
+ // as soon as we visit a node we will tag it with the "source"
+ // so we can track the path when we reach the search target
+
+ $V = [];
+
+ // We are going to keep a list of nodes that are "within reach",
+ // initially this list will only contain the start node,
+ // then gradually expand (almost like a flood fill)
+ $R = [trim($A)];
+
+ $A = trim($A);
+ $B = trim($B);
+
+ while(count($R) > 0 && $M > 0){
+ $M--;
+
+ $X = trim(array_shift($R));
+
+ if(array_key_exists($X, $G)){
+ foreach($G[$X] as $Y){
+ $Y = trim($Y);
+ // See if we got a solution
+ if($Y == $B){
+ // We did? Construct a result path then
+ array_push($P, $B);
+ array_push($P, $X);
+ while($V[$X] != $A){
+ array_push($P, trim($V[$X]));
+ $X = $V[$X];
+ }
+ array_push($P, $A);
+ //return array_reverse($P);
+ return [
+ 'path'=> array_reverse($P),
+ 'depth' => ($maxDepth - $M)
+ ];
+ }
+ // First time we visit this node?
+ if(!array_key_exists($Y, $V)){
+ // Store the path so we can track it back,
+ $V[$Y] = $X;
+ // and add it to the "within reach" list
+ array_push($R, $Y);
+ }
+ }
+ }
+ }
+
+ return [
+ 'path'=> $P,
+ 'depth' => ($maxDepth - $M)
+ ];
+ }
+
+ /**
+ * get formatted jump node data
+ * @param int $systemId
+ * @return array
+ */
+ protected function getJumpNodeData(int $systemId) : array {
+ return [
+ 'system' => $this->getSystemInfoBySystemId($systemId, 'systemName'),
+ 'security' => $this->getSystemInfoBySystemId($systemId, 'trueSec')
+ ];
+ }
+
+ /**
+ * search root between two systemIds
+ * -> function searches over ESI API, as fallback a custom search algorithm is used (no ESI)
+ * @param int $systemFromId
+ * @param int $systemToId
+ * @param int $searchDepth
+ * @param array $mapIds
+ * @param array $filterData
+ * @return array
+ * @throws \Exception
+ */
+ public function searchRoute(int $systemFromId, int $systemToId, $searchDepth = 0, array $mapIds = [], array $filterData = []) : array {
+ // search root by ESI API
+ $routeData = $this->searchRouteESI($systemFromId, $systemToId, $searchDepth, $mapIds, $filterData);
+
+ // Endpoint return http:404 in case no route find (e.g. from inside a wh)
+ // we thread that error "no route found" as a valid response! -> no fallback to custom search
+ if(!empty($routeData['error']) && strtolower($routeData['error']) !== 'no route found'){
+ // ESI route search has errors -> fallback to custom search implementation
+ $routeData = $this->searchRouteCustom($systemFromId, $systemToId, $searchDepth, $mapIds, $filterData);
+ }
+
+ return $routeData;
+ }
+
+ /**
+ * uses a custom search algorithm to fine a route
+ * @param int $systemFromId
+ * @param int $systemToId
+ * @param int $searchDepth
+ * @param array $mapIds
+ * @param array $filterData
+ * @return array
+ * @throws \Exception
+ */
+ private function searchRouteCustom(int $systemFromId, int $systemToId, $searchDepth = 0, array $mapIds = [], array $filterData = []) : array {
+ // reset all previous set jump data
+ $this->resetJumpData();
+
+ $searchDepth = $searchDepth ? $searchDepth : Config::getPathfinderData('route.search_depth');
+
+ $routeData = $this->defaultRouteData;
+ $routeData['maxDepth'] = $searchDepth;
+ $routeData['searchType'] = 'custom';
+
+ if($systemFromId && $systemToId){
+ // prepare search data ------------------------------------------------------------------------------------
+ // add static data (e.g. K-Space stargates,..)
+ $this->setStaticJumpData();
+
+ // add map specific data
+ $this->setDynamicJumpData($mapIds, $filterData);
+
+ // add current Thera connections data
+ if($filterData['wormholesThera']){
+ $this->setTheraJumpData();
+ }
+
+ // filter jump data (e.g. remove some systems (0.0, LS)
+ // --> don´t filter some systems (e.g. systemFrom, systemTo) even if they are are WH,LS,0.0
+ $this->filterJumpData($filterData, [$systemFromId, $systemToId]);
+
+ // search route -------------------------------------------------------------------------------------------
+
+ // jump counter
+ $jumpNum = 0;
+ $depthSearched = 0;
+
+ if(isset($this->jumpArray[$systemFromId])){
+ // check if the system we are looking for is a direct neighbour
+ foreach($this->jumpArray[$systemFromId] as $n){
+ if($n == $systemToId){
+ $jumpNum = 2;
+ $routeData['route'][] = $this->getJumpNodeData($n);
+ break;
+ }
+ }
+
+ // system is not a direct neighbour -> search recursive its neighbours
+ if($jumpNum == 0){
+ $searchResult = $this->graph_find_path( $this->jumpArray, $systemFromId, $systemToId, $searchDepth );
+ $depthSearched = $searchResult['depth'];
+ foreach($searchResult['path'] as $systemId){
+ if($jumpNum > 0){
+ $routeData['route'][] = $this->getJumpNodeData($systemId);
+ }
+ $jumpNum++;
+ }
+ }
+
+ if($jumpNum > 0){
+ // route found
+ $routeData['routePossible'] = true;
+ // insert "from" system on top
+ array_unshift($routeData['route'], $this->getJumpNodeData($systemFromId));
+ }else{
+ // route not found
+ $routeData['routePossible'] = false;
+ }
+ }
+
+ // route jumps
+ $routeData['routeJumps'] = $jumpNum - 1;
+ $routeData['depthSearched'] = $depthSearched;
+ }
+
+ return $routeData;
+ }
+
+ /**
+ * uses ESI route search endpoint to fine a route
+ * @param int $systemFromId
+ * @param int $systemToId
+ * @param int $searchDepth
+ * @param array $mapIds
+ * @param array $filterData
+ * @return array
+ * @throws \Exception
+ */
+ private function searchRouteESI(int $systemFromId, int $systemToId, int $searchDepth = 0, array $mapIds = [], array $filterData = []) : array {
+ // reset all previous set jump data
+ $this->resetJumpData();
+
+ $searchDepth = $searchDepth ? $searchDepth : Config::getPathfinderData('route.search_depth');
+
+ $routeData = $this->defaultRouteData;
+ $routeData['maxDepth'] = $searchDepth;
+ $routeData['searchType'] = 'esi';
+
+ if($systemFromId && $systemToId){
+ // ESI route search can only handle 50 $connections (100 entries)
+ // we want to add NON stargate connections ONLY for ESI route search
+ // because ESI will use them anyways!
+ $filterData['stargates'] = false;
+
+ // prepare search data ------------------------------------------------------------------------------------
+
+ // add map specific data
+ $this->setDynamicJumpData($mapIds, $filterData);
+
+ // add current Thera connections data
+ if($filterData['wormholesThera']){
+ $this->setTheraJumpData();
+ }
+
+ // filter jump data (e.g. remove some systems (0.0, LS)
+ // --> don´t filter some systems (e.g. systemFrom, systemTo) even if they are are WH,LS,0.0
+ $this->filterJumpData($filterData, [$systemFromId, $systemToId]);
+
+ $connections = [];
+ foreach($this->jumpArray as $systemSourceId => $jumpData){
+ $count = count($jumpData);
+ if($count > 1){
+ // ... should always > 1
+ // loop all connections for current source system
+ foreach($jumpData as $systemTargetId){
+ // skip last entry
+ if(--$count <= 0){
+ break;
+ }
+
+ // systemIds exist and wer not removed before in filterJumpData()
+ if($systemSourceId && $systemTargetId){
+ $jumpNode = [$systemSourceId, $systemTargetId];
+ // jumpNode must be unique for ESI,
+ // ... there can be multiple connections between same systems in Pathfinder
+ if(!in_array($jumpNode, $connections)){
+ $connections[] = [$systemSourceId, $systemTargetId];
+ // check if connections limit is reached
+ if(count($connections) >= self::MAX_CONNECTION_COUNT){
+ // ESI API limit for custom "connections"
+ break 2;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // search route -------------------------------------------------------------------------------------------
+ $options = [
+ 'flag' => $filterData['flag'],
+ 'connections' => $connections
+ ];
+
+ $result = $this->getF3()->ccpClient()->send('getRoute', $systemFromId, $systemToId, $options);
+
+ // format result ------------------------------------------------------------------------------------------
+
+ // jump counter
+ $jumpNum = 0;
+ $depthSearched = 0;
+ if( !empty($result['error']) ){
+ $routeData['error'] = $result['error'];
+ }elseif( !empty($result['route']) ){
+ $jumpNum = count($result['route']) - 1;
+
+ // check max search depth
+ if($jumpNum <= $routeData['maxDepth']){
+ $depthSearched = $jumpNum;
+
+ $routeData['routePossible'] = true;
+
+ // Now (after search) we have to "add" static jump data information
+ $this->setStaticJumpData();
+
+ foreach($result['route'] as $systemId){
+ $routeData['route'][] = $this->getJumpNodeData($systemId);
+ }
+ }else{
+ $depthSearched = $routeData['maxDepth'];
+ }
+ }
+
+ // route jumps
+ $routeData['routeJumps'] = $jumpNum;
+ $routeData['depthSearched'] = $depthSearched;
+ }
+
+ return $routeData;
+ }
+
+ /**
+ * get key for route cache
+ * @param $mapIds
+ * @param $systemFrom
+ * @param $systemTo
+ * @param array $filterData
+ * @return string
+ */
+ private function getRouteCacheKey($mapIds, $systemFrom, $systemTo, $filterData = []){
+
+ $keyParts = [
+ implode('_', $mapIds),
+ self::formatHiveKey($systemFrom),
+ self::formatHiveKey($systemTo)
+ ];
+
+ $keyParts += $filterData;
+ return 'route_' . hash('md5', implode('_', $keyParts));
+ }
+
+ /**
+ * search multiple route between two systems
+ * @param \Base $f3
+ * @throws \Exception
+ */
+ public function post(\Base $f3){
+ $requestData = $this->getRequestData($f3);
+
+ $activeCharacter = $this->getCharacter();
+
+ $return = (object) [];
+ $return->error = [];
+ $return->routesData = [];
+
+ if( !empty($requestData['routeData']) ){
+ $routesData = (array)$requestData['routeData'];
+
+ // map data where access was already checked -> cached data
+ $validMaps = [];
+
+ /**
+ * @var $map Pathfinder\MapModel
+ */
+ $map = Pathfinder\AbstractPathfinderModel::getNew('MapModel');
+
+ // limit max search routes to max limit
+ array_splice($routesData, Config::getPathfinderData('route.limit'));
+
+ foreach($routesData as $key => $routeData){
+ // mapIds are optional. If mapIds is empty or not set
+ // route search is limited to CCPs static data
+ $mapData = (array)$routeData['mapIds'];
+ $mapData = array_flip( array_map('intval', $mapData) );
+
+ // check map access (filter requested mapIDs and format) ----------------------------------------------
+ array_walk($mapData, function(&$item, &$key, $data){
+ /**
+ * @var Pathfinder\MapModel $data[0]
+ */
+ if( isset($data[1][$key]) ){
+ // character has map access -> do not check again
+ $item = $data[1][$key];
+ }else{
+ // check map access for current character
+ $data[0]->getById($key);
+
+ if( $data[0]->hasAccess($data[2]) ){
+ $item = ['id' => $key, 'name' => $data[0]->name];
+ }else{
+ $item = false;
+ }
+ $data[0]->reset();
+ }
+
+ }, [$map, $validMaps, $activeCharacter]);
+
+ // filter maps with NO access right
+ $mapData = array_filter($mapData);
+ $mapIds = array_column($mapData, 'id');
+
+ // add map data to cache array
+ $validMaps += $mapData;
+
+ // search route with filter options
+ $filterData = [
+ 'stargates' => (bool) $routeData['stargates'],
+ 'jumpbridges' => (bool) $routeData['jumpbridges'],
+ 'wormholes' => (bool) $routeData['wormholes'],
+ 'wormholesReduced' => (bool) $routeData['wormholesReduced'],
+ 'wormholesCritical' => (bool) $routeData['wormholesCritical'],
+ 'wormholesEOL' => (bool) $routeData['wormholesEOL'],
+ 'wormholesThera' => (bool) $routeData['wormholesThera'],
+ 'wormholesSizeMin' => (string) $routeData['wormholesSizeMin'],
+ 'excludeTypes' => (array) $routeData['excludeTypes'],
+ 'endpointsBubble' => (bool) $routeData['endpointsBubble'],
+ 'flag' => $routeData['flag']
+ ];
+
+ $returnRoutData = [
+ 'systemFromData' => $routeData['systemFromData'],
+ 'systemToData' => $routeData['systemToData'],
+ 'skipSearch' => (bool) $routeData['skipSearch'],
+ 'maps' => $mapData,
+ 'mapIds' => $mapIds
+ ];
+
+ // add filter options for each route as well
+ $returnRoutData += $filterData;
+
+ if(
+ !$returnRoutData['skipSearch'] &&
+ count($mapIds) > 0
+ ){
+ $systemFrom = $routeData['systemFromData']['name'];
+ $systemFromId = (int)$routeData['systemFromData']['systemId'];
+ $systemTo = $routeData['systemToData']['name'];
+ $systemToId = (int)$routeData['systemToData']['systemId'];
+
+ $cacheKey = $this->getRouteCacheKey(
+ $mapIds,
+ $systemFrom,
+ $systemTo,
+ $filterData
+ );
+
+ if($f3->exists($cacheKey, $cachedData)){
+ // get data from cache
+ $returnRoutData = $cachedData;
+ }else{
+ $foundRoutData = $this->searchRoute($systemFromId, $systemToId, 0, $mapIds, $filterData);
+
+ $returnRoutData = array_merge($returnRoutData, $foundRoutData);
+
+ // cache if route was found
+ if(
+ isset($returnRoutData['routePossible']) &&
+ $returnRoutData['routePossible'] === true
+ ){
+ $f3->set($cacheKey, $returnRoutData, $this->dynamicJumpDataCacheTime);
+ }
+ }
+ }
+
+ $return->routesData[] = $returnRoutData;
+ }
+ }
+
+ $this->out($return);
+ }
+}
\ No newline at end of file
diff --git a/app/Controller/Api/Rest/Signature.php b/app/Controller/Api/Rest/Signature.php
new file mode 100644
index 000000000..e928d9c00
--- /dev/null
+++ b/app/Controller/Api/Rest/Signature.php
@@ -0,0 +1,237 @@
+getRequestData($f3);
+ $signaturesData = [];
+
+ if($systemId = (int)$requestData['systemId']){
+ $activeCharacter = $this->getCharacter();
+
+ /**
+ * @var $system Pathfinder\SystemModel
+ */
+ $system = Pathfinder\AbstractPathfinderModel::getNew('SystemModel');
+ $system->getById($systemId, 0);
+ if($system->hasAccess($activeCharacter)){
+ // if there is any changed/deleted/updated signature
+ // -> we need to update signature history data for the system
+ $updateSignaturesHistory = false;
+
+ foreach((array)$requestData['signatures'] as $data){
+ // we assume "systemId" or each signature is same for each signature
+ unset($data['systemId']);
+
+ $signature = $system->getSignatureByName((string)$data['name']);
+
+ if(is_null($signature)){
+ $signature = $system->getNewSignature();
+ }else{
+ // description should not overwrite existing description
+ if(!empty($signature->description)){
+ unset( $data['description'] );
+ }
+
+ // prevent some data from overwrite manually changes
+ // wormhole typeID can not figured out/saved by the sig reader dialog
+ // -> type could not be identified -> do not overwrite them (e.g. sig update)
+ if(
+ $data['groupId'] == 5 ||
+ $data['typeId'] == 0
+ ){
+ unset($data['typeId']);
+ }
+
+ // "sig reader" should not overwrite signature group information
+ if(
+ $data['groupId'] == 0 &&
+ $signature->groupId > 0
+ ){
+ unset($data['groupId']);
+ unset($data['typeId']);
+ }
+ }
+
+ $signature->setData($data);
+ $signature->save($activeCharacter);
+ $signaturesData[] = $signature->getData();
+ $updateSignaturesHistory = true;
+
+ $signature->reset();
+ }
+
+ // delete "old" signatures ----------------------------------------------------------------------------
+ if((bool)$requestData['deleteOld']){
+ // if linked ConnectionModels should be deleted as well
+ $deleteConnectionId = (bool)$requestData['deleteConnection'];
+
+ $updatedSignatureIds = array_column($signaturesData, 'id');
+ $signatures = $system->getSignatures();
+ foreach($signatures as $signature){
+ if(!in_array($signature->_id, $updatedSignatureIds)){
+ // set if potential linked ConnectionModel should be deleted as well
+ $signature->virtual('connectionIdDeleteCascade', $deleteConnectionId);
+ if($signature->delete()){
+ $updateSignaturesHistory = true;
+ }
+ // clear temp virtual field as well
+ $signature->clearVirtual('connectionIdDeleteCascade');
+ }
+ }
+ }
+
+ if($updateSignaturesHistory){
+ // signature count changed -> clear fieldsCache[]
+ $system->reset(false);
+ $system->updateSignaturesHistory($activeCharacter, 'sync');
+ }
+ }
+ }
+
+ $this->out($signaturesData);
+ }
+
+ /**
+ * put (insert) signature
+ * @param \Base $f3
+ * @throws \Exception
+ */
+ public function put(\Base $f3){
+ $requestData = $this->getRequestData($f3);
+ $signaturesData = [];
+
+ if($systemId = (int)$requestData['systemId']){
+ $activeCharacter = $this->getCharacter();
+
+ /**
+ * @var $system Pathfinder\SystemModel
+ */
+ $system = Pathfinder\AbstractPathfinderModel::getNew('SystemModel');
+ $system->getById($systemId);
+ if($system->hasAccess($activeCharacter)){
+ $signature = $system->getNewSignature();
+ $signature->setData($requestData);
+ $signature->save($activeCharacter);
+ $signaturesData[] = $signature->getData();
+
+ $signature->systemId->updateSignaturesHistory($activeCharacter, 'add');
+ }
+ }
+
+ $this->out($signaturesData);
+ }
+
+ /**
+ * update existing signature
+ * @param \Base $f3
+ * @param $params
+ * @throws \Exception
+ */
+ public function patch(\Base $f3, $params){
+ $requestData = $this->getRequestData($f3);
+ $signaturesData = [];
+
+ if($signatureId = (int)$params['id']){
+ $activeCharacter = $this->getCharacter();
+
+ /**
+ * @var $signature Pathfinder\SystemSignatureModel
+ */
+ $signature = Pathfinder\AbstractPathfinderModel::getNew('SystemSignatureModel');
+ $signature->getById($signatureId);
+ if($signature->hasAccess($activeCharacter)){
+ // if groupId changed
+ if(array_key_exists('groupId', $requestData)){
+ // -> typeId set to 0
+ $requestData['typeId'] = 0;
+ // -> connectionId set to 0
+ $requestData['connectionId'] = 0;
+ }
+
+ if($signature->hasChanged($requestData)){
+ $signature->setData($requestData);
+ $signature->save($activeCharacter);
+ $signaturesData[] = $signature->getData();
+
+ $signature->systemId->updateSignaturesHistory($activeCharacter, 'edit');
+ }
+ }
+ }
+
+ $this->out($signaturesData);
+ }
+
+ /**
+ * @param \Base $f3
+ * @param $params
+ * @throws \Exception
+ */
+ public function delete(\Base $f3, $params){
+ $requestData = $this->getRequestData($f3);
+ $signatureIds = array_map('intval', explode(',', (string)$params['id']));
+ $deletedSignatureIds = [];
+
+ if($systemId = (int)$requestData['systemId']){
+ $activeCharacter = $this->getCharacter();
+
+ /**
+ * @var $system Pathfinder\SystemModel
+ */
+ $system = Pathfinder\AbstractPathfinderModel::getNew('SystemModel');
+ $system->getById($systemId);
+
+ if($system->hasAccess($activeCharacter)){
+ // if linked ConnectionModels should be deleted as well
+ $deleteConnectionId = (bool)$requestData['deleteConnection'];
+
+ // if there is any changed/deleted/updated signature
+ // -> we need to update signature history data for the system
+ $updateSignaturesHistory = false;
+
+ /**
+ * @var $signature Pathfinder\SystemSignatureModel
+ */
+ $signature = $system->rel('signatures');
+ foreach($signatureIds as $signatureId){
+ $signature->getById($signatureId);
+ // make sure signature belongs to main system (user has access)
+ if($signature->get('systemId', true) == $systemId){
+ // set if potential linked ConnectionModel should be deleted as well
+ $signature->virtual('connectionIdDeleteCascade', $deleteConnectionId);
+ if($signature->delete()){
+ $deletedSignatureIds[] = $signatureId;
+ $updateSignaturesHistory = true;
+ }
+ $signature->reset();
+ // clear temp virtual field as well
+ $signature->clearVirtual('connectionIdDeleteCascade');
+ }
+ }
+
+ if($updateSignaturesHistory){
+ $system->updateSignaturesHistory($activeCharacter, 'delete');
+ }
+ }
+ }
+
+ $this->out($deletedSignatureIds);
+ }
+
+}
\ No newline at end of file
diff --git a/app/Controller/Api/Rest/SignatureHistory.php b/app/Controller/Api/Rest/SignatureHistory.php
new file mode 100644
index 000000000..ffaf2de5f
--- /dev/null
+++ b/app/Controller/Api/Rest/SignatureHistory.php
@@ -0,0 +1,128 @@
+getCharacter();
+
+ /**
+ * @var $system Pathfinder\SystemModel
+ */
+ $system = Pathfinder\AbstractPathfinderModel::getNew('SystemModel');
+ $system->getById($systemId);
+
+ if($system->hasAccess($activeCharacter)){
+ $historyDataAll = $system->getSignaturesHistory();
+ foreach($historyDataAll as $historyEntry){
+ $label = [
+ $historyEntry['character']->name,
+ $historyEntry['action'],
+ count($historyEntry['signatures']),
+ Config::formatTimeInterval((int)(microtime(true) - $historyEntry['stamp']))
+ ];
+
+ $historyData[] = [
+ 'value' => md5((string)$historyEntry['stamp']),
+ 'text' => implode('%%', $label)
+ ];
+ }
+ }
+ }
+
+ $this->out($historyData);
+ }
+
+ /**
+ * put (load) historic signature data
+ * @param \Base $f3
+ * @throws \Exception
+ */
+ public function put(\Base $f3){
+ $requestData = $this->getRequestData($f3);
+ $signaturesData = [];
+
+ if(
+ ($systemId = (int)$requestData['systemId']) &&
+ ($stamp = (string)$requestData['stamp'])
+ ){
+ $activeCharacter = $this->getCharacter();
+
+ /**
+ * @var $system Pathfinder\SystemModel
+ */
+ $system = Pathfinder\AbstractPathfinderModel::getNew('SystemModel');
+ $system->getById($systemId, 0);
+ if($system->hasAccess($activeCharacter)){
+ if($historyEntry = $system->getSignatureHistoryEntry($stamp)){
+ $updateSignaturesHistory = false;
+
+ // history entry found for $stamp -> format signatures data
+ // -> same format as if they would come from client for save
+ foreach($historyEntry['signatures'] as $signatureData){
+ $data = [
+ 'id' => (int)$signatureData->id,
+ 'groupId' => (int)$signatureData->groupId,
+ 'typeId' => (int)$signatureData->typeId,
+ 'connectionId' => (int)$signatureData->connectionId,
+ 'name' => (string)$signatureData->name,
+ 'description' => (string)$signatureData->description,
+ ];
+
+ $signature = $system->getSignatureById($data['id']);
+
+ if(is_null($signature)){
+ $signature = $system->getNewSignature();
+ }
+
+ $signature->setData($data);
+ $signature->save($activeCharacter);
+ $signaturesData[] = $signature->getData();
+ $updateSignaturesHistory = true;
+
+ $signature->reset();
+ }
+
+ // delete "old" signatures ------------------------------------------------------------------------
+ $updatedSignatureIds = array_column($signaturesData, 'id');
+ $signatures = $system->getSignatures();
+ foreach($signatures as $signature){
+ if(!in_array($signature->_id, $updatedSignatureIds)){
+ if($signature->delete()){
+ $updateSignaturesHistory = true;
+ }
+ }
+ }
+
+ if($updateSignaturesHistory){
+ // signature count changed -> clear fieldsCache[]
+ $system->reset(false);
+ $system->updateSignaturesHistory($activeCharacter, 'undo');
+ }
+ }
+ }
+ }
+
+ $this->out($signaturesData);
+ }
+}
\ No newline at end of file
diff --git a/app/Controller/Api/Rest/Structure.php b/app/Controller/Api/Rest/Structure.php
new file mode 100644
index 000000000..19ea8ecb8
--- /dev/null
+++ b/app/Controller/Api/Rest/Structure.php
@@ -0,0 +1,125 @@
+getRequestData($f3);
+ $structuresData = $requestData ? $this->update($requestData) : [];
+ $this->out($structuresData);
+ }
+
+ /**
+ * @param \Base $f3
+ * @throws \Exception
+ */
+ public function put(\Base $f3){
+ $requestData = $this->getRequestData($f3);
+ $structuresData = $requestData ? $this->update([$requestData]) : [];
+ $this->out($structuresData);
+ }
+
+ /**
+ * @param \Base $f3
+ * @param $params
+ * @throws \Exception
+ */
+ public function patch(\Base $f3, $params){
+ $requestData = $this->getRequestData($f3);
+ $structuresData = (($structureId = (int)$params['id']) && ($structureId == (int)$requestData['id'])) ? $this->update([$requestData]) : [];
+ $this->out($structuresData);
+ }
+
+ /**
+ * @param \Base $f3
+ * @param $params
+ * @throws \Exception
+ */
+ public function delete(\Base $f3, $params){
+ $deletedStructureIds = [];
+
+ if($structureId = (int)$params['id']){
+ $activeCharacter = $this->getCharacter();
+ /**
+ * @var $structure Pathfinder\StructureModel
+ */
+ $structure = Pathfinder\AbstractPathfinderModel::getNew('StructureModel');
+ $structure->getById($structureId);
+ if($structure->hasAccess($activeCharacter) && $structure->erase()){
+ $deletedStructureIds[] = $structureId;
+ }
+ }
+ $this->out($deletedStructureIds);
+ }
+
+ /**
+ * @param array $structuresData
+ * @return array
+ * @throws \Exception
+ */
+ private function update(array $structuresData) : array {
+ $data = [];
+
+ $activeCharacter = $this->getCharacter();
+ if($activeCharacter->hasCorporation()){
+ // structures always belong to a corporation
+ /**
+ * @var $structure Pathfinder\StructureModel
+ */
+ $structure = Pathfinder\AbstractPathfinderModel::getNew('StructureModel');
+ foreach($structuresData as $structureData){
+ // reset on loop start because of potential "continue"
+ $structure->reset();
+
+ if(!empty($structureData['id']) && $structureId = (int)$structureData['id']){
+ // update specific structure
+ $structure->getById($structureId);
+ if(!$structure->hasAccess($activeCharacter)){
+ continue;
+ }
+ }elseif(!isset($structureData['id'])){
+ // from clipboard -> search by structure by name
+ $structure->getByName($activeCharacter->getCorporation(), (string)$structureData['name'], (int)$structureData['systemId']);
+ }
+
+ $isNew = $structure->dry();
+
+ $structure->setData($structureData);
+ $structure->save();
+
+ if($isNew){
+ $activeCharacter->getCorporation()->saveStructure($structure);
+ }
+
+ // group all updated structures by corporation -> just for return
+ $corporationsStructureData = $structure->getDataByCorporations();
+ foreach($corporationsStructureData as $corporationId => $corporationStructureData){
+ if(isset($data[$corporationId])){
+ $data[$corporationId]['structures'] = array_merge(
+ $data[$corporationId]['structures'],
+ $corporationStructureData['structures']
+ );
+ }else{
+ $data[$corporationId] = $corporationStructureData;
+ }
+ }
+ }
+ }
+
+ return $data;
+ }
+}
\ No newline at end of file
diff --git a/app/Controller/Api/Rest/System.php b/app/Controller/Api/Rest/System.php
new file mode 100644
index 000000000..efceae2d4
--- /dev/null
+++ b/app/Controller/Api/Rest/System.php
@@ -0,0 +1,228 @@
+getRequestData($f3);
+ $systemData = null;
+
+ if(
+ ($systemId = (int)$params['id']) &&
+ ($mapId = (int)$requestData['mapId'])
+ ){
+ $activeCharacter = $this->getCharacter();
+ $isCcpId = (bool)$requestData['isCcpId'];
+
+ if(
+ !is_null($map = $activeCharacter->getMap($mapId)) &&
+ !is_null($system = $isCcpId ? $map->getSystemByCCPId($systemId) : $map->getSystemById($systemId))
+ ){
+ $systemData = $system->getData();
+ $systemData->signatures = $system->getSignaturesData();
+ $systemData->sigHistory = $system->getSignaturesHistory();
+ $systemData->structures = $system->getStructuresData();
+ $systemData->stations = $system->getStationsData();
+ }
+ }
+
+ $this->out($systemData);
+ }
+
+ /**
+ * put (insert) system
+ * @param \Base $f3
+ * @throws \Exception
+ */
+ public function put(\Base $f3){
+ $requestData = $this->getRequestData($f3);
+ $systemData = [];
+
+ if($mapId = (int)$requestData['mapId']){
+ $activeCharacter = $this->getCharacter();
+
+ /**
+ * @var $map Pathfinder\MapModel
+ */
+ $map = Pathfinder\AbstractPathfinderModel::getNew('MapModel');
+ $map->getById($mapId);
+ if($map->hasAccess($activeCharacter)){
+ $system = $map->getNewSystem($requestData['systemId']);
+ $systemData = $this->update($system, $requestData)->getData();
+ }
+ }
+
+ $this->out($systemData);
+ }
+
+ /**
+ * update existing system
+ * @param \Base $f3
+ * @param $params
+ * @throws \Exception
+ */
+ public function patch(\Base $f3, $params){
+ $requestData = $this->getRequestData($f3);
+ $systemData = [];
+
+ if($systemId = (int)$params['id']){
+ $activeCharacter = $this->getCharacter();
+
+ /**
+ * @var $system Pathfinder\SystemModel
+ */
+ $system = Pathfinder\AbstractPathfinderModel::getNew('SystemModel');
+ $system->getById($systemId);
+
+ if($system->hasAccess($activeCharacter)){
+ $systemData = $this->update($system, $requestData)->getData();
+ }
+ }
+
+ $this->out($systemData);
+ }
+
+ /**
+ * @param \Base $f3
+ * @param $params
+ * @throws \Exception
+ */
+ public function delete(\Base $f3, $params){
+ $requestData = $this->getRequestData($f3);
+ $systemIds = array_map('intval', explode(',', (string)$params['id']));
+ $deletedSystemIds = [];
+
+ if($mapId = (int)$requestData['mapId']){
+ $activeCharacter = $this->getCharacter();
+
+ /**
+ * @var $map Pathfinder\MapModel
+ */
+ $map = Pathfinder\AbstractPathfinderModel::getNew('MapModel');
+ $map->getById($mapId);
+
+ if($map->hasAccess($activeCharacter)){
+ $newSystemModel = Pathfinder\AbstractPathfinderModel::getNew('SystemModel');
+ foreach($systemIds as $systemId){
+ if($system = $map->getSystemById($systemId)){
+ // check whether system should be deleted OR set "inactive"
+ if($this->checkDeleteMode($map, $system)){
+ // delete log
+ // -> first set updatedCharacterId -> required for activity log
+ $system->updatedCharacterId = $activeCharacter;
+ $system->update();
+
+ // ... now get fresh object and delete..
+ $newSystemModel->getById($system->_id, 0);
+ $newSystemModel->erase();
+ $newSystemModel->reset();
+ }else{
+ // keep data -> set "inactive"
+ $system->setActive(false);
+ $system->save($activeCharacter);
+ }
+
+ $system->reset();
+
+ $deletedSystemIds[] = $systemId;
+ }
+ }
+ // broadcast map changes
+ if(count($deletedSystemIds)){
+ $this->broadcastMap($map);
+ }
+ }
+ }
+
+ $this->out($deletedSystemIds);
+ }
+
+ // ----------------------------------------------------------------------------------------------------------------
+
+ /**
+ * update system with new data
+ * @param Pathfinder\SystemModel $system
+ * @param array $systemData
+ * @return Pathfinder\SystemModel
+ * @throws \Exception
+ */
+ private function update(Pathfinder\SystemModel $system, array $systemData) : Pathfinder\SystemModel {
+ $activeCharacter = $this->getCharacter();
+
+ // statusId === 0 is 'auto' status -> keep current status
+ // -> relevant systems that already have a status (inactive systems)
+ if( (int)$systemData['statusId'] <= 0 ){
+ unset($systemData['statusId']);
+ }
+
+ if( !$system->dry() ){
+ // activate system (e.g. was inactive))
+ $system->setActive(true);
+ }
+
+ $system->setData($systemData);
+ $system->save($activeCharacter);
+
+ // get data from "fresh" model (e.g. some relational data has changed: "statusId")
+ /**
+ * @var $newSystem Pathfinder\SystemModel
+ */
+ $newSystem = Pathfinder\AbstractPathfinderModel::getNew('SystemModel');
+ $newSystem->getById($system->_id, 0);
+ $newSystem->clearCacheData();
+
+ // broadcast map changes
+ $this->broadcastMap($newSystem->mapId);
+
+ return $newSystem;
+ }
+
+ /**
+ * checks whether a system should be "deleted" or set "inactive" (keep persistent data)
+ * @param Pathfinder\MapModel $map
+ * @param Pathfinder\SystemModel $system
+ * @return bool
+ */
+ private function checkDeleteMode(Pathfinder\MapModel $map, Pathfinder\SystemModel $system) : bool {
+ $delete = true;
+
+ if(!empty($system->description)){
+ // never delete systems with custom description set!
+ $delete = false;
+ }elseif(
+ $map->persistentAliases &&
+ !empty($system->alias) &&
+ ($system->alias != $system->name)
+ ){
+ // map setting "persistentAliases" is active (default) AND
+ // alias is set and != name
+ $delete = false;
+ }elseif(
+ $map->persistentSignatures &&
+ !empty($system->getSignatures())
+ ){
+ // map setting "persistentSignatures" is active (default) AND
+ // signatures exist
+ $delete = false;
+ }
+
+ return $delete;
+ }
+
+}
\ No newline at end of file
diff --git a/app/Controller/Api/Rest/SystemGraph.php b/app/Controller/Api/Rest/SystemGraph.php
new file mode 100644
index 000000000..b84a79055
--- /dev/null
+++ b/app/Controller/Api/Rest/SystemGraph.php
@@ -0,0 +1,112 @@
+getRequestData($f3);
+ $systemIds = (array)$requestData['systemIds'];
+ $graphsData = [];
+
+ // valid response (data found) should be cached by server + client
+ $cacheResponse = false;
+
+ // number of log entries in each table per system (24 = 24h)
+ $logEntryCount = Pathfinder\AbstractSystemApiBasicModel::DATA_COLUMN_COUNT;
+
+ $ttl = 60 * 10;
+
+ // table names with system data
+ $logTables = [
+ 'jumps' => 'SystemJumpModel',
+ 'shipKills' => 'SystemShipKillModel',
+ 'podKills' => 'SystemPodKillModel',
+ 'factionKills' => 'SystemFactionKillModel'
+ ];
+
+ $exists = false;
+
+ foreach($systemIds as $systemId){
+ $cacheKey = $this->getSystemGraphCacheKey($systemId);
+ if(!$exists = $f3->exists($cacheKey, $graphData)){
+ $graphData = [];
+ $cacheSystem = false;
+
+ foreach($logTables as $label => $className){
+ $systemLogModel = Pathfinder\AbstractSystemApiBasicModel::getNew($className);
+ $systemLogExists = false;
+
+ // 10min cache (could be up to 1h cache time)
+ $systemLogModel->getByForeignKey('systemId', $systemId);
+ if($systemLogModel->valid()){
+ $systemLogExists = true;
+ $cacheSystem = true;
+ $cacheResponse = true;
+ }
+
+ $systemLogData = $systemLogModel->getData();
+
+ // podKills share graph with shipKills -> skip
+ if($label != 'podKills'){
+ $graphData[$label]['logExists'] = $systemLogExists;
+ $graphData[$label]['updated'] = $systemLogData->updated;
+ }
+
+ $logValueCount = range(0, $logEntryCount - 1);
+ foreach($logValueCount as $i){
+ if($label == 'podKills'){
+ $graphData['shipKills']['data'][$i]['z'] = $systemLogData->values[$i];
+ }else{
+ $graphData[$label]['data'][] = [
+ 'x' => ($logEntryCount - $i - 1) . 'h',
+ 'y' => $systemLogData->values[$i]
+ ];
+ }
+ }
+ }
+
+ if($cacheSystem){
+ $f3->set($cacheKey, $graphData, $ttl);
+ }
+ }else{
+ // server cache data exists -> client should cache as well
+ $cacheResponse = true;
+ }
+ $graphsData[$systemId] = $graphData;
+ }
+
+ if($cacheResponse){
+ // send client cache header
+ $f3->expire(Config::ttlLeft($exists, $ttl));
+ }
+
+ $this->out($graphsData);
+ }
+
+ // ----------------------------------------------------------------------------------------------------------------
+
+ /**
+ * get system graph cache key
+ * @param int $systemId
+ * @return string
+ */
+ protected function getSystemGraphCacheKey(int $systemId): string {
+ return sprintf(self::CACHE_KEY_GRAPH, 'SYSTEM_' . $systemId);
+ }
+}
\ No newline at end of file
diff --git a/app/Controller/Api/Rest/SystemThera.php b/app/Controller/Api/Rest/SystemThera.php
new file mode 100644
index 000000000..d428ca8bd
--- /dev/null
+++ b/app/Controller/Api/Rest/SystemThera.php
@@ -0,0 +1,131 @@
+exists(self::CACHE_KEY_THERA_CONNECTIONS, $connectionsData)){
+ $connectionsData = $this->getEveScoutTheraConnections();
+ $f3->set(self::CACHE_KEY_THERA_CONNECTIONS, $connectionsData, $ttl);
+ }
+
+ $f3->expire(Config::ttlLeft($exists, $ttl));
+
+ $this->out($connectionsData);
+ }
+
+ /**
+ * get Thera connections data from EveScout API
+ * -> map response to Pathfinder format
+ * @return array
+ */
+ protected function getEveScoutTheraConnections() : array {
+ $connectionsData = [];
+
+ /**
+ * map system data from eveScout response to Pathfinder´s 'system' format
+ * @param string $key
+ * @param array $eveScoutConnection
+ * @param array $connectionData
+ */
+ $enrichWithSystemData = function(string $key, array $eveScoutConnection, array &$connectionData) : void {
+ $eveScoutSystem = (array)$eveScoutConnection[$key];
+ $systemData = [
+ 'id' => (int)$eveScoutSystem['id'],
+ 'name' => (string)$eveScoutSystem['name'],
+ 'trueSec' => round((float)$eveScoutSystem['security'], 4)
+ ];
+ if(!empty($eveScoutSystem['constellationID'])){
+ $systemData['constellation'] = ['id' => (int)$eveScoutSystem['constellationID']];
+ }
+ if(!empty($region = (array)$eveScoutSystem['region']) && !empty($region['id'])){
+ $systemData['region'] = ['id' => (int)$region['id'], 'name' => (string)$region['name']];
+ }
+ $connectionData[$key] = $systemData;
+ };
+
+ /**
+ * @param string $key
+ * @param array $eveScoutConnection
+ * @param array $connectionData
+ */
+ $enrichWithSignatureData = function(string $key, array $eveScoutConnection, array &$connectionData) : void {
+ $eveScoutSignature = (array)$eveScoutConnection[$key];
+ $signatureData = [
+ 'name' => $eveScoutSignature['name'] ? : null
+ ];
+ if(!empty($sigType = (array)$eveScoutSignature['type']) && !empty($sigType['name'])){
+ $signatureData['type'] = ['name' => strtoupper((string)$sigType['name'])];
+ }
+ $connectionData[$key] = $signatureData;
+ };
+
+ /**
+ * map wormhole data from eveScout to Pathfinder´s connection format
+ * @param array $wormholeData
+ * @param array $connectionsData
+ */
+ $enrichWithWormholeData = function(array $wormholeData, array &$connectionsData) : void {
+ $type = [];
+ if($wormholeData['mass'] === 'reduced'){
+ $type[] = 'wh_reduced';
+ }else if($wormholeData['mass'] === 'critical'){
+ $type[] = 'wh_critical';
+ }else{
+ $type[] = 'wh_fresh';
+ }
+
+ if($wormholeData['eol'] === 'critical'){
+ $type[] = 'wh_eol';
+ }
+ $connectionsData['type'] = $type;
+ $connectionsData['estimatedEol'] = $wormholeData['estimatedEol'];
+ };
+
+ $eveScoutResponse = $this->getF3()->eveScoutClient()->send('getTheraConnections');
+ if(!empty($eveScoutResponse) && !isset($eveScoutResponse['error'])){
+ foreach((array)$eveScoutResponse['connections'] as $eveScoutConnection){
+ if(
+ $eveScoutConnection['type'] === 'wormhole' &&
+ isset($eveScoutConnection['source']) && isset($eveScoutConnection['target'])
+ ){
+ try{
+ $data = [
+ 'id' => (int)$eveScoutConnection['id'],
+ 'scope' => 'wh',
+ 'created' => [
+ 'created' => (new \DateTime($eveScoutConnection['created']))->getTimestamp(),
+ 'character' => (array)$eveScoutConnection['character']
+ ],
+ 'updated' => (new \DateTime($eveScoutConnection['updated']))->getTimestamp()
+ ];
+ $enrichWithWormholeData((array)$eveScoutConnection['wormhole'], $data);
+ $enrichWithSystemData('source', $eveScoutConnection, $data);
+ $enrichWithSystemData('target', $eveScoutConnection, $data);
+ $enrichWithSignatureData('sourceSignature', $eveScoutConnection, $data);
+ $enrichWithSignatureData('targetSignature', $eveScoutConnection, $data);
+ $connectionsData[] = $data;
+ }catch(\Exception $e){
+ // new \DateTime Exception -> skip this data
+ }
+ }
+ }
+ }
+
+ return $connectionsData;
+ }
+}
\ No newline at end of file
diff --git a/app/Controller/Api/Setup.php b/app/Controller/Api/Setup.php
new file mode 100644
index 000000000..31f85c784
--- /dev/null
+++ b/app/Controller/Api/Setup.php
@@ -0,0 +1,425 @@
+´s for all cronjobs
+ * @param \Base $f3
+ */
+ public function cronTable(\Base $f3){
+ $return = (object) [];
+ $return->error = [];
+ $return->jobsData = Cron::instance()->getJobsConfig();
+ $return->html = $this->getCronHtml($return->jobsData);
+ echo json_encode($return);
+ }
+
+ /**
+ * toggle "isPaused" for a cronjob by its name
+ * @param \Base $f3
+ */
+ public function cronPause(\Base $f3){
+ $postData = (array)$f3->get('POST');
+ $return = (object) [];
+ $return->error = [];
+
+ if($jobName = (string)$postData['job']){
+ $cron = Cron::instance();
+ if($job = $cron->getJob($jobName)){
+ if($job->valid()){
+ $job->isPaused = !$job->isPaused;
+ $job->save();
+
+ $return->jobsData = $cron->getJobsConfig([$jobName]);
+ $return->html = $this->getCronHtml($return->jobsData);
+ }
+ }
+ }
+
+ echo json_encode($return);
+ }
+
+ /**
+ * execute a cronjob by its name
+ * -> runs sync
+ * -> max execution time might be lower than CLI calls!
+ * @param \Base $f3
+ */
+ public function cronExecute(\Base $f3){
+ $postData = (array)$f3->get('POST');
+ $return = (object) [];
+ $return->error = [];
+
+ if($jobName = (string)$postData['job']){
+ $cron = Cron::instance();
+ if($job = $cron->getJob($jobName)){
+ if($job->valid()){
+ $cron->execute($jobName, false);
+
+ $return->jobsData = $cron->getJobsConfig([$jobName]);
+ $return->html = $this->getCronHtml($return->jobsData);
+ }
+ }
+ }
+
+ echo json_encode($return);
+ }
+
+ /**
+ * get HTML for cronJobs
+ * @param array $jobsData
+ * @return string
+ */
+ protected function getCronHtml(array $jobsData) : string {
+ $tplData = [
+ 'cronConfig' => [
+ 'jobs' => $jobsData,
+ 'settings' => $this->getF3()->constants(Cron::instance(), 'DEFAULT_')
+ ],
+ 'tplCounter' => $this->counter(),
+ 'tplConvertBytes' => function(){
+ return call_user_func_array([Number::instance(), 'bytesToString'], func_get_args());
+ }
+ ];
+ return \Template::instance()->render('templates/ui/cron_table_row.html', null, $tplData, 0);
+ }
+
+ /**
+ * build search index from existing data (e.g. Systems)
+ * OR import data from ESI (e.g. Structures)
+ * -> optional build/import smaller chunks of data
+ * @param \Base $f3
+ * @throws \Exception
+ */
+ public function buildIndex(\Base $f3){
+ $postData = (array)$f3->get('POST');
+ $type = (string)$postData['type'];
+ $countAll = (int)$postData['countAll'];
+ $count = (int)$postData['count'];
+ $offset = (int)$postData['offset'];
+
+ $return = (object) [];
+ $return->error = [];
+ $return->type = $type;
+ $return->count = $count;
+ $return->offset = $offset;
+ $return->countAll = $countAll;
+ $return->countBuild = 0;
+ $return->countBuildAll = 0;
+ $return->progress = 0;
+
+ /**
+ * sum array values
+ * @param int $carry
+ * @param int $value
+ * @return int
+ */
+ $sum = function(int $carry, int $value) : int {
+ $carry += $value;
+ return $carry;
+ };
+
+ /**
+ * calc percent
+ * @param int $countAll
+ * @param int $count
+ * @return int
+ */
+ $percent = function(int $countAll, int $count){
+ return $countAll ? floor((100/$countAll) * $count) : 0;
+ };
+
+ $controller = new Controller\Ccp\Universe();
+ switch($type){
+ case 'Systems':
+ $length = 100;
+ $buildInfo = $controller->buildSystemsIndex($offset, $length);
+
+ $return->offset = $buildInfo['offset'];
+ $return->countAll = $buildInfo['countAll'];
+ $return->countBuild = $buildInfo['countBuild'];
+ $return->countBuildAll = $return->offset;
+ break;
+ case 'Wormholes':
+ $groupId = Config::ESI_GROUP_WORMHOLE_ID;
+ $length = 10;
+ $buildInfo = $controller->setupGroup($groupId, $offset, $length, true);
+
+ $return->offset = $buildInfo['offset'];
+ $return->countAll = $buildInfo['countAll'];
+ $return->countBuild = $buildInfo['count'];
+ $return->countBuildAll = $return->offset;
+ break;
+ case 'Structures':
+ $categoryId = Config::ESI_CATEGORY_STRUCTURE_ID;
+ $length = 1;
+ $buildInfo = $controller->setupCategory($categoryId, $offset, $length);
+ $categoryUniverseModel = Model\Universe\AbstractUniverseModel::getNew('CategoryModel');
+ $categoryUniverseModel->getById($categoryId, 0);
+
+ $return->offset = $buildInfo['offset'];
+ $return->countBuild = $buildInfo['count'];
+ $return->countBuildAll = $return->offset;
+ $return->subCount = [
+ 'countBuildAll' => $categoryUniverseModel->getTypesCount(false)
+ ];
+ break;
+ case 'Ships':
+ $categoryId = Config::ESI_CATEGORY_SHIP_ID;
+ $length = 2;
+ $buildInfo = $controller->setupCategory($categoryId, $offset, $length);
+ $categoryUniverseModel = Model\Universe\AbstractUniverseModel::getNew('CategoryModel');
+ $categoryUniverseModel->getById($categoryId, 0);
+
+ $return->offset = $buildInfo['offset'];
+ $return->countBuild = $buildInfo['count'];
+ $return->countBuildAll = $return->offset;
+ $return->subCount = [
+ 'countBuildAll' => $categoryUniverseModel->getTypesCount(false)
+ ];
+ break;
+ case 'SystemStatic':
+ $length = 300;
+ $buildInfo = $this->setupSystemStaticTable($offset, $length);
+
+ $return->offset = $buildInfo['offset'];
+ $return->countAll = $buildInfo['countAll'];
+ $return->countBuild = $buildInfo['count'];
+ $return->countBuildAll = $return->offset;
+ break;
+ case 'SystemNeighbour':
+ $length = 1500;
+ $buildInfo = $this->setupSystemJumpTable($offset, $length);
+
+ $return->offset = $buildInfo['offset'];
+ $return->countAll = $buildInfo['countAll'];
+ $return->countBuild = $buildInfo['count'];
+ $return->countBuildAll = $return->offset;
+ break;
+ }
+
+ $return->progress = $percent($return->countAll, $return->countBuildAll);
+
+ if($return->countBuildAll < $return->countAll){
+ $return->count++;
+ }
+
+ echo json_encode($return);
+ }
+
+ /**
+ * clear search index
+ * @param \Base $f3
+ * @throws \Exception
+ */
+ public function clearIndex(\Base $f3){
+ $postData = (array)$f3->get('POST');
+ $type = (string)$postData['type'];
+
+ $return = (object) [];
+ $return->error = [];
+ $return->type = $type;
+ $return->count = 0;
+ $return->countAll = 0;
+ $return->countBuild = 0;
+ $return->countBuildAll = 0;
+ $return->progress = 0;
+
+ $controller = new Controller\Ccp\Universe();
+ switch($type) {
+ case 'Systems':
+ $controller->clearSystemsIndex();
+ $systemUniverseModel = Model\Universe\AbstractUniverseModel::getNew('SystemModel');
+ $return->countAll = $systemUniverseModel->getRowCount();
+ break;
+ case 'SystemNeighbour':
+ $systemNeighbourModel = Model\Universe\AbstractUniverseModel::getNew('SystemNeighbourModel');
+ $systemNeighbourModel->truncate();
+ $return->countAll = (int)$f3->get('REQUIREMENTS.DATA.NEIGHBOURS');
+ break;
+ }
+
+ echo json_encode($return);
+ }
+
+ /**
+ * import static 'system_static` table data from *.csv
+ * @param int $offset
+ * @param int $length
+ * @return array
+ * @throws \Exception
+ */
+ protected function setupSystemStaticTable(int $offset = 0, int $length = 0) : array {
+ $info = ['countAll' => 0, 'countChunk' => 0, 'count' => 0, 'offset' => $offset];
+
+ /**
+ * @var $systemStaticModel Model\Universe\SystemStaticModel
+ */
+ $systemStaticModel = Model\Universe\AbstractUniverseModel::getNew('SystemStaticModel');
+ if(!empty($csvData = $systemStaticModel::getCSVData($systemStaticModel->getTable()))){
+ $info['countAll'] = count($csvData);
+ if($length){
+ $csvData = array_slice($csvData, $offset, $length);
+ }
+ $info['countChunk'] = count($csvData);
+ $cols = ['typeId' => [], 'systemId' => []];
+ foreach($csvData as $data){
+ $validColCount = 0;
+ $systemStaticModel->getById((int)$data['id'], 0);
+ $systemStaticModel->id = (int)$data['id'];
+ foreach($cols as $col => &$invalidIds){
+ if($systemStaticModel->exists($col)){
+ $colVal = (int)$data[$col];
+ if(!in_array($colVal, $invalidIds)){
+ $relModel = $systemStaticModel->rel($col);
+ $relModel->getById($colVal, 0);
+ if($relModel->valid()){
+ $systemStaticModel->$col = $relModel;
+ $validColCount++;
+ }else{
+ $invalidIds[] = $colVal;
+ break;
+ }
+ }else{
+ break;
+ }
+ }
+ }
+
+ if($validColCount == count($cols)){
+ $systemStaticModel->save();
+ }
+ $systemStaticModel->reset();
+
+ $info['count']++;
+ $info['offset']++;
+ }
+ }
+
+ return $info;
+ }
+
+ /**
+ * This function is just for setting up the cache table 'system_neighbour' which is used
+ * for system jump calculation. Call this function manually when CCP adds Systems/Stargates
+ * @param int $offset
+ * @param int $length
+ * @return array
+ */
+ protected function setupSystemJumpTable(int $offset = 0, int $length = 0) : array {
+ $info = ['countAll' => 0, 'countChunk' => 0, 'count' => 0, 'offset' => $offset];
+ $universeDB = $this->getDB('UNIVERSE');
+
+ $query = "SELECT SQL_CALC_FOUND_ROWS
+ `system`.`id` `systemId`,
+ `system`.`name` `systemName`,
+ `system`.`constellationId` `constellationId`,
+ ROUND( `system`.`securityStatus`, 4) `trueSec`,
+ `constellation`.`regionId` `regionId`,
+ (
+ SELECT
+ GROUP_CONCAT( NULLIF(`sys_inner`.`id`, NULL) SEPARATOR ':')
+ FROM
+ `stargate` INNER JOIN
+ `system` `sys_inner` ON
+ `sys_inner`.`id` = `stargate`.`destinationSystemId`
+ WHERE
+ `stargate`.`systemId` = `system`.`id`
+ ) `jumpNodes`
+ FROM
+ `system` INNER JOIN
+ `constellation` ON
+ `constellation`.`id` = `system`.`constellationId`
+ WHERE
+ `constellation`.`regionId` != :regionIdJove1 AND
+ `constellation`.`regionId` != :regionIdJove2 AND
+ `constellation`.`regionId` != :regionIdJove3 AND
+ (
+ `system`.`security` = :ns OR
+ `system`.`security` = :ls OR
+ `system`.`security` = :hs
+ )
+ HAVING
+ `jumpNodes` IS NOT NULL
+ ";
+
+ $args = [
+ ':regionIdJove1' => 10000017,
+ ':regionIdJove2' => 10000019,
+ ':regionIdJove3' => 10000004,
+ ':ns' => '0.0',
+ ':ls' => 'L',
+ ':hs' => 'H'
+ ];
+
+ if($length){
+ $query .= ' LIMIT :limit';
+ $args[':limit'] = $length;
+
+ if($offset){
+ $query .= ' OFFSET :offset';
+ $args[':offset'] = $offset;
+ }
+ }
+
+ $rows = $universeDB->exec($query, $args);
+
+ if(!empty($countRes = $universeDB->exec("SELECT FOUND_ROWS() `count`")) && isset($countRes[0]['count'])){
+ $info['countAll'] = (int)$countRes[0]['count'];
+ }
+
+ if($info['countChunk'] = count($rows)){
+ $placeholderStr = function(string $str) : string {
+ return ':' . $str;
+ };
+
+ $updateRule = function(string $str) : string {
+ return $str . " = VALUES(" . $str . ")";
+ };
+
+ $universeDB->begin();
+ foreach($rows as $row){
+ $info['count']++;
+ $info['offset']++;
+
+ if(!$row['jumpNodes']){
+ // should never happen!
+ continue;
+ }
+
+ $columns = array_keys($row);
+ $columnsQuoted = array_map($universeDB->quotekey, $columns);
+ $placeholder = array_map($placeholderStr, $columns);
+ $args = array_combine($placeholder, $row);
+
+ $updateSql = array_map($updateRule, $columns);
+
+ $sql = "INSERT INTO
+ system_neighbour(" . implode(', ', $columnsQuoted) . ")
+ VALUES(" . implode(', ', $placeholder) . ")
+ ON DUPLICATE KEY UPDATE
+ " . implode(', ', $updateSql);
+
+ $universeDB->exec($sql, $args);
+ }
+ $universeDB->commit();
+ }
+
+ return $info;
+ }
+
+}
\ No newline at end of file
diff --git a/app/Controller/Api/Statistic.php b/app/Controller/Api/Statistic.php
new file mode 100644
index 000000000..796880237
--- /dev/null
+++ b/app/Controller/Api/Statistic.php
@@ -0,0 +1,300 @@
+ 10 will get prefixed with "0"
+ * -> e.g. year 2016, week 2 => "201602"
+ * @param int $year
+ * @param int $week
+ * @return string
+ */
+ protected function concatYearWeek($year, $week){
+ return strval($year) . str_pad($week, 2, 0, STR_PAD_LEFT);
+ }
+
+ /**
+ * get max count of weeks in a year
+ * @param $year
+ * @return int
+ */
+ protected function getIsoWeeksInYear($year){
+ $week = 0;
+ try{
+ $date = new \DateTime;
+ $date->setISODate($year, 53);
+ $week = ($date->format('W') === '53' ? 53 : 52);
+ }catch(\Exception $e){}
+ return $week;
+ }
+
+ /**
+ * get number of weeks for a given period
+ * @param string $period
+ * @param int $year
+ * @return int
+ */
+ protected function getWeekCount($period, $year){
+ $weeksInYear = $this->getIsoWeeksInYear($year);
+
+ switch($period){
+ case 'yearly':
+ $weekCount = $weeksInYear;
+ break;
+ case 'monthly':
+ $weekCount = 4;
+ break;
+ case 'weekly':
+ default:
+ $weekCount = 1;
+ break;
+ }
+
+ return $weekCount;
+ }
+
+ /**
+ * calculate calendar week and year for a given offset (weekCount)
+ * -> count forward OR backward
+ * @param int $year
+ * @param int $week
+ * @param int $weekCount
+ * @param bool $backwards
+ * @return array
+ */
+ protected function calculateYearWeekOffset($year, $week, $weekCount, $backwards = false){
+ $offset = [
+ 'year' => (int)$year,
+ 'week' => (int)$week
+ ];
+
+ $weeksInYear = $this->getIsoWeeksInYear($year);
+
+ // just for security...
+ if($offset['week'] > $weeksInYear){
+ $offset['week'] = $weeksInYear;
+ }elseif($offset['week'] <= 0){
+ $offset['week'] = 1;
+ }
+
+ for($i = 1; $i < $weekCount; $i++){
+
+ if($backwards){
+ // calculate backward
+ $offset['week']--;
+
+ if($offset['week'] <= 0){
+ // year change -> reset yearWeeks
+ $offset['year']--;
+ $offset['week'] = $this->getIsoWeeksInYear($offset['year']);
+ }
+ }else{
+ // calculate forward
+ $offset['week']++;
+
+ if($offset['week'] > $weeksInYear){
+ // year change -> reset yearWeeks
+ $offset['week'] = 1;
+ $offset['year']++;
+ $weeksInYear = $this->getIsoWeeksInYear($offset['year']);
+ }
+ }
+ }
+
+ return $offset;
+ }
+
+ /**
+ * query statistic data for "activity log"
+ * -> group result by characterId
+ * @param CharacterModel $character
+ * @param int $typeId
+ * @param int $yearStart
+ * @param int $weekStart
+ * @param int $yearEnd
+ * @param int $weekEnd
+ * @return array
+ */
+ protected function queryStatistic(CharacterModel $character, $typeId, $yearStart, $weekStart, $yearEnd, $weekEnd){
+ $data = [];
+
+ // can be either "characterId" || "corporationId" || "allianceId"
+ // -> is required (>0) to limit the result to only accessible data for the given character!
+ $objectId = 0;
+
+ // add map-"typeId" (private/corp/ally) condition -------------------------------------------------------------
+ // check if "LOG_ACTIVITY_ENABLED" is active for a given "typeId"
+ $sqlMapType = "";
+
+ switch($typeId){
+ case 2:
+ if( Config::getMapsDefaultConfig('private')['log_activity_enabled'] ){
+ $sqlMapType .= " AND `character`.`id` = :objectId ";
+ $objectId = $character->_id;
+ }
+ break;
+ case 3:
+ if(
+ Config::getMapsDefaultConfig('corporation')['log_activity_enabled'] &&
+ $character->hasCorporation()
+ ){
+ $sqlMapType .= " AND `character`.`corporationId` = :objectId ";
+ $objectId = $character->get('corporationId', true);
+ }
+ break;
+ case 4:
+ if(
+ Config::getMapsDefaultConfig('alliance')['log_activity_enabled'] &&
+ $character->hasAlliance()
+ ){
+ $sqlMapType .= " AND `character`.`allianceId` = :objectId ";
+ $objectId = $character->get('allianceId', true);
+ }
+ break;
+ }
+
+ if($objectId > 0){
+
+ $queryData = [
+ ':active' => 1,
+ ':objectId' => $objectId
+ ];
+
+ // date offset condition ----------------------------------------------------------------------------------
+ $sqlDateOffset = " AND CONCAT(`log`.`year`, LPAD(`log`.`week`, 2, 0) ) BETWEEN :yearWeekStart AND :yearWeekEnd ";
+
+ $queryData[':yearWeekStart'] = $this->concatYearWeek($yearStart, $weekStart);
+ $queryData[':yearWeekEnd'] = $this->concatYearWeek($yearEnd, $weekEnd);
+
+ // build query --------------------------------------------------------------------------------------------
+ $sql = "SELECT
+ `log`.`year`,
+ `log`.`week`,
+ `log`.`characterId`,
+ `character`.`name`,
+ `character`.`lastLogin`,
+ SUM(`log`.`mapCreate`) `mapCreate`,
+ SUM(`log`.`mapUpdate`) `mapUpdate`,
+ SUM(`log`.`mapDelete`) `mapDelete`,
+ SUM(`log`.`systemCreate`) `systemCreate`,
+ SUM(`log`.`systemUpdate`) `systemUpdate`,
+ SUM(`log`.`systemDelete`) `systemDelete`,
+ SUM(`log`.`connectionCreate`) `connectionCreate`,
+ SUM(`log`.`connectionUpdate`) `connectionUpdate`,
+ SUM(`log`.`connectionDelete`) `connectionDelete`,
+ SUM(`log`.`signatureCreate`) `signatureCreate`,
+ SUM(`log`.`signatureUpdate`) `signatureUpdate`,
+ SUM(`log`.`signatureDelete`) `signatureDelete`
+ FROM
+ `activity_log` `log` INNER JOIN
+ `character` ON
+ `character`.`id` = `log`.`characterId`
+ WHERE
+ `log`.`active` = :active
+ " . $sqlMapType . "
+ " . $sqlDateOffset . "
+ GROUP BY
+ `log`.`year`,
+ `log`.`week`,
+ `log`.`characterId`
+ ORDER BY
+ `log`.`year` DESC, `log`.`week` DESC";
+
+ $result = $this->getDB()->exec($sql, $queryData);
+
+ if( !empty($result) ){
+ // group result by characterId
+ foreach ($result as $key => &$entry) {
+ $tmp = $entry;
+ unset($tmp['characterId']);
+ unset($tmp['name']);
+ unset($tmp['lastLogin']);
+ $data[$entry['characterId']]['name'] = $entry['name'];
+ $data[$entry['characterId']]['lastLogin'] = strtotime($entry['lastLogin']);
+ $data[$entry['characterId']]['weeks'][ $entry['year'] . $entry['week'] ] = $tmp;
+ }
+ }
+ }
+
+ return $data;
+ }
+
+ /**
+ * get statistics data
+ * @param \Base $f3
+ * @throws \Exception
+ */
+ public function getData(\Base $f3){
+ $postData = (array)$f3->get('POST');
+ $return = (object) [];
+
+ $period = $postData['period'];
+ $typeId = (int)$postData['typeId'];
+ $yearStart = (int)$postData['year'];
+ $weekStart = (int)$postData['week'];
+
+ $currentYear = (int)date('o');
+ $currentWeek = (int)date('W');
+
+ if(
+ $yearStart &&
+ $weekStart
+ ){
+ $weekCount = $this->getWeekCount($period, $yearStart);
+ }else{
+ // if start date is not set -> calculate it from current data
+ $tmpYear = $currentYear;
+ if($period == 'yearly'){
+ $tmpYear--;
+ }
+ $weekCount = $this->getWeekCount($period, $tmpYear);
+ $offsetStart = $this->calculateYearWeekOffset($currentYear, $currentWeek, $weekCount, true);
+ $yearStart = $offsetStart['year'];
+ $weekStart = $offsetStart['week'];
+ }
+
+ // date offset for statistics query
+ $offset = $this->calculateYearWeekOffset($yearStart, $weekStart, $weekCount);
+
+ $activeCharacter = $this->getCharacter();
+
+ $return->statistics = $this->queryStatistic($activeCharacter, $typeId, $yearStart, $weekStart, $offset['year'], $offset['week']);
+ $return->period = $period;
+ $return->typeId = $typeId;
+ $return->weekCount = $weekCount;
+ $return->yearWeeks = [
+ $yearStart => $this->getIsoWeeksInYear($yearStart),
+ ($yearStart + 1) => $this->getIsoWeeksInYear($yearStart + 1)
+ ];
+
+ // pagination offset
+ $offsetNext = $this->calculateYearWeekOffset($yearStart, $weekStart, $weekCount + 1);
+ $offsetPrev = $this->calculateYearWeekOffset($yearStart, $weekStart, $weekCount + 1, true);
+
+ // check if "next" button is available (not in future)
+ $currentCurrentDataConcat = intval($this->concatYearWeek($currentYear, $currentWeek));
+ $offsetNextDateConcat = intval($this->concatYearWeek($offsetNext['year'], $offsetNext['week']));
+ if($offsetNextDateConcat <= $currentCurrentDataConcat){
+ $return->next = $offsetNext;
+ }
+
+ $return->prev = $offsetPrev;
+ $return->start = ['year' => $yearStart, 'week' => $weekStart];
+ $return->offset = $offset;
+
+ echo json_encode($return);
+ }
+}
\ No newline at end of file
diff --git a/app/Controller/Api/System.php b/app/Controller/Api/System.php
new file mode 100644
index 000000000..8368d1b55
--- /dev/null
+++ b/app/Controller/Api/System.php
@@ -0,0 +1,94 @@
+get('POST');
+
+ $return = (object) [];
+ $return->error = [];
+ $return->destData = [];
+
+ if(!empty($destData = (array)$postData['destData'])){
+ $activeCharacter = $this->getCharacter();
+
+ $return->clearOtherWaypoints = (bool)$postData['clearOtherWaypoints'];
+ $return->first = (bool)$postData['first'];
+
+ if($accessToken = $activeCharacter->getAccessToken()){
+ $options = [
+ 'clearOtherWaypoints' => $return->clearOtherWaypoints,
+ 'addToBeginning' => $return->first,
+ ];
+
+ foreach($destData as $data){
+ $response = $f3->ccpClient()->send('setWaypoint', (int)$data['id'], $accessToken, $options);
+
+ if(empty($response)){
+ $return->destData[] = $data;
+ }else{
+ $error = (object) [];
+ $error->type = 'error';
+ $error->text = $response['error'];
+ $return->error[] = $error;
+ }
+ }
+
+ }
+ }
+
+ echo json_encode($return);
+ }
+
+ /**
+ * send Rally Point poke
+ * @param \Base $f3
+ * @throws \Exception
+ */
+ public function pokeRally(\Base $f3){
+ $rallyData = (array)$f3->get('POST');
+ $systemId = (int)$rallyData['systemId'];
+ $return = (object) [];
+
+ if($systemId){
+ $activeCharacter = $this->getCharacter();
+
+ /**
+ * @var $system Pathfinder\SystemModel
+ */
+ $system = Pathfinder\AbstractPathfinderModel::getNew('SystemModel');
+ $system->getById($systemId);
+
+ if($system->hasAccess($activeCharacter)){
+ $rallyData['pokeDesktop'] = $rallyData['pokeDesktop'] === '1';
+ $rallyData['pokeMail'] = $rallyData['pokeMail'] === '1';
+ $rallyData['pokeSlack'] = $rallyData['pokeSlack'] === '1';
+ $rallyData['pokeDiscord'] = $rallyData['pokeDiscord'] === '1';
+ $rallyData['message'] = trim($rallyData['message']);
+
+ $system->sendRallyPoke($rallyData, $activeCharacter);
+ }
+ }
+
+ echo json_encode($return);
+ }
+
+}
+
diff --git a/app/Controller/Api/Universe.php b/app/Controller/Api/Universe.php
new file mode 100644
index 000000000..907cb62dc
--- /dev/null
+++ b/app/Controller/Api/Universe.php
@@ -0,0 +1,128 @@
+get('POST');
+ $categories = (array)$postData['categories'];
+ $universeNameData = [];
+
+ if(
+ array_key_exists('arg1', $params) &&
+ !empty($search = strtolower($params['arg1'])) &&
+ !empty($categories)
+ ){
+ $universeNameData = Ccp\Universe::searchUniverseNameData($categories, $search);
+ }
+
+ echo json_encode($universeNameData);
+ }
+
+ /**
+ * search systems by name
+ * @param \Base $f3
+ * @param $params
+ * @throws \Exception
+ */
+ public function systems(\Base $f3, $params){
+ $getData = (array)$f3->get('GET');
+ $page = isset($getData['page']) ? (int)max($getData['page'],1) : 1;
+ $search = isset($params['arg1']) ? (string)$params['arg1'] : '';
+ $morePages = false;
+ $count = 0;
+
+ $return = (object) [];
+ $return->results = [];
+
+ // some "edge cases" for testing trueSec rounding...
+ //$searchToken = 'H472-N'; // -0.000001 -> 0.0
+ //$searchToken = 'X1E-OQ'; // -0.099426 -> -0.10
+ //$searchToken = 'BKK4-H'; // -0.049954 -> -0.05
+ //$searchToken = 'Uhtafal'; // 0.499612 -> 0.5 (HS)
+ //$searchToken = 'Oshaima'; // 0.453128 -> 0.5 (HS)
+ //$searchToken = 'Ayeroilen'; // 0.446568 -> 0.4 (LS)
+ //$searchToken = 'Enderailen'; // 0.448785 -> 0.4 (LS)
+ //$searchToken = 'Neziel'; // 0.449943 -> 0.4 (LS)
+ //$searchToken = 'Naga'; // 0.033684 -> 0.1 (LS)
+
+ if( strlen($search) >= 3 ){
+ $offset = ($page - 1) * self::PAGE_SIZE_SYSTEMS;
+ $system = Model\Universe\AbstractUniverseModel::getNew('SystemModel');
+
+ $filter = [
+ 'id LIKE :id OR name LIKE :name',
+ ':id' => $search . '%', // -> match first
+ ':name' => '%' . $search . '%' // -> match between
+ ];
+ $options = [
+ 'order' => 'name',
+ 'offset' => $offset,
+ 'limit' => self::PAGE_SIZE_SYSTEMS
+ ];
+ $count = $system->count($filter);
+ $endCount = $offset + self::PAGE_SIZE_SYSTEMS;
+ $morePages = $endCount < $count;
+
+ $systems = $system->find($filter, $options);
+ if($systems){
+ foreach($systems as $system){
+ if($systemData = $system->fromIndex()){
+ $return->results[] = $systemData;
+ }
+ }
+ }
+ }
+
+ $return->pagination = ['more' => $morePages, 'count' => $count];
+
+ echo json_encode($return);
+ }
+
+ /**
+ * get system data for all systems within a constellation
+ * @param \Base $f3
+ * @param array $params
+ * @throws \Exception
+ */
+ public function constellationData(\Base $f3, $params){
+ $constellationId = isset($params['arg1']) ? (int)$params['arg1'] : 0;
+
+ $return = (object) [];
+ $return->error = [];
+ $return->systemsData = [];
+
+ $constellation = Model\Universe\AbstractUniverseModel::getNew('ConstellationModel');
+ $constellation->getById($constellationId);
+ if($constellation->valid() && $constellation->systems){
+ /**
+ * @var Model\Universe\SystemModel $system
+ */
+ foreach($constellation->systems as $system){
+ if($systemData = $system->fromIndex()){
+ $return->systemsData[] = $systemData;
+ }
+ }
+ }
+
+ echo json_encode($return);
+ }
+}
\ No newline at end of file
diff --git a/app/Controller/Api/User.php b/app/Controller/Api/User.php
new file mode 100644
index 000000000..368864521
--- /dev/null
+++ b/app/Controller/Api/User.php
@@ -0,0 +1,388 @@
+getUser()){
+ // check if character belongs to current user
+ // -> If there is already a logged in user! (e.g. multi character use)
+ $currentUser = $this->getUser();
+ $timezone = $this->getF3()->get('getTimeZone')();
+
+ $sessionCharacters = [
+ [
+ 'ID' => $character->_id,
+ 'NAME' => $character->name,
+ 'TIME' => (new \DateTime('now', $timezone))->getTimestamp()
+ ]
+ ];
+
+ if(
+ is_null($currentUser) ||
+ $currentUser->_id !== $user->_id
+ ){
+ // user has changed OR new user -----------------------------------------------------------------------
+ //-> set user/character data to session
+ $this->getF3()->set(self::SESSION_KEY_USER, [
+ 'ID' => $user->_id,
+ 'NAME' => $user->name
+ ]);
+ }else{
+ // user has NOT changed -------------------------------------------------------------------------------
+ $sessionCharacters = $character::mergeSessionCharacterData($sessionCharacters);
+ }
+
+ $this->getF3()->set(self::SESSION_KEY_CHARACTERS, $sessionCharacters);
+
+ $character->updateCloneData();
+ $character->updateRoleData();
+
+ // save user login information ----------------------------------------------------------------------------
+ $character->touch('lastLogin');
+ $character->save();
+
+ // write login log ----------------------------------------------------------------------------------------
+ self::getLogger('CHARACTER_LOGIN')->write(
+ sprintf(self::LOG_LOGGED_IN,
+ $user->_id,
+ $user->name,
+ $character->_id,
+ $character->name
+ )
+ );
+
+ // set temp character data --------------------------------------------------------------------------------
+ // -> pass character data over for next http request (reroute())
+ $this->setTempCharacterData($character->_id);
+
+ $login = true;
+ }
+
+ return $login;
+ }
+
+ /**
+ * validate cookie character information
+ * -> return character data (if valid)
+ * @param \Base $f3
+ * @throws \Exception
+ */
+ public function getCookieCharacter(\Base $f3){
+ $data = $f3->get('POST');
+ $cookieName = (string)$data['cookie'];
+
+ $return = (object) [];
+ $return->ccpImageServer = Config::getPathfinderData('api.ccp_image_server');
+ $return->error = [];
+
+ if( !empty($cookieData = $this->getCookieByName($cookieName) )){
+ // cookie data is valid -> validate data against DB (security check!)
+ // -> add characters WITHOUT permission to log in too!
+ if( !empty($characters = $this->getCookieCharacters(array_slice($cookieData, 0, 1, true), false)) ){
+ // character is valid and allowed to login
+ $return->character = reset($characters)->getData();
+ // get Session status for character
+ if($activeCharacter = $this->getCharacter(0)){
+ if($activeUser = $activeCharacter->getUser()){
+ if($sessionCharacterData = $activeUser->findSessionCharacterData($return->character->id)){
+ $return->character->hasActiveSession = true;
+ }
+ }
+ }
+ }else{
+ $characterError = (object) [];
+ $characterError->type = 'warning';
+ $characterError->text = 'This can happen through "invalid cookies(SSO)", "login restrictions", "ESI problems".';
+ $return->error[] = $characterError;
+ }
+ }
+
+ echo json_encode($return);
+ }
+
+ /**
+ * get captcha image and store key to session
+ * @param \Base $f3
+ */
+ public function getCaptcha(\Base $f3){
+ $data = $f3->get('POST');
+
+ $return = (object) [];
+ $return->error = [];
+
+ // check if reason for captcha generation is valid
+ if(
+ isset($data['reason']) &&
+ in_array( $data['reason'], self::$captchaReason)
+ ){
+ $reason = $data['reason'];
+
+ $im = imagecreatetruecolor(1, 1);
+ $colorText = imagecolorallocate($im, 102, 200, 79);
+ $colorBG = imagecolorallocate($im, 49, 51, 53);
+
+ $img = new \Image();
+ $imgDump = $img->captcha(
+ 'fonts/oxygen-bold-webfont.ttf',
+ 14,
+ 6,
+ $reason,
+ '',
+ $colorText,
+ $colorBG
+ )->dump();
+
+ $return->img = $f3->base64( $imgDump, 'image/png');
+ }else{
+ $captchaError = (object) [];
+ $captchaError->type = 'error';
+ $captchaError->text = 'Could not create captcha image';
+ $return->error[] = $captchaError;
+ }
+
+ echo json_encode($return);
+ }
+
+ /**
+ * log the current user out + clear character system log data
+ * @param \Base $f3
+ * @throws \Exception
+ */
+ public function logout(\Base $f3){
+ $data = $f3->get('POST');
+ $deleteCookie = (bool)$data['deleteCookie'];
+
+ $this->logoutCharacter($f3, false, true, true, $deleteCookie, 200);
+ }
+
+ /**
+ * remote open ingame information window (character, corporation or alliance) Id
+ * -> the type is auto-recognized by CCP
+ * @param \Base $f3
+ * @throws \Exception
+ */
+ public function openIngameWindow(\Base $f3){
+ $data = $f3->get('POST');
+
+ $return = (object) [];
+ $return->error = [];
+
+ if( $targetId = (int)$data['targetId']){
+ $activeCharacter = $this->getCharacter();
+
+ $response = $f3->ccpClient()->send('openWindow', $targetId, $activeCharacter->getAccessToken());
+
+ if(empty($response)){
+ $return->targetId = $targetId;
+ }else{
+ $error = (object) [];
+ $error->type = 'error';
+ $error->text = $response['error'];
+ $return->error[] = $error;
+ }
+ }
+
+ echo json_encode($return);
+ }
+
+ /**
+ * update user account data
+ * -> a fresh user automatically generated on first login with a new character
+ * -> see SSO login
+ * @param \Base $f3
+ * @throws \Exception
+ */
+ public function saveAccount(\Base $f3){
+ $data = $f3->get('POST');
+
+ $return = (object)[];
+ $return->error = [];
+
+ $captcha = $f3->get(self::SESSION_CAPTCHA_ACCOUNT_UPDATE);
+
+ // reset captcha -> forces user to enter new one
+ $f3->clear(self::SESSION_CAPTCHA_ACCOUNT_UPDATE);
+
+ $newUserData = null;
+
+ if(isset($data['formData'])){
+ $formData = $data['formData'];
+
+ try{
+ if($activeCharacter = $this->getCharacter()){
+ $user = $activeCharacter->getUser();
+
+ // captcha is send -> check captcha ---------------------------------------------------------------
+ if(isset($formData['captcha']) && !empty($formData['captcha'])){
+ if($formData['captcha'] === $captcha){
+ // change/set sensitive user data requires captcha!
+
+ // set username
+ if(isset($formData['name']) && !empty($formData['name'])){
+ $user->name = $formData['name'];
+ }
+
+ // set email
+ if(
+ isset($formData['email']) &&
+ isset($formData['email_confirm']) &&
+ !empty($formData['email']) &&
+ !empty($formData['email_confirm']) &&
+ $formData['email'] == $formData['email_confirm']
+ ){
+ $user->email = $formData['email'];
+ }
+
+ // save/update user model
+ // this will fail if model validation fails!
+ $user->save();
+
+ }else{
+ // captcha was send but not valid -> return error
+ $captchaError = (object)[];
+ $captchaError->type = 'error';
+ $captchaError->text = 'Captcha does not match';
+ $return->error[] = $captchaError;
+ }
+ }
+
+ // sharing config ---------------------------------------------------------------------------------
+ if(isset($formData['share'])){
+ $privateSharing = (int)$formData['privateSharing'];
+ $corporationSharing = (int)$formData['corporationSharing'];
+ $allianceSharing = (int)$formData['allianceSharing'];
+
+ // update private/corp/ally
+ $corporation = $activeCharacter->getCorporation();
+ $alliance = $activeCharacter->getAlliance();
+
+ if(is_object($corporation)){
+ $corporation->shared = $corporationSharing;
+ $corporation->save();
+ }
+
+ if(is_object($alliance)){
+ $alliance->shared = $allianceSharing;
+ $alliance->save();
+ }
+
+ $activeCharacter->shared = $privateSharing;
+ $activeCharacter->save();
+ }
+
+ // character config -------------------------------------------------------------------------------
+ if(isset($formData['character'])){
+ $activeCharacter->copyfrom($formData, ['logLocation', 'selectLocation']);
+
+ $activeCharacter->save();
+ }
+
+ // get fresh updated user object
+ $newUserData = $user->getData();
+ }
+
+ }catch(Exception\ValidationException $e){
+ $return->error[] = $e->getError();
+ }catch(Exception\RegistrationException $e){
+ $return->error[] = $e->getError();
+ }
+
+ // return new/updated user data
+ $return->userData = $newUserData;
+ }
+
+ echo json_encode($return);
+ }
+
+ /**
+ * delete current user account from DB
+ * @param \Base $f3
+ * @throws \Exception
+ */
+ public function deleteAccount(\Base $f3){
+ $data = $f3->get('POST.formData');
+ $return = (object) [];
+
+ $captcha = $f3->get(self::SESSION_CAPTCHA_ACCOUNT_DELETE);
+
+ // reset captcha -> forces user to enter new one
+ $f3->clear(self::SESSION_CAPTCHA_ACCOUNT_DELETE);
+
+ if(
+ isset($data['captcha']) &&
+ !empty($data['captcha']) &&
+ $data['captcha'] === $captcha
+ ){
+ $activeCharacter = $this->getCharacter();
+ $user = $activeCharacter->getUser();
+
+ if($user){
+ // save log
+ self::getLogger('DELETE_ACCOUNT')->write(
+ sprintf(self::LOG_DELETE_ACCOUNT, $user->id, $user->name)
+ );
+
+ $this->logoutCharacter($f3, true, true, true, true, 200);
+ $user->erase();
+ }
+ }else{
+ // captcha not valid -> return error
+ $captchaError = (object) [];
+ $captchaError->type = 'error';
+ $captchaError->text = 'Captcha does not match';
+ $return->error[] = $captchaError;
+ }
+
+ echo json_encode($return);
+ }
+
+
+}
\ No newline at end of file
diff --git a/app/Controller/AppController.php b/app/Controller/AppController.php
new file mode 100644
index 000000000..b9880f4cd
--- /dev/null
+++ b/app/Controller/AppController.php
@@ -0,0 +1,75 @@
+set('tplPageTitle', Config::getPathfinderData('name'));
+
+ // main page content
+ $f3->set('tplPageContent', Config::getPathfinderData('view.login'));
+
+ // body element class
+ $f3->set('tplBodyClass', 'pf-landing');
+
+ // JS main file
+ $f3->set('tplJsView', 'login');
+
+ if($return = parent::beforeroute($f3, $params)){
+ // href for SSO Auth
+ $f3->set('tplAuthType', $f3->get('BASE') . $f3->alias( 'sso', ['action' => 'requestAuthorization'] ));
+
+ // characters from cookies
+ $f3->set('cookieCharacters', $this->getCookieByName(self::COOKIE_PREFIX_CHARACTER, true));
+ $f3->set('getCharacterGrid', function($characters){
+ return ( ((12 / count($characters)) <= 3) ? 3 : (12 / count($characters)) );
+ });
+ }
+
+ return $return;
+ }
+
+ /**
+ * event handler after routing
+ * @param \Base $f3
+ */
+ public function afterroute(\Base $f3){
+ parent::afterroute($f3);
+
+ // clear all SSO related temp data
+ if($f3->exists(Ccp\Sso::SESSION_KEY_SSO)){
+ $f3->clear(Ccp\Sso::SESSION_KEY_SSO);
+ }
+ }
+
+ /**
+ * show main login (index) page
+ * @param \Base $f3
+ */
+ public function init(\Base $f3){
+ $resource = Resource::instance();
+ $resource->register('script', 'app/login');
+ $resource->register('script', 'app/mappage', 'prefetch');
+ $resource->register('image', 'sso/signature.png');
+ $resource->register('image', 'sso/gameplay.png');
+ }
+
+}
\ No newline at end of file
diff --git a/app/Controller/Ccp/Sso.php b/app/Controller/Ccp/Sso.php
new file mode 100644
index 000000000..542e007be
--- /dev/null
+++ b/app/Controller/Ccp/Sso.php
@@ -0,0 +1,556 @@
+ cf. Controller->getCookieCharacters() ( equivalent cookie based login)
+ * @param \Base $f3
+ */
+ public function requestAdminAuthorization($f3){
+ // store browser tabId to be "targeted" after login
+ $f3->set(self::SESSION_KEY_SSO_FROM, 'admin');
+
+ $scopes = self::getScopesByAuthType('admin');
+ $this->rerouteAuthorization($f3, $scopes, 'admin');
+ }
+
+ /**
+ * redirect user to CCP SSO page and request authorization
+ * -> cf. Controller->getCookieCharacters() ( equivalent cookie based login)
+ * @param \Base $f3
+ * @throws \Exception
+ */
+ public function requestAuthorization($f3){
+ $params = $f3->get('GET');
+
+ if(
+ isset($params['characterId']) &&
+ ( $activeCharacter = $this->getCharacter() )
+ ){
+ // authentication restricted to a characterId -------------------------------------------------------------
+ // restrict login to this characterId e.g. for character switch on map page
+ $characterId = (int)trim((string)$params['characterId']);
+
+ /**
+ * @var $character Pathfinder\CharacterModel
+ */
+ $character = Pathfinder\AbstractPathfinderModel::getNew('CharacterModel');
+ $character->getById($characterId, 0);
+
+ // check if character is valid and exists
+ if(
+ $character->valid() &&
+ $character->hasUserCharacter() &&
+ ($activeCharacter->getUser()->_id === $character->getUser()->_id)
+ ){
+ // requested character belongs to current user
+ // -> update character vom ESI (e.g. corp changed,..)
+ $updateStatus = $character->updateFromESI();
+
+ if( empty($updateStatus) ){
+
+ // make sure character data is up2date!
+ // -> this is not the case if e.g. userCharacters was removed "ownerHash" changed...
+ $character->getById($character->_id);
+
+ if(
+ $character->hasUserCharacter() &&
+ ($character->isAuthorized() === 'OK')
+ ){
+ if($this->loginByCharacter($character)){
+ // set "login" cookie
+ $this->setLoginCookie($character);
+
+ // route to "map"
+ $f3->reroute(['map', ['*' => '']]);
+ }
+ }
+ }
+ }
+
+ // redirect to map map page on successful login
+ $f3->set(self::SESSION_KEY_SSO_FROM, 'map');
+ }
+
+ // redirect to CCP SSO ----------------------------------------------------------------------------------------
+ $scopes = self::getScopesByAuthType();
+ $this->rerouteAuthorization($f3, $scopes);
+ }
+
+ /**
+ * redirect user to CCPs SSO page
+ * @param \Base $f3
+ * @param array $scopes
+ * @param string $rootAlias
+ */
+ private function rerouteAuthorization(\Base $f3, array $scopes = [], string $rootAlias = 'login'){
+ if( !empty( Controller\Controller::getEnvironmentData('CCP_SSO_CLIENT_ID') ) ){
+ // used for "state" check between request and callback
+ $state = bin2hex( openssl_random_pseudo_bytes(12) );
+ $f3->set(self::SESSION_KEY_SSO_STATE, $state);
+
+ $urlParams = [
+ 'response_type' => 'code',
+ 'redirect_uri' => Controller\Controller::getEnvironmentData('URL') . Controller\Controller::getEnvironmentData('BASE') . $f3->build('/sso/callbackAuthorization'),
+ 'client_id' => Controller\Controller::getEnvironmentData('CCP_SSO_CLIENT_ID'),
+ 'scope' => implode(' ', $scopes),
+ 'state' => $state
+ ];
+
+ $ssoAuthUrl = $f3->ssoClient()->getUrl();
+ $ssoAuthUrl .= $f3->ssoClient()->getAuthorizationEndpointURI();
+ $ssoAuthUrl .= '?' . http_build_query($urlParams, '', '&', PHP_QUERY_RFC3986 );
+
+ $f3->status(302);
+ $f3->reroute($ssoAuthUrl);
+ }else{
+ // SSO clientId missing
+ $f3->set(self::SESSION_KEY_SSO_ERROR, self::ERROR_CCP_CLIENT_ID);
+ self::getSSOLogger()->write(self::ERROR_CCP_CLIENT_ID);
+ $f3->reroute([$rootAlias, ['*' => '']]);
+ }
+ }
+
+ /**
+ * callback handler for CCP SSO user Auth
+ * -> see requestAuthorization()
+ * @param \Base $f3
+ * @throws \Exception
+ */
+ public function callbackAuthorization($f3){
+ $getParams = (array)$f3->get('GET');
+
+ // users can log in either from @login (new user) or @map (existing user) root alias
+ // -> or from /admin page
+ // -> in case login fails, users should be redirected differently
+ $rootAlias = 'login';
+ if( !empty($f3->get(self::SESSION_KEY_SSO_FROM)) ){
+ $rootAlias = $f3->get(self::SESSION_KEY_SSO_FROM);
+ }
+
+ if($f3->exists(self::SESSION_KEY_SSO_STATE)){
+ // check response and validate 'state'
+ if(
+ isset($getParams['code']) &&
+ isset($getParams['state']) &&
+ !empty($getParams['code']) &&
+ !empty($getParams['state']) &&
+ $f3->get(self::SESSION_KEY_SSO_STATE) === $getParams['state']
+ ){
+ // clear 'state' for new next login request
+ $f3->clear(self::SESSION_KEY_SSO_STATE);
+ $f3->clear(self::SESSION_KEY_SSO_FROM);
+
+ $accessData = $this->getSsoAccessData($getParams['code']);
+
+ if(isset($accessData->accessToken, $accessData->esiAccessTokenExpires, $accessData->refreshToken)){
+ // login succeeded -> get basic character data for current login
+ $verificationCharacterData = $this->verifyCharacterData($accessData->accessToken);
+
+ if( !empty($verificationCharacterData) ){
+
+ // check if login is restricted to a characterID
+
+ // verification available data. Data is needed for "ownerHash" check
+
+ // get character data from ESI
+ $characterData = $this->getCharacterData((int)$verificationCharacterData['characterId']);
+
+ if( isset($characterData->character) ){
+ // add "ownerHash" and SSO tokens
+ $characterData->character['ownerHash'] = $verificationCharacterData['characterOwnerHash'];
+ $characterData->character['esiAccessToken'] = $accessData->accessToken;
+ $characterData->character['esiAccessTokenExpires'] = $accessData->esiAccessTokenExpires;
+ $characterData->character['esiRefreshToken'] = $accessData->refreshToken;
+ $characterData->character['esiScopes'] = $verificationCharacterData['scopes'];
+
+ // add/update static character data
+ $characterModel = $this->updateCharacter($characterData);
+
+ if( !is_null($characterModel) ){
+ // check if character is authorized to log in
+ if( ($authStatus = $characterModel->isAuthorized()) === 'OK'){
+ // character is authorized to log in
+ // -> update character log (current location,...)
+ $characterModel = $characterModel->updateLog();
+
+ // connect character with current user
+ if(is_null($user = $this->getUser())){
+ // connect character with existing user (no changes)
+ if(is_null($user = $characterModel->getUser())){
+ // no user found (new character) -> create new user and connect to character
+ /**
+ * @var $user Pathfinder\UserModel
+ */
+ $user = Pathfinder\AbstractPathfinderModel::getNew('UserModel');
+ $user->name = $characterModel->name;
+ $user->save();
+ }
+ }
+
+ /**
+ * @var $userCharactersModel Pathfinder\UserCharacterModel
+ */
+ if( is_null($userCharactersModel = $characterModel->userCharacter) ){
+ $userCharactersModel = $characterModel->rel('userCharacter');
+ $userCharactersModel->characterId = $characterModel;
+ }
+
+ // user might have changed
+ $userCharactersModel->userId = $user;
+ $userCharactersModel->save();
+
+ // get updated character model
+ $characterModel = $userCharactersModel->getCharacter();
+
+ // login by character
+ if($this->loginByCharacter($characterModel)){
+ // set "login" cookie
+ $this->setLoginCookie($characterModel);
+
+ // -> pass current character data to target page
+ $this->setTempCharacterData($characterModel->_id);
+
+ // route to "map"
+ if($rootAlias == 'admin'){
+ $f3->reroute([$rootAlias, ['*' => '']]);
+ }else{
+ $f3->reroute(['map', ['*' => '']]);
+ }
+ }else{
+ $f3->set(self::SESSION_KEY_SSO_ERROR, sprintf(self::ERROR_LOGIN_FAILED, $characterModel->name));
+ }
+ }else{
+ // character is not authorized to log in
+ $f3->set(self::SESSION_KEY_SSO_ERROR,
+ sprintf(self::ERROR_CHARACTER_FORBIDDEN, $characterModel->name, Pathfinder\CharacterModel::AUTHORIZATION_STATUS[$authStatus])
+ );
+ }
+ }
+ }else{
+ // failed to load characterData from API
+ $f3->set(self::SESSION_KEY_SSO_ERROR, self::ERROR_CHARACTER_DATA);
+ }
+ }else{
+ // failed to verify character by CCP SSO
+ $f3->set(self::SESSION_KEY_SSO_ERROR, self::ERROR_CHARACTER_VERIFICATION);
+ }
+ }else{
+ // SSO "accessData" missing (e.g. timeout)
+ $f3->set(self::SESSION_KEY_SSO_ERROR, sprintf(self::ERROR_SERVICE_TIMEOUT, self::SSO_TIMEOUT));
+ }
+ }else{
+ // invalid SSO response
+ $f3->set(self::SESSION_KEY_SSO_ERROR, sprintf(self::ERROR_LOGIN_FAILED, 'Invalid response'));
+ }
+ }
+
+ $f3->reroute([$rootAlias, ['*' => '']]);
+ }
+
+ /**
+ * login by cookie name
+ * @param \Base $f3
+ * @throws \Exception
+ */
+ public function login(\Base $f3){
+ $data = (array)$f3->get('GET');
+ $cookieName = (string)$data['cookie'];
+ $character = null;
+
+ if( !empty($cookieName) ){
+ if( !empty($cookieData = $this->getCookieByName($cookieName) )){
+ // cookie data is valid -> validate data against DB (security check!)
+ if( !empty($characters = $this->getCookieCharacters(array_slice($cookieData, 0, 1, true))) ){
+ // character is valid and allowed to login
+ $character = $characters[$cookieName];
+ }
+ }
+ }
+
+ if(is_object($character)){
+ // login by character
+ if($this->loginByCharacter($character)){
+ // route to "map"
+ $f3->reroute(['map', ['*' => '']]);
+ }else{
+ $f3->set(self::SESSION_KEY_SSO_ERROR, sprintf(self::ERROR_LOGIN_FAILED, $character->name));
+ }
+ }else{
+ $f3->set(self::SESSION_KEY_SSO_ERROR, self::ERROR_COOKIE_LOGIN);
+ }
+
+ // on error -> route back to login form
+ $f3->reroute(['login']);
+ }
+
+ /**
+ * get a valid "access_token" for oAuth 2.0 verification
+ * -> if $authCode is set -> request NEW "access_token"
+ * -> else check for existing (not expired) "access_token"
+ * -> else try to refresh auth and get fresh "access_token"
+ * @param bool $authCode
+ * @return null|\stdClass
+ */
+ protected function getSsoAccessData($authCode){
+ $accessData = null;
+
+ if( !empty($authCode) ){
+ // Authentication Code is set -> request new "accessToken"
+ $accessData = $this->verifyAuthorizationCode($authCode);
+ }else{
+ // Unable to get Token -> trigger error
+ self::getSSOLogger()->write(sprintf(self::ERROR_ACCESS_TOKEN, $authCode));
+ }
+
+ return $accessData;
+ }
+
+ /**
+ * verify authorization code, and get an "access_token" data
+ * @param string $authCode
+ * @return \stdClass
+ */
+ protected function verifyAuthorizationCode(string $authCode){
+ $requestParams = [
+ 'grant_type' => 'authorization_code',
+ 'code' => $authCode
+ ];
+
+ return $this->requestAccessData($requestParams);
+ }
+
+ /**
+ * get new "access_token" by an existing "refresh_token"
+ * -> if "access_token" is expired, this function gets a fresh one
+ * @param string $refreshToken
+ * @return \stdClass
+ */
+ public function refreshAccessToken(string $refreshToken){
+ $requestParams = [
+ 'grant_type' => 'refresh_token',
+ 'refresh_token' => $refreshToken
+ ];
+
+ return $this->requestAccessData($requestParams);
+ }
+
+ /**
+ * request an "access_token" AND "refresh_token" data
+ * -> this can either be done by sending a valid "authorization code"
+ * OR by providing a valid "refresh_token"
+ * @param array $requestParams
+ * @return \stdClass
+ */
+ protected function requestAccessData(array $requestParams) : \stdClass {
+ $accessData = (object) [];
+ $accessData->accessToken = null;
+ $accessData->refreshToken = null;
+ $accessData->esiAccessTokenExpires = 0;
+
+ $authCodeRequestData = $this->getF3()->ssoClient()->send('getAccess', $this->getAuthorizationData(), $requestParams);
+
+ if( !empty($authCodeRequestData) ){
+ if( !empty($authCodeRequestData['accessToken']) ){
+ // accessToken is required for endpoints that require Auth
+ $accessData->accessToken = $authCodeRequestData['accessToken'];
+ }
+
+ if( !empty($authCodeRequestData['expiresIn']) ){
+ // expire time for accessToken
+ try{
+ $accessTokenExpires = $this->getF3()->get('getDateTime')();
+ $accessTokenExpires->add(new \DateInterval('PT' . (int)$authCodeRequestData['expiresIn'] . 'S'));
+
+ $accessData->esiAccessTokenExpires = $accessTokenExpires->format('Y-m-d H:i:s');
+ }catch(\Exception $e){
+ $this->getF3()->error(500, $e->getMessage(), $e->getTrace());
+ }
+ }
+
+ if( !empty($authCodeRequestData['refreshToken']) ){
+ // this token is used to refresh/get a new access_token when expires
+ $accessData->refreshToken = $authCodeRequestData['refreshToken'];
+ }
+ }else{
+ self::getSSOLogger()->write(sprintf(self::ERROR_ACCESS_TOKEN, print_r($requestParams, true)));
+ }
+
+ return $accessData;
+ }
+
+ /**
+ * verify character data by "access_token"
+ * -> get some basic information (like character id)
+ * -> if more character information is required, use ESI "characters" endpoints request instead
+ * @param string $accessToken
+ * @return array
+ */
+ public function verifyCharacterData(string $accessToken) : array {
+ $characterData = $this->getF3()->ssoClient()->send('getVerifyCharacter', $accessToken);
+
+ if( !empty($characterData) ){
+ // convert string with scopes to array
+ $characterData['scopes'] = Lib\Util::convertScopesString($characterData['scopes']);
+ }else{
+ self::getSSOLogger()->write(sprintf(self::ERROR_VERIFY_CHARACTER, __METHOD__));
+ }
+
+ return $characterData;
+ }
+
+ /**
+ * get character data
+ * @param int $characterId
+ * @return \stdClass
+ * @throws \Exception
+ */
+ public function getCharacterData(int $characterId) : \stdClass{
+ $characterData = (object) [];
+
+ if($characterId){
+ $characterDataBasic = $this->getF3()->ccpClient()->send('getCharacter', $characterId);
+ if( !empty($characterDataBasic) ){
+ // remove some "unwanted" data -> not relevant for Pathfinder
+ $characterData->character = array_filter($characterDataBasic, function($key){
+ return in_array($key, ['id', 'name', 'securityStatus']);
+ }, ARRAY_FILTER_USE_KEY);
+
+ $characterData->corporation = null;
+ $characterData->alliance = null;
+
+ if($corporationId = (int)$characterDataBasic['corporation']['id']){
+ /**
+ * @var $corporation Pathfinder\CorporationModel
+ */
+ $corporation = Pathfinder\AbstractPathfinderModel::getNew('CorporationModel');
+ $corporation->getById($corporationId, 0);
+ if($corporation->valid()){
+ $characterData->corporation = $corporation;
+ }
+ }
+
+ if($allianceId = (int)$characterDataBasic['alliance']['id']){
+ /**
+ * @var $alliance Pathfinder\AllianceModel
+ */
+ $alliance = Pathfinder\AbstractPathfinderModel::getNew('AllianceModel');
+ $alliance->getById($allianceId, 0);
+ if($alliance->valid()){
+ $characterData->alliance = $alliance;
+ }
+ }
+ }
+ }
+
+ return $characterData;
+ }
+
+ /**
+ * update character
+ * @param \stdClass $characterData
+ * @return Pathfinder\CharacterModel|null
+ * @throws \Exception
+ */
+ protected function updateCharacter(\stdClass $characterData) : ?Pathfinder\CharacterModel {
+ $character = null;
+
+ if(!empty($characterData->character)){
+ /**
+ * @var $character Pathfinder\CharacterModel
+ */
+ $character = Pathfinder\AbstractPathfinderModel::getNew('CharacterModel');
+ $character->getById((int)$characterData->character['id'], 0);
+ $character->copyfrom($characterData->character, [
+ 'id', 'name', 'ownerHash', 'esiAccessToken', 'esiAccessTokenExpires', 'esiRefreshToken', 'esiScopes', 'securityStatus'
+ ]);
+
+ $character->corporationId = $characterData->corporation;
+ $character->allianceId = $characterData->alliance;
+ $character->save();
+ }
+
+ return $character;
+ }
+
+ /**
+ * get data for HTTP "Authorization:" Header
+ * -> This header is required for any Auth-required endpoints!
+ * @return array
+ */
+ protected function getAuthorizationData() : array {
+ return [
+ Controller\Controller::getEnvironmentData('CCP_SSO_CLIENT_ID'),
+ Controller\Controller::getEnvironmentData('CCP_SSO_SECRET_KEY'),
+ 'basic'
+ ];
+ }
+
+ /**
+ * get CCP SSO url from configuration file
+ * -> throw error if url is broken/missing
+ * @return string
+ */
+ static function getSsoUrlRoot() : string {
+ $url = '';
+ if( \Audit::instance()->url(self::getEnvironmentData('CCP_SSO_URL')) ){
+ $url = self::getEnvironmentData('CCP_SSO_URL');
+ }else{
+ $error = sprintf(self::ERROR_CCP_SSO_URL, __METHOD__);
+ self::getSSOLogger()->write($error);
+ \Base::instance()->error(502, $error);
+ }
+
+ return $url;
+ }
+
+ /**
+ * get logger for SSO logging
+ * @return \Log
+ */
+ static function getSSOLogger() : \Log {
+ return parent::getLogger('SSO');
+ }
+}
\ No newline at end of file
diff --git a/app/Controller/Ccp/Universe.php b/app/Controller/Ccp/Universe.php
new file mode 100644
index 000000000..fe6a1e3b7
--- /dev/null
+++ b/app/Controller/Ccp/Universe.php
@@ -0,0 +1,321 @@
+ 93 systems)
+ ];
+ $regionIds = $f3->ccpClient()->send('getUniverseRegions');
+ $regionIds = array_intersect($regionsWhitelist, $regionIds);
+
+ $region = Model\Universe\AbstractUniverseModel::getNew('RegionModel');
+ foreach($regionIds as $regionId){
+ $region->loadById($regionId);
+ $region->loadConstellationsData();
+
+ foreach((array)$region->constellations as $constellation){
+ $constellation->loadSystemsData();
+ }
+
+ $region->reset();
+ }
+ }*/
+
+ /* currently not used
+ protected function setupConstellations(\Base $f3){
+ $constellationsWhitelist = [
+ 20000014 // Mal (11 systems)
+ ];
+ $constellationIds = $f3->ccpClient()->send('getUniverseConstellations');
+ $constellationIds = array_intersect($constellationsWhitelist, $constellationIds);
+ $constellation = Model\Universe\AbstractUniverseModel::getNew('ConstellationModel');
+ foreach($constellationIds as $constellationId){
+ $constellation->loadById($constellationId);
+ $constellation->loadSystemsData();
+ $constellation->reset();
+ }
+ }*/
+
+ /**
+ * setup categories + all dependencies (e.g. groups, types)
+ * id 2 -> Celestial (>100 groups -> >1000 types)
+ * id 6 -> Ship (46 groups -> 4xx types)
+ * id 65 -> Structure (10 groups -> 33 types)
+ * @param array $categoriesWhitelist
+ * @return array
+ * @throws \Exception
+ */
+ protected function setupCategories(array $categoriesWhitelist = []) : array {
+ $info = [];
+ $categoryIds = Model\Universe\CategoryModel::getUniverseCategories();
+ $categoryIds = array_intersect($categoriesWhitelist, $categoryIds);
+ foreach($categoryIds as $categoryId){
+ $info[$categoryId] = $this->setupCategory($categoryId);
+ }
+ return $info;
+ }
+
+ /**
+ * setup category + all dependencies (e.g. groups, types)
+ * -> $length = 0 -> setup all groups
+ * @param int $categoryId
+ * @param int $offset
+ * @param int $length
+ * @return array
+ * @throws \Exception
+ */
+ public function setupCategory(int $categoryId, int $offset = 0, int $length = 0) : array {
+ $info = ['countAll' => 0, 'countChunk' => 0, 'count' => 0, 'offset' => $offset, 'groupTypes' => []];
+
+ if($categoryId){
+ /**
+ * @var $category Model\Universe\CategoryModel
+ */
+ $category = Model\Universe\AbstractUniverseModel::getNew('CategoryModel');
+ $category->loadById($categoryId);
+ $info = $category->loadGroupsData($offset, $length);
+ }
+
+ return $info;
+ }
+
+ /**
+ * setup groups + all dependencies (e.g. types)
+ * id 6 -> Sun (29 types)
+ * id 7 -> Planet (9 types)
+ * id 10 -> Stargate (17 types)
+ * id 988 -> Wormhole (89 types)
+ * @param array $groupsWhitelist
+ * @return array
+ * @throws \Exception
+ */
+ protected function setupGroups(array $groupsWhitelist = []) : array {
+ $info = [];
+ $groupIds = Model\Universe\GroupModel::getUniverseGroups();
+ $groupIds = array_intersect($groupsWhitelist, $groupIds);
+ foreach($groupIds as $groupId){
+ $info[$groupId] = $this->setupGroup($groupId);
+ }
+ return $info;
+ }
+
+ /**
+ * setup group + all dependencies (e.g. types)
+ * @param int $groupId
+ * @param int $offset
+ * @param int $length
+ * @param bool $storeDogmaAttributes
+ * @return array
+ * @throws \Exception
+ */
+ public function setupGroup(int $groupId, int $offset = 0, int $length = 0, bool $storeDogmaAttributes = false) : array {
+ $info = ['countAll' => 0, 'countChunk' => 0, 'count' => 0, 'offset' => $offset];
+
+ if($groupId){
+ /**
+ * @var $group Model\Universe\GroupModel
+ */
+ $group = Model\Universe\AbstractUniverseModel::getNew('GroupModel');
+ $group->storeDogmaAttributes = $storeDogmaAttributes;
+ $group->loadById($groupId);
+ $info = $group->loadTypesData($offset, $length);
+ }
+
+ return $info;
+ }
+
+ // system search index methods ====================================================================================
+
+ /**
+ * build search index from all systems data
+ * @param int $offset
+ * @param int $length
+ * @return array
+ * @throws \Exception
+ */
+ public function buildSystemsIndex(int $offset = 0, int $length = 10) : array {
+ $systemIds = $this->getSystemIds();
+ $systemsAll = count($systemIds);
+ $systemIds = array_slice($systemIds, $offset, $length);
+
+ /**
+ * @var $system Model\Universe\SystemModel
+ */
+ $system = Model\Universe\AbstractUniverseModel::getNew('SystemModel');
+ $indexData = [];
+ foreach($systemIds as $systemId){
+ $system->getById($systemId, 0);
+ if($hashKeyId = $system->getHashKey()){
+ $indexData[$hashKeyId] = $system->getData();
+ }
+ $system->reset();
+ // offset must increase otherwise we get a endless loop
+ // -> see /setup ajax build loop function
+ $offset++;
+ }
+
+ $this->getF3()->mset($indexData, '', $system::CACHE_INDEX_EXPIRE_KEY);
+
+ // ... add hashKeys for all table rows to tableIndex as well
+ $system::buildTableIndex($system, array_keys($indexData));
+
+ return ['countAll' => $systemsAll, 'countBuild' => count($systemIds), 'offset' => $offset];
+ }
+
+ /**
+ * get systemIds for all systems
+ * @param bool $ignoreCache
+ * @return array
+ * @throws \Exception
+ */
+ public function getSystemIds(bool $ignoreCache = false) : array {
+ $f3 = $this->getF3();
+ $systemIds = [];
+ if($ignoreCache || !$f3->exists(self::SESSION_KEY_SYSTEM_IDS, $systemIds)){
+ /**
+ * @var $system Model\Universe\SystemModel
+ */
+ $system = Model\Universe\AbstractUniverseModel::getNew('SystemModel');
+ if($systems = $system->find()){
+ $systemIds = $systems->getAll('id');
+ if(count($systemIds)){
+ sort($systemIds, SORT_NUMERIC);
+ $f3->set(self::SESSION_KEY_SYSTEM_IDS, $systemIds);
+ }
+ }
+ }
+
+ return $systemIds ? : [];
+ }
+
+ /**
+ * get complete system index (all systems)
+ * @param bool $all
+ * @return array
+ */
+ public function getSystemsIndex(bool $all = false) : array {
+ $index = [];
+ $cacheKeyTable = Model\Universe\AbstractUniverseModel::generateHashKeyTable('system');
+ if($this->getF3()->exists($cacheKeyTable,$cacheKeys)){
+ foreach((array)$cacheKeys as $cacheKeyRow){
+ if(($data = $this->get($cacheKeyRow)) && is_object($data)){
+ $index[] = $all ? $data : $data->id;
+ }
+ }
+ }
+ return $index;
+ }
+
+ /**
+ * clear complete systems search index for all systems
+ */
+ public function clearSystemsIndex(){
+ $cacheKeyTable = Model\Universe\AbstractUniverseModel::generateHashKeyTable('system');
+ if($this->getF3()->exists($cacheKeyTable,$cacheKeys)){
+ foreach((array)$cacheKeys as $cacheKeyRow) {
+ $this->clear($cacheKeyRow);
+ }
+ $this->getF3()->clear($cacheKeyTable);
+ }
+ }
+
+ /**
+ * look for existing systemData in index
+ * -> if not exists -> try to build
+ * @param int $systemId
+ * @return null|\stdClass
+ * @throws \Exception
+ */
+ public function getSystemData(int $systemId) : ?\stdClass {
+ $data = null;
+ if($systemId){
+ // ...check index for data
+ $cacheKeyRow = Model\Universe\AbstractUniverseModel::generateHashKeyRow('system', $systemId);
+ if(!$data = $this->get($cacheKeyRow)){
+ // .. try to build index
+ /**
+ * @var $system Model\Universe\SystemModel
+ */
+ $system = Model\Universe\AbstractUniverseModel::getNew('SystemModel');
+ if($system->getById($systemId)){
+ $data = $system->buildIndex();
+ }
+ }
+ }
+ return $data;
+ }
+
+ /**
+ * look for existing cacheKey data
+ * @param string $cacheKey
+ * @return null|\stdClass
+ */
+ private function get(string $cacheKey) : ?\stdClass {
+ $data = null;
+ if($this->getF3()->exists($cacheKey,$value)) {
+ if(is_string($value) && strpos($value, Model\Universe\AbstractUniverseModel::CACHE_KEY_PREFIX) === 0) {
+ // value references an other cacheKey that holds data
+ return $this->get($value);
+ }elseif( !empty((array)$value) ){
+ // stdClass data is not empty
+ $data = (object)$value;
+ }
+ }
+ return $data;
+ }
+
+ /**
+ * clear cacheKey
+ * @param string $cacheKey
+ */
+ private function clear(string $cacheKey){
+ if($this->getF3()->exists($cacheKey,$value)) {
+ if(is_string($value) && strpos($value, Model\Universe\AbstractUniverseModel::CACHE_KEY_PREFIX) === 0) {
+ // value references another cacheKey -> clear that one as well
+ $this->clear($value);
+ }
+ $this->getF3()->clear($cacheKey);
+ }
+ }
+
+ /**
+ * search universeName data by search term
+ * @param array $categories
+ * @param string $search
+ * @param bool $strict
+ * @return array
+ */
+ public static function searchUniverseNameData(array $categories, string $search, bool $strict = false) : array {
+ $f3 = \Base::instance();
+ $universeNameData = [];
+ if( !empty($categories) && !empty($search)){
+ $universeIds = $f3->ccpClient()->send('search', $categories, $search, $strict);
+ if(isset($universeIds['error'])){
+ // ESI error
+ $universeNameData = $universeIds;
+ }elseif( !empty($universeIds) ){
+ $universeIds = Util::arrayFlattenByValue($universeIds);
+ $universeNameData = $f3->ccpClient()->send('getUniverseNames', $universeIds);
+ }
+ }
+ return $universeNameData;
+ }
+
+}
\ No newline at end of file
diff --git a/app/Controller/Controllerr.php b/app/Controller/Controllerr.php
new file mode 100644
index 000000000..594609511
--- /dev/null
+++ b/app/Controller/Controllerr.php
@@ -0,0 +1,1017 @@
+ if it is a "graceful" logout (e.g. user clicks "logout" button, we use 200)
+ */
+ const DEFAULT_STATUS_LOGOUT = 403;
+
+ // cookie specific keys (names)
+ const COOKIE_NAME_STATE = 'cookie';
+ const COOKIE_PREFIX_CHARACTER = 'char';
+
+ // log text
+ const ERROR_SESSION_SUSPECT = 'id: [%45s], ip: [%45s], User-Agent: [%s]';
+ const ERROR_TEMP_CHARACTER_ID = 'Invalid temp characterId: %s';
+
+ const NOTIFICATION_TYPES = ['danger', 'warning', 'info', 'success'];
+ /**
+ * @var \Base
+ */
+ protected $f3;
+
+ /**
+ * @var string template for render
+ */
+ protected $template;
+
+ /**
+ * @param string $template
+ */
+ protected function setTemplate($template){
+ $this->template = $template;
+ }
+
+ /**
+ * @return string
+ */
+ protected function getTemplate(){
+ return $this->template;
+ }
+
+ /**
+ * get $f3 base object
+ * @return \Base
+ */
+ protected function getF3() : \Base {
+ return \Base::instance();
+ }
+
+ /**
+ * get DB connection
+ * @param string $alias
+ * @return SQL|null
+ */
+ protected function getDB(string $alias = 'PF') : ?SQL {
+ return $this->getF3()->DB->getDB($alias);
+ }
+
+ /**
+ * event handler for all "views"
+ * some global template variables are set in here
+ * @param \Base $f3
+ * @param $params
+ * @return bool
+ */
+ function beforeroute(\Base $f3, $params) : bool {
+ // init user session
+ $this->initSession($f3);
+
+ if($f3->get('AJAX')){
+ header('Content-Type: application/json');
+
+ // send "maintenance" Header -> e.g. before server update
+ if($modeMaintenance = (int)Config::getPathfinderData('login.mode_maintenance')){
+ header('Pf-Maintenance: ' . $modeMaintenance);
+ }
+ }else{
+ $f3->set('tplResource', $this->initResource($f3));
+
+ $this->setTemplate(Config::getPathfinderData('view.index'));
+
+
+ $f3->set('tplImage', Format\Image::instance());
+ }
+
+ return true;
+ }
+
+ /**
+ * event handler after routing
+ * -> render view
+ * @param \Base $f3
+ */
+ public function afterroute(\Base $f3){
+ // send preload/prefetch headers
+ $resource = Resource::instance();
+ if($resource->getOption('output') === 'header'){
+ header($resource->buildHeader(), false);
+ }
+
+ if($file = $this->getTemplate()){
+ // Ajax calls don´t need a page render..
+ // this happens on client side
+ echo \Template::instance()->render($file);
+ }
+ }
+
+ /**
+ * init new Session handler
+ * @param \Base $f3
+ */
+ protected function initSession(\Base $f3){
+ $session = null;
+
+ if(
+ $f3->get('SESSION_CACHE') === 'mysql' &&
+ ($db = $f3->DB->getDB('PF')) instanceof SQL
+ ){
+ if(!headers_sent() && session_status() != PHP_SESSION_ACTIVE){
+ /**
+ * callback() for suspect sessions
+ * @param \DB\SQL\Session $session
+ * @param string $sid
+ * @return bool
+ */
+ $onSuspect = function($session, $sid){
+ self::getLogger('SESSION_SUSPECT')->write( sprintf(
+ self::ERROR_SESSION_SUSPECT,
+ $sid,
+ $session->ip(),
+ $session->agent()
+ ));
+ // .. continue with default onSuspect() handler
+ // -> destroy session
+ return false;
+ };
+
+ new Mysql\Session($db, 'sessions', true, $onSuspect);
+ }
+ }
+ }
+
+ /**
+ * init new Resource handler
+ * @param \Base $f3
+ * @return Resource
+ */
+ protected function initResource(\Base $f3){
+ $resource = Resource::instance();
+ $resource->setOption('basePath', $f3->get('BASE'));
+ $resource->setOption('filePath', [
+ 'style' => sprintf('/%scss/%s', $f3->get('UI'), Config::getPathfinderData('version')),
+ 'script' => sprintf('/%sjs/%s', $f3->get('UI'), Config::getPathfinderData('version')),
+ 'font' => sprintf('/%sfonts', $f3->get('UI')),
+ 'document' => sprintf('/%stemplates', $f3->get('UI')),
+ 'image' => sprintf('/%simg/%s', $f3->get('UI'), Config::getPathfinderData('version')),
+ 'favicon' => $f3->get('FAVICON')
+ ], true);
+
+ $resource->register('style', 'pathfinder');
+
+ $resource->register('script', 'lib/require');
+ $resource->register('script', 'app');
+
+ $resource->register('font', 'oxygen-regular-webfont');
+ $resource->register('font', 'oxygen-bold-webfont');
+ $resource->register('font', 'fa-regular-400');
+ $resource->register('font', 'fa-solid-900');
+ $resource->register('font', 'fa-brands-400');
+
+ $resource->register('url', self::getEnvironmentData('CCP_SSO_URL'), 'prerender');
+ $resource->register('url', Config::getPathfinderData('api.ccp_image_server'), 'dns-prefetch');
+ $resource->register('url', '//i.ytimg.com', 'dns-prefetch'); // YouTube tiny embed domain
+
+ return $resource;
+ }
+
+ /**
+ * get cookies "state" information
+ * -> whether user accepts cookies
+ * @return bool
+ */
+ protected function getCookieState() : bool {
+ return (bool)count( $this->getCookieByName(self::COOKIE_NAME_STATE) );
+ }
+
+ /**
+ * search for existing cookies
+ * -> either a specific cookie by its name
+ * -> or get multiple cookies by their name (search by prefix)
+ * @param $cookieName
+ * @param bool $prefix
+ * @return array
+ */
+ protected function getCookieByName($cookieName, $prefix = false) : array {
+ $data = [];
+
+ if(!empty($cookieName)){
+ $cookieData = (array)$this->getF3()->get('COOKIE');
+ if($prefix === true){
+ // look for multiple cookies with same prefix
+ foreach($cookieData as $name => $value){
+ if(strpos($name, $cookieName) === 0){
+ $data[$name] = $value;
+ }
+ }
+ }elseif(isset($cookieData[$cookieName])){
+ // look for a single cookie
+ $data[$cookieName] = $cookieData[$cookieName];
+ }
+ }
+
+ return $data;
+ }
+
+ /**
+ * set/update logged in cookie by character model
+ * -> store validation data in DB
+ * @param Pathfinder\CharacterModel $character
+ * @throws \Exception
+ */
+ protected function setLoginCookie(Pathfinder\CharacterModel $character){
+ if( $this->getCookieState() ){
+ $expireSeconds = (int)Config::getPathfinderData('login.cookie_expire');
+ $expireSeconds *= 24 * 60 * 60;
+
+ $timezone = $this->getF3()->get('getTimeZone')();
+ $expireTime = new \DateTime('now', $timezone);
+
+ // add cookie expire time
+ $expireTime->add(new \DateInterval('PT' . $expireSeconds . 'S'));
+
+ // unique "selector" -> to facilitate database look-ups (small size)
+ // -> This is preferable to simply using the database id field,
+ // which leaks the number of active users on the application
+ $selector = bin2hex( openssl_random_pseudo_bytes(12) );
+
+ // generate unique "validator" (strong encryption)
+ // -> plaintext set to user (cookie), hashed version of this in DB
+ $size = openssl_cipher_iv_length('aes-256-cbc');
+ $validator = bin2hex(openssl_random_pseudo_bytes($size) );
+
+ // generate unique cookie token
+ $token = hash('sha256', $validator);
+
+ // get unique cookie name for this character
+ $name = $character->getCookieName();
+
+ $authData = [
+ 'characterId' => $character,
+ 'selector' => $selector,
+ 'token' => $token,
+ 'expires' => $expireTime->format('Y-m-d H:i:s')
+ ];
+
+ $authenticationModel = $character->rel('characterAuthentications');
+ $authenticationModel->copyfrom($authData);
+ $authenticationModel->save();
+
+ $cookieValue = implode(':', [$selector, $validator]);
+
+ // get cookie name -> save new one OR update existing cookie
+ $cookieName = 'COOKIE.' . self::COOKIE_PREFIX_CHARACTER . '_' . $name;
+ $this->getF3()->set($cookieName, $cookieValue, $expireSeconds);
+ }
+ }
+
+ /**
+ * get characters from given cookie data
+ * -> validate cookie data
+ * -> validate characters
+ * -> cf. Sso->requestAuthorization() ( equivalent DB based login)
+ *
+ * @param array $cookieData
+ * @param bool $checkAuthorization
+ * @return Pathfinder\CharacterModel[]
+ * @throws \Exception
+ */
+ protected function getCookieCharacters($cookieData = [], $checkAuthorization = true) : array {
+ $characters = [];
+
+ if(
+ $this->getCookieState() &&
+ !empty($cookieData)
+ ){
+ /**
+ * @var $characterAuth Pathfinder\CharacterAuthenticationModel
+ */
+ $characterAuth = Pathfinder\AbstractPathfinderModel::getNew('CharacterAuthenticationModel');
+
+ $timezone = $this->getF3()->get('getTimeZone')();
+ $currentTime = new \DateTime('now', $timezone);
+
+ foreach($cookieData as $name => $value){
+ // remove invalid cookies
+ $invalidCookie = false;
+
+ $data = explode(':', $value);
+ if(count($data) === 2){
+ // cookie data is well formatted
+ $characterAuth->getByForeignKey('selector', $data[0], ['limit' => 1]);
+
+ // validate "scope hash"
+ // -> either "normal" scopes OR "admin" scopes
+ // "expire data" and "validate token"
+ if( !$characterAuth->dry() ){
+ if(
+ strtotime($characterAuth->expires) >= $currentTime->getTimestamp() &&
+ hash_equals($characterAuth->token, hash('sha256', $data[1]))
+ ){
+ // cookie information is valid
+ // -> try to update character information from ESI
+ // e.g. Corp has changed, this also ensures valid "access_token"
+ /**
+ * @var $character Pathfinder\CharacterModel
+ */
+ $updateStatus = $characterAuth->characterId->updateFromESI();
+
+ if( empty($updateStatus) ){
+ // make sure character data is up2date!
+ // -> this is not the case if e.g. userCharacters was removed "ownerHash" changed...
+ $character = $characterAuth->rel('characterId');
+ $character->getById( $characterAuth->get('characterId', true) );
+
+ // check ESI scopes
+ $scopeHash = Util::getHashFromScopes($character->esiScopes);
+
+ if(
+ $scopeHash === Util::getHashFromScopes(self::getScopesByAuthType()) ||
+ $scopeHash === Util::getHashFromScopes(self::getScopesByAuthType('admin'))
+ ){
+ // check if character still has user (is not the case of "ownerHash" changed
+ // check if character is still authorized to log in (e.g. corp/ally or config has changed
+ // -> do NOT remove cookie on failure. This can be a temporary problem (e.g. ESI is down,..)
+ if( $character->hasUserCharacter() ){
+ $authStatus = $character->isAuthorized();
+
+ if(
+ $authStatus == 'OK' ||
+ !$checkAuthorization
+ ){
+ $character->virtual( 'authStatus', $authStatus);
+ }
+
+ $characters[$name] = $character;
+ }
+ }else{
+ // outdated/invalid ESI scopes
+ $characterAuth->erase();
+ $invalidCookie = true;
+ }
+ }else{
+ $invalidCookie = true;
+ }
+ }else{
+ // clear existing authentication data from DB
+ $characterAuth->erase();
+ $invalidCookie = true;
+ }
+ }else{
+ $invalidCookie = true;
+ }
+ $characterAuth->reset();
+ }else{
+ $invalidCookie = true;
+ }
+
+ // remove invalid cookie
+ if($invalidCookie){
+ $this->getF3()->clear('COOKIE.' . $name);
+ }
+ }
+ }
+
+ return $characters;
+ }
+
+ /**
+ * get current character from session data
+ * @param int $ttl
+ * @return Pathfinder\CharacterModel|null
+ * @throws \Exception
+ */
+ protected function getSessionCharacter(int $ttl = AbstractModel::DEFAULT_SQL_TTL) : ?Pathfinder\CharacterModel {
+ $character = null;
+ if($user = $this->getUser($ttl)){
+ $header = self::getRequestHeaders();
+ $requestedCharacterId = (int)$header['Pf-Character'];
+ if( !$this->getF3()->get('AJAX') ){
+ $requestedCharacterId = (int)$_COOKIE['old_char_id'];
+ if(!$requestedCharacterId){
+ $tempCharacterData = (array)$this->getF3()->get(Api\User::SESSION_KEY_TEMP_CHARACTER_DATA);
+ if((int)$tempCharacterData['ID'] > 0){
+ $requestedCharacterId = (int)$tempCharacterData['ID'];
+ }
+ }
+ }
+
+ $character = $user->getSessionCharacter($requestedCharacterId, $ttl);
+ }
+
+ return $character;
+ }
+
+ /**
+ * get current character
+ * @param int $ttl
+ * @return Pathfinder\CharacterModel|null
+ * @throws \Exception
+ */
+ public function getCharacter(int $ttl = AbstractModel::DEFAULT_SQL_TTL) : ?Pathfinder\CharacterModel {
+ return $this->getSessionCharacter($ttl);
+ }
+
+ /**
+ * get current user
+ * @param int $ttl
+ * @return Pathfinder\UserModel|null
+ * @throws \Exception
+ */
+ public function getUser($ttl = AbstractModel::DEFAULT_SQL_TTL) : ?Pathfinder\UserModel {
+ $user = null;
+
+ if($this->getF3()->exists(Api\User::SESSION_KEY_USER_ID, $userId)){
+ /**
+ * @var $userModel Pathfinder\UserModel
+ */
+ $userModel = Pathfinder\AbstractPathfinderModel::getNew('UserModel');
+ $userModel->getById($userId, $ttl);
+
+ if(
+ !$userModel->dry() &&
+ $userModel->hasUserCharacters()
+ ){
+ $user = $userModel;
+ }
+ }
+
+ return $user;
+ }
+
+ /**
+ * set temp login character data (required during HTTP redirects on login)
+ * @param int $characterId
+ * @throws \Exception
+ */
+ protected function setTempCharacterData(int $characterId){
+ if($characterId > 0){
+ $tempCharacterData = [
+ 'ID' => $characterId
+ ];
+ $this->getF3()->set(Api\User::SESSION_KEY_TEMP_CHARACTER_DATA, $tempCharacterData);
+ }else{
+ throw new \Exception( sprintf(self::ERROR_TEMP_CHARACTER_ID, $characterId) );
+ }
+ }
+
+ /**
+ * log out current character or all active characters (multiple browser tabs)
+ * -> send response data to client
+ * @param \Base $f3
+ * @param bool $all
+ * @param bool $deleteSession
+ * @param bool $deleteLog
+ * @param bool $deleteCookie
+ * @param int $statusCode
+ * @throws \Exception
+ */
+ protected function logoutCharacter(
+ \Base $f3,
+ bool $all = false,
+ bool $deleteSession = true,
+ bool $deleteLog = true,
+ bool $deleteCookie = false,
+ int $statusCode = self::DEFAULT_STATUS_LOGOUT
+ ){
+ $sessionCharacterData = (array)$f3->get(Api\User::SESSION_KEY_CHARACTERS);
+
+ if($sessionCharacterData){
+ $activeCharacterId = ($activeCharacter = $this->getCharacter()) ? $activeCharacter->_id : 0;
+ /**
+ * @var $character Pathfinder\CharacterModel
+ */
+ $character = Pathfinder\AbstractPathfinderModel::getNew('CharacterModel');
+ $characterIds = [];
+ foreach($sessionCharacterData as $characterData){
+ if($characterData['ID'] === $activeCharacterId){
+ $characterIds[] = $activeCharacter->_id;
+ $activeCharacter->logout($deleteSession, $deleteLog, $deleteCookie);
+ }elseif($all){
+ $character->getById($characterData['ID']);
+ $characterIds[] = $character->_id;
+ $character->logout($deleteSession, $deleteLog, $deleteCookie);
+ }
+ $character->reset();
+ }
+
+ if($characterIds){
+ // broadcast logout information to webSocket server
+ $f3->webSocket()->write('characterLogout', $characterIds);
+ }
+ }
+
+ if($f3->get('AJAX')){
+ $f3->status($statusCode);
+
+ $return = (object) [];
+ $return->reroute = rtrim(self::getEnvironmentData('URL'), '/') . $f3->alias('login');
+ $return->error[] = $this->getErrorObject($statusCode, Config::getHttpStatusByCode($statusCode), 'Access denied: User not found');
+
+ echo json_encode($return);
+ }else{
+ // redirect to landing page
+ $f3->reroute(['login']);
+ }
+ }
+
+ /**
+ * get EVE server status from ESI
+ * @param \Base $f3
+ * @throws \Exception
+ */
+ public function getEveServerStatus(\Base $f3){
+ $ttl = 60;
+ $esiStatusVersion = 'latest';
+ $cacheKey = 'eve_server_status';
+
+ if(!$exists = $f3->exists($cacheKey, $return)){
+ $return = (object) [];
+ $return->error = [];
+
+ /**
+ * @var $client CcpClient
+ */
+ if($client = $f3->ccpClient()){
+ $return->server = [
+ 'name' => strtoupper(self::getEnvironmentData('CCP_ESI_DATASOURCE')),
+ 'status' => 'offline',
+ 'statusColor' => 'red',
+ ];
+ $return->api = [
+ 'name' => 'ESI API',
+ 'status' => 'offline',
+ 'statusColor' => 'red',
+ 'url' => $client->getUrl(),
+ 'timeout' => $client->getTimeout(),
+ 'connectTimeout' => $client->getConnectTimeout(),
+ 'readTimeout' => $client->getReadTimeout(),
+ 'proxy' => ($proxy = $client->getProxy()) ? : 'false',
+ 'verify' => $client->getVerify(),
+ 'debug' => $client->getDebugRequests(),
+ 'dataSource' => $client->getDataSource(),
+ 'statusVersion' => $esiStatusVersion,
+ 'routes' => []
+ ];
+
+ $serverStatus = $client->send('getServerStatus');
+ if( !isset($serverStatus['error']) ){
+ $statusData = $serverStatus['status'];
+ // calculate time diff since last server restart
+ $timezone = $f3->get('getTimeZone')();
+ $dateNow = new \DateTime('now', $timezone);
+ $dateServerStart = new \DateTime($statusData['startTime']);
+ $interval = $dateNow->diff($dateServerStart);
+ $startTimestampFormat = $interval->format('%hh %im');
+ if($interval->days > 0){
+ $startTimestampFormat = $interval->days . 'd ' . $startTimestampFormat;
+ }
+
+ $statusData['name'] = $return->server['name'];
+ $statusData['status'] = 'online';
+ $statusData['statusColor'] = 'green';
+ $statusData['startTime'] = $startTimestampFormat;
+ $return->server = $statusData;
+ }else{
+ $return->error[] = (new PathfinderException($serverStatus['error'], 500))->getError();
+ }
+
+ $apiStatus = $client->send('getStatus', 'latest', true);
+ if( !isset($apiStatus['error']) ){
+ // find top status
+ $status = 'OK';
+ $color = 'green';
+ foreach($apiStatus['status'] as &$statusData){
+ if('red' == $statusData['status']){
+ $status = 'unstable';
+ $color = $statusData['status'] = 'orange'; // red is already in use for fatal API errors (e.g. no response at all, or offline)
+ break;
+ }
+ if('yellow' == $statusData['status']){
+ $status = 'degraded';
+ $color = $statusData['status'];
+ }
+ }
+
+ $return->api['status'] = $status;
+ $return->api['statusColor'] = $color;
+ $return->api['routes'] = $apiStatus['status'];
+ }else{
+ $return->error[] = (new PathfinderException($apiStatus['error'], 500))->getError();
+ }
+
+ if(empty($return->error)){
+ $f3->set($cacheKey, $return, $ttl);
+ }
+ }
+ }
+
+ if(empty($return->error)){
+ $f3->expire(Config::ttlLeft($exists, $ttl));
+ }
+
+ echo json_encode($return);
+ }
+
+ /**
+ * @param int $code
+ * @param string $status
+ * @param string $text
+ * @param null $trace
+ * @return \stdClass
+ */
+ protected function getErrorObject(int $code, string $status = '', string $text = '', $trace = null) : \stdClass {
+ $object = (object) [];
+ $object->type = 'error';
+ $object->code = $code;
+ $object->status = empty($status) ? @constant('Base::HTTP_' . $code) : $status;
+ if(!empty($text)){
+ $object->text = $text;
+ }
+ if(!empty($trace)){
+ $object->trace = $trace;
+ }
+ return $object;
+ }
+
+ /**
+ * @param string $title
+ * @param string $text
+ * @param string $type
+ * @return \stdClass
+ */
+ protected function getNotificationObject(string $title, $text = '', $type = 'danger') : \stdClass {
+ $notification = (object) [];
+ $notification->type = in_array($type, self::NOTIFICATION_TYPES) ? $type : 'danger';
+ $notification->title = $title;
+ $notification->text = $text;
+ return $notification;
+ }
+
+ /**
+ * get a program URL by alias
+ * -> if no $alias given -> get "default" route (index.php)
+ * @param null $alias
+ * @return bool|string
+ */
+ protected function getRouteUrl($alias = null){
+ $url = false;
+
+ if(!empty($alias)){
+ // check given alias is a valid (registered) route
+ if(array_key_exists($alias, $this->getF3()->get('ALIASES'))){
+ $url = $this->getF3()->alias($alias);
+ }
+ }elseif($this->getF3()->get('ALIAS')){
+ // get current URL
+ $url = $this->getF3()->alias( $this->getF3()->get('ALIAS') );
+ }else{
+ // get main (index.php) URL
+ $url = $this->getF3()->alias('login');
+ }
+
+ return $url;
+ }
+
+ /**
+ * get a custom userAgent string for API calls
+ * @return string
+ */
+ protected function getUserAgent() : string {
+ $userAgent = '';
+ $userAgent .= Config::getPathfinderData('name');
+ $userAgent .= ' - ' . Config::getPathfinderData('version');
+ $userAgent .= ' | ' . Config::getPathfinderData('contact');
+ $userAgent .= ' (' . $_SERVER['SERVER_NAME'] . ')';
+
+ return $userAgent;
+ }
+
+ /**
+ * print error information in CLI mode
+ * @param \stdClass $error
+ */
+ protected function echoErrorCLI(\stdClass $error){
+ echo '[' . date('H:i:s') . '] ───────────────────────────' . PHP_EOL;
+ foreach(get_object_vars($error) as $key => $value){
+ $row = str_pad(' ',2 ) . str_pad($key . ':',10 );
+ if($key == 'trace'){
+ $value = preg_replace("/\r\n|\r|\n/", "\n" . str_pad(' ',12 ), $value);
+ $row .= PHP_EOL . str_pad(' ',12 ) . $value;
+ }else{
+ $row .= $value;
+ }
+ echo $row . PHP_EOL;
+ }
+ }
+
+ /**
+ * onError() callback function
+ * -> on AJAX request -> return JSON with error information
+ * -> on HTTP request -> render error page
+ * @param \Base $f3
+ * @return bool
+ */
+ public function showError(\Base $f3){
+
+ if(!headers_sent()){
+ // collect error info -------------------------------------------------------------------------------------
+ $errorData = $f3->get('ERROR');
+ $exception = $f3->get('EXCEPTION');
+
+ if($exception instanceof PathfinderException){
+ // ... handle Pathfinder exceptions (e.g. validation Exceptions,..)
+ $error = $exception->getError();
+ }else{
+ // ... handle error $f3->error() calls
+ $error = $this->getErrorObject(
+ $errorData['code'],
+ $errorData['status'],
+ $errorData['text'],
+ $f3->get('DEBUG') >= 1 ? $errorData['trace'] : null
+ );
+ }
+
+ // check if error is a PDO Exception ----------------------------------------------------------------------
+ if(strpos(strtolower( $f3->get('ERROR.text') ), 'duplicate') !== false){
+ preg_match_all('/\'([^\']+)\'/', $f3->get('ERROR.text'), $matches, PREG_SET_ORDER);
+
+ if(count($matches) === 2){
+ $error->field = $matches[1][1];
+ $error->text = 'Value "' . $matches[0][1] . '" already exists';
+ }
+ }
+
+ // set response status ------------------------------------------------------------------------------------
+ if(!empty($error->code)){
+ $f3->status($error->code);
+ }
+
+ if($f3->get('CLI')){
+ $this->echoErrorCLI($error);
+ // no further processing (no HTML output)
+ return false;
+ }elseif($f3->get('AJAX')){
+ $return = (object) [];
+ $return->error[] = $error;
+ echo json_encode($return);
+ }else{
+ // non AJAX (e.g. GET/POST)
+ // recursively clear existing output buffers
+ while(ob_get_level()){
+ ob_end_clean();
+ }
+
+ $f3->set('tplPageTitle', 'ERROR - ' . $error->code);
+ // set error data for template rendering
+ $error->redirectUrl = $this->getRouteUrl();
+ $f3->set('errorData', $error);
+
+ // 4xx/5xx error -> set error page template
+ if( preg_match('/^4[0-9]{2}$/', $error->code) ){
+ $f3->set('tplPageContent', Config::getPathfinderData('STATUS.4XX') );
+ }elseif( preg_match('/^5[0-9]{2}$/', $error->code) ){
+ $f3->set('tplPageContent', Config::getPathfinderData('STATUS.5XX'));
+ }
+
+ // stop script - die(); after this fkt is done
+ // -> unload() fkt is still called
+ $f3->set('HALT', true);
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Callback for framework "unload"
+ * -> this function is called on each request!
+ * -> configured in config.ini
+ * @param \Base $f3
+ * @return bool
+ */
+ public function unload(\Base $f3){
+ // store all user activities that are buffered for logging in this request
+ // this should work even on non HTTP200 responses
+ $this->logActivities();
+
+ return true;
+ }
+
+ /**
+ * store activity log data to DB
+ */
+ protected function logActivities(){
+ LogController::instance()->logActivities();
+ Monolog::instance()->log();
+ }
+
+ /**
+ * simple counter with "static" store
+ * -> called within tpl render
+ * @return \Closure
+ */
+ protected function counter() : \Closure {
+ $store = [];
+
+ return function(string $action = 'increment', string $type = 'default', $val = 0) use (&$store){
+ $return = null;
+ switch($action){
+ case 'increment': $store[$type]++; break;
+ case 'add': $store[$type] += (int)$val; break;
+ case 'get': $return = $store[$type] ? : null; break;
+ case 'reset': unset($store[$type]); break;
+ }
+ return $return;
+ };
+ }
+
+ /**
+ * get controller by class name
+ * -> controller class is searched within all controller directories
+ * @param $className
+ * @return null|Controller
+ * @throws \Exception
+ */
+ static function getController($className){
+ $controller = null;
+ // add subNamespaces for controller classes
+ $subNamespaces = ['Api', 'Ccp'];
+
+ for($i = 0; $i <= count($subNamespaces); $i++){
+ $path = [__NAMESPACE__];
+ $path[] = ( isset($subNamespaces[$i - 1]) ) ? $subNamespaces[$i - 1] : '';
+ $path[] = $className;
+ $classPath = implode('\\', array_filter($path));
+
+ if(class_exists($classPath)){
+ $controller = new $classPath();
+ break;
+ }
+ }
+
+ if( is_null($controller) ){
+ throw new \Exception( sprintf('Controller class "%s" not found!', $className) );
+ }
+
+ return $controller;
+ }
+
+
+ /**
+ * get scope array by a "role"
+ * @param string $authType
+ * @return array
+ */
+ static function getScopesByAuthType(string $authType = '') : array {
+ $scopes = array_filter((array)self::getEnvironmentData('CCP_ESI_SCOPES'));
+ switch($authType){
+ case 'admin':
+ $scopesAdmin = array_filter((array)self::getEnvironmentData('CCP_ESI_SCOPES_ADMIN'));
+ $scopes = array_merge($scopes, $scopesAdmin);
+ break;
+ }
+ sort($scopes);
+ return $scopes;
+ }
+
+ /**
+ * Helper function to return all headers because
+ * getallheaders() is not available under nginx
+ * @return array (string $key -> string $value)
+ */
+ static function getRequestHeaders() : array {
+ $headers = [];
+ $headerPrefix = 'http_';
+ $prefixLength = mb_strlen($headerPrefix);
+ $serverData = self::getServerData();
+
+ if(
+ function_exists('apache_request_headers') &&
+ $serverData->type === 'apache'
+ ){
+ // Apache WebServer
+ $headers = apache_request_headers();
+ }else{
+ // Other WebServer, e.g. Nginx
+ // Unfortunately this "fallback" does not work for me (Apache)
+ // Therefore we can´t use this for all servers
+ // https://github.com/exodus4d/pathfinder/issues/58
+ foreach($_SERVER as $name => $value){
+ $name = mb_strtolower($name);
+ if(mb_substr($name, 0, $prefixLength) == $headerPrefix){
+ $headers[mb_convert_case(str_replace('_', '-', mb_substr($name, $prefixLength)), MB_CASE_TITLE)] = $value;
+ }
+ }
+ }
+
+ return $headers;
+ }
+
+ /**
+ * get some server information
+ * @param int $ttl cache time (default: 1h)
+ * @return \stdClass
+ */
+ static function getServerData($ttl = 3600){
+ $f3 = \Base::instance();
+ $cacheKey = 'PF_SERVER_INFO';
+
+ if( !$f3->exists($cacheKey) ){
+ $serverData = (object) [];
+ $serverData->type = 'unknown';
+ $serverData->version = 'unknown';
+ $serverData->requiredVersion = 'unknown';
+ $serverData->phpInterfaceType = php_sapi_name();
+
+ if(strpos(strtolower($_SERVER['SERVER_SOFTWARE']), 'nginx' ) !== false){
+ // Nginx server
+ $serverSoftwareArgs = explode('/', strtolower( $_SERVER['SERVER_SOFTWARE']) );
+ $serverData->type = reset($serverSoftwareArgs);
+ $serverData->version = end($serverSoftwareArgs);
+ $serverData->requiredVersion = $f3->get('REQUIREMENTS.SERVER.NGINX.VERSION');
+ }elseif(strpos(strtolower($_SERVER['SERVER_SOFTWARE']), 'apache' ) !== false){
+ // Apache server
+ $serverData->type = 'apache';
+ $serverData->requiredVersion = $f3->get('REQUIREMENTS.SERVER.APACHE.VERSION');
+
+ // try to get the apache version...
+ if(function_exists('apache_get_version')){
+ // function does not exists if PHP is running as CGI/FPM module!
+ $matches = preg_split('/[\s,\/ ]+/', strtolower( apache_get_version() ) );
+ if(count($matches) > 1){
+ $serverData->version = $matches[1];
+ }
+ }
+ }
+
+ // cache data for one day
+ $f3->set($cacheKey, $serverData, $ttl);
+ }
+
+ return $f3->get($cacheKey);
+ }
+
+ /**
+ * get the current registration status
+ * 0=registration stop |1=new registration allowed
+ * @return int
+ */
+ static function getRegistrationStatus(){
+ return (int)Config::getPathfinderData('registration.status');
+ }
+
+ /**
+ * get a Logger object by Hive key
+ * -> set in pathfinder.ini
+ * @param string $type
+ * @return \Log
+ */
+ static function getLogger($type = 'DEBUG') : \Log {
+ return LogController::getLogger($type);
+ }
+
+ /**
+ * removes illegal characters from a Hive-key that are not allowed
+ * @param $key
+ * @return string
+ */
+ static function formatHiveKey($key) : string {
+ $illegalCharacters = ['-', ' '];
+ return strtolower(str_replace($illegalCharacters, '', $key));
+ }
+
+ /**
+ * get environment specific configuration data
+ * @param string $key
+ * @return string|array|null
+ */
+ static function getEnvironmentData($key){
+ return Config::getEnvironmentData($key);
+ }
+
+}
\ No newline at end of file
diff --git a/app/Controller/LogController.php b/app/Controller/LogController.php
new file mode 100644
index 000000000..b17044fa2
--- /dev/null
+++ b/app/Controller/LogController.php
@@ -0,0 +1,173 @@
+ this buffered data can be stored somewhere (e.g. DB) before HTTP response
+ * -> should be cleared afterwards!
+ * @var array
+ */
+ protected $activityLogBuffer = [];
+
+ /**
+ * get columns from ActivityLogModel that can be uses as counter
+ * @return array
+ * @throws \Exception
+ */
+ protected function getActivityLogColumns(): array{
+ if(empty($this->activityLogColumns)){
+ $f3 = \Base::instance();
+ if(!$f3->exists(self::CACHE_KEY_ACTIVITY_COLUMNS, $this->activityLogColumns)){
+ /**
+ * @var $activityLogModel Pathfinder\ActivityLogModel
+ */
+ $activityLogModel = Pathfinder\AbstractPathfinderModel::getNew('ActivityLogModel');
+ $this->activityLogColumns = $activityLogModel->getCountableColumnNames();
+ $f3->set(self::CACHE_KEY_ACTIVITY_COLUMNS, self::CACHE_TTL_ACTIVITY_COLUMNS);
+ }
+ }
+
+ return $this->activityLogColumns;
+ }
+
+ /**
+ * buffered activity log data for this singleton LogController() class
+ * -> this buffered data can be stored somewhere (e.g. DB) before HTTP response
+ * -> should be cleared afterwards!
+ * @param MapLog $log
+ * @throws \Exception
+ */
+ public function push(MapLog $log){
+ $action = $log->getAction();
+
+ // check $action to be valid (table column exists)
+ if($action && in_array($action, $this->getActivityLogColumns())){
+ if($mapId = $log->getChannelId()){
+ $logData = $log->getData();
+ if($characterId = (int)$logData['character']['id']){
+ if($index = $this->getBufferedActivityIndex($characterId, $mapId)){
+ $this->activityLogBuffer[$index][$action]++;
+ }else{
+ $this->activityLogBuffer[] = [
+ 'characterId' => $characterId,
+ 'mapId' => $mapId,
+ $action => 1
+ ];
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * store all buffered activity log data to DB
+ */
+ public function logActivities(){
+ if( !empty($this->activityLogBuffer) ){
+ $db = \Base::instance()->DB->getDB('PF');
+
+ $quoteStr = function($str) use ($db) {
+ return $db->quotekey($str);
+ };
+
+ $placeholderStr = function($str){
+ return ':' . $str;
+ };
+
+ $updateRule = function($str){
+ return $str . " = " . $str . " + VALUES(" . $str . ")";
+ };
+
+ $year = (int)date('o');
+ $yearWeek = (int)date('W');
+ $db->begin();
+
+ foreach($this->activityLogBuffer as $activityData){
+ $activityData['year'] = $year;
+ $activityData['week'] = $yearWeek;
+
+ $columns = array_keys($activityData);
+ $columnsQuoted = array_map($quoteStr, $columns);
+ $placeholder = array_map($placeholderStr, $columns);
+ $args = array_combine($placeholder, $activityData);
+
+ // "filter" columns that can be updated
+ $columnsForUpdate = array_diff($columns, ['year', 'week', 'characterId', 'mapId']);
+ $updateSql = array_map($updateRule, $columnsForUpdate);
+
+ $sql = "INSERT DELAYED INTO
+ activity_log (" . implode(', ', $columnsQuoted) . ") VALUES(
+ " . implode(', ', $placeholder) . "
+ )
+ ON DUPLICATE KEY UPDATE
+ updated = NOW(),
+ " . implode(', ', $updateSql) . "
+ ";
+
+ $db->exec($sql, $args);
+ }
+
+ $db->commit();
+
+ // clear activity data for this instance
+ $this->activityLogBuffer = [];
+ }
+ }
+
+ /**
+ * get array key/index from "buffered activity log" array
+ * @param int $characterId
+ * @param int $mapId
+ * @return int
+ */
+ private function getBufferedActivityIndex(int $characterId, int $mapId): int {
+ $activityKey = 0;
+ if($characterId > 0 && $mapId > 0 ){
+ foreach($this->activityLogBuffer as $key => $activityData){
+ if(
+ $activityData['characterId'] === $characterId &&
+ $activityData['mapId'] === $mapId
+ ){
+ $activityKey = (int)$key;
+ break;
+ }
+ }
+ }
+
+ return $activityKey;
+ }
+
+ /**
+ * get Logger instance
+ * @param string $type
+ * @return \Log
+ */
+ public static function getLogger(string $type) : \Log {
+ $logFiles = Config::getPathfinderData('logfiles');
+ $logFileName = empty($logFiles[$type]) ? 'error' : $logFiles[$type];
+ return new \Log($logFileName . '.log');
+ }
+
+}
\ No newline at end of file
diff --git a/app/Controller/MapController.php b/app/Controller/MapController.php
new file mode 100644
index 000000000..fcab21e5f
--- /dev/null
+++ b/app/Controller/MapController.php
@@ -0,0 +1,40 @@
+register('script', 'app/mappage');
+
+ $character = $this->getCharacter();
+
+ // characterId
+ $f3->set('tplCharacterId', $character->id);
+
+ // page title
+ $f3->set('tplPageTitle', $character->name . ' | ' . Config::getPathfinderData('name'));
+
+ // main page content
+ $f3->set('tplPageContent', false);
+
+ // JS main file
+ $f3->set('tplJsView', 'mappage');
+ }
+
+}
\ No newline at end of file
diff --git a/app/Controller/Setup.php b/app/Controller/Setup.php
new file mode 100644
index 000000000..07ebd3faa
--- /dev/null
+++ b/app/Controller/Setup.php
@@ -0,0 +1,2010 @@
+ [],
+ 'BASE' => ['missingOk' => true],
+ 'URL' => [],
+ 'DEBUG' => [],
+ 'DB_PF_DNS' => [],
+ 'DB_PF_NAME' => [],
+ 'DB_PF_USER' => [],
+ 'DB_PF_PASS' => [],
+ 'DB_UNIVERSE_DNS' => [],
+ 'DB_UNIVERSE_NAME' => [],
+ 'DB_UNIVERSE_USER' => [],
+ 'DB_UNIVERSE_PASS' => [],
+ 'CCP_SSO_URL' => [],
+ 'CCP_SSO_CLIENT_ID' => [],
+ 'CCP_SSO_SECRET_KEY' => [],
+ 'CCP_SSO_DOWNTIME' => [],
+ 'CCP_ESI_URL' => [],
+ 'CCP_ESI_DATASOURCE' => [],
+ 'SMTP_HOST' => [],
+ 'SMTP_PORT' => [],
+ 'SMTP_SCHEME' => [],
+ 'SMTP_USER' => [],
+ 'SMTP_PASS' => [],
+ 'SMTP_FROM' => [],
+ 'SMTP_ERROR' => []
+ ];
+
+ /**
+ * required database setup
+ * @var array
+ */
+ protected $databases = [
+ 'PF' => [
+ 'info' => [],
+ 'models' => [
+ 'Model\Pathfinder\CronModel',
+ 'Model\Pathfinder\UserModel',
+ 'Model\Pathfinder\AllianceModel',
+ 'Model\Pathfinder\CorporationModel',
+ 'Model\Pathfinder\MapModel',
+ 'Model\Pathfinder\MapScopeModel',
+ 'Model\Pathfinder\MapTypeModel',
+ 'Model\Pathfinder\SystemTypeModel',
+ 'Model\Pathfinder\SystemStatusModel',
+ 'Model\Pathfinder\RightModel',
+ 'Model\Pathfinder\RoleModel',
+ 'Model\Pathfinder\StructureModel',
+
+ 'Model\Pathfinder\CharacterStatusModel',
+ 'Model\Pathfinder\ConnectionScopeModel',
+ 'Model\Pathfinder\StructureStatusModel',
+
+ 'Model\Pathfinder\CharacterMapModel',
+ 'Model\Pathfinder\AllianceMapModel',
+ 'Model\Pathfinder\CorporationMapModel',
+
+ 'Model\Pathfinder\CorporationRightModel',
+ 'Model\Pathfinder\CorporationStructureModel',
+
+ 'Model\Pathfinder\UserCharacterModel',
+ 'Model\Pathfinder\CharacterModel',
+ 'Model\Pathfinder\CharacterAuthenticationModel',
+ 'Model\Pathfinder\CharacterLogModel',
+
+ 'Model\Pathfinder\SystemModel',
+
+ 'Model\Pathfinder\ConnectionModel',
+ 'Model\Pathfinder\ConnectionLogModel',
+ 'Model\Pathfinder\SystemSignatureModel',
+
+ 'Model\Pathfinder\ActivityLogModel',
+
+ 'Model\Pathfinder\SystemShipKillModel',
+ 'Model\Pathfinder\SystemPodKillModel',
+ 'Model\Pathfinder\SystemFactionKillModel',
+ 'Model\Pathfinder\SystemJumpModel'
+ ]
+ ],
+ 'UNIVERSE' => [
+ 'info' => [],
+ 'models' => [
+ 'Model\Universe\DogmaAttributeModel',
+ 'Model\Universe\TypeAttributeModel',
+ 'Model\Universe\TypeModel',
+ 'Model\Universe\GroupModel',
+ 'Model\Universe\CategoryModel',
+ 'Model\Universe\FactionModel',
+ 'Model\Universe\AllianceModel',
+ 'Model\Universe\CorporationModel',
+ 'Model\Universe\RaceModel',
+ 'Model\Universe\StationModel',
+ 'Model\Universe\StructureModel',
+ 'Model\Universe\StargateModel',
+ 'Model\Universe\StarModel',
+ 'Model\Universe\PlanetModel',
+ 'Model\Universe\SystemModel',
+ 'Model\Universe\ConstellationModel',
+ 'Model\Universe\RegionModel',
+ 'Model\Universe\SystemNeighbourModel',
+ 'Model\Universe\SystemStaticModel',
+ 'Model\Universe\SovereigntyMapModel',
+ 'Model\Universe\FactionWarSystemModel'
+ ]
+ ]
+ ];
+
+ /**
+ * database error
+ * @var bool
+ */
+ protected $databaseHasError = false;
+
+ /**
+ * event handler for all "views"
+ * some global template variables are set in here
+ * @param \Base $f3
+ * @param array $params
+ * @return bool
+ */
+ function beforeroute(\Base $f3, $params): bool {
+ $f3->set('tplResource', $this->initResource($f3));
+
+ // page title
+ $f3->set('tplPageTitle', 'Setup | ' . Config::getPathfinderData('name'));
+
+ // main page content
+ $f3->set('tplPageContent', Config::getPathfinderData('view.setup'));
+
+ // body element class
+ $f3->set('tplBodyClass', 'pf-landing');
+
+ // top navigation configuration
+ $f3->set('tplNavigation', $this->getNavigationConfig());
+
+ return true;
+ }
+
+ /**
+ * @param \Base $f3
+ */
+ public function afterroute(\Base $f3) {
+ // js view (file)
+ $f3->set('tplJsView', 'setup');
+
+ $f3->set('tplCounter', $this->counter());
+
+ $f3->set('tplConvertBytes', function(){
+ return call_user_func_array([Number::instance(), 'bytesToString'], func_get_args());
+ });
+
+ // render view
+ echo \Template::instance()->render( Config::getPathfinderData('view.index') );
+ }
+
+ /**
+ * main setup route handler
+ * works as dispatcher for setup functions
+ * -> for security reasons all /setup "routes" are dispatched by GET params
+ * @param \Base $f3
+ * @throws \Exception
+ */
+ public function init(\Base $f3){
+ $params = $f3->get('GET');
+
+ // enables automatic column fix
+ $fixColumns = false;
+
+ switch($params['action']){
+ case 'createDB':
+ $this->createDB($f3, $params['db']);
+ break;
+ case 'bootstrapDB':
+ $this->bootstrapDB($f3, $params['db']);
+ break;
+ case 'fixCols':
+ $fixColumns = true;
+ break;
+ case 'importTable':
+ $this->importTable($params['model']);
+ break;
+ case 'exportTable':
+ $this->exportTable($params['model']);
+ break;
+ case 'clearFiles':
+ $this->clearFiles((string)$params['path']);
+ break;
+ case 'flushRedisDb':
+ $this->flushRedisDb((string)$params['host'], (int)$params['port'], (int)$params['db']);
+ break;
+ case 'invalidateCookies':
+ $this->invalidateCookies($f3);
+ break;
+ }
+
+ // ============================================================================================================
+ // Template data
+ // ============================================================================================================
+
+ // Server -----------------------------------------------------------------------------------------------------
+ // Server information
+ $f3->set('serverInformation', $this->getServerInformation($f3));
+
+ // Pathfinder directory config
+ $f3->set('directoryConfig', $this->getDirectoryConfig($f3));
+
+ // Server environment variables
+ $f3->set('checkSystemConfig', $this->checkSystemConfig($f3));
+
+ // Environment ------------------------------------------------------------------------------------------------
+ // Server requirement
+ $f3->set('checkRequirements', $this->checkRequirements($f3));
+
+ // PHP config
+ $f3->set('checkPHPConfig', $this->checkPHPConfig($f3));
+
+ // Settings ---------------------------------------------------------------------------------------------------
+ // Pathfinder environment config
+ $f3->set('environmentInformation', $this->getEnvironmentInformation($f3));
+
+ // Pathfinder map default config
+ $f3->set('mapsDefaultConfig', $this->getMapsDefaultConfig($f3));
+
+ // Database ---------------------------------------------------------------------------------------------------
+ // Database config
+ $f3->set('checkDatabase', $this->checkDatabase($f3, $fixColumns));
+
+ // Redis ------------------------------------------------------------------------------------------------------
+ // Redis information
+ $f3->set('checkRedisInformation', $this->checkRedisInformation($f3));
+
+ // Socket -----------------------------------------------------------------------------------------------------
+ // WebSocket information
+ $f3->set('socketInformation', $this->getSocketInformation($f3));
+
+ // Cronjob ----------------------------------------------------------------------------------------------------
+ $f3->set('cronConfig', $this->getCronConfig($f3));
+
+ // Administration ---------------------------------------------------------------------------------------------
+ // Index information
+ $f3->set('indexInformation', $this->getIndexData($f3));
+
+ // Filesystem (cache) size
+ $f3->set('checkDirSize', $this->checkDirSize($f3));
+ }
+
+ /**
+ * get top navigation configuration
+ * @return array
+ */
+ protected function getNavigationConfig() : array {
+ return [
+ 'server' => [
+ 'icon' => 'fa-home'
+ ],
+ 'environment' => [
+ 'icon' => 'fa-server'
+ ],
+ 'settings' => [
+ 'icon' => 'fa-sliders-h'
+ ],
+ 'database' => [
+ 'icon' => 'fa-database'
+ ],
+ 'cache' => [
+ 'icon' => 'fa-hdd'
+ ],
+ 'socket' => [
+ 'icon' => 'fa-exchange-alt'
+ ],
+ 'cronjob' => [
+ 'icon' => 'fa-user-clock'
+ ],
+ 'administration' => [
+ 'icon' => 'fa-wrench'
+ ]
+ ];
+ }
+
+ /**
+ * set environment information
+ * @param \Base $f3
+ * @return array
+ */
+ protected function getEnvironmentInformation(\Base $f3) : array {
+ $environmentData = [];
+ // exclude some sensitive data (e.g. database, passwords)
+ $excludeVars = [
+ 'DB_PF_DNS', 'DB_PF_NAME', 'DB_PF_USER', 'DB_PF_PASS',
+ 'DB_UNIVERSE_DNS', 'DB_UNIVERSE_NAME', 'DB_UNIVERSE_USER', 'DB_UNIVERSE_PASS'
+ ];
+
+ // obscure some values
+ $obscureVars = ['CCP_SSO_CLIENT_ID', 'CCP_SSO_SECRET_KEY', 'SMTP_PASS'];
+
+ foreach($this->environmentVars as $var => $options){
+ if( !in_array($var, $excludeVars) ){
+ $value = Config::getEnvironmentData($var);
+ $check = true;
+
+ if(is_null($value) && !array_key_exists('missingOk', $options)){
+ // variable missing
+ $check = false;
+ $value = '[missing]';
+ }elseif( in_array($var, $obscureVars)){
+ $value = Util::obscureString($value);
+ }
+
+ $environmentData[$var] = [
+ 'label' => $var,
+ 'value' => ((empty($value) && !is_int($value)) ? ' ' : $value),
+ 'check' => $check
+ ];
+ }
+ }
+
+ return $environmentData;
+ }
+
+ /**
+ * get server information
+ * @param \Base $f3
+ * @return array
+ */
+ protected function getServerInformation(\Base $f3) : array {
+ return [
+ 'time' => [
+ 'label' => 'Time',
+ 'value' => date('Y/m/d H:i:s') . ' - (' . $f3->get('TZ') . ')'
+ ],
+ 'os' => [
+ 'label' => 'OS',
+ 'value' => function_exists('php_uname') ? php_uname('s') : $_SERVER['OS']
+ ],
+ 'name' => [
+ 'label' => 'Host name',
+ 'value' => function_exists('php_uname') ? php_uname('n') : $_SERVER['SERVER_NAME']
+ ],
+ 'release' => [
+ 'label' => 'Release name',
+ 'value' => function_exists('php_uname') ? php_uname('r') : 'unknown'
+ ],
+ 'version' => [
+ 'label' => 'Version info',
+ 'value' => function_exists('php_uname') ? php_uname('v') : 'unknown'
+ ],
+ 'machine' => [
+ 'label' => 'Machine type',
+ 'value' => function_exists('php_uname') ? php_uname('m') : $_SERVER['PROCESSOR_ARCHITECTURE']
+ ],
+ 'root' => [
+ 'label' => 'Document root',
+ 'value' => $f3->get('ROOT')
+ ],
+ 'port' => [
+ 'label' => 'Port',
+ 'value' => $f3->get('PORT')
+ ],
+ 'protocol' => [
+ 'label' => 'Protocol - scheme',
+ 'value' => $f3->get('SERVER.SERVER_PROTOCOL') . ' - ' . $f3->get('SCHEME')
+ ]
+ ];
+ }
+
+ /**
+ * get information for used directories
+ * @param \Base $f3
+ * @return array
+ */
+ protected function getDirectoryConfig(\Base $f3) : array {
+ return [
+ 'TEMP' => [
+ 'label' => 'TEMP',
+ 'value' => $f3->get('TEMP'),
+ 'check' => true,
+ 'tooltip' => 'Temporary folder for pre compiled templates.',
+ 'chmod' => Util::filesystemInfo($f3->get('TEMP'))['chmod']
+ ],
+ 'CACHE' => [
+ 'label' => 'CACHE',
+ 'value' => $f3->get('CACHE'),
+ 'check' => true,
+ 'tooltip' => 'Cache backend. Support for Redis, Memcache, APC, WinCache, XCache and a filesystem-based (default) cache.',
+ 'chmod' => ((Config::parseDSN($f3->get('CACHE'), $confCache)) && $confCache['type'] == 'folder') ?
+ Util::filesystemInfo((string)$confCache['folder'])['chmod'] : ''
+ ],
+ 'API_CACHE' => [
+ 'label' => 'API_CACHE',
+ 'value' => $f3->get('API_CACHE'),
+ 'check' => true,
+ 'tooltip' => 'Cache backend for API related cache data. Support for Redis and a filesystem-based (default) cache.',
+ 'chmod' => ((Config::parseDSN($f3->get('API_CACHE'), $confCacheApi)) && $confCacheApi['type'] == 'folder') ?
+ Util::filesystemInfo((string)$confCacheApi['folder'])['chmod'] : ''
+ ],
+ 'LOGS' => [
+ 'label' => 'LOGS',
+ 'value' => $f3->get('LOGS'),
+ 'check' => true,
+ 'tooltip' => 'Folder for pathfinder logs (e.g. cronjob-, error-logs, ...).',
+ 'chmod' => Util::filesystemInfo($f3->get('LOGS'))['chmod']
+ ],
+ 'UI' => [
+ 'label' => 'UI',
+ 'value' => $f3->get('UI'),
+ 'check' => true,
+ 'tooltip' => 'Folder for public accessible resources (templates, js, css, images,..).',
+ 'chmod' => Util::filesystemInfo($f3->get('UI'))['chmod']
+ ],
+ 'AUTOLOAD' => [
+ 'label' => 'AUTOLOAD',
+ 'value' => $f3->get('AUTOLOAD'),
+ 'check' => true,
+ 'tooltip' => 'Autoload folder for PHP files.',
+ 'chmod' => Util::filesystemInfo($f3->get('AUTOLOAD'))['chmod']
+ ],
+ 'FAVICON' => [
+ 'label' => 'FAVICON',
+ 'value' => $f3->get('FAVICON'),
+ 'check' => true,
+ 'tooltip' => 'Folder for Favicons.',
+ 'chmod' => Util::filesystemInfo($f3->get('FAVICON'))['chmod']
+ ],
+ 'HISTORY' => [
+ 'label' => 'HISTORY [optional]',
+ 'value' => Config::getPathfinderData('history.log'),
+ 'check' => true,
+ 'tooltip' => 'Folder for log history files. (e.g. change logs for maps).',
+ 'chmod' => Util::filesystemInfo(Config::getPathfinderData('history.log'))['chmod']
+ ],
+ 'CONFIG' => [
+ 'label' => 'CONFIG PATH [optional]',
+ 'value' => implode(' ', (array)$f3->get('CONF')),
+ 'check' => true,
+ 'tooltip' => 'Folder for custom *.ini files. (e.g. when overwriting of default values in app/*.ini)'
+ ]
+ ];
+ }
+
+ /**
+ * check all required backend requirements
+ * (Fat Free Framework)
+ * @param \Base $f3
+ * @return array
+ */
+ protected function checkRequirements(\Base $f3) : array {
+
+ $serverData = self::getServerData(0);
+
+ $checkRequirements = [
+ 'serverType' => [
+ 'label' => 'Server type',
+ 'version' => $serverData->type,
+ 'check' => true
+ ],
+ 'serverVersion' => [
+ 'label' => 'Server version',
+ 'required' => $serverData->requiredVersion,
+ 'version' => $serverData->version,
+ 'check' => version_compare( $serverData->version, $serverData->requiredVersion, '>='),
+ 'tooltip' => 'If not specified, please check your \'ServerTokens\' server config. (not critical)'
+ ],
+ 'phpInterface' => [
+ 'label' => 'PHP interface type',
+ 'version' => $serverData->phpInterfaceType,
+ 'check' => empty($serverData->phpInterfaceType) ? false : true
+ ],
+ 'php' => [
+ 'label' => 'PHP',
+ 'required' => number_format((float)$f3->get('REQUIREMENTS.PHP.VERSION'), 1, '.', ''),
+ 'version' => phpversion(),
+ 'check' => version_compare( phpversion(), $f3->get('REQUIREMENTS.PHP.VERSION'), '>=')
+ ],
+ 'php_bit' => [
+ 'label' => 'php_int_size',
+ 'required' => ($f3->get('REQUIREMENTS.PHP.PHP_INT_SIZE') * 8 ) . '-bit',
+ 'version' => (PHP_INT_SIZE * 8) . '-bit',
+ 'check' => $f3->get('REQUIREMENTS.PHP.PHP_INT_SIZE') == PHP_INT_SIZE
+ ],
+ [
+ 'label' => 'PHP extensions'
+ ],
+ 'pcre' => [
+ 'label' => 'PCRE',
+ 'required' => $f3->get('REQUIREMENTS.PHP.PCRE_VERSION'),
+ 'version' => strstr(PCRE_VERSION, ' ', true),
+ 'check' => version_compare( strstr(PCRE_VERSION, ' ', true), $f3->get('REQUIREMENTS.PHP.PCRE_VERSION'), '>=')
+ ],
+ 'ext_pdo' => [
+ 'label' => 'PDO',
+ 'required' => 'installed',
+ 'version' => extension_loaded('pdo') ? 'installed' : 'missing',
+ 'check' => extension_loaded('pdo')
+ ],
+ 'ext_pdoMysql' => [
+ 'label' => 'PDO_MYSQL',
+ 'required' => 'installed',
+ 'version' => extension_loaded('pdo_mysql') ? 'installed' : 'missing',
+ 'check' => extension_loaded('pdo_mysql')
+ ],
+ 'ext_openssl' => [
+ 'label' => 'OpenSSL',
+ 'required' => 'installed',
+ 'version' => extension_loaded('openssl') ? 'installed' : 'missing',
+ 'check' => extension_loaded('openssl')
+ ],
+ 'ext_xml' => [
+ 'label' => 'XML',
+ 'required' => 'installed',
+ 'version' => extension_loaded('xml') ? 'installed' : 'missing',
+ 'check' => extension_loaded('xml')
+ ],
+ 'ext_gd' => [
+ 'label' => 'GD Library (for Image plugin)',
+ 'required' => 'installed',
+ 'version' => (extension_loaded('gd') && function_exists('gd_info')) ? 'installed' : 'missing',
+ 'check' => (extension_loaded('gd') && function_exists('gd_info'))
+ ],
+ 'ext_curl' => [
+ 'label' => 'cURL (for Web plugin)',
+ 'required' => 'installed',
+ 'version' => (extension_loaded('curl') && function_exists('curl_version')) ? 'installed' : 'missing',
+ 'check' => (extension_loaded('curl') && function_exists('curl_version'))
+ ],
+ 'ext_redis' => [
+ 'label' => 'Redis [optional]',
+ 'required' => $f3->get('REQUIREMENTS.PHP.REDIS'),
+ 'version' => extension_loaded('redis') ? phpversion('redis') : 'missing',
+ 'check' => version_compare( phpversion('redis'), $f3->get('REQUIREMENTS.PHP.REDIS'), '>='),
+ 'tooltip' => 'Redis can replace the default file-caching mechanic. It is much faster!'
+ ],
+ [
+ 'label' => 'LibEvent library [optional]'
+ ],
+ 'ext_event' => [
+ 'label' => 'Event extension',
+ 'required' => $f3->get('REQUIREMENTS.PHP.EVENT'),
+ 'version' => extension_loaded('event') ? phpversion('event') : 'missing',
+ 'check' => version_compare( phpversion('event'), $f3->get('REQUIREMENTS.PHP.EVENT'), '>='),
+ 'tooltip' => 'LibEvent PHP extension. Optional performance boost for WebSocket configuration.'
+ ]
+ ];
+
+ if($serverData->type != 'nginx'){
+ // default msg if module status not available
+ $modNotFoundMsg = 'Module status can not be identified. '
+ . 'This can happen if PHP runs as \'FastCGI\'. Please check manual! ';
+
+ // mod_rewrite check --------------------------------------------------------------------------------------
+ $modRewriteCheck = false;
+ $modRewriteVersion = 'disabled';
+ $modRewriteTooltip = false;
+ if(function_exists('apache_get_modules')){
+ if(in_array('mod_rewrite',apache_get_modules())){
+ $modRewriteCheck = true;
+ $modRewriteVersion = 'enabled';
+ }
+ }else{
+ // e.g. Nginx server
+ $modRewriteVersion = 'unknown';
+ $modRewriteTooltip = $modNotFoundMsg;
+ }
+
+ $checkRequirements['mod_rewrite'] = [
+ 'label' => 'mod_rewrite',
+ 'required' => 'enabled',
+ 'version' => $modRewriteVersion,
+ 'check' => $modRewriteCheck,
+ 'tooltip' => $modRewriteTooltip
+ ];
+
+ // mod_headers check --------------------------------------------------------------------------------------
+ $modHeadersCheck = false;
+ $modHeadersVersion = 'disabled';
+ $modHeadersTooltip = false;
+ if(function_exists('apache_get_modules')){
+ if(in_array('mod_headers',apache_get_modules())){
+ $modHeadersCheck = true;
+ $modHeadersVersion = 'enabled';
+ }
+ }else{
+ // e.g. Nginx server
+ $modHeadersVersion = 'unknown';
+ $modHeadersTooltip = $modNotFoundMsg;
+ }
+
+ $checkRequirements['mod_headers'] = [
+ 'label' => 'mod_headers',
+ 'required' => 'enabled',
+ 'version' => $modHeadersVersion,
+ 'check' => $modHeadersCheck,
+ 'tooltip' => $modHeadersTooltip
+ ];
+ }
+
+ return $checkRequirements;
+ }
+
+ /**
+ * check PHP config (php.ini)
+ * @param \Base $f3
+ * @return array
+ */
+ protected function checkPHPConfig(\Base $f3): array {
+ $memoryLimit = (int)ini_get('memory_limit');
+ $maxInputVars = (int)ini_get('max_input_vars');
+ $maxExecutionTime = (int)ini_get('max_execution_time'); // 0 == infinite
+ $htmlErrors = (int)ini_get('html_errors');
+
+ return [
+ 'exec' => [
+ 'label' => 'exec()',
+ 'required' => $f3->get('REQUIREMENTS.PHP.EXEC'),
+ 'version' => function_exists('exec'),
+ 'check' => function_exists('exec') == $f3->get('REQUIREMENTS.PHP.EXEC'),
+ 'tooltip' => 'exec() funktion. Check "disable_functions" in php.ini'
+ ],
+ 'memoryLimit' => [
+ 'label' => 'memory_limit',
+ 'required' => $f3->get('REQUIREMENTS.PHP.MEMORY_LIMIT'),
+ 'version' => $memoryLimit,
+ 'check' => $memoryLimit >= $f3->get('REQUIREMENTS.PHP.MEMORY_LIMIT'),
+ 'tooltip' => 'PHP default = 64MB.'
+ ],
+ 'maxInputVars' => [
+ 'label' => 'max_input_vars',
+ 'required' => $f3->get('REQUIREMENTS.PHP.MAX_INPUT_VARS'),
+ 'version' => $maxInputVars,
+ 'check' => $maxInputVars >= $f3->get('REQUIREMENTS.PHP.MAX_INPUT_VARS'),
+ 'tooltip' => 'PHP default = 1000. Increase it in order to import larger maps.'
+ ],
+ 'maxExecutionTime' => [
+ 'label' => 'max_execution_time',
+ 'required' => $f3->get('REQUIREMENTS.PHP.MAX_EXECUTION_TIME'),
+ 'version' => $maxExecutionTime,
+ 'check' => !$maxExecutionTime || $maxExecutionTime >= $f3->get('REQUIREMENTS.PHP.MAX_EXECUTION_TIME'),
+ 'tooltip' => 'PHP default = 30. Max execution time for PHP scripts.'
+ ],
+ 'htmlErrors' => [
+ 'label' => 'html_errors',
+ 'required' => $f3->get('REQUIREMENTS.PHP.HTML_ERRORS'),
+ 'version' => $htmlErrors,
+ 'check' => (bool)$htmlErrors == (bool)$f3->get('REQUIREMENTS.PHP.HTML_ERRORS'),
+ 'tooltip' => 'Formatted HTML StackTrace on error.'
+ ],
+ [
+ 'label' => 'Session'
+ ],
+ 'sessionSaveHandler' => [
+ 'label' => 'save_handler',
+ 'version' => ini_get('session.save_handler'),
+ 'check' => true,
+ 'tooltip' => 'PHP Session save handler (Redis is preferred).'
+ ],
+ 'sessionSavePath' => [
+ 'label' => 'session.save_path',
+ 'version' => ini_get('session.save_path'),
+ 'check' => true,
+ 'tooltip' => 'PHP Session save path (Redis is preferred).'
+ ],
+ 'sessionName' => [
+ 'label' => 'session.name',
+ 'version' => ini_get('session.name'),
+ 'check' => true,
+ 'tooltip' => 'PHP Session name.'
+ ]
+ ];
+ }
+
+ /**
+ * check Redis (cache) config
+ * -> only visible if Redis is used as Cache backend
+ * @param \Base $f3
+ * @return array
+ */
+ protected function checkRedisInformation(\Base $f3): array {
+ $redisConfig = [];
+
+ if(
+ extension_loaded('redis') &&
+ class_exists('\Redis')
+ ){
+ // collection of DSN specific $conf array (host, port, db,..)
+ $dsnData = [];
+
+ /**
+ * @param int $dbNum
+ * @param string $tag
+ * @return string
+ */
+ $getDbLabel = function(int $dbNum, string $tag) : string {
+ return ' db(' . $dbNum . ') : ' . $tag;
+ };
+
+ /**
+ * get client information for a Redis client
+ * @param \Redis $client
+ * @param array $conf
+ * @return array
+ */
+ $getClientInfo = function(\Redis $client, array $conf) : array {
+ return [
+ 'dsn' => [
+ 'label' => 'DSN',
+ 'value' => $conf['host'] . ':' . $conf['port']
+ ],
+ 'connected' => [
+ 'label' => 'status',
+ 'value' => $client->isConnected()
+ ]
+ ];
+ };
+
+ /**
+ * get status information for a Redis client
+ * @param \Redis $client
+ * @return array
+ */
+ $getClientStats = function(\Redis $client) use ($f3) : array {
+ $redisStats = [];
+
+ if($client->isConnected() && !$client->getLastError()){
+ $redisServerInfo = (array)$client->info('SERVER');
+ $redisClientsInfo = (array)$client->info('CLIENTS');
+ $redisMemoryInfo = (array)$client->info('MEMORY');
+ $redisStatsInfo = (array)$client->info('STATS');
+
+ $redisStats = [
+ 'redisVersion' => [
+ 'label' => 'redis_version',
+ 'required' => number_format((float)$f3->get('REQUIREMENTS.REDIS.VERSION'), 1, '.', ''),
+ 'version' => $redisServerInfo['redis_version'],
+ 'check' => version_compare( $redisServerInfo['redis_version'], $f3->get('REQUIREMENTS.REDIS.VERSION'), '>='),
+ 'tooltip' => 'Redis server version'
+ ],
+ 'maxMemory' => [
+ 'label' => 'maxmemory',
+ 'required' => Number::instance()->bytesToString($f3->get('REQUIREMENTS.REDIS.MAX_MEMORY')),
+ 'version' => Number::instance()->bytesToString($redisMemoryInfo['maxmemory']),
+ 'check' => $redisMemoryInfo['maxmemory'] >= $f3->get('REQUIREMENTS.REDIS.MAX_MEMORY'),
+ 'tooltip' => 'Max memory limit for Redis'
+ ],
+ 'usedMemory' => [
+ 'label' => 'used_memory',
+ 'version' => Number::instance()->bytesToString($redisMemoryInfo['used_memory']),
+ 'check' => $redisMemoryInfo['used_memory'] < $redisMemoryInfo['maxmemory'],
+ 'tooltip' => 'Current memory used by Redis'
+ ],
+ 'usedMemoryPeak' => [
+ 'label' => 'used_memory_peak',
+ 'version' => Number::instance()->bytesToString($redisMemoryInfo['used_memory_peak']),
+ 'check' => $redisMemoryInfo['used_memory_peak'] <= $redisMemoryInfo['maxmemory'],
+ 'tooltip' => 'Peak memory used by Redis'
+ ],
+ 'maxmemoryPolicy' => [
+ 'label' => 'maxmemory_policy',
+ 'required' => $f3->get('REQUIREMENTS.REDIS.MAXMEMORY_POLICY'),
+ 'version' => $redisMemoryInfo['maxmemory_policy'],
+ 'check' => $redisMemoryInfo['maxmemory_policy'] == $f3->get('REQUIREMENTS.REDIS.MAXMEMORY_POLICY'),
+ 'tooltip' => 'How Redis behaves if \'maxmemory\' limit reached'
+ ],
+ 'connectedClients' => [
+ 'label' => 'connected_clients',
+ 'version' => $redisClientsInfo['connected_clients'],
+ 'check' => (bool)$redisClientsInfo['connected_clients'],
+ 'tooltip' => 'Number of client connections (excluding connections from replicas)'
+ ],
+ 'blockedClients' => [
+ 'label' => 'blocked_clients',
+ 'version' => $redisClientsInfo['blocked_clients'],
+ 'check' => !(bool)$redisClientsInfo['blocked_clients'],
+ 'tooltip' => 'Number of clients pending on a blocking call (BLPOP, BRPOP, BRPOPLPUSH)'
+ ],
+ 'evictedKeys' => [
+ 'label' => 'evicted_keys',
+ 'version' => $redisStatsInfo['evicted_keys'],
+ 'check' => !(bool)$redisStatsInfo['evicted_keys'],
+ 'tooltip' => 'Number of evicted keys due to maxmemory limit'
+ ],
+ [
+ 'label' => 'Databases'
+ ]
+ ];
+ }
+
+ return $redisStats;
+ };
+
+ /**
+ * get database status for current selected db
+ * @param \Redis $client
+ * @param string $tag
+ * @return array
+ */
+ $getDatabaseStatus = function(\Redis $client, string $tag) use ($getDbLabel) : array {
+ $redisDatabases = [];
+ if($client->isConnected() && !$client->getLastError()){
+ $dbNum = $client->getDbNum();
+ $dbSize = $client->dbSize();
+ $redisDatabases = [
+ 'db_' . $dbNum => [
+ 'label' => $getDbLabel($dbNum, $tag),
+ 'version' => $dbSize . ' keys',
+ 'check' => $dbSize > 0,
+ 'tooltip' => 'Keys in db(' . $dbNum . ')',
+ 'task' => [
+ [
+ 'action' => http_build_query([
+ 'action' => 'flushRedisDb',
+ 'host' => $client->getHost(),
+ 'port' => $client->getPort(),
+ 'db' => $dbNum
+ ]) . '#pf-setup-cache',
+ 'label' => 'Flush',
+ 'icon' => 'fa-trash',
+ 'btn' => 'btn-danger' . (($dbSize > 0) ? '' : ' disabled')
+ ]
+ ]
+ ]
+ ];
+ }
+
+ return $redisDatabases;
+ };
+
+ /**
+ * build (modify) $redisConfig with DNS $conf data
+ * @param array $conf
+ */
+ $buildRedisConfig = function(array $conf) use (&$redisConfig, $getDbLabel, $getClientInfo, $getClientStats, $getDatabaseStatus){
+ if($conf['type'] == 'redis'){
+ // is Redis -> group all DNS by host:port
+ $uid = $conf['host'] . ':' . $conf['port'];
+
+ $client = new \Redis();
+ try{
+ $client->pconnect($conf['host'], $conf['port'], 0.3);
+ if(!empty($conf['auth'])){
+ $client->auth($conf['auth']);
+ }
+
+ if(isset($conf['db'])) {
+ $client->select($conf['db']);
+ }
+
+ $conf['db'] = $client->getDbNum();
+ }catch(\RedisException $e){
+ // connection failed, getLastError() is called further down
+ }
+
+ if(!array_key_exists($uid, $redisConfig)){
+ $redisConfig[$uid] = $getClientInfo($client, $conf);
+ $redisConfig[$uid]['status'] = $getClientStats($client) + $getDatabaseStatus($client, $conf['tag']);
+ }elseif(!array_key_exists($uidDb = 'db_' . $conf['db'], $redisConfig[$uid]['status'])){
+ $redisConfig[$uid]['status'] += $getDatabaseStatus($client, $conf['tag']);
+ }else{
+ $redisConfig[$uid]['status'][$uidDb]['label'] .= '; ' . $conf['tag'];
+ }
+
+ if($error = $client->getLastError()){
+ $redisConfig[$uid]['errors'][] = [
+ 'label' => $getDbLabel((int)$conf['db'], $conf['tag']),
+ 'error' => $error
+ ];
+ }
+
+ $client->close();
+ }
+ };
+
+ // potential Redis caches ---------------------------------------------------------------------------------
+ $redisCaches = [
+ 'CACHE' => $f3->get('CACHE'),
+ 'API_CACHE' => $f3->get('API_CACHE')
+ ];
+
+ foreach($redisCaches as $tag => $dsn){
+ if(Config::parseDSN($dsn, $conf)){
+ $conf['tag'] = $tag;
+ $dsnData[] = $conf;
+ }
+ }
+
+ // if Session handler is also Redis -> add this as well ---------------------------------------------------
+ // -> the DSN format is not the same, convert URL format into DSN
+ if(
+ strtolower(session_module_name()) == 'redis' &&
+ ($parts = parse_url(strtolower(session_save_path())))
+ ){
+ // parse URL parameters
+ parse_str((string)$parts['query'], $params);
+
+ $conf = [
+ 'type' => 'redis',
+ 'host' => $parts['host'],
+ 'port' => $parts['port'],
+ 'db' => !empty($params['database']) ? (int)$params['database'] : 0,
+ 'auth' => !empty($params['auth']) ? $params['auth'] : null,
+ 'tag' => 'SESSION'
+ ];
+ $dsnData[] = $conf;
+ }
+
+ // sort all $dsnData by 'db' number -----------------------------------------------------------------------
+ usort($dsnData, function($a, $b){
+ return $a['db'] <=> $b['db'];
+ });
+
+ foreach($dsnData as $conf){
+ $buildRedisConfig($conf);
+ }
+ }
+
+ return $redisConfig;
+ }
+
+ /**
+ * check system environment vars
+ * -> mostly relevant for development/build/deployment
+ * @param \Base $f3
+ * @return array
+ */
+ protected function checkSystemConfig(\Base $f3): array {
+ $systemConf = [];
+ if(function_exists('exec')){
+ $gitOut = $composerOut = $nodeOut = $npmOut = [];
+ $gitStatus = $composerStatus = $nodeStatus = $npmStatus = 1;
+
+ exec('git --version', $gitOut, $gitStatus);
+ exec('composer -V', $composerOut, $composerStatus);
+ exec('node -v', $nodeOut, $nodeStatus);
+ exec('npm -v', $npmOut, $npmStatus);
+
+ $normalizeVersion = function($version): string {
+ return preg_replace("/[^0-9\.\s]/", '', (string)$version);
+ };
+
+ $systemConf = [
+ 'git' => [
+ 'label' => 'Git',
+ 'version' => $gitOut[0] ? 'installed' : 'missing',
+ 'check' => $gitStatus == 0,
+ 'tooltip' => 'Git # git --version : ' . $gitOut[0]
+ ],
+ 'composer' => [
+ 'label' => 'Composer',
+ 'version' => $composerOut[0] ? 'installed' : 'missing',
+ 'check' => $composerStatus == 0,
+ 'tooltip' => 'Composer # composer -V : ' . $composerOut[0]
+ ],
+ 'node' => [
+ 'label' => 'NodeJs',
+ 'required' => number_format((float)$f3->get('REQUIREMENTS.PATH.NODE'), 1, '.', ''),
+ 'version' => $normalizeVersion($nodeOut[0]) ?: 'missing',
+ 'check' => version_compare( $normalizeVersion($nodeOut[0]), number_format((float)$f3->get('REQUIREMENTS.PATH.NODE'), 1, '.', ''), '>='),
+ 'tooltip' => 'NodeJs # node -v'
+ ],
+ 'npm' => [
+ 'label' => 'npm',
+ 'required' => $f3->get('REQUIREMENTS.PATH.NPM'),
+ 'version' => $normalizeVersion($npmOut[0]) ?: 'missing',
+ 'check' => version_compare( $normalizeVersion($npmOut[0]), $f3->get('REQUIREMENTS.PATH.NPM'), '>='),
+ 'tooltip' => 'npm # npm -v'
+ ]
+ ];
+ }
+
+ return $systemConf;
+ }
+
+ /**
+ * get default map config
+ * @param \Base $f3
+ * @return array
+ */
+ protected function getMapsDefaultConfig(\Base $f3): array {
+ $matrix = \Matrix::instance();
+ $mapsDefaultConfig = (array)Config::getMapsDefaultConfig();
+ $matrix->transpose($mapsDefaultConfig);
+
+ $mapConfig = ['mapTypes' => array_keys(reset($mapsDefaultConfig))];
+
+ foreach($mapsDefaultConfig as $option => $defaultConfig){
+ $tooltip = '';
+ switch($option){
+ case 'lifetime':
+ $label = 'Map lifetime (days)';
+ $tooltip = 'Unchanged/inactive maps get auto deleted afterwards (cronjob).';
+ break;
+ case 'max_count':
+ $label = 'Max. maps count/user';
+ break;
+ case 'max_shared':
+ $label = 'Map share limit/map';
+ $tooltip = 'E.g. A Corp map can be shared with X other corps.';
+ break;
+ case 'max_systems':
+ $label = 'Max. systems count/map';
+ break;
+ case 'log_activity_enabled':
+ $label = ' Activity statistics';
+ $tooltip = 'If "enabled", map admins can enable user statistics for a map.';
+ break;
+ case 'log_history_enabled':
+ $label = ' History log files';
+ $tooltip = 'If "enabled", map admins can pipe map logs to file. (one file per map)';
+ break;
+ case 'send_history_slack_enabled':
+ $label = ' History log Slack';
+ $tooltip = 'If "enabled", map admins can set a Slack channel were map logs get piped to.';
+ break;
+ case 'send_rally_slack_enabled':
+ $label = ' Rally point poke Slack';
+ $tooltip = 'If "enabled", map admins can set a Slack channel for rally point pokes.';
+ break;
+ case 'send_history_discord_enabled':
+ $label = ' History log Discord';
+ $tooltip = 'If "enabled", map admins can set a Discord channel were map logs get piped to.';
+ break;
+ case 'send_rally_discord_enabled':
+ $label = ' Rally point poke Discord';
+ $tooltip = 'If "enabled", map admins can set a Discord channel for rally point pokes.';
+ break;
+ case 'send_rally_mail_enabled':
+ $label = ' Rally point poke Email';
+ $tooltip = 'If "enabled", rally point pokes can be send by Email (SMTP config + recipient address required).';
+ break;
+ default:
+ $label = 'unknown';
+ }
+
+ $mapsDefaultConfig[$option] = [
+ 'label' => $label,
+ 'tooltip' => $tooltip,
+ 'data' => $defaultConfig
+ ];
+ }
+
+ $mapConfig['mapConfig'] = $mapsDefaultConfig;
+
+ return $mapConfig;
+ }
+
+ /**
+ * get database connection information
+ * @param \Base $f3
+ * @param bool|false $exec
+ * @return array
+ */
+ protected function checkDatabase(\Base $f3, $exec = false){
+
+ foreach($this->databases as $dbAlias => $dbData){
+
+ $dbLabel = '';
+ $dbConfig = [];
+
+ // DB connection status
+ $dbConnected = false;
+ // DB initialized as persistent connection
+ $dbPersistent = false;
+ // DB type (e.g. MySql,..)
+ $dbDriver = 'unknown';
+ // enable database ::create() function on UI
+ $dbCreate = false;
+ // enable database ::setup() function on UI
+ $dbSetupEnable = false;
+ // check if everything is OK (connection, tables, columns, indexes,..)
+ $dbStatusCheckCount = 0;
+ // db queries for column fixes (types, indexes, unique)
+ $dbColumnQueries = [];
+ // tables that should exist in this DB
+ $requiredTables = [];
+ // get DB config
+ $dbConfigValues = Config::getDatabaseConfig($f3, $dbAlias);
+ // collection for errors
+ $dbErrors = [];
+ /**
+ * @var $db Sql
+ */
+ $db = $f3->DB->getDB($dbAlias);
+
+ // check config that does NOT require a valid DB connection
+ switch($dbAlias){
+ case 'PF': $dbLabel = 'Pathfinder'; break;
+ case 'UNIVERSE': $dbLabel = 'EVE-Online universe'; break;
+ }
+
+ $dbName = $dbConfigValues['NAME'];
+ $dbUser = $dbConfigValues['USER'];
+ $dbAlias = $dbConfigValues['ALIAS'];
+
+ if($db){
+ switch($dbAlias){
+ case 'PF':
+ case 'UNIVERSE':
+ // enable (table) setup for this DB
+ $dbSetupEnable = true;
+
+ // get table data from model
+ foreach($dbData['models'] as $model){
+ $tableConfig = call_user_func(Config::withNamespace($model) . '::resolveConfiguration');
+ $requiredTables[$tableConfig['table']] = [
+ 'model' => $model,
+ 'name' => $tableConfig['table'],
+ 'fieldConf' => $tableConfig['fieldConf'],
+ 'exists' => false,
+ 'empty' => true,
+ 'requiredCharset' => $tableConfig['charset'],
+ 'requiredCollation' => $tableConfig['charset'] . '_unicode_ci',
+ 'foreignKeys' => []
+ ];
+ }
+ break;
+ }
+
+ // db connect was successful
+ $dbConnected = true;
+ $dbPersistent = $db->pdo()->getAttribute(\PDO::ATTR_PERSISTENT);
+ $dbDriver = $db->driver();
+ $dbConfig = $this->checkDBConfig($f3, $db);
+
+ // get tables
+ $schema = new Schema($db);
+ $currentTables = $schema->getTables();
+
+ // check each table for changes
+ foreach($requiredTables as $requiredTableName => $data){
+ $tableCharset = null;
+ $tableCollation = null;
+ $tableExists = false;
+ $tableRows = 0;
+ // Check if table status is OK (no errors/warnings,..)
+ $tableStatusCheckCount = 0;
+
+ $currentColumns = [];
+ if(in_array($requiredTableName, $currentTables)){
+ // Table exists
+ $tableExists = true;
+ // get existing table columns and column related constraints (if exists)
+ $tableModifierTemp = new Mysql\TableModifier($requiredTableName, $schema);
+ $currentColumns = $tableModifierTemp->getCols(true);
+ // get row count
+ $tableRows = $db->getRowCount($requiredTableName);
+
+ $tableStatus = $db->getTableStatus($requiredTableName);
+ if(
+ !empty($tableStatus['Collation']) &&
+ ($statusVal = strstr($tableStatus['Collation'], '_', true)) !== false
+ ){
+ $tableCharset = $statusVal;
+ $tableCollation = $tableStatus['Collation'];
+ }
+
+ // find deprecated columns that are no longer needed ------------------------------------------
+ $deprecatedColumnNames = array_diff(array_keys($currentColumns), array_keys($data['fieldConf']), ['id']);
+ foreach($deprecatedColumnNames as $deprecatedColumnName){
+ $requiredTables[$requiredTableName]['fieldConf'][$deprecatedColumnName]['deprecated'] = true;
+ $requiredTables[$requiredTableName]['fieldConf'][$deprecatedColumnName]['currentType'] = 'deprecated';
+ //$requiredTables[$requiredTableName]['fieldConf'][$deprecatedColumnName]['statusCheck'] = false;
+ //$tableStatusCheckCount++;
+
+ //$tableModifierTemp->dropColumn($deprecatedColumnName);
+ }
+
+ //$buildStatus = $tableModifierTemp->build(false);
+ //$dbColumnQueries = array_merge($dbColumnQueries, (array)$buildStatus);
+ }else{
+ // table missing
+ $dbStatusCheckCount++;
+ $tableStatusCheckCount++;
+ }
+
+ foreach((array)$data['fieldConf'] as $columnName => $fieldConf){
+ // if 'nullable' key not set in $fieldConf, Column was created with 'nullable' = true (Cortex default)
+ $fieldConf['nullable'] = isset($fieldConf['nullable']) ? (bool)$fieldConf['nullable'] : true;
+
+ $columnStatusCheck = true;
+ $foreignKeyStatusCheck = true;
+ $requiredTables[$requiredTableName]['fieldConf'][$columnName]['requiredType'] = $fieldConf['type'];
+ $requiredTables[$requiredTableName]['fieldConf'][$columnName]['requiredNullable'] = ($fieldConf['nullable']) ? '1' : '0';
+ $requiredTables[$requiredTableName]['fieldConf'][$columnName]['requiredIndex'] = ($fieldConf['index']) ? '1' : '0';
+ $requiredTables[$requiredTableName]['fieldConf'][$columnName]['requiredUnique'] = ($fieldConf['unique']) ? '1' : '0';
+
+ if(array_key_exists($columnName, $currentColumns)){
+ // column exists
+
+ // get tableModifier -> possible column update
+ $tableModifier = new Mysql\TableModifier($requiredTableName, $schema);
+
+ // get new column and copy Schema from existing column
+ $col = new Mysql\Column($columnName, $tableModifier);
+ $col->copyfrom($currentColumns[$columnName]);
+
+ $currentColType = $currentColumns[$columnName]['type'];
+ $currentNullable = $currentColumns[$columnName]['nullable'];
+ $hasNullable = $currentNullable ? '1' : '0';
+ $currentColIndexData = call_user_func(Config::withNamespace($data['model']) . '::indexExists', [$columnName]);
+ $currentColIndex = is_array($currentColIndexData);
+ $hasIndex = ($currentColIndex) ? '1' : '0';
+ $hasUnique = ($currentColIndexData['unique']) ? '1' : '0';
+ $changedType = false;
+ $changedNullable = false;
+ $changedUnique = false;
+ $changedIndex = false;
+ $addConstraints = [];
+
+ // set (new) column information -----------------------------------------------------------
+ $requiredTables[$requiredTableName]['fieldConf'][$columnName]['exists'] = true;
+ $requiredTables[$requiredTableName]['fieldConf'][$columnName]['currentType'] = $currentColType;
+ $requiredTables[$requiredTableName]['fieldConf'][$columnName]['currentNullable'] = $hasNullable;
+ $requiredTables[$requiredTableName]['fieldConf'][$columnName]['currentIndex'] = $hasIndex;
+ $requiredTables[$requiredTableName]['fieldConf'][$columnName]['currentUnique'] = $hasUnique;
+
+ // check constraint -----------------------------------------------------------------------
+ if(isset($fieldConf['constraint'])){
+ // add or update constraints
+ foreach((array)$fieldConf['constraint'] as $constraintData){
+ $constraint = $col->newConstraint($constraintData);
+
+ $foreignKeyExists = $col->constraintExists($constraint);
+
+ // constraint information -> show in template
+ $requiredTables[$requiredTableName]['foreignKeys'][] = [
+ 'exists' => $foreignKeyExists,
+ 'keyName' => $constraint->getConstraintName()
+ ];
+
+ if($foreignKeyExists){
+ // drop constraint and re-add again at the and, in case something has changed
+ $col->dropConstraint($constraint);
+ }else{
+ $tableStatusCheckCount++;
+ $foreignKeyStatusCheck = false;
+ }
+
+ $addConstraints[] = $constraint;
+ }
+ }
+
+ // check type changed ---------------------------------------------------------------------
+ if(
+ $fieldConf['type'] !== 'JSON' &&
+ !$schema->isCompatible($fieldConf['type'], $currentColType)
+ ){
+ // column type has changed
+ $changedType = true;
+ $columnStatusCheck = false;
+ $tableStatusCheckCount++;
+ }
+
+ // check if column nullable changed -------------------------------------------------------
+ if( $currentNullable != $fieldConf['nullable']){
+ $changedNullable = true;
+ $columnStatusCheck = false;
+ $tableStatusCheckCount++;
+ }
+
+ // check if column index changed ----------------------------------------------------------
+ $indexUpdate = false;
+ $indexKey = (bool)$hasIndex;
+ $indexUnique = (bool)$hasUnique;
+
+ if($currentColIndex != $fieldConf['index']){
+ $changedIndex = true;
+ $columnStatusCheck = false;
+ $tableStatusCheckCount++;
+
+ $indexUpdate = true;
+ $indexKey = (bool)$fieldConf['index'];
+ }
+
+ // check if column unique changed ---------------------------------------------------------
+ if($currentColIndexData['unique'] != $fieldConf['unique']){
+ $changedUnique = true;
+ $columnStatusCheck = false;
+ $tableStatusCheckCount++;
+
+ $indexUpdate = true;
+ $indexUnique = (bool)$fieldConf['unique'];
+ }
+
+ // build table with changed columns -------------------------------------------------------
+ if(!$columnStatusCheck || !$foreignKeyStatusCheck){
+
+ if(!$columnStatusCheck ){
+ // IMPORTANT: setType is always required! Even if type has not changed
+ $col->type($fieldConf['type']);
+
+ // update "nullable"
+ if($changedNullable){
+ $col->nullable($fieldConf['nullable']);
+ }
+
+ // update/change/delete index/unique keys
+ if($indexUpdate){
+ if($hasIndex){
+ $tableModifier->dropIndex($columnName);
+ }
+
+ if($indexKey){
+ $tableModifier->addIndex($columnName, $indexUnique);
+ }
+ }
+ $tableModifier->updateColumn($columnName, $col);
+ }
+
+ // (re-)add constraints !after! index update is done
+ // otherwise index update will fail if there are existing constraints
+ foreach($addConstraints as $constraint){
+ $col->addConstraint($constraint);
+ }
+
+ $buildStatus = $tableModifier->build($exec);
+
+ if(
+ is_array($buildStatus) ||
+ is_string($buildStatus)
+ ){
+ // query strings for change available
+ $dbColumnQueries = array_merge($dbColumnQueries, (array)$buildStatus);
+ }
+ }
+
+ // set (new) column information -----------------------------------------------------------
+ $requiredTables[$requiredTableName]['fieldConf'][$columnName]['changedType'] = $changedType;
+ $requiredTables[$requiredTableName]['fieldConf'][$columnName]['changedNullable'] = $changedNullable;
+ $requiredTables[$requiredTableName]['fieldConf'][$columnName]['changedUnique'] = $changedUnique;
+ $requiredTables[$requiredTableName]['fieldConf'][$columnName]['changedIndex'] = $changedIndex;
+
+ }elseif(
+ !isset($fieldConf['has-manny']) &&
+ isset($fieldConf['type'])
+ ){
+ // column not exists but it is required!
+ // columns that do not match this criteria ("mas-manny") are "virtual" fields
+ // and can be ignored
+ $requiredTables[$requiredTableName]['fieldConf'][$columnName]['currentType'] = '';
+ $columnStatusCheck = false;
+ $tableStatusCheckCount++;
+ }
+ $requiredTables[$requiredTableName]['fieldConf'][$columnName]['statusCheck'] = $columnStatusCheck;
+ }
+
+ $dbStatusCheckCount += $tableStatusCheckCount;
+ $requiredTables[$requiredTableName]['currentCharset'] = $tableCharset;
+ $requiredTables[$requiredTableName]['currentCollation'] = $tableCollation;
+ $requiredTables[$requiredTableName]['rows'] = $tableRows;
+ $requiredTables[$requiredTableName]['exists'] = $tableExists;
+ $requiredTables[$requiredTableName]['statusCheckCount'] = $tableStatusCheckCount;
+ }
+
+ }else{
+ // DB connection failed
+ $dbStatusCheckCount++;
+
+ foreach($f3->DB->getErrors($dbAlias, 10) as $dbException){
+ $dbErrors[] = $dbException->getMessage();
+ }
+
+ // try to connect without! DB (-> offer option to create them)
+ // do not log errors (silent)
+ $f3->DB->setSilent(true);
+ $dbServer = $f3->DB->connectToServer($dbAlias);
+ $f3->DB->setSilent(false);
+ if(!is_null($dbServer)){
+ // connection succeeded
+ $dbCreate = true;
+ $dbDriver = $dbServer->driver();
+ }
+ }
+
+ if($dbStatusCheckCount !== 0){
+ $this->databaseHasError = true;
+ }
+
+ // sort tables for better readability
+ ksort($requiredTables);
+
+ $this->databases[$dbAlias]['info'] = [
+ 'label' => $dbLabel,
+ 'host' => $dbConfigValues['SOCKET'] ? : $dbConfigValues['HOST'],
+ 'port' => $dbConfigValues['PORT'] && !$dbConfigValues['SOCKET'] ? $dbConfigValues['PORT'] : '',
+ 'driver' => $dbDriver,
+ 'name' => $dbName,
+ 'user' => $dbUser,
+ 'pass' => Util::obscureString((string)$dbConfigValues['PASS'], 8),
+ 'dbConfig' => $dbConfig,
+ 'dbCreate' => $dbCreate,
+ 'setupEnable' => $dbSetupEnable,
+ 'connected' => $dbConnected,
+ 'persistent' => $dbPersistent,
+ 'statusCheckCount' => $dbStatusCheckCount,
+ 'columnQueries' => $dbColumnQueries,
+ 'tableData' => $requiredTables,
+ 'errors' => $dbErrors
+ ];
+ }
+
+ if($exec){
+ $f3->reroute('@setup');
+ }
+
+ return $this->databases;
+ }
+
+ /**
+ * check MySQL params
+ * @param \Base $f3
+ * @param Sql $db
+ * @return array
+ */
+ protected function checkDBConfig(\Base $f3, Sql $db) : array {
+ $checkAll = true;
+ // some db like "Maria DB" have some strange version strings....
+ $dbVersionString = $db->version();
+ $dbVersionParts = explode('-', $dbVersionString);
+ $dbVersion = 'unknown';
+ foreach($dbVersionParts as $dbVersionPart){
+ // check if this is a valid version number
+ // hint: MariaDB´s version is NOT always the last valid version number
+ if( version_compare( $dbVersionPart, '1', '>' ) > 0 ){
+ $dbVersion = $dbVersionPart;
+ }
+ }
+
+ $dbConfig = [
+ 'data' => [
+ 'version' => [
+ 'label' => 'DB version',
+ 'required' => $f3->get('REQUIREMENTS.MYSQL.VERSION'),
+ 'version' => $dbVersion,
+ 'check' => version_compare($dbVersion, $f3->get('REQUIREMENTS.MYSQL.VERSION'), '>=' ) ? : $checkAll = false
+ ]
+ ]
+ ];
+
+ $mySQLConfig = array_change_key_case((array)$f3->get('REQUIREMENTS.MYSQL.VARS'));
+ $mySQLConfigKeys = array_keys($mySQLConfig);
+
+ $results = $db->exec("SHOW VARIABLES WHERE Variable_Name IN ('" . implode("','", $mySQLConfigKeys) . "')");
+
+ $getValue = function(string $param) use ($results) : string {
+ $match = array_filter($results, function($k) use ($param) : bool {
+ return strtolower($k['Variable_name']) == $param;
+ });
+ return !empty($match) ? end(reset($match)) : 'unknown';
+ };
+
+ $checkValue = function($requiredValue, $value) : bool {
+ $check = true;
+ if(!empty($requiredValue)){
+ if(is_int($requiredValue)){
+ $check = $requiredValue <= $value;
+ }else{
+ $check = $requiredValue == $value;
+ }
+ }
+ return $check;
+ };
+
+ foreach($mySQLConfig as $param => $requiredValue){
+ $value = $getValue($param);
+ $dbConfig['data'][] = [
+ 'label' => $param,
+ 'required' => $requiredValue,
+ 'version' => $value,
+ 'check' => $checkValue($requiredValue, $value) ? : $checkAll = false
+ ];
+ }
+
+ $dbConfig['meta'] = [
+ 'check' => $checkAll
+ ];
+
+ return $dbConfig;
+ }
+
+ /**
+ * try to create a fresh database
+ * @param \Base $f3
+ * @param string $dbAlias
+ */
+ protected function createDB(\Base $f3, string $dbAlias){
+ // check for valid key
+ if(!empty($this->databases[$dbAlias])){
+ // disable logging (we expect the DB connect to fail -> no db created)
+ $f3->DB->setSilent(true);
+ // try to connect
+ $db = $f3->DB->getDB($dbAlias);
+ // enable logging
+ $f3->DB->setSilent(false, true);
+ if(is_null($db)){
+ // try create new db
+ $db = $f3->DB->createDB($dbAlias);
+ if(is_null($db)){
+ foreach($f3->DB->getErrors($dbAlias, 5) as $error){
+ // ... no further error handling here -> check log files
+ //$error->getMessage()
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * init the complete database
+ * - create tables
+ * - create indexes
+ * - set default static values
+ * @param \Base $f3
+ * @param string $dbAlias
+ * @return array
+ */
+ protected function bootstrapDB(\Base $f3, string $dbAlias) : array {
+ $checkTables = [];
+ if($db = $f3->DB->getDB($dbAlias)){
+ // set some default config for this database
+ $requiredVars = Config::getRequiredDbVars($f3, $db->driver());
+ $db->prepareDatabase($requiredVars['CHARACTER_SET_DATABASE'], $requiredVars['COLLATION_DATABASE']);
+
+ // setup tables
+ foreach($this->databases[$dbAlias]['models'] as $modelClass){
+ $checkTables[] = call_user_func(Config::withNamespace($modelClass) . '::setup', $db);
+ }
+ }
+ return $checkTables;
+ }
+
+ /**
+ * get Socket information (TCP (internal)), (WebSocket (clients))
+ * @param \Base $f3
+ * @return array
+ * @throws \Exception
+ */
+ protected function getSocketInformation(\Base $f3) : array {
+ $ttl = 0.6;
+ $task = 'healthCheck';
+ $healthCheckToken = microtime(true);
+
+ $statusTcp = [
+ 'type' => 'danger',
+ 'label' => 'INIT CONNECTION…',
+ 'class' => 'txt-color-danger'
+ ];
+
+ $statusWeb = [
+ 'type' => 'danger',
+ 'label' => 'INIT CONNECTION…',
+ 'class' => 'txt-color-danger'
+ ];
+
+ $statsTcp = false;
+ $statsWeb = false;
+
+ $setStats = function(array $stats) use (&$statsTcp, &$statsWeb) {
+ if(!empty($stats['tcpSocket'])){
+ $statsTcp = $stats['tcpSocket'];
+ }
+ if(!empty($stats['webSocket'])){
+ $statsWeb = $stats['webSocket'];
+ }
+ };
+
+ // ping TCP Socket with "healthCheck" task
+ $f3->webSocket(['timeout' => $ttl])
+ ->write($task, $healthCheckToken)
+ ->then(
+ function($payload) use ($task, $healthCheckToken, &$statusTcp, $setStats) {
+ if(
+ $payload['task'] == $task &&
+ $payload['load'] == $healthCheckToken
+ ){
+ $statusTcp['type'] = 'success';
+ $statusTcp['label'] = 'PING OK';
+ $statusTcp['class'] = 'txt-color-success';
+ }else{
+ $statusTcp['type'] = 'warning';
+ $statusTcp['label'] = is_string($payload['load']) ? $payload['load'] : 'INVALID RESPONSE';
+ $statusTcp['class'] = 'txt-color-warning';
+ }
+
+ // statistics (e.g. current connection count)
+ $setStats((array)$payload['stats']);
+ },
+ function($payload) use (&$statusTcp, $setStats) {
+ $statusTcp['label'] = $payload['load'];
+
+ // statistics (e.g. current connection count)
+ $setStats((array)$payload['stats']);
+ });
+
+ return [
+ 'tcpSocket' => [
+ 'label' => 'TCP-Socket (intern)',
+ 'icon' => 'fa-exchange-alt',
+ 'status' => $statusTcp,
+ 'stats' => $statsTcp,
+ 'data' => [
+ [
+ 'label' => 'HOST',
+ 'value' => Config::getEnvironmentData('SOCKET_HOST') ? : '[missing]',
+ 'check' => !empty( Config::getEnvironmentData('SOCKET_HOST') )
+ ],[
+ 'label' => 'PORT',
+ 'value' => Config::getEnvironmentData('SOCKET_PORT') ? : '[missing]',
+ 'check' => !empty( Config::getEnvironmentData('SOCKET_PORT') )
+ ],[
+ 'label' => 'URI',
+ 'value' => Config::getSocketUri() ? : '[missing]',
+ 'check' => !empty( Config::getSocketUri() )
+ ],[
+ 'label' => 'timeout (seconds)',
+ 'value' => $ttl,
+ 'check' => !empty( $ttl )
+ ],[
+ 'label' => 'uptime',
+ 'value' => Config::formatTimeInterval($statsTcp['startup'] ? : 0),
+ 'check' => $statsTcp['startup'] > 0
+ ]
+ ],
+ 'token' => $healthCheckToken
+ ],
+ 'webSocket' => [
+ 'label' => 'Web-Socket',
+ 'icon' => 'fa-random',
+ 'status' => $statusWeb,
+ 'stats' => $statsWeb,
+ 'data' => [
+ [
+ 'label' => 'URI',
+ 'value' => '',
+ 'check' => null // undefined
+ ]
+ ]
+ ]
+ ];
+ }
+
+ /**
+ * get cronjob config
+ * @param \Base $f3
+ * @return array
+ */
+ protected function getCronConfig(\Base $f3) : array {
+ $cron = Cron::instance();
+
+ $cronConf = [
+ 'log' => [
+ 'label' => 'LOG',
+ 'required' => $f3->get('REQUIREMENTS.CRON.LOG'),
+ 'version' => $f3->get('CRON.log'),
+ 'check' => $f3->get('CRON.log') == $f3->get('REQUIREMENTS.CRON.LOG'),
+ 'tooltip' => 'Write default cron.log'
+ ],
+ 'cli' => [
+ 'label' => 'CLI',
+ 'required' => $f3->get('REQUIREMENTS.CRON.CLI'),
+ 'version' => $f3->get('CRON.cli'),
+ 'check' => $f3->get('CRON.cli') == $f3->get('REQUIREMENTS.CRON.CLI'),
+ 'tooltip' => 'Jobs can be triggered by CLI. Must be set on Unix where "crontab -e" config is used'
+ ],
+ 'web' => [
+ 'label' => 'WEB',
+ 'version' => (int)$f3->get('CRON.web'),
+ 'check' => true,
+ 'tooltip' => 'Jobs can be triggered by URL. Could be useful if jobs should be triggered by e.g. 3rd party app. Secure "/cron" url if active!'
+ ],
+ 'silent' => [
+ 'label' => 'SILENT',
+ 'version' => (int)$f3->get('CRON.silent'),
+ 'check' => true,
+ 'tooltip' => 'Write job execution status to STDOUT if job completes'
+ ]
+ ];
+
+ return [
+ 'checkCronConfig' => $cronConf,
+ 'settings' => $f3->constants($cron, 'DEFAULT_'),
+ 'jobs' => $cron->getJobsConfig()
+ ];
+ }
+
+ /**
+ * get indexed (cache) data information
+ * @param \Base $f3
+ * @return array
+ * @throws \Exception
+ */
+ protected function getIndexData(\Base $f3) : array {
+ // active DB and tables are required for obtain index data
+ if(!$this->databaseHasError){
+ /**
+ * @var $categoryUniverseModel Universe\CategoryModel
+ */
+ $categoryUniverseModel = Universe\AbstractUniverseModel::getNew('CategoryModel');
+ $categoryUniverseModel->getById(Config::ESI_CATEGORY_STRUCTURE_ID, 0);
+ $groupsCountStructure = $categoryUniverseModel->getGroupsCount(false);
+ $typesCountStructure = $categoryUniverseModel->getTypesCount(false);
+
+ $categoryUniverseModel->getById(Config::ESI_CATEGORY_SHIP_ID, 0);
+ $groupsCountShip = $categoryUniverseModel->getGroupsCount(false);
+ $typesCountShip = $categoryUniverseModel->getTypesCount(false);
+
+ /**
+ * @var $groupUniverseModel Universe\GroupModel
+ */
+
+ $groupUniverseModel = Universe\AbstractUniverseModel::getNew('GroupModel');
+ $groupUniverseModel->getById(Config::ESI_GROUP_WORMHOLE_ID, 0);
+ $wormholeCount = $groupUniverseModel->getTypesCount(false);
+
+ /**
+ * @var $systemNeighbourModel Universe\SystemNeighbourModel
+ */
+ $systemNeighbourModel = Universe\AbstractUniverseModel::getNew('SystemNeighbourModel');
+
+ /**
+ * @var $systemStaticModel Universe\SystemStaticModel
+ */
+ $systemStaticModel = Universe\AbstractUniverseModel::getNew('SystemStaticModel');
+
+ if(empty($systemCountAll = count(($universeController = new UniverseController())->getSystemIds(true)))){
+ // no systems found in 'universe' DB. Clear potential existing system cache
+ $universeController->clearSystemsIndex();
+ }
+
+ $sum = function(int $carry, int $value) : int {
+ return $carry + $value;
+ };
+
+ $indexInfo = [
+ 'Wormholes' => [
+ 'task' => [
+ [
+ 'action' => 'buildIndex',
+ 'label' => 'Import',
+ 'icon' => 'fa-sync',
+ 'btn' => 'btn-primary'
+ ]
+ ],
+ 'label' => 'Wormholes data',
+ 'countBuild' => $wormholeCount,
+ 'countAll' => count(Universe\GroupModel::getUniverseGroupTypes(Config::ESI_GROUP_WORMHOLE_ID)),
+ 'tooltip' => 'import all wormhole types (e.g. L031) from ESI. Runtime: ~25s'
+ ],
+ 'Structures' => [
+ 'task' => [
+ [
+ 'action' => 'buildIndex',
+ 'label' => 'Import',
+ 'icon' => 'fa-sync',
+ 'btn' => 'btn-primary'
+ ]
+ ],
+ 'label' => 'Structures data',
+ 'countBuild' => $groupsCountStructure,
+ 'countAll' => count(Universe\CategoryModel::getUniverseCategoryGroups(Config::ESI_CATEGORY_STRUCTURE_ID)),
+ 'tooltip' => 'import all structure types (e.g. Citadels) from ESI. Runtime: ~15s',
+ 'subCount' => [
+ 'countBuild' => $typesCountStructure,
+ 'countAll' => array_reduce(array_map('count', Universe\CategoryModel::getUniverseCategoryTypes(Config::ESI_CATEGORY_STRUCTURE_ID)), $sum, 0),
+ ]
+ ],
+ 'Ships' => [
+ 'task' => [
+ [
+ 'action' => 'buildIndex',
+ 'label' => 'Import',
+ 'icon' => 'fa-sync',
+ 'btn' => 'btn-primary'
+ ]
+ ],
+ 'label' => 'Ships data',
+ 'countBuild' => $groupsCountShip,
+ 'countAll' => count(Universe\CategoryModel::getUniverseCategoryGroups(Config::ESI_CATEGORY_SHIP_ID)),
+ 'tooltip' => 'import all ships from ESI. Runtime: ~2min',
+ 'subCount' => [
+ 'countBuild' => $typesCountShip,
+ 'countAll' => array_reduce(array_map('count', Universe\CategoryModel::getUniverseCategoryTypes(Config::ESI_CATEGORY_SHIP_ID)), $sum, 0),
+ ]
+ ],
+ 'SystemStatic' => [
+ 'task' => [
+ [
+ 'action' => 'buildIndex',
+ 'label' => 'Import',
+ 'icon' => 'fa-sync',
+ 'btn' => 'btn-primary'
+ ]
+ ],
+ 'label' => 'Wormhole statics data',
+ 'countBuild' => $systemStaticModel->getRowCount(),
+ 'countAll' => 3772,
+ 'tooltip' => 'import all static wormholes for systems. Runtime: ~25s'
+ ],
+ [
+ 'label' => 'Build search index',
+ 'icon' => 'fa-search',
+ 'tooltip' => 'Search indexes are build from static EVE universe data (e.g. systems, stargate connections,…). Re-build if underlying data was updated.'
+ ],
+ 'Systems' => [
+ 'task' => [
+ [
+ 'action' => 'clearIndex',
+ 'label' => 'Clear',
+ 'icon' => 'fa-trash',
+ 'btn' => 'btn-danger'
+ ],[
+ 'action' => 'buildIndex',
+ 'label' => 'Build',
+ 'icon' => 'fa-sync',
+ 'btn' => 'btn-primary'
+ ]
+ ],
+ 'label' => 'Systems data index',
+ 'countBuild' => count($universeController->getSystemsIndex()),
+ 'countAll' => $systemCountAll,
+ 'tooltip' => 'Build up a static search index over all systems, found on DB. Runtime: ~5min'
+ ],
+ 'SystemNeighbour' => [
+ 'task' => [
+ [
+ 'action' => 'clearIndex',
+ 'label' => 'Clear',
+ 'icon' => 'fa-trash',
+ 'btn' => 'btn-danger'
+ ],[
+ 'action' => 'buildIndex',
+ 'label' => 'Build',
+ 'icon' => 'fa-sync',
+ 'btn' => 'btn-primary'
+ ]
+ ],
+ 'label' => 'Systems neighbour index',
+ 'countBuild' => $systemNeighbourModel->getRowCount(),
+ 'countAll' => (int)$f3->get('REQUIREMENTS.DATA.NEIGHBOURS'),
+ 'tooltip' => 'Build up a static search index for route search. This is used as fallback in case ESI is down. Runtime: ~10s'
+ ]
+ ];
+ }else{
+ $indexInfo = [
+ [
+ 'label' => 'Fix database errors first!',
+ 'class' => 'txt-color-danger text-center'
+ ]
+ ];
+ }
+
+ return $indexInfo;
+ }
+
+ /**
+ * import table data from existing dump file (e.g *.csv)
+ * @param string $modelClass
+ * @return bool
+ * @throws \Exception
+ */
+ protected function importTable($modelClass){
+ $this->getDB('PF');
+ return Pathfinder\AbstractPathfinderModel::getNew($modelClass)->importData();
+ }
+
+ /**
+ * export table data
+ * @param string $modelClass
+ * @throws \Exception
+ */
+ protected function exportTable($modelClass){
+ $this->getDB('PF');
+ Pathfinder\AbstractPathfinderModel::getNew($modelClass)->exportData();
+ }
+
+ /**
+ * get cache folder size
+ * @param \Base $f3
+ * @return array
+ */
+ protected function checkDirSize(\Base $f3) : array {
+ // limit shown cache size. Reduce page load on big cache. In Bytes
+ $maxBytes = 10 * 1024 * 1024; // 10MB
+ $dirTemp = (string)$f3->get('TEMP');
+ $cacheDsn = (string)$f3->get('CACHE');
+ Config::parseDSN($cacheDsn, $conf);
+ // if 'CACHE' is e.g. redis=... -> show default dir for cache
+ $dirCache = $conf['type'] == 'folder' ? $conf['folder'] : $dirTemp . 'cache/';
+
+ $dirAll = [
+ 'TEMP' => [
+ 'label' => 'Temp dir',
+ 'path' => $dirTemp
+ ],
+ 'CACHE' => [
+ 'label' => 'Cache dir',
+ 'path' => $dirCache
+ ]
+ ];
+
+ $maxHitAll = false;
+ $bytesAll = 0;
+
+ foreach($dirAll as $key => $dirData){
+ $maxHit = false;
+ $bytes = 0;
+ $files = Search::getFilesByMTime($dirData['path']);
+ foreach($files as $filename => $file) {
+ $bytes += $file->getSize();
+ if($bytes > $maxBytes){
+ $maxHit = $maxHitAll = true;
+ break;
+ }
+ }
+ $bytesAll += $bytes;
+
+ $dirAll[$key]['size'] = ($maxHit ? '>' : '') . Number::instance()->bytesToString($bytes);
+ $dirAll[$key]['task'] = [
+ [
+ 'action' => http_build_query([
+ 'action' => 'clearFiles',
+ 'path' => $dirData['path']
+ ]),
+ 'label' => 'Delete files',
+ 'icon' => 'fa-trash',
+ 'btn' => 'btn-danger' . (($bytes > 0) ? '' : ' disabled')
+ ]
+ ];
+ }
+
+ return [
+ 'sizeAll' => ($maxHitAll ? '>' : '') . Number::instance()->bytesToString($bytesAll),
+ 'dirAll' => $dirAll
+ ];
+ }
+
+ /**
+ * clear directory
+ * @param string $path
+ */
+ protected function clearFiles(string $path){
+ $files = Search::getFilesByMTime($path);
+ foreach($files as $filename => $file){
+ /**
+ * @var $file \SplFileInfo
+ */
+ if($file->isFile()){
+ if($file->isWritable()){
+ unlink($file->getRealPath());
+ }
+ }
+ }
+ }
+
+ /**
+ * clear all key in a specific Redis database
+ * @param string $host
+ * @param int $port
+ * @param int $db
+ */
+ protected function flushRedisDb(string $host, int $port, int $db = 0){
+ $client = new \Redis();
+ $client->pconnect($host, $port, 0.3);
+ $client->select($db);
+ $client->flushDB();
+ $client->close();
+ }
+
+ /**
+ * clear all character authentication (Cookie) data
+ * @param \Base $f3
+ * @throws \Exception
+ */
+ protected function invalidateCookies(\Base $f3){
+ $this->getDB('PF');
+ $authenticationModel = Pathfinder\AbstractPathfinderModel::getNew('CharacterAuthenticationModel');
+ $results = $authenticationModel->find();
+ if($results){
+ foreach($results as $result){
+ $result->erase();
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/Cron/AbstractCron.php b/app/Cron/AbstractCron.php
new file mode 100644
index 000000000..2413a8661
--- /dev/null
+++ b/app/Cron/AbstractCron.php
@@ -0,0 +1,211 @@
+ generic information data
+ */
+ const LOG_TEXT_BASE = '%4s/%-4s %6s done, %5s total, %8s peak, %9s exec';
+
+ /**
+ * default max_execution_time for cronJobs
+ * -> should be less then execution period
+ */
+ const DEFAULT_MAX_EXECUTION_TIME = 50;
+
+ /**
+ * default threshold time in seconds before a running script (e.g. a large loop) should stop
+ * -> so there is some time or e.g. logging,... left
+ */
+ const DEFAULT_EXECUTION_TIME_THRESHOLD = 3;
+
+ /**
+ * started jobs
+ * @var Pathfinder\CronModel[]
+ */
+ protected $activeCron = [];
+
+ /**
+ * disables log file write entry for some cronJobs
+ * -> either job runs too frequently, or no relevant data available for logging
+ * @var array
+ */
+ protected $logDisabled = [];
+
+ /**
+ * set max execution time for cronJbs
+ * -> Default CLI execution time is 0 == infinite!
+ * php.ini settings are ignored! http://php.net/manual/en/info.configuration.php#ini.max-execution-time
+ * @param int $time
+ */
+ protected function setMaxExecutionTime(int $time = self::DEFAULT_MAX_EXECUTION_TIME){
+ ini_set('max_execution_time', $time);
+ }
+
+ /**
+ * get max execution time
+ * -> 0 means == infinite!
+ * @return int
+ */
+ protected function getMaxExecutionTime() : int {
+ return (int)ini_get('max_execution_time');
+ }
+
+ /**
+ * checks execution time of a "long" running script
+ * -> returns false if execution time is close to maxExecutionTime
+ * @param float $timeTotalStart
+ * @param float|null $timeCheck
+ * @param int $timeThreshold
+ * @return bool
+ */
+ protected function isExecutionTimeLeft(float $timeTotalStart, float $timeCheck = null, int $timeThreshold = self::DEFAULT_EXECUTION_TIME_THRESHOLD) : bool {
+ $timeLeft = true;
+ if($timeTotalMax = $this->getMaxExecutionTime()){
+ $timeTotalMaxThreshold = $timeTotalStart + $timeTotalMax - $timeThreshold;
+ $timeCheck = $timeCheck ? : microtime(true);
+ if($timeCheck >= $timeTotalMaxThreshold){
+ $timeLeft = false;
+ }
+ }
+ return $timeLeft;
+ }
+
+ /**
+ * log cronjob exec state on start
+ * @param string $job
+ * @param bool $logging
+ */
+ protected function logStart(string $job, bool $logging = true){
+ $this->setMaxExecutionTime();
+
+ $cron = \Exodus4D\Pathfinder\Lib\Cron::instance();
+ if(isset($cron->jobs[$job])){
+ // set "start" date for current cronjob
+ $jobConf = $cron->getJobDataFromConf($cron->jobs[$job]);
+ $jobConf['lastExecStart'] = $_SERVER['REQUEST_TIME_FLOAT'];
+ if(($cronModel = $cron->registerJob($job, $jobConf)) instanceof Pathfinder\CronModel){
+ $this->activeCron[$job] = $cronModel;
+ }
+ }
+
+ if(!$logging){
+ $this->logDisabled[] = $job;
+ }
+ }
+
+ /**
+ * log cronjob exec state on finish
+ * @param string $job
+ * @param int $total
+ * @param int $count
+ * @param int $importCount
+ * @param int $offset
+ * @param string $logText
+ */
+ protected function logEnd(string $job, int $total = 0, int $count = 0, int $importCount = 0, int $offset = 0, string $logText = ''){
+ $execEnd = microtime(true);
+ $memPeak = memory_get_peak_usage();
+ $state = [
+ 'total' => $total,
+ 'count' => $count,
+ 'importCount' => $importCount,
+ 'offset' => $offset,
+ 'loop' => 1,
+ 'percent' => $total ? round(100 / $total * ($count + $offset), 1) : 100
+ ];
+
+ if(isset($this->activeCron[$job])){
+ if($lastState = $this->activeCron[$job]->lastExecState){
+ if(isset($lastState['loop']) && $offset){
+ $state['loop'] = (int)$lastState['loop'] + 1;
+ }
+ }
+
+ $jobConf = [
+ 'lastExecEnd' => $execEnd,
+ 'lastExecMemPeak' => $memPeak,
+ 'lastExecState' => $state
+ ];
+ $this->activeCron[$job]->setData($jobConf);
+ $this->activeCron[$job]->save();
+ unset($this->activeCron[$job]);
+ }
+
+ if(!in_array($job, $this->logDisabled)){
+ $this->writeLog($job, $memPeak, $execEnd, $state, $logText);
+ }
+ }
+
+ /**
+ * get either CLI GET params OR
+ * check for params from last run -> incremental import
+ * @param string $job
+ * @return array
+ */
+ protected function getParams(string $job) : array {
+ $params = [];
+
+ // check for CLI GET params
+ $f3 = \Base::instance();
+ if($getParams = (array)$f3->get('GET')){
+ if(isset($getParams['offset'])){
+ $params['offset'] = (int)$getParams['offset'];
+ }
+ if(isset($getParams['length']) && (int)$getParams['length'] > 0){
+ $params['length'] = (int)$getParams['length'];
+ }
+ }
+
+ // .. or check for logged params from last exec state (DB entry)
+ if(empty($params) && isset($this->activeCron[$job])){
+ if($lastState = $this->activeCron[$job]->lastExecState){
+ if(isset($lastState['offset'])){
+ $params['offset'] = (int)$lastState['offset'];
+ }
+ if(isset($lastState['count'])){
+ $params['offset'] = (int)$params['offset'] + (int)$lastState['count'];
+ }
+ if(isset($lastState['loop'])){
+ $params['loop'] = (int)$lastState['loop'];
+ }
+ }
+ }
+
+ return $params;
+ }
+
+ /**
+ * write log file for $job
+ * @param string $job
+ * @param int $memPeak
+ * @param float $execEnd
+ * @param array $state
+ * @param string $logText for custom text
+ */
+ private function writeLog(string $job, int $memPeak = 0, float $execEnd = 0, array $state = [], string $logText = ''){
+ $percent = number_format($state['percent'], 1) . '%';
+ $duration = number_format(round($execEnd - $_SERVER['REQUEST_TIME_FLOAT'], 3), 3) . 's';
+ $log = new \Log('cron_' . $job . '.log');
+
+ $text = sprintf(self::LOG_TEXT_BASE,
+ $state['count'], $state['importCount'], $percent, $state['total'],
+ Number::instance()->bytesToString($memPeak), $duration
+ );
+
+ $text .= $logText ? $logText: '';
+ $log->write($text);
+ }
+}
\ No newline at end of file
diff --git a/app/Cron/Cache.php b/app/Cron/Cache.php
new file mode 100644
index 000000000..9cc0caf46
--- /dev/null
+++ b/app/Cron/Cache.php
@@ -0,0 +1,82 @@
+get('PATHFINDER.CACHE.EXPIRE_MAX');
+ return ($expireTime >= 0) ? $expireTime : self::CACHE_EXPIRE_MAX;
+ }
+
+ /**
+ * clear expired cached files
+ * >> php index.php "/cron/deleteExpiredCacheData"
+ * @param \Base $f3
+ */
+ function deleteExpiredCacheData(\Base $f3){
+ $this->logStart(__FUNCTION__);
+
+ // cache dir (dir is recursively searched...)
+ $cacheDir = $f3->get('TEMP');
+
+ $filterTime = (int)strtotime('-' . $this->getExpireMaxTime($f3) . ' seconds');
+ $expiredFiles = Search::getFilesByMTime($cacheDir, $filterTime, Search::DEFAULT_FILE_LIMIT);
+
+ $totalFiles = 0;
+ $deletedFiles = 0;
+ $deletedSize = 0;
+ $notWritableFiles = 0;
+ $deleteErrors = 0;
+ foreach($expiredFiles as $filename => $file) {
+ /**
+ * @var $file \SplFileInfo
+ */
+ if($file->isFile()){
+ $totalFiles++;
+ if($file->isWritable()){
+ $tmpSize = $file->getSize();
+ if( unlink($file->getRealPath()) ){
+ $deletedSize += $tmpSize;
+ $deletedFiles++;
+ }else{
+ $deleteErrors++;
+ }
+ }else{
+ $notWritableFiles++;
+ }
+ }
+ }
+
+ // Log --------------------------------------------------------------------------------------------------------
+ $total = $totalFiles;
+ $importCount = $total;
+ $count = $deletedFiles;
+
+ $text = sprintf(self::LOG_TEXT, $deletedSize, $notWritableFiles, $deleteErrors);
+ $this->logEnd(__FUNCTION__, $total, $count, $importCount, 0, $text);
+ }
+
+}
diff --git a/app/Cron/CcpSystemsUpdate.php b/app/Cron/CcpSystemsUpdate.php
new file mode 100644
index 000000000..c9880f42c
--- /dev/null
+++ b/app/Cron/CcpSystemsUpdate.php
@@ -0,0 +1,190 @@
+ 'system_jumps',
+ 'ship_kills' => 'system_kills_ships',
+ 'pod_kills' => 'system_kills_pods',
+ 'npc_kills' => 'system_kills_factions'
+ ];
+
+ /**
+ * checks if a table exists in DB or not
+ * @param Sql $db
+ * @param string $table
+ * @return bool
+ */
+ protected function tableExists (Sql $db, string $table) : bool {
+ return !empty($db->exec('SHOW TABLES LIKE :table', [':table' => $table]));
+ }
+
+ /**
+ * check all system log tables for the correct number of system entries that will be locked
+ * @param \Base $f3
+ * @return int[]
+ */
+ private function prepareSystemLogTables(\Base $f3) : array {
+ $systemIds = [];
+
+ // get all available systems from "universe" DB
+ $universeDB = $f3->DB->getDB('UNIVERSE');
+
+ if($this->tableExists($universeDB, 'system')){
+ $systemsData = $universeDB->exec('
+ SELECT
+ `id`
+ FROM
+ `system`
+ WHERE
+ `security` = :ns OR
+ `security` = :ls OR
+ `security` = :hs
+ ',
+ [':ns' => '0.0', ':ls' => 'L', ':hs' => 'H']
+ );
+
+ $systemIds = array_map('intval', array_column($systemsData, 'id'));
+ sort($systemIds, SORT_NUMERIC);
+
+ $pfDB = $f3->DB->getDB('PF');
+
+ // insert systems into each log table if not exist
+ foreach($this->logTables as $tableName){
+ $pfDB->begin();
+ // insert systems into jump log table
+ $sqlInsertSystem = "INSERT IGNORE INTO " . $tableName . " (`systemId`) VALUES (:systemId)";
+
+ foreach($systemIds as $systemId){
+ $pfDB->exec($sqlInsertSystem, [
+ ':systemId' => $systemId
+ ], 0, false);
+ }
+
+ $pfDB->commit();
+ }
+ }
+
+ return $systemIds;
+ }
+
+
+ /**
+ * imports all relevant map stats from CCPs API
+ * >> php index.php "/cron/importSystemData"
+ * @param \Base $f3
+ */
+ function importSystemData(\Base $f3){
+ $this->logStart(__FUNCTION__);
+ $params = $this->getParams(__FUNCTION__);
+
+
+ // prepare system jump log table ------------------------------------------------------------------------------
+ $time_start = microtime(true);
+ $systemIds = $this->prepareSystemLogTables($f3);
+ $time_end = microtime(true);
+ $execTimePrepareSystemLogTables = $time_end - $time_start;
+
+ $total = count($systemIds);
+ $offset = ($params['offset'] > 0 && $params['offset'] < $total) ? $params['offset'] : 0;
+ $systemIds = array_slice($systemIds, $offset, $params['length']);
+ $importCount = count($systemIds);
+ $count = 0;
+
+ // switch DB for data import..
+ /**
+ * @var $pfDB Sql
+ */
+ $pfDB = $f3->DB->getDB('PF');
+
+ // get current jump data --------------------------------------------------------------------------------------
+ $time_start = microtime(true);
+ $jumpData = $f3->ccpClient()->send('getUniverseJumps');
+ $time_end = microtime(true);
+ $execTimeGetJumpData = $time_end - $time_start;
+
+ // get current kill data --------------------------------------------------------------------------------------
+ $time_start = microtime(true);
+ $killData = $f3->ccpClient()->send('getUniverseKills');
+ $time_end = microtime(true);
+ $execTimeGetKillData = $time_end - $time_start;
+
+ // merge both results
+ $systemValues = array_replace_recursive($jumpData, $killData);
+
+ // update system log tables -----------------------------------------------------------------------------------
+ $time_start = microtime(true);
+
+ $logTableCount = count($this->logTables);
+ $logTableCounter = 0;
+ foreach($this->logTables as $key => $tableName){
+ $logTableCounter++;
+ $pfDB->begin();
+
+ $sqlUpdateColumn = vsprintf("SELECT `systemId`, IF (lastUpdatedValue, lastUpdatedValue, DEFAULT (lastUpdatedValue)) AS `updateColumn` FROM %s", [
+ $pfDB->quotekey($tableName)
+ ]);
+ $resUpdateColumns = $pfDB->exec($sqlUpdateColumn, null, 0);
+ $resUpdateColumns = array_column($resUpdateColumns, 'updateColumn', 'systemId');
+
+ foreach($systemIds as $systemId){
+ $column = 1;
+ if(isset($resUpdateColumns[$systemId])){
+ $column = (int)$resUpdateColumns[$systemId];
+ $column = (++$column > 24) ? 1 : $column;
+ }
+
+ // update data (if available)
+ $currentData = 0;
+ if(isset($systemValues[$systemId][$key])){
+ $currentData = (int)$systemValues[$systemId][$key];
+ }
+
+ $sql = vsprintf("UPDATE %s SET `updated` = NOW(), %s = :value, `lastUpdatedValue` = :updateColumn WHERE systemId = :systemId", [
+ $pfDB->quotekey($tableName),
+ $pfDB->quotekey('value' . $column)
+ ]);
+
+ $pfDB->exec($sql, [
+ ':systemId' => $systemId,
+ ':updateColumn' => $column,
+ ':value' => $currentData
+ ] , 0);
+
+ // system import is done if ALL related tables (4) were updated
+ if($logTableCounter === $logTableCount){
+ $count++;
+ }
+ }
+
+ $pfDB->commit();
+ }
+
+ $time_end = microtime(true);
+ $execTimeUpdateTables = $time_end - $time_start;
+
+ // Log --------------------------------------------------------------------------------------------------------
+ $text = sprintf(self::LOG_TEXT, $execTimePrepareSystemLogTables, $execTimeGetJumpData, $execTimeGetKillData, $execTimeUpdateTables);
+ $this->logEnd(__FUNCTION__, $total, $count, $importCount, $offset, $text);
+ }
+}
\ No newline at end of file
diff --git a/app/Cron/CharacterUpdate.php b/app/Cron/CharacterUpdate.php
new file mode 100644
index 000000000..53eb59215
--- /dev/null
+++ b/app/Cron/CharacterUpdate.php
@@ -0,0 +1,158 @@
+get('PATHFINDER.CACHE.CHARACTER_LOG_INACTIVE');
+ return ($logInactiveTime >= 0) ? $logInactiveTime : self::CHARACTER_LOG_INACTIVE;
+ }
+
+ /**
+ * delete all character log data that have not changed since X seconds
+ * -> see deactivateLogData()
+ * >> php index.php "/cron/deleteLogData"
+ * @param \Base $f3
+ * @throws \Exception
+ */
+ function deleteLogData(\Base $f3){
+ $this->logStart(__FUNCTION__, false);
+ $logInactiveTime = $this->getCharacterLogInactiveTime($f3);
+
+ /**
+ * @var $characterLogModel Pathfinder\CharacterLogModel
+ */
+ $characterLogModel = Pathfinder\AbstractPathfinderModel::getNew('CharacterLogModel');
+
+ // find character logs that were not checked recently and update
+ $characterLogs = $characterLogModel->find([
+ 'TIMESTAMPDIFF(SECOND, updated, NOW() ) > :lifetime',
+ ':lifetime' => $logInactiveTime
+ ], [
+ 'order' => 'updated asc',
+ 'limit' => self::CHARACTERS_UPDATE_LOGS_MAX
+ ]);
+
+ $total = 0;
+ $count = 0;
+
+ if(is_object($characterLogs)){
+ $total = count($characterLogs);
+ foreach($characterLogs as $characterLog){
+ /**
+ * @var $characterLog Pathfinder\CharacterLogModel
+ */
+ if(is_object($characterLog->characterId)){
+ if($accessToken = $characterLog->characterId->getAccessToken()){
+ if($characterLog->characterId->isOnline($accessToken)){
+ // force characterLog as "updated" even if no changes were made
+ $characterLog->touch('updated');
+ $characterLog->save();
+ }else{
+ $characterLog->erase();
+ }
+ }else{
+ // no valid $accessToken. (e.g. ESI is down; or invalid `refresh_token` found
+ $characterLog->erase();
+ }
+ }else{
+ // character_log does not have a character assigned -> delete
+ $characterLog->erase();
+ }
+
+ $count++;
+ }
+ }
+
+ $importCount = $total;
+
+ $this->logEnd(__FUNCTION__, $total, $count, $importCount);
+ }
+
+ /**
+ * clean up outdated character data e.g. kicked until status
+ * >> php index.php "/cron/cleanUpCharacterData"
+ * @param \Base $f3
+ * @throws \Exception
+ */
+ function cleanUpCharacterData(\Base $f3){
+ $this->logStart(__FUNCTION__, false);
+
+ /**
+ * @var $characterModel Pathfinder\CharacterModel
+ */
+ $characterModel = Pathfinder\AbstractPathfinderModel::getNew('CharacterModel');
+
+ $characters = $characterModel->find([
+ 'active = :active AND TIMESTAMPDIFF(SECOND, kicked, NOW() ) > 0',
+ ':active' => 1
+ ]);
+
+ if(is_object($characters)){
+ foreach($characters as $character){
+ /**
+ * @var $character Pathfinder\CharacterModel
+ */
+ $character->kick();
+ $character->save();
+ }
+ }
+
+ $this->logEnd(__FUNCTION__);
+ }
+
+ /**
+ * delete expired character authentication data
+ * authentication data is used for cookie based login
+ * >> php index.php "/cron/deleteAuthenticationData"
+ * @param \Base $f3
+ * @throws \Exception
+ */
+ function deleteAuthenticationData(\Base $f3){
+ $this->logStart(__FUNCTION__, false);
+
+ /**
+ * @var $authenticationModel Pathfinder\CharacterAuthenticationModel
+ */
+ $authenticationModel = Pathfinder\AbstractPathfinderModel::getNew('CharacterAuthenticationModel');
+
+ // find expired authentication data
+ $authentications = $authenticationModel->find([
+ '(expires - NOW()) <= 0'
+ ]);
+
+ if(is_object($authentications)){
+ foreach($authentications as $authentication){
+ $authentication->erase();
+ }
+ }
+
+ $this->logEnd(__FUNCTION__);
+ }
+
+}
\ No newline at end of file
diff --git a/app/Cron/MapHistory.php b/app/Cron/MapHistory.php
new file mode 100644
index 000000000..70da7d97f
--- /dev/null
+++ b/app/Cron/MapHistory.php
@@ -0,0 +1,131 @@
+get('PATHFINDER.HISTORY.LOG_SIZE_THRESHOLD');
+ return ($logSize >= 0) ? ($logSize * 1024 * 1024) : self::LOG_SIZE_THRESHOLD;
+ }
+
+ /**
+ * get max log entries (lines) after truncate
+ * @param \Base $f3
+ * @return int
+ */
+ protected function getMaxLogLines(\Base $f3) : int {
+ $logLines = (int)$f3->get('PATHFINDER.HISTORY.LOG_LINES');
+ return ($logLines >= 0) ? $logLines : self::LOG_LINES;
+ }
+
+ /**
+ * truncate map history log files and keep size small
+ * >> php index.php "/cron/truncateMapHistoryLogFiles"
+ * @param \Base $f3
+ */
+ function truncateMapHistoryLogFiles(\Base $f3){
+ $this->logStart(__FUNCTION__);
+
+ $largeFiles = 0;
+ $notWritableFiles = 0;
+ $readErrors = 0;
+ $writeErrors = 0;
+ $renameErrors = 0;
+ $deleteErrors = 0;
+ $truncatedFileNames = [];
+
+ if($f3->exists('PATHFINDER.HISTORY.LOG', $dir)){
+ $fileHandler = FileHandler::instance();
+
+ $dir = $f3->fixslashes('./' . $dir . 'map/');
+ $files = Search::getFilesBySize($dir, $this->getMaxLogSize($f3));
+
+ // sort by file size
+ $files = new SortingIterator($files, function( \SplFileInfo $a, \SplFileInfo $b){
+ return $b->getSize() - $a->getSize();
+ });
+
+ // limit files count for truncate
+ $files = new \LimitIterator($files, 0, self::LOG_COUNT);
+
+ foreach($files as $filename => $file){
+ /**
+ * @var $file \SplFileInfo
+ */
+ if($file->isFile()){
+ $largeFiles++;
+ if($file->isWritable()){
+ // read newest logs from large files (reverse order) -> new log entries were appended...
+ $rowsData = $fileHandler->readFileReverse($file->getRealPath(), 0, self::LOG_LINES);
+ if(!empty($rowsData)){
+ // create temp file...
+ $temp = tempnam(sys_get_temp_dir(), 'map_');
+ // write newest logs into temp file...
+ $fileSizeNew = file_put_contents($temp, implode(PHP_EOL, array_reverse($rowsData)) . PHP_EOL, LOCK_EX);
+ if($fileSizeNew){
+ // move temp file from PHP temp dir into Pathfinders history log dir...
+ // ... overwrite old log file with new file
+ if(rename($temp, $file->getRealPath())){
+ $truncatedFileNames[] = $file->getFilename();
+ // map history logs should be writable for non cronjob user too
+ @chmod($file->getRealPath(), 0666);
+ }else{
+ $renameErrors++;
+ }
+ }else{
+ $writeErrors++;
+ }
+ }else{
+ $readErrors++;
+ }
+ }else{
+ $notWritableFiles++;
+ }
+ }
+ }
+ }
+
+ $importCount = $total = $largeFiles;
+ $count = count($truncatedFileNames);
+
+ // Log --------------------------------------------------------------------------------------------------------
+ $text = sprintf(self::LOG_TEXT, $largeFiles, implode(', ', $truncatedFileNames), $notWritableFiles, $readErrors, $writeErrors, $renameErrors, $deleteErrors);
+ $this->logEnd(__FUNCTION__, $total, $count, $importCount, 0, $text);
+ }
+}
\ No newline at end of file
diff --git a/app/Cron/MapUpdate.php b/app/Cron/MapUpdate.php
new file mode 100644
index 000000000..1d6196362
--- /dev/null
+++ b/app/Cron/MapUpdate.php
@@ -0,0 +1,230 @@
+> php index.php "/cron/deactivateMapData"
+ * @param \Base $f3
+ */
+ function deactivateMapData(\Base $f3){
+ $this->logStart(__FUNCTION__, false);
+ $privateMapLifetime = (int)Config::getMapsDefaultConfig('private.lifetime');
+
+ if($privateMapLifetime > 0){
+ if($pfDB = $f3->DB->getDB('PF')){
+ $sqlDeactivateExpiredMaps = "UPDATE map SET
+ active = 0
+ WHERE
+ map.active = 1 AND
+ map.typeId = 2 AND
+ TIMESTAMPDIFF(DAY, map.updated, NOW() ) > :lifetime";
+
+ $pfDB->exec($sqlDeactivateExpiredMaps, ['lifetime' => $privateMapLifetime]);
+ }
+ }
+
+ $this->logEnd(__FUNCTION__);
+ }
+
+ /**
+ * delete all deactivated maps
+ * >> php index.php "/cron/deleteMapData"
+ * @param \Base $f3
+ * @throws \Exception
+ */
+ function deleteMapData(\Base $f3){
+ $this->logStart(__FUNCTION__);
+ $total = 0;
+
+ if($pfDB = $f3->DB->getDB('PF')){
+ $sqlDeleteDisabledMaps = "SELECT
+ id
+ FROM
+ map
+ WHERE
+ map.active = 0 AND
+ TIMESTAMPDIFF(DAY, map.updated, NOW() ) > :deletion_time";
+
+ $disabledMaps = $pfDB->exec($sqlDeleteDisabledMaps, ['deletion_time' => self::DAYS_UNTIL_MAP_DELETION]);
+
+ if($total = $pfDB->count()){
+ $mapModel = Pathfinder\AbstractPathfinderModel::getNew('MapModel');
+ foreach($disabledMaps as $data){
+ $mapModel->getById( (int)$data['id'], 3, false );
+ if($mapModel->valid()){
+ $mapModel->erase();
+ }
+ $mapModel->reset();
+ }
+ }
+ }
+
+ $count = $importCount = $total;
+
+ // Log --------------------------------------------------------------------------------------------------------
+ $text = sprintf(self::LOG_TEXT_MAPS_DELETED, $total);
+ $this->logEnd(__FUNCTION__, $total, $count, $importCount, 0, $text);
+ }
+
+ /**
+ * delete expired EOL connections
+ * >> php index.php "/cron/deleteEolConnections"
+ * @param \Base $f3
+ * @throws \Exception
+ */
+ function deleteEolConnections(\Base $f3){
+ $this->logStart(__FUNCTION__, false);
+ $eolExpire = (int)$f3->get('PATHFINDER.CACHE.EXPIRE_CONNECTIONS_EOL');
+
+ $total = 0;
+ $count = 0;
+ if($eolExpire > 0){
+ if($pfDB = $f3->DB->getDB('PF')){
+ $sql = "SELECT
+ `con`.`id`
+ FROM
+ `connection` `con` INNER JOIN
+ `map` ON
+ `map`.`id` = `con`.`mapId`
+ WHERE
+ `map`.`deleteEolConnections` = :deleteEolConnections AND
+ TIMESTAMPDIFF(SECOND, `con`.`eolUpdated`, NOW() ) > :expire_time
+ ";
+
+ $connectionsData = $pfDB->exec($sql, [
+ 'deleteEolConnections' => 1,
+ 'expire_time' => $eolExpire
+ ]);
+
+ if($connectionsData){
+ $total = count($connectionsData);
+ /**
+ * @var $connection Pathfinder\ConnectionModel
+ */
+ $connection = Pathfinder\AbstractPathfinderModel::getNew('ConnectionModel');
+ foreach($connectionsData as $data){
+ $connection->getById( (int)$data['id'] );
+ if($connection->valid()){
+ $connection->erase();
+ $count++;
+ }
+ }
+ }
+ }
+ }
+
+ $importCount = $total;
+
+ $this->logEnd(__FUNCTION__, $total, $count, $importCount);
+ }
+
+ /**
+ * delete expired WH connections after max lifetime for wormholes is reached
+ * >> php index.php "/cron/deleteExpiredConnections"
+ * @param \Base $f3
+ * @throws \Exception
+ */
+ function deleteExpiredConnections(\Base $f3){
+ $this->logStart(__FUNCTION__, false);
+
+ $total = 0;
+ $count = 0;
+
+ $whExpire = (int)$f3->get('PATHFINDER.CACHE.EXPIRE_CONNECTIONS_WH');
+
+ if($whExpire > 0){
+ if($pfDB = $f3->DB->getDB('PF')){
+ $sql = "SELECT
+ `con`.`id`
+ FROM
+ `connection` `con` INNER JOIN
+ `map` ON
+ `map`.`id` = `con`.`mapId`
+ WHERE
+ `map`.`deleteExpiredConnections` = :deleteExpiredConnections AND
+ `con`.`scope` = :scope AND
+ TIMESTAMPDIFF(SECOND, `con`.`created`, NOW() ) > :expire_time
+ ";
+
+ $connectionsData = $pfDB->exec($sql, [
+ 'deleteExpiredConnections' => 1,
+ 'scope' => 'wh',
+ 'expire_time' => $whExpire
+ ]);
+
+ if($connectionsData){
+ $total = count($connectionsData);
+ /**
+ * @var $connection Pathfinder\ConnectionModel
+ */
+ $connection = Pathfinder\AbstractPathfinderModel::getNew('ConnectionModel');
+ foreach($connectionsData as $data){
+ $connection->getById( (int)$data['id'] );
+ if($connection->valid()){
+ $connection->erase();
+ $count++;
+ }
+ }
+ }
+ }
+ }
+
+ $importCount = $total;
+
+ $this->logEnd(__FUNCTION__, $total, $count, $importCount);
+ }
+
+ /**
+ * delete all expired signatures on "inactive" systems
+ * >> php index.php "/cron/deleteSignatures"
+ * @param \Base $f3
+ */
+ function deleteSignatures(\Base $f3){
+ $this->logStart(__FUNCTION__, false);
+ $signatureExpire = (int)$f3->get('PATHFINDER.CACHE.EXPIRE_SIGNATURES');
+
+ $count = 0;
+ if($signatureExpire > 0){
+ if($pfDB = $f3->DB->getDB('PF')){
+ $sqlDeleteExpiredSignatures = "DELETE `sigs` FROM
+ `system_signature` `sigs` INNER JOIN
+ `system` ON
+ `system`.`id` = `sigs`.`systemId`
+ WHERE
+ `system`.`active` = 0 AND
+ TIMESTAMPDIFF(SECOND, `sigs`.`updated`, NOW() ) > :lifetime
+ ";
+
+ $count = $pfDB->exec($sqlDeleteExpiredSignatures, ['lifetime' => $signatureExpire]);
+ }
+ }
+
+ $importCount = $total = $count;
+
+ $this->logEnd(__FUNCTION__, $total, $count, $importCount);
+ }
+
+}
\ No newline at end of file
diff --git a/app/Cron/StatisticsUpdate.php b/app/Cron/StatisticsUpdate.php
new file mode 100644
index 000000000..b2dc743ef
--- /dev/null
+++ b/app/Cron/StatisticsUpdate.php
@@ -0,0 +1,52 @@
+ older than 1 year
+ * >> php index.php "/cron/deleteStatisticsData"
+ * @param \Base $f3
+ */
+ function deleteStatisticsData(\Base $f3){
+ $this->logStart(__FUNCTION__);
+
+ $currentYear = (int)date('o');
+ $currentWeek = (int)date('W');
+ $expiredYear = $currentYear - 1;
+
+ $pfDB = $f3->DB->getDB('PF');
+
+ $queryData = [
+ 'yearWeekEnd' => strval($expiredYear) . str_pad($currentWeek, 2, 0, STR_PAD_LEFT)
+ ];
+
+ $sql = "DELETE FROM
+ activity_log
+ WHERE
+ CONCAT(`year`, `week`) < :yearWeekEnd";
+
+ $pfDB->exec($sql, $queryData);
+
+ $deletedLogsCount = $pfDB->count();
+
+ // Log --------------------------------------------------------------------------------------------------------
+ $total = $count = $importCount = $deletedLogsCount;
+
+ $text = sprintf(self::LOG_TEXT_STATISTICS, $deletedLogsCount);
+ $this->logEnd(__FUNCTION__, $total, $count, $importCount, 0, $text);
+ }
+}
\ No newline at end of file
diff --git a/app/Cron/Universe.php b/app/Cron/Universe.php
new file mode 100644
index 000000000..2caa9368b
--- /dev/null
+++ b/app/Cron/Universe.php
@@ -0,0 +1,396 @@
+echoFlush();
+ }
+
+ /**
+ * echo configuration
+ */
+ private function echoConfig(){
+ echo 'config ───────────────────────────────────────────────────────────────────────────────────────────────────────' . PHP_EOL;
+ echo 'max_execution_time : ' . ini_get('max_execution_time') . PHP_EOL;
+ echo 'memory_limit : ' . ini_get('memory_limit') . PHP_EOL;
+ $this->echoFlush();
+ }
+
+ /**
+ * echo information
+ * @param int $total
+ * @param int $offset
+ * @param int $importCount
+ * @param array $ids
+ */
+ private function echoInfo(int $total, int $offset, int $importCount, array $ids){
+ echo 'info ─────────────────────────────────────────────────────────────────────────────────────────────────────────' . PHP_EOL;
+ echo 'all data : ' . $total . PHP_EOL;
+ echo 'import offset : ' . $offset . PHP_EOL;
+ echo 'import count : ' . $importCount . PHP_EOL;
+ echo 'import chunk : ' . implode(',', $ids) . PHP_EOL;
+ $this->echoFlush();
+ }
+
+ /**
+ * echo start
+ */
+ private function echoStart(){
+ echo 'start ────────────────────────────────────────────────────────────────────────────────────────────────────────' . PHP_EOL;
+ $this->echoFlush();
+ }
+
+ /**
+ * echo loop start information
+ * @param int $count
+ * @param int $importCount
+ * @param int $id
+ */
+ private function echoLoading(int $count, int $importCount, int $id){
+ echo '[' . date('H:i:s') . '] loading... ' . $this->formatCounterValue($count) . '/' . $importCount . ' id: ' . $this->formatIdValue($id) . PHP_EOL;
+ $this->echoFlush();
+ }
+
+ /**
+ * echo loop finish information
+ * @param int $importCount
+ * @param int $id
+ * @param float $timeLoopStart
+ * @param float $timeTotalStart
+ */
+ private function echoLoaded(int $importCount, int $id, float $timeLoopStart, float $timeTotalStart){
+ $time = microtime(true);
+ echo '[' . date('H:i:s') . '] loaded ' . str_pad('', strlen($importCount), ' ') . ' id: ' . $this->formatIdValue($id) .
+ ' memory: ' . $this->formatMemoryValue(memory_get_usage()) .
+ ' time: ' . $this->formatSeconds($time - $timeLoopStart) .
+ ' total: ' . $this->formatSeconds($time - $timeTotalStart) . PHP_EOL;
+ $this->echoFlush();
+ }
+
+ /**
+ * echo finish information
+ * @param int $count
+ * @param int $importCount
+ * @param float $timeTotalStart
+ */
+ private function echoFinish(int $count, int $importCount, float $timeTotalStart){
+ echo 'finished ─────────────────────────────────────────────────────────────────────────────────────────────────────' . PHP_EOL;
+ echo '[' . date('H:i:s') . '] ' . $this->formatCounterValue($count) . '/' . $importCount .
+ ' peak: ' . $this->formatMemoryValue(memory_get_peak_usage ()) .
+ ' total: ' . $this->formatSeconds(microtime(true) - $timeTotalStart) . PHP_EOL;
+ $this->echoFlush();
+ }
+
+ /**
+ * imports static universe data from ESI
+ * >> php index.php "/cron/setup?type=system&offset=0&length=5"
+ * @param \Base $f3
+ * @throws \Exception
+ */
+ function setup(\Base $f3){
+ $params = (array)$f3->get('GET');
+ $type = (string)$params['type'];
+ $paramOffset = (int)$params['offset'];
+ $paramLength = (int)$params['length'];
+ $timeTotalStart = microtime(true);
+ $msg = '';
+
+ $ids = [];
+ $importCount = 0;
+ $count = 0;
+ $modelClass = '';
+ $setupModel = function(Model\Universe\AbstractUniverseModel &$model, int $id){};
+
+ switch($type){
+ case 'system':
+ // load systems + dependencies (planets, star, types,...)
+ $ids = $f3->ccpClient()->send('getUniverseSystems');
+ $modelClass = 'SystemModel';
+ $setupModel = function(Model\Universe\SystemModel &$model, int $id){
+ $model->loadById($id);
+ $model->loadPlanetsData();
+ };
+ break;
+ case 'stargate':
+ // load all stargates. Systems must be present first!
+ $ids = $f3->ccpClient()->send('getUniverseSystems');
+ $modelClass = 'SystemModel';
+ $setupModel = function(Model\Universe\SystemModel &$model, int $id){
+ $model->loadById($id);
+ $model->loadStargatesData();
+ };
+ break;
+ case 'station':
+ $ids = $f3->ccpClient()->send('getUniverseSystems');
+ $modelClass = 'SystemModel';
+ $setupModel = function(Model\Universe\SystemModel &$model, int $id){
+ if($model->getById($id)){
+ $model->loadStationsData();
+ }else{
+ echo 'NOT VALID ' . $id . PHP_EOL;
+ die();
+ }
+ };
+ break;
+ case 'sovereignty':
+ // load sovereignty map data. Systems must be present first!
+ $sovData = $f3->ccpClient()->send('getSovereigntyMap');
+ $ids = !empty($sovData = $sovData['map']) ? array_keys($sovData): [];
+ $modelClass = 'SystemModel';
+ $setupModel = function(Model\Universe\SystemModel &$model, int $id) use ($sovData) {
+ if($model->getById($id)){
+ $model->updateSovereigntyData($sovData[$id]);
+ }else{
+ echo 'NOT VALID ' . $id . PHP_EOL;
+ die();
+ }
+ };
+ break;
+ case 'faction_war_systems':
+ $fwSystems = $f3->ccpClient()->send('getFactionWarSystems');
+ $ids = !empty($fwSystems = $fwSystems['systems']) ? array_keys($fwSystems): [];
+ $modelClass = 'SystemModel';
+ $setupModel = function(Model\Universe\SystemModel &$model, int $id) use ($fwSystems) {
+ if($model->getById($id)){
+ $model->updateFactionWarData($fwSystems[$id]);
+ }else{
+ echo 'NOT VALID ' . $id . PHP_EOL;
+ die();
+ }
+ };
+ break;
+ case 'index_system':
+ // setup system index, Systems must be present first!
+ $ids = $f3->ccpClient()->send('getUniverseSystems');
+ $modelClass = 'SystemModel';
+ $setupModel = function(Model\Universe\SystemModel &$model, int $id){
+ $model->getById($id); // no loadById() here! would take "forever" when system not exists and must be build up first...
+ $model->buildIndex();
+ };
+ break;
+ default:
+ $msg = 'Model is not valid';
+ }
+
+ if($modelClass){
+ $this->echoParams($type, $paramOffset, $paramLength);
+ $this->echoConfig();
+
+ $total = count($ids);
+ $offset = ($paramOffset < 0) ? 0 : (($paramOffset >= $total) ? $total : $paramOffset);
+ $length = ($paramLength < 0) ? 0 : $paramLength;
+ sort($ids, SORT_NUMERIC);
+ $ids = array_slice($ids, $offset, $length);
+ $importCount = count($ids);
+ $count = 0;
+
+ $this->echoInfo($total, $offset, $importCount, $ids);
+ $this->echoStart();
+
+ /**
+ * @var $model Model\Universe\SystemModel
+ */
+ $model = Model\Universe\AbstractUniverseModel::getNew($modelClass);
+ foreach($ids as $id){
+ $timeLoopStart = microtime(true);
+ $this->echoLoading(++$count, $importCount, $id);
+ $setupModel($model, $id);
+ $model->reset();
+ $this->echoLoaded($importCount, $id, $timeLoopStart, $timeTotalStart);
+ }
+
+ $this->echoFinish($count, $importCount, $timeTotalStart);
+ }
+
+ // Log --------------------------------------------------------------------------------------------------------
+ $log = new \Log('cron_' . __FUNCTION__ . '.log');
+ $log->write(sprintf(self::LOG_TEXT, __FUNCTION__, $type,
+ $this->formatCounterValue($count), $importCount, $this->formatMemoryValue(memory_get_peak_usage ()),
+ $this->formatSeconds(microtime(true) - $timeTotalStart), $msg));
+ }
+
+ /**
+ * update Sovereignty system data from ESI
+ * -> this updates Faction warfare data as well
+ * >> php index.php "/cron/updateSovereigntyData"
+ * @param \Base $f3
+ * @throws \Exception
+ */
+ function updateSovereigntyData(\Base $f3){
+ $this->logStart(__FUNCTION__);
+ $params = $this->getParams(__FUNCTION__);
+
+ $timeTotalStart = microtime(true);
+ $msg = '';
+
+ /**
+ * @var $system Model\Universe\SystemModel
+ */
+ $system = Model\Universe\AbstractUniverseModel::getNew('SystemModel');
+
+ $sovData = $f3->ccpClient()->send('getSovereigntyMap');
+ $fwSystems = $f3->ccpClient()->send('getFactionWarSystems');
+ $fwSystems = $fwSystems['systems'];
+ $ids = !empty($sovData = $sovData['map']) ? array_keys($sovData): [];
+ sort($ids, SORT_NUMERIC);
+
+ $total = count($ids);
+ $offset = ($params['offset'] > 0 && $params['offset'] < $total) ? $params['offset'] : 0;
+ $ids = array_slice($ids, $offset, $params['length']);
+ $importCount = count($ids);
+ $count = 0;
+
+ $changes = [];
+ foreach($ids as $id){
+ // skip wormhole systems -> can not have sov data
+ // -> even though they are returned from sovereignty/map endpoint?!
+ if(
+ $system->getById($id, 0) &&
+ strpos($system->security, 'C') === false
+ ){
+ if($changedSovData = $system->updateSovereigntyData($sovData[$id])){
+ $changes['sovereignty'][] = $id;
+ }
+
+ $changedFwData = false;
+ if(is_array($fwSystems[$id])){
+ if($changedFwData = $system->updateFactionWarData($fwSystems[$id])){
+ $changes['factionWarfare'][] = $id;
+ }
+ }
+
+ if($changedSovData || $changedFwData){
+ $system->buildIndex();
+ }
+ }
+ $system->reset();
+
+ $count++;
+
+ // stop loop if runtime gets close to "max_execution_time"
+ // -> we need some time for writing *.log file
+ if(!$this->isExecutionTimeLeft($timeTotalStart)){
+ $msg = 'Script execution stopped due to "max_execution_time" limit reached';
+ break;
+ }
+ }
+
+ $changedIds = array_reduce($changes, function(array $reducedIds, array $changedIds) : array {
+ return array_unique(array_merge($reducedIds, $changedIds));
+ }, []);
+
+ // Log --------------------------------------------------------------------------------------------------------
+ $text = sprintf(self::LOG_TEXT_SOV_FW,
+ count($changedIds), count($changes['sovereignty'] ? : []), count($changes['factionWarfare'] ? : []),
+ $msg);
+
+ $this->logEnd(__FUNCTION__, $total, $count, $importCount, $offset, $text);
+ }
+
+ /**
+ * update static universe system data from ESI
+ * -> updates small chunk of systems at once
+ * >> php index.php "/cron/updateUniverseSystems"
+ * @param \Base $f3
+ * @throws \Exception
+ */
+ function updateUniverseSystems(\Base $f3){
+ $this->logStart(__FUNCTION__);
+ /**
+ * @var $systemModel Model\Universe\SystemModel
+ * @var $system Model\Universe\SystemModel
+ */
+ $systemModel = Model\Universe\AbstractUniverseModel::getNew('SystemModel');
+ $systems = $systemModel->find( null, ['order' => 'updated', 'limit' => 2]);
+ if($systems){
+ foreach ($systems as $system){
+ $system->updateModel();
+ }
+ }
+
+ $this->logEnd(__FUNCTION__);
+ }
+
+}
\ No newline at end of file
diff --git a/app/Data/File/FileHandler.php b/app/Data/File/FileHandler.php
new file mode 100644
index 000000000..e6ff1ddf9
--- /dev/null
+++ b/app/Data/File/FileHandler.php
@@ -0,0 +1,89 @@
+setFlags(\SplFileObject::DROP_NEW_LINE | \SplFileObject::READ_AHEAD | \SplFileObject::SKIP_EMPTY);
+
+ foreach( new \LimitIterator($file, 0, $limit) as $i => $rowData){
+ if(is_callable($rowParser)){
+ // custom parser for row data -> manipulate $data by ref
+ $rowParser($rowData, $data);
+ }else{
+ $data[] = $rowData;
+ }
+ }
+ }else{
+ \Base::instance()->error(500, sprintf(self::ERROR_STREAM_READABLE, $sourceFile));
+ }
+ }
+
+ return $data;
+ }
+
+ /**
+ * validate offset
+ * @param int $offset
+ * @return int
+ */
+ public static function validateOffset(int $offset): int{
+ if(
+ $offset < self::LOG_FILE_OFFSET_MIN ||
+ $offset > self::LOG_FILE_OFFSET_MAX
+ ){
+ $offset = self::LOG_FILE_OFFSET;
+ }
+ return $offset;
+ }
+
+ /**
+ * validate limit
+ * @param int $limit
+ * @return int
+ */
+ public static function validateLimit(int $limit): int{
+ if(
+ $limit < self::LOG_FILE_LIMIT_MIN ||
+ $limit > self::Log_File_LIMIT_MAX
+ ){
+ $limit = self::LOG_FILE_LIMIT;
+ }
+ return $limit;
+ }
+}
\ No newline at end of file
diff --git a/app/Data/File/ReverseSplFileObject.php b/app/Data/File/ReverseSplFileObject.php
new file mode 100644
index 000000000..829ae0279
--- /dev/null
+++ b/app/Data/File/ReverseSplFileObject.php
@@ -0,0 +1,219 @@
+ 'start with 2nd last line')
+ * @var int
+ */
+ protected $offset = 0;
+
+ /**
+ * total lines found in file
+ * @var int
+ */
+ protected $lineCount = 0;
+
+ /**
+ * empty lines found in file
+ * @var int
+ */
+ protected $lineCountEmpty = 0;
+
+ /**
+ * current pointer position
+ * @var int
+ */
+ protected $pointer = 0;
+
+ /**
+ * position increments when valid row data found
+ * @var
+ */
+ protected $position;
+
+ /**
+ * control characters
+ * @var array
+ */
+ protected $eol = ["\r", "\n"];
+
+ public function __construct($sourceFile, $offset = 0){
+ parent::__construct($sourceFile);
+
+ // set total line count of the file
+ $this->setLineCount();
+
+ //Seek to the first position of the file and record its position
+ //Should be 0
+ $this->fseek(0);
+ $this->begin = $this->ftell();
+ $this->offset = $offset;
+
+ //Seek to the last position from the end of the file
+ //This varies depending on the file
+ $this->fseek($this->pointer, SEEK_END);
+ }
+
+ /**
+ * reverse rewind file.
+ */
+ public function rewind(){
+ //Set the line position to 0 - First Line
+ $this->position = 0;
+
+ //Reset the file pointer to the end of the file minus 1 character. "0" == false
+ $this->fseek(-1, SEEK_END);
+
+ $this->findLineBegin();
+ //... File pointer is now at the beginning of the last line that contains data
+
+ // add custom line offset
+ if($this->offset){
+ // calculate offset start line
+ $offsetLine = $this->lineCount - $this->lineCountEmpty - $this->offset;
+
+ if($offsetLine > 0){
+ // row is zero based
+ $offsetIndex = $offsetLine - 1;
+
+ parent::seek($offsetIndex);
+ // seek() sets pointer to next line... set it back to previous
+ $this->fseek(-2, SEEK_CUR);
+
+ $this->findLineBegin();
+ //... File pointer is now at the beginning of the last line that contains data from $offset
+ }else{
+ // negative offsetLine -> invalid!
+ $this->pointer = $this->begin -1;
+ }
+
+ }
+ }
+
+ /**
+ * Return the current line after the file pointer
+ * @return string
+ */
+ public function current(){
+ return trim($this->fgets());
+ }
+
+ /**
+ * Return the current key of the line we're on
+ * These go in reverse order
+ * @return mixed
+ */
+ public function key(){
+ return $this->position;
+ }
+
+ /**
+ * move one line up
+ */
+ public function next(){
+ //Step the file pointer back one step to the last letter of the previous line
+ --$this->pointer;
+ if($this->pointer < $this->begin){
+ return;
+ }
+
+ $this->fseek($this->pointer);
+
+ $this->findLineBegin();
+
+ //File pointer is now on the next previous line
+ //Increment the line position
+ ++$this->position;
+ }
+
+ /**
+ * Check the current file pointer to make sure we are not at the beginning of the file
+ * @return bool
+ */
+ public function valid(){
+ return ($this->pointer >= $this->begin);
+ }
+
+ /**
+ * seek to previous lines
+ * @param int $lineCount
+ */
+ public function seek($lineCount){
+ for($i = 0; $i < $lineCount; $i++){
+ $this->next();
+ }
+ }
+
+ /**
+ * move pointer to line begin
+ * -> skip line breaks
+ */
+ private function findLineBegin(){
+ //Check the character over and over till we hit another new line
+ $c = $this->fgetc();
+
+ // skip empty lines
+ while(in_array($c, $this->eol)){
+ $this->fseek(-2, SEEK_CUR);
+ if(!$this->pointer = $this->ftell()){
+ break;
+ }
+ $c = $this->fgetc();
+
+ $this->lineCountEmpty++;
+ }
+
+ //Check the last character to make sure it is not a new line
+ while(!in_array($c, $this->eol)){
+ $this->fseek(-2, SEEK_CUR);
+ if(!$this->pointer = $this->ftell()){
+ break;
+ }
+ $c = $this->fgetc();
+ }
+ }
+
+ /**
+ * set total line count. No matter if there are empty lines in between
+ */
+ private function setLineCount(){
+ // Store flags and position
+ $flags = $this->getFlags();
+ $currentPointer = $this->ftell();
+
+ // Prepare count by resetting flags as READ_CSV for example make the tricks very slow
+ $this->setFlags(null);
+
+ // Go to the larger INT we can as seek will not throw exception, errors, notice if we go beyond the bottom line
+ //$this->seek(PHP_INT_MAX);
+ parent::seek(PHP_INT_MAX);
+
+ // We store the key position
+ // As key starts at 0, we add 1
+ $this->lineCount = parent::key() + 1;
+
+ // We move to old position
+ // As seek method is longer with line number < to the max line number, it is better to count at the beginning of iteration
+ //parent::seek($currentPointer);
+ $this->fseek($currentPointer);
+
+ // Re set flags
+ $this->setFlags($flags);
+ }
+}
\ No newline at end of file
diff --git a/app/Data/Filesystem/Search.php b/app/Data/Filesystem/Search.php
new file mode 100644
index 000000000..2872faa6f
--- /dev/null
+++ b/app/Data/Filesystem/Search.php
@@ -0,0 +1,90 @@
+isFile() || // allow recursion
+ (
+ strpos($current->getFilename(), '.') !== 0 && // skip e.g. ".gitignore"
+ $current->getMTime() < $mTime // filter last modification date
+ )
+ ){
+ return true;
+ }
+ return false;
+ };
+
+ return self::getFilesByCallback($dir, $filterCallback, $limit);
+ }
+
+ /**
+ * recursive file filter by size
+ * @param string $dir
+ * @param int $size
+ * @param int $limit
+ * @return \Traversable
+ */
+ static function getFilesBySize(string $dir, int $size = 0, int $limit = self::DEFAULT_FILE_LIMIT) : \Traversable {
+
+ $filterCallback = function($current, $key, $iterator) use ($size) {
+ /**
+ * @var $current \RecursiveDirectoryIterator
+ */
+ if (
+ !$current->isFile() || // allow recursion
+ (
+ strpos($current->getFilename(), '.') !== 0 && // skip e.g. ".gitignore"
+ $current->getSize() > $size // filter file size
+ )
+ ){
+ return true;
+ }
+ return false;
+ };
+
+ return self::getFilesByCallback($dir, $filterCallback, $limit);
+ }
+
+ /**
+ * @param string $dir
+ * @param \Closure $filterCallback
+ * @param int $limit
+ * @return \Traversable
+ */
+ private static function getFilesByCallback(string $dir, \Closure $filterCallback, int $limit = self::DEFAULT_FILE_LIMIT) : \Traversable {
+ $files = new \ArrayIterator();
+ if(is_dir($dir)){
+ $directory = new \RecursiveDirectoryIterator( $dir, \FilesystemIterator::SKIP_DOTS );
+ $files = new \RecursiveCallbackFilterIterator($directory, $filterCallback);
+ }
+ return new \LimitIterator($files, 0, $limit);
+ }
+}
\ No newline at end of file
diff --git a/app/Data/Mapper/AbstractIterator.php b/app/Data/Mapper/AbstractIterator.php
new file mode 100644
index 000000000..f81b02e49
--- /dev/null
+++ b/app/Data/Mapper/AbstractIterator.php
@@ -0,0 +1,126 @@
+ overwrite in child classes (late static binding)
+ * @var array
+ */
+ protected static $map = [];
+
+ /**
+ * remove unmapped values from Array
+ * -> see $map
+ * @var bool
+ */
+ protected static $removeUnmapped = true;
+
+ /**
+ * AbstractIterator constructor.
+ * @param $data
+ */
+ function __construct($data){
+ parent::__construct($data, \RecursiveIteratorIterator::SELF_FIRST);
+ }
+
+ /**
+ * map iterator
+ * @return array
+ */
+ public function getData(){
+ iterator_apply($this, 'self::recursiveIterator', [$this]);
+
+ return iterator_to_array($this, true);
+ }
+
+ /**
+ * convert array keys to camelCase
+ * @param $array
+ * @return array
+ */
+ protected function camelCaseKeys($array){
+ return Util::arrayChangeKeys($array, [\Base::instance(), 'camelcase']);
+ }
+
+ /**
+ * recursive iterator function called on every node
+ * @param AbstractIterator $iterator
+ * @return AbstractIterator
+ */
+ static function recursiveIterator(AbstractIterator $iterator){
+
+ $keyWhitelist = array_keys(static::$map);
+
+ while($iterator->valid()){
+
+ if( isset(static::$map[$iterator->key()]) ){
+ $mapValue = static::$map[$iterator->key()];
+
+ // check for mapping key
+ if(
+ $iterator->hasChildren() &&
+ Util::is_assoc($iterator->current())
+ ){
+ // recursive call for child elements
+ $iterator->offsetSet($iterator->key(), forward_static_call(array('self', __METHOD__), $iterator->getChildren())->getArrayCopy());
+ $iterator->next();
+ }elseif(is_array($mapValue)){
+ // a -> array mapping
+ $parentKey = array_keys($mapValue)[0];
+ $entryKey = array_values($mapValue)[0];
+
+ // check if key already exists
+ if($iterator->offsetExists($parentKey)){
+ $currentValue = $iterator->offsetGet($parentKey);
+ // add new array entry
+ $currentValue[$entryKey] = $iterator->current();
+ $iterator->offsetSet($parentKey, $currentValue);
+ }else{
+ $iterator->offsetSet($parentKey, [$entryKey => $iterator->current()]);
+ $keyWhitelist[] = $parentKey;
+ }
+
+ $iterator->offsetUnset($iterator->key());
+ }elseif(is_object($mapValue)){
+ // a -> a (format by function)
+ $formatFunction = $mapValue;
+ $iterator->offsetSet($iterator->key(), call_user_func($formatFunction, $iterator));
+
+ // just value change no key change
+ $iterator->next();
+ }elseif($mapValue !== $iterator->key()){
+ // a -> b mapping (key changed)
+ $iterator->offsetSet($mapValue, $iterator->current());
+ $iterator->offsetUnset($iterator->key());
+ $keyWhitelist[] = $mapValue;
+ }else{
+ // a -> a (no changes)
+ $iterator->next();
+ }
+
+ }elseif(
+ static::$removeUnmapped &&
+ !in_array($iterator->key(), $keyWhitelist)
+ ){
+ $iterator->offsetUnset($iterator->key());
+ }else{
+ $iterator->next();
+ }
+
+ }
+
+ return $iterator;
+ }
+
+}
\ No newline at end of file
diff --git a/app/Data/Mapper/SortingIterator.php b/app/Data/Mapper/SortingIterator.php
new file mode 100644
index 000000000..ceb4b7f3f
--- /dev/null
+++ b/app/Data/Mapper/SortingIterator.php
@@ -0,0 +1,20 @@
+uasort($callback);
+ }
+}
\ No newline at end of file
diff --git a/app/Db/Sql/Mysql/Session.php b/app/Db/Sql/Mysql/Session.php
new file mode 100644
index 000000000..cce602ecd
--- /dev/null
+++ b/app/Db/Sql/Mysql/Session.php
@@ -0,0 +1,46 @@
+ We use this "custom" SQl rather than the default in parent::__construct()
+ // because of the defaults 'data' column type TEXT
+ $dbName = $db->name();
+
+ $sql = "CREATE TABLE IF NOT EXISTS ";
+ $sql .= $dbName ? $db->quotekey($dbName,FALSE) . "." : "";
+ $sql .= $db->quotekey($table,FALSE) . " (";
+ $sql .= $db->quotekey('session_id') . " VARCHAR(255),";
+ $sql .= $db->quotekey('data') . " MEDIUMTEXT,";
+ $sql .= $db->quotekey('ip') . " VARCHAR(45),";
+ $sql .= $db->quotekey('agent') . " VARCHAR(300),";
+ $sql .= $db->quotekey('stamp') . " INT(11),";
+ $sql .= "PRIMARY KEY (" . $db->quotekey('session_id') . ")";
+ $sql .= ");";
+
+ $db->exec($sql);
+ }
+
+ // $force = false for parent constructor -> skip default create SQL
+ parent::__construct($db, $table, false, $onsuspect, $key);
+ }
+}
\ No newline at end of file
diff --git a/app/Db/Sql/Mysql/tablemodifier.php b/app/Db/Sql/Mysql/tablemodifier.php
new file mode 100644
index 000000000..7e3312efa
--- /dev/null
+++ b/app/Db/Sql/Mysql/tablemodifier.php
@@ -0,0 +1,337 @@
+ if §constraint is passed, constraints are limited to that column
+ * @param null| Constraint $constraint
+ * @return Constraint[]
+ */
+ public function listConstraint($constraint = null){
+
+ $constraintName = '%';
+ $keys = [];
+ if($constraint instanceof Constraint){
+ // list constraints for given column in this table
+ $constraintName = $constraint->getConstraintName() . '%';
+ $keys = $constraint->getKeys();
+ }
+
+ $this->db->exec("USE information_schema");
+ $constraintsData = $this->db->exec("
+ SELECT
+ *
+ FROM
+ referential_constraints
+ WHERE
+ constraint_schema = :db AND
+ table_name = :table AND
+ constraint_name LIKE :constraint_name
+ ", [
+ ':db' => $this->db->name(),
+ ':table' => $this->name,
+ ':constraint_name' => $constraintName
+ ]);
+ // switch back to current DB
+ $this->db->exec("USE " . $this->db->quotekey($this->db->name()));
+
+ $constraints = [];
+ foreach($constraintsData as $data){
+ $constraints[$data['CONSTRAINT_NAME']] = new Constraint($this, $keys, $data['REFERENCED_TABLE_NAME'] );
+ }
+
+ return $constraints;
+ }
+
+ /**
+ * checks whether a constraint name exists or not
+ * -> does not check constraint params
+ * @param Constraint $constraint
+ * @return bool
+ */
+ public function constraintExists($constraint){
+ $constraints = $this->listConstraint();
+ return array_key_exists($constraint->getConstraintName(), $constraints);
+ }
+
+ /**
+ * drop foreign key constraint
+ * @param Constraint $constraint
+ */
+ public function dropConstraint($constraint){
+ if($constraint->isValid()){
+ $this->queries[] = "ALTER TABLE " . $this->db->quotekey($this->name) . "
+ DROP FOREIGN KEY " . $this->db->quotekey($constraint->getConstraintName()) . ";";
+ }else{
+ trigger_error(sprintf(self::TEXT_ConstraintNotValid, 'table: ' . $this->name . ' constraintName: ' . $constraint->getConstraintName()));
+ }
+ }
+
+ /**
+ * Add/Update foreign key constraint
+ * @param Constraint $constraint
+ */
+ public function addConstraint($constraint){
+
+ if($constraint->isValid()){
+ $this->queries[] = "
+ ALTER TABLE " . $this->db->quotekey($this->name) . "
+ ADD CONSTRAINT " . $this->db->quotekey($constraint->getConstraintName()) . "
+ FOREIGN KEY (" . implode(', ', $constraint->getKeys()) . ")
+ REFERENCES " . $this->db->quotekey($constraint->getReferencedTable()) . " (" . implode(', ', $constraint->getReferencedCols()) . ")
+ ON DELETE " . $constraint->getOnDelete() . "
+ ON UPDATE " . $constraint->getOnUpdate() . ";";
+ }else{
+ trigger_error(sprintf(self::TEXT_ConstraintNotValid, 'table: ' . $this->name . ' constraintName: ' . $constraint->getConstraintName()));
+ }
+ }
+}
+
+/**
+ * Class Column
+ * @package DB\SQL\MySQL
+ */
+class Column extends SQL\Column {
+
+ const TEXT_TableNameMissing = 'Table name missing for FOREIGN KEY in `%s`';
+
+ /**
+ * drop constraint from this column
+ * @param Constraint $constraint
+ */
+ public function dropConstraint(Constraint $constraint){
+ $this->table->dropConstraint($constraint);
+ }
+
+ /**
+ * add constraint to this column
+ * @param Constraint $constraint
+ */
+ public function addConstraint(Constraint $constraint){
+ $this->table->addConstraint($constraint);
+ }
+
+ /**
+ * @param Constraint $constraint
+ * @return mixed
+ */
+ public function constraintExists(Constraint $constraint){
+ return $this->table->constraintExists($constraint);
+ }
+
+ /**
+ * get a new column based constraint
+ * $constraintData['table'] => referenceTable name (required)
+ * $constraintData['id'] => referenceColumns (optional) default: ['id']
+ * $constraintData['on-delete'] => ON DELETE action (optional) default: see \DB\SQL\MySQL\Constraint const
+ * $constraintData['on-update'] => ON UPDATE action (optional) default: see \DB\SQL\MySQL\Constraint const
+ *
+ * @param array $constraintData
+ * @return Constraint
+ */
+ public function newConstraint($constraintData){
+
+ $constraint = null;
+
+ if(isset($constraintData['table'])){
+ if(isset($constraintData['column'])){
+ $constraintData['column'] = (array)$constraintData['column'];
+ }else{
+ $constraintData['column'] = ['id'];
+ }
+
+ $constraint = new Constraint($this->table, $this->name, $constraintData['table'], $constraintData['column']);
+
+ if(isset($constraintData['on-delete'])){
+ $constraint->setOnDelete($constraintData['on-delete']);
+ }
+
+ if(isset($constraintData['on-update'])){
+ $constraint->setOnUpdate($constraintData['on-update']);
+ }
+
+ }else{
+ trigger_error(sprintf(self::TEXT_TableNameMissing, $this->table->name . '->' . $this->name));
+ }
+
+ return $constraint;
+ }
+}
+
+class Constraint {
+
+ // available actions
+ const ACTIONS_DELETE = ['RESTRICT', 'CASCADE', 'SET NULL', 'NO ACTION'];
+ const ACTIONS_UPDATE = ['RESTRICT', 'CASCADE', 'SET NULL', 'NO ACTION'];
+
+ // default actions
+ const ACTION_DELETE = 'RESTRICT';
+ const ACTION_UPDATE = 'RESTRICT';
+
+ const TEXT_ActionNotSupported = 'Constraint action `%s` is not supported.';
+
+ protected $table;
+ protected $keys = [];
+ protected $referencedTable = '';
+ protected $referencedCols = [];
+ protected $onDelete = self::ACTION_DELETE;
+ protected $onUpdate = self::ACTION_UPDATE;
+
+ /**
+ * Constraint constructor.
+ * @param SQL\TableBuilder $table
+ * @param array $keys
+ * @param string $referencedTable
+ * @param array $referencedCols
+ */
+ public function __construct(SQL\TableBuilder $table, $keys = [], $referencedTable = '', $referencedCols = ['id']){
+ $this->table = &$table;
+ $this->setKeys($keys);
+ $this->setReferencedTable($referencedTable);
+ $this->setReferencedCols($referencedCols);
+ }
+
+ /**
+ * @param mixed $keys
+ */
+ public function setKeys($keys){
+ $this->keys = (array)$keys;
+ }
+
+ /**
+ * @param mixed $referencedTable
+ */
+ public function setReferencedTable($referencedTable){
+ $this->referencedTable = $referencedTable;
+ }
+
+ /**
+ * @param mixed $referencedCols
+ */
+ public function setReferencedCols($referencedCols){
+ $this->referencedCols = (array)$referencedCols;
+ }
+
+ /**
+ * @param string $onDelete
+ */
+ public function setOnDelete($onDelete){
+ if( in_array($onDelete, self::ACTIONS_DELETE) ){
+ $this->onDelete = $onDelete;
+ }else{
+ trigger_error(sprintf(self::TEXT_ActionNotSupported, $onDelete));
+ }
+ }
+
+ /**
+ * @param string $onUpdate
+ */
+ public function setOnUpdate($onUpdate){
+ if( in_array($onUpdate, self::ACTIONS_UPDATE) ){
+ $this->onUpdate = $onUpdate;
+ }else{
+ trigger_error(sprintf(self::TEXT_ActionNotSupported, $onUpdate));
+ }
+ }
+
+ /**
+ * @return array
+ */
+ public function getKeys(){
+ return $this->keys;
+ }
+
+ /**
+ * @return string
+ */
+ public function getReferencedTable(){
+ return $this->referencedTable;
+ }
+
+ /**
+ * @return array
+ */
+ public function getReferencedCols(){
+ return $this->referencedCols;
+ }
+
+ /**
+ * @return string
+ */
+ public function getOnDelete(){
+ return $this->onDelete;
+ }
+
+ /**
+ * @return string
+ */
+ public function getOnUpdate(){
+ return $this->onUpdate;
+ }
+
+ /**
+ * get a constraint name for this table.
+ * This can either be used to generate unique constraint names for foreign keys in parent tables
+ * or generate a "part" of a name. e.g. for db-Query all constraints of this table (ignore columns)
+ * by "LIKE" selecting "information_schema"
+ * -> To get a certain constraint or generate a unique constraint, ALL params are required!
+ * @return string
+ */
+ public function getConstraintName(){
+ $constraintName = 'fk_' . $this->table->name;
+
+ if(!empty($this->getKeys())){
+ $constraintName .= '___' . implode('__', $this->getKeys());
+ if(!empty($this->getReferencedTable())){
+ $constraintName .= '___' . $this->getReferencedTable();
+ if(!empty($this->getReferencedCols())){
+ $constraintName .= '___' . implode('__', $this->getReferencedCols());
+ }
+ }
+ }
+
+ return $constraintName;
+ }
+
+ /**
+ * checks if constraint is valid
+ * -> all required members must be set!
+ * @return bool
+ */
+ public function isValid(){
+ $valid = false;
+
+ if(
+ !empty($this->getKeys()) &&
+ !empty($this->getReferencedTable()) &&
+ !empty($this->getReferencedCols())
+ ){
+ $valid = true;
+ }
+
+ return $valid;
+ }
+
+
+}
\ No newline at end of file
diff --git a/app/Exception/ConfigException.php b/app/Exception/ConfigException.php
new file mode 100644
index 000000000..d61d89e40
--- /dev/null
+++ b/app/Exception/ConfigException.php
@@ -0,0 +1,21 @@
+ 500
+ ];
+
+}
\ No newline at end of file
diff --git a/app/Exception/DatabaseException.php b/app/Exception/DatabaseException.php
new file mode 100644
index 000000000..79ac3fe39
--- /dev/null
+++ b/app/Exception/DatabaseException.php
@@ -0,0 +1,28 @@
+ 500
+ ];
+
+ /**
+ * DatabaseException constructor.
+ * @param string $message
+ */
+ public function __construct(string $message){
+ parent::__construct($message, 1500);
+ }
+}
\ No newline at end of file
diff --git a/app/Exception/DateException.php b/app/Exception/DateException.php
new file mode 100644
index 000000000..823c49a4b
--- /dev/null
+++ b/app/Exception/DateException.php
@@ -0,0 +1,20 @@
+ 500 // invalid DateRange
+ ];
+}
\ No newline at end of file
diff --git a/app/Exception/PathfinderException.php b/app/Exception/PathfinderException.php
new file mode 100644
index 000000000..a65164f29
--- /dev/null
+++ b/app/Exception/PathfinderException.php
@@ -0,0 +1,67 @@
+ can be specified by using custom Exception codes
+ */
+ const DEFAULT_RESPONSECODE = 500;
+
+ /**
+ * lists all exception codes
+ * @var array
+ */
+ protected $codes = [
+ 0 => self::DEFAULT_RESPONSECODE
+ ];
+
+ /**
+ * PathfinderException constructor.
+ * @param string $message
+ * @param int $code
+ */
+ public function __construct(string $message, int $code = 0){
+ if( !array_key_exists($code, $this->codes) ){
+ // exception code not specified by child class
+ $code = 0;
+ }
+ parent::__construct($message, $code);
+ }
+
+ /**
+ * get error object
+ * @return \stdClass
+ */
+ public function getError() : \stdClass {
+ $error = (object) [];
+ $error->type = 'error';
+ $error->code = $this->getResponseCode();
+ $error->status = Config::getHttpStatusByCode($this->getResponseCode());
+ $error->text = $this->getMessage();
+ if(\Base::instance()->get('DEBUG') >= 1){
+ $error->trace = preg_split('/\R/', $this->getTraceAsString()); // no $this->>getTrace() here -> to much data
+ }
+ return $error;
+ }
+
+ /**
+ * returns the HTTP response code for the client from exception
+ * -> if Exception is not handled/catched 'somewhere' this code is used by the final onError handler
+ * @return int
+ */
+ public function getResponseCode() : int {
+ return $this->codes[$this->getCode()];
+ }
+}
\ No newline at end of file
diff --git a/app/Exception/RegistrationException.php b/app/Exception/RegistrationException.php
new file mode 100644
index 000000000..bd0057b2d
--- /dev/null
+++ b/app/Exception/RegistrationException.php
@@ -0,0 +1,46 @@
+ 403
+ ];
+
+ /**
+ * form field name that causes this exception
+ * @var string
+ */
+ private $field;
+
+ /**
+ * RegistrationException constructor.
+ * @param string $message
+ * @param string $field
+ */
+ public function __construct(string $message, string $field = ''){
+ parent::__construct($message, 2000);
+ $this->field = $field;
+ }
+
+ /**
+ * get error object
+ * @return \stdClass
+ */
+ public function getError() : \stdClass {
+ $error = parent::getError();
+ $error->field = $this->field;
+ return $error;
+ }
+}
\ No newline at end of file
diff --git a/app/Exception/ValidationException.php b/app/Exception/ValidationException.php
new file mode 100644
index 000000000..b28e1b804
--- /dev/null
+++ b/app/Exception/ValidationException.php
@@ -0,0 +1,46 @@
+ 422
+ ];
+
+ /**
+ * table column that triggers the exception
+ * @var string
+ */
+ private $field;
+
+ /**
+ * ValidationException constructor.
+ * @param string $message
+ * @param string $field
+ */
+ public function __construct(string $message, string $field = ''){
+ parent::__construct($message, 2000);
+ $this->field = $field;
+ }
+
+ /**
+ * get error object
+ * @return \stdClass
+ */
+ public function getError() : \stdClass {
+ $error = parent::getError();
+ $error->field = $this->field;
+ return $error;
+ }
+}
\ No newline at end of file
diff --git a/app/Lib/Api/AbstractClient.php b/app/Lib/Api/AbstractClient.php
new file mode 100644
index 000000000..6579fa52b
--- /dev/null
+++ b/app/Lib/Api/AbstractClient.php
@@ -0,0 +1,367 @@
+ can be Redis, Filesystem or Array cachePool
+ * -> used by e.g. GuzzleCacheMiddleware
+ * @var CacheItemPoolInterface|null
+ */
+ protected $cachePool = null;
+
+ /**
+ * @param \Base $f3
+ * @return ApiInterface|null
+ */
+ abstract protected function getClient(\Base $f3) : ?ApiInterface;
+
+ /**
+ * get userAgent
+ * @return string
+ */
+ protected function getUserAgent() : string {
+ $userAgent = '';
+ $userAgent .= Config::getPathfinderData('name');
+ $userAgent .= ' - ' . Config::getPathfinderData('version');
+ $userAgent .= ' | ' . Config::getPathfinderData('contact');
+ $userAgent .= ' (' . $_SERVER['SERVER_NAME'] . ')';
+ return $userAgent;
+ }
+
+ /**
+ * returns a new Log object used within the Api for logging
+ * @return \Closure
+ */
+ protected function newLog() : \Closure {
+ return function(string $action, string $level = 'warning') : Logging\LogInterface {
+ $log = new Logging\ApiLog($action, $level);
+ $log->addHandler('stream', 'json', $this->getStreamConfig($action));
+ return $log;
+ };
+ }
+
+ /**
+ * returns a new instance of PSR-6 compatible CacheItemPoolInterface
+ * -> this Cache backend will be used across Guzzle Middleware
+ * e.g. GuzzleCacheMiddleware
+ * @see http://www.php-cache.com
+ * @param \Base $f3
+ * @return \Closure
+ */
+ protected function getCachePool(\Base $f3) : \Closure {
+ // determine cachePool options
+ $poolConfig = $this->getCachePoolConfig($f3);
+
+ return function() use ($poolConfig) : ?CacheItemPoolInterface {
+ // an active CachePool should be re-used
+ // -> no need for e.g. a new Redis->pconnect()
+ // and/or re-init when it is used the next time
+ if(!is_null($this->cachePool)){
+ return $this->cachePool;
+ }
+
+ // Redis is preferred option (best performance) -----------------------------------------------------------
+ if(
+ $poolConfig['type'] == 'redis' &&
+ extension_loaded('redis') &&
+ class_exists('\Redis') &&
+ class_exists(RedisCachePool::class)
+ ){
+ $client = new \Redis();
+ if(
+ $client->pconnect(
+ $poolConfig['host'],
+ $poolConfig['port'],
+ Config::REDIS_OPT_TIMEOUT,
+ null,
+ Config::REDIS_OPT_RETRY_INTERVAL,
+ Config::REDIS_OPT_READ_TIMEOUT
+ )
+ ){
+ if(!empty($poolConfig['auth'])){
+ $client->auth($poolConfig['auth']);
+ }
+
+ if(isset($poolConfig['tag'])){
+ $name = 'pathfinder|php|tag:' . strtolower($poolConfig['tag']) . '|pid:' . getmypid();
+ $client->client('setname', $name);
+ }
+
+ if(isset($poolConfig['db'])){
+ $client->select($poolConfig['db']);
+ }
+ $poolRedis = new RedisCachePool($client);
+
+ // RedisCachePool supports "Hierarchy" store slots
+ // -> "Hierarchy" support is required to use it in a NamespacedCachePool
+ // This helps to separate keys by a namespace
+ // @see http://www.php-cache.com/en/latest/
+ $this->cachePool = new NamespacedCachePool($poolRedis, static::CLIENT_NAME);
+
+ register_shutdown_function([$this,'unloadCache'], $client);
+ }
+ }
+
+ // Filesystem is second option and fallback for failed Redis pool -----------------------------------------
+ if(
+ is_null($this->cachePool) &&
+ in_array($poolConfig['type'], ['redis', 'folder']) &&
+ class_exists(FilesystemCachePool::class)
+ ){
+ $filesystemAdapter = new Local(\Base::instance()->get('ROOT'));
+ $filesystem = new Filesystem($filesystemAdapter);
+ $poolFilesystem = new FilesystemCachePool($filesystem, $poolConfig['folder']);
+
+ $this->cachePool = $poolFilesystem;
+ }
+
+ // Array cache pool fallback (not persistent) -------------------------------------------------------------
+ if(
+ is_null($this->cachePool) &&
+ in_array($poolConfig['type'], ['redis', 'folder', 'array']) &&
+ class_exists(ArrayCachePool::class)
+ ){
+ $this->cachePool = new ArrayCachePool(2000);
+ }
+
+ return $this->cachePool;
+ };
+ }
+
+ /**
+ * get cachePool config from [D]ata [S]ource [N]ame string
+ * @param \Base $f3
+ * @return array
+ */
+ protected function getCachePoolConfig(\Base $f3) : array {
+ $tag = 'API_CACHE';
+ $dsn = (string)$f3->get($tag);
+
+ // fallback
+ $conf = ['type' => 'array'];
+
+ if(!empty($folder = (string)$f3->get('TEMP'))){
+ // filesystem (better than 'array' cache)
+ $conf = [
+ 'type' => 'folder',
+ 'folder' => $folder . 'cache/'
+ ];
+ }
+
+ // redis or filesystem -> overwrites $conf
+ Config::parseDSN($dsn, $conf);
+
+ // tag name is used as alias name e.g. for debugging
+ // -> e.g. for Redis https://redis.io/commands/client-setname
+ $conf['tag'] = $tag;
+
+ return $conf;
+ }
+
+ /**
+ * return callback function that expects a $request and checks
+ * whether it should be logged (in case of errors)
+ * @param \Base $f3
+ * @return \Closure
+ */
+ protected function isLoggable(\Base $f3) : \Closure {
+ return function(RequestInterface $request) use ($f3) : bool {
+ // we need the timestamp for $request that should be checked
+ // -> we assume $request was "recently" send. -> current server time is used for check
+ $requestTime = $f3->get('getDateTime')();
+ // ... "interpolate" time to short interval
+ // -> this might help to re-use sequential calls of this method
+ Util::roundToInterval($requestTime);
+ // check if request was send within ESI downTime range
+ // -> errors during downTime should not be logged
+ $inDowntimeRange = Config::inDownTimeRange($requestTime);
+
+ return !$inDowntimeRange;
+ };
+ }
+
+ /**
+ * get Logger
+ * @param string $ype
+ * @return \Log
+ */
+ protected function getLogger(string $ype = 'ERROR') : \Log {
+ return LogController::getLogger($ype);
+ }
+ /**
+ * get error msg for missing $this->client class
+ * @param string $class
+ * @return string
+ */
+ protected function getMissingClassError(string $class) : string {
+ return sprintf(Config::ERROR_CLASS_NOT_EXISTS_COMPOSER, $class);
+ }
+
+ /**
+ * get error msg for undefined method in $this->client class
+ * @param string $class
+ * @param string $method
+ * @return string
+ */
+ protected function getMissingMethodError(string $class, string $method) : string {
+ return sprintf(Config::ERROR_METHOD_NOT_EXISTS_COMPOSER, $method, $class);
+ }
+
+ /**
+ * get config for stream logging
+ * @param string $logFileName
+ * @param bool $abs
+ * @return \stdClass
+ */
+ protected function getStreamConfig(string $logFileName, bool $abs = false) : \stdClass {
+ $f3 = \Base::instance();
+
+ $config = (object) [];
+ $config->stream = '';
+ if( $f3->exists('LOGS', $dir) ){
+ $config->stream .= $abs ? $f3->get('ROOT') . '/' : './';
+ $config->stream .= $dir . $logFileName . '.log';
+ $config->stream = $f3->fixslashes($config->stream);
+ }
+ return $config;
+ }
+
+ /**
+ * unload function
+ * @param \Redis $client
+ */
+ public function unloadCache(\Redis $client){
+ if($client->isConnected()){
+ $client->close();
+ }
+ }
+
+ /**
+ * call request API data
+ * @param string $name
+ * @param array $arguments
+ * @return array|mixed
+ */
+ public function __call(string $name, array $arguments = []){
+ $return = [];
+ if(is_object($this->client)){
+ if(method_exists($this->client, $name)){
+ $return = call_user_func_array([$this->client, $name], $arguments);
+ }else{
+ $errorMsg = $this->getMissingMethodError(get_class($this->client), $name);
+ $this->getLogger('ERROR')->write($errorMsg);
+ \Base::instance()->error(501, $errorMsg);
+ }
+ }else{
+ \Base::instance()->error(501, self::ERROR_CLIENT_INVALID);
+ }
+
+ return $return;
+ }
+
+ /**
+ * init web client on __invoke()
+ * -> no need to init client on __construct()
+ * maybe it is never used...
+ * @return AbstractClient
+ */
+ function __invoke() : self {
+ $f3 = \Base::instance();
+
+ if(
+ !($this->client instanceof ApiInterface) &&
+ ($this->getClient($f3) instanceof ApiInterface)
+ ){
+ // web client not initialized
+ $client = $this->getClient($f3);
+ $client->setTimeout(5);
+ $client->setConnectTimeout(5);
+ $client->setUserAgent($this->getUserAgent());
+ $client->setDecodeContent('gzip, deflate');
+
+ $client->setDebugLevel($f3->get('DEBUG'));
+ $client->setNewLog($this->newLog());
+ $client->setIsLoggable($this->isLoggable($f3));
+
+ $client->setLogStats(true); // add cURL stats (e.g. transferTime) to loggable requests
+ $client->setLogCache(true); // add cache info (e.g. from cached) to loggable requests
+ $client->setLogAllStatus(false); // log all requests regardless of response HTTP status code
+ $client->setLogRequestHeaders(false); // add request HTTP headers to loggable requests
+ $client->setLogResponseHeaders(false); // add response HTTP headers to loggable requests
+ $client->setLogFile('esi_requests');
+
+ $client->setRetryLogFile('esi_retry_requests');
+
+ $client->setCacheDebug(true);
+ $client->setCachePool($this->getCachePool($f3));
+
+
+ //$client->setProxy('127.0.0.1:8888'); // use local proxy server for debugging requests
+
+ // disable SSL certificate verification -> allow proxy to decode(view) request
+ //$client->setVerify(false);
+
+ //$client->setDebugRequests(true);
+
+ $this->client = $client;
+ }
+
+ return $this;
+ }
+}
\ No newline at end of file
diff --git a/app/Lib/Api/CcpClient.php b/app/Lib/Api/CcpClient.php
new file mode 100644
index 000000000..0d7c8933c
--- /dev/null
+++ b/app/Lib/Api/CcpClient.php
@@ -0,0 +1,44 @@
+setDataSource(Config::getEnvironmentData('CCP_ESI_DATASOURCE'));
+ }else{
+ $this->getLogger()->write($this->getMissingClassError(Client::class));
+ }
+
+ return $client;
+ }
+}
\ No newline at end of file
diff --git a/app/Lib/Api/EveScoutClient.php b/app/Lib/Api/EveScoutClient.php
new file mode 100644
index 000000000..1d124a33a
--- /dev/null
+++ b/app/Lib/Api/EveScoutClient.php
@@ -0,0 +1,38 @@
+getLogger()->write($this->getMissingClassError(Client::class));
+ }
+
+ return $client;
+ }
+}
\ No newline at end of file
diff --git a/app/Lib/Api/GitHubClient.php b/app/Lib/Api/GitHubClient.php
new file mode 100644
index 000000000..d87d6c23d
--- /dev/null
+++ b/app/Lib/Api/GitHubClient.php
@@ -0,0 +1,43 @@
+getLogger()->write($this->getMissingClassError(Client::class));
+ }
+
+ return $client;
+ }
+}
\ No newline at end of file
diff --git a/app/Lib/Api/SsoClient.php b/app/Lib/Api/SsoClient.php
new file mode 100644
index 000000000..30c473273
--- /dev/null
+++ b/app/Lib/Api/SsoClient.php
@@ -0,0 +1,40 @@
+getLogger()->write($this->getMissingClassError(Client::class));
+ }
+
+ return $client;
+ }
+}
\ No newline at end of file
diff --git a/app/Lib/Config.php b/app/Lib/Config.php
new file mode 100644
index 000000000..000b28c77
--- /dev/null
+++ b/app/Lib/Config.php
@@ -0,0 +1,686 @@
+ use "," as delimiter in config files/data
+ */
+ const ARRAY_KEYS = ['CCP_ESI_SCOPES', 'CCP_ESI_SCOPES_ADMIN'];
+
+ /**
+ * custom HTTP status codes
+ */
+ const
+ HTTP_422 = 'Unprocessable Entity';
+
+ /**
+ * all environment data
+ * @var array
+ */
+ private $serverConfigData = [];
+
+ /**
+ * Config constructor.
+ * @param \Base $f3
+ */
+ public function __construct(\Base $f3){
+ // set server data
+ // -> CGI params (Nginx)
+ // -> .htaccess (Apache)
+ $this->setServerData();
+ // set environment data
+ $this->setAllEnvironmentData($f3);
+ // set hive configuration variables
+ // -> overwrites default configuration
+ $this->setHiveVariables($f3);
+
+ // set global getter for \DateTimeZone
+ $f3->set('getTimeZone', function() use ($f3) : \DateTimeZone {
+ return new \DateTimeZone( $f3->get('TZ') );
+ });
+
+ // set global getter for new \DateTime
+ $f3->set('getDateTime', function(string $time = 'now', ?\DateTimeZone $timeZone = null) use ($f3) : \DateTime {
+ $timeZone = $timeZone ? : $f3->get('getTimeZone')();
+ return new \DateTime($time, $timeZone);
+ });
+
+ // database connection pool -----------------------------------------------------------------------------------
+ $f3->set(Pool::POOL_NAME, Pool::instance(
+ function(string $alias) use ($f3) : array {
+ // get DB config by alias for new connections
+ return self::getDatabaseConfig($f3, $alias);
+ },
+ function(string $schema) use ($f3) : array {
+ // get DB requirement vars from requirements.ini
+ return self::getRequiredDbVars($f3, $schema);
+ }
+ ));
+
+ // lazy init Web Api clients ----------------------------------------------------------------------------------
+ $f3->set(SsoClient::CLIENT_NAME, SsoClient::instance());
+ $f3->set(CcpClient::CLIENT_NAME, CcpClient::instance());
+ $f3->set(GitHubClient::CLIENT_NAME, GitHubClient::instance());
+ $f3->set(EveScoutClient::CLIENT_NAME, EveScoutClient::instance());
+
+ // Socket connectors ------------------------------------------------------------------------------------------
+ $f3->set(TcpSocket::SOCKET_NAME, function(array $options = ['timeout' => 1]) : SocketInterface {
+ return AbstractSocket::factory(TcpSocket::class, self::getSocketUri(), $options);
+ });
+ }
+
+ /**
+ * get environment configuration data
+ * @param \Base $f3
+ * @return array|null
+ */
+ protected function getAllEnvironmentData(\Base $f3){
+ if(!$f3->exists(self::HIVE_KEY_ENVIRONMENT, $environmentData)){
+ $environmentData = $this->setAllEnvironmentData($f3);
+ }
+
+ return $environmentData;
+ }
+
+ /**
+ * set/overwrite some global framework variables original set in config.ini
+ * -> can be overwritten in environments.ini OR ENV-Vars
+ * -> see: https://github.com/exodus4d/pathfinder/issues/175
+ * that depend on environment settings
+ * @param \Base $f3
+ */
+ protected function setHiveVariables(\Base $f3){
+ // hive keys that can be overwritten
+ $hiveKeys = ['BASE', 'URL', 'DEBUG', 'CACHE'];
+
+ foreach($hiveKeys as $key){
+ if( !is_null( $var = self::getEnvironmentData($key)) ){
+ $f3->set($key,$var);
+ }
+ }
+ }
+
+ /**
+ * set all environment configuration data
+ * @param \Base $f3
+ * @return array|mixed|null
+ */
+ protected function setAllEnvironmentData(\Base $f3){
+ $environmentData = null;
+
+ if( !empty($this->serverConfigData['ENV']) ){
+ // get environment config from $_SERVER data
+ $environmentData = (array)$this->serverConfigData['ENV'];
+
+ // some environment variables should be parsed as array
+ array_walk($environmentData, function(&$item, $key){
+ $item = (in_array($key, self::ARRAY_KEYS)) ? explode(',', $item) : $item;
+ });
+
+ $environmentData['ENVIRONMENT_CONFIG'] = 'PHP: environment variables';
+ }else{
+ // get environment data from *.ini file config
+ $customConfDir = $f3->get('CONF');
+
+ // check "custom" ini dir, of not found check default ini dir
+ foreach($customConfDir as $type => $path){
+ $envConfFile = $path . 'environment.ini';
+ $f3->config($envConfFile, true);
+
+ if(
+ $f3->exists(self::HIVE_KEY_ENVIRONMENT) &&
+ ($environment = $f3->get(self::HIVE_KEY_ENVIRONMENT . '.SERVER')) &&
+ ($environmentData = $f3->get(self::HIVE_KEY_ENVIRONMENT . '.' . $environment))
+ ){
+ $environmentData['ENVIRONMENT_CONFIG'] = 'Config: ' . $envConfFile;
+ break;
+ }
+ }
+ }
+
+ if( !is_null($environmentData) ){
+ ksort($environmentData);
+ $f3->set(self::HIVE_KEY_ENVIRONMENT, $environmentData);
+ }
+
+ return $environmentData;
+ }
+
+ /**
+ * get/extract all server data passed to PHP
+ * this can be done by either:
+ * OS Environment variables:
+ * -> add to /etc/environment
+ * OR:
+ * Nginx (server config):
+ * -> FastCGI syntax
+ * fastcgi_param PF-ENV-DEBUG 3;
+ */
+ protected function setServerData(){
+ $data = [];
+ foreach($_SERVER as $key => $value){
+ if(strpos($key, self::PREFIX_KEY . self::ARRAY_DELIMITER) === 0){
+ $path = explode( self::ARRAY_DELIMITER, $key);
+ // remove prefix
+ array_shift($path);
+
+ $tmp = &$data;
+ foreach($path as $segment){
+ $tmp[$segment] = (array)$tmp[$segment];
+ $tmp = &$tmp[$segment];
+ }
+
+ // type cast values
+ // (e.g. '1.2' => (float); '4' => (int),...)
+ $tmp = is_numeric($value) ? $value + 0 : $value;
+ }
+ }
+
+ $this->serverConfigData = $data;
+ }
+
+ /**
+ * get a environment variable by hive key
+ * @param $key
+ * @return string|null
+ */
+ static function getEnvironmentData($key){
+ $hiveKey = self::HIVE_KEY_ENVIRONMENT . '.' . $key;
+ \Base::instance()->exists($hiveKey, $data);
+ return $data;
+ }
+
+ /**
+ * get database config values
+ * @param \Base $f3
+ * @param string $alias
+ * @return array
+ */
+ static function getDatabaseConfig(\Base $f3, string $alias) : array {
+ $alias = strtoupper($alias);
+
+ $config = [
+ 'ALIAS' => $alias,
+ 'SCHEME' => 'mysql',
+ 'HOST' => 'localhost',
+ 'PORT' => 3306,
+ 'SOCKET' => null,
+ 'NAME' => self::getEnvironmentData('DB_' . $alias . '_NAME'),
+ 'USER' => self::getEnvironmentData('DB_' . $alias . '_USER'),
+ 'PASS' => self::getEnvironmentData('DB_' . $alias . '_PASS')
+ ];
+
+ $pdoReg = '/^(?'.$this->encode($text?:$req).'
'.$eol. - ($this->hive['DEBUG']?(''.$trace.''.$eol):''). - ''.$eol. - ''); - if ($this->hive['HALT']) - die; - } - - /** - * Mock HTTP request - * @return mixed - * @param $pattern string - * @param $args array - * @param $headers array - * @param $body string - **/ - function mock($pattern, - array $args=NULL,array $headers=NULL,$body=NULL) { - if (!$args) - $args=array(); - $types=array('sync','ajax'); - preg_match('/([\|\w]+)\h+(?:@(\w+)(?:(\(.+?)\))*|([^\h]+))'. - '(?:\h+\[('.implode('|',$types).')\])?/',$pattern,$parts); - $verb=strtoupper($parts[1]); - if ($parts[2]) { - if (empty($this->hive['ALIASES'][$parts[2]])) - user_error(sprintf(self::E_Named,$parts[2]),E_USER_ERROR); - $parts[4]=$this->hive['ALIASES'][$parts[2]]; - $parts[4]=$this->build($parts[4], - isset($parts[3])?$this->parse($parts[3]):array()); - } - if (empty($parts[4])) - user_error(sprintf(self::E_Pattern,$pattern),E_USER_ERROR); - $url=parse_url($parts[4]); - parse_str(@$url['query'],$GLOBALS['_GET']); - if (preg_match('/GET|HEAD/',$verb)) - $GLOBALS['_GET']=array_merge($GLOBALS['_GET'],$args); - $GLOBALS['_POST']=$verb=='POST'?$args:array(); - $GLOBALS['_REQUEST']=array_merge($GLOBALS['_GET'],$GLOBALS['_POST']); - foreach ($headers?:array() as $key=>$val) - $_SERVER['HTTP_'.strtr(strtoupper($key),'-','_')]=$val; - $this->hive['VERB']=$verb; - $this->hive['URI']=$this->hive['BASE'].$url['path']; - if ($GLOBALS['_GET']) - $this->hive['URI'].='?'.http_build_query($GLOBALS['_GET']); - $this->hive['BODY']=''; - if (!preg_match('/GET|HEAD/',$verb)) - $this->hive['BODY']=$body?:http_build_query($args); - $this->hive['AJAX']=isset($parts[5]) && - preg_match('/ajax/i',$parts[5]); - return $this->run(); - } - - /** - * Bind handler to route pattern - * @return NULL - * @param $pattern string|array - * @param $handler callback - * @param $ttl int - * @param $kbps int - **/ - function route($pattern,$handler,$ttl=0,$kbps=0) { - $types=array('sync','ajax'); - $alias=null; - if (is_array($pattern)) { - foreach ($pattern as $item) - $this->route($item,$handler,$ttl,$kbps); - return; - } - preg_match('/([\|\w]+)\h+(?:(?:@(\w+)\h*:\h*)?(@(\w+)|[^\h]+))'. - '(?:\h+\[('.implode('|',$types).')\])?/',$pattern,$parts); - if (isset($parts[2]) && $parts[2]) - $this->hive['ALIASES'][$alias=$parts[2]]=$parts[3]; - elseif (!empty($parts[4])) { - if (empty($this->hive['ALIASES'][$parts[4]])) - user_error(sprintf(self::E_Named,$parts[4]),E_USER_ERROR); - $parts[3]=$this->hive['ALIASES'][$alias=$parts[4]]; - } - if (empty($parts[3])) - user_error(sprintf(self::E_Pattern,$pattern),E_USER_ERROR); - $type=empty($parts[5])? - self::REQ_SYNC|self::REQ_AJAX: - constant('self::REQ_'.strtoupper($parts[5])); - foreach ($this->split($parts[1]) as $verb) { - if (!preg_match('/'.self::VERBS.'/',$verb)) - $this->error(501,$verb.' '.$this->hive['URI']); - $this->hive['ROUTES'][$parts[3]][$type][strtoupper($verb)]= - array($handler,$ttl,$kbps,$alias); - } - } - - /** - * Reroute to specified URI - * @return NULL - * @param $url string - * @param $permanent bool - **/ - function reroute($url=NULL,$permanent=FALSE) { - if (!$url) - $url=$this->hive['REALM']; - if (preg_match('/^(?:@(\w+)(?:(\(.+?)\))*)/',$url,$parts)) { - if (empty($this->hive['ALIASES'][$parts[1]])) - user_error(sprintf(self::E_Named,$parts[1]),E_USER_ERROR); - $url=$this->hive['ALIASES'][$parts[1]]; - } - $url=$this->build($url, - isset($parts[2])?$this->parse($parts[2]):array()); - if (($handler=$this->hive['ONREROUTE']) && - $this->call($handler,array($url,$permanent))!==FALSE) - return; - if ($url[0]=='/') - $url=$this->hive['BASE'].$url; - if (PHP_SAPI!='cli') { - header('Location: '.$url); - $this->status($permanent?301:302); - die; - } - $this->mock('GET '.$url); - } - - /** - * Provide ReST interface by mapping HTTP verb to class method - * @return NULL - * @param $url string - * @param $class string|object - * @param $ttl int - * @param $kbps int - **/ - function map($url,$class,$ttl=0,$kbps=0) { - if (is_array($url)) { - foreach ($url as $item) - $this->map($item,$class,$ttl,$kbps); - return; - } - foreach (explode('|',self::VERBS) as $method) - $this->route($method.' '.$url,is_string($class)? - $class.'->'.$this->hive['PREMAP'].strtolower($method): - array($class,$this->hive['PREMAP'].strtolower($method)), - $ttl,$kbps); - } - - /** - * Redirect a route to another URL - * @return NULL - * @param $pattern string|array - * @param $url string - * @param $permanent bool - */ - function redirect($pattern,$url,$permanent=TRUE) { - if (is_array($pattern)) { - foreach ($pattern as $item) - $this->redirect($item,$url,$permanent); - return; - } - $this->route($pattern,function($fw) use($url,$permanent) { - $fw->reroute($url,$permanent); - }); - } - - /** - * Return TRUE if IPv4 address exists in DNSBL - * @return bool - * @param $ip string - **/ - function blacklisted($ip) { - if ($this->hive['DNSBL'] && - !in_array($ip, - is_array($this->hive['EXEMPT'])? - $this->hive['EXEMPT']: - $this->split($this->hive['EXEMPT']))) { - // Reverse IPv4 dotted quad - $rev=implode('.',array_reverse(explode('.',$ip))); - foreach (is_array($this->hive['DNSBL'])? - $this->hive['DNSBL']: - $this->split($this->hive['DNSBL']) as $server) - // DNSBL lookup - if (checkdnsrr($rev.'.'.$server,'A')) - return TRUE; - } - return FALSE; - } - - /** - * Applies the specified URL mask and returns parameterized matches - * @return $args array - * @param $pattern string - * @param $url string|NULL - **/ - function mask($pattern,$url=NULL) { - if (!$url) - $url=$this->rel($this->hive['URI']); - $case=$this->hive['CASELESS']?'i':''; - preg_match('/^'. - preg_replace('/@(\w+\b)/','(?P<\1>[^\/\?]+)', - str_replace('\*','([^\?]+)',preg_quote($pattern,'/'))). - '\/?(?:\?.*)?$/'.$case.'um',$url,$args); - return $args; - } - - /** - * Match routes against incoming URI - * @return mixed - **/ - function run() { - if ($this->blacklisted($this->hive['IP'])) - // Spammer detected - $this->error(403); - if (!$this->hive['ROUTES']) - // No routes defined - user_error(self::E_Routes,E_USER_ERROR); - // Match specific routes first - $paths=array(); - foreach ($keys=array_keys($this->hive['ROUTES']) as $key) - $paths[]=str_replace('@','*@',$key); - $vals=array_values($this->hive['ROUTES']); - array_multisort($paths,SORT_DESC,$keys,$vals); - $this->hive['ROUTES']=array_combine($keys,$vals); - // Convert to BASE-relative URL - $req=$this->rel($this->hive['URI']); - if ($cors=(isset($this->hive['HEADERS']['Origin']) && - $this->hive['CORS']['origin'])) { - $cors=$this->hive['CORS']; - header('Access-Control-Allow-Origin: '.$cors['origin']); - header('Access-Control-Allow-Credentials: '. - ($cors['credentials']?'true':'false')); - } - $allowed=array(); - foreach ($this->hive['ROUTES'] as $pattern=>$routes) { - if (!$args=$this->mask($pattern,$req)) - continue; - ksort($args); - $route=NULL; - if (isset( - $routes[$ptr=$this->hive['AJAX']+1][$this->hive['VERB']])) - $route=$routes[$ptr]; - elseif (isset($routes[self::REQ_SYNC|self::REQ_AJAX])) - $route=$routes[self::REQ_SYNC|self::REQ_AJAX]; - if (!$route) - continue; - if ($this->hive['VERB']!='OPTIONS' && - isset($route[$this->hive['VERB']])) { - $parts=parse_url($req); - if ($this->hive['VERB']=='GET' && - preg_match('/.+\/$/',$parts['path'])) - $this->reroute(substr($parts['path'],0,-1). - (isset($parts['query'])?('?'.$parts['query']):'')); - list($handler,$ttl,$kbps,$alias)=$route[$this->hive['VERB']]; - if (is_bool(strpos($pattern,'/*'))) - foreach (array_keys($args) as $key) - if (is_numeric($key) && $key) - unset($args[$key]); - // Capture values of route pattern tokens - $this->hive['PARAMS']=$args=array_map('urldecode',$args); - // Save matching route - $this->hive['ALIAS']=$alias; - $this->hive['PATTERN']=$pattern; - if ($cors && $cors['expose']) - header('Access-Control-Expose-Headers: '.(is_array($cors['expose'])? - implode(',',$cors['expose']):$cors['expose'])); - if (is_string($handler)) { - // Replace route pattern tokens in handler if any - $handler=preg_replace_callback('/@(\w+\b)/', - function($id) use($args) { - return isset($args[$id[1]])?$args[$id[1]]:$id[0]; - }, - $handler - ); - if (preg_match('/(.+)\h*(?:->|::)/',$handler,$match) && - !class_exists($match[1])) - $this->error(404); - } - // Process request - $result=NULL; - $body=''; - $now=microtime(TRUE); - if (preg_match('/GET|HEAD/',$this->hive['VERB']) && $ttl) { - // Only GET and HEAD requests are cacheable - $headers=$this->hive['HEADERS']; - $cache=Cache::instance(); - $cached=$cache->exists( - $hash=$this->hash($this->hive['VERB'].' '. - $this->hive['URI']).'.url',$data); - if ($cached && $cached[0]+$ttl>$now) { - if (isset($headers['If-Modified-Since']) && - strtotime($headers['If-Modified-Since'])+ - $ttl>$now) { - $this->status(304); - die; - } - // Retrieve from cache backend - list($headers,$body,$result)=$data; - if (PHP_SAPI!='cli') - array_walk($headers,'header'); - $this->expire($cached[0]+$ttl-$now); - } - else - // Expire HTTP client-cached page - $this->expire($ttl); - } - else - $this->expire(0); - if (!strlen($body)) { - if (!$this->hive['RAW'] && !$this->hive['BODY']) - $this->hive['BODY']=file_get_contents('php://input'); - ob_start(); - // Call route handler - $result=$this->call($handler,array($this,$args), - 'beforeroute,afterroute'); - $body=ob_get_clean(); - if (isset($cache) && !error_get_last()) { - // Save to cache backend - $cache->set($hash,array( - // Remove cookies - preg_grep('/Set-Cookie\:/',headers_list(), - PREG_GREP_INVERT),$body,$result),$ttl); - } - } - $this->hive['RESPONSE']=$body; - if (!$this->hive['QUIET']) { - if ($kbps) { - $ctr=0; - foreach (str_split($body,1024) as $part) { - // Throttle output - $ctr++; - if ($ctr/$kbps>($elapsed=microtime(TRUE)-$now) && - !connection_aborted()) - usleep(1e6*($ctr/$kbps-$elapsed)); - echo $part; - } - } - else - echo $body; - } - return $result; - } - $allowed=array_merge($allowed,array_keys($route)); - } - if (!$allowed) - // URL doesn't match any route - $this->error(404); - elseif (PHP_SAPI!='cli') { - // Unhandled HTTP method - header('Allow: '.implode(',',array_unique($allowed))); - if ($cors) { - header('Access-Control-Allow-Methods: OPTIONS,'.implode(',',$allowed)); - if ($cors['headers']) - header('Access-Control-Allow-Headers: '.(is_array($cors['headers'])? - implode(',',$cors['headers']):$cors['headers'])); - if ($cors['ttl']>0) - header('Access-Control-Max-Age: '.$cors['ttl']); - } - if ($this->hive['VERB']!='OPTIONS') - $this->error(405); - } - return FALSE; - } - - /** - * Loop until callback returns TRUE (for long polling) - * @return mixed - * @param $func callback - * @param $args array - * @param $timeout int - **/ - function until($func,$args=NULL,$timeout=60) { - if (!$args) - $args=array(); - $time=time(); - $limit=max(0,min($timeout,$max=ini_get('max_execution_time')-1)); - $out=''; - $flag=FALSE; - // Not for the weak of heart - while ( - // Still alive? - !connection_aborted() && - // Got time left? - (time()-$time+1<$limit) && - // Restart session - $flag=@session_start() && - // CAUTION: Callback will kill host if it never becomes truthy! - !($out=$this->call($func,$args))) { - session_commit(); - ob_flush(); - flush(); - // Hush down - sleep(1); - } - if ($flag) { - session_commit(); - ob_flush(); - flush(); - } - return $out; - } - - /** - * Disconnect HTTP client - **/ - function abort() { - @session_start(); - session_commit(); - header('Content-Length: 0'); - while (ob_get_level()) - ob_end_clean(); - flush(); - if (function_exists('fastcgi_finish_request')) - fastcgi_finish_request(); - } - - /** - * Grab the real route handler behind the string expression - * @return string|array - * @param $func string - * @param $args array - **/ - function grab($func,$args=NULL) { - if (preg_match('/(.+)\h*(->|::)\h*(.+)/s',$func,$parts)) { - // Convert string to executable PHP callback - if (!class_exists($parts[1])) - user_error(sprintf(self::E_Class,$parts[1]),E_USER_ERROR); - if ($parts[2]=='->') { - if (is_subclass_of($parts[1],'Prefab')) - $parts[1]=call_user_func($parts[1].'::instance'); - else { - $ref=new ReflectionClass($parts[1]); - $parts[1]=method_exists($parts[1],'__construct')? - $ref->newinstanceargs($args): - $ref->newinstance(); - } - } - $func=array($parts[1],$parts[3]); - } - return $func; - } - - /** - * Execute callback/hooks (supports 'class->method' format) - * @return mixed|FALSE - * @param $func callback - * @param $args mixed - * @param $hooks string - **/ - function call($func,$args=NULL,$hooks='') { - if (!is_array($args)) - $args=array($args); - // Grab the real handler behind the string representation - if (is_string($func)) - $func=$this->grab($func,$args); - // Execute function; abort if callback/hook returns FALSE - if (!is_callable($func)) - // No route handler - if ($hooks=='beforeroute,afterroute') { - $allowed=array(); - if (is_array($func)) - $allowed=array_intersect( - array_map('strtoupper',get_class_methods($func[0])), - explode('|',self::VERBS) - ); - header('Allow: '.implode(',',$allowed)); - $this->error(405); - } - else - user_error(sprintf(self::E_Method, - is_string($func)?$func:$this->stringify($func)), - E_USER_ERROR); - $obj=FALSE; - if (is_array($func)) { - $hooks=$this->split($hooks); - $obj=TRUE; - } - // Execute pre-route hook if any - if ($obj && $hooks && in_array($hook='beforeroute',$hooks) && - method_exists($func[0],$hook) && - call_user_func_array(array($func[0],$hook),$args)===FALSE) - return FALSE; - // Execute callback - $out=call_user_func_array($func,$args?:array()); - if ($out===FALSE) - return FALSE; - // Execute post-route hook if any - if ($obj && $hooks && in_array($hook='afterroute',$hooks) && - method_exists($func[0],$hook) && - call_user_func_array(array($func[0],$hook),$args)===FALSE) - return FALSE; - return $out; - } - - /** - * Execute specified callbacks in succession; Apply same arguments - * to all callbacks - * @return array - * @param $funcs array|string - * @param $args mixed - **/ - function chain($funcs,$args=NULL) { - $out=array(); - foreach (is_array($funcs)?$funcs:$this->split($funcs) as $func) - $out[]=$this->call($func,$args); - return $out; - } - - /** - * Execute specified callbacks in succession; Relay result of - * previous callback as argument to the next callback - * @return array - * @param $funcs array|string - * @param $args mixed - **/ - function relay($funcs,$args=NULL) { - foreach (is_array($funcs)?$funcs:$this->split($funcs) as $func) - $args=array($this->call($func,$args)); - return array_shift($args); - } - - /** - * Configure framework according to .ini-style file settings; - * If optional 2nd arg is provided, template strings are interpreted - * @return object - * @param $file string - * @param $allow bool - **/ - function config($file,$allow=FALSE) { - preg_match_all( - '/(?<=^|\n)(?:'. - '\[(?
'.$out.''):$text;
- }
-
- /**
- * Dump expression with syntax highlighting
- * @return NULL
- * @param $expr mixed
- **/
- function dump($expr) {
- echo $this->highlight($this->stringify($expr));
- }
-
- /**
- * Return path (and query parameters) relative to the base directory
- * @return string
- * @param $url string
- **/
- function rel($url) {
- return preg_replace('/^(?:https?:\/\/)?'.
- preg_quote($this->hive['BASE'],'/').'(\/.*|$)/','\1',$url);
- }
-
- /**
- * Namespace-aware class autoloader
- * @return mixed
- * @param $class string
- **/
- protected function autoload($class) {
- $class=$this->fixslashes(ltrim($class,'\\'));
- $func=NULL;
- if (is_array($path=$this->hive['AUTOLOAD']) &&
- isset($path[1]) && is_callable($path[1]))
- list($path,$func)=$path;
- foreach ($this->split($this->hive['PLUGINS'].';'.$path) as $auto)
- if ($func && is_file($file=$func($auto.$class).'.php') ||
- is_file($file=$auto.$class.'.php') ||
- is_file($file=$auto.strtolower($class).'.php') ||
- is_file($file=strtolower($auto.$class).'.php'))
- return require($file);
- }
-
- /**
- * Execute framework/application shutdown sequence
- * @return NULL
- * @param $cwd string
- **/
- function unload($cwd) {
- chdir($cwd);
- if (!$error=error_get_last())
- @session_commit();
- $handler=$this->hive['UNLOAD'];
- if ((!$handler || $this->call($handler,$this)===FALSE) &&
- $error && in_array($error['type'],
- array(E_ERROR,E_PARSE,E_CORE_ERROR,E_COMPILE_ERROR)))
- // Fatal error detected
- $this->error(500,sprintf(self::E_Fatal,$error['message']),
- array($error));
- }
-
- /**
- * Convenience method for checking hive key
- * @return mixed
- * @param $key string
- **/
- function offsetexists($key) {
- return $this->exists($key);
- }
-
- /**
- * Convenience method for assigning hive value
- * @return mixed
- * @param $key string
- * @param $val scalar
- **/
- function offsetset($key,$val) {
- return $this->set($key,$val);
- }
-
- /**
- * Convenience method for retrieving hive value
- * @return mixed
- * @param $key string
- **/
- function &offsetget($key) {
- $val=&$this->ref($key);
- return $val;
- }
-
- /**
- * Convenience method for removing hive key
- * @return NULL
- * @param $key string
- **/
- function offsetunset($key) {
- $this->clear($key);
- }
-
- /**
- * Alias for offsetexists()
- * @return mixed
- * @param $key string
- **/
- function __isset($key) {
- return $this->offsetexists($key);
- }
-
- /**
- * Alias for offsetset()
- * @return mixed
- * @param $key string
- * @param $val mixed
- **/
- function __set($key,$val) {
- return $this->offsetset($key,$val);
- }
-
- /**
- * Alias for offsetget()
- * @return mixed
- * @param $key string
- **/
- function &__get($key) {
- $val=&$this->offsetget($key);
- return $val;
- }
-
- /**
- * Alias for offsetunset()
- * @return mixed
- * @param $key string
- **/
- function __unset($key) {
- $this->offsetunset($key);
- }
-
- /**
- * Call function identified by hive key
- * @return mixed
- * @param $key string
- * @param $args array
- **/
- function __call($key,$args) {
- return call_user_func_array($this->get($key),$args);
- }
-
- //! Prohibit cloning
- private function __clone() {
- }
-
- //! Bootstrap
- function __construct() {
- // Managed directives
- ini_set('default_charset',$charset='UTF-8');
- if (extension_loaded('mbstring'))
- mb_internal_encoding($charset);
- ini_set('display_errors',0);
- // Deprecated directives
- @ini_set('magic_quotes_gpc',0);
- @ini_set('register_globals',0);
- // Intercept errors/exceptions; PHP5.3-compatible
- error_reporting((E_ALL|E_STRICT)&~(E_NOTICE|E_USER_NOTICE));
- $fw=$this;
- set_exception_handler(
- function($obj) use($fw) {
- $fw->hive['EXCEPTION']=$obj;
- $fw->error(500,$obj->getmessage(),$obj->gettrace());
- }
- );
- set_error_handler(
- function($code,$text) use($fw) {
- if ($code & error_reporting())
- $fw->error(500,$text);
- }
- );
- if (!isset($_SERVER['SERVER_NAME']))
- $_SERVER['SERVER_NAME']=gethostname();
- if (PHP_SAPI=='cli') {
- // Emulate HTTP request
- if (isset($_SERVER['argc']) && $_SERVER['argc']<2) {
- $_SERVER['argc']++;
- $_SERVER['argv'][1]='/';
- }
- $_SERVER['REQUEST_METHOD']='GET';
- $_SERVER['REQUEST_URI']=$_SERVER['argv'][1];
- }
- $headers=array();
- if (PHP_SAPI!='cli')
- foreach (array_keys($_SERVER) as $key)
- if (substr($key,0,5)=='HTTP_')
- $headers[strtr(ucwords(strtolower(strtr(
- substr($key,5),'_',' '))),' ','-')]=&$_SERVER[$key];
- if (isset($headers['X-HTTP-Method-Override']))
- $_SERVER['REQUEST_METHOD']=$headers['X-HTTP-Method-Override'];
- elseif ($_SERVER['REQUEST_METHOD']=='POST' && isset($_POST['_method']))
- $_SERVER['REQUEST_METHOD']=$_POST['_method'];
- $scheme=isset($_SERVER['HTTPS']) && $_SERVER['HTTPS']=='on' ||
- isset($headers['X-Forwarded-Proto']) &&
- $headers['X-Forwarded-Proto']=='https'?'https':'http';
- // Create hive early on to expose header methods
- $this->hive=array('HEADERS'=>$headers);
- if (function_exists('apache_setenv')) {
- // Work around Apache pre-2.4 VirtualDocumentRoot bug
- $_SERVER['DOCUMENT_ROOT']=str_replace($_SERVER['SCRIPT_NAME'],'',
- $_SERVER['SCRIPT_FILENAME']);
- apache_setenv("DOCUMENT_ROOT",$_SERVER['DOCUMENT_ROOT']);
- }
- $_SERVER['DOCUMENT_ROOT']=realpath($_SERVER['DOCUMENT_ROOT']);
- $base='';
- if (PHP_SAPI!='cli')
- $base=rtrim($this->fixslashes(
- dirname($_SERVER['SCRIPT_NAME'])),'/');
- $uri=parse_url($_SERVER['REQUEST_URI']);
- $path=preg_replace('/^'.preg_quote($base,'/').'/','',$uri['path']);
- call_user_func_array('session_set_cookie_params',
- $jar=array(
- 'expire'=>0,
- 'path'=>$base?:'/',
- 'domain'=>is_int(strpos($_SERVER['SERVER_NAME'],'.')) &&
- !filter_var($_SERVER['SERVER_NAME'],FILTER_VALIDATE_IP)?
- $_SERVER['SERVER_NAME']:'',
- 'secure'=>($scheme=='https'),
- 'httponly'=>TRUE
- )
- );
- $port=0;
- if (isset($_SERVER['SERVER_PORT']))
- $port=$_SERVER['SERVER_PORT'];
- // Default configuration
- $this->hive+=array(
- 'AGENT'=>$this->agent(),
- 'AJAX'=>$this->ajax(),
- 'ALIAS'=>NULL,
- 'ALIASES'=>array(),
- 'AUTOLOAD'=>'./',
- 'BASE'=>$base,
- 'BITMASK'=>ENT_COMPAT,
- 'BODY'=>NULL,
- 'CACHE'=>FALSE,
- 'CASELESS'=>TRUE,
- 'CONFIG'=>NULL,
- 'CORS'=>array(
- 'headers'=>'',
- 'origin'=>false,
- 'credentials'=>false,
- 'expose'=>false,
- 'ttl'=>0),
- 'DEBUG'=>0,
- 'DIACRITICS'=>array(),
- 'DNSBL'=>'',
- 'EMOJI'=>array(),
- 'ENCODING'=>$charset,
- 'ERROR'=>NULL,
- 'ESCAPE'=>TRUE,
- 'EXCEPTION'=>NULL,
- 'EXEMPT'=>NULL,
- 'FALLBACK'=>$this->fallback,
- 'FRAGMENT'=>isset($uri['fragment'])?$uri['fragment']:'',
- 'HALT'=>TRUE,
- 'HIGHLIGHT'=>TRUE,
- 'HOST'=>$_SERVER['SERVER_NAME'],
- 'IP'=>$this->ip(),
- 'JAR'=>$jar,
- 'LANGUAGE'=>isset($headers['Accept-Language'])?
- $this->language($headers['Accept-Language']):
- $this->fallback,
- 'LOCALES'=>'./',
- 'LOGS'=>'./',
- 'ONERROR'=>NULL,
- 'ONREROUTE'=>NULL,
- 'PACKAGE'=>self::PACKAGE,
- 'PARAMS'=>array(),
- 'PATH'=>$path,
- 'PATTERN'=>NULL,
- 'PLUGINS'=>$this->fixslashes(__DIR__).'/',
- 'PORT'=>$port,
- 'PREFIX'=>NULL,
- 'PREMAP'=>'',
- 'QUERY'=>isset($uri['query'])?$uri['query']:'',
- 'QUIET'=>FALSE,
- 'RAW'=>FALSE,
- 'REALM'=>$scheme.'://'.$_SERVER['SERVER_NAME'].
- ($port && $port!=80 && $port!=443?
- (':'.$port):'').$_SERVER['REQUEST_URI'],
- 'RESPONSE'=>'',
- 'ROOT'=>$_SERVER['DOCUMENT_ROOT'],
- 'ROUTES'=>array(),
- 'SCHEME'=>$scheme,
- 'SERIALIZER'=>extension_loaded($ext='igbinary')?$ext:'php',
- 'TEMP'=>'tmp/',
- 'TIME'=>microtime(TRUE),
- 'TZ'=>(@ini_get('date.timezone'))?:'UTC',
- 'UI'=>'./',
- 'UNLOAD'=>NULL,
- 'UPLOADS'=>'./',
- 'URI'=>&$_SERVER['REQUEST_URI'],
- 'VERB'=>&$_SERVER['REQUEST_METHOD'],
- 'VERSION'=>self::VERSION,
- 'XFRAME'=>'SAMEORIGIN'
- );
- if (PHP_SAPI=='cli-server' &&
- preg_match('/^'.preg_quote($base,'/').'$/',$this->hive['URI']))
- $this->reroute('/');
- if (ini_get('auto_globals_jit'))
- // Override setting
- $GLOBALS+=array('_ENV'=>$_ENV,'_REQUEST'=>$_REQUEST);
- // Sync PHP globals with corresponding hive keys
- $this->init=$this->hive;
- foreach (explode('|',self::GLOBALS) as $global) {
- $sync=$this->sync($global);
- $this->init+=array(
- $global=>preg_match('/SERVER|ENV/',$global)?$sync:array()
- );
- }
- if ($error=error_get_last())
- // Error detected
- $this->error(500,sprintf(self::E_Fatal,$error['message']),
- array($error));
- date_default_timezone_set($this->hive['TZ']);
- // Register framework autoloader
- spl_autoload_register(array($this,'autoload'));
- // Register shutdown handler
- register_shutdown_function(array($this,'unload'),getcwd());
- }
-
-}
-
-//! Cache engine
-class Cache extends Prefab {
-
- protected
- //! Cache DSN
- $dsn,
- //! Prefix for cache entries
- $prefix,
- //! MemCache or Redis object
- $ref;
-
- /**
- * Return timestamp and TTL of cache entry or FALSE if not found
- * @return array|FALSE
- * @param $key string
- * @param $val mixed
- **/
- function exists($key,&$val=NULL) {
- $fw=Base::instance();
- if (!$this->dsn)
- return FALSE;
- $ndx=$this->prefix.'.'.$key;
- $parts=explode('=',$this->dsn,2);
- switch ($parts[0]) {
- case 'apc':
- case 'apcu':
- $raw=apc_fetch($ndx);
- break;
- case 'redis':
- $raw=$this->ref->get($ndx);
- break;
- case 'memcache':
- $raw=memcache_get($this->ref,$ndx);
- break;
- case 'wincache':
- $raw=wincache_ucache_get($ndx);
- break;
- case 'xcache':
- $raw=xcache_get($ndx);
- break;
- case 'folder':
- $raw=$fw->read($parts[1].$ndx);
- break;
- }
- if (!empty($raw)) {
- list($val,$time,$ttl)=(array)$fw->unserialize($raw);
- if ($ttl===0 || $time+$ttl>microtime(TRUE))
- return array($time,$ttl);
- $val=null;
- $this->clear($key);
- }
- return FALSE;
- }
-
- /**
- * Store value in cache
- * @return mixed|FALSE
- * @param $key string
- * @param $val mixed
- * @param $ttl int
- **/
- function set($key,$val,$ttl=0) {
- $fw=Base::instance();
- if (!$this->dsn)
- return TRUE;
- $ndx=$this->prefix.'.'.$key;
- $time=microtime(TRUE);
- if ($cached=$this->exists($key))
- list($time,$ttl)=$cached;
- $data=$fw->serialize(array($val,$time,$ttl));
- $parts=explode('=',$this->dsn,2);
- switch ($parts[0]) {
- case 'apc':
- case 'apcu':
- return apc_store($ndx,$data,$ttl);
- case 'redis':
- return $this->ref->set($ndx,$data,array('ex'=>$ttl));
- case 'memcache':
- return memcache_set($this->ref,$ndx,$data,0,$ttl);
- case 'wincache':
- return wincache_ucache_set($ndx,$data,$ttl);
- case 'xcache':
- return xcache_set($ndx,$data,$ttl);
- case 'folder':
- return $fw->write($parts[1].$ndx,$data);
- }
- return FALSE;
- }
-
- /**
- * Retrieve value of cache entry
- * @return mixed|FALSE
- * @param $key string
- **/
- function get($key) {
- return $this->dsn && $this->exists($key,$data)?$data:FALSE;
- }
-
- /**
- * Delete cache entry
- * @return bool
- * @param $key string
- **/
- function clear($key) {
- if (!$this->dsn)
- return;
- $ndx=$this->prefix.'.'.$key;
- $parts=explode('=',$this->dsn,2);
- switch ($parts[0]) {
- case 'apc':
- case 'apcu':
- return apc_delete($ndx);
- case 'redis':
- return $this->ref->del($ndx);
- case 'memcache':
- return memcache_delete($this->ref,$ndx);
- case 'wincache':
- return wincache_ucache_delete($ndx);
- case 'xcache':
- return xcache_unset($ndx);
- case 'folder':
- return @unlink($parts[1].$ndx);
- }
- return FALSE;
- }
-
- /**
- * Clear contents of cache backend
- * @return bool
- * @param $suffix string
- * @param $lifetime int
- **/
- function reset($suffix=NULL,$lifetime=0) {
- if (!$this->dsn)
- return TRUE;
- $regex='/'.preg_quote($this->prefix.'.','/').'.+?'.
- preg_quote($suffix,'/').'/';
- $parts=explode('=',$this->dsn,2);
- switch ($parts[0]) {
- case 'apc':
- case 'apcu':
- $info=apc_cache_info('user');
- if (!empty($info['cache_list'])) {
- $key=array_key_exists('info',$info['cache_list'][0])?'info':'key';
- $mtkey=array_key_exists('mtime',$info['cache_list'][0])?
- 'mtime':'modification_time';
- foreach ($info['cache_list'] as $item)
- if (preg_match($regex,$item[$key]) &&
- $item[$mtkey]+$lifetime