diff --git a/.gitignore b/.gitignore index 4a64b2bd1..ee44fc35f 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,4 @@ Temporary Items /node_modules/ /public/js/vX.X.X/ /vendor/ +/history/ diff --git a/README.md b/README.md index c07f7f55b..efbfacc27 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Mapping tool for [*EVE ONLINE*](https://www.eveonline.com) - Database will be cleared from time to time - Installation guide: - [wiki](https://github.com/exodus4d/pathfinder/wiki) -- Developer chat [Slack](https://slack.com) : +- Developer [Slack](https://slack.com) chat: - https://pathfinder-eve-online.slack.com - Please send me a mail for invite: pathfinder@exodus4d.de @@ -28,44 +28,46 @@ Issues should be reported in the [Issue](https://github.com/exodus4d/pathfinder/ ### Project structure ``` - |-- (0755) app --> backend [*.php] - |-- app --> "Fat Free Framework" extensions - |-- lib --> "Fat Free Framework" - |-- main --> "PATHFINDER" root + |-- [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 |-- environment.ini --> config - system environment |-- pathfinder.ini --> config - pathfinder |-- requirements.ini --> config - system requirements |-- routes.ini --> config - routes - |-- (0755) export --> DB export data - |-- sql --> static DB data for import (pathfinder.sql) - |-- (0755) favicon --> Favicons - |-- (0755) js --> JS source files (raw) - |-- app --> "PASTHFINDER" core files (not used for production) - |-- lib --> 3rd partie extension/library (not used for production) + |-- [0755] export/ --> static data + |-- csv/ --> *.csv used by /setup page + |-- json/ --> *.json used by /setup page + |-- sql/ --> DB dump for import (pathfinder.sql) + |-- [0755] favicon/ --> Favicons + |-- [0777] history/ --> log files (map history logs) [optional] + |-- [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 + |-- [0777] logs/ --> log files |-- ... - | -- node_modules --> node.js modules (not used for production) + | -- node_modules/ --> node.js modules (not used for production) |-- ... - |-- (0755) public --> frontend source - |-- 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 source (not used for production) - |-- ... - |-- (0777) tmp --> cache folder - |-- ... - |-- (0755) .htaccess --> reroute/caching rules ("Apache" only!) - |-- (0755) index.php + |-- [0755] public/ --> frontend source + |-- 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 source (not used for production) + |-- [0777] tmp/ --> cache folder + |-- [0755] .htaccess --> reroute/caching rules ("Apache" only!) + |-- [0755] index.php -------------------------- CI/CD config files: -------------------------- |-- .jshintrc --> "JSHint" config (not used for production) + |-- composer.json --> Composer package definition |-- config.rb --> "Compass" config (not used for production) |-- gulpfile.js --> "Gulp" task config (not used for production ) |-- package.json --> "Node.js" dependency config (not used for production) diff --git a/app/app/schema.php b/app/app/schema.php index ea6bbfd75..1aaee30fc 100644 --- a/app/app/schema.php +++ b/app/app/schema.php @@ -34,23 +34,23 @@ function get() $this->f3->set('CACHE', false); $dbs = array( - /*'mysql' => new \DB\SQL( + 'mysql' => new \DB\SQL( 'mysql:host=localhost;port=3306;dbname=fatfree', 'fatfree', '' - ),*/ + ), 'sqlite' => new \DB\SQL( 'sqlite::memory:' - // 'sqlite:db/sqlite.db' +// 'sqlite:db/sqlite.db' ), - /*'pgsql' => new \DB\SQL( + 'pgsql' => new \DB\SQL( 'pgsql:host=localhost;dbname=fatfree', 'fatfree', 'fatfree' - ),*/ - /*'sqlsrv2012' => new \DB\SQL( - 'sqlsrv:SERVER=LOCALHOST\SQLEXPRESS2012;Database=fatfree','fatfree', 'fatfree' - ),*/ - /*'sqlsrv2008' => new \DB\SQL( - 'sqlsrv:SERVER=LOCALHOST\SQLEXPRESS2008;Database=fatfree','fatfree', 'fatfree' - )*/ - ); + ), +// 'sqlsrv2012' => new \DB\SQL( +// 'sqlsrv:SERVER=LOCALHOST\SQLEXPRESS2012;Database=fatfree','fatfree', 'fatfree' +// ), +// 'sqlsrv2008' => new \DB\SQL( +// 'sqlsrv:SERVER=LOCALHOST\SQLEXPRESS2008;Database=fatfree','fatfree', 'fatfree' +// ) + ); $this->roundTime = microtime(TRUE) - \Base::instance()->get('timer'); $this->tname = 'test_table'; @@ -117,10 +117,27 @@ private function runTestSuite($db) $this->getTestDesc('adding column ['.$field.'], nullable') ); } + + $r1 = $table->getCols(true); + foreach (array_keys($schema->dataTypes) as $index => $field) { + if (isset($r1['column_'.$index])) { + $datType=$schema->findQuery($schema->dataTypes[$field]); + $compatible = $schema->isCompatible($field,$r1['column_'.$index]['type']); + $this->test->expect( + $compatible, + $this->getTestDesc('reverse lookup compatible: '. + ($compatible?'YES':'NO'). + ', '.$field.': ['.$datType.' > '.$r1['column_'.$index]['type'].']') + ); + } + } unset($r1); + // adding some testing data $mapper = new \DB\SQL\Mapper($db, $this->tname); + $mapper->column_5 = 123.456; + $mapper->column_6 = 123456.789012; $mapper->column_7 = 'hello world'; $mapper->save(); $mapper->reset(); @@ -130,11 +147,33 @@ private function runTestSuite($db) $result['column_7'] == 'hello world', $this->getTestDesc('mapping dummy data') ); + $this->test->expect( + $result['column_5'] == 123.456, + $this->getTestDesc('testing float value: '.$result['column_5']) + ); + $this->test->expect( + $result['column_6'] == 123456.789012, + $this->getTestDesc('testing decimal value: '.$result['column_6']) + ); + + + $mapper = new \DB\SQL\Mapper($db, $this->tname); + $mapper->load(); + $num = $this->current_engine == 'sqlite' ? '123456789.012345' : '123456789012.345678'; + $mapper->column_6 = $num; + $mapper->save(); + $mapper->reset(); + $result = $mapper->findone(array('column_7 = ?', 'hello world'))->cast(); + $this->test->expect( + $result['column_6'] == $num, + $this->getTestDesc('testing max decimal precision: '.$result['column_6']) + ); + unset($mapper); // default value text, not nullable $table->addColumn('text_default_not_null') - ->type($schema::DT_VARCHAR128) - ->nullable(false)->defaults('foo bar'); + ->type($schema::DT_VARCHAR128) + ->nullable(false)->defaults('foo bar'); $table->build(); $r1 = $table->getCols(true); $this->test->expect( @@ -160,7 +199,7 @@ private function runTestSuite($db) // default value numeric, not nullable $table->addColumn('int_default_not_null') - ->type($schema::DT_INT4)->nullable(false)->defaults(123); + ->type($schema::DT_INT4)->nullable(false)->defaults(123); $table->build(); $r1 = $table->getCols(true); $this->test->expect( @@ -187,8 +226,8 @@ private function runTestSuite($db) // default value text, nullable $table->addColumn('text_default_nullable') - ->type($schema::DT_VARCHAR128) - ->defaults('foo bar'); + ->type($schema::DT_VARCHAR128) + ->defaults('foo bar'); $table->build(); $r1 = $table->getCols(true); $this->test->expect( @@ -253,9 +292,9 @@ private function runTestSuite($db) // current timestamp $table->addColumn('stamp') - ->type($schema::DT_TIMESTAMP) - ->nullable(false) - ->defaults($schema::DF_CURRENT_TIMESTAMP); + ->type($schema::DT_TIMESTAMP) + ->nullable(false) + ->defaults($schema::DF_CURRENT_TIMESTAMP); $table->build(); $r1 = $table->getCols(true); $this->test->expect( @@ -321,7 +360,7 @@ private function runTestSuite($db) $table->renameColumn('title123', 'text_default_not_null'); $table->build(); unset($result,$mapper); - + // remove column $table->dropColumn('column_1'); $table->build(); @@ -380,7 +419,7 @@ private function runTestSuite($db) // adding composite primary keys $table = $schema->createTable($this->tname); $table->addColumn('version')->type($schema::DT_INT4) - ->defaults(1)->nullable(false); + ->defaults(1)->nullable(false); $table->primary(array('id', 'version')); $table = $table->build(); $r1 = $table->getCols(true); @@ -399,7 +438,7 @@ private function runTestSuite($db) $table->addColumn('title')->type($schema::DT_VARCHAR256); $table->addColumn('title2')->type($schema::DT_TEXT); $table->addColumn('title_notnull') - ->type($schema::DT_VARCHAR128)->nullable(false)->defaults("foo"); + ->type($schema::DT_VARCHAR128)->nullable(false)->defaults("foo"); $table->build(); $r1 = $table->getCols(true); $this->test->expect( @@ -461,6 +500,18 @@ private function runTestSuite($db) $this->getTestDesc('adding items with composite primary-keys') ); + $mapper = new \DB\SQL\Mapper($db, $this->tname); + $mapper->load(); + $rec_count_cur = $mapper->loaded(); + $schema->truncateTable($this->tname); + $mapper->reset(); + $mapper->load(); + $rec_count_new = $mapper->loaded(); + $this->test->expect( + $rec_count_cur==3 && $rec_count_new == 0, + $this->getTestDesc('truncate table') + ); + $schema->dropTable($this->tname); // indexes @@ -520,11 +571,54 @@ private function runTestSuite($db) $table->updateColumn('bar',$schema::DT_TEXT); $table->build(); $r1 = $table->getCols(true); + $text = preg_match('/sybase|dblib|odbc|sqlsrv/',$this->current_engine) + ? 'nvarchar' : 'text'; $this->test->expect( - array_key_exists('bar', $r1) && $r1['bar']['type'] == 'text', + array_key_exists('bar', $r1) && $r1['bar']['type'] == $text, $this->getTestDesc('update column') ); + // update column + $cols = $table->getCols(true); + $bar = $cols['bar']; + $col = new \DB\SQL\Column('bar',$table); + $col->copyfrom($bar); + $col->type_varchar(60); + $col->defaults('great'); + $table->updateColumn('bar',$col); + $table->build(); + $r1 = $table->getCols(true); + $this->test->expect( + array_key_exists('bar', $r1) + && $r1['bar']['default'] == 'great', + $this->getTestDesc('update column and default') + ); + + // update column default only + $cols = $table->getCols(true); + $bar = $cols['bar']; + $col = new \DB\SQL\Column('bar',$table); + $col->copyfrom($bar); + $col->passThrough(); + $col->defaults(''); + $table->updateColumn('bar',$col); + $table->build(); + $r1 = $table->getCols(true); + $this->test->expect( + array_key_exists('bar', $r1) && $r1['bar']['default'] == '', + $this->getTestDesc('update default value') + ); + + $col->nullable(false); + $table->updateColumn('bar',$col); + $table->build(); + $r1 = $table->getCols(true); + $this->test->expect( + array_key_exists('bar', $r1) && $r1['bar']['nullable'] == false, + $this->getTestDesc('update nullable flag') + ); + + // create table with text not nullable column $table2 = $schema->createTable($this->tname.'_notnulltext'); $table2->addColumn('desc')->type($schema::DT_TEXT)->nullable(false); @@ -538,7 +632,37 @@ private function runTestSuite($db) ); $table2->drop(); - + // boolean fields are actually bit/tinyint + $schema->dropTable($this->tname.'_notnullbool'); + $table2 = $schema->createTable($this->tname.'_notnullbool'); + $table2->addColumn('active')->type($schema::DT_BOOL)->nullable(false); + $table2 = $table2->build(); + $r1 = $schema->getTables(); + $r2 = $table2->getCols(true); + $this->test->expect( + in_array($this->tname.'_notnullbool', $r1) && array_key_exists('active', $r2) + && $r2['active']['nullable']==false, + $this->getTestDesc('create new table with not nullable boolean column') + ); + + $table2->addColumn('active2')->type($schema::DT_BOOL)->nullable(false)->defaults(0); + $table2->addColumn('active3')->type($schema::DT_BOOL)->nullable(false)->defaults(1); + $table2->build(); + $r1 = $schema->getTables(); + $r2 = $table2->getCols(true); + $this->test->expect( + in_array($this->tname.'_notnullbool', $r1) + && array_key_exists('active2', $r2) && $r2['active2']['nullable']==false && + ((int)$r2['active2']['default']==0||$r2['active2']['default']=='false') + && array_key_exists('active3', $r2) && $r2['active3']['nullable']==false && + ((int)$r2['active3']['default']==1||$r2['active3']['default']=='true'), + $this->getTestDesc('add not nullable boolean columns with default to existing table') + ); + + + $table2->drop(); + + } } \ No newline at end of file diff --git a/app/config.ini b/app/config.ini index 59222d7b3..94ce3573f 100644 --- a/app/config.ini +++ b/app/config.ini @@ -21,6 +21,13 @@ TZ = UTC CACHE = folder=tmp/cache/ ;CACHE = redis=localhost:6379 +; Cache backend used by Session handler. +; default +; -If CACHE is enabled (see above), the same location is used for Session data (e.g. fileCache, RedisDB) +; mysql +; - Session data get stored in your 'PathfinderDB' table 'sessions' (faster) +SESSION_CACHE = mysql + ; Callback functions ============================================================================== ONERROR = Controller\Controller->showError UNLOAD = Controller\Controller->unload diff --git a/app/environment.ini b/app/environment.ini index 2eca34fdd..803f9daf8 100644 --- a/app/environment.ini +++ b/app/environment.ini @@ -14,15 +14,21 @@ BASE = URL = {{@SCHEME}}://local.pathfinder ; level of debug/error stack trace DEBUG = 3 -; main db -DB_DNS = mysql:host=localhost;port=3306;dbname= -DB_NAME = pathfinder -DB_USER = root -DB_PASS = - -; EVE-Online CCP Database export +; Pathfinder database +DB_PF_DNS = mysql:host=localhost;port=3306;dbname= +DB_PF_NAME = pathfinder +DB_PF_USER = root +DB_PF_PASS = + +; Universe data (New Eden) cache DB for ESI API respons +DB_UNIVERSE_DNS = mysql:host=localhost;port=3306;dbname= +DB_UNIVERSE_NAME = eve_universe +DB_UNIVERSE_USER = root +DB_UNIVERSE_PASS = + +; EVE-Online CCP database export DB_CCP_DNS = mysql:host=localhost;port=3306;dbname= -DB_CCP_NAME = eve_citadel_min +DB_CCP_NAME = eve_lifeblood_min DB_CCP_USER = root DB_CCP_PASS = @@ -34,7 +40,7 @@ CCP_SSO_SECRET_KEY = ; CCP ESI API CCP_ESI_URL = https://esi.tech.ccp.is CCP_ESI_DATASOURCE = singularity -CCP_ESI_SCOPES = esi-location.read_online.v1,esi-location.read_location.v1,esi-location.read_ship_type.v1,esi-ui.write_waypoint.v1,esi-ui.open_window.v1 +CCP_ESI_SCOPES = esi-location.read_online.v1,esi-location.read_location.v1,esi-location.read_ship_type.v1,esi-ui.write_waypoint.v1,esi-ui.open_window.v1,esi-universe.read_structures.v1 CCP_ESI_SCOPES_ADMIN = esi-corporations.read_corporation_membership.v1 ; SMTP settings (optional) @@ -60,11 +66,17 @@ BASE = URL = {{@SCHEME}}://www.pathfinder-w.space ; level of debug/error stack trace DEBUG = 0 -; main db -DB_DNS = mysql:host=localhost;port=3306;dbname= -DB_NAME = -DB_USER = -DB_PASS = +; Pathfinder database +DB_PF_DNS = mysql:host=localhost;port=3306;dbname= +DB_PF_NAME = +DB_PF_USER = +DB_PF_PASS = + +; Universe data (New Eden) cache DB for ESI API respons +DB_UNIVERSE_DNS = mysql:host=localhost;port=3306;dbname= +DB_UNIVERSE_NAME = +DB_UNIVERSE_USER = +DB_UNIVERSE_PASS = ; EVE-Online CCP Database export DB_CCP_DNS = mysql:host=localhost;port=3306;dbname= @@ -80,7 +92,7 @@ CCP_SSO_SECRET_KEY = ; CCP ESI API CCP_ESI_URL = https://esi.tech.ccp.is CCP_ESI_DATASOURCE = tranquility -CCP_ESI_SCOPES = esi-location.read_online.v1,esi-location.read_location.v1,esi-location.read_ship_type.v1,esi-ui.write_waypoint.v1,esi-ui.open_window.v1 +CCP_ESI_SCOPES = esi-location.read_online.v1,esi-location.read_location.v1,esi-location.read_ship_type.v1,esi-ui.write_waypoint.v1,esi-ui.open_window.v1,esi-universe.read_structures.v1 CCP_ESI_SCOPES_ADMIN = esi-corporations.read_corporation_membership.v1 ; SMTP settings (optional) diff --git a/app/lib/db/cortex.php b/app/lib/db/cortex.php index c922eb34e..b1fbdc7ba 100644 --- a/app/lib/db/cortex.php +++ b/app/lib/db/cortex.php @@ -18,8 +18,8 @@ * https://github.com/ikkez/F3-Sugar/ * * @package DB - * @version 1.5.0-dev - * @date 27.02.2017 + * @version 1.5.0 + * @date 30.06.2017 * @since 24.04.2012 */ @@ -312,6 +312,7 @@ public function getTable() { static public function setup($db=null, $table=null, $fields=null) { /** @var Cortex $self */ $self = get_called_class(); + $self::$schema_cache=[]; if (is_null($db) || is_null($table) || is_null($fields)) $df = $self::resolveConfiguration(); if (!is_object($db=(is_string($db=($db?:$df['db']))?\Base::instance()->get($db):$db))) @@ -836,9 +837,14 @@ function($str) use($db) { array_unshift($filter,$crit); } } + if ($options) { + $options = $this->queryParser->prepareOptions($options,$this->dbsType); + if ($count) + unset($options['order']); + } return ($count) ? $this->mapper->count($filter,$options,$ttl) - : $this->mapper->find($filter,$this->queryParser->prepareOptions($options,$this->dbsType),$ttl); + : $this->mapper->find($filter,$options,$ttl); } /** @@ -1250,12 +1256,12 @@ public function countRel($key, $alias=null, $filter=null, $option=null) { $mmTable = $this->mmTable($relConf,$key); $filter = array($mmTable.'.'.$relConf['relField'] .' = '.$this->table.'.'.$this->primary); - $from=$mmTable; + $from = $this->db->quotekey($mmTable); if (array_key_exists($key, $this->relFilter) && !empty($this->relFilter[$key][0])) { $options=array(); - $from = $mmTable.' '.$this->_sql_left_join($key,$mmTable, - $relConf['relPK'],$relConf['relTable']); + $from = $this->db->quotekey($mmTable).' '. + $this->_sql_left_join($key,$mmTable,$relConf['relPK'],$relConf['relTable']); $relFilter = $this->relFilter[$key]; $this->_sql_mergeRelCondition($relFilter,$relConf['relTable'], $filter,$options); @@ -1266,8 +1272,8 @@ public function countRel($key, $alias=null, $filter=null, $option=null) { if (count($filter)>0) $this->preBinds=array_merge($this->preBinds,$filter); $this->mapper->set($alias, - '(select count('.$this->db->quotekey($mmTable.'.'.$relConf['relField']).') from '. - $this->db->quotekey($from).' where '.$crit. + '(select count('.$this->db->quotekey($mmTable.'.'.$relConf['relField']).')'. + ' from '.$from.' where '.$crit. ' group by '.$this->db->quotekey($mmTable.'.'.$relConf['relField']).')'); if ($this->whitelist && !in_array($alias,$this->whitelist)) $this->whitelist[] = $alias; @@ -2444,6 +2450,8 @@ protected function _mongo_parse_logical_op($parts) { $child = array(); for ($i = 0, $max = count($parts); $i < $max; $i++) { $part = $parts[$i]; + if (is_string($part)) + $part = trim($part); if ($part == '(') { // add sub-bracket to parse array if ($b_offset > 0) @@ -2460,14 +2468,15 @@ protected function _mongo_parse_logical_op($parts) { else // add sub-bracket to parse array $child[] = $part; - } // add to parse array - elseif ($b_offset > 0) - $child[] = $part; - // condition type - elseif (!is_array($part)) { - if (strtoupper(trim($part)) == 'AND') + } + elseif ($b_offset > 0) { + // add to parse array + $child[]=$part; + // condition type + } elseif (!is_array($part)) { + if (strtoupper($part) == 'AND') $add = true; - elseif (strtoupper(trim($part)) == 'OR') + elseif (strtoupper($part) == 'OR') $or = true; } else // skip $ncond[] = $part; @@ -2589,7 +2598,7 @@ public function prepareOptions($options, $engine) { if (array_key_exists('group', $options) && is_string($options['group'])) { $keys = explode(',',$options['group']); $options['group']=array('keys'=>array(),'initial'=>array(), - 'reduce'=>'function (obj, prev) {}','finalize'=>''); + 'reduce'=>'function (obj, prev) {}','finalize'=>''); $keys = array_combine($keys,array_fill(0,count($keys),1)); $options['group']['keys']=$keys; $options['group']['initial']=$keys; diff --git a/app/lib/db/sql/schema.php b/app/lib/db/sql/schema.php index bea09c30e..06ddfea8c 100644 --- a/app/lib/db/sql/schema.php +++ b/app/lib/db/sql/schema.php @@ -32,77 +32,77 @@ class Schema extends DB_Utils { public $dataTypes = array( 'BOOLEAN' => array('mysql' => 'tinyint(1)', - 'sqlite2?|pgsql' => 'BOOLEAN', - 'mssql|sybase|dblib|odbc|sqlsrv' => 'bit', - 'ibm' => 'numeric(1,0)', + 'sqlite2?|pgsql' => 'BOOLEAN', + 'mssql|sybase|dblib|odbc|sqlsrv' => 'bit', + 'ibm' => 'numeric(1,0)', ), 'INT1' => array('mysql' => 'tinyint(4)', - 'sqlite2?' => 'integer(4)', - 'mssql|sybase|dblib|odbc|sqlsrv' => 'tinyint', - 'pgsql|ibm' => 'smallint', + 'sqlite2?' => 'integer(4)', + 'mssql|sybase|dblib|odbc|sqlsrv' => 'tinyint', + 'pgsql|ibm' => 'smallint', ), 'INT2' => array('mysql' => 'smallint(6)', - 'sqlite2?' => 'integer(6)', - 'pgsql|ibm|mssql|sybase|dblib|odbc|sqlsrv' => 'smallint', + 'sqlite2?' => 'integer(6)', + 'pgsql|ibm|mssql|sybase|dblib|odbc|sqlsrv' => 'smallint', ), 'INT4' => array('sqlite2?' => 'integer(11)', - 'pgsql|imb' => 'integer', - 'mysql' => 'int(11)', - 'mssql|dblib|sybase|odbc|sqlsrv' => 'int', + 'pgsql|imb' => 'integer', + 'mysql' => 'int(11)', + 'mssql|dblib|sybase|odbc|sqlsrv' => 'int', ), 'INT8' => array('sqlite2?' => 'integer(20)', - 'pgsql|mssql|sybase|dblib|odbc|sqlsrv|imb' => 'bigint', - 'mysql' => 'bigint(20)', + 'pgsql|mssql|sybase|dblib|odbc|sqlsrv|imb' => 'bigint', + 'mysql' => 'bigint(20)', ), 'FLOAT' => array('mysql|sqlite2?' => 'FLOAT', - 'pgsql' => 'double precision', - 'mssql|sybase|dblib|odbc|sqlsrv' => 'float', - 'imb' => 'decfloat' + 'pgsql' => 'double precision', + 'mssql|sybase|dblib|odbc|sqlsrv' => 'float', + 'imb' => 'decfloat' ), 'DOUBLE' => array('mysql|ibm' => 'decimal(18,6)', - 'sqlite2?' => 'decimal(15,6)', // max 15-digit on sqlite - 'pgsql' => 'numeric(18,6)', - 'mssql|dblib|sybase|odbc|sqlsrv' => 'decimal(18,6)', + 'sqlite2?' => 'decimal(15,6)', // max 15-digit on sqlite + 'pgsql' => 'numeric(18,6)', + 'mssql|dblib|sybase|odbc|sqlsrv' => 'decimal(18,6)', ), 'VARCHAR128' => array('mysql|sqlite2?|ibm|mssql|sybase|dblib|odbc|sqlsrv' => 'varchar(128)', - 'pgsql' => 'character varying(128)', + 'pgsql' => 'character varying(128)', ), 'VARCHAR256' => array('mysql|sqlite2?|ibm|mssql|sybase|dblib|odbc|sqlsrv' => 'varchar(255)', - 'pgsql' => 'character varying(255)', + 'pgsql' => 'character varying(255)', ), 'VARCHAR512' => array('mysql|sqlite2?|ibm|mssql|sybase|dblib|odbc|sqlsrv' => 'varchar(512)', - 'pgsql' => 'character varying(512)', + 'pgsql' => 'character varying(512)', ), 'TEXT' => array('mysql|sqlite2?|pgsql|mssql' => 'text', - 'sybase|dblib|odbc|sqlsrv' => 'nvarchar(max)', - 'ibm' => 'BLOB SUB_TYPE TEXT', + 'sybase|dblib|odbc|sqlsrv' => 'nvarchar(max)', + 'ibm' => 'BLOB SUB_TYPE TEXT', ), 'LONGTEXT' => array('mysql' => 'LONGTEXT', - 'sqlite2?|pgsql|mssql' => 'text', - 'sybase|dblib|odbc|sqlsrv' => 'nvarchar(max)', - 'ibm' => 'CLOB(2000000000)', + 'sqlite2?|pgsql|mssql' => 'text', + 'sybase|dblib|odbc|sqlsrv' => 'nvarchar(max)', + 'ibm' => 'CLOB(2000000000)', ), 'DATE' => array('mysql|sqlite2?|pgsql|mssql|sybase|dblib|odbc|sqlsrv|ibm' => 'date', ), 'DATETIME' => array('pgsql' => 'timestamp without time zone', - 'mysql|sqlite2?|mssql|sybase|dblib|odbc|sqlsrv' => 'datetime', - 'ibm' => 'timestamp', + 'mysql|sqlite2?|mssql|sybase|dblib|odbc|sqlsrv' => 'datetime', + 'ibm' => 'timestamp', ), 'TIMESTAMP' => array('mysql|ibm' => 'timestamp', - 'pgsql|odbc' => 'timestamp without time zone', - 'sqlite2?|mssql|sybase|dblib|sqlsrv'=>'DATETIME', + 'pgsql|odbc' => 'timestamp without time zone', + 'sqlite2?|mssql|sybase|dblib|sqlsrv'=>'DATETIME', ), 'BLOB' => array('mysql|odbc|sqlite2?|ibm' => 'blob', - 'pgsql' => 'bytea', - 'mssql|sybase|dblib' => 'image', - 'sqlsrv' => 'varbinary(max)', + 'pgsql' => 'bytea', + 'mssql|sybase|dblib' => 'image', + 'sqlsrv' => 'varbinary(max)', ), ), $defaultTypes = array( 'CUR_STAMP' => array('mysql' => 'CURRENT_TIMESTAMP', - 'mssql|sybase|dblib|odbc|sqlsrv' => 'getdate()', - 'pgsql' => 'LOCALTIMESTAMP(0)', - 'sqlite2?' => "(datetime('now','localtime'))", + 'mssql|sybase|dblib|odbc|sqlsrv' => 'getdate()', + 'pgsql' => 'LOCALTIMESTAMP(0)', + 'sqlite2?' => "(datetime('now','localtime'))", ), ); diff --git a/app/main/controller/accesscontroller.php b/app/main/controller/accesscontroller.php index e35cca710..1c095b5b0 100644 --- a/app/main/controller/accesscontroller.php +++ b/app/main/controller/accesscontroller.php @@ -17,30 +17,30 @@ class AccessController extends Controller { /** * event handler * @param \Base $f3 - * @param array $params + * @param $params + * @return bool */ - function beforeroute(\Base $f3, $params) { - parent::beforeroute($f3, $params); - - // Any route/endpoint of a child class of this one, - // requires a valid logged in user! - $loginCheck = $this->isLoggedIn($f3); - - if( !$loginCheck ){ - // no user found or login timer expired - $this->logout($f3); - - if( $f3->get('AJAX') ){ - // unauthorized request - $f3->status(403); - }else{ - // redirect to landing page - $f3->reroute(['login']); + function beforeroute(\Base $f3, $params): bool { + if($return = parent::beforeroute($f3, $params)){ + // Any route/endpoint of a child class of this one, + // requires a valid logged in user! + if( !$this->isLoggedIn($f3) ){ + // no character found or login timer expired + $this->logoutCharacter(); + + if($f3->get('AJAX')){ + // unauthorized request + $f3->status(403); + }else{ + // redirect to landing page + $f3->reroute(['login']); + } + // skip route handler and afterroute() + $return = false; } - - // die() triggers unload() function - die(); } + + return $return; } /** @@ -48,7 +48,7 @@ function beforeroute(\Base $f3, $params) { * @param \Base $f3 * @return bool */ - protected function isLoggedIn(\Base $f3){ + protected function isLoggedIn(\Base $f3): bool { $loginCheck = false; if( $character = $this->getCharacter() ){ if($this->checkLogTimer($f3, $character)){ @@ -84,7 +84,7 @@ private function checkLogTimer(\Base $f3, Model\CharacterModel $character){ $minutes += $timeDiff->h * 60; $minutes += $timeDiff->i; - if($minutes <= $f3->get('PATHFINDER.TIMER.LOGGED')){ + if($minutes <= Config::getPathfinderData('timer.logged')){ $loginCheck = true; } } diff --git a/app/main/controller/admin.php b/app/main/controller/admin.php index 378bfbd11..20a514b9e 100644 --- a/app/main/controller/admin.php +++ b/app/main/controller/admin.php @@ -32,9 +32,11 @@ class Admin extends Controller{ * 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) { - parent::beforeroute($f3, $params); + function beforeroute(\Base $f3, $params): bool { + $return = parent::beforeroute($f3, $params); $f3->set('tplPage', 'login'); @@ -54,6 +56,8 @@ function beforeroute(\Base $f3, $params) { // body element class $f3->set('tplBodyClass', 'pf-landing'); + + return $return; } /** @@ -291,7 +295,9 @@ protected function initMembers(\Base $f3, CharacterModel $character){ } foreach($corporations as $corporation){ - $data->corpMembers[$corporation->name] = $corporation->getCharacters(); + if($characters = $corporation->getCharacters()){ + $data->corpMembers[$corporation->name] = $corporation->getCharacters(); + } } // sort corporation from current user first diff --git a/app/main/controller/api/access.php b/app/main/controller/api/access.php index c323a54b0..94898f544 100644 --- a/app/main/controller/api/access.php +++ b/app/main/controller/api/access.php @@ -12,16 +12,6 @@ class Access extends Controller\AccessController { - /** - * event handler - * @param \Base $f3 - * @param array $params - */ - function beforeroute(\Base $f3, $params) { - // set header for all routes - header('Content-type: application/json'); - parent::beforeroute($f3, $params); - } /** * search character/corporation or alliance by name diff --git a/app/main/controller/api/connection.php b/app/main/controller/api/connection.php index 89717d287..85a490c67 100644 --- a/app/main/controller/api/connection.php +++ b/app/main/controller/api/connection.php @@ -12,15 +12,6 @@ class Connection extends Controller\AccessController { - /** - * @param \Base $f3 - * @param array $params - */ - function beforeroute(\Base $f3, $params) { - // set header for all routes - header('Content-type: application/json'); - parent::beforeroute($f3, $params); - } /** * save a new connection or updates an existing (drag/drop) between two systems @@ -29,7 +20,10 @@ function beforeroute(\Base $f3, $params) { */ public function save(\Base $f3){ $postData = (array)$f3->get('POST'); - $newConnectionData = []; + + $return = (object) []; + $return->error = []; + $return->connectionData = (object) []; if( isset($postData['connectionData']) && @@ -75,27 +69,23 @@ public function save(\Base $f3){ $connectionData['scope'] = 'wh'; $connectionData['type'] = ['wh_fresh']; } - $connectionData['mapId'] = $map; - // "updated" should not be set by client e.g. after manual drag&drop - unset($connectionData['updated']); - $connection->setData($connectionData); - if( $connection->isValid() ){ - $connection->save(); - - $newConnectionData = $connection->getData(); + if($connection->save($activeCharacter)){ + $return->connectionData = $connection->getData(); // broadcast map changes $this->broadcastMapData($connection->mapId); + }else{ + $return->error = $connection->getErrors(); } } } } - echo json_encode($newConnectionData); + echo json_encode($return); } /** diff --git a/app/main/controller/api/github.php b/app/main/controller/api/github.php index e90787883..18ccc0930 100644 --- a/app/main/controller/api/github.php +++ b/app/main/controller/api/github.php @@ -7,7 +7,7 @@ */ namespace Controller\Api; -use Model; +use lib\Config; use Controller; @@ -43,7 +43,7 @@ public function releases($f3){ $releaseCount = 4; if( !$f3->exists($cacheKey) ){ - $apiPath = $this->getF3()->get('PATHFINDER.API.GIT_HUB') . '/repos/exodus4d/pathfinder/releases'; + $apiPath = Config::getPathfinderData('api.git_hub') . '/repos/exodus4d/pathfinder/releases'; // build request URL $options = $this->getRequestOptions(); diff --git a/app/main/controller/api/map.php b/app/main/controller/api/map.php index 37362da12..2719ff45e 100644 --- a/app/main/controller/api/map.php +++ b/app/main/controller/api/map.php @@ -8,9 +8,11 @@ namespace Controller\Api; use Controller; +use data\file\FileHandler; use lib\Config; use lib\Socket; use Model; +use Exception; /** * Map controller @@ -23,24 +25,15 @@ class Map extends Controller\AccessController { const CACHE_KEY_INIT = 'CACHED_INIT'; const CACHE_KEY_MAP_DATA = 'CACHED.MAP_DATA.%s'; const CACHE_KEY_USER_DATA = 'CACHED.USER_DATA.%s_%s'; + const CACHE_KEY_HISTORY = 'CACHED_MAP_HISTORY_%s'; - /** - * event handler - * @param \Base $f3 - * @param array $params - */ - function beforeroute(\Base $f3, $params) { - // set header for all routes - header('Content-type: application/json'); - parent::beforeroute($f3, $params); - } /** * get map data cache key * @param Model\CharacterModel $character * @return string */ - protected function getMapDataCacheKey(Model\CharacterModel $character){ + protected function getMapDataCacheKey(Model\CharacterModel $character): string { return sprintf(self::CACHE_KEY_MAP_DATA, 'CHAR_' . $character->_id); } @@ -62,10 +55,19 @@ protected function clearMapDataCache(Model\CharacterModel $character){ * @param int $systemId * @return string */ - protected function getUserDataCacheKey($mapId, $systemId = 0){ + protected function getUserDataCacheKey($mapId, $systemId = 0): string { return sprintf(self::CACHE_KEY_USER_DATA, 'MAP_' . $mapId, 'SYS_' . $systemId); } + /** + * get log history data cache key + * @param int $mapId + * @return string + */ + protected function getHistoryDataCacheKey(int $mapId): string { + return sprintf(self::CACHE_KEY_HISTORY, 'MAP_' . $mapId); + } + /** * Get all required static config data for program initialization * @param \Base $f3 @@ -79,10 +81,10 @@ public function init(\Base $f3){ $return = (object) []; $return->error = []; - // static program data ---------------------------------------------------------------------------------------- - $return->timer = $f3->get('PATHFINDER.TIMER'); + // static program data ------------------------------------------------------------------------------------ + $return->timer = Config::getPathfinderData('timer'); - // get all available map types -------------------------------------------------------------------------------- + // get all available map types ---------------------------------------------------------------------------- $mapType = Model\BasicModel::getNew('MapTypeModel'); $rows = $mapType->find('active = 1', null, $expireTimeSQL); @@ -103,7 +105,7 @@ public function init(\Base $f3){ } $return->mapTypes = $mapTypeData; - // get all available map scopes ------------------------------------------------------------------------------- + // get all available map scopes --------------------------------------------------------------------------- $mapScope = Model\BasicModel::getNew('MapScopeModel'); $rows = $mapScope->find('active = 1', null, $expireTimeSQL); $mapScopeData = []; @@ -116,7 +118,7 @@ public function init(\Base $f3){ } $return->mapScopes = $mapScopeData; - // get all available system status ---------------------------------------------------------------------------- + // get all available system status ------------------------------------------------------------------------ $systemStatus = Model\BasicModel::getNew('SystemStatusModel'); $rows = $systemStatus->find('active = 1', null, $expireTimeSQL); $systemScopeData = []; @@ -130,7 +132,7 @@ public function init(\Base $f3){ } $return->systemStatus = $systemScopeData; - // get all available system types ----------------------------------------------------------------------------- + // get all available system types ------------------------------------------------------------------------- $systemType = Model\BasicModel::getNew('SystemTypeModel'); $rows = $systemType->find('active = 1', null, $expireTimeSQL); $systemTypeData = []; @@ -143,7 +145,7 @@ public function init(\Base $f3){ } $return->systemType = $systemTypeData; - // get available connection scopes ---------------------------------------------------------------------------- + // get available connection scopes ------------------------------------------------------------------------ $connectionScope = Model\BasicModel::getNew('ConnectionScopeModel'); $rows = $connectionScope->find('active = 1', null, $expireTimeSQL); $connectionScopeData = []; @@ -157,7 +159,7 @@ public function init(\Base $f3){ } $return->connectionScopes = $connectionScopeData; - // get available character status ----------------------------------------------------------------------------- + // get available character status ------------------------------------------------------------------------- $characterStatus = Model\BasicModel::getNew('CharacterStatusModel'); $rows = $characterStatus->find('active = 1', null, $expireTimeSQL); $characterStatusData = []; @@ -171,21 +173,27 @@ public function init(\Base $f3){ } $return->characterStatus = $characterStatusData; - // route search config ---------------------------------------------------------------------------------------- + // route search config ------------------------------------------------------------------------------------ $return->routeSearch = [ - 'defaultCount' => $this->getF3()->get('PATHFINDER.ROUTE.SEARCH_DEFAULT_COUNT'), - 'maxDefaultCount' => $this->getF3()->get('PATHFINDER.ROUTE.MAX_Default_COUNT'), - 'limit' => $this->getF3()->get('PATHFINDER.ROUTE.LIMIT'), + 'defaultCount' => Config::getPathfinderData('route.search_default_count'), + 'maxDefaultCount' => Config::getPathfinderData('route.max_default_count'), + 'limit' => Config::getPathfinderData('route.limit') ]; - // get program routes ----------------------------------------------------------------------------------------- + // get program routes ------------------------------------------------------------------------------------- $return->routes = [ 'ssoLogin' => $this->getF3()->alias( 'sso', ['action' => 'requestAuthorization'] ) ]; - // get notification status ------------------------------------------------------------------------------------ - $return->notificationStatus = [ - 'rallySet' => (bool)Config::getNotificationMail('RALLY_SET') + // get third party APIs ----------------------------------------------------------------------------------- + $return->url = [ + 'ccpImageServer' => Config::getPathfinderData('api.ccp_image_server'), + 'zKillboard' => Config::getPathfinderData('api.z_killboard') + ]; + + // Slack integration status ------------------------------------------------------------------------------- + $return->slack = [ + 'status' => (bool)Config::getPathfinderData('slack.status') ]; $f3->set(self::CACHE_KEY_INIT, $return, $expireTimeCache ); @@ -195,7 +203,7 @@ public function init(\Base $f3){ // program mode (e.g. "maintenance") -------------------------------------------------------------------------- $return->programMode = [ - 'maintenance' => $this->getF3()->get('PATHFINDER.LOGIN.MODE_MAINTENANCE') + 'maintenance' => Config::getPathfinderData('login.mode_maintenance') ]; // get SSO error messages that should be shown immediately ---------------------------------------------------- @@ -263,17 +271,12 @@ public function import(\Base $f3){ isset($mapData['data']['systems']) && isset($mapData['data']['connections']) ){ - if(isset($mapData['config']['id'])){ - unset($mapData['config']['id']); - } - - $systemCount = count($mapData['data']['systems']); if( $systemCount <= $defaultConfig['max_systems']){ $map->setData($mapData['config']); $map->typeId = (int)$importData['typeId']; - $map->save(); + $map->save($activeCharacter); // new system IDs will be generated // therefore we need to temp store a mapping between IDs @@ -282,13 +285,10 @@ public function import(\Base $f3){ foreach($mapData['data']['systems'] as $systemData){ if(isset($systemData['id'])){ $oldId = (int)$systemData['id']; - unset($systemData['id']); $system->setData($systemData); $system->mapId = $map; - $system->createdCharacterId = $activeCharacter; - $system->updatedCharacterId = $activeCharacter; - $system->save(); + $system->save($activeCharacter); $tempSystemIdMapping[$oldId] = $system->id; $system->reset(); @@ -301,15 +301,11 @@ public function import(\Base $f3){ isset( $tempSystemIdMapping[$connectionData['source']] ) && isset( $tempSystemIdMapping[$connectionData['target']] ) ){ - if(isset($connectionData['id'])){ - unset($connectionData['id']); - } - $connection->setData($connectionData); $connection->mapId = $map; $connection->source = $tempSystemIdMapping[$connectionData['source']]; $connection->target = $tempSystemIdMapping[$connectionData['target']]; - $connection->save(); + $connection->save($activeCharacter); $connection->reset(); } @@ -391,150 +387,161 @@ public function save(\Base $f3){ $map->dry() || $map->hasAccess($activeCharacter) ){ - // new map - $map->setData($formData); - $map = $map->save(); - - // save global map access. Depends on map "type" - if($map->isPrivate()){ - - // share map between characters -> set access - if(isset($formData['mapCharacters'])){ - // remove character corporation (re-add later) - $accessCharacters = array_diff($formData['mapCharacters'], [$activeCharacter->_id]); - - // avoid abuse -> respect share limits - $maxShared = max($f3->get('PATHFINDER.MAP.PRIVATE.MAX_SHARED') - 1, 0); - $accessCharacters = array_slice($accessCharacters, 0, $maxShared); - - // clear map access. In case something has removed from access list - $map->clearAccess(); - - if($accessCharacters){ - /** - * @var $tempCharacter Model\CharacterModel - */ - $tempCharacter = Model\BasicModel::getNew('CharacterModel'); - - foreach($accessCharacters as $characterId){ - $tempCharacter->getById( (int)$characterId ); - - if( - !$tempCharacter->dry() && - $tempCharacter->shared == 1 // check if map shared is enabled - ){ - $map->setAccess($tempCharacter); - } - - $tempCharacter->reset(); - } - } - } + try{ + // new map + $map->setData($formData); + $map = $map->save($activeCharacter); - // the current character itself should always have access - // just in case he removed himself :) - $map->setAccess($activeCharacter); - }elseif($map->isCorporation()){ - $corporation = $activeCharacter->getCorporation(); + $mapDefaultConf = Config::getMapsDefaultConfig(); - if($corporation){ - // the current user has to have a corporation when - // working on corporation maps! + // save global map access. Depends on map "type" + if($map->isPrivate()){ - // share map between corporations -> set access - if(isset($formData['mapCorporations'])){ + // share map between characters -> set access + if(isset($formData['mapCharacters'])){ // remove character corporation (re-add later) - $accessCorporations = array_diff($formData['mapCorporations'], [$corporation->_id]); + $accessCharacters = array_diff($formData['mapCharacters'], [$activeCharacter->_id]); // avoid abuse -> respect share limits - $maxShared = max($f3->get('PATHFINDER.MAP.CORPORATION.MAX_SHARED') - 1, 0); - $accessCorporations = array_slice($accessCorporations, 0, $maxShared); + $maxShared = max($mapDefaultConf['private']['max_shared'] - 1, 0); + $accessCharacters = array_slice($accessCharacters, 0, $maxShared); // clear map access. In case something has removed from access list $map->clearAccess(); - if($accessCorporations){ + if($accessCharacters){ /** - * @var $tempCorporation Model\CorporationModel + * @var $tempCharacter Model\CharacterModel */ - $tempCorporation = Model\BasicModel::getNew('CorporationModel'); + $tempCharacter = Model\BasicModel::getNew('CharacterModel'); - foreach($accessCorporations as $corporationId){ - $tempCorporation->getById( (int)$corporationId ); + foreach($accessCharacters as $characterId){ + $tempCharacter->getById( (int)$characterId ); if( - !$tempCorporation->dry() && - $tempCorporation->shared == 1 // check if map shared is enabled + !$tempCharacter->dry() && + $tempCharacter->shared == 1 // check if map shared is enabled ){ - $map->setAccess($tempCorporation); + $map->setAccess($tempCharacter); } - $tempCorporation->reset(); + $tempCharacter->reset(); } } } - // the corporation of the current user should always have access - $map->setAccess($corporation); - } - }elseif($map->isAlliance()){ - $alliance = $activeCharacter->getAlliance(); + // the current character itself should always have access + // just in case he removed himself :) + $map->setAccess($activeCharacter); + }elseif($map->isCorporation()){ + $corporation = $activeCharacter->getCorporation(); - if($alliance){ - // the current user has to have a alliance when - // working on alliance maps! + if($corporation){ + // the current user has to have a corporation when + // working on corporation maps! - // share map between alliances -> set access - if(isset($formData['mapAlliances'])){ - // remove character alliance (re-add later) - $accessAlliances = array_diff($formData['mapAlliances'], [$alliance->_id]); + // share map between corporations -> set access + if(isset($formData['mapCorporations'])){ + // remove character corporation (re-add later) + $accessCorporations = array_diff($formData['mapCorporations'], [$corporation->_id]); - // avoid abuse -> respect share limits - $maxShared = max($f3->get('PATHFINDER.MAP.ALLIANCE.MAX_SHARED') - 1, 0); - $accessAlliances = array_slice($accessAlliances, 0, $maxShared); + // avoid abuse -> respect share limits + $maxShared = max($mapDefaultConf['corporation']['max_shared'] - 1, 0); + $accessCorporations = array_slice($accessCorporations, 0, $maxShared); - // clear map access. In case something has removed from access list - $map->clearAccess(); + // clear map access. In case something has removed from access list + $map->clearAccess(); - if($accessAlliances){ - /** - * @var $tempAlliance Model\AllianceModel - */ - $tempAlliance = Model\BasicModel::getNew('AllianceModel'); + if($accessCorporations){ + /** + * @var $tempCorporation Model\CorporationModel + */ + $tempCorporation = Model\BasicModel::getNew('CorporationModel'); - foreach($accessAlliances as $allianceId){ - $tempAlliance->getById( (int)$allianceId ); + foreach($accessCorporations as $corporationId){ + $tempCorporation->getById( (int)$corporationId ); - if( - !$tempAlliance->dry() && - $tempAlliance->shared == 1 // check if map shared is enabled - ){ - $map->setAccess($tempAlliance); - } + if( + !$tempCorporation->dry() && + $tempCorporation->shared == 1 // check if map shared is enabled + ){ + $map->setAccess($tempCorporation); + } - $tempAlliance->reset(); + $tempCorporation->reset(); + } } } + + // the corporation of the current user should always have access + $map->setAccess($corporation); } + }elseif($map->isAlliance()){ + $alliance = $activeCharacter->getAlliance(); - // the alliance of the current user should always have access - $map->setAccess($alliance); - } - } - // reload the same map model (refresh) - // this makes sure all data is up2date - $map->getById( $map->_id, 0 ); + if($alliance){ + // the current user has to have a alliance when + // working on alliance maps! + // share map between alliances -> set access + if(isset($formData['mapAlliances'])){ + // remove character alliance (re-add later) + $accessAlliances = array_diff($formData['mapAlliances'], [$alliance->_id]); - $charactersData = $map->getCharactersData(); - $characterIds = array_map(function ($data){ - return $data->id; - }, $charactersData); + // avoid abuse -> respect share limits + $maxShared = max($mapDefaultConf['alliance']['max_shared'] - 1, 0); + $accessAlliances = array_slice($accessAlliances, 0, $maxShared); - // broadcast map Access -> and send map Data - $this->broadcastMapAccess($map, $characterIds); + // clear map access. In case something has removed from access list + $map->clearAccess(); + + if($accessAlliances){ + /** + * @var $tempAlliance Model\AllianceModel + */ + $tempAlliance = Model\BasicModel::getNew('AllianceModel'); + + foreach($accessAlliances as $allianceId){ + $tempAlliance->getById( (int)$allianceId ); + + if( + !$tempAlliance->dry() && + $tempAlliance->shared == 1 // check if map shared is enabled + ){ + $map->setAccess($tempAlliance); + } + + $tempAlliance->reset(); + } + } + } + + // the alliance of the current user should always have access + $map->setAccess($alliance); + } + } + // reload the same map model (refresh) + // this makes sure all data is up2date + $map->getById( $map->_id, 0 ); + + + $charactersData = $map->getCharactersData(); + $characterIds = array_map(function ($data){ + return $data->id; + }, $charactersData); + + // broadcast map Access -> and send map Data + $this->broadcastMapAccess($map, $characterIds); + + $return->mapData = $map->getData(); + }catch(Exception\ValidationException $e){ + $validationError = (object) []; + $validationError->type = 'error'; + $validationError->field = $e->getField(); + $validationError->message = $e->getMessage(); + $return->error[] = $validationError; + } - $return->mapData = $map->getData(); }else{ // map access denied $captchaError = (object) []; @@ -559,18 +566,30 @@ public function save(\Base $f3){ */ public function delete(\Base $f3){ $mapData = (array)$f3->get('POST.mapData'); - $activeCharacter = $this->getCharacter(); + $mapId = (int)$mapData['id']; + $return = (object) []; + $return->deletedMapIds = []; - /** - * @var $map Model\MapModel - */ - $map = Model\BasicModel::getNew('MapModel'); - $map->getById($mapData['id']); - $map->delete( $activeCharacter, function($mapId){ - $this->broadcastMapDeleted($mapId); - }); + if($mapId){ + $activeCharacter = $this->getCharacter(); + + /** + * @var $map Model\MapModel + */ + $map = Model\BasicModel::getNew('MapModel'); + $map->getById($mapId); + + if($map->hasAccess($activeCharacter)){ + $map->setActive(false); + $map->save($activeCharacter); + $return->deletedMapIds[] = $mapId; - echo json_encode([]); + // broadcast map delete + $this->broadcastMapDeleted($mapId); + } + } + + echo json_encode($return); } /** @@ -654,7 +673,7 @@ public function updateData(\Base $f3){ $return = (object) []; $return->error = []; - // get current map data =============================================================================== + // get current map data =================================================================================== $maps = $activeCharacter->getMaps(); // loop all submitted map data that should be saved @@ -680,13 +699,13 @@ public function updateData(\Base $f3){ count($connections) > 0 ){ - // map changes expected ======================================================================= + // map changes expected =========================================================================== // loop current user maps and check for changes foreach($maps as $map){ $mapChanged = false; - // update system data --------------------------------------------------------------------- + // update system data ------------------------------------------------------------------------- foreach($systems as $i => $systemData){ // check if current system belongs to the current map @@ -703,25 +722,25 @@ public function updateData(\Base $f3){ // system belongs to the current map if(is_object($filteredMap->systems)){ // update - unset($systemData['updated']); /** * @var $system Model\SystemModel */ $system = $filteredMap->systems->current(); $system->setData($systemData); - $system->updatedCharacterId = $activeCharacter; - $system->save(); - - $mapChanged = true; - // a system belongs to ONE map -> speed up for multiple maps - unset($systemData[$i]); + if($system->save($activeCharacter)){ + $mapChanged = true; + // 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 ----------------------------------------------------------------- + // update connection data --------------------------------------------------------------------- foreach($connections as $i => $connectionData){ // check if the current connection belongs to the current map @@ -738,19 +757,20 @@ public function updateData(\Base $f3){ // connection belongs to the current map if(is_object($filteredMap->connections)){ // update - unset($connectionData['updated']); /** * @var $connection Model\ConnectionModel */ $connection = $filteredMap->connections->current(); $connection->setData($connectionData); - $connection->save(); - $mapChanged = true; - - // a connection belongs to ONE map -> speed up for multiple maps - unset($connectionData[$i]); + if($connection->save($activeCharacter)){ + $mapChanged = true; + // one connection belongs to ONE map -> speed up for multiple maps + unset($connectionData[$i]); + }else{ + $return->error = array_merge($return->error, $connection->getErrors()); + } } } } @@ -767,7 +787,7 @@ public function updateData(\Base $f3){ // cache time(s) per user should be equal or less than this function is called // prevent request flooding - $responseTTL = (int)$f3->get('PATHFINDER.TIMER.UPDATE_SERVER_MAP.DELAY') / 1000; + $responseTTL = (int)Config::getPathfinderData('timer.update_server_map.delay') / 1000; $f3->set($cacheKey, $return, $responseTTL); } @@ -852,7 +872,7 @@ public function updateUserData(\Base $f3){ // cache time (seconds) should be equal or less than request trigger time // prevent request flooding - $responseTTL = (int)$f3->get('PATHFINDER.TIMER.UPDATE_SERVER_USER_DATA.DELAY') / 1000; + $responseTTL = (int)Config::getPathfinderData('timer.update_server_user_data.delay') / 1000; // cache response $f3->set($cacheKey, $return, $responseTTL); @@ -1003,13 +1023,13 @@ protected function updateMapData(Model\CharacterModel $character, Model\MapModel break; } - // save source system ------------------------------------------------------------------------------------- + // save source system --------------------------------------------------------------------------------- if( $addSourceSystem && $sourceSystem && !$sourceExists ){ - $sourceSystem = $map->saveSystem($sourceSystem, $systemPosX, $systemPosY, $character); + $sourceSystem = $map->saveSystem($sourceSystem, $character, $systemPosX, $systemPosY); // get updated maps object if($sourceSystem){ $map = $sourceSystem->mapId; @@ -1021,13 +1041,13 @@ protected function updateMapData(Model\CharacterModel $character, Model\MapModel } } - // save target system ------------------------------------------------------------------------------------- + // save target system --------------------------------------------------------------------------------- if( $addTargetSystem && $targetSystem && !$targetExists ){ - $targetSystem = $map->saveSystem($targetSystem, $systemPosX, $systemPosY, $character); + $targetSystem = $map->saveSystem($targetSystem, $character, $systemPosX, $systemPosY); // get updated maps object if($targetSystem){ $map = $targetSystem->mapId; @@ -1036,7 +1056,7 @@ protected function updateMapData(Model\CharacterModel $character, Model\MapModel } } - // save connection ---------------------------------------------------------------------------------------- + // save connection ------------------------------------------------------------------------------------ if( $addConnection && $sourceExists && @@ -1046,7 +1066,7 @@ protected function updateMapData(Model\CharacterModel $character, Model\MapModel !$map->searchConnection( $sourceSystem, $targetSystem ) ){ $connection = $map->getNewConnection($sourceSystem, $targetSystem); - $connection = $map->saveConnection($connection); + $connection = $map->saveConnection($connection, $character); // get updated maps object if($connection){ $map = $connection->mapId; @@ -1097,6 +1117,53 @@ public function getConnectionData (\Base $f3){ echo json_encode($connectionData); } + /** + * get map log data + * @param \Base $f3 + */ + 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 Model\MapModel $map + */ + $map = Model\BasicModel::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/main/controller/api/route.php b/app/main/controller/api/route.php index a6b69e598..807b0233e 100644 --- a/app/main/controller/api/route.php +++ b/app/main/controller/api/route.php @@ -8,6 +8,7 @@ namespace Controller\Api; use Controller; +use lib\Config; use Model; @@ -530,7 +531,7 @@ public function search($f3){ $map = Model\BasicModel::getNew('MapModel'); // limit max search routes to max limit - array_splice($routesData, $f3->get('PATHFINDER.ROUTE.LIMIT')); + array_splice($routesData, Config::getPathfinderData('route.limit')); foreach($routesData as $key => $routeData){ // mapIds are optional. If mapIds is empty or not set @@ -609,7 +610,7 @@ public function search($f3){ $returnRoutData = $cachedData; }else{ // max search depth for search - $searchDepth = $f3->get('PATHFINDER.ROUTE.SEARCH_DEPTH'); + $searchDepth = Config::getPathfinderData('route.search_depth'); // set jump data for following route search // --> don´t filter some systems (e.g. systemFrom, systemTo) even if they are are WH,LS,0.0 diff --git a/app/main/controller/api/signature.php b/app/main/controller/api/signature.php index 6a3b0e558..39b8250df 100644 --- a/app/main/controller/api/signature.php +++ b/app/main/controller/api/signature.php @@ -13,16 +13,6 @@ class Signature extends Controller\AccessController { - /** - * event handler - * @param \Base $f3 - * @param array $params - */ - function beforeroute(\Base $f3, $params) { - // set header for all routes - header('Content-type: application/json'); - parent::beforeroute($f3, $params); - } /** * get signature data for systems @@ -120,8 +110,6 @@ public function save(\Base $f3){ if($signature->dry()){ // new signature $signature->systemId = $system; - $signature->updatedCharacterId = $activeCharacter; - $signature->createdCharacterId = $activeCharacter; $signature->setData($data); }else{ // update signature @@ -181,13 +169,11 @@ public function save(\Base $f3){ } if( $signature->hasChanged($newData) ){ - // Character should only be changed if something else has changed - $signature->updatedCharacterId = $activeCharacter; $signature->setData($newData); } } - $signature->save(); + $signature->save($activeCharacter); $updatedSignatureIds[] = $signature->id; // get a fresh signature object with the new data. This is a bad work around! diff --git a/app/main/controller/api/statistic.php b/app/main/controller/api/statistic.php index ab89776e5..e42dc68c9 100644 --- a/app/main/controller/api/statistic.php +++ b/app/main/controller/api/statistic.php @@ -9,6 +9,7 @@ namespace controller\api; use Controller; +use lib\Config; use Model\CharacterModel; class Statistic extends Controller\AccessController { @@ -131,19 +132,19 @@ protected function queryStatistic( CharacterModel $character, $typeId, $yearStar $objectId = 0; // add map-"typeId" (private/corp/ally) condition ------------------------------------------------------------- - // check if "ACTIVITY_LOGGING" is active for a given "typeId" + // check if "LOG_ACTIVITY_ENABLED" is active for a given "typeId" $sqlMapType = ""; switch($typeId){ case 2: - if( $this->getF3()->get('PATHFINDER.MAP.PRIVATE.ACTIVITY_LOGGING') ){ + if( Config::getMapsDefaultConfig('private')['log_activity_enabled'] ){ $sqlMapType .= " AND `character`.`id` = :objectId "; $objectId = $character->_id; } break; case 3: if( - $this->getF3()->get('PATHFINDER.MAP.CORPORATION.ACTIVITY_LOGGING') && + Config::getMapsDefaultConfig('corporation')['log_activity_enabled'] && $character->hasCorporation() ){ $sqlMapType .= " AND `character`.`corporationId` = :objectId "; @@ -152,7 +153,7 @@ protected function queryStatistic( CharacterModel $character, $typeId, $yearStar break; case 4: if( - $this->getF3()->get('PATHFINDER.MAP.ALLIANCE.ACTIVITY_LOGGING') && + Config::getMapsDefaultConfig('alliance')['log_activity_enabled'] && $character->hasAlliance() ){ $sqlMapType .= " AND `character`.`allianceId` = :objectId "; @@ -181,6 +182,9 @@ protected function queryStatistic( CharacterModel $character, $typeId, $yearStar `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`, diff --git a/app/main/controller/api/system.php b/app/main/controller/api/system.php index 6fac7a271..be00945bc 100644 --- a/app/main/controller/api/system.php +++ b/app/main/controller/api/system.php @@ -8,8 +8,8 @@ namespace Controller\Api; use Controller; -use Controller\Ccp\Sso; use Data\Mapper as Mapper; +use lib\Config; use Model; class System extends Controller\AccessController { @@ -65,16 +65,6 @@ class System extends Controller\AccessController { private $limitQuery = ""; - /** - * @param \Base $f3 - * @param array $params - */ - function beforeroute(\Base $f3, $params) { - parent::beforeroute($f3, $params); - - // set header for all routes - header('Content-type: application/json'); - } /** * build query @@ -187,9 +177,12 @@ public function search(\Base $f3, $params){ * @param \Base $f3 */ public function save(\Base $f3){ - $newSystemData = []; $postData = (array)$f3->get('POST'); + $return = (object) []; + $return->error = []; + $return->systemData = (object) []; + if( isset($postData['systemData']) && isset($postData['mapData']) @@ -229,23 +222,21 @@ public function save(\Base $f3){ */ $map = Model\BasicModel::getNew('MapModel'); $map->getById($mapData['id']); - if( - !$map->dry() && - $map->hasAccess($activeCharacter) - ){ + if( $map->hasAccess($activeCharacter) ){ // make sure system is not already on map // --> (e.g. multiple simultaneously save() calls for the same system) $systemModel = $map->getSystemByCCPId($systemData['systemId']); if( is_null($systemModel) ){ // system not found on map -> get static system data (CCP DB) $systemModel = $map->getNewSystem($systemData['systemId']); - $systemModel->createdCharacterId = $activeCharacter; - $systemModel->statusId = isset($systemData['statusId']) ? $systemData['statusId'] : 1; + $defaultStatusId = 1; }else{ // system already exists (e.g. was inactive) - $systemModel->statusId = isset($systemData['statusId']) ? $systemData['statusId'] : $systemModel->statusId; + $defaultStatusId = $systemModel->statusId; } + $systemModel->statusId = isset($systemData['statusId']) ? $systemData['statusId'] : $defaultStatusId; + // map is not changeable for a system! (security) $systemData['mapId'] = $map; } @@ -255,28 +246,32 @@ public function save(\Base $f3){ // "statusId" was set above unset($systemData['statusId']); unset($systemData['mapId']); - unset($systemData['createdCharacterId']); - unset($systemData['updatedCharacterId']); // set/update system $systemModel->setData($systemData); // activate system (e.g. was inactive)) $systemModel->setActive(true); - $systemModel->updatedCharacterId = $activeCharacter; - $systemModel->save(); - // get data from "fresh" model (e.g. some relational data has changed: "statusId") - $newSystemModel = Model\BasicModel::getNew('SystemModel'); - $newSystemModel->getById( $systemModel->id, 0); - $newSystemModel->clearCacheData(); - $newSystemData = $newSystemModel->getData(); - // broadcast map changes - $this->broadcastMapData($newSystemModel->mapId); + if($systemModel->save($activeCharacter)){ + // get data from "fresh" model (e.g. some relational data has changed: "statusId") + /** + * @var $newSystemModel Model\SystemModel + */ + $newSystemModel = Model\BasicModel::getNew('SystemModel'); + $newSystemModel->getById( $systemModel->id, 0); + $newSystemModel->clearCacheData(); + $return->systemData = $newSystemModel->getData(); + + // broadcast map changes + $this->broadcastMapData($newSystemModel->mapId); + }else{ + $return->error = $systemModel->getErrors(); + } } } - echo json_encode($newSystemData); + echo json_encode($return); } /** @@ -358,7 +353,7 @@ public function constellationData(\Base $f3, $params){ $return->systemData[] = $systemModel->getData(); } - $f3->set($cacheKey, $return->systemData, $f3->get('PATHFINDER.CACHE.CONSTELLATION_SYSTEMS') ); + $f3->set($cacheKey, $return->systemData, Config::getPathfinderData('cache.constellation_systems')); } } @@ -407,6 +402,37 @@ public function setDestination(\Base $f3){ echo json_encode($return); } + /** + * send Rally Point poke + * @param \Base $f3 + */ + public function pokeRally(\Base $f3){ + $rallyData = (array)$f3->get('POST'); + $systemId = (int)$rallyData['systemId']; + $return = (object) []; + + if($systemId){ + $activeCharacter = $this->getCharacter(); + + /** + * @var Model\SystemModel $system + */ + $system = Model\BasicModel::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['message'] = trim($rallyData['message']); + + $system->sendRallyPoke($rallyData, $activeCharacter); + } + } + + echo json_encode($return); + } + /** * delete systems and all its connections from map * -> set "active" flag @@ -428,7 +454,7 @@ public function delete(\Base $f3){ $map = Model\BasicModel::getNew('MapModel'); $map->getById($mapId); - if( $map->hasAccess($activeCharacter) ){ + if($map->hasAccess($activeCharacter)){ foreach($systemIds as $systemId){ if( $system = $map->getSystemById($systemId) ){ // check whether system should be deleted OR set "inactive" @@ -437,7 +463,7 @@ public function delete(\Base $f3){ }else{ // keep data -> set "inactive" $system->setActive(false); - $system->save(); + $system->save($activeCharacter); } $system->reset(); diff --git a/app/main/controller/api/user.php b/app/main/controller/api/user.php index 41bfbdc7e..7b33b808e 100644 --- a/app/main/controller/api/user.php +++ b/app/main/controller/api/user.php @@ -8,10 +8,8 @@ namespace Controller\Api; use Controller; -use controller\MailController; use Model; use Exception; -use DB; class User extends Controller\Controller{ @@ -27,8 +25,8 @@ class User extends Controller\Controller{ // character specific session keys const SESSION_KEY_CHARACTERS = 'SESSION.CHARACTERS'; - // temp login character ID (during HTTP redirects on login) - const SESSION_KEY_TEMP_CHARACTER_ID = 'SESSION.TEMP_CHARACTER_ID'; + // temp login character data (during HTTP redirects on login) + const SESSION_KEY_TEMP_CHARACTER_DATA = 'SESSION.TEMP_CHARACTER_DATA'; // log text const LOG_LOGGED_IN = 'userId: [%10s], userName: [%30s], charId: [%20s], charName: %s'; @@ -44,9 +42,10 @@ class User extends Controller\Controller{ /** * login a valid character * @param Model\CharacterModel $characterModel + * @param string $browserTabId * @return bool */ - protected function loginByCharacter(Model\CharacterModel &$characterModel){ + protected function loginByCharacter(Model\CharacterModel &$characterModel, string $browserTabId){ $login = false; if($user = $characterModel->getUser()){ @@ -69,7 +68,7 @@ protected function loginByCharacter(Model\CharacterModel &$characterModel){ ){ // user has changed OR new user --------------------------------------------------- //-> set user/character data to session - $this->f3->set(self::SESSION_KEY_USER, [ + $this->getF3()->set(self::SESSION_KEY_USER, [ 'ID' => $user->_id, 'NAME' => $user->name ]); @@ -78,7 +77,7 @@ protected function loginByCharacter(Model\CharacterModel &$characterModel){ $sessionCharacters = $characterModel::mergeSessionCharacterData($sessionCharacters); } - $this->f3->set(self::SESSION_KEY_CHARACTERS, $sessionCharacters); + $this->getF3()->set(self::SESSION_KEY_CHARACTERS, $sessionCharacters); // save user login information -------------------------------------------------------- $characterModel->roleId = $characterModel->requestRoleId(); @@ -95,6 +94,10 @@ protected function loginByCharacter(Model\CharacterModel &$characterModel){ ) ); + // set temp character data ------------------------------------------------------------ + // -> pass character data over for next http request (reroute()) + $this->setTempCharacterData($characterModel->_id, $browserTabId); + $login = true; } @@ -119,6 +122,14 @@ public function getCookieCharacter(\Base $f3){ 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()){ + if($activeUser = $activeCharacter->getUser()){ + if($sessionCharacterData = $activeUser->findSessionCharacterData($return->character->id)){ + $return->character->hasActiveSession = true; + } + } + } }else{ $characterError = (object) []; $characterError->type = 'warning'; @@ -180,9 +191,7 @@ public function getCaptcha(\Base $f3){ */ public function deleteLog(\Base $f3){ if($activeCharacter = $this->getCharacter()){ - if($characterLog = $activeCharacter->getLog()){ - $characterLog->erase(); - } + $activeCharacter->logout(false, true, false); } } @@ -191,8 +200,7 @@ public function deleteLog(\Base $f3){ * @param \Base $f3 */ public function logout(\Base $f3){ - $this->deleteLog($f3); - parent::logout($f3); + $this->logoutCharacter(false, true, true, true); $return = (object) []; $return->reroute = rtrim(self::getEnvironmentData('URL'), '/') . $f3->alias('login'); @@ -372,23 +380,15 @@ public function deleteAccount(\Base $f3){ $user = $activeCharacter->getUser(); if($user){ - // try to send delete account mail - $msg = 'Hello ' . $user->name . ',

'; - $msg .= 'your account data has been successfully deleted.'; - - $mailController = new MailController(); - $mailController->sendDeleteAccount($user->email, $msg); - // save log self::getLogger('DELETE_ACCOUNT')->write( sprintf(self::LOG_DELETE_ACCOUNT, $user->id, $user->name) ); - // remove user + $this->logoutCharacter(true, true, true, true); $user->erase(); - $this->logout($f3); - die(); + $return->reroute = rtrim(self::getEnvironmentData('URL'), '/') . $f3->alias('login'); } }else{ // captcha not valid -> return error diff --git a/app/main/controller/appcontroller.php b/app/main/controller/appcontroller.php index 857771425..d730a82e3 100644 --- a/app/main/controller/appcontroller.php +++ b/app/main/controller/appcontroller.php @@ -13,6 +13,33 @@ class AppController extends Controller { + public function beforeroute(\Base $f3, $params) : bool{ + // page title + $f3->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->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 @@ -31,26 +58,7 @@ public function afterroute(\Base $f3){ * @param \Base $f3 */ public function init(\Base $f3) { - // page title - $f3->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'); - - // href for SSO Auth - $f3->set('tplAuthType', $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)) ); - }); } } \ No newline at end of file diff --git a/app/main/controller/ccp/sso.php b/app/main/controller/ccp/sso.php index 6e7050d8c..5e509e6c5 100644 --- a/app/main/controller/ccp/sso.php +++ b/app/main/controller/ccp/sso.php @@ -34,6 +34,7 @@ class Sso extends Api\User{ const SESSION_KEY_SSO_ERROR = 'SESSION.SSO.ERROR'; const SESSION_KEY_SSO_STATE = 'SESSION.SSO.STATE'; const SESSION_KEY_SSO_FROM = 'SESSION.SSO.FROM'; + const SESSION_KEY_SSO_TAB_ID = 'SESSION.SSO.TABID'; // error messages const ERROR_CCP_SSO_URL = 'Invalid "ENVIRONMENT.[ENVIRONMENT].CCP_SSO_URL" url. %s'; @@ -53,6 +54,8 @@ class Sso extends Api\User{ * @param \Base $f3 */ public function requestAdminAuthorization($f3){ + // store browser tabId to be "targeted" after login + $f3->set(self::SESSION_KEY_SSO_TAB_ID, ''); $f3->set(self::SESSION_KEY_SSO_FROM, 'admin'); $scopes = self::getScopesByAuthType('admin'); @@ -66,6 +69,10 @@ public function requestAdminAuthorization($f3){ */ public function requestAuthorization($f3){ $params = $f3->get('GET'); + $browserTabId = trim((string)$params['tabId']); + + // store browser tabId to be "targeted" after login + $f3->set(self::SESSION_KEY_SSO_TAB_ID, $browserTabId); if( isset($params['characterId']) && @@ -73,7 +80,7 @@ public function requestAuthorization($f3){ ){ // authentication restricted to a characterId ----------------------------------------------- // restrict login to this characterId e.g. for character switch on map page - $characterId = (int)trim($params['characterId']); + $characterId = (int)trim((string)$params['characterId']); /** * @var Model\CharacterModel $character @@ -101,15 +108,12 @@ public function requestAuthorization($f3){ $character->hasUserCharacter() && ($character->isAuthorized() === 'OK') ){ - $loginCheck = $this->loginByCharacter($character); + $loginCheck = $this->loginByCharacter($character, $browserTabId); if($loginCheck){ // set "login" cookie $this->setLoginCookie($character); - // -> pass current character data to target page - $f3->set(Api\User::SESSION_KEY_TEMP_CHARACTER_ID, $character->_id); - // route to "map" $f3->reroute(['map']); } @@ -174,6 +178,8 @@ public function callbackAuthorization($f3){ $rootAlias = $f3->get(self::SESSION_KEY_SSO_FROM); } + $browserTabId = (string)$f3->get(self::SESSION_KEY_SSO_TAB_ID) ; + if($f3->exists(self::SESSION_KEY_SSO_STATE)){ // check response and validate 'state' if( @@ -186,6 +192,7 @@ public function callbackAuthorization($f3){ // clear 'state' for new next login request $f3->clear(self::SESSION_KEY_SSO_STATE); $f3->clear(self::SESSION_KEY_SSO_FROM); + $f3->clear(self::SESSION_KEY_SSO_TAB_ID); $accessData = $this->getSsoAccessData($getParams['code']); @@ -252,14 +259,14 @@ public function callbackAuthorization($f3){ $characterModel = $userCharactersModel->getCharacter(); // login by character - $loginCheck = $this->loginByCharacter($characterModel); + $loginCheck = $this->loginByCharacter($characterModel, $browserTabId); if($loginCheck){ // set "login" cookie $this->setLoginCookie($characterModel); // -> pass current character data to target page - $f3->set(Api\User::SESSION_KEY_TEMP_CHARACTER_ID, $characterModel->_id); + $f3->set(Api\User::SESSION_KEY_TEMP_CHARACTER_DATA, $characterModel->_id); // route to "map" if($rootAlias == 'admin'){ @@ -302,6 +309,7 @@ public function callbackAuthorization($f3){ public function login(\Base $f3){ $data = (array)$f3->get('GET'); $cookieName = empty($data['cookie']) ? '' : $data['cookie']; + $browserTabId = empty($data['tabId']) ? '' : $data['tabId']; $character = null; if( !empty($cookieName) ){ @@ -316,12 +324,8 @@ public function login(\Base $f3){ if( is_object($character)){ // login by character - $loginCheck = $this->loginByCharacter($character); + $loginCheck = $this->loginByCharacter($character, $browserTabId); if($loginCheck){ - // set character id - // -> pass current character data to target page - $f3->set(Api\User::SESSION_KEY_TEMP_CHARACTER_ID, $character->_id); - // route to "map" $f3->reroute(['map']); } diff --git a/app/main/controller/ccp/universe.php b/app/main/controller/ccp/universe.php new file mode 100644 index 000000000..e199eec23 --- /dev/null +++ b/app/main/controller/ccp/universe.php @@ -0,0 +1,93 @@ +setupRegions($f3); + $this->setupConstellations($f3); + } + + /** + * get all regions from CCP and store region data + * @param \Base $f3 + */ + private function setupRegions(\Base $f3){ + $this->getDB('UNIVERSE'); + $regionIds = $f3->ccpClient->getRegions(); + $regionModel = BasicModel::getNew('Universe\RegionModel'); + + foreach($regionIds as $regionId){ + $regionModel->getById($regionId); + + if($regionModel->dry()){ + $regionData = $f3->ccpClient->getRegionData($regionId); + if( !empty($regionData) ){ + $regionModel->copyfrom($regionData, ['id', 'name', 'description']); + $regionModel->save(); + } + } + + $regionModel->reset(); + } + } + + /** + * get all constellations from CCP and store constellation data + * @param \Base $f3 + */ + private function setupConstellations(\Base $f3){ + $this->getDB('UNIVERSE'); + $constellationIds = $f3->ccpClient->getConstellations(); + $constellationModel = BasicModel::getNew('Universe\ConstellationModel'); + + foreach($constellationIds as $constellationId){ + $constellationModel->getById($constellationId); + + if($constellationModel->dry()){ + $constellationData = $f3->ccpClient->getConstellationData($constellationId); + + if( !empty($constellationData) ){ + // $constellationModel->copyfrom($constellationData, ['id', 'name', 'regionId']); + $constellationModel->copyfrom($constellationData, function($fields){ + // add position coordinates as separate columns + if(is_array($fields['position'])){ + $position = $fields['position']; + if( + isset($position['x']) && + isset($position['y']) && + isset($position['z']) + ){ + $fields['x'] = $position['x']; + $fields['y'] = $position['y']; + $fields['z'] = $position['z']; + } + } + + // filter relevant data for insert + return array_intersect_key($fields, array_flip(['id', 'name', 'regionId', 'x', 'y', 'z'])); + }); + + $constellationModel->save(); + } + } + + $constellationModel->reset(); + } + } +} \ No newline at end of file diff --git a/app/main/controller/controller.php b/app/main/controller/controller.php index 44d636aaf..7bf52a876 100644 --- a/app/main/controller/controller.php +++ b/app/main/controller/controller.php @@ -9,8 +9,9 @@ namespace Controller; use Controller\Api as Api; use lib\Config; +use lib\Monolog; use lib\Socket; -use Lib\Util; +use lib\Util; use Model; use DB; @@ -21,8 +22,8 @@ class Controller { const COOKIE_PREFIX_CHARACTER = 'char'; // log text - const LOG_UNAUTHORIZED = 'User-Agent: [%s]'; - const ERROR_SESSION_SUSPECT = 'Suspect id: [%45s], ip: [%45s], new ip: [%45s], User-Agent: [%s]'; + const ERROR_SESSION_SUSPECT = 'id: [%45s], ip: [%45s], User-Agent: [%s]'; + const ERROR_TEMP_CHARACTER_ID = 'Invalid temp characterId: %s'; /** * @var \Base @@ -48,46 +49,38 @@ protected function getTemplate(){ return $this->template; } - /** - * set $f3 base object - * @param \Base $f3 - */ - protected function setF3(\Base $f3){ - $this->f3 = $f3; - } - /** * get $f3 base object * @return \Base */ protected function getF3(){ - if( !($this->f3 instanceof \Base) ){ - $this->setF3( \Base::instance() ); - } - return $this->f3; + return \Base::instance(); } /** * event handler for all "views" * some global template variables are set in here * @param \Base $f3 - * @param array $params + * @param $params + * @return bool */ - function beforeroute(\Base $f3, $params) { - $this->setF3($f3); - + function beforeroute(\Base $f3, $params): bool { // initiate DB connection - DB\Database::instance('PF'); + DB\Database::instance()->getDB('PF'); // init user session - $this->initSession(); + $this->initSession($f3); - if( !$f3->get('AJAX') ){ + if($f3->get('AJAX')){ + header('Content-type: application/json'); + }else{ // js path (build/minified or raw uncompressed files) $f3->set('tplPathJs', 'public/js/' . Config::getPathfinderData('version') ); $this->setTemplate( Config::getPathfinderData('view.index') ); } + + return true; } /** @@ -96,9 +89,6 @@ function beforeroute(\Base $f3, $params) { * @param \Base $f3 */ public function afterroute(\Base $f3){ - // store all user activities that are buffered for logging in this request - self::storeActivities(); - if($this->getTemplate()){ // Ajax calls don´t need a page render.. // this happens on client side @@ -118,32 +108,35 @@ protected function getDB($database = 'PF'){ /** * init new Session handler */ - protected function initSession(){ - - // init DB based Session (not file based) - if( $this->getDB('PF') instanceof DB\SQL){ - // init session with custom "onsuspect()" handler - new DB\SQL\Session($this->getDB('PF'), 'sessions', true, function($session, $sid){ - $f3 = $this->getF3(); - if( ($ip = $session->ip() )!= $f3->get('IP') ){ - // IP address changed -> not critical - self::getLogger('SESSION_SUSPECT')->write( sprintf( - self::ERROR_SESSION_SUSPECT, - $sid, - $session->ip(), - $f3->get('IP'), - $f3->get('AGENT') - )); - // no more error handling here - return true; - }elseif($session->agent() != $f3->get('AGENT') ){ - // The default behaviour destroys the suspicious session. - return false; - } + protected function initSession(\Base $f3){ + $sessionCacheKey = $f3->get('SESSION_CACHE'); + $session = null; + + /** + * callback() for suspect sessions + * @param $session + * @param $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; + }; - return true; - }); + if( + $sessionCacheKey === 'mysql' && + $this->getDB('PF') instanceof DB\SQL + ){ + $session = new DB\SQL\Session($this->getDB('PF'), 'sessions', true, $onSuspect); } + } /** @@ -190,12 +183,11 @@ protected function getCookieByName($cookieName, $prefix = false){ * @param Model\CharacterModel $character */ protected function setLoginCookie(Model\CharacterModel $character){ - if( $this->getCookieState() ){ - $expireSeconds = (int) $this->getF3()->get('PATHFINDER.LOGIN.COOKIE_EXPIRE'); + $expireSeconds = (int)Config::getPathfinderData('login.cookie_expire'); $expireSeconds *= 24 * 60 * 60; - $timezone = new \DateTimeZone( $this->getF3()->get('TZ') ); + $timezone = $this->getF3()->get('getTimeZone')(); $expireTime = new \DateTime('now', $timezone); // add cookie expire time @@ -258,7 +250,7 @@ protected function getCookieCharacters($cookieData = [], $checkAuthorization = t */ $characterAuth = Model\BasicModel::getNew('CharacterAuthenticationModel'); - $timezone = new \DateTimeZone( $this->getF3()->get('TZ') ); + $timezone = $this->getF3()->get('getTimeZone')(); $currentTime = new \DateTime('now', $timezone); foreach($cookieData as $name => $value){ @@ -268,7 +260,7 @@ protected function getCookieCharacters($cookieData = [], $checkAuthorization = t $data = explode(':', $value); if(count($data) === 2){ // cookie data is well formatted - $characterAuth->getByForeignKey('selector', $data[0], ['limit' => 1], 0); + $characterAuth->getByForeignKey('selector', $data[0], ['limit' => 1]); // validate "scope hash" // -> either "normal" scopes OR "admin" scopes @@ -354,28 +346,37 @@ public function getSessionCharacterData(){ $data = []; if($user = $this->getUser()){ - $requestedCharacterId = 0; + $header = self::getRequestHeaders(); + $requestedCharacterId = (int)$header['Pf-Character']; + $browserTabId = (string)$header['Pf-Tab-Id']; + $tempCharacterData = (array)$this->getF3()->get(Api\User::SESSION_KEY_TEMP_CHARACTER_DATA); - // get all characterData from currently active characters if($this->getF3()->get('AJAX')){ - // Ajax request -> get characterId from Header (if already available!) - $header = $this->getRequestHeaders(); - $requestedCharacterId = (int)$header['Pf-Character']; + // _blank browser tab don´t have a $browserTabId jet.. + // first Ajax call from that new tab with empty $requestedCharacterId -> bind to that new tab if( - $requestedCharacterId > 0 && - (int)$this->getF3()->get(Api\User::SESSION_KEY_TEMP_CHARACTER_ID) === $requestedCharacterId + !empty($browserTabId) && + $requestedCharacterId <= 0 && + (int)$tempCharacterData['ID'] > 0 && + empty($tempCharacterData['TAB_ID']) ){ - // requested characterId is "now" available on the client (Javascript) - // -> clear temp characterId for next character login/switch - $this->getF3()->clear(Api\User::SESSION_KEY_TEMP_CHARACTER_ID); + $tempCharacterData['TAB_ID'] = $browserTabId; + // update tempCharacterData (SESSION) + $this->setTempCharacterData($tempCharacterData['ID'], $tempCharacterData['TAB_ID']); } - } - if($requestedCharacterId <= 0){ - // Ajax BUT characterID not yet set as HTTP header - // OR non Ajax -> get characterId from temp session (e.g. from HTTP redirect) - $requestedCharacterId = (int)$this->getF3()->get(Api\User::SESSION_KEY_TEMP_CHARACTER_ID); + if( + !empty($browserTabId) && + !empty($tempCharacterData['TAB_ID']) && + (int)$tempCharacterData['ID'] > 0 && + $browserTabId === $tempCharacterData['TAB_ID'] + ){ + $requestedCharacterId = (int)$tempCharacterData['ID']; + } + + }elseif((int)$tempCharacterData['ID'] > 0){ + $requestedCharacterId = (int)$tempCharacterData['ID']; } $data = $user->getSessionCharacterData($requestedCharacterId); @@ -420,21 +421,18 @@ public function getCharacter($ttl = 0){ public function getUser($ttl = 0){ $user = null; - if( $this->getF3()->exists(Api\User::SESSION_KEY_USER_ID) ){ - $userId = (int)$this->getF3()->get(Api\User::SESSION_KEY_USER_ID); - if($userId){ - /** - * @var $userModel Model\UserModel - */ - $userModel = Model\BasicModel::getNew('UserModel'); - $userModel->getById($userId, $ttl); + if($this->getF3()->exists(Api\User::SESSION_KEY_USER_ID, $userId)){ + /** + * @var $userModel Model\UserModel + */ + $userModel = Model\BasicModel::getNew('UserModel'); + $userModel->getById($userId, $ttl); - if( - !$userModel->dry() && - $userModel->hasUserCharacters() - ){ - $user = &$userModel; - } + if( + !$userModel->dry() && + $userModel->hasUserCharacters() + ){ + $user = &$userModel; } } @@ -442,26 +440,57 @@ public function getUser($ttl = 0){ } /** - * log out current character - * @param \Base $f3 + * set temp login character data (required during HTTP redirects on login) + * @param int $characterId + * @param string $browserTabId + * @throws \Exception */ - public function logout(\Base $f3){ - $params = (array)$f3->get('POST'); + protected function setTempCharacterData(int $characterId, string $browserTabId){ + if($characterId > 0){ + $tempCharacterData = [ + 'ID' => $characterId, + 'TAB_ID' => trim($browserTabId) + ]; + $this->getF3()->set(Api\User::SESSION_KEY_TEMP_CHARACTER_DATA, $tempCharacterData); + }else{ + throw new \Exception( sprintf(self::ERROR_TEMP_CHARACTER_ID, $characterId) ); + } + } - if( $activeCharacter = $this->getCharacter() ){ + /** + * log out current character or all active characters (multiple browser tabs) + * @param bool $all + * @param bool $deleteSession + * @param bool $deleteLog + * @param bool $deleteCookie + */ + protected function logoutCharacter(bool $all = false, bool $deleteSession = true, bool $deleteLog = true, bool $deleteCookie = false){ + $sessionCharacterData = (array)$this->getF3()->get(Api\User::SESSION_KEY_CHARACTERS); - if($params['clearCookies'] === '1'){ - // delete server side cookie validation data - // for the active character - $activeCharacter->logout(); + if($sessionCharacterData){ + $activeCharacterId = ($activeCharacter = $this->getCharacter()) ? $activeCharacter->_id : 0; + /** + * @var Model\CharacterModel $character + */ + $character = Model\BasicModel::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(); } - // broadcast logout information to webSocket server - (new Socket( Config::getSocketUri() ))->sendData('characterLogout', $activeCharacter->_id); + if($characterIds){ + // broadcast logout information to webSocket server + (new Socket( Config::getSocketUri() ))->sendData('characterLogout', $characterIds); + } } - - // destroy session login data ------------------------------- - $f3->clear('SESSION'); } /** @@ -482,7 +511,7 @@ public function getEveServerStatus(\Base $f3){ if( !empty($response) ){ // calculate time diff since last server restart - $timezone = new \DateTimeZone( $f3->get('TZ') ); + $timezone = $f3->get('getTimeZone')(); $dateNow = new \DateTime('now', $timezone); $dateServerStart = new \DateTime($response['startTime']); $interval = $dateNow->diff($dateServerStart); @@ -504,14 +533,24 @@ public function getEveServerStatus(\Base $f3){ } /** - * get error object is a user is not found/logged of + * @param int $code + * @param string $message + * @param string $status + * @param null $trace * @return \stdClass */ - protected function getLogoutError(){ - $userError = (object) []; - $userError->type = 'error'; - $userError->message = 'User not found'; - return $userError; + protected function getErrorObject(int $code, string $message = '', string $status = '', $trace = null): \stdClass{ + $object = (object) []; + $object->type = 'error'; + $object->code = $code; + $object->status = empty($status) ? @constant('Base::HTTP_' . $code) : $status; + if(!empty($message)){ + $object->message = $message; + } + if(!empty($trace)){ + $object->trace = $trace; + } + return $object; } /** @@ -558,59 +597,54 @@ protected function getUserAgent(){ * -> on AJAX request -> return JSON with error information * -> on HTTP request -> render error page * @param \Base $f3 + * @return bool */ public function showError(\Base $f3){ - // set HTTP status - $errorCode = $f3->get('ERROR.code'); - if(!empty($errorCode)){ - $f3->status($errorCode); - } - - // collect error info --------------------------------------- - $return = (object) []; - $error = (object) []; - $error->type = 'error'; - $error->code = $errorCode; - $error->status = $f3->get('ERROR.status'); - $error->message = $f3->get('ERROR.text'); - - // append stack trace for greater debug level - if( $f3->get('DEBUG') === 3){ - $error->trace = $f3->get('ERROR.trace'); - } - - // 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->message = 'Value "' . $matches[0][1] . '" already exists'; + if(!headers_sent()){ + // collect error info ------------------------------------------------------------------------------------- + $error = $this->getErrorObject( + $f3->get('ERROR.code'), + $f3->get('ERROR.status'), + $f3->get('ERROR.text'), + $f3->get('DEBUG') === 3 ? $f3->get('ERROR.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->message = 'Value "' . $matches[0][1] . '" already exists'; + } } - } - $return->error[] = $error; - // return error information --------------------------------- - if($f3->get('AJAX')){ - header('Content-type: application/json'); - echo json_encode($return); - die(); - }else{ - $f3->set('tplPageTitle', 'ERROR - ' . $error->code . ' | Pathfinder'); - // set error data for template rendering - $error->redirectUrl = $this->getRouteUrl(); - $f3->set('errorData', $error); - - if( preg_match('/^4[0-9]{2}$/', $error->code) ){ - // 4xx error -> render error page - $f3->set('tplPageContent', Config::getPathfinderData('STATUS.4XX') ); - }elseif( preg_match('/^5[0-9]{2}$/', $error->code) ){ - $f3->set('tplPageContent', Config::getPathfinderData('STATUS.5XX')); + // set response status ------------------------------------------------------------------------------------ + if(!empty($error->code)){ + $f3->status($error->code); } - echo \Template::instance()->render( Config::getPathfinderData('view.index') ); - die(); + if($f3->get('AJAX')){ + $return = (object) []; + $return->error[] = $error; + echo json_encode($return); + }else{ + $f3->set('tplPageTitle', 'ERROR - ' . $error->code); + // set error data for template rendering + $error->redirectUrl = $this->getRouteUrl(); + $f3->set('errorData', $error); + + if( preg_match('/^4[0-9]{2}$/', $error->code) ){ + // 4xx error -> render error page + $f3->set('tplPageContent', Config::getPathfinderData('STATUS.4XX') ); + }elseif( preg_match('/^5[0-9]{2}$/', $error->code) ){ + $f3->set('tplPageContent', Config::getPathfinderData('STATUS.5XX')); + } + } } + + return true; } /** @@ -624,44 +658,36 @@ public function unload(\Base $f3){ // track some 4xx Client side errors // 5xx errors are handled in "ONERROR" callback $status = http_response_code(); - $halt = false; - - switch( $status ){ - case 403: // Unauthorized - self::getLogger('UNAUTHORIZED')->write(sprintf( - self::LOG_UNAUTHORIZED, - $f3->get('AGENT') - )); - $halt = true; - break; - } - - // Ajax - if( - $halt && - $f3->get('AJAX') - ){ - $params = (array)$f3->get('POST'); - $response = (object) []; - $response->type = 'error'; - $response->code = $status; - $response->message = 'Access denied: User not found'; + if(!headers_sent() && $status >= 300){ + if($f3->get('AJAX')){ + $params = (array)$f3->get('POST'); + $return = (object) []; + if((bool)$params['reroute']){ + $return->reroute = rtrim(self::getEnvironmentData('URL'), '/') . $f3->alias('login'); + }else{ + // no reroute -> errors can be shown + $return->error[] = $this->getErrorObject($status, Config::getMessageFromHTTPStatus($status)); + } - $return = (object) []; - if( (bool)$params['reroute']){ - $return->reroute = rtrim(self::getEnvironmentData('URL'), '/') . $f3->alias('login'); - }else{ - // no reroute -> errors can be shown - $return->error[] = $response; + echo json_encode($return); } - - echo json_encode($return); - die(); } + // 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(); + } + /** * get controller by class name * -> controller class is searched within all controller directories @@ -793,7 +819,7 @@ static function getServerData($ttl = 3600){ * @return int */ static function getRegistrationStatus(){ - return (int)\Base::instance()->get('PATHFINDER.REGISTRATION.STATUS'); + return (int)Config::getPathfinderData('registration.status'); } /** @@ -806,13 +832,6 @@ static function getLogger($type){ return LogController::getLogger($type); } - /** - * store activity log data to DB - */ - static function storeActivities(){ - LogController::instance()->storeActivities(); - } - /** * removes illegal characters from a Hive-key that are not allowed * @param $key @@ -842,20 +861,4 @@ static function checkTcpSocket($ttl, $load){ (new Socket( Config::getSocketUri(), $ttl ))->sendData('healthCheck', $load); } - /** - * get required MySQL variable value - * @param $key - * @return string|null - */ - static function getRequiredMySqlVariables($key){ - $f3 = \Base::instance(); - $requiredMySqlVarKey = 'REQUIREMENTS[MYSQL][VARS][' . $key . ']'; - $data = null; - - if( $f3->exists($requiredMySqlVarKey) ){ - $data = $f3->get($requiredMySqlVarKey); - } - return $data; - } - } \ No newline at end of file diff --git a/app/main/controller/logcontroller.php b/app/main/controller/logcontroller.php index c00a60afa..3c9ee6ac1 100644 --- a/app/main/controller/logcontroller.php +++ b/app/main/controller/logcontroller.php @@ -8,42 +8,73 @@ namespace controller; use DB; +use lib\Config; +use lib\logging\MapLog; +use Model\ActivityLogModel; +use Model\BasicModel; class LogController extends \Prefab { + const CACHE_KEY_ACTIVITY_COLUMNS = 'CACHED_ACTIVITY_COLUMNS'; + const CACHE_TTL_ACTIVITY_COLUMNS = 300; + + /** + * @var string[] + */ + protected $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! * @var array */ - protected $activityLogBuffer = []; + protected $activityLogBuffer = []; + + /** + * get columns from ActivityLogModel that can be uses as counter + * @return array + */ + protected function getActivityLogColumns(): array{ + if(empty($this->activityLogColumns)){ + $f3 = \Base::instance(); + if(!$f3->exists(self::CACHE_KEY_ACTIVITY_COLUMNS, $this->activityLogColumns)){ + /** + * @var $activityLogModel ActivityLogModel + */ + $activityLogModel = BasicModel::getNew('ActivityLogModel'); + $this->activityLogColumns = $activityLogModel->getCountableColumnNames(); + $f3->set(self::CACHE_KEY_ACTIVITY_COLUMNS, self::CACHE_TTL_ACTIVITY_COLUMNS); + } + } + + return $this->activityLogColumns; + } /** - * reserve a "new" character activity for logging - * @param $characterId - * @param $mapId - * @param $action + * 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 */ - public function bufferActivity($characterId, $mapId, $action){ - $characterId = (int)$characterId; - $mapId = (int)$mapId; - - if( - $characterId > 0 && - $mapId > 0 - ){ - $key = $this->getBufferedActivityKey($characterId, $mapId); - - if( is_null($key) ){ - $activity = [ - 'characterId' => $characterId, - 'mapId' => $mapId, - $action => 1 - ]; - $this->activityLogBuffer[] = $activity; - }else{ - $this->activityLogBuffer[$key][$action]++; + 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 + ]; + } + } } } } @@ -51,7 +82,7 @@ public function bufferActivity($characterId, $mapId, $action){ /** * store all buffered activity log data to DB */ - public function storeActivities(){ + public function logActivities(){ if( !empty($this->activityLogBuffer) ){ $db = DB\Database::instance()->getDB('PF'); @@ -104,24 +135,20 @@ public function storeActivities(){ } /** - * get array key from "buffered activity log" array + * get array key/index from "buffered activity log" array * @param int $characterId * @param int $mapId - * @return int|null + * @return int */ - private function getBufferedActivityKey($characterId, $mapId){ - $activityKey = null; - - if( - $characterId > 0 && - $mapId > 0 - ){ + 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 = $key; + $activityKey = (int)$key; break; } } @@ -136,8 +163,7 @@ private function getBufferedActivityKey($characterId, $mapId){ * @return \Log|null */ public static function getLogger($type){ - $f3 = \Base::instance(); - $logFiles = $f3->get('PATHFINDER.LOGFILES'); + $logFiles = Config::getPathfinderData('logfiles'); $logFileName = empty($logFiles[$type]) ? 'error' : $logFiles[$type]; $logFile = $logFileName . '.log'; diff --git a/app/main/controller/mailcontroller.php b/app/main/controller/mailcontroller.php deleted file mode 100644 index 2477d66c6..000000000 --- a/app/main/controller/mailcontroller.php +++ /dev/null @@ -1,80 +0,0 @@ -set('Errors-to', '<' . Controller::getEnvironmentData('SMTP_ERROR') . '>'); - $this->set('MIME-Version', '1.0'); - $this->set('Content-Type', 'text/html; charset=ISO-8859-1'); - } - - /** - * send mail to removed user account - * @param $to - * @param $msg - * @return bool - */ - public function sendDeleteAccount($to, $msg){ - $status = false; - - if( !empty($to)){ - $this->set('To', '<' . $to . '>'); - $this->set('From', '"Pathfinder" <' . Controller::getEnvironmentData('SMTP_FROM') . '>'); - $this->set('Subject', 'Account deleted'); - $status = $this->send($msg); - } - - return $status; - } - - /** - * send notification mail for new rally point systems - * @param $to - * @param $msg - * @return bool - */ - public function sendRallyPoint($to, $msg){ - $status = false; - - if( !empty($to)){ - $this->set('To', '<' . $to . '>'); - $this->set('From', '"Pathfinder" <' . Controller::getEnvironmentData('SMTP_FROM') . '>'); - $this->set('Subject', 'PATHFINDER - New rally point'); - $status = $this->send($msg); - } - - return $status; - } - - public function send($message, $log = true, $mock = false){ - $status = false; - - if( - !empty($this->host) && - !empty($this->port) - ){ - $status = parent::send($message, $log, $mock); - } - - return $status; - } -} \ No newline at end of file diff --git a/app/main/controller/mapcontroller.php b/app/main/controller/mapcontroller.php index 947a866ec..300c7bccc 100644 --- a/app/main/controller/mapcontroller.php +++ b/app/main/controller/mapcontroller.php @@ -16,7 +16,7 @@ class MapController extends AccessController { /** * @param \Base $f3 */ - public function init($f3) { + public function init(\Base $f3) { $character = $this->getCharacter(); // page title diff --git a/app/main/controller/setup.php b/app/main/controller/setup.php index 67cefba08..c673bc0f8 100644 --- a/app/main/controller/setup.php +++ b/app/main/controller/setup.php @@ -13,6 +13,7 @@ use DB\SQL; use DB\SQL\MySQL as MySQL; use lib\Config; +use lib\Util; use Model; class Setup extends Controller { @@ -26,10 +27,10 @@ class Setup extends Controller { 'BASE', 'URL', 'DEBUG', - 'DB_DNS', - 'DB_NAME', - 'DB_USER', - 'DB_PASS', + 'DB_PF_DNS', + 'DB_PF_NAME', + 'DB_PF_USER', + 'DB_PF_PASS', 'DB_CCP_DNS', 'DB_CCP_NAME', 'DB_CCP_USER', @@ -95,6 +96,16 @@ class Setup extends Controller { ], 'tables' => [] ], + 'UNIVERSE' => [ + 'info' => [], + 'models' => [ + 'Model\Universe\TypeModel', + 'Model\Universe\StructureModel', + //'Model\Universe\RegionModel', + //'Model\Universe\ConstellationModel' + ], + 'tables' => [] + ], 'CCP' => [ 'info' => [], 'models' => [], @@ -111,19 +122,28 @@ class Setup extends Controller { ] ]; + /** + * @var DB\Database + */ + protected $dbLib = null; + /** * database error * @var bool */ - protected $databaseCheck = true; + 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) { + function beforeroute(\Base $f3, $params): bool { + // init dbLib class. Manages all DB connections + $this->dbLib = DB\Database::instance(); + // page title $f3->set('tplPageTitle', 'Setup | ' . Config::getPathfinderData('name')); @@ -135,8 +155,13 @@ function beforeroute(\Base $f3, $params) { // js path (build/minified or raw uncompressed files) $f3->set('tplPathJs', 'public/js/' . Config::getPathfinderData('version') ); + + return true; } + /** + * @param \Base $f3 + */ public function afterroute(\Base $f3) { // js view (file) $f3->set('tplJsView', 'setup'); @@ -150,6 +175,16 @@ public function afterroute(\Base $f3) { return $cacheType; }); + // simple counter (called within template) + $counter = 0; + $f3->set('tplCounter', function(string $action = 'add') use (&$counter){ + switch($action){ + case 'add': $counter++; break; + case 'get': return $counter; break; + case 'reset': $counter = 0; break; + } + }); + // render view echo \Template::instance()->render( Config::getPathfinderData('view.index') ); } @@ -166,26 +201,31 @@ public function init(\Base $f3){ // enables automatic column fix $fixColumns = false; - // bootstrap database from model class definition - if( !empty($params['db']) ){ - $this->bootstrapDB($params['db']); - - // reload page - // -> remove GET param - $f3->reroute('@setup'); - return; - }elseif( !empty($params['fixCols']) ){ - $fixColumns = true; - }elseif( !empty($params['buildIndex']) ){ - $this->setupSystemJumpTable(); - }elseif( !empty($params['importTable']) ){ - $this->importTable($params['importTable']); - }elseif( !empty($params['exportTable']) ){ - $this->exportTable($params['exportTable']); - }elseif( !empty($params['clearCache']) ){ - $this->clearCache($f3); - }elseif( !empty($params['invalidateCookies']) ){ - $this->invalidateCookies($f3); + switch($params['action']){ + case 'createDB': + $this->createDB($params['db']); + break; + case 'bootstrapDB': + $this->bootstrapDB($params['db']); + break; + case 'fixCols': + $fixColumns = true; + break; + case 'buildIndex': + $this->setupSystemJumpTable(); + break; + case 'importTable': + $this->importTable($params['model']); + break; + case 'exportTable': + $this->exportTable($params['model']); + break; + case 'clearCache': + $this->clearCache($f3); + break; + case 'invalidateCookies': + $this->invalidateCookies($f3); + break; } // set template data ---------------------------------------------------------------- @@ -198,6 +238,15 @@ public function init(\Base $f3){ // set requirement check information $f3->set('checkRequirements', $this->checkRequirements($f3)); + // set php config check information + $f3->set('checkPHPConfig', $this->checkPHPConfig($f3)); + + // set system config check information + $f3->set('checkSystemConfig', $this->checkSystemConfig($f3)); + + // set map default config + $f3->set('mapsDefaultConfig', $this->getMapsDefaultConfig($f3)); + // set database connection information $f3->set('checkDatabase', $this->checkDatabase($f3, $fixColumns)); @@ -311,9 +360,9 @@ protected function importSystemWormholesFromJson(\Base $f3){ protected function getEnvironmentInformation(\Base $f3){ $environmentData = []; // exclude some sensitive data (e.g. database, passwords) - $excludeVars = ['DB_DNS', 'DB_NAME', 'DB_USER', - 'DB_PASS', 'DB_CCP_DNS', 'DB_CCP_NAME', - 'DB_CCP_USER', 'DB_CCP_PASS' + $excludeVars = [ + 'DB_PF_DNS', 'DB_PF_NAME', 'DB_PF_USER', 'DB_PF_PASS', + 'DB_CCP_DNS', 'DB_CCP_NAME', 'DB_CCP_USER', 'DB_CCP_PASS' ]; // obscure some values @@ -329,10 +378,7 @@ protected function getEnvironmentInformation(\Base $f3){ $check = false; $value = '[missing]'; }elseif( in_array($var, $obscureVars)){ - $length = strlen($value); - $hideChars = ($length < 10) ? $length : 10; - $value = substr_replace($value, str_repeat('.', 3), -$hideChars); - $value .= ' [' . $length . ']'; + $value = Util::obscureString($value); } $environmentData[$var] = [ @@ -435,6 +481,9 @@ protected function checkRequirements(\Base $f3){ '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'), @@ -477,20 +526,6 @@ protected function checkRequirements(\Base $f3){ 'version' => (extension_loaded('curl') && function_exists('curl_version')) ? 'installed' : 'missing', 'check' => (extension_loaded('curl') && function_exists('curl_version')) ], - 'maxInputVars' => [ - 'label' => 'max_input_vars', - 'required' => $f3->get('REQUIREMENTS.PHP.MAX_INPUT_VARS'), - 'version' => ini_get('max_input_vars'), - 'check' => ini_get('max_input_vars') >= $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' => ini_get('max_execution_time'), - 'check' => ini_get('max_execution_time') >= $f3->get('REQUIREMENTS.PHP.MAX_EXECUTION_TIME'), - 'tooltip' => 'PHP default = 30. Max execution time for PHP scripts.' - ], [ 'label' => 'Redis Server [optional]' ], @@ -585,6 +620,207 @@ protected function checkRequirements(\Base $f3){ return $checkRequirements; } + /** + * check PHP config (php.ini) + * @param \Base $f3 + * @return array + */ + protected function checkPHPConfig(\Base $f3): array { + $phpConfig = [ + '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' + ], + 'maxInputVars' => [ + 'label' => 'max_input_vars', + 'required' => $f3->get('REQUIREMENTS.PHP.MAX_INPUT_VARS'), + 'version' => ini_get('max_input_vars'), + 'check' => ini_get('max_input_vars') >= $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' => ini_get('max_execution_time'), + 'check' => ini_get('max_execution_time') >= $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' => (int)ini_get('html_errors'), + 'check' => (bool)ini_get('html_errors') == (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.' + ] + ]; + + return $phpConfig; + } + + /** + * 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 = $rubyOut = $rubyGemsOut = $compassOut = $nodeOut = $npmOut = []; + $gitStatus = $composerStatus = $rubyStatus = $rubyGemsStatus = $compassStatus = $nodeStatus = $npmStatus = 1; + + exec('git --version', $gitOut, $gitStatus); + exec('composer -V', $composerOut, $composerStatus); + exec('ruby -v', $rubyOut, $rubyStatus); + exec('gem -v', $rubyGemsOut, $rubyGemsStatus); + exec('compass -v', $compassOut, $compassStatus); + 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] + ], + 'Ruby' => [ + 'label' => 'Ruby', + 'version' => $rubyOut[0] ? 'installed' : 'missing', + 'check' => $rubyStatus == 0, + 'tooltip' => 'Ruby # ruby -v : ' . $rubyOut[0] + ], + 'rubyGems' => [ + 'label' => 'Ruby gem', + 'version' => $normalizeVersion($rubyGemsOut[0]) ?: 'missing', + 'check' => $rubyGemsStatus == 0, + 'tooltip' => 'gem # gem -v' + ], + 'compass' => [ + 'label' => 'Compass', + 'version' => $compassOut[0] ? 'installed' : 'missing', + 'check' => $compassStatus == 0, + 'tooltip' => 'Compass # compass -v : ' . $compassOut[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_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 @@ -596,42 +832,44 @@ protected function checkDatabase(\Base $f3, $exec = false){ foreach($this->databases as $dbKey => $dbData){ $dbLabel = ''; - $dbName = ''; - $dbUser = ''; $dbConfig = []; // DB connection status $dbConnected = false; // DB type (e.g. MySql,..) $dbDriver = 'unknown'; - // enable database ::setup() function in UI + // enable database ::create() function on UI + $dbCreate = false; + // enable database ::setup() function on UI $dbSetupEnable = false; - // check of everything is OK (connection, tables, columns, indexes,..) + // 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($dbKey); // check DB for valid connection - $db = DB\Database::instance()->getDB($dbKey); + $db = $this->dbLib->getDB($dbKey); + // collection for errors + $dbErrors = []; // check config that does NOT require a valid DB connection switch($dbKey){ - case 'PF': - $dbLabel = 'Pathfinder'; - $dbName = Controller::getEnvironmentData('DB_NAME'); - $dbUser = Controller::getEnvironmentData('DB_USER'); - break; - case 'CCP': - $dbLabel = 'EVE-Online [SDE]'; - $dbName = Controller::getEnvironmentData('DB_CCP_NAME'); - $dbUser = Controller::getEnvironmentData('DB_CCP_USER'); - break; + case 'PF': $dbLabel = 'Pathfinder'; break; + case 'UNIVERSE': $dbLabel = 'EVE-Online universe'; break; + case 'CCP': $dbLabel = 'EVE-Online [SDE]'; break; } + $dbName = $dbConfigValues['NAME']; + $dbUser = $dbConfigValues['USER']; + $dbAlias = $dbConfigValues['ALIAS']; + if($db){ switch($dbKey){ case 'PF': + case 'UNIVERSE': // enable (table) setup for this DB $dbSetupEnable = true; @@ -852,34 +1090,54 @@ protected function checkDatabase(\Base $f3, $exec = false){ }else{ // DB connection failed $dbStatusCheckCount++; - } - if($exec){ - $f3->reroute('@setup'); + foreach($this->dbLib->getErrors($dbAlias, 10) as $dbException){ + $dbErrors[] = $dbException->getMessage(); + } + + // try to connect without! DB (-> offer option to create them) + // do not log errors (silent) + $this->dbLib->setSilent(true); + $dbServer = $this->dbLib->connectToServer($dbAlias); + $this->dbLib->setSilent(false); + if(!is_null($dbServer)){ + // connection succeeded + $dbCreate = true; + $dbDriver = $dbServer->driver(); + } } if($dbStatusCheckCount !== 0){ - $this->databaseCheck = false; + $this->databaseHasError = true; } // sort tables for better readability ksort($requiredTables); $this->databases[$dbKey]['info'] = [ - 'db' => $db, - 'label' => $dbLabel, - 'driver' => $dbDriver, - 'name' => $dbName, - 'user' => $dbUser, - 'dbConfig' => $dbConfig, - 'setupEnable' => $dbSetupEnable, - 'connected' => $dbConnected, - 'statusCheckCount' => $dbStatusCheckCount, - 'columnQueries' => $dbColumnQueries, - 'tableData' => $requiredTables + // 'db' => $db, + 'label' => $dbLabel, + 'host' => Config::getDatabaseDNSValue((string)$dbConfigValues['DNS'], 'host'), + 'port' => Config::getDatabaseDNSValue((string)$dbConfigValues['DNS'], 'port'), + 'driver' => $dbDriver, + 'name' => $dbName, + 'user' => $dbUser, + 'pass' => Util::obscureString((string)$dbConfigValues['PASS'], 8), + 'dbConfig' => $dbConfig, + 'dbCreate' => $dbCreate, + 'setupEnable' => $dbSetupEnable, + 'connected' => $dbConnected, + 'statusCheckCount' => $dbStatusCheckCount, + 'columnQueries' => $dbColumnQueries, + 'tableData' => $requiredTables, + 'errors' => $dbErrors ]; } + if($exec){ + $f3->reroute('@setup'); + } + return $this->databases; } @@ -935,28 +1193,50 @@ protected function checkDBConfig(\Base $f3, $db){ return $dbConfig; } + /** + * try to create a fresh database + * @param string $dbKey + */ + protected function createDB(string $dbKey){ + // check for valid key + if(!empty($this->databases[$dbKey])){ + // disable logging (we expect the DB connect to fail -> no db created) + $this->dbLib->setSilent(true); + // try to connect + $db = $this->dbLib->getDB($dbKey); + // enable logging + $this->dbLib->setSilent(false, true); + if(is_null($db)){ + // try create new db + $db = $this->dbLib->createDB($dbKey); + if(is_null($db)){ + foreach($this->dbLib->getErrors($dbKey, 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 $dbKey + * @param string $dbKey * @return array */ - protected function bootstrapDB($dbKey){ - $db = DB\Database::instance()->getDB($dbKey); - + protected function bootstrapDB(string $dbKey){ + $db = $this->dbLib->getDB($dbKey); $checkTables = []; if($db){ - // set/change default "character set" and "collation" - $db->exec('ALTER DATABASE ' . $db->quotekey($db->name()) - . ' CHARACTER SET ' . self::getRequiredMySqlVariables('CHARACTER_SET_DATABASE') - . ' COLLATE ' . self::getRequiredMySqlVariables('COLLATION_DATABASE') - ); + // set some default config for this database + DB\Database::prepareDatabase($db); // setup tables foreach($this->databases[$dbKey]['models'] as $modelClass){ - $checkTables[] = call_user_func($modelClass . '::setup'); + $checkTables[] = call_user_func($modelClass . '::setup', $db); } } return $checkTables; @@ -970,10 +1250,10 @@ protected function getSocketInformation(){ // $ttl for health check $ttl = 600; - $heachCheckToken = microtime(true); + $healthCheckToken = microtime(true); // ping TCP Socket with checkToken - self::checkTcpSocket($ttl, $heachCheckToken); + self::checkTcpSocket($ttl, $healthCheckToken); $socketInformation = [ 'tcpSocket' => [ @@ -998,7 +1278,7 @@ protected function getSocketInformation(){ 'check' => !empty( $ttl ) ] ], - 'token' => $heachCheckToken + 'token' => $healthCheckToken ], 'webSocket' => [ 'label' => 'WebSocket (clients) [HTTP]', @@ -1020,78 +1300,77 @@ protected function getSocketInformation(){ * @return array */ protected function getIndexData(){ - // active DB and tables are required for obtain index data - if( $this->databaseCheck ){ + if(!$this->databaseHasError){ $indexInfo = [ 'SystemNeighbourModel' => [ - 'action' => [ + 'task' => [ [ - 'task' => 'buildIndex', + 'action' => 'buildIndex', 'label' => 'build', 'icon' => 'fa-refresh', 'btn' => 'btn-primary' ] ], 'table' => Model\BasicModel::getNew('SystemNeighbourModel')->getTable(), - 'count' => DB\Database::instance()->getRowCount( Model\BasicModel::getNew('SystemNeighbourModel')->getTable() ) + 'count' => $this->dbLib->getRowCount( Model\BasicModel::getNew('SystemNeighbourModel')->getTable() ) ], 'WormholeModel' => [ - 'action' => [ + 'task' => [ [ - 'task' => 'exportTable', + 'action' => 'exportTable', 'label' => 'export', 'icon' => 'fa-download', 'btn' => 'btn-default' ],[ - 'task' => 'importTable', + 'action' => 'importTable', 'label' => 'import', 'icon' => 'fa-upload', 'btn' => 'btn-primary' ] ], 'table' => Model\BasicModel::getNew('WormholeModel')->getTable(), - 'count' => DB\Database::instance()->getRowCount( Model\BasicModel::getNew('WormholeModel')->getTable() ) + 'count' => $this->dbLib->getRowCount( Model\BasicModel::getNew('WormholeModel')->getTable() ) ], 'SystemWormholeModel' => [ - 'action' => [ + 'task' => [ [ - 'task' => 'exportTable', + 'action' => 'exportTable', 'label' => 'export', 'icon' => 'fa-download', 'btn' => 'btn-default' ],[ - 'task' => 'importTable', + 'action' => 'importTable', 'label' => 'import', 'icon' => 'fa-upload', 'btn' => 'btn-primary' ] ], 'table' => Model\BasicModel::getNew('SystemWormholeModel')->getTable(), - 'count' => DB\Database::instance()->getRowCount( Model\BasicModel::getNew('SystemWormholeModel')->getTable() ) + 'count' => $this->dbLib->getRowCount( Model\BasicModel::getNew('SystemWormholeModel')->getTable() ) ], 'ConstellationWormholeModel' => [ - 'action' => [ + 'task' => [ [ - 'task' => 'exportTable', + 'action' => 'exportTable', 'label' => 'export', 'icon' => 'fa-download', 'btn' => 'btn-default' ],[ - 'task' => 'importTable', + 'action' => 'importTable', 'label' => 'import', 'icon' => 'fa-upload', 'btn' => 'btn-primary' ] ], 'table' => Model\BasicModel::getNew('ConstellationWormholeModel')->getTable(), - 'count' => DB\Database::instance()->getRowCount( Model\BasicModel::getNew('ConstellationWormholeModel')->getTable() ) + 'count' => $this->dbLib->getRowCount( Model\BasicModel::getNew('ConstellationWormholeModel')->getTable() ) ] ]; }else{ $indexInfo = [ 'SystemNeighbourModel' => [ - 'action' => [], + 'task' => [], 'table' => 'Fix database errors first!' ] ]; @@ -1172,7 +1451,7 @@ protected function setupSystemJumpTable(){ /** * import table data from existing dump file (e.g *.csv) - * @param $modelClass + * @param string $modelClass * @return bool * @throws \Exception */ @@ -1183,7 +1462,7 @@ protected function importTable($modelClass){ /** * export table data - * @param $modelClass + * @param string $modelClass * @throws \Exception */ protected function exportTable($modelClass){ diff --git a/app/main/cron/cache.php b/app/main/cron/cache.php index a635fb8df..63832068a 100644 --- a/app/main/cron/cache.php +++ b/app/main/cron/cache.php @@ -12,11 +12,25 @@ class Cache { - const LOG_TEXT = '%s [%\'_10s] files, size [%\'_10s] byte, not writable [%\'_10s] files, errors [%\'_10s], exec (%.3Fs)'; + const LOG_TEXT = '%s [%\'_10s] files, size [%\'_10s] byte, not writable [%\'_10s] files, errors [%\'_10s], exec (%.3Fs)'; + /** + * default max expire for files (seconds) + */ + const CACHE_EXPIRE_MAX = 864000; + + /** + * @param \Base $f3 + * @return int + */ + protected function getExpireMaxTime(\Base $f3): int { + $expireTime = (int)$f3->get('PATHFINDER.CACHE.EXPIRE_MAX'); + return ($expireTime >= 0) ? $expireTime : self::CACHE_EXPIRE_MAX; + } + /** * clear expired cached files - * >> >php index.php "/cron/deleteExpiredCacheData" + * >> php index.php "/cron/deleteExpiredCacheData" * @param \Base $f3 */ function deleteExpiredData(\Base $f3){ @@ -25,8 +39,8 @@ function deleteExpiredData(\Base $f3){ // cache dir (dir is recursively searched...) $cacheDir = $f3->get('TEMP'); - $filterTime = (int)strtotime('-' . $f3->get('PATHFINDER.CACHE.EXPIRE_MAX') . ' seconds'); - $expiredFiles = Search::getFilesByMTime($cacheDir, $filterTime); + $filterTime = (int)strtotime('-' . $this->getExpireMaxTime($f3) . ' seconds'); + $expiredFiles = Search::getFilesByMTime($cacheDir, $filterTime, Search::DEFAULT_FILE_LIMIT); $deletedFiles = 0; $deletedSize = 0; @@ -36,16 +50,18 @@ function deleteExpiredData(\Base $f3){ /** * @var $file \SplFileInfo */ - if( $file->isWritable() ){ - $tmpSize = $file->getSize(); - if( unlink($file->getRealPath()) ){ - $deletedSize += $tmpSize; - $deletedFiles++; + if($file->isFile()){ + if( $file->isWritable() ){ + $tmpSize = $file->getSize(); + if( unlink($file->getRealPath()) ){ + $deletedSize += $tmpSize; + $deletedFiles++; + }else{ + $deleteErrors++; + } }else{ - $deleteErrors++; + $notWritableFiles++; } - }else{ - $notWritableFiles++; } } diff --git a/app/main/cron/mapupdate.php b/app/main/cron/mapupdate.php index 3882ce3d5..74d53f1c7 100644 --- a/app/main/cron/mapupdate.php +++ b/app/main/cron/mapupdate.php @@ -8,6 +8,7 @@ namespace cron; use DB; +use lib\Config; use Model; class MapUpdate { @@ -23,19 +24,20 @@ class MapUpdate { * @param \Base $f3 */ function deactivateMapData(\Base $f3){ - $privateMapLifetime = (int)$f3->get('PATHFINDER.MAP.PRIVATE.LIFETIME'); + $privateMapLifetime = (int)Config::getMapsDefaultConfig('private.lifetime'); if($privateMapLifetime > 0){ $pfDB = DB\Database::instance()->getDB('PF'); - - $sqlDeactivateExpiredMaps = "UPDATE map SET + if($pfDB){ + $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]); + $pfDB->exec($sqlDeactivateExpiredMaps, ['lifetime' => $privateMapLifetime]); + } } } @@ -45,18 +47,31 @@ function deactivateMapData(\Base $f3){ * @param \Base $f3 */ function deleteMapData(\Base $f3){ - $pfDB = DB\Database::instance()->getDB('PF'); + $deletedMapsCount = 0; - $sqlDeleteDisabledMaps = "DELETE FROM + if($pfDB){ + $sqlDeleteDisabledMaps = "SELECT + id + FROM map WHERE map.active = 0 AND TIMESTAMPDIFF(DAY, map.updated, NOW() ) > :deletion_time"; - $pfDB->exec($sqlDeleteDisabledMaps, ['deletion_time' => self::DAYS_UNTIL_MAP_DELETION]); + $disabledMaps = $pfDB->exec($sqlDeleteDisabledMaps, ['deletion_time' => self::DAYS_UNTIL_MAP_DELETION]); - $deletedMapsCount = $pfDB->count(); + if($deletedMapsCount = $pfDB->count()){ + $mapModel = Model\BasicModel::getNew('MapModel'); + foreach($disabledMaps as $data){ + $mapModel->getById( (int)$data['id'], 3, false ); + if( !$mapModel->dry() ){ + $mapModel->erase(); + } + $mapModel->reset(); + } + } + } // Log ------------------------ $log = new \Log('cron_' . __FUNCTION__ . '.log'); @@ -73,8 +88,8 @@ function deleteEolConnections(\Base $f3){ if($eolExpire > 0){ $pfDB = DB\Database::instance()->getDB('PF'); - - $sql = "SELECT + if($pfDB){ + $sql = "SELECT `con`.`id` FROM `connection` `con` INNER JOIN @@ -85,20 +100,21 @@ function deleteEolConnections(\Base $f3){ TIMESTAMPDIFF(SECOND, `con`.`eolUpdated`, NOW() ) > :expire_time "; - $connectionsData = $pfDB->exec($sql, [ - 'deleteEolConnections' => 1, - 'expire_time' => $eolExpire - ]); - - if($connectionsData){ - /** - * @var $connection Model\ConnectionModel - */ - $connection = Model\BasicModel::getNew('ConnectionModel'); - foreach($connectionsData as $data){ - $connection->getById( (int)$data['id'] ); - if( !$connection->dry() ){ - $connection->erase(); + $connectionsData = $pfDB->exec($sql, [ + 'deleteEolConnections' => 1, + 'expire_time' => $eolExpire + ]); + + if($connectionsData){ + /** + * @var $connection Model\ConnectionModel + */ + $connection = Model\BasicModel::getNew('ConnectionModel'); + foreach($connectionsData as $data){ + $connection->getById( (int)$data['id'] ); + if( !$connection->dry() ){ + $connection->erase(); + } } } } @@ -115,8 +131,8 @@ function deleteExpiredConnections(\Base $f3){ if($whExpire > 0){ $pfDB = DB\Database::instance()->getDB('PF'); - - $sql = "SELECT + if($pfDB){ + $sql = "SELECT `con`.`id` FROM `connection` `con` INNER JOIN @@ -128,21 +144,22 @@ function deleteExpiredConnections(\Base $f3){ TIMESTAMPDIFF(SECOND, `con`.`created`, NOW() ) > :expire_time "; - $connectionsData = $pfDB->exec($sql, [ - 'deleteExpiredConnections' => 1, - 'scope' => 'wh', - 'expire_time' => $whExpire - ]); - - if($connectionsData){ - /** - * @var $connection Model\ConnectionModel - */ - $connection = Model\BasicModel::getNew('ConnectionModel'); - foreach($connectionsData as $data){ - $connection->getById( (int)$data['id'] ); - if( !$connection->dry() ){ - $connection->erase(); + $connectionsData = $pfDB->exec($sql, [ + 'deleteExpiredConnections' => 1, + 'scope' => 'wh', + 'expire_time' => $whExpire + ]); + + if($connectionsData){ + /** + * @var $connection Model\ConnectionModel + */ + $connection = Model\BasicModel::getNew('ConnectionModel'); + foreach($connectionsData as $data){ + $connection->getById( (int)$data['id'] ); + if( !$connection->dry() ){ + $connection->erase(); + } } } } @@ -159,8 +176,8 @@ function deleteSignatures(\Base $f3){ if($signatureExpire > 0){ $pfDB = DB\Database::instance()->getDB('PF'); - - $sqlDeleteExpiredSignatures = "DELETE `sigs` FROM + if($pfDB){ + $sqlDeleteExpiredSignatures = "DELETE `sigs` FROM `system_signature` `sigs` INNER JOIN `system` ON `system`.`id` = `sigs`.`systemId` @@ -169,7 +186,8 @@ function deleteSignatures(\Base $f3){ TIMESTAMPDIFF(SECOND, `sigs`.`updated`, NOW() ) > :lifetime "; - $pfDB->exec($sqlDeleteExpiredSignatures, ['lifetime' => $signatureExpire]); + $pfDB->exec($sqlDeleteExpiredSignatures, ['lifetime' => $signatureExpire]); + } } } diff --git a/app/main/data/file/filehandler.php b/app/main/data/file/filehandler.php new file mode 100644 index 000000000..be313301a --- /dev/null +++ b/app/main/data/file/filehandler.php @@ -0,0 +1,90 @@ + Each row is a JSON object + * @param string $sourceFile + * @param int $offset + * @param int $limit + * @param null|callable $formatter + * @return array + */ + public static function readLogFile( + string $sourceFile, + int $offset = self::LOG_FILE_OFFSET, + int $limit = self::LOG_FILE_LIMIT, + $formatter = null + ): array { + $data = []; + + if(is_file($sourceFile)){ + if(is_readable($sourceFile)){ + $file = new ReverseSplFileObject($sourceFile, $offset); + $file->setFlags(\SplFileObject::DROP_NEW_LINE | \SplFileObject::READ_AHEAD | \SplFileObject::SKIP_EMPTY); + + foreach( new \LimitIterator($file, 0, $limit) as $i => $rowData){ + if( !empty($rowDataObj = (array)json_decode($rowData, true)) ){ + if(is_callable($formatter)){ + $formatter($rowDataObj); + } + $data[] = $rowDataObj; + } + } + }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/main/data/file/reversesplfileobject.php b/app/main/data/file/reversesplfileobject.php new file mode 100644 index 000000000..29cc4ea67 --- /dev/null +++ b/app/main/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/main/data/filesystem/search.php b/app/main/data/filesystem/search.php index 36c1d8b0d..d94958fe5 100644 --- a/app/main/data/filesystem/search.php +++ b/app/main/data/filesystem/search.php @@ -11,20 +11,26 @@ class Search { + /** + * max file count that should be deleted in this session + */ + const DEFAULT_FILE_LIMIT = 1000; + /** * timestamp (seconds) filter files by mTime() * -> default = "no filter" * @var int */ - static $filterTime = 0; + static $filterTime = 0; /** * recursive file filter by mTime * @param string $dir * @param int $mTime - * @return array|\RecursiveCallbackFilterIterator + * @param int $limit + * @return array|\LimitIterator */ - static function getFilesByMTime($dir, $mTime = null){ + static function getFilesByMTime(string $dir, $mTime = null, $limit = self::DEFAULT_FILE_LIMIT){ $files = []; if(is_dir($dir)){ @@ -53,7 +59,8 @@ static function getFilesByMTime($dir, $mTime = null){ return false; }); - $files = new \RecursiveIteratorIterator($files); + // limit max files + $files = new \LimitIterator($files, 0, $limit); } return $files; diff --git a/app/main/data/mapper/abstractiterator.php b/app/main/data/mapper/abstractiterator.php index 710e93c7a..9c7ec3da0 100644 --- a/app/main/data/mapper/abstractiterator.php +++ b/app/main/data/mapper/abstractiterator.php @@ -8,7 +8,7 @@ namespace data\mapper; -use Lib\Util; +use lib\Util; class AbstractIterator extends \RecursiveArrayIterator { diff --git a/app/main/db/database.php b/app/main/db/database.php index bfda794fa..f5f09bcb6 100644 --- a/app/main/db/database.php +++ b/app/main/db/database.php @@ -7,56 +7,71 @@ */ namespace DB; -use Controller; use controller\LogController; +use lib\Config; class Database extends \Prefab { - - function __construct($database = 'PF'){ - // set database - $this->setDB($database); - } - /** - * set database - * @param string $database - * @return SQL + * if true, errors will not get logged + * @var bool */ - public function setDB($database = 'PF'){ - $f3 = \Base::instance(); + private $silent = false; - // "Hive" Key for DB storage - $dbHiveKey = $this->getDbHiveKey($database); + /** + * @var array + */ + private $errors = []; - // check if DB connection already exists - if( !$f3->exists($dbHiveKey, $db) ){ - if($database === 'CCP'){ - // CCP DB - $dns = Controller\Controller::getEnvironmentData('DB_CCP_DNS'); - $name = Controller\Controller::getEnvironmentData('DB_CCP_NAME'); - $user = Controller\Controller::getEnvironmentData('DB_CCP_USER'); - $password = Controller\Controller::getEnvironmentData('DB_CCP_PASS'); - }else{ - // Pathfinder(PF) DB - $dns = Controller\Controller::getEnvironmentData('DB_DNS'); - $name = Controller\Controller::getEnvironmentData('DB_NAME'); - $user = Controller\Controller::getEnvironmentData('DB_USER'); - $password = Controller\Controller::getEnvironmentData('DB_PASS'); - } + /** + * connect to the DB server itself -> NO database is used + * -> can be used to check if a certain DB exists without connecting to it directly + * @param string $dbKey + * @return SQL|null + */ + public function connectToServer(string $dbKey = 'PF'){ + $dbConfig = Config::getDatabaseConfig($dbKey); + $dbConfig['DNS'] = str_replace(';dbname=', '', $dbConfig['DNS'] ); + $dbConfig['NAME'] = ''; + return call_user_func_array([$this, 'connect'], $dbConfig); + } - $db = $this->connect($dns, $name, $user, $password); + /** + * tries to create a database if not exists + * -> DB user needs rights to create a DB + * @param string $dbKey + * @return SQL|null + */ + public function createDB(string $dbKey = 'PF'){ + $db = null; + $dbConfig = Config::getDatabaseConfig($dbKey); + // remove database from $dsn (we want to crate it) + $newDbName = $dbConfig['NAME']; + if(!empty($newDbName)){ + $dbConfig['NAME'] = ''; + $dbConfig['DNS'] = str_replace(';dbname=', '', $dbConfig['DNS'] ); - if( !is_null($db) ){ - // set DB timezone to UTC +00:00 (eve server time) - $db->exec('SET @@session.time_zone = "+00:00";'); + $db = call_user_func_array([$this, 'connect'], $dbConfig); - // set default storage engine - $db->exec('SET @@session.default_storage_engine = "' . - Controller\Controller::getRequiredMySqlVariables('DEFAULT_STORAGE_ENGINE') . '"'); + if(!is_null($db)){ + $schema = new SQL\Schema($db); + if(!in_array($newDbName, $schema->getDatabases())){ + $db->exec("CREATE DATABASE IF NOT EXISTS + `" . $newDbName . "` DEFAULT CHARACTER SET utf8 + COLLATE utf8_general_ci;"); + $db->exec("USE `" . $newDbName . "`"); - // store DB object - $f3->set($dbHiveKey, $db); + // check if DB create was successful + $dbCheck = $db->exec("SELECT DATABASE()"); + if( + !empty($dbCheck[0]) && + !empty($checkDbName = reset($dbCheck[0])) && + $checkDbName == $newDbName + ){ + self::prepareDBConnection($db); + self::prepareDatabase($db); + } + } } } @@ -65,14 +80,20 @@ public function setDB($database = 'PF'){ /** * get database - * @param string $database - * @return SQL + * @param string $dbKey + * @return SQL|null */ - public function getDB($database = 'PF'){ + public function getDB(string $dbKey = 'PF'){ $f3 = \Base::instance(); - $dbHiveKey = $this->getDbHiveKey($database); + // "Hive" Key for DB object cache + $dbHiveKey = $this->getDbHiveKey($dbKey); if( !$f3->exists($dbHiveKey, $db) ){ - $db = $this->setDB($database); + $dbConfig = Config::getDatabaseConfig($dbKey); + $db = call_user_func_array([$this, 'connect'], $dbConfig); + if(!is_null($db)){ + self::prepareDBConnection($db); + $f3->set($dbHiveKey, $db); + } } return $db; @@ -80,23 +101,23 @@ public function getDB($database = 'PF'){ /** * get a unique hive key for each DB connection - * @param $database + * @param $dbKey * @return string */ - protected function getDbHiveKey($database){ - return 'DB_' . $database; + protected function getDbHiveKey($dbKey){ + return 'DB_' . $dbKey; } - /** * connect to a database - * @param $dns - * @param $name - * @param $user - * @param $password - * @return SQL + * @param string $dns + * @param string $name + * @param string $user + * @param string $password + * @param string $alias + * @return SQL|null */ - protected function connect($dns, $name, $user, $password){ + protected function connect($dns, $name, $user, $password, $alias){ $db = null; $f3 = \Base::instance(); @@ -120,9 +141,10 @@ protected function connect($dns, $name, $user, $password){ $options ); }catch(\PDOException $e){ - // DB connection error - // -> log it - self::getLogger()->write($e->getMessage()); + $this->pushError($alias, $e); + if(!$this->isSilent()){ + self::getLogger()->write($e); + } } return $db; @@ -130,22 +152,22 @@ protected function connect($dns, $name, $user, $password){ /** * get all table names from a DB - * @param string $database + * @param string $dbKey * @return array|bool */ - public function getTables($database = 'PF'){ - $schema = new SQL\Schema( $this->getDB($database) ); + public function getTables($dbKey = 'PF'){ + $schema = new SQL\Schema( $this->getDB($dbKey) ); return $schema->getTables(); } /** * checks whether a table exists on a DB or not * @param $table - * @param string $database + * @param string $dbKey * @return bool */ - public function tableExists($table, $database = 'PF'){ - $tableNames = $this->getTables($database); + public function tableExists($table, $dbKey = 'PF'){ + $tableNames = $this->getTables($dbKey); return in_array($table, $tableNames); } @@ -153,13 +175,13 @@ public function tableExists($table, $database = 'PF'){ * get current row (data) count for an existing table * -> returns 0 if table not exists or empty * @param $table - * @param string $database + * @param string $dbKey * @return int */ - public function getRowCount($table, $database = 'PF') { + public function getRowCount($table, $dbKey = 'PF') { $count = 0; - if( $this->tableExists($table, $database) ){ - $db = $this->getDB($database); + if( $this->tableExists($table, $dbKey) ){ + $db = $this->getDB($dbKey); $countRes = $db->exec("SELECT COUNT(*) `num` FROM " . $db->quotekey($table)); if(isset($countRes[0]['num'])){ $count = (int)$countRes[0]['num']; @@ -168,6 +190,98 @@ public function getRowCount($table, $database = 'PF') { return $count; } + /** + * @return bool + */ + public function isSilent() : bool{ + return $this->silent; + } + + /** + * set "silent" mode (no error logging) + * -> optional clear $this->errors + * @param bool $silent + * @param bool $clearErrors + */ + public function setSilent(bool $silent, bool $clearErrors = false){ + $this->silent = $silent; + if($clearErrors){ + $this->errors = []; + } + } + + /** + * push new Exception into static error history + * @param string $alias + * @param \PDOException $e + */ + protected function pushError(string $alias, \PDOException $e){ + if(!is_array($this->errors[$alias])){ + $this->errors[$alias] = []; + } + + // prevent adding same errors twice + if(!empty($this->errors[$alias])){ + $lastError = array_values($this->errors[$alias])[0]; + if($lastError->getMessage() === $e->getMessage()){ + return; + } + } + + array_unshift($this->errors[$alias], $e); + if(count($this->errors[$alias]) > 5){ + $this->errors[$alias] = array_pop($this->errors[$alias]); + } + } + + /** + * get last recent Exceptions from error history + * @param string $alias + * @param int $limit + * @return \PDOException[] + */ + public function getErrors(string $alias, int $limit = 1){ + return array_slice((array)$this->errors[$alias] , 0, $limit); + } + + /** + * prepare current DB + * -> set session connection variables + * @param SQL $db + */ + public static function prepareDBConnection(SQL &$db){ + // set DB timezone to UTC +00:00 (eve server time) + $db->exec('SET @@session.time_zone = "+00:00";'); + + // set default storage engine + $db->exec('SET @@session.default_storage_engine = "' . + self::getRequiredMySqlVariables('DEFAULT_STORAGE_ENGINE') . '"'); + } + + /** + * set some default config for current DB + * @param SQL $db + */ + public static function prepareDatabase(SQL &$db){ + if($db->name()){ + // set/change default "character set" and "collation" + $db->exec('ALTER DATABASE ' . $db->quotekey($db->name()) + . ' CHARACTER SET ' . self::getRequiredMySqlVariables('CHARACTER_SET_DATABASE') + . ' COLLATE ' . self::getRequiredMySqlVariables('COLLATION_DATABASE') + ); + } + } + + /** + * get required MySQL variable value + * @param string $key + * @return string|null + */ + public static function getRequiredMySqlVariables(string $key){ + \Base::instance()->exists('REQUIREMENTS[MYSQL][VARS][' . $key . ']', $data); + return $data; + } + /** * get logger for DB logging * @return \Log diff --git a/app/main/exception/baseexception.php b/app/main/exception/baseexception.php index 9262e4c42..af4b580c9 100644 --- a/app/main/exception/baseexception.php +++ b/app/main/exception/baseexception.php @@ -11,11 +11,12 @@ class BaseException extends \Exception { - const VALIDATION_FAILED = 403; - const REGISTRATION_FAILED = 403; - const CONFIGURATION_FAILED = 500; + const VALIDATION_EXCEPTION = 403; + const REGISTRATION_EXCEPTION = 403; + const CONFIG_VALUE_EXCEPTION = 500; + const DB_EXCEPTION = 500; - public function __construct($message, $code = 0){ + public function __construct(string $message, int $code = 0){ parent::__construct($message, $code); } diff --git a/app/main/exception/databaseexception.php b/app/main/exception/databaseexception.php new file mode 100644 index 000000000..a6a72becb --- /dev/null +++ b/app/main/exception/databaseexception.php @@ -0,0 +1,16 @@ +setField($field); } } \ No newline at end of file diff --git a/app/main/exception/validationexception.php b/app/main/exception/validationexception.php index 74a3db58a..200f4d0c0 100644 --- a/app/main/exception/validationexception.php +++ b/app/main/exception/validationexception.php @@ -11,27 +11,41 @@ class ValidationException extends BaseException { + /** + * table column that triggers the exception + * @var string + */ private $field; /** - * @return mixed + * @return string */ - public function getField(){ + public function getField(): string { return $this->field; } /** - * @param mixed $field + * @param string $field */ - public function setField($field){ + public function setField(string $field){ $this->field = $field; } - public function __construct($message, $field = 0){ - - parent::__construct($message, self::VALIDATION_FAILED); - + public function __construct(string $message, string $field = ''){ + parent::__construct($message, self::VALIDATION_EXCEPTION); $this->setField($field); } + + /** + * get error object + * @return \stdClass + */ + public function getError(){ + $error = (object) []; + $error->type = 'error'; + $error->field = $this->getField(); + $error->message = $this->getMessage(); + return $error; + } } \ No newline at end of file diff --git a/app/main/lib/Monolog.php b/app/main/lib/Monolog.php new file mode 100644 index 000000000..d83c01be1 --- /dev/null +++ b/app/main/lib/Monolog.php @@ -0,0 +1,216 @@ + 'Monolog\Formatter\LineFormatter', + 'json' => 'Monolog\Formatter\JsonFormatter', + 'html' => 'Monolog\Formatter\HtmlFormatter', + 'mail' => 'lib\logging\formatter\MailFormatter' + ]; + + const HANDLER = [ + 'stream' => 'Monolog\Handler\StreamHandler', + 'mail' => 'Monolog\Handler\SwiftMailerHandler', + 'slackMap' => 'lib\logging\handler\SlackMapWebhookHandler', + 'slackRally' => 'lib\logging\handler\SlackRallyWebhookHandler', + 'zmq' => 'lib\logging\handler\ZMQHandler' + ]; + + const PROCESSOR = [ + 'psr' => 'Monolog\Processor\PsrLogMessageProcessor' + ]; + + /** + * @var Logging\LogCollection[][]|Logging\MapLog[][] + */ + private $logs = [ + 'solo' => [], + 'groups' => [] + ]; + + public function __construct(){ + // set timezone for all Logger instances + if(class_exists(Logger::class)){ + if( is_callable($getTimezone = \Base::instance()->get('getTimeZone')) ){ + Logger::setTimezone($getTimezone()); + }; + }else{ + LogController::getLogger('ERROR')->write(sprintf(Config::ERROR_CLASS_NOT_EXISTS_COMPOSER, Logger::class)); + } + } + + /** + * buffer log object, add to objectStorage collection + * -> this buffered data can be stored/logged somewhere (e.g. DB/file) at any time + * -> should be cleared afterwards! + * @param Logging\AbstractLog $log + */ + public function push(Logging\AbstractLog $log){ + // check whether $log should be "grouped" by common handlers + if($log->isGrouped()){ + $groupHash = $log->getGroupHash(); + + if(!isset($this->logs['groups'][$groupHash])){ + // create new log collection + // $this->logs['groups'][$groupHash] = new Logging\LogCollection($log->getChannelName()); + $this->logs['groups'][$groupHash] = new Logging\LogCollection('mapDelete'); + } + $this->logs['groups'][$groupHash]->addLog($log); + + // remove "group" handler from $log + // each log should only be logged once per handler! + $log->removeHandlerGroups(); + } + + $this->logs['solo'][] = $log; + } + + /** + * bulk process all stored logs -> send to Monolog lib + */ + public function log(){ + + foreach($this->logs as $logType => $logs){ + foreach($logs as $logKey => $log){ + $groupHash = $log->getGroupHash(); + $level = Logger::toMonologLevel($log->getLevel()); + + // add new logger to Registry if not already exists + if(Registry::hasLogger($groupHash)){ + $logger = Registry::getInstance($groupHash); + }else{ + $logger = new Logger($log->getChannelName()); + + // disable microsecond timestamps (seconds should be fine) + $logger->useMicrosecondTimestamps(true); + + // configure new $logger -------------------------------------------------------------------------- + // get Monolog Handler with Formatter config + // -> $log could have multiple handler with different Formatters + $handlerConf = $log->getHandlerConfig(); + foreach($handlerConf as $handlerKey => $formatterKey){ + // get Monolog Handler class + $handlerParams = $log->getHandlerParams($handlerKey); + $handler = $this->getHandler($handlerKey, $handlerParams); + + // get Monolog Formatter + $formatter = $this->getFormatter((string)$formatterKey); + if( $formatter instanceof FormatterInterface){ + $handler->setFormatter($formatter); + } + + if($log->hasBuffer()){ + // wrap Handler into bufferHandler + // -> bulk save all logs for this $logger + $bufferHandler = new BufferHandler($handler); + $logger->pushHandler($bufferHandler); + }else{ + $logger->pushHandler($handler); + } + } + + // get Monolog Processor config + $processorConf = $log->getProcessorConfig(); + foreach($processorConf as $processorKey => $processorCallback){ + if(is_callable($processorCallback)){ + // custom Processor callback function + $logger->pushProcessor($processorCallback); + }else{ + // get Monolog Processor class + $processor = $this->getProcessor($processorKey); + $logger->pushProcessor($processor); + } + } + + Registry::addLogger($logger, $groupHash); + } + + $logger->addRecord($level, $log->getMessage(), $log->getContext()); + } + } + + // clear log object storage + $this->logs['groups'] = []; + $this->logs['solo'] = []; + } + + /** + * get Monolog Formatter instance by key + * @param string $formatKey + * @return FormatterInterface|null + * @throws \Exception + */ + private function getFormatter(string $formatKey){ + $formatter = null; + if(!empty($formatKey)){ + if(array_key_exists($formatKey, self::FORMATTER)){ + $formatClass = self::FORMATTER[$formatKey]; + $formatter = new $formatClass(); + }else{ + throw new \Exception(sprintf(self::ERROR_FORMATTER, $formatKey)); + } + } + + return $formatter; + } + + /** + * get Monolog Handler instance by key + * @param string $handlerKey + * @param array $handlerParams + * @return HandlerInterface + * @throws \Exception + */ + private function getHandler(string $handlerKey, array $handlerParams = []): HandlerInterface{ + if(array_key_exists($handlerKey, self::HANDLER)){ + $handlerClass = self::HANDLER[$handlerKey]; + $handler = new $handlerClass(...$handlerParams); + }else{ + throw new \Exception(sprintf(self::ERROR_HANDLER, $handlerKey)); + } + + return $handler; + } + + /** + * get Monolog Processor instance by key + * @param string $processorKey + * @return callable + * @throws \Exception + */ + private function getProcessor(string $processorKey): callable { + if(array_key_exists($processorKey, self::PROCESSOR)){ + $ProcessorClass = self::PROCESSOR[$processorKey]; + $processor = new $ProcessorClass(); + }else{ + throw new \Exception(sprintf(self::ERROR_PROCESSOR, $processorKey)); + } + + return $processor; + } + + +} \ No newline at end of file diff --git a/app/main/lib/ccpclient.php b/app/main/lib/ccpclient.php index aef5a774c..250801e63 100644 --- a/app/main/lib/ccpclient.php +++ b/app/main/lib/ccpclient.php @@ -6,7 +6,7 @@ * Time: 19:17 */ -namespace Lib; +namespace lib; use controller\LogController; use \Exodus4D\ESI\ESI as ApiClient; @@ -16,24 +16,26 @@ class CcpClient extends \Prefab { private $apiClient; public function __construct(\Base $f3){ - $this->apiClient = $this->getClient(); + $this->apiClient = $this->getClient($f3); $f3->set('ccpClient', $this); } /** * get ApiClient instance + * @param \Base $f3 * @return ApiClient|null */ - protected function getClient(){ + protected function getClient(\Base $f3){ $client = null; - if( !class_exists(ApiClient::class) ){ - LogController::getLogger('ERROR')->write($this->getMissingClientError()); - }else{ + if(class_exists(ApiClient::class)){ $client = new ApiClient(); $client->setUrl( Config::getEnvironmentData('CCP_ESI_URL') ); $client->setDatasource( Config::getEnvironmentData('CCP_ESI_DATASOURCE') ); $client->setUserAgent($this->getUserAgent()); + $client->setDebugLevel($f3->get('DEBUG')); + }else{ + LogController::getLogger('ERROR')->write(sprintf(Config::ERROR_CLASS_NOT_EXISTS_COMPOSER, ApiClient::class)); } return $client; @@ -52,14 +54,6 @@ protected function getUserAgent(){ return $userAgent; } - /** - * get error msg for failed ApiClient() class -> Composer package not found - * @return string - */ - protected function getMissingClientError(){ - return "Class '" . ApiClient::class . "' not found. -> Check installed Composer packages.'"; - } - /** * get error msg for undefined method in ApiClient() class * @param $method @@ -86,8 +80,8 @@ public function __call($name, $arguments){ \Base::instance()->error(501, $this->getMissingMethodError($name)); } }else{ - LogController::getLogger('ERROR')->write($this->getMissingClientError()); - \Base::instance()->error(501, $this->getMissingClientError()); + LogController::getLogger('ERROR')->write(sprintf(Config::ERROR_CLASS_NOT_EXISTS_COMPOSER, ApiClient::class)); + \Base::instance()->error(501, sprintf(Config::ERROR_CLASS_NOT_EXISTS_COMPOSER, ApiClient::class)); } return $return; diff --git a/app/main/lib/config.php b/app/main/lib/config.php index c788d9d1d..a663fd875 100644 --- a/app/main/lib/config.php +++ b/app/main/lib/config.php @@ -17,8 +17,11 @@ class Config extends \Prefab { const ARRAY_DELIMITER = '-'; const HIVE_KEY_PATHFINDER = 'PATHFINDER'; const HIVE_KEY_ENVIRONMENT = 'ENVIRONMENT'; + const CACHE_KEY_SOCKET_VALID = 'CACHED_SOCKET_VALID'; + const CACHE_TTL_SOCKET_VALID = 60; const ERROR_CONF_PATHFINDER = 'Config value missing in pathfinder.ini file [%s]'; + const ERROR_CLASS_NOT_EXISTS_COMPOSER = 'Class "%s" not found. -> Check installed Composer packages'; /** @@ -43,6 +46,11 @@ public function __construct(\Base $f3){ // set hive configuration variables // -> overwrites default configuration $this->setHiveVariables($f3); + + // set global function for current DateTimeZone() + $f3->set('getTimeZone', function() use ($f3){ + return new \DateTimeZone( $f3->get('TZ') ); + }); } /** @@ -132,8 +140,6 @@ protected function setAllEnvironmentData(\Base $f3){ * Nginx (server config): * -> FastCGI syntax * fastcgi_param PF-ENV-DEBUG 3; - * - * @return array */ protected function setServerData(){ $data = []; @@ -166,10 +172,82 @@ protected function setServerData(){ static function getEnvironmentData($key){ $hiveKey = self::HIVE_KEY_ENVIRONMENT . '.' . $key; \Base::instance()->exists($hiveKey, $data); - return $data; } + /** + * get database config values + * @param string $dbKey + * @return array + */ + static function getDatabaseConfig(string $dbKey = 'PF'){ + $dbKey = strtoupper($dbKey); + return [ + 'DNS' => self::getEnvironmentData('DB_' . $dbKey . '_DNS'), + 'NAME' => self::getEnvironmentData('DB_' . $dbKey . '_NAME'), + 'USER' => self::getEnvironmentData('DB_' . $dbKey . '_USER'), + 'PASS' => self::getEnvironmentData('DB_' . $dbKey . '_PASS'), + 'ALIAS' => $dbKey + ]; + } + + /** + * get DB config value from PDO connect $dns string + * @param string $dns + * @param string $key + * @return bool + */ + static function getDatabaseDNSValue(string $dns, string $key = 'dbname'){ + $value = false; + if(preg_match('/' . preg_quote($key, '/') . '=([[:alnum:]]+)/is', $dns, $parts)){ + $value = $parts[1]; + } + return $value; + } + + /** + * get SMTP config values + * @return \stdClass + */ + static function getSMTPConfig(): \stdClass{ + $config = new \stdClass(); + $config->host = self::getEnvironmentData('SMTP_HOST'); + $config->port = self::getEnvironmentData('SMTP_PORT'); + $config->scheme = self::getEnvironmentData('SMTP_SCHEME'); + $config->username = self::getEnvironmentData('SMTP_USER'); + $config->password = self::getEnvironmentData('SMTP_PASS'); + $config->from = [ + self::getEnvironmentData('SMTP_FROM') => self::getPathfinderData('name') + ]; + return $config; + } + + /** + * validates an SMTP config + * @param \stdClass $config + * @return bool + */ + static function isValidSMTPConfig(\stdClass $config): bool { + // validate email from either an configured array or plain string + $validateMailConfig = function($mailConf = null): bool { + $email = null; + if(is_array($mailConf)){ + reset($mailConf); + $email = key($mailConf); + }elseif(is_string($mailConf)){ + $email = $mailConf; + } + return \Audit::instance()->email($email); + }; + + return ( + !empty($config->host) && + !empty($config->username) && + $validateMailConfig($config->from) && + $validateMailConfig($config->to) + ); + } + /** * get email for notifications by hive key * @param $key @@ -183,7 +261,7 @@ static function getNotificationMail($key){ * get map default config values for map types (private/corp/ally) * -> read from pathfinder.ini * @param string $mapType - * @return array + * @return mixed */ static function getMapsDefaultConfig($mapType = ''){ if( $mapConfig = self::getPathfinderData('map' . ($mapType ? '.' . $mapType : '')) ){ @@ -193,6 +271,92 @@ static function getMapsDefaultConfig($mapType = ''){ return $mapConfig; } + /** + * get custom $message for a a HTTP $status + * -> use this in addition to the very general Base::HTTP_XXX labels + * @param int $status + * @return string + */ + static function getMessageFromHTTPStatus(int $status): string { + switch($status){ + case 403: + $message = 'Access denied: User not found'; break; + default: + $message = ''; + } + return $message; + } + + /** + * check whether this installation fulfills all requirements + * -> check for ZMQ PHP extension and installed ZQM version + * -> this does NOT check versions! -> those can be verified on /setup page + * @return bool + */ + static function checkSocketRequirements(): bool { + return extension_loaded('zmq') && class_exists('ZMQ'); + } + + /** + * use this function to "validate" the socket connection. + * The result will be CACHED for a few seconds! + * This function is intended to pre-check a Socket connection if it MIGHT exists. + * No data will be send to the Socket, this function just validates if a socket is available + * -> see pingDomain() + * @return bool + */ + static function validSocketConnect(): bool{ + $valid = false; + $f3 = \Base::instance(); + + if( !$f3->exists(self::CACHE_KEY_SOCKET_VALID, $valid) ){ + if(self::checkSocketRequirements() && ($socketUrl = self::getSocketUri()) ){ + // get socket URI parts -> not elegant... + $domain = parse_url( $socketUrl, PHP_URL_SCHEME) . '://' . parse_url( $socketUrl, PHP_URL_HOST); + $port = parse_url( $socketUrl, PHP_URL_PORT); + // check connection -> get ms + $status = self::pingDomain($domain, $port); + if($status >= 0){ + // connection OK + $valid = true; + }else{ + // connection error/timeout + $valid = false; + } + }else{ + // requirements check failed or URL not valid + $valid = false; + } + + $f3->set(self::CACHE_KEY_SOCKET_VALID, $valid, self::CACHE_TTL_SOCKET_VALID); + } + + return $valid; + } + + /** + * get response time for a host in ms or -1 on error/timeout + * @param string $domain + * @param int $port + * @param int $timeout + * @return int + */ + static function pingDomain(string $domain, int $port, $timeout = 1): int { + $starttime = microtime(true); + $file = @fsockopen ($domain, $port, $errno, $errstr, $timeout); + $stoptime = microtime(true); + + if (!$file){ + // Site is down + $status = -1; + }else { + fclose($file); + $status = ($stoptime - $starttime) * 1000; + $status = floor($status); + } + return $status; + } + /** * get URI for TCP socket * @return bool|string diff --git a/app/main/lib/logging/AbstractChannelLog.php b/app/main/lib/logging/AbstractChannelLog.php new file mode 100644 index 000000000..0bd1803aa --- /dev/null +++ b/app/main/lib/logging/AbstractChannelLog.php @@ -0,0 +1,87 @@ +setChannelData($channelData); + + // add log processor -> remove $channelData from log + $processorClearChannelData = function($record){ + $record['context'] = array_diff_key($record['context'], $this->getChannelData()); + return $record; + }; + + // init processorConfig. IMPORTANT: first processor gets executed at the end! + $this->processorConfig = ['clearChannelData' => $processorClearChannelData] + $this->processorConfig; + } + + /** + * @param array $channelData + */ + protected function setChannelData(array $channelData){ + $this->channelData = $channelData; + } + + /** + * @return array + */ + public function getChannelData() : array{ + return $this->channelData; + } + + /** + * @return int + */ + public function getChannelId() : int{ + return (int)$this->getChannelData()['channelId']; + } + + /** + * @return string + */ + public function getChannelName() : string{ + return (string)$this->getChannelData()['channelName']; + } + + /** + * @return array + */ + public function getData() : array{ + $data['main'] = parent::getData(); + + if(!empty($channelLogData = $this->getChannelData())){ + $channelData['channel'] = $channelLogData; + $data = $channelData + $data; + } + + return $data; + } + + /** + * @return array + */ + public function getContext(): array{ + $context = parent::getContext(); + + // add temp data (e.g. used for $message placeholder replacement + $context += $this->getChannelData(); + + return $context; + } +} \ No newline at end of file diff --git a/app/main/lib/logging/AbstractCharacterLog.php b/app/main/lib/logging/AbstractCharacterLog.php new file mode 100644 index 000000000..a40830700 --- /dev/null +++ b/app/main/lib/logging/AbstractCharacterLog.php @@ -0,0 +1,81 @@ + remove $channelData from log + $processorAddThumbData = function($record){ + $record['extra']['thumb']['url'] = $this->getThumbUrl(); + return $record; + }; + + // init processorConfig. IMPORTANT: first processor gets executed at the end! + $this->processorConfig = ['addThumbData' => $processorAddThumbData] + $this->processorConfig; + } + + /** + * CharacterModel $character + * @param CharacterModel $character + * @return LogInterface + */ + public function setCharacter(CharacterModel $character): LogInterface{ + $this->character = $character; + return $this; + } + + /** + * @return CharacterModel + */ + public function getCharacter(): CharacterModel{ + return $this->character; + } + + /** + * @return array + */ + public function getData() : array{ + $data = parent::getData(); + + if(is_object($character = $this->getCharacter())){ + $characterData['character'] = [ + 'id' => $character->_id, + 'name' => $character->name + ]; + $data = $characterData + $data; + } + + return $data; + } + + /** + * get character thumbnailUrl + * @return string + */ + protected function getThumbUrl(): string { + $url = ''; + if(is_object($character = $this->getCharacter())){ + $url = Config::getPathfinderData('api.ccp_image_server') . '/Character/' . $character->_id . '_128.jpg'; + } + + return $url; + } + +} \ No newline at end of file diff --git a/app/main/lib/logging/AbstractLog.php b/app/main/lib/logging/AbstractLog.php new file mode 100644 index 000000000..7a87ae965 --- /dev/null +++ b/app/main/lib/logging/AbstractLog.php @@ -0,0 +1,549 @@ + check Monolog::HANDLER and Monolog::FORMATTER + * @var array + */ + protected $handlerConfig = ['stream' => 'line']; + + /** + * log Processors, array with either callable functions or Processor class with __invoce() method + * -> functions used to add "extra" data to a log + * @var array + */ + protected $processorConfig = ['psr' => null]; + + /** + * some handler need individual configuration parameters + * -> see $handlerConfig end getHandlerParams() + * @var array + */ + protected $handlerParamsConfig = []; + + /** + * multiple Log() objects can be marked as "grouped" + * -> Logs with Slack Handler should be grouped by map (send multiple log data in once + * @var array + */ + protected $handlerGroups = []; + + /** + * @var string + */ + protected $message = ''; + + /** + * @var string + */ + protected $action = ''; + + /** + * @var string + */ + protected $channelType = ''; + + /** + * log level from self::LEVEL + * -> private - use setLevel() to set + * @var string + */ + private $level = 'debug'; + + /** + * log tag from self::TAG + * -> private - use setTag() to set + * @var string + */ + private $tag = 'default'; + + /** + * log data (main log data) + * @var array + */ + private $data = []; + + /** + * (optional) temp data for logger (will not be stored with the log entry) + * @var array + */ + private $tmpData = []; + + /** + * buffer multiple logs with the same chanelType and store all at once + * @var bool + */ + private $buffer = true; + + + public function __construct(string $action){ + $this->setF3(); + $this->action = $action; + + // add custom log processor callback -> add "extra" (meta) data + $f3 = $this->f3; + $processorExtraData = function($record) use(&$f3){ + $record['extra'] = [ + 'path' => $f3->get('PATH'), + 'ip' => $f3->get('IP') + ]; + return $record; + }; + + // add log processor -> remove §tempData from log + $processorClearTempData = function($record){ + $record['context'] = array_diff_key($record['context'], $this->getTempData()); + return $record; + }; + + // init processorConfig. IMPORTANT: first processor gets executed at the end! + $this->processorConfig = ['cleaTempData' => $processorClearTempData] + [ 'addExtra' => $processorExtraData] + $this->processorConfig; + } + + /** + * set $f3 base object + */ + public function setF3(){ + $this->f3 = \Base::instance(); + } + + /** + * @param $message + */ + public function setMessage(string $message){ + $this->message = $message; + } + + /** + * @param string $level + * @throws \Exception + */ + public function setLevel(string $level){ + if( in_array($level, self::LEVEL)){ + $this->level = $level; + }else{ + throw new \Exception( sprintf(self::ERROR_LEVEL, $level)); + } + } + + /** + * @param string $tag + * @throws \Exception + */ + public function setTag(string $tag){ + if( in_array($tag, self::TAG)){ + $this->tag = $tag; + }else{ + throw new \Exception( sprintf(self::ERROR_TAG, $tag)); + } + } + + /** + * @param array $data + * @return LogInterface + */ + public function setData(array $data): LogInterface{ + $this->data = $data; + return $this; + } + + /** + * @param array $data + * @return LogInterface + */ + public function setTempData(array $data): LogInterface{ + $this->tmpData = $data; + return $this; + } + + /** + * add new Handler by $handlerKey + * set its default Formatter by $formatterKey + * @param string $handlerKey + * @param string|null $formatterKey + * @param \stdClass|null $handlerParams + * @return LogInterface + */ + public function addHandler(string $handlerKey, string $formatterKey = null, \stdClass $handlerParams = null): LogInterface{ + if(!$this->hasHandlerKey($handlerKey)){ + $this->handlerConfig[$handlerKey] = $formatterKey; + // add more configuration params for the new handler + if(!is_null($handlerParams)){ + $this->handlerParamsConfig[$handlerKey] = $handlerParams; + } + } + return $this; + } + + /** + * add new handler for Log() grouping + * @param string $handlerKey + * @return LogInterface + */ + public function addHandlerGroup(string $handlerKey): LogInterface{ + if( + $this->hasHandlerKey($handlerKey) && + !$this->hasHandlerGroupKey($handlerKey) + ){ + $this->handlerGroups[] = $handlerKey; + } + return $this; + } + + /** + * @return array + */ + public function getHandlerConfig(): array{ + return $this->handlerConfig; + } + + /** + * get __construct() parameters for a given $handlerKey + * @param string $handlerKey + * @return array + * @throws \Exception + */ + public function getHandlerParams(string $handlerKey): array{ + $params = []; + + if($this->hasHandlerKey($handlerKey)){ + switch($handlerKey){ + case 'stream': $params = $this->getHandlerParamsStream(); + break; + case 'zmq': $params = $this->getHandlerParamsZMQ(); + break; + case 'mail': $params = $this->getHandlerParamsMail(); + break; + case 'slackMap': + case 'slackRally': + $params = $this->getHandlerParamsSlack($handlerKey); + break; + default: + throw new \Exception( sprintf(self::ERROR_HANDLER_PARAMS, $handlerKey)); + } + }else{ + throw new \Exception( sprintf(self::ERROR_HANDLER_KEY, $handlerKey, implode(', ', array_flip($this->handlerConfig)))); + } + + return $params; + } + + /** + * @return array + */ + public function getHandlerParamsConfig(): array{ + return $this->handlerParamsConfig; + } + + /** + * @return array + */ + public function getProcessorConfig(): array{ + return $this->processorConfig; + } + + /** + * @return string + */ + public function getMessage(): string{ + return $this->message; + } + + /** + * @return string + */ + public function getAction(): string{ + return $this->action; + } + + /** + * @return string + */ + public function getChannelType(): string{ + return $this->channelType; + } + + /** + * @return string + */ + public function getChannelName(): string{ + return $this->getChannelType(); + } + + /** + * @return string + */ + public function getLevel(): string{ + return $this->level; + } + + /** + * @return string + */ + public function getTag(): string{ + return $this->tag; + } + + /** + * @return array + */ + public function getData(): array{ + return $this->data; + } + /** + * @return array + */ + public function getContext(): array{ + $context = [ + 'data' => $this->getData(), + 'tag' => $this->getTag() + ]; + + // add temp data (e.g. used for $message placeholder replacement + $context += $this->getTempData(); + + return $context; + } + + /** + * @return array + */ + protected function getTempData(): array { + return $this->tmpData; + } + + /** + * @return array + */ + public function getHandlerGroups(): array{ + return $this->handlerGroups; + } + + /** + * get unique hash for this kind of logs (channel) and same $handlerGroups + * @return string + */ + public function getGroupHash(): string { + $groupName = $this->getChannelName(); + if($this->isGrouped()){ + $groupName .= '_' . implode('_', $this->getHandlerGroups()); + } + + return $this->f3->hash($groupName); + } + + /** + * @param string $handlerKey + * @return bool + */ + public function hasHandlerKey(string $handlerKey): bool{ + return array_key_exists($handlerKey, $this->handlerConfig); + } + + /** + * @param string $handlerKey + * @return bool + */ + public function hasHandlerGroupKey(string $handlerKey): bool{ + return in_array($handlerKey, $this->getHandlerGroups()); + } + + /** + * @return bool + */ + public function hasBuffer(): bool{ + return $this->buffer; + } + + /** + * @return bool + */ + public function isGrouped(): bool{ + return !empty($this->getHandlerGroups()); + } + + /** + * remove all group handlers and their config params + */ + public function removeHandlerGroups(){ + foreach($this->getHandlerGroups() as $handlerKey){ + $this->removeHandlerGroup($handlerKey); + } + } + + /** + * @param string $handlerKey + */ + public function removeHandlerGroup(string $handlerKey){ + unset($this->handlerConfig[$handlerKey]); + unset($this->handlerParamsConfig[$handlerKey]); + } + + // Handler parameters for Monolog\Handler\AbstractHandler --------------------------------------------------------- + protected function getHandlerParamsStream(): array{ + $params = []; + if( !empty($conf = $this->handlerParamsConfig['stream']) ){ + $params[] = $conf->stream; + } + + return $params; + } + + /** + * get __construct() parameters for ZMQHandler() call + * @return array + */ + protected function getHandlerParamsZMQ(): array { + $params = []; + if( !empty($conf = $this->handlerParamsConfig['zmq']) ){ + // meta data (required by receiver socket) + $meta = [ + 'logType' => 'mapLog', + 'stream'=> $conf->streamConf->stream + ]; + + $context = new \ZMQContext(); + $pusher = $context->getSocket(\ZMQ::SOCKET_PUSH); + $pusher->connect($conf->uri); + + $params[] = $pusher; + $params[] = \ZMQ::MODE_DONTWAIT; + $params[] = false; // multipart + $params[] = Logger::toMonologLevel($this->getLevel()); // min level that is handled + $params[] = true; // bubble + $params[] = $meta; + } + + return $params; + } + + /** + * get __construct() parameters for SwiftMailerHandler() call + * @return array + */ + protected function getHandlerParamsMail(): array{ + $params = []; + if( !empty($conf = $this->handlerParamsConfig['mail']) ){ + $transport = (new \Swift_SmtpTransport()) + ->setHost($conf->host) + ->setPort($conf->port) + ->setEncryption($conf->scheme) + ->setUsername($conf->username) + ->setPassword($conf->password) + ->setStreamOptions([ + 'ssl' => [ + 'allow_self_signed' => true, + 'verify_peer' => false + ] + ]); + + $mailer = new \Swift_Mailer($transport); + + // callback function used instead of Swift_Message() object + // -> we want the formatted/replaced message as subject + $messageCallback = function($content, $records) use ($conf){ + $subject = 'No Subject'; + if(!empty($records)){ + // build subject from first record -> remove "markdown" + $subject = str_replace(['*', '_'], '', $records[0]['message']); + } + + $jsonData = @json_encode($records, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + + + $message = (new \Swift_Message()) + ->setSubject($subject) + ->addPart($jsonData) + ->setFrom($conf->from) + ->setTo($conf->to) + ->setContentType('text/html') + ->setCharset('utf-8') + ->setMaxLineLength(1000); + + if($conf->addJson){ + $jsonAttachment = (new \Swift_Attachment()) + ->setFilename('data.json') + ->setContentType('application/json') + ->setBody($jsonData); + $message->attach($jsonAttachment); + } + + return $message; + }; + + $params[] = $mailer; + $params[] = $messageCallback; + $params[] = Logger::toMonologLevel($this->getLevel()); // min level that is handled + $params[] = true; // bubble + } + + return $params; + } + + /** + * get __construct() params for SlackWebhookHandler() call + * @param string $handlerKey + * @return array + */ + protected function getHandlerParamsSlack(string $handlerKey): array { + $params = []; + if( !empty($conf = $this->handlerParamsConfig[$handlerKey]) ){ + $params[] = $conf->slackWebHookURL; + $params[] = $conf->slackChannel; + $params[] = $conf->slackUsername; + $params[] = true; // $useAttachment + $params[] = $conf->slackIcon; + $params[] = true; // $includeContext + $params[] = false; // $includeExtra + $params[] = Logger::toMonologLevel($this->getLevel()); // min level that is handled + $params[] = true; // $bubble + //$params[] = ['extra', 'context.tag']; // $excludeFields + $params[] = []; // $excludeFields + } + + return $params; + } + + /** + * send this Log to global log buffer storage + */ + public function buffer(){ + Monolog::instance()->push($this); + } + + +} \ No newline at end of file diff --git a/app/main/lib/logging/DefaultLog.php b/app/main/lib/logging/DefaultLog.php new file mode 100644 index 000000000..2653b1262 --- /dev/null +++ b/app/main/lib/logging/DefaultLog.php @@ -0,0 +1,19 @@ + no default is set + * @var array + */ + protected $handlerConfig = []; + + /** + * processors for this collection + * -> no default is set + * @var array + */ + protected $processorConfig = []; + + /** + * @var null|\SplObjectStorage + */ + private $collection = null; + + public function __construct(string $action){ + parent::__construct($action); + + $this->collection = new \SplObjectStorage(); + } + + /** + * get first Log from Collection + * @return AbstractLog + * @throws \Exception + */ + protected function getPrimaryLog(): AbstractLog{ + $this->collection->rewind(); + if($this->collection->valid()){ + /** + * @var $log AbstractLog + */ + $log = $this->collection->current(); + }else{ + throw new \Exception( self::ERROR_EMPTY); + } + + return $log; + } + + /** + * add a new log object to this collection + * @param AbstractLog $log + */ + public function addLog(AbstractLog $log){ + if(!$this->collection->contains($log)){ + if(!$this->collection->count()){ + // first log sets the default for this collection + $this->channelType = $log->getChannelType(); + + // get relevant handlerKeys for this collection + $handlerGroups = array_flip($log->getHandlerGroups()); + + // remove handlers that are not relevant for this collection + $handlerConfig = $log->getHandlerConfig(); + $handlerConfigGroup = array_intersect_key($handlerConfig, $handlerGroups); + + // remove handlersParams that are not relevant for this collection + $handlerParamsConfig = $log->getHandlerParamsConfig(); + $handlerParamsConfigGroup = array_intersect_key($handlerParamsConfig, $handlerGroups); + + // add all handlers that are relevant for this collection + foreach($handlerConfigGroup as $handlerKey => $formatterKey){ + $handlerParams = array_key_exists($handlerKey, $handlerParamsConfigGroup) ? $handlerParamsConfigGroup[$handlerKey] : null; + $this->addHandler($handlerKey, $formatterKey, $handlerParams); + } + + // add processors for this collection + $this->processorConfig = $log->getProcessorConfig(); + } + + $this->setMessage($log->getMessage()); + $this->setTag($log->getTag()); + + $this->collection->attach($log); + } + } + + /** + * @param string $message + */ + public function setMessage(string $message){ + $currentMessage = parent::getMessage(); + if(empty($currentMessage)){ + $newMessage = $message; + }elseif($message !== $currentMessage){ + $newMessage = 'multi changes'; + }else{ + $newMessage = $currentMessage ; + } + + parent::setMessage($newMessage); + } + + /** + * @param string $tag + */ + public function setTag(string $tag){ + $currentTag = parent::getTag(); + switch($currentTag){ + case 'default': + // no specific tag set so far... set new + $newTag = $tag; break; + case 'information': + // do not change "information" tag (mixed tag logs in this collection) + $newTag = $currentTag; break; + default: + // set mixed tag -> "information" + $newTag = ($tag !== $currentTag) ? 'information': $tag; + } + + parent::setTag($newTag); + } + + /** + * get log data for all logs in this collection + * @return array + */ + public function getData() : array{ + $this->collection->rewind(); + $data = []; + while($this->collection->valid()){ + $data[] = $this->collection->current()->getData(); + $this->collection->next(); + } + return $data; + } + + /** + * @return string + */ + public function getChannelName() : string{ + return $this->getPrimaryLog()->getChannelName(); + } + + /** + * @return string + */ + public function getLevel() : string{ + return $this->getPrimaryLog()->getLevel(); + } + + /** + * @return bool + */ + public function hasBuffer() : bool{ + return $this->getPrimaryLog()->hasBuffer(); + } + + /** + * @return array + */ + public function getTempData() : array{ + return $this->getPrimaryLog()->getTempData(); + } + + + +} \ No newline at end of file diff --git a/app/main/lib/logging/LogInterface.php b/app/main/lib/logging/LogInterface.php new file mode 100644 index 000000000..1b0d20419 --- /dev/null +++ b/app/main/lib/logging/LogInterface.php @@ -0,0 +1,64 @@ + final handler will be set dynamic for per instance + * @var array + */ + protected $handlerConfig = [ + //'stream' => 'json', + //'zmq' => 'json', + //'slackMap' => 'json' + ]; + + /** + * @var string + */ + protected $channelType = 'map'; + + /** + * @var bool + */ + protected $logActivity = false; + + + public function __construct(string $action, array $objectData){ + parent::__construct($action, $objectData); + + $this->setLevel('info'); + $this->setTag($this->getTagFromAction()); + } + + /** + * get log tag depending on log action + * @return string + */ + public function getTagFromAction(){ + $tag = parent::getTag(); + $actionParts = $this->getActionParts(); + switch($actionParts[1]){ + case 'create': $tag = 'success'; break; + case 'update': $tag = 'warning'; break; + case 'delete': $tag = 'danger'; break; + } + + return $tag; + } + + /** + * @return string + */ + public function getChannelName(): string{ + return $this->getChannelType() . '_' . $this->getChannelId(); + } + + /** + * @return string + */ + public function getMessage() : string{ + return $this->getActionParts()[0] . " '{objName}'"; + } + + /** + * @return array + */ + public function getData() : array{ + $data = parent::getData(); + + // add system, connection, signature data ------------------------------------------------- + if(!empty($tempLogData = $this->getTempData())){ + $objectData['object'] = $tempLogData; + $data = $objectData + $data; + } + + // add human readable changes to string --------------------------------------------------- + $data['formatted'] = $this->formatData($data); + + return $data; + } + + /** + * @param array $data + * @return string + */ + protected function formatData(array $data): string{ + $actionParts = $this->getActionParts(); + $objectString = !empty($data['object']) ? "'" . $data['object']['objName'] . "'" . ' #' . $data['object']['objId'] : ''; + $string = ucfirst($actionParts[1]) . 'd ' . $actionParts[0] . " " . $objectString; + + // format changed columns (recursive) --------------------------------------------- + switch($actionParts[1]){ + case 'create': + case 'update': + $formatChanges = function(array $changes) use ( &$formatChanges ): string{ + $string = ''; + foreach($changes as $field => $value){ + if(is_array($value)){ + $string .= $field . ": "; + $string .= $formatChanges($value); + $string .= next( $changes ) ? " , " : ''; + }else{ + if(is_numeric($value)){ + $formattedValue = $value; + }elseif(is_null($value)){ + $formattedValue = "NULL"; + }elseif(empty($value)){ + $formattedValue = "' '"; + }elseif(is_string($value)){ + $formattedValue = "'" . $value . "'"; + }else{ + $formattedValue = (string)$value; + } + + $string .= $formattedValue; + if($field == 'old'){ + $string .= " ➜ "; + } + } + } + return $string; + }; + + $string .= ' | ' . $formatChanges($data['main']); + break; + } + + return $string; + } + + /** + * split $action "CamelCase" wise + * @return array + */ + protected function getActionParts(): array{ + return array_map('strtolower', preg_split('/(?=[A-Z])/', $this->getAction())); + } + + /** + * @param bool $logActivity + */ + public function logActivity(bool $logActivity){ + $this->logActivity = $logActivity; + } + + public function buffer(){ + parent::buffer(); + + if($this->logActivity){ + // map logs should also used for "activity" logging + LogController::instance()->push($this); + } + } + +} \ No newline at end of file diff --git a/app/main/lib/logging/RallyLog.php b/app/main/lib/logging/RallyLog.php new file mode 100644 index 000000000..b6171c3c5 --- /dev/null +++ b/app/main/lib/logging/RallyLog.php @@ -0,0 +1,105 @@ + final handler will be set dynamic for per instance + * @var array + */ + protected $handlerConfig = [ + // 'slackRally' => 'json', + // 'mail' => 'html' + ]; + + /** + * @var string + */ + protected $channelType = 'rally'; + + + public function __construct(string $action, array $objectData){ + parent::__construct($action, $objectData); + + $this->setLevel('notice'); + $this->setTag('information'); + } + + /** + * @return string + */ + protected function getThumbUrl() : string{ + $url = ''; + if(is_object($character = $this->getCharacter())){ + $characterLog = $character->getLog(); + if($characterLog && !empty($characterLog->shipTypeId)){ + $url = Config::getPathfinderData('api.ccp_image_server') . '/Render/' . $characterLog->shipTypeId . '_64.png'; + }else{ + $url = parent::getThumbUrl(); + } + } + + return $url; + } + + /** + * @return string + */ + public function getMessage() : string{ + return "*New RallyPoint system '{objName}'* _#{objId}_ *map '{channelName}'* _#{channelId}_ "; + } + + /** + * @return array + */ + public function getData() : array{ + $data = parent::getData(); + + // add system ----------------------------------------------------------------------------- + if(!empty($tempLogData = $this->getTempData())){ + $objectData['object'] = $tempLogData; + $data = $objectData + $data; + } + + // add human readable changes to string --------------------------------------------------- + $data['formatted'] =$this->formatData($data); + + return $data; + } + + /** + * @param array $data + * @return string + */ + protected function formatData(array $data): string{ + $string = ''; + + if( + !empty($data['object']) && + !empty($data['channel']) + ){ + $replace = [ + '{objName}' => $data['object']['objName'], + '{objId}' => $data['object']['objId'], + '{channelName}' => $data['channel']['channelName'], + '{channelId}' => $data['channel']['channelId'] + ]; + $string = str_replace(array_keys($replace), array_values($replace), $this->getMessage()); + } + + return $string; + } + + + +} \ No newline at end of file diff --git a/app/main/lib/logging/UserLog.php b/app/main/lib/logging/UserLog.php new file mode 100644 index 000000000..23ea3205d --- /dev/null +++ b/app/main/lib/logging/UserLog.php @@ -0,0 +1,36 @@ + final handler will be set dynamic for per instance + * @var array + */ + protected $handlerConfig = [ + // 'mail' => 'html' + ]; + + /** + * @var string + */ + protected $channelType = 'user'; + + public function __construct(string $action, array $objectData){ + parent::__construct($action, $objectData); + + $this->setLevel('notice'); + $this->setTag('information'); + } + + +} \ No newline at end of file diff --git a/app/main/lib/logging/formatter/MailFormatter.php b/app/main/lib/logging/formatter/MailFormatter.php new file mode 100644 index 000000000..8a35f5861 --- /dev/null +++ b/app/main/lib/logging/formatter/MailFormatter.php @@ -0,0 +1,46 @@ + $record['message'], + 'tplGreeting' => \Markdown::instance()->convert(str_replace('*', '', $record['message'])), + 'message' => false, + 'tplText2' => false, + 'tplClosing' => 'Fly save!', + 'actionPrimary' => false, + 'appName' => Config::getPathfinderData('name'), + 'appUrl' => Config::getEnvironmentData('URL'), + 'appHost' => $_SERVER['HTTP_HOST'], + 'appContact' => Config::getPathfinderData('contact'), + 'appMail' => Config::getPathfinderData('email'), + ]; + + $tplData = array_replace_recursive($tplDefaultData, (array)$record['context']['data']['main']); + + return \Template::instance()->render('templates/mail/basic_inline.html', 'text/html', $tplData); + } + + public function formatBatch(array $records){ + $message = ''; + foreach ($records as $key => $record) { + $message .= $this->format($record); + } + + return $message; + } + +} \ No newline at end of file diff --git a/app/main/lib/logging/handler/AbstractSlackWebhookHandler.php b/app/main/lib/logging/handler/AbstractSlackWebhookHandler.php new file mode 100644 index 000000000..e045fe68b --- /dev/null +++ b/app/main/lib/logging/handler/AbstractSlackWebhookHandler.php @@ -0,0 +1,260 @@ +webhookUrl = $webhookUrl; + $this->channel = $channel; + $this->username = $username; + $this->userIcon = trim($iconEmoji, ':'); + $this->useAttachment = $useAttachment; + $this->includeContext = $includeContext; + $this->includeExtra = $includeExtra; + $this->excludeFields = $excludeFields; + + parent::__construct($level, $bubble); + + } + + /** + * format + * @param array $record + * @return array + */ + protected function getSlackData(array $record): array { + $postData = []; + + if ($this->username) { + $postData['username'] = $this->username; + } + + if ($this->channel) { + $postData['channel'] = $this->channel; + } + + $postData['text'] = (string)$record['message']; + + if ($this->userIcon) { + if (filter_var($this->userIcon, FILTER_VALIDATE_URL)) { + $postData['icon_url'] = $this->userIcon; + } else { + $postData['icon_emoji'] = ":{$this->userIcon}:"; + } + } + + return $postData; + } + + /** + * {@inheritdoc} + * + * @param array $record + */ + protected function write(array $record){ + $record = $this->excludeFields($record); + + $postData = $this->getSlackData($record); + + $postData = $this->cleanAttachments($postData); + + $postString = json_encode($postData); + + $ch = curl_init(); + $options = [ + CURLOPT_URL => $this->webhookUrl, + CURLOPT_CUSTOMREQUEST => 'POST', + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => ['Content-type: application/json'], + CURLOPT_POSTFIELDS => $postString + ]; + if (defined('CURLOPT_SAFE_UPLOAD')) { + $options[CURLOPT_SAFE_UPLOAD] = true; + } + + curl_setopt_array($ch, $options); + + Handler\Curl\Util::execute($ch); + } + + /** + * @param array $postData + * @return array + */ + protected function cleanAttachments(array $postData): array{ + $attachmentCount = count($postData['attachments']); + if( $attachmentCount > $this->maxAttachments){ + $text = 'To many attachments! ' . ($attachmentCount - $this->maxAttachments) . ' of ' . $attachmentCount . ' attachments not visible'; + $postData['attachments'] = array_slice($postData['attachments'], 0, $this->maxAttachments); + + $attachment = [ + 'title' => $text, + 'fallback' => $text, + 'color' => $this->getAttachmentColor('information') + ]; + + $postData['attachments'][] = $attachment; + } + + return $postData; + } + + /** + * @param array $attachment + * @param array $characterData + * @return array + */ + protected function setAuthor(array $attachment, array $characterData): array { + if( !empty($characterData['id']) && !empty($characterData['name'])){ + $attachment['author_name'] = $characterData['name'] . ' #' . $characterData['id']; + $attachment['author_link'] = Config::getPathfinderData('api.z_killboard') . '/character/' . $characterData['id'] . '/'; + $attachment['author_icon'] = Config::getPathfinderData('api.ccp_image_server') . '/Character/' . $characterData['id'] . '_32.jpg'; + } + + return $attachment; + } + + /** + * @param array $attachment + * @param array $thumbData + * @return array + */ + protected function setThumb(array $attachment, array $thumbData): array { + if( !empty($thumbData['url'])) { + $attachment['thumb_url'] = $thumbData['url']; + } + + return $attachment; + } + + /** + * @param $title + * @param $value + * @param bool $format + * @param bool $short + * @return array + */ + protected function generateAttachmentField($title, $value, $format = false, $short = true){ + return [ + 'title' => $title, + 'value' => !empty($value) ? ( $format ? sprintf('`%s`', $value) : $value ) : '', + 'short' => $short + ]; + } + + /** + * @param string $tag + * @return string + */ + protected function getAttachmentColor(string $tag): string { + switch($tag){ + case 'information': $color = '#428bca'; break; + case 'success': $color = '#4f9e4f'; break; + case 'warning': $color = '#e28a0d'; break; + case 'danger': $color = '#a52521'; break; + default: $color = '#313335'; break; + } + return $color; + } + + /** + * Get a copy of record with fields excluded according to $this->excludeFields + * @param array $record + * @return array + */ + private function excludeFields(array $record){ + foreach($this->excludeFields as $field){ + $keys = explode('.', $field); + $node = &$record; + $lastKey = end($keys); + foreach($keys as $key){ + if(!isset($node[$key])){ + break; + } + if($lastKey === $key){ + unset($node[$key]); + break; + } + $node = &$node[$key]; + } + } + + return $record; + } +} \ No newline at end of file diff --git a/app/main/lib/logging/handler/SlackMapWebhookHandler.php b/app/main/lib/logging/handler/SlackMapWebhookHandler.php new file mode 100644 index 000000000..3d49aad61 --- /dev/null +++ b/app/main/lib/logging/handler/SlackMapWebhookHandler.php @@ -0,0 +1,101 @@ +getTimestamp(); + $text = ''; + + if ( + $this->useAttachment && + !empty( $attachmentsData = $record['context']['data']) + ) { + + // convert non grouped data (associative array) to multi dimensional (sequential) array + // -> see "group" records + $attachmentsData = Util::is_assoc($attachmentsData) ? [$attachmentsData] : $attachmentsData; + + $thumbData = (array)$record['extra']['thumb']; + + $postData['attachments'] = []; + + foreach($attachmentsData as $attachmentData){ + $channelData = (array)$attachmentData['channel']; + $characterData = (array)$attachmentData['character']; + $formatted = (string)$attachmentData['formatted']; + + // get "message" from $formatted + $msgParts = explode('|', $formatted, 2); + + // build main text from first Attachment (they belong to same channel) + if(!empty($channelData)){ + $text = "*Map '" . $channelData['channelName'] . "'* _#" . $channelData['channelId'] . "_ *changed*"; + } + + $attachment = [ + 'title' => !empty($msgParts[0]) ? $msgParts[0] : 'No Title', + //'pretext' => '', + 'text' => !empty($msgParts[1]) ? sprintf('```%s```', $msgParts[1]) : '', + 'fallback' => !empty($msgParts[1]) ? $msgParts[1] : 'No Fallback', + 'color' => $this->getAttachmentColor($tag), + 'fields' => [], + 'mrkdwn_in' => ['fields', 'text'], + 'footer' => 'Pathfinder API', + //'footer_icon'=> '', + 'ts' => $timestamp + ]; + + $attachment = $this->setAuthor($attachment, $characterData); + $attachment = $this->setThumb($attachment, $thumbData); + + + // set 'field' array ---------------------------------------------------------------------------------- + if ($this->includeExtra) { + $attachment['fields'][] = $this->generateAttachmentField('', 'Meta data:', false, false); + + if(!empty($record['extra']['path'])){ + $attachment['fields'][] = $this->generateAttachmentField('Path', $record['extra']['path'], true); + } + + if(!empty($tag)){ + $attachment['fields'][] = $this->generateAttachmentField('Tag', $tag, true); + } + + if(!empty($record['level_name'])){ + $attachment['fields'][] = $this->generateAttachmentField('Level', $record['level_name'], true); + } + + if(!empty($record['extra']['ip'])){ + $attachment['fields'][] = $this->generateAttachmentField('IP', $record['extra']['ip'], true); + } + } + + $postData['attachments'][] = $attachment; + } + } + + $postData['text'] = empty($text) ? $postData['text'] : $text; + + + return $postData; + } + + +} \ No newline at end of file diff --git a/app/main/lib/logging/handler/SlackRallyWebhookHandler.php b/app/main/lib/logging/handler/SlackRallyWebhookHandler.php new file mode 100644 index 000000000..be61478fa --- /dev/null +++ b/app/main/lib/logging/handler/SlackRallyWebhookHandler.php @@ -0,0 +1,135 @@ +getTimestamp(); + $text = ''; + + if ( + $this->useAttachment && + !empty( $attachmentsData = $record['context']['data']) + ){ + // convert non grouped data (associative array) to multi dimensional (sequential) array + // -> see "group" records + $attachmentsData = Util::is_assoc($attachmentsData) ? [$attachmentsData] : $attachmentsData; + + $thumbData = (array)$record['extra']['thumb']; + + $postData['attachments'] = []; + + foreach($attachmentsData as $attachmentData){ + $characterData = (array)$attachmentData['character']; + + $text = 'No Title'; + if( !empty($attachmentData['formatted']) ){ + $text = $attachmentData['formatted']; + } + + $attachment = [ + 'title' => !empty($attachmentData['main']['message']) ? 'Message' : '', + //'pretext' => '', + 'text' => !empty($attachmentData['main']['message']) ? sprintf('```%s```', $attachmentData['main']['message']) : '', + 'fallback' => !empty($attachmentData['main']['message']) ? $attachmentData['main']['message'] : 'No Fallback', + 'color' => $this->getAttachmentColor($tag), + 'fields' => [], + 'mrkdwn_in' => ['fields', 'text'], + 'footer' => 'Pathfinder API', + //'footer_icon'=> '', + 'ts' => $timestamp + ]; + + $attachment = $this->setAuthor($attachment, $characterData); + $attachment = $this->setThumb($attachment, $thumbData); + + // set 'field' array ---------------------------------------------------------------------------------- + if ($this->includeContext) { + if(!empty($objectData = $attachmentData['object'])){ + if(!empty($objectData['objAlias'])){ + // System alias + $attachment['fields'][] = $this->generateAttachmentField('Alias', $objectData['objAlias']); + } + + if(!empty($objectData['objName'])){ + // System name + $attachment['fields'][] = $this->generateAttachmentField('System', $objectData['objName']); + } + + if(!empty($objectData['objRegion'])){ + // System region + $attachment['fields'][] = $this->generateAttachmentField('Region', $objectData['objRegion']); + } + + if(isset($objectData['objIsWormhole'])){ + // Is wormhole + $attachment['fields'][] = $this->generateAttachmentField('Wormhole', $objectData['objIsWormhole'] ? 'Yes' : 'No'); + } + + if(!empty($objectData['objSecurity'])){ + // System security + $attachment['fields'][] = $this->generateAttachmentField('Security', $objectData['objSecurity']); + } + + if(!empty($objectData['objEffect'])){ + // System effect + $attachment['fields'][] = $this->generateAttachmentField('Effect', $objectData['objEffect']); + } + + if(!empty($objectData['objTrueSec'])){ + // System trueSec + $attachment['fields'][] = $this->generateAttachmentField('TrueSec', $objectData['objTrueSec']); + } + + if(!empty($objectData['objDescription'])){ + // System trueSec + $attachment['fields'][] = $this->generateAttachmentField('System description', '```' . $objectData['objDescription'] . '```', false, false); + } + } + } + + if($this->includeExtra){ + if(!empty($record['extra']['path'])){ + $attachment['fields'][] = $this->generateAttachmentField('Path', $record['extra']['path'], true); + } + + if(!empty($tag)){ + $attachment['fields'][] = $this->generateAttachmentField('Tag', $tag, true); + } + + if(!empty($record['level_name'])){ + $attachment['fields'][] = $this->generateAttachmentField('Level', $record['level_name'], true); + } + + if(!empty($record['extra']['ip'])){ + $attachment['fields'][] = $this->generateAttachmentField('IP', $record['extra']['ip'], true); + } + } + + $postData['attachments'][] = $attachment; + } + } + + $postData['text'] = empty($text) ? $postData['text'] : $text; + + return $postData; + } + + +} \ No newline at end of file diff --git a/app/main/lib/logging/handler/ZMQHandler.php b/app/main/lib/logging/handler/ZMQHandler.php new file mode 100644 index 000000000..df73d0073 --- /dev/null +++ b/app/main/lib/logging/handler/ZMQHandler.php @@ -0,0 +1,65 @@ +metaData = $metaData; + + parent::__construct($zmqSocket, $zmqMode, $multipart, $level, $bubble); + } + + /** + * overwrite default handle() + * -> change data structure after processor() calls and before formatter() calls + * @param array $record + * @return bool + * @throws \Exception + */ + public function handle(array $record){ + if (!$this->isHandling($record)) { + return false; + } + + $record = $this->processRecord($record); + + $record = [ + 'task' => 'logData', + 'load' => [ + 'meta' => $this->metaData, + 'log' => $record + ] + ]; + + $record['formatted'] = $this->getFormatter()->format($record); + + $this->write($record); + + return false === $this->bubble; + } + +} \ No newline at end of file diff --git a/app/main/lib/socket.php b/app/main/lib/socket.php index fb96c1085..98cc7e8e3 100644 --- a/app/main/lib/socket.php +++ b/app/main/lib/socket.php @@ -76,30 +76,22 @@ public function setTtl(int $ttl, int $maxRetries){ } } - /** - * init new socket - */ - /* - public function initSocket(){ - if(self::checkRequirements()){ - $context = new \ZMQContext(); - $this->socket = $context->getSocket(\ZMQ::SOCKET_REQ); - // The linger value of the socket. Specifies how long the socket blocks trying flush messages after it has been closed - $this->socket->setSockOpt(\ZMQ::SOCKOPT_LINGER, 0); - } - } */ - /** * init new socket */ public function initSocket(){ - if(self::checkRequirements()){ + if(Config::checkSocketRequirements()){ $context = new \ZMQContext(); $this->socket = $context->getSocket(\ZMQ::SOCKET_PUSH); } } - public function sendData($task, $load = ''){ + /** + * @param $task + * @param string $load + * @return bool|string + */ + public function sendData(string $task, $load = ''){ $response = false; $this->initSocket(); @@ -122,7 +114,7 @@ public function sendData($task, $load = ''){ //$this->socket->send(json_encode($send), \ZMQ::MODE_DONTWAIT); $this->socket->send(json_encode($send)); - $this->socket->disconnect($this->socketUri); + // $this->socket->disconnect($this->socketUri); $response = 'OK'; @@ -230,24 +222,5 @@ public function sendData($task, $load = ''){ return $response; }*/ - /** - * check whether this installation fulfills all requirements - * -> check for ZMQ PHP extension and installed ZQM version - * -> this does NOT check versions! -> those can be verified on /setup page - * @return bool - */ - static function checkRequirements(){ - $check = false; - - if( - extension_loaded('zmq') && - class_exists('ZMQ') - ){ - $check = true; - } - - return $check; - } - } \ No newline at end of file diff --git a/app/main/lib/util.php b/app/main/lib/util.php index 1001cf99e..c40d3080f 100644 --- a/app/main/lib/util.php +++ b/app/main/lib/util.php @@ -6,7 +6,7 @@ * Time: 17:32 */ -namespace Lib; +namespace lib; class Util { @@ -17,11 +17,44 @@ class Util { * @return array */ static function arrayChangeKeyCaseRecursive($arr, $case = CASE_LOWER){ - return array_map( function($item){ - if( is_array($item) ) - $item = self::arrayChangeKeyCaseRecursive($item); - return $item; - }, array_change_key_case($arr, $case)); + if(is_array($arr)){ + $arr = array_map( function($item){ + if( is_array($item) ) + $item = self::arrayChangeKeyCaseRecursive($item); + return $item; + }, array_change_key_case((array)$arr, $case)); + } + + return $arr; + } + + /** + * flatten multidimensional array + * -> overwrites duplicate keys! + * @param array $array + * @return array + */ + static function arrayFlatten(array $array) : array { + $return = []; + array_walk_recursive($array, function($value, $key) use (&$return) { $return[$key] = $value; }); + return $return; + } + + /** + * checks whether an array is associative or not (sequential) + * @param mixed $array + * @return bool + */ + static function is_assoc($array): bool { + $isAssoc = false; + if( + is_array($array) && + array_keys($array) !== range(0, count($array) - 1) + ){ + $isAssoc = true; + } + + return $isAssoc; } /** @@ -59,6 +92,23 @@ static function convertScopesString($scopes){ return $scopes; } + /** + * obsucre string e.g. password (hide last characters) + * @param string $string + * @param int $maxHideChars + * @return string + */ + static function obscureString(string $string, int $maxHideChars = 10): string { + $formatted = ''; + $length = mb_strlen((string)$string); + if($length > 0){ + $hideChars = ($length < $maxHideChars) ? $length : $maxHideChars; + $formatted = substr_replace($string, str_repeat('_', min(3, $length)), -$hideChars) . + ' [' . $length . ']'; + } + return $formatted; + } + /** * get hash from an array of ESI scopes * @param array $scopes diff --git a/app/main/lib/web.php b/app/main/lib/web.php index 298a6af57..bf1ff510f 100644 --- a/app/main/lib/web.php +++ b/app/main/lib/web.php @@ -6,7 +6,7 @@ * Time: 12:28 */ -namespace Lib; +namespace lib; use controller\LogController; diff --git a/app/main/model/abstractmaptrackingmodel.php b/app/main/model/abstractmaptrackingmodel.php new file mode 100644 index 000000000..5098eef40 --- /dev/null +++ b/app/main/model/abstractmaptrackingmodel.php @@ -0,0 +1,139 @@ + [ + 'type' => Schema::DT_INT, + 'index' => true, + 'belongs-to-one' => 'Model\CharacterModel', + 'constraint' => [ + [ + 'table' => 'character', + 'on-delete' => 'CASCADE' + ] + ], + 'validate' => 'validate_notDry' + ], + 'updatedCharacterId' => [ + 'type' => Schema::DT_INT, + 'index' => true, + 'belongs-to-one' => 'Model\CharacterModel', + 'constraint' => [ + [ + 'table' => 'character', + 'on-delete' => 'CASCADE' + ] + ], + 'validate' => 'validate_notDry' + ] + ]; + + /** + * get static character fields for this model instance + * @return array + */ + protected function getStaticFieldConf(): array{ + return array_merge(parent::getStaticFieldConf(), $this->trackingFieldConf); + } + + /** + * validates a model field to be a valid relational model + * @param $key + * @param $val + * @return bool + */ + protected function validate_notDry($key, $val): bool { + $valid = true; + if($colConf = $this->fieldConf[$key]){ + if(isset($colConf['belongs-to-one'])){ + if( (is_int($val) || ctype_digit($val)) && (int)$val > 0){ + $valid = true; + }elseif( is_a($val, $colConf['belongs-to-one']) && !$val->dry() ){ + $valid = true; + }else{ + $valid = false; + $msg = 'Validation failed: "' . get_class($this) . '->' . $key . '" must be a valid instance of ' . $colConf['belongs-to-one']; + $this->throwValidationException($key, $msg); + } + } + } + + return $valid; + } + + /** + * log character activity create/update/delete events + * @param string $action + */ + protected function logActivity($action){ + // check if activity logging is enabled for this object + if($this->enableActivityLogging){ + // check for field changes + if( + mb_stripos(mb_strtolower($action), 'delete') !== false || + !empty($this->fieldChanges) + ){ + $this->newLog($action)->setCharacter($this->updatedCharacterId)->setData($this->fieldChanges)->buffer(); + } + } + } + + /** + * validates all required columns of this class + * @return bool + * @throws \Exception\ValidationException + */ + public function isValid(): bool { + if($valid = parent::isValid()){ + foreach($this->trackingFieldConf as $key => $colConf){ + if($this->exists($key)){ + $valid = $this->validateField($key, $this->$key); + if(!$valid){ + break; + } + }else{ + $valid = false; + $this->throwDbException('Missing table column "' . $this->getTable(). '.' . $key . '"'); + break; + } + } + } + + return $valid; + } + + /** + * get log file data + * @return array + */ + public function getLogData(): array { + return []; + } + + + /** + * save connection + * @param CharacterModel $characterModel + * @return ConnectionModel|false + */ + public function save(CharacterModel $characterModel = null){ + if($this->dry()){ + $this->createdCharacterId = $characterModel; + } + $this->updatedCharacterId = $characterModel; + + return parent::save(); + } + +} \ No newline at end of file diff --git a/app/main/model/activitylogmodel.php b/app/main/model/activitylogmodel.php index 19a32b791..bcee4e710 100644 --- a/app/main/model/activitylogmodel.php +++ b/app/main/model/activitylogmodel.php @@ -44,22 +44,46 @@ class ActivityLogModel extends BasicModel { ] ], + // map actions ----------------------------------------------------- + + 'mapCreate' => [ + 'type' => Schema::DT_SMALLINT, + 'nullable' => false, + 'default' => 0, + 'counter' => true + ], + 'mapUpdate' => [ + 'type' => Schema::DT_SMALLINT, + 'nullable' => false, + 'default' => 0, + 'counter' => true + ], + 'mapDelete' => [ + 'type' => Schema::DT_SMALLINT, + 'nullable' => false, + 'default' => 0, + 'counter' => true + ], + // system actions ----------------------------------------------------- 'systemCreate' => [ 'type' => Schema::DT_SMALLINT, 'nullable' => false, 'default' => 0, + 'counter' => true ], 'systemUpdate' => [ 'type' => Schema::DT_SMALLINT, 'nullable' => false, 'default' => 0, + 'counter' => true ], 'systemDelete' => [ 'type' => Schema::DT_SMALLINT, 'nullable' => false, 'default' => 0, + 'counter' => true ], // connection actions ------------------------------------------------- @@ -68,16 +92,19 @@ class ActivityLogModel extends BasicModel { 'type' => Schema::DT_SMALLINT, 'nullable' => false, 'default' => 0, + 'counter' => true ], 'connectionUpdate' => [ 'type' => Schema::DT_SMALLINT, 'nullable' => false, 'default' => 0, + 'counter' => true ], 'connectionDelete' => [ 'type' => Schema::DT_SMALLINT, 'nullable' => false, 'default' => 0, + 'counter' => true ], // signature actions ------------------------------------------------- @@ -86,16 +113,19 @@ class ActivityLogModel extends BasicModel { 'type' => Schema::DT_SMALLINT, 'nullable' => false, 'default' => 0, + 'counter' => true ], 'signatureUpdate' => [ 'type' => Schema::DT_SMALLINT, 'nullable' => false, 'default' => 0, + 'counter' => true ], 'signatureDelete' => [ 'type' => Schema::DT_SMALLINT, 'nullable' => false, 'default' => 0, + 'counter' => true ], ]; @@ -128,6 +158,20 @@ private function addStaticDateFieldConfig(){ } } + /** + * get all table columns that are used as "counter" columns + * @return array + */ + public function getCountableColumnNames(): array { + $fieldConf = $this->getFieldConfiguration(); + + $filterCounterColumns = function($key, $value){ + return isset($value['counter']) ? $key : false; + }; + + return array_values(array_filter(array_map($filterCounterColumns, array_keys($fieldConf), $fieldConf))); + } + /** * overwrites parent * @param null $db diff --git a/app/main/model/alliancemodel.php b/app/main/model/alliancemodel.php index d5bca1322..23bcb7035 100644 --- a/app/main/model/alliancemodel.php +++ b/app/main/model/alliancemodel.php @@ -9,6 +9,7 @@ namespace Model; use DB\SQL\Schema; +use lib\Config; class AllianceModel extends BasicModel { @@ -60,8 +61,6 @@ public function getData(){ public function getMaps(){ $maps = []; - $f3 = self::getF3(); - $this->filter('mapAlliances', ['active = ?', 1], ['order' => 'created'] @@ -72,7 +71,7 @@ public function getMaps(){ foreach($this->mapAlliances as $mapAlliance){ if( $mapAlliance->mapId->isActive() && - $mapCount < $f3->get('PATHFINDER.MAP.ALLIANCE.MAX_COUNT') + $mapCount < Config::getMapsDefaultConfig('alliance')['max_count'] ){ $maps[] = $mapAlliance->mapId; $mapCount++; diff --git a/app/main/model/basicmodel.php b/app/main/model/basicmodel.php index 272080f71..ba946bfa0 100644 --- a/app/main/model/basicmodel.php +++ b/app/main/model/basicmodel.php @@ -9,9 +9,11 @@ namespace Model; use DB\SQL\Schema; -use Exception; use Controller; use DB; +use lib\logging; +use Exception\ValidationException; +use Exception\DatabaseException; abstract class BasicModel extends \DB\Cortex { @@ -19,7 +21,7 @@ abstract class BasicModel extends \DB\Cortex { * Hive key with DB object * @var string */ - protected $db = 'DB_PF'; + protected $db = 'DB_PF'; /** * caching time of field schema - seconds @@ -27,26 +29,20 @@ abstract class BasicModel extends \DB\Cortex { * -> leave this at a higher value * @var int */ - protected $ttl = 120; + protected $ttl = 60; /** * caching for relational data * @var int */ - protected $rel_ttl = 0; + protected $rel_ttl = 0; /** * ass static columns for this table * -> can be overwritten in child models * @var bool */ - protected $addStaticFields = true; - - /** - * field validation array - * @var array - */ - protected $validate = []; + protected $addStaticFields = true; /** * enables check for $fieldChanges on update/insert @@ -54,7 +50,7 @@ abstract class BasicModel extends \DB\Cortex { * in $fieldConf config * @var bool */ - protected $enableActivityLogging = true; + protected $enableActivityLogging = true; /** * enables change for "active" column @@ -62,40 +58,53 @@ abstract class BasicModel extends \DB\Cortex { * -> $this->active = false; will NOT work (prevent abuse)! * @var bool */ - private $allowActiveChange = false; + private $allowActiveChange = false; /** * getData() cache key prefix * -> do not change, otherwise cached data is lost * @var string */ - private $dataCacheKeyPrefix = 'DATACACHE'; + private $dataCacheKeyPrefix = 'DATACACHE'; /** * enables data export for this table * -> can be overwritten in child models * @var bool */ - public static $enableDataExport = false; + public static $enableDataExport = false; /** * enables data import for this table * -> can be overwritten in child models * @var bool */ - public static $enableDataImport = false; + public static $enableDataImport = false; /** * changed fields (columns) on update/insert * -> e.g. for character "activity logging" * @var array */ - protected $fieldChanges = []; + protected $fieldChanges = []; /** - * default TTL for getData(); cache + * collection for validation errors + * @var array */ - const DEFAULT_CACHE_TTL = 120; + protected $validationError = []; + + /** + * default caching time of field schema - seconds + */ + const DEFAULT_TTL = 86400; + + /** + * default TTL for getData(); cache - seconds + */ + const DEFAULT_CACHE_TTL = 120; + + const ERROR_INVALID_MODEL_CLASS = 'Model class (%s) not found'; public function __construct($db = NULL, $table = NULL, $fluid = NULL, $ttl = 0){ @@ -135,8 +144,8 @@ public function __construct($db = NULL, $table = NULL, $fluid = NULL, $ttl = 0){ /** * @param string $key * @param mixed $val - * @return mixed|void - * @throws Exception\ValidationException + * @return mixed + * @throws ValidationException */ public function set($key, $val){ if( @@ -167,15 +176,13 @@ public function set($key, $val){ $val = trim($val); } - $valid = $this->validateField($key, $val); - - if(!$valid){ - $this->throwValidationError($key); + if( !$this->validateField($key, $val) ){ + $this->throwValidationException($key); }else{ $this->checkFieldForActivityLogging($key, $val); - - return parent::set($key, $val); } + + return parent::set($key, $val); } /** @@ -206,15 +213,25 @@ protected function checkFieldForActivityLogging($key, $val){ $val = (int)$val; } + if(is_object($val)){ + $val = $val->_id; + } + if( $fieldConf['type'] === self::DT_JSON){ $currentValue = $this->get($key); }else{ $currentValue = $this->get($key, true); } + if($currentValue !== $val){ // field has changed - in_array($key, $this->fieldChanges) ?: $this->fieldChanges[] = $key; + if( !array_key_exists($key, $this->fieldChanges) ){ + $this->fieldChanges[$key] = [ + 'old' => $currentValue, + 'new' => $val + ]; + } } } } @@ -239,11 +256,12 @@ public function set_active($active){ } /** - * extent the fieldConf Array with static fields for each table + * get static fields for this model instance + * @return array */ - private function addStaticFieldConfig(){ + protected function getStaticFieldConf(): array { + $staticFieldConfig = []; - // add static fields to this mapper // static tables (fixed data) do not require them... if($this->addStaticFields){ $staticFieldConfig = [ @@ -258,61 +276,37 @@ private function addStaticFieldConfig(){ 'index' => true ] ]; + } + return $staticFieldConfig; + } - $this->fieldConf = array_merge($staticFieldConfig, $this->fieldConf); - } + /** + * extent the fieldConf Array with static fields for each table + */ + private function addStaticFieldConfig(){ + $this->fieldConf = array_merge($this->getStaticFieldConf(), $this->fieldConf); } /** * validates a table column based on validation settings - * @param $col + * @param string $key * @param $val * @return bool */ - private function validateField($col, $val){ + protected function validateField(string $key, $val): bool { $valid = true; - - if(array_key_exists($col, $this->validate)){ - - $fieldValidationOptions = $this->validate[$col]; - - foreach($fieldValidationOptions as $validateKey => $validateOption ){ - if(is_array($fieldValidationOptions[$validateKey])){ - $fieldSubValidationOptions = $fieldValidationOptions[$validateKey]; - - foreach($fieldSubValidationOptions as $validateSubKey => $validateSubOption ){ - switch($validateKey){ - case 'length': - switch($validateSubKey){ - case 'min'; - if(strlen($val) < $validateSubOption){ - $valid = false; - } - break; - case 'max'; - - if(strlen($val) > $validateSubOption){ - $valid = false; - } - break; - } - break; - } - } - - }else{ - switch($validateKey){ - case 'regex': - $valid = (bool)preg_match($fieldValidationOptions[$validateKey], $val); - break; - } - } - - // a validation rule failed - if(!$valid){ - break; + if($fieldConf = $this->fieldConf[$key]){ + if($method = $this->fieldConf[$key]['validate']){ + if( !is_string($method)){ + $method = 'validate_' . $key; } + if(method_exists($this, $method)){ + // validate $key (column) with this method... + $valid = $this->$method($key, $val); + }else{ + self::getF3()->error(501, 'Method ' . get_class($this) . '->' . $method . '() is not implemented'); + }; } } @@ -428,13 +422,22 @@ private function clearCache($cacheKey){ } /** - * Throws a validation error for a giben column - * @param $col - * @throws \Exception\ValidationException + * throw validation exception for a model property + * @param string $col + * @param string $msg + * @throws ValidationException */ - protected function throwValidationError($col){ - throw new Exception\ValidationException('Validation failed: "' . $col . '".', $col); + protected function throwValidationException(string $col, string $msg = ''){ + $msg = empty($msg) ? 'Validation failed: "' . $col . '".' : $msg; + throw new ValidationException($msg, $col); + } + /** + * @param string $msg + * @throws DatabaseException + */ + protected function throwDbException(string $msg){ + throw new DatabaseException($msg); } /** @@ -458,11 +461,11 @@ protected function setUpdated(){ * get single dataSet by id * @param $id * @param int $ttl + * @param bool $isActive * @return \DB\Cortex */ - public function getById($id, $ttl = 3) { - - return $this->getByForeignKey('id', (int)$id, ['limit' => 1], $ttl); + public function getById(int $id, int $ttl = 3, bool $isActive = true){ + return $this->getByForeignKey('id', (int)$id, ['limit' => 1], $ttl, $isActive); } /** @@ -492,10 +495,10 @@ public function setActive($active){ * @param $value * @param array $options * @param int $ttl + * @param bool $isActive * @return \DB\Cortex */ - public function getByForeignKey($key, $value, $options = [], $ttl = 60){ - + public function getByForeignKey($key, $value, $options = [], $ttl = 0, $isActive = true){ $querySet = []; $query = []; if($this->exists($key)){ @@ -504,7 +507,7 @@ public function getByForeignKey($key, $value, $options = [], $ttl = 60){ } // check active column - if($this->exists('active')){ + if($isActive && $this->exists('active')){ $query[] = "active = :active"; $querySet[':active'] = 1; } @@ -594,7 +597,7 @@ public function hasAccess(CharacterModel $characterModel){ * function should be overwritten in parent classes * @return bool */ - public function isValid(){ + public function isValid(): bool { return true; } @@ -678,7 +681,7 @@ public function importData(){ $status = $this->importStaticData($tableData); $this->getF3()->status(202); }else{ - $this->getF3()->error(502, 'File could not be read'); + $this->getF3()->error(500, 'File could not be read'); } }else{ $this->getF3()->error(404, 'File not found: ' . $filePath); @@ -727,15 +730,54 @@ protected function importStaticData($tableData = []){ } /** - * buffer a new activity (action) logging - * -> increment buffered counter - * -> log character activity create/update/delete events - * @param int $characterId - * @param int $mapId + * get "default" logging object for this kind of model + * -> can be overwritten * @param string $action + * @return Logging\LogInterface + */ + protected function newLog($action = ''): Logging\LogInterface{ + return new Logging\DefaultLog($action); + } + + /** + * get formatter callback function for parsed logs + * @return null + */ + protected function getLogFormatter(){ + return null; + } + + /** + * add new validation error + * @param ValidationException $e + */ + protected function setValidationError(ValidationException $e){ + $this->validationError[] = $e->getError(); + } + + /** + * get all validation errors + * @return array + */ + public function getErrors(): array { + return $this->validationError; + } + + public function save(){ + try{ + return parent::save(); + }catch(ValidationException $e){ + $this->setValidationError($e); + }catch(DatabaseException $e){ + self::getF3()->error($e->getCode(), $e->getMessage(), $e->getTrace()); + } + } + + /** + * @return string */ - protected function bufferActivity($characterId, $mapId, $action){ - Controller\LogController::instance()->bufferActivity($characterId, $mapId, $action); + public function __toString(){ + return $this->getTable(); } /** @@ -755,14 +797,14 @@ public static function getClassName(){ * @return BasicModel * @throws \Exception */ - public static function getNew($model, $ttl = 86400){ + public static function getNew($model, $ttl = self::DEFAULT_TTL){ $class = null; $model = '\\' . __NAMESPACE__ . '\\' . $model; if(class_exists($model)){ $class = new $model( null, null, null, $ttl ); }else{ - throw new \Exception('No model class found'); + throw new \Exception(sprintf(self::ERROR_INVALID_MODEL_CLASS, $model)); } return $class; diff --git a/app/main/model/characterlogmodel.php b/app/main/model/characterlogmodel.php index 1411a5bc9..a7c4b3bf4 100644 --- a/app/main/model/characterlogmodel.php +++ b/app/main/model/characterlogmodel.php @@ -57,6 +57,11 @@ class CharacterLogModel extends BasicModel { 'type' => Schema::DT_BIGINT, 'index' => true ], + 'shipMass' => [ + 'type' => Schema::DT_FLOAT, + 'nullable' => false, + 'default' => 0 + ], 'shipName' => [ 'type' => Schema::DT_VARCHAR128, 'nullable' => false, @@ -70,6 +75,15 @@ class CharacterLogModel extends BasicModel { 'type' => Schema::DT_VARCHAR128, 'nullable' => false, 'default' => '' + ], + 'structureId' => [ + 'type' => Schema::DT_BIGINT, + 'index' => true + ], + 'structureName' => [ + 'type' => Schema::DT_VARCHAR128, + 'nullable' => false, + 'default' => '' ] ]; @@ -92,11 +106,13 @@ public function setData($logData){ $this->shipTypeName = $logData['ship']['typeName']; $this->shipId = (int)$logData['ship']['id']; $this->shipName = $logData['ship']['name']; + $this->shipMass = (float)$logData['ship']['mass']; }else{ $this->shipTypeId = null; $this->shipTypeName = ''; $this->shipId = null; $this->shipName = ''; + $this->shipMass = 0; } if( isset($logData['station']) ){ @@ -107,6 +123,14 @@ public function setData($logData){ $this->stationName = ''; } + if( isset($logData['structure']) ){ + $this->structureId = (int)$logData['structure']['id']; + $this->structureName = $logData['structure']['name']; + }else{ + $this->structureId = null; + $this->structureName = ''; + } + } /** @@ -125,11 +149,16 @@ public function getData(){ $logData->ship->typeName = $this->shipTypeName; $logData->ship->id = $this->shipId; $logData->ship->name = $this->shipName; + $logData->ship->mass = $this->shipMass; $logData->station = (object) []; $logData->station->id = (int)$this->stationId; $logData->station->name = $this->stationName; + $logData->structure = (object) []; + $logData->structure->id = (int)$this->structureId; + $logData->structure->name = $this->structureName; + return $logData; } @@ -191,23 +220,26 @@ public function clearCacheData(){ * update session data for active character * @param int $systemId */ - protected function updateCharacterSessionLocation($systemId){ + protected function updateCharacterSessionLocation(int $systemId){ $controller = new Controller(); if( !empty($sessionCharacter = $controller->getSessionCharacterData()) && $sessionCharacter['ID'] === $this->get('characterId', true) ){ - $prevSystemId = (int)$sessionCharacter['PREV_SYSTEM_ID']; - - if($prevSystemId === 0){ + $systemChanged = false; + if((int)$sessionCharacter['PREV_SYSTEM_ID'] === 0){ $sessionCharacter['PREV_SYSTEM_ID'] = (int)$systemId; - }else{ + $systemChanged = true; + }elseif((int)$sessionCharacter['PREV_SYSTEM_ID'] !== $this->systemId){ $sessionCharacter['PREV_SYSTEM_ID'] = $this->systemId; + $systemChanged = true; } - $sessionCharacters = CharacterModel::mergeSessionCharacterData([$sessionCharacter]); - $this->getF3()->set(User::SESSION_KEY_CHARACTERS, $sessionCharacters); + if($systemChanged){ + $sessionCharacters = CharacterModel::mergeSessionCharacterData([$sessionCharacter]); + $this->getF3()->set(User::SESSION_KEY_CHARACTERS, $sessionCharacters); + } } } diff --git a/app/main/model/charactermodel.php b/app/main/model/charactermodel.php index 0259fc6e5..890479be6 100644 --- a/app/main/model/charactermodel.php +++ b/app/main/model/charactermodel.php @@ -11,7 +11,9 @@ use Controller\Ccp\Sso as Sso; use Controller\Api\User as User; use DB\SQL\Schema; -use Lib\Util; +use lib\Util; +use lib\Config; +use Model\Universe; class CharacterModel extends BasicModel { @@ -279,7 +281,7 @@ public function set_kicked($minutes){ if($minutes){ $seconds = $minutes * 60; - $timezone = new \DateTimeZone( self::getF3()->get('TZ') ); + $timezone = self::getF3()->get('getTimeZone')(); $kickedUntil = new \DateTime('now', $timezone); // add cookie expire time @@ -306,7 +308,7 @@ public function set_banned($status){ $banned = null; if($status){ - $timezone = new \DateTimeZone( self::getF3()->get('TZ') ); + $timezone = self::getF3()->get('getTimeZone')(); $bannedSince = new \DateTime('now', $timezone); $banned = $bannedSince->format('Y-m-d H:i:s'); } @@ -479,7 +481,7 @@ public function getAccessToken(){ !empty($this->crestAccessToken) && !empty($this->crestAccessTokenUpdated) ){ - $timezone = new \DateTimeZone( self::getF3()->get('TZ') ); + $timezone = self::getF3()->get('getTimeZone')(); $tokenTime = \DateTime::createFromFormat( 'Y-m-d H:i:s', $this->crestAccessTokenUpdated, @@ -547,8 +549,8 @@ public function isAuthorized(){ if(is_null($this->banned)){ if( !$this->isKicked() ){ $f3 = self::getF3(); - $whitelistCorporations = array_filter( array_map('trim', (array)$f3->get('PATHFINDER.LOGIN.CORPORATION') ) ); - $whitelistAlliance = array_filter( array_map('trim', (array)$f3->get('PATHFINDER.LOGIN.ALLIANCE') ) ); + $whitelistCorporations = array_filter( array_map('trim', (array)Config::getPathfinderData('login.corporation') ) ); + $whitelistAlliance = array_filter( array_map('trim', (array)Config::getPathfinderData('login.alliance') ) ); if( empty($whitelistCorporations) && @@ -676,9 +678,7 @@ public function updateLog($additionalOptions = []){ if( !empty($locationData['system']['id']) ){ // character is currently in-game - // IDs for "systemId", "stationId and "shipTypeId" that require more data - $lookupIds = []; - + // get current $characterLog or get new --------------------------------------------------- if( !($characterLog = $this->getLog()) ){ // create new log $characterLog = $this->rel('characterLog'); @@ -687,12 +687,17 @@ public function updateLog($additionalOptions = []){ // get current log data and modify on change $logData = json_decode(json_encode( $characterLog->getData()), true); + // check system and station data for changes ---------------------------------------------- + + // IDs for "systemId", "stationId" that require more data + $lookupUniverseIds = []; + if( empty($logData['system']['name']) || $logData['system']['id'] !== $locationData['system']['id'] ){ // system changed -> request "system name" for current system - $lookupIds[] = $locationData['system']['id']; + $lookupUniverseIds[] = $locationData['system']['id']; } if( !empty($locationData['station']['id']) ){ @@ -701,7 +706,7 @@ public function updateLog($additionalOptions = []){ $logData['station']['id'] !== $locationData['station']['id'] ){ // station changed -> request "station name" for current station - $lookupIds[] = $locationData['station']['id']; + $lookupUniverseIds[] = $locationData['station']['id']; } }else{ unset($logData['station']); @@ -709,30 +714,10 @@ public function updateLog($additionalOptions = []){ $logData = array_replace_recursive($logData, $locationData); - // get current ship data - $shipData = self::getF3()->ccpClient->getCharacterShipData($this->_id, $accessToken, $additionalOptions); - - if( !empty($shipData['ship']['typeId']) ){ - if( - empty($logData['ship']['typeName']) || - $logData['ship']['typeId'] !== $shipData['ship']['typeId'] - ){ - // ship changed -> request "station name" for current station - $lookupIds[] = $shipData['ship']['typeId']; - } - - // "shipName"/"shipId" could have changed... - $logData = array_replace_recursive($logData, $shipData); - }else{ - // ship data should never be empty -> keep current one - //unset($logData['ship']); - $invalidResponse = true; - } - - if( !empty($lookupIds) ){ + // get "more" data for systemId and/or stationId ----------------------------------------- + if( !empty($lookupUniverseIds) ){ // get "more" information for some Ids (e.g. name) - $universeData = self::getF3()->ccpClient->getUniverseNamesData($lookupIds, $additionalOptions); - + $universeData = self::getF3()->ccpClient->getUniverseNamesData($lookupUniverseIds, $additionalOptions); if( !empty($universeData) ){ $logData = array_replace_recursive($logData, $universeData); }else{ @@ -741,6 +726,79 @@ public function updateLog($additionalOptions = []){ } } + // check structure data for changes ------------------------------------------------------- + if(!$deleteLog){ + + // IDs for "structureId" that require more data + $lookupStructureId = 0; + if( !empty($locationData['structure']['id']) ){ + if( + empty($logData['structure']['name']) || + $logData['structure']['id'] !== $locationData['structure']['id'] + ){ + // structure changed -> request "structure name" for current station + $lookupStructureId = $locationData['structure']['id']; + } + }else{ + unset($logData['structure']); + } + + // get "more" data for structureId --------------------------------------------------- + if($lookupStructureId > 0){ + /** + * @var $structureModel Universe\StructureModel + */ + $structureModel = Universe\BasicUniverseModel::getNew('StructureModel'); + $structureModel->loadById($lookupStructureId, $accessToken, $additionalOptions); + if(!$structureModel->dry()){ + $structureData['structure'] = (array)$structureModel->getData(); + $logData = array_replace_recursive($logData, $structureData); + }else{ + unset($logData['structure']); + } + } + } + + // check ship data for changes ------------------------------------------------------------ + if( !$deleteLog ){ + $shipData = self::getF3()->ccpClient->getCharacterShipData($this->_id, $accessToken, $additionalOptions); + + // IDs for "shipTypeId" that require more data + $lookupShipTypeId = 0; + if( !empty($shipData['ship']['typeId']) ){ + if( + empty($logData['ship']['typeName']) || + $logData['ship']['typeId'] !== $shipData['ship']['typeId'] + ){ + // ship changed -> request "station name" for current station + $lookupShipTypeId = $shipData['ship']['typeId']; + } + + // "shipName"/"shipId" could have changed... + $logData = array_replace_recursive($logData, $shipData); + }else{ + // ship data should never be empty -> keep current one + //unset($logData['ship']); + $invalidResponse = true; + } + + // get "more" data for shipTypeId ---------------------------------------------------- + if($lookupShipTypeId > 0){ + /** + * @var $typeModel Universe\TypeModel + */ + $typeModel = Universe\BasicUniverseModel::getNew('TypeModel'); + $typeModel->loadById($lookupShipTypeId, '', $additionalOptions); + if(!$typeModel->dry()){ + $shipData['ship'] = (array)$typeModel->getShipData(); + $logData = array_replace_recursive($logData, $shipData); + }else{ + // this is important! ship data is a MUST HAVE! + $deleteLog = true; + } + } + } + if( !$deleteLog ){ // mark log as "updated" even if no changes were made if($additionalOptions['markUpdated'] === true){ @@ -921,7 +979,7 @@ public function getMaps(){ $mapCountPrivate = 0; foreach($this->characterMaps as $characterMap){ if( - $mapCountPrivate < self::getF3()->get('PATHFINDER.MAP.PRIVATE.MAX_COUNT') && + $mapCountPrivate < Config::getMapsDefaultConfig('private')['max_count'] && $characterMap->mapId->isActive() ){ $maps[] = $characterMap->mapId; @@ -934,11 +992,19 @@ public function getMaps(){ } /** - * character logout - * -> clear authentication data + * delete current location */ - public function logout(){ - if( is_object($this->characterAuthentications) ){ + protected function deleteLog(){ + if($characterLog = $this->getLog()){ + $characterLog->erase(); + } + } + + /** + * delete authentications data + */ + protected function deleteAuthentications(){ + if(is_object($this->characterAuthentications)){ foreach($this->characterAuthentications as $characterAuthentication){ /** * @var $characterAuthentication CharacterAuthenticationModel @@ -947,6 +1013,40 @@ public function logout(){ } } } + /** + * character logout + * @param bool $deleteLog + * @param bool $deleteSession + * @param bool $deleteCookie + */ + public function logout(bool $deleteSession = true, bool $deleteLog = true, bool $deleteCookie = false){ + // delete current session data -------------------------------------------------------------------------------- + if($deleteSession){ + $sessionCharacterData = (array)$this->getF3()->get(User::SESSION_KEY_CHARACTERS); + $sessionCharacterData = array_filter($sessionCharacterData, function($data){ + return ($data['ID'] != $this->_id); + }); + + if(empty($sessionCharacterData)){ + // no active characters logged in -> log user out + $this->getF3()->clear(User::SESSION_KEY_USER); + $this->getF3()->clear(User::SESSION_KEY_CHARACTERS); + }else{ + // update remaining active characters + $this->getF3()->set(User::SESSION_KEY_CHARACTERS, $sessionCharacterData); + } + } + + // delete current location data ------------------------------------------------------------------------------- + if($deleteLog){ + $this->deleteLog(); + } + + // delete auth cookie data ------------------------------------------------------------------------------------ + if($deleteCookie ){ + $this->deleteAuthentications(); + } + } /** * merges two multidimensional characterSession arrays by checking characterID diff --git a/app/main/model/connectionmodel.php b/app/main/model/connectionmodel.php index 29f77e255..ec7cf2a1d 100644 --- a/app/main/model/connectionmodel.php +++ b/app/main/model/connectionmodel.php @@ -9,10 +9,10 @@ namespace Model; use DB\SQL\Schema; -use Controller; use Controller\Api\Route; +use lib\logging; -class ConnectionModel extends BasicModel{ +class ConnectionModel extends AbstractMapTrackingModel { protected $table = 'connection'; @@ -79,10 +79,16 @@ class ConnectionModel extends BasicModel{ /** * set an array with all data for a system - * @param $systemData + * @param array $data */ - public function setData($systemData){ - foreach((array)$systemData as $key => $value){ + public function setData($data){ + unset($data['id']); + unset($data['created']); + unset($data['updated']); + unset($data['createdCharacterId']); + unset($data['updatedCharacterId']); + + foreach((array)$data as $key => $value){ if( !is_array($value) ){ if( $this->exists($key) ){ $this->$key = $value; @@ -100,7 +106,6 @@ public function setData($systemData){ * @return \stdClass */ public function getData($addSignatureData = false){ - $connectionData = (object) []; $connectionData->id = $this->id; $connectionData->source = $this->source->id; @@ -189,21 +194,21 @@ public function isWormhole(){ * check whether this model is valid or not * @return bool */ - public function isValid(){ - $isValid = true; - - // check if source/target system are not equal - // check if source/target belong to same map - if( - is_object($this->source) && - is_object($this->target) && - $this->get('source', true) === $this->get('target', true) || - $this->source->get('mapId', true) !== $this->target->get('mapId', true) - ){ - $isValid = false; + public function isValid(): bool { + if($valid = parent::isValid()){ + // check if source/target system are not equal + // check if source/target belong to same map + if( + is_object($this->source) && + is_object($this->target) && + $this->get('source', true) === $this->get('target', true) || + $this->source->get('mapId', true) !== $this->target->get('mapId', true) + ){ + $valid = false; + } } - return $isValid; + return $valid; } /** @@ -218,7 +223,6 @@ public function beforeInsertEvent($self, $pkeys){ // check for "default" connection type and add them if missing // -> get() with "true" returns RAW data! important for JSON table column check! $types = (array)json_decode( $this->get('type', true) ); - if( !$this->scope || empty($types) @@ -226,7 +230,7 @@ public function beforeInsertEvent($self, $pkeys){ $this->setDefaultTypeData(); } - return parent::beforeInsertEvent($self, $pkeys); + return $this->isValid() ? parent::beforeInsertEvent($self, $pkeys) : false; } /** @@ -263,35 +267,18 @@ public function afterEraseEvent($self, $pkeys){ } /** - * log character activity create/update/delete events * @param string $action + * @return Logging\LogInterface */ - protected function logActivity($action){ - - if( - $this->enableActivityLogging && - ( - $action === 'connectionDelete' || - !empty($this->fieldChanges) - ) && - $this->get('mapId')->isActivityLogEnabled() - ){ - // TODO implement "dependency injection" for active character object... - $controller = new Controller\Controller(); - $currentActiveCharacter = $controller->getCharacter(); - $characterId = is_null($currentActiveCharacter) ? 0 : $currentActiveCharacter->_id; - $mapId = $this->get('mapId', true); - - parent::bufferActivity($characterId, $mapId, $action); - } + public function newLog($action = ''): Logging\LogInterface{ + return $this->getMap()->newLog($action)->setTempData($this->getLogObjectData()); } /** - * save connection and check if obj is valid - * @return ConnectionModel|false + * @return MapModel */ - public function save(){ - return ( $this->isValid() ) ? parent::save() : false; + public function getMap(): MapModel{ + return $this->get('mapId'); } /** @@ -307,6 +294,17 @@ public function delete(CharacterModel $characterModel){ } } + /** + * get object relevant data for model log + * @return array + */ + public function getLogObjectData() : array{ + return [ + 'objId' => $this->_id, + 'objName' => $this->scope + ]; + } + /** * see parent */ diff --git a/app/main/model/corporationmodel.php b/app/main/model/corporationmodel.php index 02cfc7894..a503d7df1 100644 --- a/app/main/model/corporationmodel.php +++ b/app/main/model/corporationmodel.php @@ -9,6 +9,7 @@ namespace Model; use DB\SQL\Schema; +use lib\Config; class CorporationModel extends BasicModel { @@ -131,8 +132,6 @@ public function getData(){ public function getMaps(){ $maps = []; - $f3 = self::getF3(); - $this->filter('mapCorporations', ['active = ?', 1], ['order' => 'created'] @@ -143,7 +142,7 @@ public function getMaps(){ foreach($this->mapCorporations as $mapCorporation){ if( $mapCorporation->mapId->isActive() && - $mapCount < $f3->get('PATHFINDER.MAP.CORPORATION.MAX_COUNT') + $mapCount < Config::getMapsDefaultConfig('corporation')['max_count'] ){ $maps[] = $mapCorporation->mapId; $mapCount++; diff --git a/app/main/model/logmodelinterface.php b/app/main/model/logmodelinterface.php new file mode 100644 index 000000000..cca816aaf --- /dev/null +++ b/app/main/model/logmodelinterface.php @@ -0,0 +1,19 @@ + [ @@ -31,7 +32,7 @@ class MapModel extends BasicModel { 'nullable' => false, 'default' => 1, 'index' => true, - 'after' => 'updated' + 'activity-log' => true ], 'scopeId' => [ 'type' => Schema::DT_INT, @@ -42,7 +43,9 @@ class MapModel extends BasicModel { 'table' => 'map_scope', 'on-delete' => 'CASCADE' ] - ] + ], + 'validate' => 'validate_notDry', + 'activity-log' => true ], 'typeId' => [ 'type' => Schema::DT_INT, @@ -53,32 +56,82 @@ class MapModel extends BasicModel { 'table' => 'map_type', 'on-delete' => 'CASCADE' ] - ] + ], + 'validate' => 'validate_notDry', + 'activity-log' => true ], 'name' => [ 'type' => Schema::DT_VARCHAR128, 'nullable' => false, - 'default' => '' + 'default' => '', + 'activity-log' => true, + 'validate' => true ], 'icon' => [ 'type' => Schema::DT_VARCHAR128, 'nullable' => false, - 'default' => '' + 'default' => '', + 'activity-log' => true ], 'deleteExpiredConnections' => [ 'type' => Schema::DT_BOOL, 'nullable' => false, - 'default' => 1 + 'default' => 1, + 'activity-log' => true ], 'deleteEolConnections' => [ 'type' => Schema::DT_BOOL, 'nullable' => false, - 'default' => 1 + 'default' => 1, + 'activity-log' => true ], 'persistentAliases' => [ 'type' => Schema::DT_BOOL, 'nullable' => false, - 'default' => 1 + 'default' => 1, + 'activity-log' => true + ], + 'logActivity' => [ + 'type' => Schema::DT_BOOL, + 'nullable' => false, + 'default' => 1, + 'activity-log' => true + ], + 'logHistory' => [ + 'type' => Schema::DT_BOOL, + 'nullable' => false, + 'default' => 0, + 'activity-log' => true + ], + 'slackWebHookURL' => [ + 'type' => Schema::DT_VARCHAR128, + 'nullable' => false, + 'default' => '', + 'validate' => true + ], + 'slackUsername' => [ + 'type' => Schema::DT_VARCHAR128, + 'nullable' => false, + 'default' => '', + 'activity-log' => true + ], + 'slackIcon' => [ + 'type' => Schema::DT_VARCHAR128, + 'nullable' => false, + 'default' => '', + 'activity-log' => true + ], + 'slackChannelHistory' => [ + 'type' => Schema::DT_VARCHAR128, + 'nullable' => false, + 'default' => '', + 'activity-log' => true + ], + 'slackChannelRally' => [ + 'type' => Schema::DT_VARCHAR128, + 'nullable' => false, + 'default' => '', + 'activity-log' => true ], 'systems' => [ 'has-many' => ['Model\SystemModel', 'mapId'] @@ -97,37 +150,18 @@ class MapModel extends BasicModel { ] ]; - protected $validate = [ - 'name' => [ - 'length' => [ - 'min' => 3 - ] - ], - 'icon' => [ - 'length' => [ - 'min' => 3 - ] - ], - 'scopeId' => [ - 'regex' => '/^[1-9]+$/' - ], - 'typeId' => [ - 'regex' => '/^[1-9]+$/' - ] - ]; - /** * set map data by an associative array - * @param $data + * @param array $data */ public function setData($data){ + unset($data['id']); + unset($data['created']); + unset($data['updated']); + unset($data['createdCharacterId']); + unset($data['updatedCharacterId']); foreach((array)$data as $key => $value){ - - if($key == 'created'){ - continue; - } - if(!is_array($value)){ if($this->exists($key)){ $this->$key = $value; @@ -155,35 +189,62 @@ public function getData(){ if(is_null($mapDataAll)){ // no cached map data found - $mapData = (object) []; - $mapData->id = $this->id; - $mapData->name = $this->name; - $mapData->icon = $this->icon; - $mapData->deleteExpiredConnections = $this->deleteExpiredConnections; - $mapData->deleteEolConnections = $this->deleteEolConnections; - $mapData->persistentAliases = $this->persistentAliases; - $mapData->created = strtotime($this->created); - $mapData->updated = strtotime($this->updated); + $mapData = (object) []; + $mapData->id = $this->id; + $mapData->name = $this->name; + $mapData->icon = $this->icon; + $mapData->deleteExpiredConnections = $this->deleteExpiredConnections; + $mapData->deleteEolConnections = $this->deleteEolConnections; + $mapData->persistentAliases = $this->persistentAliases; // map scope - $mapData->scope = (object) []; - $mapData->scope->id = $this->scopeId->id; - $mapData->scope->name = $this->scopeId->name; - $mapData->scope->label = $this->scopeId->label; + $mapData->scope = (object) []; + $mapData->scope->id = $this->scopeId->id; + $mapData->scope->name = $this->scopeId->name; + $mapData->scope->label = $this->scopeId->label; // map type - $mapData->type = (object) []; - $mapData->type->id = $this->typeId->id; - $mapData->type->name = $this->typeId->name; - $mapData->type->classTab = $this->typeId->classTab; + $mapData->type = (object) []; + $mapData->type->id = $this->typeId->id; + $mapData->type->name = $this->typeId->name; + $mapData->type->classTab = $this->typeId->classTab; + + // map logging + $mapData->logging = (object) []; + $mapData->logging->activity = $this->isActivityLogEnabled(); + $mapData->logging->history = $this->isHistoryLogEnabled(); + + // map Slack logging + $mapData->logging->slackHistory = $this->isSlackChannelEnabled('slackChannelHistory'); + $mapData->logging->slackRally = $this->isSlackChannelEnabled('slackChannelRally'); + $mapData->logging->slackWebHookURL = $this->slackWebHookURL; + $mapData->logging->slackUsername = $this->slackUsername; + $mapData->logging->slackIcon = $this->slackIcon; + $mapData->logging->slackChannelHistory = $this->slackChannelHistory; + $mapData->logging->slackChannelRally = $this->slackChannelRally; + + // map mail logging + $mapData->logging->mailRally = $this->isMailSendEnabled('RALLY_SET'); // map access - $mapData->access = (object) []; - $mapData->access->character = []; - $mapData->access->corporation = []; - $mapData->access->alliance = []; + $mapData->access = (object) []; + $mapData->access->character = []; + $mapData->access->corporation = []; + $mapData->access->alliance = []; + + $mapData->created = (object) []; + $mapData->created->created = strtotime($this->created); + if(is_object($this->createdCharacterId)){ + $mapData->created->character = $this->createdCharacterId->getData(); + } + + $mapData->updated = (object) []; + $mapData->updated->updated = strtotime($this->updated); + if(is_object($this->updatedCharacterId)){ + $mapData->updated->character = $this->updatedCharacterId->getData(); + } - // get access object data ------------------------------------- + // get access object data --------------------------------------------------------------------------------- if($this->isPrivate()){ $characters = $this->getCharacters(); $characterData = []; @@ -209,14 +270,14 @@ public function getData(){ $mapData->access->alliance = $allianceData; } - // merge all data --------------------------------------------- + // merge all data ----------------------------------------------------------------------------------------- $mapDataAll = (object) []; $mapDataAll->mapData = $mapData; - // map system data -------------------------------------------- + // map system data ---------------------------------------------------------------------------------------- $mapDataAll->systems = $this->getSystemData(); - // map connection data ---------------------------------------- + // map connection data ------------------------------------------------------------------------------------ $mapDataAll->connections = $this->getConnectionData(); // max caching time for a map @@ -228,6 +289,70 @@ public function getData(){ return $mapDataAll; } + /** + * validate name column + * @param string $key + * @param string $val + * @return bool + */ + protected function validate_name(string $key, string $val): bool { + $valid = true; + if(mb_strlen($val) < 3){ + $valid = false; + $this->throwValidationException($key); + } + return $valid; + } + + /** + * validate Slack WebHook URL + * @param string $key + * @param string $val + * @return bool + */ + protected function validate_slackWebHookURL(string $key, string $val): bool { + $valid = true; + if( !empty($val) ){ + if( + !\Audit::instance()->url($val) || + parse_url($val, PHP_URL_HOST) !== 'hooks.slack.com' + ){ + $valid = false; + $this->throwValidationException($key); + } + } + return $valid; + } + + /** + * @param $channel + * @return string + */ + protected function set_slackChannelHistory($channel){ + return $this->formatSlackChannelName($channel); + } + + /** + * @param $channel + * @return string + */ + protected function set_slackChannelRally($channel){ + return $this->formatSlackChannelName($channel); + } + + /** + * convert a Slack channel name into correct format + * @param $channel + * @return string + */ + private function formatSlackChannelName($channel){ + $channel = strtolower(str_replace(' ','', trim(trim((string)$channel), '#@'))); + if($channel){ + $channel = '#' . $channel; + } + return $channel; + } + /** * Event "Hook" function * @param self $self @@ -235,6 +360,7 @@ public function getData(){ */ public function afterInsertEvent($self, $pkeys){ $self->clearCacheData(); + $self->logActivity('mapCreate'); } /** @@ -244,6 +370,9 @@ public function afterInsertEvent($self, $pkeys){ */ public function afterUpdateEvent($self, $pkeys){ $self->clearCacheData(); + + $activity = ($self->isActive()) ? 'mapUpdate' : 'mapDelete'; + $self->logActivity($activity); } /** @@ -253,6 +382,8 @@ public function afterUpdateEvent($self, $pkeys){ */ public function afterEraseEvent($self, $pkeys){ $self->clearCacheData(); + $self->logActivity('mapDelete'); + $self->deleteLogFile(); } /** @@ -701,49 +832,185 @@ public function getAlliances(){ } /** - * delete this map and all dependencies - * @param CharacterModel $characterModel - * @param null $callback + * @param string $action + * @return Logging\LogInterface */ - public function delete(CharacterModel $characterModel, $callback = null){ - - if( !$this->dry() ){ - // check if character has access - if($this->hasAccess($characterModel)){ - // all map related tables will be deleted on cascade - if( - $this->erase() && - is_callable($callback) - ){ - $callback($this->_id); - } + public function newLog($action = ''): Logging\LogInterface{ + $logChannelData = $this->getLogChannelData(); + $logObjectData = $this->getLogObjectData(); + $log = (new logging\MapLog($action, $logChannelData))->setTempData($logObjectData); + + // update map history *.log files ----------------------------------------------------------------------------- + if($this->isHistoryLogEnabled()){ + // check socket config + if(Config::validSocketConnect()){ + $log->addHandler('zmq', 'json', $this->getSocketConfig()); + }else{ + // update log file local (slow) + $log->addHandler('stream', 'json', $this->getStreamConfig()); } } + + // send map history to Slack channel -------------------------------------------------------------------------- + $slackChannelKey = 'slackChannelHistory'; + if($this->isSlackChannelEnabled($slackChannelKey)){ + $log->addHandler('slackMap', null, $this->getSlackWebHookConfig($slackChannelKey)); + $log->addHandlerGroup('slackMap'); + } + + // update map activity ---------------------------------------------------------------------------------------- + $log->logActivity($this->isActivityLogEnabled()); + + return $log; + } + + /** + * @return MapModel + */ + public function getMap(): MapModel{ + return $this; + } + + /** + * get object relevant data for model log channel + * @return array + */ + public function getLogChannelData() : array{ + return [ + 'channelId' => $this->_id, + 'channelName' => $this->name + ]; + } + /** + * get object relevant data for model log object + * @return array + */ + public function getLogObjectData() : array{ + return [ + 'objId' => $this->_id, + 'objName' => $this->name + ]; + } + + protected function getLogFormatter(){ + return function(&$rowDataObj){ + unset($rowDataObj['extra']); + }; } /** * check if "activity logging" is enabled for this map type * @return bool */ - public function isActivityLogEnabled(){ - $f3 = self::getF3(); - $activityLogEnabled = false; + public function isActivityLogEnabled(): bool { + return $this->logActivity && (bool) Config::getMapsDefaultConfig($this->typeId->name)['log_activity_enabled']; + } - if( $this->isAlliance() ){ - if( $f3->get('PATHFINDER.MAP.ALLIANCE.ACTIVITY_LOGGING') ){ - $activityLogEnabled = true; - } - }elseif( $this->isCorporation() ){ - if( $f3->get('PATHFINDER.MAP.CORPORATION.ACTIVITY_LOGGING') ){ - $activityLogEnabled = true; + /** + * check if "history logging" is enabled for this map type + * @return bool + */ + public function isHistoryLogEnabled(): bool { + return $this->logHistory && (bool) Config::getMapsDefaultConfig($this->typeId->name)['log_history_enabled']; + } + + /** + * check if "Slack WebHook" is enabled for this map type + * @param string $channel + * @return bool + * @throws PathfinderException + */ + public function isSlackChannelEnabled(string $channel): bool { + $enabled = false; + // check global Slack status + if((bool)Config::getPathfinderData('slack.status')){ + // check global map default config for this channel + switch($channel){ + case 'slackChannelHistory': $defaultMapConfigKey = 'send_history_slack_enabled'; break; + case 'slackChannelRally': $defaultMapConfigKey = 'send_rally_slack_enabled'; break; + default: throw new PathfinderException(sprintf(self::ERROR_SLACK_CHANNEL, $channel)); } - }elseif( $this->isPrivate() ){ - if( $f3->get('PATHFINDER.MAP.PRIVATE.ACTIVITY_LOGGING') ){ - $activityLogEnabled = true; + + if((bool) Config::getMapsDefaultConfig($this->typeId->name)[$defaultMapConfigKey]){ + $config = $this->getSlackWebHookConfig($channel); + if($config->slackWebHookURL && $config->slackChannel){ + $enabled = true; + } } } - return $activityLogEnabled; + return $enabled; + } + + /** + * check if "E-Mail" Log is enabled for this map + * @param string $type + * @return bool + */ + public function isMailSendEnabled(string $type): bool{ + $enabled = false; + if((bool) Config::getMapsDefaultConfig($this->typeId->name)['send_rally_mail_enabled']){ + $enabled = Config::isValidSMTPConfig($this->getSMTPConfig($type)); + } + + return $enabled; + } + + /** + * get config for stream logging + * @param bool $abs absolute path + * @return \stdClass + */ + public function getStreamConfig(bool $abs = false): \stdClass{ + $config = (object) []; + $config->stream = ''; + if( $this->getF3()->exists('PATHFINDER.HISTORY.LOG', $dir) ){ + $config->stream .= $abs ? $this->getF3()->get('ROOT') . '/' : './'; + $config->stream .= $dir . 'map/map_' . $this->_id . '.log'; + $config->stream = $this->getF3()->fixslashes($config->stream); + } + return $config; + } + + /** + * get config for Socket connection (e.g. where to send log data) + * @return \stdClass + */ + public function getSocketConfig(): \stdClass{ + $config = (object) []; + $config->uri = Config::getSocketUri(); + $config->streamConf = $this->getStreamConfig(true); + return $config; + } + + /** + * get Config for Slack WebHook cURL calls + * -> https://api.slack.com/incoming-webhooks + * @param string $channel + * @return \stdClass + */ + public function getSlackWebHookConfig(string $channel = ''): \stdClass{ + $config = (object) []; + $config->slackWebHookURL = $this->slackWebHookURL; + $config->slackUsername = $this->slackUsername; + $config->slackIcon = $this->slackIcon; + if($channel && $this->exists($channel)){ + $config->slackChannel = $this->$channel; + } + return $config; + } + + /** + * get Config for SMTP connection and recipient address + * @param string $type + * @param bool $addJson + * @return \stdClass + */ + public function getSMTPConfig(string $type, bool $addJson = true): \stdClass{ + $config = Config::getSMTPConfig(); + $config->to = Config::getNotificationMail($type); + $config->addJson = $addJson; + return $config; } /** @@ -782,22 +1049,31 @@ public function getScope(){ return $scope; } + /** + * get log file data + * @param int $offset + * @param int $limit + * @return array + */ + public function getLogData(int $offset = FileHandler::LOG_FILE_OFFSET, int $limit = FileHandler::LOG_FILE_LIMIT): array { + $streamConf = $this->getStreamConfig(); + return FileHandler::readLogFile($streamConf->stream, $offset, $limit, $this->getLogFormatter()); + } + /** * save a system to this map * @param SystemModel $system + * @param CharacterModel $character * @param int $posX * @param int $posY - * @param null|CharacterModel $character - * @return mixed + * @return false|ConnectionModel */ - public function saveSystem( SystemModel $system, $posX = 10, $posY = 0, $character = null){ + public function saveSystem( SystemModel $system, CharacterModel $character, $posX = 10, $posY = 0){ $system->setActive(true); $system->mapId = $this->id; $system->posX = $posX; $system->posY = $posY; - $system->createdCharacterId = $character; - $system->updatedCharacterId = $character; - return $system->save(); + return $system->save($character); } /** @@ -839,11 +1115,26 @@ public function searchConnection(SystemModel $sourceSystem, SystemModel $targetS * save new connection * -> connection scope/type is automatically added * @param ConnectionModel $connection + * @param CharacterModel $character * @return false|ConnectionModel */ - public function saveConnection(ConnectionModel $connection){ + public function saveConnection(ConnectionModel $connection, CharacterModel $character){ $connection->mapId = $this; - return $connection->save(); + return $connection->save($character); + } + + /** + * delete existing log file + */ + protected function deleteLogFile(){ + $config = $this->getStreamConfig(); + if(is_file($config->stream)){ + // try to set write access + if(!is_writable($config->stream)){ + chmod($config->stream, 0666); + } + @unlink($config->stream); + } } /** @@ -909,12 +1200,11 @@ public function getUserData(){ } /** - * save a map - * @return mixed + * @param CharacterModel|null $characterModel + * @return false|ConnectionModel */ - public function save(){ - - $mapModel = parent::save(); + public function save(CharacterModel $characterModel = null){ + $mapModel = parent::save($characterModel); // check if map type has changed and clear access objects if( !$mapModel->dry() ){ diff --git a/app/main/model/systemmodel.php b/app/main/model/systemmodel.php index a8b786f1b..b749671b8 100644 --- a/app/main/model/systemmodel.php +++ b/app/main/model/systemmodel.php @@ -8,11 +8,10 @@ namespace Model; -use controller\MailController; use DB\SQL\Schema; -use lib\Config; +use lib\logging; -class SystemModel extends BasicModel { +class SystemModel extends AbstractMapTrackingModel { const MAX_POS_X = 2300; const MAX_POS_Y = 498; @@ -124,7 +123,8 @@ class SystemModel extends BasicModel { 'rallyPoke' => [ 'type' => Schema::DT_BOOL, 'nullable' => false, - 'default' => 0 + 'default' => 0, + 'activity-log' => true ], 'description' => [ 'type' => Schema::DT_VARCHAR512, @@ -142,28 +142,6 @@ class SystemModel extends BasicModel { 'nullable' => false, 'default' => 0 ], - 'createdCharacterId' => [ - 'type' => Schema::DT_INT, - 'index' => true, - 'belongs-to-one' => 'Model\CharacterModel', - 'constraint' => [ - [ - 'table' => 'character', - 'on-delete' => 'CASCADE' - ] - ] - ], - 'updatedCharacterId' => [ - 'type' => Schema::DT_INT, - 'index' => true, - 'belongs-to-one' => 'Model\CharacterModel', - 'constraint' => [ - [ - 'table' => 'character', - 'on-delete' => 'CASCADE' - ] - ] - ], 'signatures' => [ 'has-many' => ['Model\SystemSignatureModel', 'systemId'] ], @@ -177,16 +155,16 @@ class SystemModel extends BasicModel { /** * set an array with all data for a system - * @param array $systemData + * @param array $data */ - public function setData($systemData){ - - foreach((array)$systemData as $key => $value){ - - if($key == 'created'){ - continue; - } - + public function setData($data){ + unset($data['id']); + unset($data['created']); + unset($data['updated']); + unset($data['createdCharacterId']); + unset($data['updatedCharacterId']); + + foreach((array)$data as $key => $value){ if(!is_array($value)){ if($this->exists($key)){ $this->$key = $value; @@ -361,8 +339,6 @@ public function set_rallyUpdated($rally){ case 1: // new rally point set $rally = date('Y-m-d H:i:s', time()); - // flag system for mail poke -> after save() - $this->virtual('newRallyPointSet', true); break; default: $rally = date('Y-m-d H:i:s', $rally); @@ -417,15 +393,6 @@ public function beforeUpdateEvent($self, $pkeys){ */ public function afterUpdateEvent($self, $pkeys){ $self->clearCacheData(); - - // check if rally point mail should be send - if( - $self->newRallyPointSet && - $self->rallyPoke - ){ - $self->sendRallyPointMail(); - } - $activity = ($self->isActive()) ? 'systemUpdate' : 'systemDelete'; $self->logActivity($activity); } @@ -441,23 +408,18 @@ public function afterEraseEvent($self, $pkeys){ } /** - * log character activity create/update/delete events * @param string $action + * @return Logging\LogInterface */ - protected function logActivity($action){ - if( - $this->enableActivityLogging && - ( - $action === 'systemDelete' || - !empty($this->fieldChanges) - ) && - $this->get('mapId')->isActivityLogEnabled() - ){ - $characterId = $this->get('updatedCharacterId', true); - $mapId = $this->get('mapId', true); + public function newLog($action = ''): Logging\LogInterface{ + return $this->getMap()->newLog($action)->setTempData($this->getLogObjectData()); + } - parent::bufferActivity($characterId, $mapId, $action); - } + /** + * @return MapModel + */ + public function getMap(): MapModel{ + return $this->get('mapId'); } /** @@ -599,6 +561,52 @@ public function isShatteredWormhole(){ return ($this->isWormhole() && $this->security === 'SH'); } + /** + * send rally point poke to various "APIs" + * -> send to a Slack channel + * -> send to an Email + * @param array $rallyData + * @param CharacterModel $characterModel + */ + public function sendRallyPoke(array $rallyData, CharacterModel $characterModel){ + // rally log needs at least one handler to be valid + $isValidLog = false; + $log = new Logging\RallyLog('rallySet', $this->getMap()->getLogChannelData()); + + // Slack poke ----------------------------------------------------------------------------- + $slackChannelKey = 'slackChannelRally'; + if( + $rallyData['pokeSlack'] === true && + $this->getMap()->isSlackChannelEnabled($slackChannelKey) + ){ + $isValidLog = true; + $log->addHandler('slackRally', null, $this->getMap()->getSlackWebHookConfig($slackChannelKey)); + } + + // Mail poke ------------------------------------------------------------------------------ + $mailAddressKey = 'RALLY_SET'; + if( + $rallyData['pokeMail'] === true && + $this->getMap()->isMailSendEnabled('RALLY_SET') + ){ + $isValidLog = true; + $mailConf = $this->getMap()->getSMTPConfig($mailAddressKey, false); + $log->addHandler('mail', 'mail', $mailConf); + } + + // Buffer log ----------------------------------------------------------------------------- + if($isValidLog){ + $log->setTempData($this->getLogObjectData(true)); + $log->setCharacter($characterModel); + if( !empty($rallyData['message']) ){ + $log->setData([ + 'message' => $rallyData['message'] + ]); + } + $log->buffer(); + } + } + /** * get static WH data for this system * -> any WH system has at least one static WH @@ -641,34 +649,27 @@ protected function getStaticWormholeData(){ } /** - * send rally point information by mail + * get object relevant data for model log + * @param bool $fullData + * @return array */ - protected function sendRallyPointMail(){ - $recipient = Config::getNotificationMail('RALLY_SET'); - - if( - $recipient && - \Audit::instance()->email($recipient) - ){ - $updatedCharacterId = (int) $this->get('updatedCharacterId', true); - /** - * @var $character CharacterModel - */ - $character = $this->rel('updatedCharacterId'); - $character->getById( $updatedCharacterId ); - if( !$character->dry() ){ - $body = []; - $body[] = "Map:\t\t" . $this->mapId->name; - $body[] = "System:\t\t" . $this->name; - $body[] = "Region:\t\t" . $this->region; - $body[] = "Security:\t" . $this->security; - $body[] = "Character:\t" . $character->name; - $body[] = "Time:\t\t" . date('g:i a; F j, Y', strtotime($this->rallyUpdated) ); - $bodyMsg = implode("\r\n", $body); - - (new MailController())->sendRallyPoint($recipient, $bodyMsg); - } + public function getLogObjectData($fullData = false) : array{ + $objectData = [ + 'objId' => $this->_id, + 'objName' => $this->name + ]; + + if($fullData){ + $objectData['objAlias'] = $this->alias; + $objectData['objRegion'] = $this->region; + $objectData['objIsWormhole'] = $this->isWormhole(); + $objectData['objEffect'] = $this->effect; + $objectData['objSecurity'] = $this->security; + $objectData['objTrueSec'] = $this->trueSec; + $objectData['objDescription'] = $this->description; } + + return $objectData; } /** diff --git a/app/main/model/systemsignaturemodel.php b/app/main/model/systemsignaturemodel.php index 553301af7..bd457c465 100644 --- a/app/main/model/systemsignaturemodel.php +++ b/app/main/model/systemsignaturemodel.php @@ -9,8 +9,9 @@ namespace Model; use DB\SQL\Schema; +use lib\logging; -class SystemSignatureModel extends BasicModel { +class SystemSignatureModel extends AbstractMapTrackingModel { protected $table = 'system_signature'; @@ -62,52 +63,29 @@ class SystemSignatureModel extends BasicModel { 'type' => Schema::DT_VARCHAR128, 'nullable' => false, 'default' => '', - 'activity-log' => true + 'activity-log' => true, + 'validate' => true ], 'description' => [ 'type' => Schema::DT_VARCHAR512, 'nullable' => false, 'default' => '', 'activity-log' => true - ], - 'createdCharacterId' => [ - 'type' => Schema::DT_INT, - 'index' => true, - 'belongs-to-one' => 'Model\CharacterModel', - 'constraint' => [ - [ - 'table' => 'character', - 'on-delete' => 'CASCADE' - ] - ] - ], - 'updatedCharacterId' => [ - 'type' => Schema::DT_INT, - 'index' => true, - 'belongs-to-one' => 'Model\CharacterModel', - 'constraint' => [ - [ - 'table' => 'character', - 'on-delete' => 'CASCADE' - ] - ] - ] - ]; - - protected $validate = [ - 'name' => [ - 'length' => [ - 'min' => 3 - ] ] ]; /** * set an array with all data for a system - * @param $signatureData + * @param $data */ - public function setData($signatureData){ - foreach((array)$signatureData as $key => $value){ + public function setData($data){ + unset($data['id']); + unset($data['created']); + unset($data['updated']); + unset($data['createdCharacterId']); + unset($data['updatedCharacterId']); + + foreach((array)$data as $key => $value){ if(!is_array($value)){ if($this->exists($key)){ $this->$key = $value; @@ -121,7 +99,6 @@ public function setData($signatureData){ * @return \stdClass */ public function getData(){ - $signatureData = (object) []; $signatureData->id = $this->id; @@ -187,6 +164,36 @@ public function set_connectionId($connectionId){ return $validConnectionId; } + /** + * validate name column + * @param string $key + * @param string $val + * @return bool + */ + protected function validate_name(string $key, string $val): bool { + $valid = true; + if(mb_strlen($val) < 3){ + $valid = false; + $this->throwValidationException($key); + } + return $valid; + } + + /** + * @param string $action + * @return Logging\LogInterface + */ + public function newLog($action = ''): Logging\LogInterface{ + return $this->getMap()->newLog($action)->setTempData($this->getLogObjectData()); + } + + /** + * @return MapModel + */ + public function getMap(): MapModel{ + return $this->get('systemId')->getMap(); + } + /** * get the connection (if attached) * @return \Model\ConnectionModel|null @@ -270,29 +277,14 @@ public function afterEraseEvent($self, $pkeys){ } /** - * log character activity create/update/delete events - * @param string $action + * get object relevant data for model log + * @return array */ - protected function logActivity($action){ - if($this->enableActivityLogging){ - /** - * @var $map MapModel - */ - $map = $this->get('systemId')->get('mapId'); - - if( - ( - $action === 'signatureDelete' || - !empty($this->fieldChanges) - ) && - $map->isActivityLogEnabled() - ){ - $characterId = $this->get('updatedCharacterId', true); - $mapId = $map->_id; - - parent::bufferActivity($characterId, $mapId, $action); - } - } + public function getLogObjectData() : array{ + return [ + 'objId' => $this->_id, + 'objName' => $this->name + ]; } /** diff --git a/app/main/model/universe/basicuniversemodel.php b/app/main/model/universe/basicuniversemodel.php new file mode 100644 index 000000000..b34c15d34 --- /dev/null +++ b/app/main/model/universe/basicuniversemodel.php @@ -0,0 +1,85 @@ + refresh static data after X days + */ + const CACHE_MAX_DAYS = 7; + + protected $db = 'DB_UNIVERSE'; + + public static function getNew($model, $ttl = self::DEFAULT_TTL){ + $class = null; + + $model = '\\' . __NAMESPACE__ . '\\' . $model; + if(class_exists($model)){ + $db = Database::instance()->getDB('UNIVERSE'); + $class = new $model($db, null, null, $ttl); + }else{ + throw new \Exception(sprintf(self::ERROR_INVALID_MODEL_CLASS, $model)); + } + + return $class; + } + + /** + * load data from API into $this and save $this + * @param int $id + * @param string $accessToken + * @param array $additionalOptions + */ + abstract protected function loadData(int $id, string $accessToken = '', array $additionalOptions = []); + + /** + * load object by $id + * -> if $id not exists in DB -> query API + * @param int $id + * @param string $accessToken + * @param array $additionalOptions + */ + public function loadById(int $id, string $accessToken = '', array $additionalOptions = []){ + /** + * @var $model self + */ + $model = $this->getById($id); + if($model->isOutdated()){ + $model->loadData($id, $accessToken, $additionalOptions); + } + } + + /** + * checks whether data is outdated and should be refreshed + * @return bool + */ + protected function isOutdated(): bool { + $outdated = true; + if(!$this->dry()){ + $timezone = $this->getF3()->get('getTimeZone')(); + $currentTime = new \DateTime('now', $timezone); + $updateTime = \DateTime::createFromFormat( + 'Y-m-d H:i:s', + $this->updated, + $timezone + ); + $interval = $updateTime->diff($currentTime); + if($interval->days < self::CACHE_MAX_DAYS ){ + $outdated = false; + } + } + return $outdated; + } +} \ No newline at end of file diff --git a/app/main/model/universe/constellationmodel.php b/app/main/model/universe/constellationmodel.php new file mode 100644 index 000000000..29c33516f --- /dev/null +++ b/app/main/model/universe/constellationmodel.php @@ -0,0 +1,57 @@ + [ + 'type' => Schema::DT_VARCHAR128, + 'nullable' => false, + 'default' => '' + ], + 'regionId' => [ + 'type' => Schema::DT_INT, + 'index' => true, + 'belongs-to-one' => 'Model\Universe\RegionModel', + 'constraint' => [ + [ + 'table' => 'region', + 'on-delete' => 'CASCADE' + ] + ] + ], + 'x' => [ + 'type' => Schema::DT_INT8, + 'nullable' => false, + 'default' => 0 + ], + 'y' => [ + 'type' => Schema::DT_INT8, + 'nullable' => false, + 'default' => 0 + ], + 'z' => [ + 'type' => Schema::DT_INT8, + 'nullable' => false, + 'default' => 0 + ] + ]; + +} \ No newline at end of file diff --git a/app/main/model/universe/regionmodel.php b/app/main/model/universe/regionmodel.php new file mode 100644 index 000000000..44db09bd5 --- /dev/null +++ b/app/main/model/universe/regionmodel.php @@ -0,0 +1,36 @@ + [ + 'type' => Schema::DT_VARCHAR128, + 'nullable' => false, + 'default' => '' + ], + 'description' => [ + 'type' => Schema::DT_TEXT + ], + 'constellations' => [ + 'has-many' => ['Model\Universe\ConstellationModel', 'regionId'] + ], + ]; +} \ No newline at end of file diff --git a/app/main/model/universe/structuremodel.php b/app/main/model/universe/structuremodel.php new file mode 100644 index 000000000..19df04e05 --- /dev/null +++ b/app/main/model/universe/structuremodel.php @@ -0,0 +1,122 @@ + [ + 'type' => Schema::DT_VARCHAR128, + 'nullable' => false, + 'default' => '' + ], + 'systemId' => [ + 'type' => Schema::DT_INT, + 'nullable' => false, + 'default' => 0, + 'index' => true + ], + 'typeId' => [ + 'type' => Schema::DT_INT, + 'index' => true, + 'belongs-to-one' => 'Model\Universe\TypeModel', + 'constraint' => [ + [ + 'table' => 'type', + 'on-delete' => 'CASCADE' + ] + ] + ], + 'x' => [ + 'type' => Schema::DT_FLOAT, + 'nullable' => false, + 'default' => 0 + ], + 'y' => [ + 'type' => Schema::DT_FLOAT, + 'nullable' => false, + 'default' => 0 + ], + 'z' => [ + 'type' => Schema::DT_FLOAT, + 'nullable' => false, + 'default' => 0 + ] + ]; + + /** + * get data from object + * -> more fields can be added in here if needed + * @return \stdClass + */ + public function getData(): \stdClass { + $data = (object) []; + if(!$this->dry()){ + $data->id = $this->_id; + $data->name = $this->name; + } + return $data; + } + + /** + * load data from API into $this and save $this + * @param int $id + * @param string $accessToken + * @param array $additionalOptions + */ + protected function loadData(int $id, string $accessToken = '', array $additionalOptions = []){ + $data = self::getF3()->ccpClient->getUniverseStructureData($id, $accessToken, $additionalOptions); + if(!empty($data)){ + $type = $this->rel('typeId'); + $type->loadById($data['typeId'], $accessToken, $additionalOptions); + $data['typeId'] = $type; + + $this->copyfrom($data); + $this->save(); + } + } + + /** + * @param array|string $key + * @param null $fields + * @return NULL + */ + public function copyfrom($key, $fields = null){ + // flatten array (e.g. "position" key) + $key = Util::arrayFlatten((array)$key); + parent::copyfrom($key, $fields); + } + + /** + * overwrites parent + * @param null|SQL $db + * @param null $table + * @param null $fields + * @return bool + */ + public static function setup($db=null, $table=null, $fields=null){ + if($status = parent::setup($db,$table,$fields)){ + //change `id` column to BigInt + $schema = new Schema($db); + $typeQuery = $schema->findQuery($schema->dataTypes[Schema::DT_BIGINT]); + $db->exec("ALTER TABLE " . $db->quotekey('structure') . + " MODIFY COLUMN " . $db->quotekey('id') . " " . $typeQuery . " NOT NULL"); + } + + return $status; + } + +} \ No newline at end of file diff --git a/app/main/model/universe/typemodel.php b/app/main/model/universe/typemodel.php new file mode 100644 index 000000000..52185d85d --- /dev/null +++ b/app/main/model/universe/typemodel.php @@ -0,0 +1,114 @@ + [ + 'type' => Schema::DT_VARCHAR128, + 'nullable' => false, + 'default' => '' + ], + 'description' => [ + 'type' => Schema::DT_TEXT + ], + 'published' => [ + 'type' => Schema::DT_BOOL, + 'nullable' => false, + 'default' => 1, + 'index' => true + ], + 'radius' => [ + 'type' => Schema::DT_FLOAT, + 'nullable' => false, + 'default' => 0 + ], + 'volume' => [ + 'type' => Schema::DT_FLOAT, + 'nullable' => false, + 'default' => 0 + ], + 'capacity' => [ + 'type' => Schema::DT_FLOAT, + 'nullable' => false, + 'default' => 0 + ], + 'mass' => [ + 'type' => Schema::DT_FLOAT, + 'nullable' => false, + 'default' => 0 + ], + 'groupId' => [ + 'type' => Schema::DT_INT, + 'nullable' => false, + 'default' => 0, + 'index' => true + ], + 'marketGroupId' => [ + 'type' => Schema::DT_INT, + 'nullable' => false, + 'default' => 0, + 'index' => true + ], + 'packagedVolume' => [ + 'type' => Schema::DT_FLOAT, + 'nullable' => false, + 'default' => 0 + ], + 'portionSize' => [ + 'type' => Schema::DT_INT, + 'nullable' => false, + 'default' => 0 + ], + 'graphicId' => [ + 'type' => Schema::DT_INT, + 'nullable' => false, + 'default' => 0, + 'index' => true + ], + 'structures' => [ + 'has-many' => ['Model\Universe\StructureModel', 'typeId'] + ] + ]; + + /** + * get shipData from object + * -> more fields can be added in here if needed + * @return \stdClass + */ + public function getShipData(): \stdClass { + $shipData = (object) []; + if(!$this->dry()){ + $shipData->typeId = $this->_id; + $shipData->typeName = $this->name; + $shipData->mass = $this->mass; + } + return $shipData; + } + + /** + * load data from API into $this and save $this + * @param int $id + * @param string $accessToken + * @param array $additionalOptions + */ + protected function loadData(int $id, string $accessToken = '', array $additionalOptions = []){ + $data = self::getF3()->ccpClient->getUniverseTypesData($id, $additionalOptions); + if(!empty($data)){ + $this->copyfrom($data); + $this->save(); + } + } +} \ No newline at end of file diff --git a/app/main/model/usermodel.php b/app/main/model/usermodel.php index 93e59c844..f6a8de3cd 100644 --- a/app/main/model/usermodel.php +++ b/app/main/model/usermodel.php @@ -12,6 +12,8 @@ use Controller; use Controller\Api\User as User; use Exception; +use lib\Config; +use lib\logging; class UserModel extends BasicModel { @@ -28,27 +30,20 @@ class UserModel extends BasicModel { 'type' => Schema::DT_VARCHAR128, 'nullable' => false, 'default' => '', - 'index' => true + 'index' => true, + 'validate' => true ], 'email' => [ 'type' => Schema::DT_VARCHAR128, 'nullable' => false, - 'default' => '' + 'default' => '', + 'validate' => true ], 'userCharacters' => [ 'has-many' => ['Model\UserCharacterModel', 'userId'] ] ]; - protected $validate = [ - 'name' => [ - 'length' => [ - 'min' => 3, - 'max' => 50 - ] - ] - ]; - /** * get all data for this user * -> ! caution ! this function returns sensitive data! (e.g. email,..) @@ -93,23 +88,6 @@ public function getSimpleData(){ return $userData; } - /** - * validate and set a email address for this user - * -> empty email is allowed! - * @param string $email - * @return string - */ - public function set_email($email){ - if ( - !empty($email) && - \Audit::instance()->email($email) == false - ) { - // no valid email address - $this->throwValidationError('email'); - } - return $email; - } - /** * check if new user registration is allowed * @param UserModel $self @@ -119,10 +97,8 @@ public function set_email($email){ */ public function beforeInsertEvent($self, $pkeys){ $registrationStatus = Controller\Controller::getRegistrationStatus(); - switch($registrationStatus){ case 0: - $f3 = self::getF3(); throw new Exception\RegistrationException('User registration is currently not allowed'); break; case 1: @@ -133,6 +109,80 @@ public function beforeInsertEvent($self, $pkeys){ } } + /** + * @param BasicModel $self + * @param $pkeys + */ + public function afterEraseEvent($self, $pkeys){ + $this->sendDeleteMail(); + } + + /** + * send delete confirm mail to this user + */ + protected function sendDeleteMail(){ + if($this->isMailSendEnabled()){ + $log = new Logging\UserLog('userDelete', $this->getLogChannelData()); + $log->addHandler('mail', 'mail', $this->getSMTPConfig()); + $log->setMessage('Delete Account - {channelName}'); + $log->setData([ + 'message' =>'Your account was successfully deleted.' + ]); + $log->buffer(); + } + } + + /** + * checks whether user has a valid email address and pathfinder has a valid SMTP config + * @return bool + */ + protected function isMailSendEnabled() : bool{ + return Config::isValidSMTPConfig($this->getSMTPConfig()); + } + + /** + * get SMTP config for this user + * @return \stdClass + */ + protected function getSMTPConfig() : \stdClass{ + $config = Config::getSMTPConfig(); + $config->to = $this->email; + return $config; + } + + /** + * validate name column + * @param string $key + * @param string $val + * @return bool + */ + protected function validate_name(string $key, string $val): bool { + $valid = true; + if( + mb_strlen($val) < 3 || + mb_strlen($val) > 80 + ){ + $valid = false; + $this->throwValidationException($key); + } + return $valid; + } + + /** + * validate email column + * @param string $key + * @param string $val + * @return bool + */ + protected function validate_email(string $key, string $val): bool { + $valid = true; + if ( !empty($val) && \Audit::instance()->email($val) == false ){ + $valid = false; + $this->throwValidationException($key); + } + return $valid; + } + /** * check whether this character has already a user assigned to it * @return bool @@ -145,10 +195,10 @@ public function hasUserCharacters(){ /** * search for user by unique username * @param $name - * @return array|FALSE + * @return \DB\Cortex */ public function getByName($name){ - return $this->getByForeignKey('name', $name, [], 0); + return $this->getByForeignKey('name', $name, []); } /** @@ -165,17 +215,9 @@ public function getSessionCharacterData($characterId = 0, $objectCheck = true){ if($this->_id === $currentSessionUser['ID']){ // user matches session data - $sessionCharacters = (array)$this->getF3()->get(User::SESSION_KEY_CHARACTERS); - if($characterId > 0){ - // search for specific characterData - foreach($sessionCharacters as $characterData){ - if($characterId === (int)$characterData['ID']){ - $data = $characterData; - break; - } - } - }elseif( !empty($sessionCharacters) ){ + $data = $this->findSessionCharacterData($characterId); + }elseif( !empty($sessionCharacters = (array)$this->getF3()->get(User::SESSION_KEY_CHARACTERS)) ){ // no character was requested ($requestedCharacterId = 0) AND session characters were found // -> get first matched character (e.g. user open browser tab) $data = $sessionCharacters[0]; @@ -206,6 +248,26 @@ public function getSessionCharacterData($characterId = 0, $objectCheck = true){ return $data; } + /** + * search in session data for $characterId + * @param int $characterId + * @return array + */ + public function findSessionCharacterData(int $characterId): array{ + $data = []; + if($characterId){ + $sessionCharacters = (array)$this->getF3()->get(User::SESSION_KEY_CHARACTERS); + // search for specific characterData + foreach($sessionCharacters as $characterData){ + if($characterId === (int)$characterData['ID']){ + $data = $characterData; + break; + } + } + } + return $data; + } + /** * get all userCharacters models for a user * characters will be checked/updated on login by CCP API call @@ -292,4 +354,16 @@ public function getActiveCharacters(){ return $activeCharacters; } + /** + * get object relevant data for model log channel + * @return array + */ + public function getLogChannelData() : array{ + return [ + 'channelId' => $this->_id, + 'channelName' => $this->name + ]; + } + + } \ No newline at end of file diff --git a/app/pathfinder.ini b/app/pathfinder.ini index 1162c185e..54063594a 100644 --- a/app/pathfinder.ini +++ b/app/pathfinder.ini @@ -3,7 +3,7 @@ [PATHFINDER] NAME = Pathfinder ; installed version (used for CSS/JS cache busting) -VERSION = v1.2.4 +VERSION = v1.3.0 ; contact information [optional] CONTACT = https://github.com/exodus4d ; public contact email [optional] @@ -31,6 +31,11 @@ MODE_MAINTENANCE = 0 CORPORATION = ALLIANCE = +; Slack API integration =========================================================================== +[PATHFINDER.SLACK] +; Global Slack API status, check PATHFINDER.MAP section for individual control (0=disabled, 1=enabled) +STATUS = 1 + ; View ============================================================================================ [PATHFINDER.VIEW] ; static page templates @@ -55,29 +60,51 @@ ADMIN = templates/view/admin.html ; - Max number of shared entities per map ; MAX_SYSTEMS: ; - Max number of active systems per map -; ACTIVITY_LOGGING (0: disable, 1: enable): -; - Whether user activity should be logged for a map type -; - E.g. create/update/delete of systems/connections/signatures +; LOG_ACTIVITY_ENABLED (0: disable, 1: enable): +; - Whether user activity statistics can be anabled for a map type +; - E.g. create/update/delete of systems/connections/signatures/... +; LOG_HISTORY_ENABLED (0: disable, 1: enable): +; - Whether map change history should be logged to separat *.log files +; - see: [PATHFINDER.HISTORY] config section below +; SEND_HISTORY_SLACK_ENABLED (0: disable, 1: enable): +; - Send map updates to a Slack channel per map +; SEND_RALLY_SLACK_ENABLED (0: disable, 1: enable): +; - Send rally point pokes to a Slack channel per map +; SEND_RALLY_Mail_ENABLED (0: disable, 1: enable): +; - Send rally point pokes by mail +; - see: [PATHFINDER.NOTIFICATION] section below [PATHFINDER.MAP.PRIVATE] -LIFETIME = 30 -MAX_COUNT = 3 -MAX_SHARED = 10 -MAX_SYSTEMS = 50 -ACTIVITY_LOGGING = 1 +LIFETIME = 60 +MAX_COUNT = 3 +MAX_SHARED = 10 +MAX_SYSTEMS = 50 +LOG_ACTIVITY_ENABLED = 1 +LOG_HISTORY_ENABLED = 1 +SEND_HISTORY_SLACK_ENABLED = 0 +SEND_RALLY_SLACK_ENABLED = 1 +SEND_RALLY_Mail_ENABLED = 0 [PATHFINDER.MAP.CORPORATION] -LIFETIME = 99999 -MAX_COUNT = 3 -MAX_SHARED = 3 -MAX_SYSTEMS = 100 -ACTIVITY_LOGGING = 1 +LIFETIME = 99999 +MAX_COUNT = 3 +MAX_SHARED = 3 +MAX_SYSTEMS = 100 +LOG_ACTIVITY_ENABLED = 1 +LOG_HISTORY_ENABLED = 1 +SEND_HISTORY_SLACK_ENABLED = 1 +SEND_RALLY_SLACK_ENABLED = 1 +SEND_RALLY_Mail_ENABLED = 0 [PATHFINDER.MAP.ALLIANCE] -LIFETIME = 99999 -MAX_COUNT = 3 -MAX_SHARED = 2 -MAX_SYSTEMS = 100 -ACTIVITY_LOGGING = 0 +LIFETIME = 99999 +MAX_COUNT = 3 +MAX_SHARED = 2 +MAX_SYSTEMS = 100 +LOG_ACTIVITY_ENABLED = 0 +LOG_HISTORY_ENABLED = 1 +SEND_HISTORY_SLACK_ENABLED = 1 +SEND_RALLY_SLACK_ENABLED = 1 +SEND_RALLY_Mail_ENABLED = 0 ; Route search ==================================================================================== [PATHFINDER.ROUTE] @@ -87,7 +114,7 @@ SEARCH_DEPTH = 7000 ; default count of routes that will be checked (initial) when a system is selected (default: 2) SEARCH_DEFAULT_COUNT = 2 ; max count of routes that can be selected in "route settings" dialog (default: 4) -MAX_Default_COUNT = 4 +MAX_DEFAULT_COUNT = 4 ; max count of routes that will be checked (MAX_COUNT + custom routes ) (default: 6) LIMIT = 6 @@ -151,8 +178,6 @@ LOGIN = login SESSION_SUSPECT = session_suspect ; account deleted DELETE_ACCOUNT = account_delete -; unauthorized request (HTTP 401) -UNAUTHORIZED = unauthorized ; admin action (e.g. kick, bann) log ADMIN = admin ; TCP socket errors @@ -160,7 +185,15 @@ SOCKET_ERROR = socket_error ; debug log for development DEBUG = debug +[PATHFINDER.HISTORY] +; cache time for parsed log files (seconds) (default: 5) +CACHE = 5 +; file folder for 'history' logs (e.g. map history) (default: history/) +LOG = history/ + ; API ============================================================================================= [PATHFINDER.API] +CCP_IMAGE_SERVER = https://image.eveonline.com +Z_KILLBOARD = https://zkillboard.com/api ; GitHub Developer API GIT_HUB = https://api.github.com diff --git a/app/requirements.ini b/app/requirements.ini index 5d9f67526..3254f29ba 100644 --- a/app/requirements.ini +++ b/app/requirements.ini @@ -30,7 +30,10 @@ ZMQ = 1.1.3 ; https://pecl.php.net/package/event EVENT = 2.3.0 -; max execution time for requests +; exec() function required for run Shell scripts from PHP +EXEC = 1 + +; max execution time for requests (seconds) MAX_EXECUTION_TIME = 10 ; max variable size for $_GET, $_POST and $_COOKIE @@ -39,6 +42,9 @@ MAX_EXECUTION_TIME = 10 ; PHP default = 1000 MAX_INPUT_VARS = 3000 +; Formatted HTML StackTraces +HTML_ERRORS = 0 + [REQUIREMENTS.LIBS] ZMQ = 4.1.3 @@ -62,4 +68,7 @@ COLLATION_DATABASE = utf8_general_ci COLLATION_CONNECTION = utf8_general_ci FOREIGN_KEY_CHECKS = ON +[REQUIREMENTS.PATH] +NODE = 6.0 +NPM = 3.10.0 diff --git a/build.js b/build.js deleted file mode 100644 index 5229680b7..000000000 --- a/build.js +++ /dev/null @@ -1,214 +0,0 @@ -({ - //The top level directory that contains your app. If this option is used - //then it assumed your scripts are in a subdirectory under this path. - //This option is not required. If it is not specified, then baseUrl - //below is the anchor point for finding things. If this option is specified, - //then all the files from the app directory will be copied to the dir: - //output area, and baseUrl will assume to be a relative path under - //this directory. - appDir: './js', - - //By default, all modules are located relative to this path. If baseUrl - //is not explicitly set, then all modules are loaded relative to - //the directory that holds the build file. If appDir is set, then - //baseUrl should be specified as relative to the appDir. - baseUrl: './', - - //By default all the configuration for optimization happens from the command - //line or by properties in the config file, and configuration that was - //passed to requirejs as part of the app's runtime "main" JS file is *not* - //considered. However, if you prefer the "main" JS file configuration - //to be read for the build so that you do not have to duplicate the values - //in a separate configuration, set this property to the location of that - //main JS file. The first requirejs({}), require({}), requirejs.config({}), - //or require.config({}) call found in that file will be used. - //As of 2.1.10, mainConfigFile can be an array of values, with the last - //value's config take precedence over previous values in the array. - mainConfigFile: './js/app.js', - - //Specify modules to stub out in the optimized file. The optimizer will - //use the source version of these modules for dependency tracing and for - //plugin use, but when writing the text into an optimized bundle, these - //modules will get the following text instead: - //If the module is used as a plugin: - // define({load: function(id){throw new Error("Dynamic load not allowed: " + id);}}); - //If just a plain module: - // define({}); - //This is useful particularly for plugins that inline all their resources - //and use the default module resolution behavior (do *not* implement the - //normalize() method). In those cases, an AMD loader just needs to know - //that the module has a definition. These small stubs can be used instead of - //including the full source for a plugin. - //stubModules: ['text'], - - //As of RequireJS 2.0.2, the dir above will be deleted before the - //build starts again. If you have a big build and are not doing - //source transforms with onBuildRead/onBuildWrite, then you can - //set keepBuildDir to true to keep the previous dir. This allows for - //faster rebuilds, but it could lead to unexpected errors if the - //built code is transformed in some way. - keepBuildDir: false, - - //Finds require() dependencies inside a require() or define call. By default - //this value is false, because those resources should be considered dynamic/runtime - //calls. However, for some optimization scenarios, it is desirable to - //include them in the build. - //Introduced in 1.0.3. Previous versions incorrectly found the nested calls - //by default. - findNestedDependencies: false, - - - //Inlines the text for any text! dependencies, to avoid the separate - //async XMLHttpRequest calls to load those dependencies. - inlineText: false, - - //If set to true, any files that were combined into a build bundle will be - //removed from the output folder. - removeCombined: true, - - //List the modules that will be optimized. All their immediate and deep - //dependencies will be included in the module's file when the build is - //done. If that module or any of its dependencies includes i18n bundles, - //only the root bundles will be included unless the locale: section is set above. - modules: [ - //Just specifying a module name means that module will be converted into - //a built file that contains all of its dependencies. If that module or any - //of its dependencies includes i18n bundles, they may not be included in the - //built file unless the locale: section is set above. - { - name: 'login', - include: ['text'], - excludeShallow: [ - 'app' - ] - },{ - name: 'mappage', - include: ['text'], - excludeShallow: [ - 'app' - ] - },{ - name: 'setup', - excludeShallow: [ - 'app' - ] - },{ - name: 'admin', - excludeShallow: [ - 'app' - ] - },{ - name: 'app/notification', - excludeShallow: [ - 'app', - 'jquery' - ] - } - ], - - //By default, comments that have a license in them are preserved in the - //output when a minifier is used in the "optimize" option. - //However, for a larger built files there could be a lot of - //comment files that may be better served by having a smaller comment - //at the top of the file that points to the list of all the licenses. - //This option will turn off the auto-preservation, but you will need - //work out how best to surface the license information. - //NOTE: As of 2.1.7, if using xpcshell to run the optimizer, it cannot - //parse out comments since its native Reflect parser is used, and does - //not have the same comments option support as esprima. - preserveLicenseComments: false, // not working with "generate source maps" :( - - //Introduced in 2.1.2 and considered experimental. - //If the minifier specified in the "optimize" option supports generating - //source maps for the minified code, then generate them. The source maps - //generated only translate minified JS to non-minified JS, it does not do - //anything magical for translating minified JS to transpiled source code. - //Currently only optimize: "uglify2" is supported when running in node or - //rhino, and if running in rhino, "closure" with a closure compiler jar - //build after r1592 (20111114 release). - //The source files will show up in a browser developer tool that supports - //source maps as ".js.src" files. - generateSourceMaps: false, - - //Sets the logging level. It is a number. If you want "silent" running, - //set logLevel to 4. From the logger.js file: - //TRACE: 0, - //INFO: 1, - //WARN: 2, - //ERROR: 3, - //SILENT: 4 - //Default is 0. - logLevel: 0, - - //How to optimize all the JS files in the build output directory. - //Right now only the following values - //are supported: - //- "uglify": (default) uses UglifyJS to minify the code. - //- "uglify2": in version 2.1.2+. Uses UglifyJS2. - //- "closure": uses Google's Closure Compiler in simple optimization - //mode to minify the code. Only available if running the optimizer using - //Java. - //- "closure.keepLines": Same as closure option, but keeps line returns - //in the minified files. - //- "none": no minification will be done. - optimize: 'uglify2', - - //Introduced in 2.1.2: If using "dir" for an output directory, normally the - //optimize setting is used to optimize the build bundles (the "modules" - //section of the config) and any other JS file in the directory. However, if - //the non-build bundle JS files will not be loaded after a build, you can - //skip the optimization of those files, to speed up builds. Set this value - //to true if you want to skip optimizing those other non-build bundle JS - //files. - //skipDirOptimize: true, - - //If using UglifyJS2 for script optimization, these config options can be - //used to pass configuration values to UglifyJS2. - //For possible `output` values see: - //https://github.com/mishoo/UglifyJS2#beautifier-options - //For possible `compress` values see: - //https://github.com/mishoo/UglifyJS2#compressor-options - uglify2: { - //Example of a specialized config. If you are fine - //with the default options, no need to specify - //any of these properties. - output: { - beautify: false, - comments: false - }, - compress: { - sequences: false, - drop_console: true, - global_defs: { - DEBUG: false - } - }, - warnings: false, - mangle: true - }, - - //A function that will be called for every write to an optimized bundle - //of modules. This allows transforms of the content before serialization. - onBuildWrite: function (moduleName, path, contents) { - - // show module names for each file - if(moduleName === 'mappage'){ - // perform transformations on the original source - // contents = contents.replace( /#version/i, new Date().toString() ); - } - - return contents; - }, - - paths: { - app: './../js/app' // the main config file will not be build - }, - - //The directory path to save the output. If not specified, then - //the path will default to be a directory called "build" as a sibling - //to the build file. All relative paths are relative to the build file. - dir: './build_js' - - - -}) \ No newline at end of file diff --git a/composer-dev.json b/composer-dev.json index 206241bad..a0b84c784 100644 --- a/composer-dev.json +++ b/composer-dev.json @@ -21,8 +21,12 @@ }], "require": { "php-64bit": ">=7.0", - "ext-zmq": "1.1.*", + "ext-curl": ">=7.0", + "ext-zmq": ">=1.1.3", "react/zmq": "0.3.*", + "monolog/monolog": "1.*", + "websoftwares/monolog-zmq-handler": "0.2.*", + "swiftmailer/swiftmailer": "^6.0", "exodus4d/pathfinder_esi": "dev-develop as 0.0.x-dev" } } diff --git a/composer.json b/composer.json index 2c7e48b29..086fddf4e 100644 --- a/composer.json +++ b/composer.json @@ -21,8 +21,12 @@ }], "require": { "php-64bit": ">=7.0", - "ext-zmq": "1.1.*", + "ext-curl": ">=7.0", + "ext-zmq": ">=1.1.3", "react/zmq": "0.3.*", - "exodus4d/pathfinder_esi": "dev-master#v1.1.0" + "monolog/monolog": "1.*", + "websoftwares/monolog-zmq-handler": "0.2.*", + "swiftmailer/swiftmailer": "^6.0", + "exodus4d/pathfinder_esi": "dev-master#v1.2.1" } } diff --git a/export/sql/eve_citadel_min.sql.zip b/export/sql/eve_lifeblood_min.sql.zip similarity index 68% rename from export/sql/eve_citadel_min.sql.zip rename to export/sql/eve_lifeblood_min.sql.zip index b6dd80dad..d6d0be3fa 100644 Binary files a/export/sql/eve_citadel_min.sql.zip and b/export/sql/eve_lifeblood_min.sql.zip differ diff --git a/gulpfile.js b/gulpfile.js index b57823fd2..b39faf7f8 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -499,7 +499,7 @@ gulp.task('task:hintJS', () => { * concat/build JS files by modules */ gulp.task('task:concatJS', () => { - let modules = ['login', 'mappage', 'setup', 'admin', 'notification']; + let modules = ['login', 'mappage', 'setup', 'admin', 'notification', 'datatables.loader']; let srcModules = ['./js/app/*(' + modules.join('|') + ').js']; return gulp.src(srcModules, {base: 'js'}) @@ -854,11 +854,15 @@ gulp.task( 'production', gulp.series( 'task:configProduction', - 'task:cleanJsBuild', - 'task:cleanCssBuild', gulp.parallel( - 'task:buildJs', - 'task:watchCss' + gulp.series( + 'task:cleanJsBuild', + 'task:buildJs' + ), + gulp.series( + 'task:cleanCssBuild', + 'task:watchCss' + ) ) ) ); diff --git a/js/app.js b/js/app.js index 652d7af6e..6683161c5 100644 --- a/js/app.js +++ b/js/app.js @@ -11,7 +11,7 @@ requirejs.config({ paths: { layout: 'layout', - config: 'app/config', // path for "configuration" files dir + conf: 'app/conf', // path for "config" files dir dialog: 'app/ui/dialog', // path for "dialog" files dir templates: '../../templates', // template dir img: '../../img', // images dir @@ -59,11 +59,13 @@ requirejs.config({ tweenLite: 'lib/TweenLite.min', // datatables // v1.10.12 DataTables - https://datatables.net + 'datatables.loader': './app/datatables.loader', 'datatables.net': 'lib/datatables/DataTables-1.10.12/js/jquery.dataTables.min', 'datatables.net-buttons': 'lib/datatables/Buttons-1.2.1/js/dataTables.buttons.min', 'datatables.net-buttons-html': 'lib/datatables/Buttons-1.2.1/js/buttons.html5.min', 'datatables.net-responsive': 'lib/datatables/Responsive-2.1.0/js/dataTables.responsive.min', 'datatables.net-select': 'lib/datatables/Select-1.2.0/js/dataTables.select.min', + 'datatables.plugins.render.ellipsis': 'lib/datatables/plugins/render/ellipsis', // notification plugin pnotify: 'lib/pnotify/pnotify', // v3.0.0 PNotify - notification core file - https://sciactive.com/pnotify/ @@ -94,6 +96,9 @@ requirejs.config({ customScrollbar: { deps: ['jquery', 'mousewheel'] }, + 'datatables.loader': { + deps: ['jquery'] + }, 'datatables.net': { deps: ['jquery'] }, @@ -109,6 +114,9 @@ requirejs.config({ 'datatables.net-select': { deps: ['datatables.net'] }, + 'datatables.plugins.render.ellipsis': { + deps: ['datatables.net'] + }, xEditable: { deps: ['bootstrap'] }, diff --git a/js/app/admin.js b/js/app/admin.js index aecb732ac..18a7c5316 100644 --- a/js/app/admin.js +++ b/js/app/admin.js @@ -6,11 +6,7 @@ define([ 'jquery', 'app/init', 'app/util', - 'datatables.net', - 'datatables.net-buttons', - 'datatables.net-buttons-html', - 'datatables.net-responsive', - 'datatables.net-select' + 'datatables.loader' ], function($, Init, Util) { 'use strict'; diff --git a/js/app/config/signature_type.js b/js/app/conf/signature_type.js similarity index 100% rename from js/app/config/signature_type.js rename to js/app/conf/signature_type.js diff --git a/js/app/config/system_effect.js b/js/app/conf/system_effect.js similarity index 100% rename from js/app/config/system_effect.js rename to js/app/conf/system_effect.js diff --git a/js/app/datatables.loader.js b/js/app/datatables.loader.js new file mode 100644 index 000000000..82f451bc3 --- /dev/null +++ b/js/app/datatables.loader.js @@ -0,0 +1,11 @@ +define([ + 'datatables.net', + 'datatables.net-buttons', + 'datatables.net-buttons-html', + 'datatables.net-responsive', + 'datatables.net-select' +], (a, b) => { + 'use strict'; + + // all Datatables stuff is available... +}); \ No newline at end of file diff --git a/js/app/init.js b/js/app/init.js index 0b0e6f8fb..79360978f 100644 --- a/js/app/init.js +++ b/js/app/init.js @@ -31,6 +31,7 @@ define(['jquery'], function($) { deleteMap: 'api/map/delete', // ajax URL - delete map importMap: 'api/map/import', // ajax URL - import map getMapConnectionData: 'api/map/getConnectionData', // ajax URL - get connection data + getMapLogData: 'api/map/getLogData', // ajax URL - get logs data // system API searchSystem: 'api/system/search', // ajax URL - search system by name saveSystem: 'api/system/save', // ajax URL - saves system to map @@ -38,6 +39,7 @@ define(['jquery'], function($) { getSystemGraphData: 'api/system/graphData', // ajax URL - get all system graph data getConstellationData: 'api/system/constellationData', // ajax URL - get system constellation data setDestination: 'api/system/setDestination', // ajax URL - set destination + pokeRally: 'api/system/pokeRally', // ajax URL - send rally point pokes // connection API saveConnection: 'api/connection/save', // ajax URL - save new connection to map deleteConnection: 'api/connection/delete', // ajax URL - delete connection from map @@ -52,10 +54,6 @@ define(['jquery'], function($) { // GitHub API gitHubReleases: 'api/github/releases' // ajax URL - get release info from GitHub }, - url: { - ccpImageServer: '//image.eveonline.com/', // CCP image Server - zKillboard: '//zkillboard.com/api/' // killboard api - }, breakpoints: [ { name: 'desktop', width: Infinity }, { name: 'tablet', width: 1200 }, diff --git a/js/app/key.js b/js/app/key.js index 01a04cdb3..5e264c3b5 100644 --- a/js/app/key.js +++ b/js/app/key.js @@ -63,7 +63,7 @@ define([ }; /** - * enables some console.log() information + * enables some debug output in console * @type {boolean} */ let debug = false; @@ -297,20 +297,29 @@ define([ // global dom remove listener ------------------------------------------------------------------- // -> check whether the removed element had an event listener active and removes them. - document.body.addEventListener ('DOMNodeRemoved', function(e){ - if(typeof e.target.getAttribute === 'function'){ - let eventNames = e.target.getAttribute(dataKeyEvents); - if(eventNames){ - eventNames.split(',').forEach((event) => { - let index = allEvents[event].elements.indexOf(e.target); - if(index > -1){ - // remove element from event list - allEvents[event].elements.splice(index, 1); + new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + if(mutation.type === 'childList'){ + for (let i = 0; i < mutation.removedNodes.length; i++){ + let removedNode = mutation.removedNodes[i]; + if(typeof removedNode.getAttribute === 'function'){ + let eventNames = removedNode.getAttribute(dataKeyEvents); + if(eventNames){ + let events = eventNames.split(','); + for(let j = 0; i < events.length; j++){ + let event = events[j]; + let index = allEvents[event].elements.indexOf(removedNode); + if(index > -1){ + // remove element from event list + allEvents[event].elements.splice(index, 1); + } + } + } } - }); + } } - } - }, false); + }); + }).observe(document.body, { childList: true, subtree: true }); isInit = true; } diff --git a/js/app/logging.js b/js/app/logging.js index 61f62e948..0cfc5cf6d 100644 --- a/js/app/logging.js +++ b/js/app/logging.js @@ -80,7 +80,7 @@ define([ let showDialog = function(){ // dialog content - requirejs(['text!templates/dialog/task_manager.html', 'mustache'], function(templateTaskManagerDialog, Mustache) { + requirejs(['text!templates/dialog/task_manager.html', 'mustache', 'datatables.loader'], function(templateTaskManagerDialog, Mustache) { let data = { id: config.taskDialogId, dialogDynamicAreaClass: config.dialogDynamicAreaClass, diff --git a/js/app/login.js b/js/app/login.js index 2d189bb4e..30ed0997b 100644 --- a/js/app/login.js +++ b/js/app/login.js @@ -676,7 +676,8 @@ define([ dataType: 'json', context: { cookieName: requestData.cookie, - characterElement: characterElement + characterElement: characterElement, + browserTabId: Util.getBrowserTabId() } }).done(function(responseData, textStatus, request){ this.characterElement.hideLoadingAnimation(); @@ -698,9 +699,11 @@ define([ let data = { link: this.characterElement.data('href'), cookieName: this.cookieName, + browserTabId: this.browserTabId, character: responseData.character, authLabel: getCharacterAuthLabel(responseData.character.authStatus), - authOK: responseData.character.authStatus === 'OK' + authOK: responseData.character.authStatus === 'OK', + hasActiveSession: responseData.character.hasActiveSession === true }; let content = Mustache.render(template, data); @@ -766,6 +769,12 @@ define([ * main init "landing" page */ $(function(){ + // clear sessionStorage + Util.clearSessionStorage(); + + // set default AJAX config + Util.ajaxSetup(); + // set Dialog default config Util.initDefaultBootboxConfig(); diff --git a/js/app/map/local.js b/js/app/map/local.js index 29bdf747e..5c102a9d3 100644 --- a/js/app/map/local.js +++ b/js/app/map/local.js @@ -28,9 +28,9 @@ define([ overlayLocalJumpsClass: 'pf-map-overlay-local-jumps', // class for jump distance for table results // dataTable - tableImageCellClass: 'pf-table-image-cell', // class for table "image" cells - tableActionCellClass: 'pf-table-action-cell', // class for table "action" cells - tableActionCellIconClass: 'pf-table-action-icon-cell', // class for table "action" icon (icon is part of cell content) + tableCellImageClass: 'pf-table-image-cell', // class for table "image" cells + tableCellActionClass: 'pf-table-action-cell', // class for table "action" cells + tableCellActionIconClass: 'pf-table-action-icon-cell', // class for table "action" icon (icon is part of cell content) // toolbar toolbarClass: 'pf-map-overlay-toolbar', // class for toolbar - content @@ -288,236 +288,247 @@ define([ * @returns {*} */ $.fn.initLocalOverlay = function(mapId){ - return this.each(function(){ - let parentElement = $(this); + let parentElements = $(this); - let overlay = $('
', { - class: [config.overlayClass, config.overlayLocalClass].join(' ') - }); + require(['datatables.loader'], () => { + parentElements.each(function(){ + let parentElement = $(this); - let content = $('
', { - class: [ 'text-right', config.overlayLocalContentClass].join(' ') - }); + let overlay = $('
', { + class: [config.overlayClass, config.overlayLocalClass].join(' ') + }); - // crate new route table - let table = $('', { - class: ['compact', 'order-column', config.overlayLocalTableClass].join(' ') - }); + let content = $('
', { + class: [ 'text-right', config.overlayLocalContentClass].join(' ') + }); + + // crate new route table + let table = $('
', { + class: ['compact', 'order-column', config.overlayLocalTableClass].join(' ') + }); - let overlayMain = $('
', { - text: '', - class: config.overlayLocalMainClass - }).append( - $('', { - class: ['fa', 'fa-chevron-down', 'fa-fw', 'pf-animate-rotate', config.overlayLocalTriggerClass].join(' ') - }), - $('', { - class: ['badge', 'txt-color', 'txt-color-red', config.overlayLocalUsersClass].join(' '), - text: 0 - }), - $('
', { - class: config.overlayLocalJumpsClass + let overlayMain = $('
', { + text: '', + class: config.overlayLocalMainClass + }).append( + $('', { + class: ['fa', 'fa-chevron-down', 'fa-fw', 'pf-animate-rotate', config.overlayLocalTriggerClass].join(' ') + }), + $('', { + class: ['badge', 'txt-color', 'txt-color-red', config.overlayLocalUsersClass].join(' '), + text: 0 + }), + $('
', { + class: config.overlayLocalJumpsClass + }).append( + $('', { + class: ['badge', 'txt-color', 'txt-color-grayLight'].join(' '), + text: MapUtil.config.defaultLocalJumpRadius + }).attr('title', 'jumps') + ) + ); + + let headline = $('
', { + class: config.overlayLocalHeadlineClass }).append( $('', { - class: ['badge', 'txt-color', 'txt-color-grayLight'].join(' '), - text: MapUtil.config.defaultLocalJumpRadius - }).attr('title', 'jumps') - ) - ); - - let headline = $('
', { - class: config.overlayLocalHeadlineClass - }).append( - $('', { - html: 'Nearby   ', - class: 'pull-left' - }), - $(''), - $(''), - $('', { - class: ['badge', ' txt-color', 'txt-color-red'].join(' '), - text: 0 - }) - ); - - content.append(headline); - content.append(table); - // toolbar not used for now - // content.append(initToolbar()); - - overlay.append(overlayMain); - overlay.append(content); - - // set observer - setOverlayObserver(overlay, mapId); - - parentElement.append(overlay); - - // init local table --------------------------------------------------------------------------------------- - - table.on('draw.dt', function(e, settings){ - // init table tooltips - $(this).find('td').initTooltips({ - container: 'body', - placement: 'left' + html: 'Nearby   ', + class: 'pull-left' + }), + $(''), + $(''), + $('', { + class: ['badge', ' txt-color', 'txt-color-red'].join(' '), + text: 0 + }) + ); + + content.append(headline); + content.append(table); + // toolbar not used for now + // content.append(initToolbar()); + + overlay.append(overlayMain); + overlay.append(content); + + // set observer + setOverlayObserver(overlay, mapId); + + parentElement.append(overlay); + + // init local table --------------------------------------------------------------------------------------- + + table.on('draw.dt', function(e, settings){ + // init table tooltips + $(this).find('td').initTooltips({ + container: 'body', + placement: 'left' + }); + + // hide pagination in case of only one page + let paginationElement = overlay.find('.dataTables_paginate'); + let pageElements = paginationElement.find('span .paginate_button'); + if(pageElements.length <= 1){ + paginationElement.hide(); + }else{ + paginationElement.show(); + } }); - // hide pagination in case of only one page - let paginationElement = overlay.find('.dataTables_paginate'); - let pageElements = paginationElement.find('span .paginate_button'); - if(pageElements.length <= 1){ - paginationElement.hide(); - }else{ - paginationElement.show(); - } - }); - - // table init complete - table.on( 'init.dt', function (){ - // init table head tooltips - $(this).initTooltips({ - container: 'body', - placement: 'top' + // table init complete + table.on( 'init.dt', function (){ + // init table head tooltips + $(this).initTooltips({ + container: 'body', + placement: 'top' + }); }); - }); - let localTable = table.DataTable( { - pageLength: 13, // hint: if pagination visible => we need space to show it - paging: true, - lengthChange: false, - ordering: true, - order: [ 0, 'asc' ], - info: false, - searching: false, - hover: false, - autoWidth: false, - rowId: function(rowData) { - return 'pf-local-row_' + rowData.id; // characterId - }, - language: { - emptyTable: 'You are alone' - }, - columnDefs: [ - { - targets: 0, - orderable: true, - title: ' ', - width: '1px', - className: ['pf-help-default', 'text-center'].join(' '), - data: 'jumps', - render: { - _: function(data, type, row, meta){ - let value = data; - if(type === 'display'){ - if(value === 0){ - value = ''; + let localTable = table.DataTable( { + pageLength: 13, // hint: if pagination visible => we need space to show it + paging: true, + lengthChange: false, + ordering: true, + order: [ 0, 'asc' ], + info: false, + searching: false, + hover: false, + autoWidth: false, + rowId: function(rowData) { + return 'pf-local-row_' + rowData.id; // characterId + }, + language: { + emptyTable: 'You are alone' + }, + columnDefs: [ + { + targets: 0, + orderable: true, + title: ' ', + width: '1px', + className: ['pf-help-default', 'text-center'].join(' '), + data: 'jumps', + render: { + _: function(data, type, row, meta){ + let value = data; + if(type === 'display'){ + if(value === 0){ + value = ''; + } } + return value; } - return value; - } - }, - createdCell: function(cell, cellData, rowData, rowIndex, colIndex){ - let api = this.DataTable(); - initCellTooltip(api, cell, 'log.system.name'); - } - },{ - targets: 1, - orderable: false, - title: '', - width: '26px', - className: ['pf-help-default', 'text-center', config.tableImageCellClass].join(' '), - data: 'log.ship', - render: { - _: function(data, type, row, meta){ - let value = data.typeName; - if(type === 'display'){ - value = ''; - } - return value; + }, + createdCell: function(cell, cellData, rowData, rowIndex, colIndex){ + let api = this.DataTable(); + initCellTooltip(api, cell, 'log.system.name'); } - }, - createdCell: function(cell, cellData, rowData, rowIndex, colIndex){ - let api = this.DataTable(); - initCellTooltip(api, cell, 'log.ship.typeName'); - } - }, { - targets: 2, - orderable: true, - title: 'ship name', - width: '80px', - data: 'log.ship', - render: { - _: function(data, type, row, meta){ - let value = data.name; - if(type === 'display'){ - value = '
' + data.name + '
'; + },{ + targets: 1, + orderable: false, + title: '', + width: '26px', + className: ['pf-help-default', 'text-center', config.tableCellImageClass].join(' '), + data: 'log.ship', + render: { + _: function(data, type, row, meta){ + let value = data.typeName; + if(type === 'display'){ + value = ''; + } + return value; } - return value; }, - sort: 'name' - } - },{ - targets: 3, - orderable: true, - title: 'pilot', - data: 'name', - render: { - _: function(data, type, row, meta){ - let value = data; - if(type === 'display'){ - value = '
' + data + '
'; + createdCell: function(cell, cellData, rowData, rowIndex, colIndex){ + let api = this.DataTable(); + initCellTooltip(api, cell, 'log.ship.typeName'); + } + }, { + targets: 2, + orderable: true, + title: 'ship name', + width: '80px', + data: 'log.ship', + render: { + _: function(data, type, row, meta){ + let value = data.name; + if(type === 'display'){ + value = '
' + data.name + '
'; + } + return value; + }, + sort: 'name' + } + },{ + targets: 3, + orderable: true, + title: 'pilot', + data: 'name', + render: { + _: function(data, type, row, meta){ + let value = data; + if(type === 'display'){ + value = '
' + data + '
'; + } + return value; } - return value; } - } - },{ - targets: 4, - orderable: false, - title: '', - width: '10px', - className: ['pf-help-default'].join(' '), - data: 'log.station', - render: { - _: function(data, type, row, meta){ - let value = ''; - if( - type === 'display' && - data.id - ){ - value = ''; + },{ + targets: 4, + orderable: false, + title: '', + width: '10px', + className: ['pf-help-default'].join(' '), + data: 'log', + render: { + _: function(data, type, row, meta){ + let value = ''; + if(type === 'display'){ + if(data.station && data.station.id > 0){ + value = ''; + }else if(data.structure && data.structure.id > 0){ + value = ''; + } + } + return value; } - return value; + }, + createdCell: function(cell, cellData, rowData, rowIndex, colIndex){ + let selector = ''; + if(cellData.station && cellData.station.id > 0){ + selector = 'log.station.name'; + }else if(cellData.structure && cellData.structure.id > 0){ + selector = 'log.structure.name'; + } + let api = this.DataTable(); + initCellTooltip(api, cell, selector); } - }, - createdCell: function(cell, cellData, rowData, rowIndex, colIndex){ - let api = this.DataTable(); - initCellTooltip(api, cell, 'log.station.name'); - } - },{ - targets: 5, - orderable: false, - title: '', - width: '10px', - className: [config.tableActionCellClass].join(' '), - data: 'id', - render: { - _: function(data, type, row, meta){ - let value = data; - if(type === 'display'){ - value = ''; + },{ + targets: 5, + orderable: false, + title: '', + width: '10px', + className: [config.tableCellActionClass].join(' '), + data: 'id', + render: { + _: function(data, type, row, meta){ + let value = data; + if(type === 'display'){ + value = ''; + } + return value; } - return value; + }, + createdCell: function(cell, cellData, rowData, rowIndex, colIndex){ + // open character information window (ingame) + $(cell).on('click', { tableApi: this.DataTable(), cellData: cellData }, function(e){ + let cellData = e.data.tableApi.cell(this).data(); + Util.openIngameWindow(e.data.cellData); + }); } - }, - createdCell: function(cell, cellData, rowData, rowIndex, colIndex){ - // open character information window (ingame) - $(cell).on('click', { tableApi: this.DataTable(), cellData: cellData }, function(e){ - let cellData = e.data.tableApi.cell(this).data(); - Util.openIngameWindow(e.data.cellData); - }); } - } - ] + ] + }); }); }); }; diff --git a/js/app/map/map.js b/js/app/map/map.js index 687dfba35..8cd97831b 100644 --- a/js/app/map/map.js +++ b/js/app/map/map.js @@ -945,14 +945,14 @@ define([ mapContainer = $(mapContainer); // add additional information for this map - if(mapContainer.data('updated') !== mapConfig.config.updated){ + if(mapContainer.data('updated') !== mapConfig.config.updated.updated){ mapContainer.data('name', mapConfig.config.name); mapContainer.data('scopeId', mapConfig.config.scope.id); mapContainer.data('typeId', mapConfig.config.type.id); mapContainer.data('typeName', mapConfig.config.type.name); mapContainer.data('icon', mapConfig.config.icon); - mapContainer.data('created', mapConfig.config.created); - mapContainer.data('updated', mapConfig.config.updated); + mapContainer.data('created', mapConfig.config.created.created); + mapContainer.data('updated', mapConfig.config.updated.updated); } // get map data @@ -1320,11 +1320,10 @@ define([ * @returns {boolean} */ let isValidSystem = function(systemData){ - let isValid = true; if( - ! systemData.hasOwnProperty('name') || + !systemData.hasOwnProperty('name') || systemData.name.length === 0 ){ return false; @@ -1382,37 +1381,51 @@ define([ /** * save a new system and add it to the map - * @param map * @param requestData - * @param sourceSystem - * @param callback + * @param context */ - let saveSystem = function(map, requestData, sourceSystem, callback){ + let saveSystem = function(requestData, context){ $.ajax({ type: 'POST', url: Init.path.saveSystem, data: requestData, dataType: 'json', - context: { - map: map, - sourceSystem: sourceSystem - } - }).done(function(newSystemData){ - Util.showNotify({title: 'New system', text: newSystemData.name, type: 'success'}); + context: context + }).done(function(responseData){ + let newSystemData = responseData.systemData; - // draw new system to map - drawSystem(this.map, newSystemData, this.sourceSystem); + if( !$.isEmptyObject(newSystemData) ){ + Util.showNotify({title: 'New system', text: newSystemData.name, type: 'success'}); - // re/arrange systems (prevent overlapping) - MagnetizerWrapper.setElements(this.map); + // draw new system to map + drawSystem(this.map, newSystemData, this.sourceSystem); + + // re/arrange systems (prevent overlapping) + MagnetizerWrapper.setElements(this.map); + + if(this.onSuccess){ + this.onSuccess(); + } + } - if(callback){ - callback(); + // show errors + if( + responseData.error && + responseData.error.length > 0 + ){ + for(let i = 0; i < responseData.error.length; i++){ + let error = responseData.error[i]; + Util.showNotify({title: error.field + ' error', text: 'System: ' + error.message, type: error.type}); + } } }).fail(function( jqXHR, status, error) { let reason = status + ' ' + error; Util.showNotify({title: jqXHR.status + ': saveSystem', text: reason, type: 'warning'}); $(document).setProgramStatus('problem'); + }).always(function(){ + if(this.onAlways){ + this.onAlways(this); + } }); }; @@ -1542,8 +1555,18 @@ define([ } }; - saveSystem(map, requestData, sourceSystem, function(){ - bootbox.hideAll(); + this.find('.modal-content').showLoadingAnimation(); + + saveSystem(requestData, { + map: map, + sourceSystem: sourceSystem, + systemDialog: this, + onSuccess: () => { + bootbox.hideAll(); + }, + onAlways: (context) => { + context.systemDialog.find('.modal-content').hideLoadingAnimation(); + } }); return false; } @@ -1694,7 +1717,8 @@ define([ mapId: mapId, oldConnectionData: connectionData } - }).done(function(newConnectionData){ + }).done(function(responseData){ + let newConnectionData = responseData.connectionData; if( !$.isEmptyObject(newConnectionData) ){ let updateCon = false; @@ -1739,6 +1763,16 @@ define([ this.map.detach(this.connection, {fireEvent: false}); } + // show errors + if( + responseData.error && + responseData.error.length > 0 + ){ + for(let i = 0; i < responseData.error.length; i++){ + let error = responseData.error[i]; + Util.showNotify({title: error.field + ' error', text: 'System: ' + error.message, type: error.type}); + } + } }).fail(function( jqXHR, status, error) { // remove this connection from map this.map.detach(this.connection, {fireEvent: false}); @@ -1826,7 +1860,6 @@ define([ let moduleData = { id: config.mapContextMenuId, items: [ - {icon: 'fa-street-view', action: 'info', text: 'information'}, {icon: 'fa-plus', action: 'add_system', text: 'add system'}, {icon: 'fa-object-ungroup', action: 'select_all', text: 'select all'}, {icon: 'fa-filter', action: 'filter_scope', text: 'filter scope', subitems: [ @@ -1834,6 +1867,10 @@ define([ {subIcon: '', subAction: 'filter_stargate', subText: 'stargate'}, {subIcon: '', subAction: 'filter_jumpbridge', subText: 'jumpbridge'} ]}, + {icon: 'fa-sitemap', action: 'map', text: 'map', subitems: [ + {subIcon: 'fa-edit', subAction: 'map_edit', subText: 'edit map'}, + {subIcon: 'fa-street-view', subAction: 'map_info', subText: 'map info'}, + ]}, {divider: true, action: 'delete_systems'}, {icon: 'fa-trash', action: 'delete_systems', text: 'delete systems'} ] @@ -1905,7 +1942,7 @@ define([ items: [ {icon: 'fa-plus', action: 'add_system', text: 'add system'}, {icon: 'fa-lock', action: 'lock_system', text: 'lock system'}, - {icon: 'fa-users', action: 'set_rally', text: 'set rally point'}, + {icon: 'fa-volume-up', action: 'set_rally', text: 'set rally point'}, {icon: 'fa-tags', text: 'set status', subitems: systemStatus}, {icon: 'fa-reply fa-rotate-180', text: 'waypoints', subitems: [ {subIcon: 'fa-flag-checkered', subAction: 'set_destination', subText: 'set destination'}, @@ -2731,8 +2768,12 @@ define([ let selectedSystems = currentMapElement.getSelectedSystems(); $.fn.showDeleteSystemDialog(currentMap, selectedSystems); break; - case 'info': - // open map info dialog + case 'map_edit': + // open map edit dialog tab + $(document).triggerMenuEvent('ShowMapSettings', {tab: 'edit'}); + break; + case 'map_info': + // open map info dialog tab $(document).triggerMenuEvent('ShowMapInfo', {tab: 'information'}); break; @@ -2893,37 +2934,38 @@ define([ let mapElement = $(this); let mapOverlay = mapElement.getMapOverlay('local'); - let currentCharacterLog = Util.getCurrentCharacterLog(); - let currentMapData = Util.getCurrentMapData(userData.config.id); - let clearLocal = true; + if(userData && userData.config && userData.config.id){ + let currentMapData = Util.getCurrentMapData(userData.config.id); + let currentCharacterLog = Util.getCurrentCharacterLog(); + let clearLocal = true; - if( - currentMapData && - currentCharacterLog && - currentCharacterLog.system - ){ - let currentSystemData = currentMapData.data.systems.filter(function (system) { - return system.systemId === currentCharacterLog.system.id; - }); + if( + currentMapData && + currentCharacterLog && + currentCharacterLog.system + ){ + let currentSystemData = currentMapData.data.systems.filter(function (system) { + return system.systemId === currentCharacterLog.system.id; + }); - if(currentSystemData.length){ - // current user system is on this map - currentSystemData = currentSystemData[0]; + if(currentSystemData.length){ + // current user system is on this map + currentSystemData = currentSystemData[0]; - // check for active users "nearby" (x jumps radius) - let nearBySystemData = Util.getNearBySystemData(currentSystemData, currentMapData, MapUtil.config.defaultLocalJumpRadius); - let nearByCharacterData = Util.getNearByCharacterData(nearBySystemData, userData.data.systems); + // check for active users "nearby" (x jumps radius) + let nearBySystemData = Util.getNearBySystemData(currentSystemData, currentMapData, MapUtil.config.defaultLocalJumpRadius); + let nearByCharacterData = Util.getNearByCharacterData(nearBySystemData, userData.data.systems); - // update "local" table in overlay - mapOverlay.updateLocalTable(currentSystemData, nearByCharacterData); - clearLocal = false; + // update "local" table in overlay + mapOverlay.updateLocalTable(currentSystemData, nearByCharacterData); + clearLocal = false; + } } - } - if(clearLocal){ - mapOverlay.clearLocalTable(); + if(clearLocal){ + mapOverlay.clearLocalTable(); + } } - }); }; diff --git a/js/app/map/system.js b/js/app/map/system.js index 2b149f934..b5dac0ffb 100644 --- a/js/app/map/system.js +++ b/js/app/map/system.js @@ -18,7 +18,19 @@ define([ y: 0 }, - systemActiveClass: 'pf-system-active' // class for an active system in a map + systemActiveClass: 'pf-system-active', // class for an active system in a map + + dialogRallyId: 'pf-rally-dialog', // id for "Rally point" dialog + + dialogRallyPokeDesktopId: 'pf-rally-dialog-poke-desktop', // id for "desktop" poke checkbox + dialogRallyPokeSlackId: 'pf-rally-dialog-poke-slack', // id for "Slack" poke checkbox + dialogRallyPokeMailId: 'pf-rally-dialog-poke-mail', // id for "mail" poke checkbox + dialogRallyMessageId: 'pf-rally-dialog-message', // id for "message" textarea + + dialogRallyMessageDefault: '' + + 'I need some help!\n\n' + + '- Potential PvP options around\n' + + '- DPS and Logistic ships needed' }; /** @@ -26,41 +38,105 @@ define([ * @param system */ $.fn.showRallyPointDialog = (system) => { + let mapData = Util.getCurrentMapData(system.data('mapid')); + requirejs(['text!templates/dialog/system_rally.html', 'mustache'], function(template, Mustache) { + + let setCheckboxObserver = (checkboxes) => { + checkboxes.each(function(){ + $(this).on('change', function(){ + // check all others + let allUnchecked = true; + checkboxes.each(function(){ + if(this.checked){ + allUnchecked = false; + } + }); + let textareaElement = $('#' + config.dialogRallyMessageId); + if(allUnchecked){ + textareaElement.prop('disabled', true); + }else{ + textareaElement.prop('disabled', false); + } + }); + }); + }; + + let sendPoke = (requestData, context) => { + // lock dialog + let dialogContent = context.rallyDialog.find('.modal-content'); + dialogContent.showLoadingAnimation(); + + $.ajax({ + type: 'POST', + url: Init.path.pokeRally, + data: requestData, + dataType: 'json', + context: context + }).done(function(data){ + + }).fail(function( jqXHR, status, error) { + let reason = status + ' ' + error; + Util.showNotify({title: jqXHR.status + ': sendPoke', text: reason, type: 'warning'}); + }).always(function(){ + this.rallyDialog.find('.modal-content').hideLoadingAnimation(); + }); + }; + let data = { - notificationStatus: Init.notificationStatus.rallySet + id: config.dialogRallyId, + + dialogRallyPokeDesktopId: config.dialogRallyPokeDesktopId, + dialogRallyPokeSlackId: config.dialogRallyPokeSlackId, + dialogRallyPokeMailId: config.dialogRallyPokeMailId, + dialogRallyMessageId: config.dialogRallyMessageId , + + desktopRallyEnabled: true, + slackRallyEnabled: Boolean(Util.getObjVal(mapData, 'config.logging.slackRally')), + mailRallyEnabled: Boolean(Util.getObjVal(mapData, 'config.logging.mailRally')), + dialogRallyMessageDefault: config.dialogRallyMessageDefault, + + systemId: system.data('id') }; let content = Mustache.render(template, data); let rallyDialog = bootbox.dialog({ message: content, - title: 'Set rally point for "' + system.getSystemInfo( ['alias'] ) + '"', + title: 'Set rally point in "' + system.getSystemInfo( ['alias'] ) + '"', buttons: { close: { label: 'cancel', className: 'btn-default' }, - setRallyPoke: { - label: ' set rally and poke', - className: 'btn-primary', - callback: function() { - system.setSystemRally(1, { - poke: true - }); - system.markAsChanged(); - } - }, success: { - label: ' set rally', + label: ' set rally point', className: 'btn-success', callback: function() { - system.setSystemRally(1); + let form = $('#' + config.dialogRallyId).find('form'); + // get form data + let formData = form.getFormValues(); + + // update map + system.setSystemRally(1, { + poke: Boolean(formData.pokeDesktop) + }); system.markAsChanged(); + + // send poke data to server + sendPoke(formData, { + rallyDialog: this + }); } } } }); + + // after modal is shown ================================================================================== + rallyDialog.on('shown.bs.modal', function(e){ + // set event for checkboxes + setCheckboxObserver(rallyDialog.find(':checkbox')); + }); }); }; diff --git a/js/app/mappage.js b/js/app/mappage.js index a856c0a60..34a0d29bf 100644 --- a/js/app/mappage.js +++ b/js/app/mappage.js @@ -23,6 +23,9 @@ define([ $(() => { Util.initPrototypes(); + // clear sessionStorage + //Util.clearSessionStorage(); + // set default AJAX config Util.ajaxSetup(); @@ -63,7 +66,8 @@ define([ Init.systemType = initData.systemType; Init.characterStatus = initData.characterStatus; Init.routes = initData.routes; - Init.notificationStatus = initData.notificationStatus; + Init.url = initData.url; + Init.slack = initData.slack; Init.routeSearch = initData.routeSearch; Init.programMode = initData.programMode; @@ -165,8 +169,7 @@ define([ if(jqXHR.responseJSON){ // handle JSON - let errorObj = $.parseJSON(jqXHR.responseText); - + let errorObj = jqXHR.responseJSON; if( errorObj.error && errorObj.error.length > 0 @@ -182,7 +185,6 @@ define([ } $(document).trigger('pf:shutdown', {status: jqXHR.status, reason: reason, error: errorData}); - }; /** diff --git a/js/app/module_map.js b/js/app/module_map.js index 59fad3fc1..1337d183a 100644 --- a/js/app/module_map.js +++ b/js/app/module_map.js @@ -9,12 +9,7 @@ define([ 'app/ui/system_graph', 'app/ui/system_signature', 'app/ui/system_route', - 'app/ui/system_killboard', - 'datatables.net', - 'datatables.net-buttons', - 'datatables.net-buttons-html', - 'datatables.net-responsive', - 'datatables.net-select' + 'app/ui/system_killboard' ], function($, Init, Util, Map, MapUtil) { 'use strict'; @@ -106,29 +101,31 @@ define([ * @param tabContentElement */ let drawSystemModules = function(tabContentElement){ - let currentSystemData = Util.getCurrentSystemData(); + require(['datatables.loader'], () => { + let currentSystemData = Util.getCurrentSystemData(); - // get Table cell for system Info - let firstCell = $(tabContentElement).find('.' + config.mapTabContentCellFirst); - let secondCell = $(tabContentElement).find('.' + config.mapTabContentCellSecond); + // get Table cell for system Info + let firstCell = $(tabContentElement).find('.' + config.mapTabContentCellFirst); + let secondCell = $(tabContentElement).find('.' + config.mapTabContentCellSecond); - // draw system info module - firstCell.drawSystemInfoModule(currentSystemData.mapId, currentSystemData.systemData); + // draw system info module + firstCell.drawSystemInfoModule(currentSystemData.mapId, currentSystemData.systemData); - // draw system graph module - firstCell.drawSystemGraphModule(currentSystemData.systemData); + // draw system graph module + firstCell.drawSystemGraphModule(currentSystemData.systemData); - // draw signature table module - firstCell.drawSignatureTableModule(currentSystemData.mapId, currentSystemData.systemData); + // draw signature table module + firstCell.drawSignatureTableModule(currentSystemData.mapId, currentSystemData.systemData); - // draw system routes module - secondCell.drawSystemRouteModule(currentSystemData.mapId, currentSystemData.systemData); + // draw system routes module + secondCell.drawSystemRouteModule(currentSystemData.mapId, currentSystemData.systemData); - // draw system killboard module - secondCell.drawSystemKillboardModule(currentSystemData.systemData); + // draw system killboard module + secondCell.drawSystemKillboardModule(currentSystemData.systemData); - // set Module Observer - setModuleObserver(); + // set Module Observer + setModuleObserver(); + }); }; /** @@ -295,7 +292,12 @@ define([ let tabElement = $(this); // set "main" data - tabElement.data('map-id', options.id).data('updated', options.updated); + tabElement.data('map-id', options.id); + + // add updated timestamp (not available for "add" tab + if(Util.getObjVal(options, 'updated.updated')){ + tabElement.data('updated', options.updated.updated); + } // change "tab" link tabElement.attr('href', '#' + config.mapTabIdPrefix + options.id); @@ -565,7 +567,7 @@ define([ activeMapIds.push(mapId); // check for map data change and update tab - if(tabMapData.config.updated > tabElement.data('updated')){ + if(tabMapData.config.updated.updated > tabElement.data('updated')){ tabElement.updateTabData(tabMapData.config); } }else{ diff --git a/js/app/page.js b/js/app/page.js index 1a7613af9..bfc863f92 100644 --- a/js/app/page.js +++ b/js/app/page.js @@ -198,8 +198,7 @@ define([ getMenuHeadline('Information') ).append( $('', { - class: 'list-group-item list-group-item-info', - href: '#' + class: 'list-group-item list-group-item-info' }).html('  Statistics').prepend( $('',{ class: 'fa fa-line-chart fa-fw' @@ -209,8 +208,7 @@ define([ }) ).append( $('', { - class: 'list-group-item list-group-item-info', - href: '#' + class: 'list-group-item list-group-item-info' }).html('  Effect info').prepend( $('',{ class: 'fa fa-crosshairs fa-fw' @@ -220,8 +218,7 @@ define([ }) ).append( $('', { - class: 'list-group-item list-group-item-info', - href: '#' + class: 'list-group-item list-group-item-info' }).html('  Jump info').prepend( $('',{ class: 'fa fa-space-shuttle fa-fw' @@ -233,8 +230,7 @@ define([ getMenuHeadline('Settings') ).append( $('', { - class: 'list-group-item', - href: '#' + class: 'list-group-item' }).html('  Account').prepend( $('',{ class: 'fa fa-user fa-fw' @@ -245,8 +241,7 @@ define([ ).append( $('', { class: 'list-group-item hide', // trigger by js - id: Util.config.menuButtonFullScreenId, - href: '#' + id: Util.config.menuButtonFullScreenId }).html('  Full screen').prepend( $('',{ class: 'glyphicon glyphicon-fullscreen', @@ -265,8 +260,7 @@ define([ }) ).append( $('', { - class: 'list-group-item', - href: '#' + class: 'list-group-item' }).html('  Notification test').prepend( $('',{ class: 'fa fa-volume-up fa-fw' @@ -278,8 +272,7 @@ define([ getMenuHeadline('Danger zone') ).append( $('', { - class: 'list-group-item list-group-item-danger', - href: '#' + class: 'list-group-item list-group-item-danger' }).html('  Delete account').prepend( $('',{ class: 'fa fa-user-times fa-fw' @@ -289,8 +282,7 @@ define([ }) ).append( $('', { - class: 'list-group-item list-group-item-warning', - href: '#' + class: 'list-group-item list-group-item-warning' }).html('  Logout').prepend( $('',{ class: 'fa fa-sign-in fa-fw' @@ -317,8 +309,7 @@ define([ class: 'list-group' }).append( $('', { - class: 'list-group-item', - href: '#' + class: 'list-group-item' }).html('  Information').prepend( $('',{ class: 'fa fa-street-view fa-fw' @@ -327,12 +318,11 @@ define([ $(document).triggerMenuEvent('ShowMapInfo', {tab: 'information'}); }) ).append( - getMenuHeadline('Settings') + getMenuHeadline('Configuration') ).append( $('', { - class: 'list-group-item', - href: '#' - }).html('  Configuration').prepend( + class: 'list-group-item' + }).html('  Settings').prepend( $('',{ class: 'fa fa-gears fa-fw' }) @@ -342,8 +332,7 @@ define([ ).append( $('', { class: 'list-group-item', - id: Util.config.menuButtonGridId, - href: '#' + id: Util.config.menuButtonGridId }).html('   Grid snapping').prepend( $('',{ class: 'glyphicon glyphicon-th' @@ -357,8 +346,7 @@ define([ ).append( $('', { class: 'list-group-item', - id: Util.config.menuButtonMagnetizerId, - href: '#' + id: Util.config.menuButtonMagnetizerId }).html('   Magnetizing').prepend( $('',{ class: 'fa fa-magnet fa-fw' @@ -372,8 +360,7 @@ define([ ).append( $('', { class: 'list-group-item', - id: Util.config.menuButtonEndpointId, - href: '#' + id: Util.config.menuButtonEndpointId }).html('   Signatures').prepend( $('',{ class: 'fa fa-link fa-fw' @@ -388,8 +375,7 @@ define([ getMenuHeadline('Help') ).append( $('', { - class: 'list-group-item list-group-item-info', - href: '#' + class: 'list-group-item list-group-item-info' }).html('  Manual').prepend( $('',{ class: 'fa fa-book fa-fw' @@ -399,8 +385,7 @@ define([ }) ).append( $('', { - class: 'list-group-item list-group-item-info', - href: '#' + class: 'list-group-item list-group-item-info' }).html('  Shortcuts').prepend( $('',{ class: 'fa fa-keyboard-o fa-fw' @@ -410,8 +395,7 @@ define([ }) ).append( $('', { - class: 'list-group-item list-group-item-info', - href: '#' + class: 'list-group-item list-group-item-info' }).html('  Task-Manager').prepend( $('',{ class: 'fa fa-tasks fa-fw' @@ -423,8 +407,7 @@ define([ getMenuHeadline('Danger zone') ).append( $('', { - class: 'list-group-item list-group-item-danger', - href: '#' + class: 'list-group-item list-group-item-danger' }).html('  Delete map').prepend( $('',{ class: 'fa fa-trash fa-fw' @@ -482,11 +465,13 @@ define([ }); // main menus - $('.' + config.headMenuClass).on('click', function() { + $('.' + config.headMenuClass).on('click', function(e) { + e.preventDefault(); slideMenu.slidebars.toggle('left'); }); - $('.' + config.headMapClass).on('click', function() { + $('.' + config.headMapClass).on('click', function(e) { + e.preventDefault(); slideMenu.slidebars.toggle('right'); }); @@ -872,7 +857,7 @@ define([ animateHeaderElement(userInfoElement, function(){ if(currentCharacterChanged){ userInfoElement.find('span').text( newCharacterName ); - userInfoElement.find('img').attr('src', Init.url.ccpImageServer + 'Character/' + newCharacterId + '_32.jpg' ); + userInfoElement.find('img').attr('src', Init.url.ccpImageServer + '/Character/' + newCharacterId + '_32.jpg' ); } // init "character switch" popover userInfoElement.initCharacterSwitchPopover(userData); @@ -894,7 +879,7 @@ define([ // toggle element animateHeaderElement(userShipElement, function(){ userShipElement.find('span').text( newShipName ); - userShipElement.find('img').attr('src', Init.url.ccpImageServer + 'Render/' + newShipId + '_32.png' ); + userShipElement.find('img').attr('src', Init.url.ccpImageServer + '/Render/' + newShipId + '_32.png' ); }, showShipElement); // set new id for next check @@ -1104,10 +1089,12 @@ define([ if( statusElement.data('status') !== status ){ // status has changed if(! programStatusInterval){ + // check if timer exists if not -> set default (in case of the "init" ajax call failed + let programStatusVisible = Init.timer ? Init.timer.PROGRAM_STATUS_VISIBLE : 5000; let timer = function(){ // change status on first timer iteration - if(programStatusCounter === Init.timer.PROGRAM_STATUS_VISIBLE){ + if(programStatusCounter === programStatusVisible){ statusElement.velocity('stop').velocity('fadeOut', { duration: Init.animationSpeed.headerLink, @@ -1134,8 +1121,8 @@ define([ } }; - if(! programStatusInterval){ - programStatusCounter = Init.timer.PROGRAM_STATUS_VISIBLE; + if(!programStatusInterval){ + programStatusCounter = programStatusVisible; programStatusInterval = setInterval(timer, 1000); } } diff --git a/js/app/render.js b/js/app/render.js index 66676088e..bbe43ca52 100644 --- a/js/app/render.js +++ b/js/app/render.js @@ -11,7 +11,7 @@ define(['jquery', 'mustache'], function($, Mustache) { * @param functionName * @param config */ - var initModule = function(functionName, config){ + let initModule = function(functionName, config){ if( typeof config.functions === 'object' && @@ -26,7 +26,7 @@ define(['jquery', 'mustache'], function($, Mustache) { * @param config * @param data */ - var showModule = function(config, data){ + let showModule = function(config, data){ // require module template requirejs(['text!templates/' + config.name + '.html'], function(template) { @@ -37,7 +37,7 @@ define(['jquery', 'mustache'], function($, Mustache) { $('#' + data.id).length === 0 ){ - var content = Mustache.render(template, data); + let content = Mustache.render(template, data); // display module switch(config.link){ @@ -62,8 +62,130 @@ define(['jquery', 'mustache'], function($, Mustache) { }); }; + /** + * convert JSON object into HTML highlighted string + * @param obj + */ + let highlightJson = (obj) => { + let multiplyString = (num, str) => { + let sb = []; + for (let i = 0; i < num; i++) { + sb.push(str); + } + return sb.join(''); + }; + + let dateObj = new Date(); + let regexpObj = new RegExp(); + let tab = multiplyString(1, ' '); + let isCollapsible = true; + let quoteKeys = false; + let expImageClicked = '(() => {let container=this.parentNode.nextSibling; container.style.display=container.style.display===\'none\'?\'inline\':\'none\'})();'; + + let checkForArray = function (obj) { + return obj && + typeof obj === 'object' && + typeof obj.length === 'number' && + !(obj.propertyIsEnumerable('length')); + }; + + let getRow = function (indent, data, isPropertyContent) { + let tabs = ''; + for (let i = 0; i < indent && !isPropertyContent; i++) tabs += tab; + if (data !== null && data.length > 0 && data.charAt(data.length - 1) !== '\n') + data = data + '\n'; + return tabs + data; + }; + + let formatLiteral = function (literal, quote, comma, indent, isArray, style) { + if (typeof literal === 'string') + literal = literal.split('<').join('<').split('>').join('>'); + let str = '' + quote + literal + quote + comma + ''; + if (isArray) str = getRow(indent, str); + return str; + }; + + let formatFunction = function (indent, obj) { + let tabs = ''; + for (let i = 0; i < indent; i++) tabs += tab; + let funcStrArray = obj.toString().split('\n'); + let str = ''; + for (let i = 0; i < funcStrArray.length; i++) { + str += ((i === 0) ? '' : tabs) + funcStrArray[i] + '\n'; + } + return str; + }; + + + let highlight = (obj, indent, addComma, isArray, isPropertyContent) => { + let html = ''; + + let comma = (addComma) ? ', ' : ''; + let type = typeof obj; + let clpsHtml = ''; + if (checkForArray(obj)) { + if (obj.length === 0) { + html += getRow(indent, '[ ]' + comma, isPropertyContent); + } else { + clpsHtml = isCollapsible ? '' : ''; + html += getRow(indent, '[' + clpsHtml, isPropertyContent); + for (let i = 0; i < obj.length; i++) { + html += highlight(obj[i], indent + 1, i < (obj.length - 1), true, false); + } + clpsHtml = isCollapsible ? '' : ''; + html += getRow(indent, clpsHtml + ']' + comma); + } + } else if (type === 'object') { + if (obj === null) { + html += formatLiteral('null', '', comma, indent, isArray, 'pf-code-Null'); + } else if (obj.constructor === dateObj.constructor) { + html += formatLiteral('new Date(' + obj.getTime() + ') /*' + obj.toLocaleString() + '*/', '', comma, indent, isArray, 'Date'); + } else if (obj.constructor === regexpObj.constructor) { + html += formatLiteral('new RegExp(' + obj + ')', '', comma, indent, isArray, 'RegExp'); + } else { + let numProps = 0; + for (let prop in obj) numProps++; + if (numProps === 0) { + html += getRow(indent, '{ }' + comma, isPropertyContent); + } else { + clpsHtml = isCollapsible ? '' : ''; + html += getRow(indent, '{' + clpsHtml, isPropertyContent); + let j = 0; + for (let prop in obj) { + if (obj.hasOwnProperty(prop)) { + let quote = quoteKeys ? '"' : ''; + html += getRow(indent + 1, '' + quote + prop + quote + ': ' + highlight(obj[prop], indent + 1, ++j < numProps, false, true)); + } + } + clpsHtml = isCollapsible ? '' : ''; + html += getRow(indent, clpsHtml + '}' + comma); + } + } + } else if (type === 'number') { + html += formatLiteral(obj, '', comma, indent, isArray, 'pf-code-Number'); + } else if (type === 'boolean') { + html += formatLiteral(obj, '', comma, indent, isArray, 'pf-code-Boolean'); + } else if (type === 'function') { + if (obj.constructor === regexpObj.constructor) { + html += formatLiteral('new RegExp(' + obj + ')', '', comma, indent, isArray, 'RegExp'); + } else { + obj = formatFunction(indent, obj); + html += formatLiteral(obj, '', comma, indent, isArray, 'pf-code-Function'); + } + } else if (type === 'undefined') { + html += formatLiteral('undefined', '', comma, indent, isArray, 'pf-code-Null'); + } else { + html += formatLiteral(obj.toString().split('\\').join('\\\\').split('"').join('\\"'), '"', comma, indent, isArray, 'pf-code-String'); + } + + return html; + }; + + return highlight(obj, 0, false, false, false); + }; return { - showModule: showModule + showModule: showModule, + highlightJson: highlightJson }; }); \ No newline at end of file diff --git a/js/app/ui/dialog/map_info.js b/js/app/ui/dialog/map_info.js index 112ace431..c297daaf4 100644 --- a/js/app/ui/dialog/map_info.js +++ b/js/app/ui/dialog/map_info.js @@ -6,9 +6,10 @@ define([ 'jquery', 'app/init', 'app/util', + 'app/render', 'bootbox', 'app/map/util' -], function($, Init, Util, bootbox, MapUtil) { +], function($, Init, Util, Render, bootbox, MapUtil) { 'use strict'; @@ -19,23 +20,27 @@ define([ // map info dialog/tabs dialogMapInfoSummaryId: 'pf-map-info-dialog-summary', // id for map "summary" container dialogMapInfoUsersId: 'pf-map-info-dialog-users', // id for map "user" container + dialogMapInfoLogsId: 'pf-map-info-dialog-logs', // id for map "logs" container dialogMapInfoRefreshId: 'pf-map-info-dialog-refresh', // id for map "refresh" container - // "summary" container + // dialog containers mapInfoId: 'pf-map-info', // id for map info mapInfoSystemsId: 'pf-map-info-systems', // id for map info systems box mapInfoConnectionsId: 'pf-map-info-connections', // id for map info connections box mapInfoUsersId: 'pf-map-info-users', // id for map info users box + mapInfoLogsId: 'pf-map-info-logs', // id for map info logs box - mapInfoTableClass: 'pf-map-info-table', // class for data mapInfoLifetimeCounterClass: 'pf-map-info-lifetime-counter', // class for map lifetime counter // dataTable - tableImageCellClass: 'pf-table-image-cell', // class for table "image" cells - tableImageSmallCellClass: 'pf-table-image-small-cell', // class for table "small image" cells - tableActionCellClass: 'pf-table-action-cell', // class for table "action" cells - tableCounterCellClass: 'pf-table-counter-cell', // class for table "counter" cells - tableActionCellIconClass: 'pf-table-action-icon-cell', // class for table "action" icon (icon is part of cell content) + tableToolsClass: 'pf-table-tools', // class for table "tools" section (e.g. Buttons) + tableCellImageClass: 'pf-table-image-cell', // class for table "image" cells + tableCellImageSmallClass: 'pf-table-image-small-cell', // class for table "small image" cells + tableCellActionClass: 'pf-table-action-cell', // class for table "action" cells + tableCellLinkClass: 'pf-table-link-cell', // class for table "links" cells + tableCellCounterClass: 'pf-table-counter-cell', // class for table "counter" cells + tableCellEllipsisClass: 'pf-table-cell-ellipses-auto', // class for table "ellipsis" cells + tableCellActionIconClass: 'pf-table-action-icon-cell', // class for table "action" icon (icon is part of cell content) loadingOptions: { // config for loading overlay icon: { @@ -56,16 +61,36 @@ define([ btnOkIcon: 'fa fa-fw fa-close' }; + /** + * get icon that marks a table cell as clickable + * @returns {string} + */ + let getIconForInformationWindow = () => { + return ''; + }; + + /** + * get icon for socked status + * @param type + * @returns {string} + */ + let getIconForDockedStatus = (type) => { + let icon = type === 'station' ? 'fa-home' : type === 'structure' ? 'fa-industry' : ''; + return icon.length ? '' : ''; + }; + /** * loads the map info data into an element * @param mapData */ - $.fn.loadMapInfoData = function(mapData){ - let mapElement = $(this); + $.fn.initMapInfoData = function(mapData){ + let mapElement = $(this).empty(); - mapElement.empty(); mapElement.showLoadingAnimation(config.loadingOptions); + // get some more config values from this map. Which are not part of "mapData" + let mapDataOrigin = Util.getCurrentMapData(mapData.config.id); + let countSystems = mapData.data.systems.length; let countConnections = mapData.data.connections.length; @@ -80,11 +105,11 @@ define([ } } - // check max map limits (e.g. max systems per map) ============================================================ + // check max map limits (e.g. max systems per map) ------------------------------------------------------------ let percentageSystems = (100 / mapType.defaultConfig.max_systems) * countSystems; let maxSystemsClass = (percentageSystems < 90) ? 'txt-color-success' : (percentageSystems < 100) ? 'txt-color-warning' : 'txt-color-danger'; - // build content ============================================================================================== + // build content ---------------------------------------------------------------------------------------------- let dlElementLeft = $('
', { class: 'dl-horizontal', @@ -115,13 +140,6 @@ define([ class: 'dl-horizontal', css: {'float': 'right'} }).append( - $('
').text( 'Lifetime' ) - ).append( - $('
', { - class: config.mapInfoLifetimeCounterClass, - text: mapData.config.created - }) - ).append( $('
').text( 'Systems' ) ).append( $('
', { @@ -131,6 +149,17 @@ define([ $('
').text( 'Connections' ) ).append( $('
').text( countConnections ) + ).append( + $('
').text( 'Lifetime' ) + ).append( + $('
', { + class: config.mapInfoLifetimeCounterClass, + text: mapData.config.created + }) + ).append( + $('
').text( 'Created' ) + ).append( + $('
').text(Util.getObjVal(mapDataOrigin, 'config.created.character.name')) ); mapElement.append(dlElementRight); @@ -145,14 +174,11 @@ define([ * loads system info table into an element * @param mapData */ - $.fn.loadSystemInfoTable = function(mapData){ - - let systemsElement = $(this); - - systemsElement.empty(); + $.fn.initSystemInfoTable = function(mapData){ + let systemsElement = $(this).empty(); let systemTable = $('
', { - class: ['compact', 'stripe', 'order-column', 'row-border', config.mapInfoTableClass].join(' ') + class: ['compact', 'stripe', 'order-column', 'row-border'].join(' ') }); systemsElement.append(systemTable); @@ -292,7 +318,7 @@ define([ systemsData.push(tempData); } - let systemsDataTable = systemTable.dataTable( { + let systemsDataTable = systemTable.DataTable( { pageLength: 20, paging: true, lengthMenu: [[5, 10, 20, 50, -1], [5, 10, 20, 50, 'All']], @@ -343,7 +369,14 @@ define([ } },{ title: 'system', - data: 'name' + data: 'name', + className: [config.tableCellLinkClass].join(' '), + createdCell: function(cell, cellData, rowData, rowIndex, colIndex){ + // select system + $(cell).on('click', function(e){ + Util.getMapModule().getActiveMap().triggerMenuEvent('SelectSystem', {systemId: rowData.id }); + }); + } },{ title: 'alias', data: 'alias' @@ -401,7 +434,7 @@ define([ title: 'updated', width: '80px', searchable: false, - className: ['text-right', config.tableCounterCellClass, 'min-desktop'].join(' '), + className: ['text-right', config.tableCellCounterClass, 'min-desktop'].join(' '), data: 'updated', createdCell: function(cell, cellData, rowData, rowIndex, colIndex){ $(cell).initTimestampCounter(); @@ -418,7 +451,7 @@ define([ orderable: false, searchable: false, width: '10px', - className: ['text-center', config.tableActionCellClass].join(' '), + className: ['text-center', config.tableCellActionClass].join(' '), data: 'clear', createdCell: function(cell, cellData, rowData, rowIndex, colIndex) { let tempTableElement = this; @@ -444,11 +477,11 @@ define([ Util.showNotify({title: 'System deleted', text: rowData.name, type: 'success'}); - // refresh connection table (connections might have changed) ================== + // refresh connection table (connections might have changed) -------------- let connectionsElement = $('#' + config.mapInfoConnectionsId); let mapDataNew = activeMap.getMapDataFromClient({forceData: true}); - connectionsElement.loadConnectionInfoTable(mapDataNew); + connectionsElement.initConnectionInfoTable(mapDataNew); }else{ // error Util.showNotify({title: 'Failed to delete system', text: rowData.name, type: 'error'}); @@ -472,13 +505,11 @@ define([ * loads connection info table into an element * @param mapData */ - $.fn.loadConnectionInfoTable = function(mapData){ - let connectionsElement = $(this); - - connectionsElement.empty(); + $.fn.initConnectionInfoTable = function(mapData){ + let connectionsElement = $(this).empty(); let connectionTable = $('
', { - class: ['compact', 'stripe', 'order-column', 'row-border', config.mapInfoTableClass].join(' ') + class: ['compact', 'stripe', 'order-column', 'row-border'].join(' ') }); connectionsElement.append(connectionTable); @@ -489,7 +520,7 @@ define([ connectionsElement.hideLoadingAnimation(); }); - // connections table ========================================================================================== + // connections table ------------------------------------------------------------------------------------------ // prepare data for dataTables let connectionData = []; @@ -505,8 +536,10 @@ define([ scope_sort: tempConnectionData.scope }; - // source system name - tempConData.source = tempConnectionData.sourceName; + tempConData.source = { + id: tempConnectionData.source, + name: tempConnectionData.sourceName, + }; // connection let connectionClasses = []; @@ -519,8 +552,10 @@ define([ tempConData.connection = '
'; - - tempConData.target = tempConnectionData.targetName; + tempConData.target = { + id: tempConnectionData.target, + name: tempConnectionData.targetName, + }; tempConData.updated = tempConnectionData.updated; @@ -557,7 +592,14 @@ define([ } },{ title: 'source system', - data: 'source' + data: 'source.name', + className: [config.tableCellLinkClass].join(' '), + createdCell: function(cell, cellData, rowData, rowIndex, colIndex){ + // select system + $(cell).on('click', function(e){ + Util.getMapModule().getActiveMap().triggerMenuEvent('SelectSystem', {systemId: rowData.source.id }); + }); + } },{ title: 'connection', width: '80px', @@ -567,12 +609,19 @@ define([ data: 'connection' }, { title: 'target system', - data: 'target' + data: 'target.name', + className: [config.tableCellLinkClass].join(' '), + createdCell: function(cell, cellData, rowData, rowIndex, colIndex){ + // select system + $(cell).on('click', function(e){ + Util.getMapModule().getActiveMap().triggerMenuEvent('SelectSystem', {systemId: rowData.target.id }); + }); + } },{ title: 'updated', width: '80px', searchable: false, - className: ['text-right', config.tableCounterCellClass].join(' '), + className: ['text-right', config.tableCellCounterClass].join(' '), data: 'updated', createdCell: function(cell, cellData, rowData, rowIndex, colIndex){ $(cell).initTimestampCounter(); @@ -589,7 +638,7 @@ define([ orderable: false, searchable: false, width: '10px', - className: ['text-center', config.tableActionCellClass].join(' '), + className: ['text-center', config.tableCellActionClass].join(' '), data: 'clear', createdCell: function(cell, cellData, rowData, rowIndex, colIndex) { let tempTableElement = this; @@ -621,13 +670,11 @@ define([ * loads user info table into an element * @param mapData */ - $.fn.loadUsersInfoTable = function(mapData){ - let usersElement = $(this); - - usersElement.empty(); + $.fn.initUsersInfoTable = function(mapData){ + let usersElement = $(this).empty(); let userTable = $('
', { - class: ['compact', 'stripe', 'order-column', 'row-border', config.mapInfoTableClass].join(' ') + class: ['compact', 'stripe', 'order-column', 'row-border'].join(' ') }); usersElement.append(userTable); @@ -644,11 +691,7 @@ define([ }); }); - let getIconForInformationWindow = () => { - return ''; - }; - - // users table ================================================================================================ + // users table ------------------------------------------------------------------------------------------------ // prepare users data for dataTables let currentMapUserData = Util.getCurrentMapUserData( mapData.config.id ); let usersData = []; @@ -688,13 +731,13 @@ define([ width: '26px', orderable: false, searchable: false, - className: ['pf-help-default', 'text-center', config.tableImageCellClass].join(' '), + className: ['pf-help-default', 'text-center', config.tableCellImageClass].join(' '), data: 'log.ship', render: { _: function(data, type, row, meta){ let value = data; if(type === 'display'){ - value = ''; + value = ''; } return value; } @@ -721,13 +764,13 @@ define([ width: '26px', orderable: false, searchable: false, - className: [config.tableImageCellClass].join(' '), + className: [config.tableCellImageClass].join(' '), data: 'id', render: { _: function(data, type, row, meta){ let value = data; if(type === 'display'){ - value = ''; + value = ''; } return value; } @@ -737,7 +780,7 @@ define([ title: 'pilot', orderable: true, searchable: true, - className: [config.tableActionCellClass].join(' '), + className: [config.tableCellActionClass].join(' '), data: 'name', render: { _: function(data, type, row, meta){ @@ -761,13 +804,13 @@ define([ width: '26px', orderable: false, searchable: false, - className: [config.tableImageCellClass, config.tableImageSmallCellClass].join(' '), + className: [config.tableCellImageClass, config.tableCellImageSmallClass].join(' '), data: 'corporation', render: { _: function(data, type, row, meta){ let value = data; if(type === 'display'){ - value = ''; + value = ''; } return value; } @@ -777,7 +820,7 @@ define([ title: 'corporation', orderable: true, searchable: true, - className: [config.tableActionCellClass].join(' '), + className: [config.tableCellActionClass].join(' '), data: 'corporation', render: { _: function (data, type, row, meta) { @@ -807,13 +850,21 @@ define([ } },{ targets: 7, - title: 'station', + title: 'docked', orderable: true, searchable: true, - data: 'log.station', + className: [config.tableCellActionClass].join(' '), + data: 'log', render: { - _: 'name', - sort: 'name' + _: function (data, type, row, meta) { + let value = ''; + if(data.station && data.station.id > 0){ + value = data.station.name + ' ' + getIconForDockedStatus('station'); + }else if(data.structure && data.structure.id > 0){ + value = data.structure.name + ' ' + getIconForDockedStatus('structure'); + } + return value; + } } } ] @@ -821,6 +872,316 @@ define([ }; + /** + * loads logs table into an element + * @param mapData + */ + $.fn.initLogsInfoTable = function(mapData){ + let logsElement = $(this).empty(); + + /** + * ajax load function for log fdata + * @param requestData + * @param context + */ + let getLogsData = (requestData, context) => { + context.logsElement.showLoadingAnimation(config.loadingOptions); + + $.ajax({ + type: 'POST', + url: Init.path.getMapLogData, + data: requestData, + dataType: 'json', + context: context + }).done(function(data){ + this.callback(data, context); + }).fail(function( jqXHR, status, error) { + let reason = status + ' ' + error; + Util.showNotify({title: jqXHR.status + ': loadLogs', text: reason, type: 'warning'}); + }).always(function(){ + this.logsElement.hideLoadingAnimation(); + }); + }; + + /** + * callback function after ajax response with log data + * @param responseData + * @param context + */ + let updateTableDataCallback = (responseData, context) => { + let newLogCount = responseData.data.length; + + if(newLogCount > 0){ + let pageInfoOld = context.tableApi.page.info(); + + // add new rows + context.tableApi.rows.add(responseData.data).draw(); + + let newPageIndex = 0; + if(pageInfoOld.recordsDisplay === 0){ + Util.showNotify({title: 'New logs loaded', text: newLogCount + ' most recent logs added', type: 'success'}); + }else{ + // get new pageInfo (new max page count) + let pageInfoNew = context.tableApi.page.info(); + newPageIndex = Math.max(0, pageInfoNew.pages - 1); + Util.showNotify({title: 'More logs loaded', text: newLogCount + ' older logs added', type: 'info'}); + } + + // get to last page (pageIndex starts at zero) -> check if last page > 0 + context.tableApi.page(newPageIndex).draw(false); + }else{ + Util.showNotify({title: 'No logs found', text: 'No more entries', type: 'danger'}); + } + + }; + + // init logs table -------------------------------------------------------------------------------------------- + let logTable = $('
', { + class: ['compact', 'stripe', 'order-column', 'row-border', 'pf-table-fixed'].join(' ') + }); + logsElement.append(logTable); + + let serverDate = Util.getServerTime(); + let serverHours = serverDate.setHours(0,0,0,0); + + let logDataTable = logTable.DataTable({ + pageLength: 25, + paging: true, + lengthMenu: [[10, 25, 50, 100], [10, 25, 50, 100]], + pagingType: 'full_numbers', + ordering: false, + autoWidth: false, + searching: true, + hover: false, + data: [], + language: { + emptyTable: 'No logs available', + zeroRecords: 'No logs found', + lengthMenu: 'Show _MENU_ rows', + info: 'Showing _START_ to _END_ of _TOTAL_ rows' + }, + columnDefs: [ + { + targets: 0, + title: ' ', + width: 12, + data: 'context.tag', + render: { + _: function(data, type, row, meta){ + let value = data; + if(type === 'display'){ + let className = 'txt-color-' + data; + value = ''; + } + return value; + } + } + },{ + targets: 1, + name: 'timestamp', + title: '', + width: 100, + className: ['text-right'].join(' '), + data: 'datetime.date', + render: { + _: function(data, type, row, meta){ + // strip microseconds + let logDateString = data.substring(0, 19) ; + let logDate = new Date(logDateString.replace(/-/g, '/')); + data = Util.convertDateToString(logDate, true); + + // check whether log is new (today) -> + if(logDate.setHours(0,0,0,0) === serverHours) { + // replace dd/mm/YYYY + data = 'today' + data.substring(10); + } + return data; + } + } + },{ + targets: 2, + title: 'level', + width: 40, + data: 'level_name' + },{ + targets: 3, + title: 'channel', + className: [config.tableCellEllipsisClass].join(' '), + width: 40, + data: 'channel' + },{ + targets: 4, + title: 'message', + width: 115, + data: 'message', + render: { + _: function(data, type, row, meta){ + let value = data; + if(type === 'display'){ + let className = 'txt-color-'; + if(Util.getObjVal(row, 'context.tag')){ + className += row.context.tag; + } + value = '' + value + ''; + } + return value; + } + } + },{ + targets: 5, + title: '', + width: 26, + searchable: false, + className: [config.tableCellImageClass].join(' '), + data: 'context.data.character.id' , + render: { + _: function(data, type, row, meta){ + let value = data; + if(type === 'display'){ + value = ''; + } + return value; + } + } + },{ + targets: 6, + title: 'pilot', + width: 110, + className: [config.tableCellActionClass].join(' '), + data: 'context.data.character.name', + render: { + _: function(data, type, row, meta){ + let value = data; + if(type === 'display'){ + value += ' ' + getIconForInformationWindow(); + } + return value; + } + }, + createdCell: function(cell, cellData, rowData, rowIndex, colIndex){ + // open character information window (ingame) + $(cell).on('click', { tableApi: this.DataTable() }, function(e) { + let rowData = e.data.tableApi.row(this).data(); + Util.openIngameWindow(rowData.context.data.character.id); + }); + } + },{ + targets: 7, + title: 'context', + className: [config.tableCellEllipsisClass].join(' '), + data: 'context.data.formatted' + },{ + targets: 8, + title: '', + width: 12, + className: [config.tableCellActionClass].join(' '), + data: 'context.data', + render: { + _: function(data, type, row, meta){ + let value = data; + if(type === 'display'){ + // txt-color-redDarker + value = ''; + } + return value; + } + }, + createdCell: function(cell, cellData, rowData, rowIndex, colIndex){ + // unset formatted string (to much content) + + if(cellData.formatted){ + // clone data before delete() values + cellData = Object.assign({}, cellData); + delete(cellData.formatted); + } + + let jsonHighlighted = Render.highlightJson(cellData); + let content = '
' + jsonHighlighted + '
'; + + // open popover with raw log data + $(cell).popover({ + placement: 'left', + html: true, + trigger: 'hover', + content: content, + container: 'body', + title: 'Raw data', + delay: { + show: 180, + hide: 0 + } + }); + } + } + ], + initComplete: function(settings){ + let tableApi = this.api(); + + // empty table is ready -> load logs + getLogsData({ + mapId: mapData.config.id + }, { + tableApi: tableApi, + callback: updateTableDataCallback, + logsElement: logsElement + }); + }, + drawCallback: function(settings){ + let tableApi = this.api(); + + // en/disable "load more" button ---------------------------------------------------------------------- + let tableInfo = tableApi.page.info(); + let isLastPage = (tableInfo.pages === 0 || tableInfo.page === tableInfo.pages - 1); + tableApi.button(0).enable(isLastPage); + + // adjust "timestamp" column width -------------------------------------------------------------------- + let timestampColumn = tableApi.column('timestamp:name').header(); + let timestampColumnCells = tableApi.cells(undefined, 'timestamp:name', {page: 'current', order:'current'}); + + let hasOldLogs = timestampColumnCells.render( 'display' ).reduce((hasOldLogs, cellValue) => { + return (hasOldLogs === false && !cellValue.startsWith('today')) ? true : hasOldLogs; + }, false); + + if(hasOldLogs){ + $(timestampColumn).css({width: '100px'}); + }else{ + $(timestampColumn).css({width: '80px'}); + } + } + }); + + // ------------------------------------------------------------------------------------------------------------ + // add dataTable buttons (extension) + logsElement.append($('
', { + class: config.tableToolsClass + })); + + let buttons = new $.fn.dataTable.Buttons( logDataTable, { + buttons: [ + { + className: 'btn btn-sm btn-default', + text: ' load more', + enabled: false, + action: function ( e, dt, node, config ) { + let pageInfo = dt.page.info(); + + getLogsData({ + mapId: mapData.config.id, + limit: pageInfo.length, + offset: pageInfo.recordsTotal + }, { + tableApi: dt, + callback: updateTableDataCallback, + logsElement: logsElement + }); + } + } + ] + } ); + + logDataTable.buttons().container().appendTo( $(this).find('.' + config.tableToolsClass)); + }; + /** * shows the map information modal dialog * @param options @@ -830,21 +1191,29 @@ define([ let mapData = activeMap.getMapDataFromClient({forceData: true}); if(mapData !== false){ - requirejs(['text!templates/dialog/map_info.html', 'mustache'], function(template, Mustache) { + // "log" tab -> get "Origin", not all config options are set in mapData + let mapDataOrigin = Util.getCurrentMapData(mapData.config.id); + + requirejs(['text!templates/dialog/map_info.html', 'mustache', 'datatables.loader'], (template, Mustache) => { let data = { dialogSummaryContainerId: config.dialogMapInfoSummaryId, dialogUsersContainerId: config.dialogMapInfoUsersId, + dialogLogsContainerId: config.dialogMapInfoLogsId, dialogRefreshContainerId: config.dialogMapInfoRefreshId, dialogNavigationClass: config.dialogNavigationClass, mapInfoId: config.mapInfoId, mapInfoSystemsId: config.mapInfoSystemsId, mapInfoConnectionsId: config.mapInfoConnectionsId, mapInfoUsersId: config.mapInfoUsersId, + mapInfoLogsId: config.mapInfoLogsId, + + logHistoryEnabled: Boolean(Util.getObjVal(mapDataOrigin, 'config.logging.history')), // default open tab ---------- openTabInformation: options.tab === 'information', - openTabActivity: options.tab === 'activity' + openTabActivity: options.tab === 'activity', + openTabLog: options.tab === 'log' }; let content = Mustache.render(template, data); @@ -865,39 +1234,51 @@ define([ }); mapInfoDialog.on('shown.bs.modal', function(e) { - // modal on open - let mapElement = $('#' + config.mapInfoId); let systemsElement = $('#' + config.mapInfoSystemsId); let connectionsElement = $('#' + config.mapInfoConnectionsId); let usersElement = $('#' + config.mapInfoUsersId); - // set refresh button observer - $('#' + config.dialogMapInfoRefreshId).on('click', function(){ + $('#' + config.dialogMapInfoRefreshId).on('click', function(e){ let menuAction = $(this).attr('data-action'); if(menuAction === 'refresh'){ // get new map data let mapData = activeMap.getMapDataFromClient({forceData: true}); + // find active tab + let activeTabLink = $(this).parents('.navbar').find('.navbar-header.pull-left li.active a'); + if(activeTabLink.attr('href') === '#' + config.dialogMapInfoLogsId){ + $('#' + config.mapInfoLogsId).initLogsInfoTable(mapDataOrigin); + } - mapElement.loadMapInfoData(mapData); - systemsElement.loadSystemInfoTable(mapData); - connectionsElement.loadConnectionInfoTable(mapData); - usersElement.loadUsersInfoTable(mapData); + mapElement.initMapInfoData(mapData); + systemsElement.initSystemInfoTable(mapData); + connectionsElement.initConnectionInfoTable(mapData); + usersElement.initUsersInfoTable(mapData); } }); // load map data - mapElement.loadMapInfoData(mapData); + mapElement.initMapInfoData(mapData); // load system table - systemsElement.loadSystemInfoTable(mapData); + systemsElement.initSystemInfoTable(mapData); // load connection table - connectionsElement.loadConnectionInfoTable(mapData); + connectionsElement.initConnectionInfoTable(mapData); // load users table - usersElement.loadUsersInfoTable(mapData); + usersElement.initUsersInfoTable(mapData); + + }); + + // events for tab change + mapInfoDialog.find('.navbar a').on('shown.bs.tab', function(e){ + if($(e.target).attr('href') === '#' + config.dialogMapInfoLogsId){ + // "log" tab + let mapDataOrigin = Util.getCurrentMapData(mapData.config.id); + $('#' + config.mapInfoLogsId).initLogsInfoTable(mapDataOrigin); + } }); }); diff --git a/js/app/ui/dialog/map_settings.js b/js/app/ui/dialog/map_settings.js index ecba3f2fe..6a4a2dff2 100644 --- a/js/app/ui/dialog/map_settings.js +++ b/js/app/ui/dialog/map_settings.js @@ -24,6 +24,15 @@ define([ deleteEolConnectionsId: 'pf-map-dialog-delete-connections-eol', // id for "deleteEOLConnections" checkbox persistentAliasesId: 'pf-map-dialog-persistent-aliases', // id for "persistentAliases" checkbox + logHistoryId: 'pf-map-dialog-history', // id for "history logging" checkbox + logActivityId: 'pf-map-dialog-activity', // id for "activity" checkbox + + slackWebHookURLId: 'pf-map-dialog-slack-url', // id for Slack "webHookUrl" + slackUsernameId: 'pf-map-dialog-slack-username', // id for Slack "username" + slackIconId: 'pf-map-dialog-slack-icon', // id for Slack "icon" + slackChannelHistoryId: 'pf-map-dialog-slack-channel-history', // id for Slack channel "history" + slackChannelRallyId: 'pf-map-dialog-slack-channel-rally', // id for Slack channel "rally" + characterSelectId: 'pf-map-dialog-character-select', // id for "character" select corporationSelectId: 'pf-map-dialog-corporation-select', // id for "corporation" select allianceSelectId: 'pf-map-dialog-alliance-select', // id for "alliance" select @@ -67,7 +76,7 @@ define([ requirejs([ 'text!templates/dialog/map.html', - 'text!templates/form/map_settings.html', + 'text!templates/form/map.html', 'mustache' ], function(templateMapDialog, templateMapSettings, Mustache) { @@ -96,10 +105,10 @@ define([ formInfoContainerClass: Util.config.formInfoContainerClass }; - // render "new map" tab content ------------------------------------------- + // render "new map" tab content ----------------------------------------------------------------------- let contentNewMap = Mustache.render(templateMapSettings, data); - // render "edit map" tab content ------------------------------------------ + // render "edit map" tab content ---------------------------------------------------------------------- let contentEditMap = Mustache.render(templateMapSettings, data); contentEditMap = $(contentEditMap); @@ -111,6 +120,19 @@ define([ let deleteEolConnections = true; let persistentAliases = true; + let logActivity = true; + let logHistory = true; + + let slackWebHookURL = ''; + let slackUsername = ''; + let slackIcon = ''; + let slackChannelHistory = ''; + let slackChannelRally = ''; + let slackEnabled = false; + let slackHistoryEnabled = false; + let slackRallyEnabled = false; + let slackSectionShow = false; + if(mapData !== false){ // set current map information contentEditMap.find('input[name="id"]').val( mapData.config.id ); @@ -126,9 +148,26 @@ define([ deleteExpiredConnections = mapData.config.deleteExpiredConnections; deleteEolConnections = mapData.config.deleteEolConnections; persistentAliases = mapData.config.persistentAliases; + + logActivity = mapData.config.logging.activity; + logHistory = mapData.config.logging.history; + + slackWebHookURL = mapData.config.logging.slackWebHookURL; + slackUsername = mapData.config.logging.slackUsername; + slackIcon = mapData.config.logging.slackIcon; + slackChannelHistory = mapData.config.logging.slackChannelHistory; + slackChannelRally = mapData.config.logging.slackChannelRally; + slackEnabled = Boolean(Util.getObjVal(Init, 'slack.status')); + slackHistoryEnabled = slackEnabled && Boolean(Util.getObjVal(Init.mapTypes, mapData.config.type.name + '.defaultConfig.send_history_slack_enabled')); + slackRallyEnabled = slackEnabled && Boolean(Util.getObjVal(Init.mapTypes, mapData.config.type.name + '.defaultConfig.send_rally_slack_enabled')); + slackSectionShow = (slackEnabled && slackWebHookURL.length > 0); + + // remove "#" from Slack channels + slackChannelHistory = slackChannelHistory.indexOf('#') === 0 ? slackChannelHistory.substr(1) : slackChannelHistory; + slackChannelRally = slackChannelRally.indexOf('#') === 0 ? slackChannelRally.substr(1) : slackChannelRally; } - // render main dialog ----------------------------------------------------- + // render main dialog --------------------------------------------------------------------------------- data = { id: config.newMapDialogId, mapData: mapData, @@ -162,6 +201,26 @@ define([ deleteEolConnections: deleteEolConnections, persistentAliases: persistentAliases, + logHistoryId: config.logHistoryId, + logActivityId: config.logActivityId, + logActivity: logActivity, + logHistory: logHistory, + + slackWebHookURLId: config.slackWebHookURLId, + slackUsernameId: config.slackUsernameId, + slackIconId: config.slackIconId, + slackChannelHistoryId: config.slackChannelHistoryId, + slackChannelRallyId: config.slackChannelRallyId, + slackWebHookURL: slackWebHookURL, + slackUsername: slackUsername, + slackIcon: slackIcon, + slackChannelHistory: slackChannelHistory, + slackChannelRally: slackChannelRally, + slackEnabled: slackEnabled, + slackHistoryEnabled: slackHistoryEnabled, + slackRallyEnabled: slackRallyEnabled, + slackSectionShow: slackSectionShow, + characterSelectId: config.characterSelectId, corporationSelectId: config.corporationSelectId, allianceSelectId: config.allianceSelectId, @@ -244,6 +303,15 @@ define([ // get form data let formData = form.getFormValues(); + // add value prefixes (Slack channels) + let tmpVal; + if(typeof (tmpVal = Util.getObjVal(formData, 'slackChannelHistory')) === 'string' && tmpVal.length){ + formData.slackChannelHistory = '#' + tmpVal; + } + if(typeof (tmpVal = Util.getObjVal(formData, 'slackChannelRally')) === 'string' && tmpVal.length){ + formData.slackChannelRally = '#' + tmpVal; + } + // checkbox fix -> settings tab if( form.find('#' + config.deleteExpiredConnectionsId).length ){ formData.deleteExpiredConnections = formData.hasOwnProperty('deleteExpiredConnections') ? parseInt( formData.deleteExpiredConnections ) : 0; @@ -254,6 +322,15 @@ define([ if( form.find('#' + config.persistentAliasesId).length ){ formData.persistentAliases = formData.hasOwnProperty('persistentAliases') ? parseInt( formData.persistentAliases ) : 0; } + if( form.find('#' + config.persistentAliasesId).length ){ + formData.persistentAliases = formData.hasOwnProperty('persistentAliases') ? parseInt( formData.persistentAliases ) : 0; + } + if( form.find('#' + config.logHistoryId).length ){ + formData.logHistory = formData.hasOwnProperty('logHistory') ? parseInt( formData.logHistory ) : 0; + } + if( form.find('#' + config.logActivityId).length ){ + formData.logActivity = formData.hasOwnProperty('logActivity') ? parseInt( formData.logActivity ) : 0; + } let requestData = {formData: formData}; @@ -264,8 +341,6 @@ define([ dataType: 'json' }).done(function(responseData){ - dialogContent.hideLoadingAnimation(); - if(responseData.error.length){ form.showFormMessage(responseData.error); }else{ @@ -287,6 +362,8 @@ define([ Util.showNotify({title: jqXHR.status + ': saveMap', text: reason, type: 'warning'}); $(document).setProgramStatus('problem'); + }).always(function() { + dialogContent.hideLoadingAnimation(); }); } @@ -297,10 +374,13 @@ define([ }); - // after modal is shown ======================================================================= + // after modal is shown =============================================================================== mapInfoDialog.on('shown.bs.modal', function(e){ mapInfoDialog.initTooltips(); + // manually trigger the "show" event for the initial active tab (not triggered by default...) + mapInfoDialog.find('.navbar li.active a[data-toggle=tab]').trigger('shown.bs.tab'); + // prevent "disabled" tabs from being clicked... "bootstrap" bugFix... mapInfoDialog.find('.navbar a[data-toggle=tab]').on('click', function(e){ if ($(this).hasClass('disabled')){ @@ -323,17 +403,12 @@ define([ form.showFormMessage([{type: 'warning', message: 'No maps found. Create a new map before you can start'}]); } - // init select fields in case "settings" tab is open by default - if(options.tab === 'settings'){ - initSettingsSelectFields(mapInfoDialog); - } - - // init "download tab" ======================================================================== + // init "download tab" ============================================================================ let downloadTabElement = mapInfoDialog.find('#' + config.dialogMapDownloadContainerId); if(downloadTabElement.length){ // tab exists - // export map data ------------------------------------------------------------------------ + // export map data ---------------------------------------------------------------------------- downloadTabElement.find('#' + config.buttonExportId).on('click', { mapData: mapData }, function(e){ let exportForm = $('#' + config.dialogMapExportFormId); @@ -364,7 +439,7 @@ define([ } }); - // import map data ------------------------------------------------------------------------ + // import map data ---------------------------------------------------------------------------- // check if "FileReader" API is supported let importFormElement = downloadTabElement.find('#' + config.dialogMapImportFormId); if(window.File && window.FileReader && window.FileList && window.Blob){ @@ -477,16 +552,22 @@ define([ } }); - // events for tab change + // events for tab change ------------------------------------------------------------------------------ mapInfoDialog.find('.navbar a').on('shown.bs.tab', function(e){ + let modalDialog = mapInfoDialog.find('div.modal-dialog'); let selectElementCharacter = mapInfoDialog.find('#' + config.characterSelectId); let selectElementCorporation = mapInfoDialog.find('#' + config.corporationSelectId); let selectElementAlliance = mapInfoDialog.find('#' + config.allianceSelectId); + if($(e.target).attr('href') === '#' + config.dialogMapSettingsContainerId){ - // "settings" tab + // "settings" tab -> resize modal + modalDialog.toggleClass('modal-lg', true); initSettingsSelectFields(mapInfoDialog); }else{ + // resize modal + modalDialog.toggleClass('modal-lg', false); + if( $(selectElementCharacter).data('select2') !== undefined ){ $(selectElementCharacter).select2('destroy'); } @@ -611,29 +692,38 @@ define([ * @param mapData */ $.fn.showDeleteMapDialog = function(mapData){ - let mapName = mapData.config.name; + let mapNameStr = '' + mapName + ''; + + let mapDeleteDialog = bootbox.confirm({ + message: 'Delete map "' + mapNameStr + '"?', + buttons: { + confirm: { + label: ' delete map', + className: 'btn-danger' + } + }, + callback: function(result){ + if(result){ + let data = {mapData: mapData.config}; + + $.ajax({ + type: 'POST', + url: Init.path.deleteMap, + data: data, + dataType: 'json' + }).done(function(data){ + Util.showNotify({title: 'Map deleted', text: 'Map: ' + mapName, type: 'success'}); + }).fail(function( jqXHR, status, error) { + let reason = status + ' ' + error; + Util.showNotify({title: jqXHR.status + ': deleteMap', text: reason, type: 'warning'}); + $(document).setProgramStatus('problem'); + }).always(function() { + $(mapDeleteDialog).modal('hide'); + }); - let mapDeleteDialog = bootbox.confirm('Delete map "' + mapName + '"?', function(result){ - if(result){ - let data = {mapData: mapData.config}; - - $.ajax({ - type: 'POST', - url: Init.path.deleteMap, - data: data, - dataType: 'json' - }).done(function(data){ - Util.showNotify({title: 'Map deleted', text: 'Map: ' + mapName, type: 'success'}); - }).fail(function( jqXHR, status, error) { - let reason = status + ' ' + error; - Util.showNotify({title: jqXHR.status + ': deleteMap', text: reason, type: 'warning'}); - $(document).setProgramStatus('problem'); - }).always(function() { - $(mapDeleteDialog).modal('hide'); - }); - - return false; + return false; + } } }); diff --git a/js/app/ui/dialog/stats.js b/js/app/ui/dialog/stats.js index 0d7977510..0b814f9d7 100644 --- a/js/app/ui/dialog/stats.js +++ b/js/app/ui/dialog/stats.js @@ -8,7 +8,8 @@ define([ 'app/init', 'app/util', 'app/render', - 'bootbox' + 'bootbox', + 'peityInlineChart' ], function($, Init, Util, Render, bootbox) { 'use strict'; @@ -25,7 +26,7 @@ define([ // stats/dataTable statsContainerId: 'pf-stats-dialog-container', // class for statistics container (dynamic ajax content) statsTableId: 'pf-stats-table', // id for statistics table element - tableImageCellClass: 'pf-table-image-cell', // class for table "image" cells + tableCellImageClass: 'pf-table-image-cell', // class for table "image" cells // charts statsLineChartClass: 'pf-line-chart' // class for inline chart elements @@ -36,7 +37,9 @@ define([ * @param dialogElement */ let initStatsTable = function(dialogElement){ - let columnNumberWidth = 35; + let columnNumberWidth = 28; + let cellPadding = 4; + let lineChartWidth = columnNumberWidth + (2 * cellPadding); let lineColor = '#477372'; // render function for inline-chart columns @@ -71,7 +74,7 @@ define([ lengthMenu: [[10, 20, 30, 50], [10, 20, 30, 50]], paging: true, ordering: true, - order: [ 16, 'desc' ], + order: [ 20, 'desc' ], info: true, searching: true, hover: false, @@ -97,11 +100,11 @@ define([ orderable: false, searchable: false, width: 26, - className: ['text-center', config.tableImageCellClass].join(' '), + className: ['text-center', config.tableCellImageClass].join(' '), data: 'character', render: { _: function(data, type, row, meta){ - return ''; + return ''; } } },{ @@ -134,7 +137,7 @@ define([ searchable: false, width: columnNumberWidth, className: ['text-right', 'hidden-xs', 'hidden-sm'].join(' '), - data: 'systemCreate', + data: 'mapCreate', render: { _: renderInlineChartColumn } @@ -145,7 +148,7 @@ define([ searchable: false, width: columnNumberWidth, className: ['text-right', 'hidden-xs', 'hidden-sm'].join(' '), - data: 'systemUpdate', + data: 'mapUpdate', render: { _: renderInlineChartColumn } @@ -156,7 +159,7 @@ define([ searchable: false, width: columnNumberWidth, className: ['text-right', 'hidden-xs', 'hidden-sm'].join(' '), - data: 'systemDelete', + data: 'mapDelete', render: { _: renderInlineChartColumn } @@ -166,7 +169,7 @@ define([ searchable: false, width: 20, className: ['text-right', 'separator-right'].join(' ') , - data: 'systemSum', + data: 'mapSum', render: { _: renderNumericColumn } @@ -177,7 +180,7 @@ define([ searchable: false, width: columnNumberWidth, className: ['text-right', 'hidden-xs', 'hidden-sm'].join(' '), - data: 'connectionCreate', + data: 'systemCreate', render: { _: renderInlineChartColumn } @@ -188,7 +191,7 @@ define([ searchable: false, width: columnNumberWidth, className: ['text-right', 'hidden-xs', 'hidden-sm'].join(' '), - data: 'connectionUpdate', + data: 'systemUpdate', render: { _: renderInlineChartColumn } @@ -199,7 +202,7 @@ define([ searchable: false, width: columnNumberWidth, className: ['text-right', 'hidden-xs', 'hidden-sm'].join(' '), - data: 'connectionDelete', + data: 'systemDelete', render: { _: renderInlineChartColumn } @@ -208,13 +211,56 @@ define([ title: 'Σ  ', searchable: false, width: 20, + className: ['text-right', 'separator-right'].join(' ') , + data: 'systemSum', + render: { + _: renderNumericColumn + } + },{ + targets: 12, + title: 'C  ', + orderable: false, + searchable: false, + width: columnNumberWidth, + className: ['text-right', 'hidden-xs', 'hidden-sm'].join(' '), + data: 'connectionCreate', + render: { + _: renderInlineChartColumn + } + },{ + targets: 13, + title: 'U  ', + orderable: false, + searchable: false, + width: columnNumberWidth, + className: ['text-right', 'hidden-xs', 'hidden-sm'].join(' '), + data: 'connectionUpdate', + render: { + _: renderInlineChartColumn + } + },{ + targets: 14, + title: 'D  ', + orderable: false, + searchable: false, + width: columnNumberWidth, + className: ['text-right', 'hidden-xs', 'hidden-sm'].join(' '), + data: 'connectionDelete', + render: { + _: renderInlineChartColumn + } + },{ + targets: 15, + title: 'Σ  ', + searchable: false, + width: 20, className: ['text-right', 'separator-right'].join(' '), data: 'connectionSum', render: { _: renderNumericColumn } },{ - targets: 12, + targets: 16, title: 'C  ', orderable: false, searchable: false, @@ -225,7 +271,7 @@ define([ _: renderInlineChartColumn } },{ - targets: 13, + targets: 17, title: 'U  ', orderable: false, searchable: false, @@ -236,7 +282,7 @@ define([ _: renderInlineChartColumn } },{ - targets: 14, + targets: 18, title: 'D  ', orderable: false, searchable: false, @@ -247,7 +293,7 @@ define([ _: renderInlineChartColumn } },{ - targets: 15, + targets: 19, title: 'Σ  ', searchable: false, width: 20, @@ -257,7 +303,7 @@ define([ _: renderNumericColumn } },{ - targets: 16, + targets: 20, title: 'Σ  ', searchable: false, width: 20, @@ -277,17 +323,17 @@ define([ }, drawCallback: function(settings){ this.api().rows().nodes().to$().each(function(i, row){ - $(row).find('.' + config.statsLineChartClass).peity('line', { + $($(row).find('.' + config.statsLineChartClass)).peity('line', { fill: 'transparent', height: 18, min: 0, - width: 50 + width: lineChartWidth }); }); }, footerCallback: function ( row, data, start, end, display ) { let api = this.api(); - let sumColumnIndexes = [7, 11, 15, 16]; + let sumColumnIndexes = [7, 11, 15, 19, 20]; // column data for "sum" columns over this page let pageTotalColumns = api @@ -310,7 +356,16 @@ define([ statsTable.on('order.dt search.dt', function(){ statsTable.column(0, {search:'applied', order:'applied'}).nodes().each(function(cell, i){ - $(cell).html( (i + 1) + '.  '); + let rowCount = i + 1; + let content = ''; + switch(rowCount){ + case 1: content = ''; break; + case 2: content = ''; break; + case 3: content = ''; break; + default: content = rowCount + '.  '; + } + + $(cell).html(content); }); }).draw(); @@ -413,6 +468,9 @@ define([ let currentWeek = weekStart; let formattedWeeksData = { + mapCreate: [], + mapUpdate: [], + mapDelete: [], systemCreate: [], systemUpdate: [], systemDelete: [], @@ -422,6 +480,7 @@ define([ signatureCreate: [], signatureUpdate: [], signatureDelete: [], + mapSum: 0, systemSum: 0, connectionSum: 0, signatureSum: 0 @@ -433,6 +492,16 @@ define([ if(weeksData.hasOwnProperty( yearWeekProp )){ let weekData = weeksData[ yearWeekProp ]; + // map ---------------------------------------------------------------------------------- + formattedWeeksData.mapCreate.push( weekData.mapCreate ); + formattedWeeksData.mapSum += parseInt( weekData.mapCreate ); + + formattedWeeksData.mapUpdate.push( weekData.mapUpdate ); + formattedWeeksData.mapSum += parseInt( weekData.mapUpdate ); + + formattedWeeksData.mapDelete.push( weekData.mapDelete ); + formattedWeeksData.mapSum += parseInt( weekData.mapDelete ); + // system ------------------------------------------------------------------------------- formattedWeeksData.systemCreate.push( weekData.systemCreate ); formattedWeeksData.systemSum += parseInt( weekData.systemCreate ); @@ -463,6 +532,11 @@ define([ formattedWeeksData.signatureDelete.push( weekData.signatureDelete ); formattedWeeksData.signatureSum += parseInt( weekData.signatureDelete ); }else{ + // map ------------------------------------------------------------------------------- + formattedWeeksData.mapCreate.push(0); + formattedWeeksData.mapUpdate.push(0); + formattedWeeksData.mapDelete.push(0); + // system ------------------------------------------------------------------------------- formattedWeeksData.systemCreate.push(0); formattedWeeksData.systemUpdate.push(0); @@ -487,6 +561,11 @@ define([ } } + // map --------------------------------------------------------------------------------------- + formattedWeeksData.mapCreate = formattedWeeksData.mapCreate.join(','); + formattedWeeksData.mapUpdate = formattedWeeksData.mapUpdate.join(','); + formattedWeeksData.mapDelete = formattedWeeksData.mapDelete.join(','); + // system --------------------------------------------------------------------------------------- formattedWeeksData.systemCreate = formattedWeeksData.systemCreate.join(','); formattedWeeksData.systemUpdate = formattedWeeksData.systemUpdate.join(','); @@ -515,6 +594,19 @@ define([ name: data.name, lastLogin: data.lastLogin }, + mapCreate: { + type: 'C', + data: formattedWeeksData.mapCreate + }, + mapUpdate: { + type: 'U', + data: formattedWeeksData.mapUpdate + }, + mapDelete: { + type: 'D', + data: formattedWeeksData.mapDelete + }, + mapSum: formattedWeeksData.mapSum, systemCreate: { type: 'C', data: formattedWeeksData.systemCreate @@ -554,7 +646,8 @@ define([ data: formattedWeeksData.signatureDelete }, signatureSum: formattedWeeksData.signatureSum, - totalSum: formattedWeeksData.systemSum + formattedWeeksData.connectionSum + formattedWeeksData.signatureSum + totalSum: formattedWeeksData.mapSum + formattedWeeksData.systemSum + + formattedWeeksData.connectionSum + formattedWeeksData.signatureSum }; formattedData.push(rowData); @@ -602,18 +695,18 @@ define([ * @param type * @returns {boolean} */ - let isTabTypeEnabled = function(type){ + let isTabTypeEnabled = (type) => { let enabled = false; switch(type){ case 'private': - if(Init.mapTypes.private.defaultConfig.activity_logging){ + if( Boolean(Util.getObjVal(Init.mapTypes, type + '.defaultConfig.log_activity_enabled')) ){ enabled = true; } break; case 'corporation': if( - Init.mapTypes.corporation.defaultConfig.activity_logging && + Boolean(Util.getObjVal(Init.mapTypes, type + '.defaultConfig.log_activity_enabled')) && Util.getCurrentUserInfo('corporationId') ){ enabled = true; @@ -621,7 +714,7 @@ define([ break; case 'alliance': if( - Init.mapTypes.alliance.defaultConfig.activity_logging && + Boolean(Util.getObjVal(Init.mapTypes, type + '.defaultConfig.log_activity_enabled')) && Util.getCurrentUserInfo('allianceId') ){ enabled = true; @@ -636,15 +729,46 @@ define([ * show activity stats dialog */ $.fn.showStatsDialog = function(){ - requirejs(['text!templates/dialog/stats.html', 'mustache', 'peityInlineChart'], function(template, Mustache) { + requirejs(['text!templates/dialog/stats.html', 'mustache', 'datatables.loader'], function(template, Mustache) { + // get current statistics map settings + let logActivityEnabled = false; + let activeMap = Util.getMapModule().getActiveMap(); + if(activeMap){ + let activeMapId = activeMap.data('id'); + let activeMapData = Util.getCurrentMapData(activeMapId); + if(activeMapData){ + logActivityEnabled = Boolean(Util.getObjVal(activeMapData, 'config.logging.activity')); + } + } + + // check which dialog tab is default active + let enablePrivateTab = isTabTypeEnabled('private'); + let enableCorporationTab = isTabTypeEnabled('corporation'); + let enableAllianceTab = isTabTypeEnabled('alliance'); + + let activePrivateTab = false; + let activeCorporationTab = false; + let activeAllianceTab = false; + + if(enableCorporationTab){ + activeCorporationTab = true; + }else if(enableAllianceTab){ + activeAllianceTab = true; + }else if(enablePrivateTab){ + activePrivateTab = true; + } let data = { id: config.statsDialogId, dialogNavigationClass: config.dialogNavigationClass, dialogNavLiClass: config.dialogNavigationListItemClass, - enablePrivateTab: isTabTypeEnabled('private'), - enableCorporationTab: isTabTypeEnabled('corporation'), - enableAllianceTab: isTabTypeEnabled('alliance'), + enablePrivateTab: enablePrivateTab, + enableCorporationTab: enableCorporationTab, + enableAllianceTab: enableAllianceTab, + activePrivateTab: activePrivateTab, + activeCorporationTab: activeCorporationTab, + activeAllianceTab: activeAllianceTab, + logActivityEnabled: logActivityEnabled, statsContainerId: config.statsContainerId, statsTableId: config.statsTableId, dialogNavigationOffsetClass: config.dialogNavigationOffsetClass, @@ -670,7 +794,6 @@ define([ // model events statsDialog.on('show.bs.modal', function(e) { let dialogElement = $(e.target); - initStatsTable(dialogElement); }); diff --git a/js/app/ui/form_element.js b/js/app/ui/form_element.js index 649fa592f..94d14e6b1 100644 --- a/js/app/ui/form_element.js +++ b/js/app/ui/form_element.js @@ -197,15 +197,15 @@ define([ switch(options.type){ case 'character': - imagePath = Init.url.ccpImageServer + 'Character/' + data.id + '_32.jpg'; + imagePath = Init.url.ccpImageServer + '/Character/' + data.id + '_32.jpg'; previewContent = ''; break; case 'corporation': - imagePath = Init.url.ccpImageServer + 'Corporation/' + data.id + '_32.png'; + imagePath = Init.url.ccpImageServer + '/Corporation/' + data.id + '_32.png'; previewContent = ''; break; case 'alliance': - imagePath = Init.url.ccpImageServer + 'Alliance/' + data.id + '_32.png'; + imagePath = Init.url.ccpImageServer + '/Alliance/' + data.id + '_32.png'; previewContent = ''; break; } diff --git a/js/app/ui/system_graph.js b/js/app/ui/system_graph.js index 4dba2c025..965f8dcb1 100644 --- a/js/app/ui/system_graph.js +++ b/js/app/ui/system_graph.js @@ -10,7 +10,7 @@ define([ ], function($, Init, Util, Morris) { 'use strict'; - var config = { + let config = { // module info moduleClass: 'pf-module', // class for each module @@ -53,8 +53,8 @@ define([ * @param option * @returns {string} */ - var getInfoForGraph = function(graphKey, option){ - var info = ''; + let getInfoForGraph = function(graphKey, option){ + let info = ''; if(config.systemGraphLabels.hasOwnProperty(graphKey)){ info = config.systemGraphLabels[graphKey][option]; @@ -69,14 +69,14 @@ define([ * @param graphKey * @param graphData */ - var initGraph = function(graphElement, graphKey, graphData, eventLine){ + let initGraph = function(graphElement, graphKey, graphData, eventLine){ if(graphData.length > 0){ - var labelYFormat = function(y){ + let labelYFormat = function(y){ return Math.round(y); }; - var graphConfig = { + let graphConfig = { element: graphElement, data: graphData, xkey: 'x', @@ -121,24 +121,24 @@ define([ * @param parentElement * @param systemData */ - var drawModule = function(parentElement, systemData){ + let drawModule = function(parentElement, systemData){ // graph data is available for k-space systems if(systemData.type.id === 2){ - var requestData = { + let requestData = { systemIds: [systemData.systemId] }; // calculate time offset until system created - var serverData = Util.getServerTime(); + let serverData = Util.getServerTime(); - var timestampNow = Math.floor(serverData.getTime() / 1000); - var timeSinceUpdate = timestampNow - systemData.updated; + let timestampNow = Math.floor(serverData.getTime() / 1000); + let timeSinceUpdate = timestampNow - systemData.updated; - var timeInHours = Math.floor(timeSinceUpdate / 3600); - var timeInMinutes = Math.floor((timeSinceUpdate % 3600) / 60); - var timeInMinutesPercent = ( timeInMinutes / 60 ).toFixed(2); - var eventLine = timeInHours + timeInMinutesPercent; + let timeInHours = Math.floor(timeSinceUpdate / 3600); + let timeInMinutes = Math.floor((timeSinceUpdate % 3600) / 60); + let timeInMinutesPercent = ( timeInMinutes / 60 ).toFixed(2); + let eventLine = timeInHours + timeInMinutesPercent; // graph is from right to left -> convert event line eventLine = 23 - eventLine; @@ -152,7 +152,7 @@ define([ if( Object.keys(systemGraphsData).length > 0 ){ // create new (hidden) module container - var moduleElement = $('
', { + let moduleElement = $('
', { class: [config.moduleClass, config.systemGraphModuleClass].join(' '), css: {opacity: 0} }); @@ -165,7 +165,7 @@ define([ } // row element - var rowElement = $('
', { + let rowElement = $('
', { class: 'row' }); moduleElement.append(rowElement); @@ -173,15 +173,15 @@ define([ $.each(systemGraphsData, function(systemId, graphsData){ $.each(graphsData, function(graphKey, graphData){ - var colElement = $('
', { + let colElement = $('
', { class: ['col-xs-12', 'col-sm-6', 'col-md-4'].join(' ') }); - var headlineElement = $('
').text( getInfoForGraph(graphKey, 'headline') ); + let headlineElement = $('
').text( getInfoForGraph(graphKey, 'headline') ); colElement.append(headlineElement); - var graphElement = $('
', { + let graphElement = $('
', { class: config.systemGraphClass }); @@ -203,7 +203,7 @@ define([ }); } }).fail(function( jqXHR, status, error) { - var reason = status + ' ' + error; + let reason = status + ' ' + error; Util.showNotify({title: jqXHR.status + ': System graph data', text: reason, type: 'warning'}); $(document).setProgramStatus('problem'); }); @@ -218,10 +218,10 @@ define([ */ $.fn.drawSystemGraphModule = function(systemData){ - var parentElement = $(this); + let parentElement = $(this); // check if module already exists - var moduleElement = parentElement.find('.' + config.systemGraphModuleClass); + let moduleElement = parentElement.find('.' + config.systemGraphModuleClass); if(moduleElement.length > 0){ moduleElement.velocity('transition.slideDownOut', { diff --git a/js/app/ui/system_killboard.js b/js/app/ui/system_killboard.js index 32913f025..b58483b07 100644 --- a/js/app/ui/system_killboard.js +++ b/js/app/ui/system_killboard.js @@ -30,9 +30,10 @@ define([ }; /** - * get label element with given content + * * @param text - * @returns {*|XMLList} + * @param options + * @returns {jQuery} */ let getLabel = function(text, options){ let label = $('', { @@ -42,7 +43,6 @@ define([ return label; }; - let showKillmails = function(moduleElement, killboardData){ // show number of killMails @@ -61,7 +61,7 @@ define([ break; } - moduleElement.append( $('
').text(i + 'h ago')); + moduleElement.append( $('
').text( i ? i + 'h ago' : 'recent')); let killMailData = killboardData.tableData[i].killmails; @@ -77,25 +77,25 @@ define([ let killData = killMailData[j]; - let linkUrl = '//zkillboard.com/kill/' + killData.killID + '/'; - let victimImageUrl = Init.url.ccpImageServer + 'Type/' + killData.victim.shipTypeID + '_64.png'; - let killDate = getDateObjectByTimeString(killData.killTime); + let linkUrl = '//zkillboard.com/kill/' + killData.killmail_id + '/'; + let victimImageUrl = Init.url.ccpImageServer + '/Type/' + killData.victim.ship_type_id + '_64.png'; + let killDate = Util.convertDateToUTC(new Date(killData.killmail_time)); let killDateString = Util.convertDateToString(killDate); let killLossValue = Util.formatPrice( killData.zkb.totalValue ); // check for ally let victimAllyLogoUrl = ''; let displayAlly = 'none'; - if(killData.victim.allianceID > 0){ - victimAllyLogoUrl = Init.url.ccpImageServer + 'Alliance/' + killData.victim.allianceID + '_32.png'; + if(killData.victim.alliance_id > 0){ + victimAllyLogoUrl = Init.url.ccpImageServer + '/Alliance/' + killData.victim.alliance_id + '_32.png'; displayAlly = 'block'; } // check for corp let victimCorpLogoUrl = ''; let displayCorp = 'none'; - if(killData.victim.corporationID > 0){ - victimCorpLogoUrl = Init.url.ccpImageServer + 'Corporation/' + killData.victim.corporationID + '_32.png'; + if(killData.victim.corporation_id > 0){ + victimCorpLogoUrl = Init.url.ccpImageServer + '/Corporation/' + killData.victim.corporation_id + '_32.png'; displayCorp = 'inline'; } @@ -126,7 +126,7 @@ define([ text: killData.victim.characterName }).prepend( $('', { - text: killDateString + ' - ' + text: killDateString }) ).prepend( $('', { @@ -324,7 +324,7 @@ define([ wSpaceLinkModifier = 'w-space/'; } - let url = Init.url.zKillboard; + let url = Init.url.zKillboard + '/'; url += 'no-items/' + wSpaceLinkModifier + 'no-attackers/solarSystemID/' + systemData.systemId + '/pastSeconds/' + timeFrameInSeconds + '/'; killboardGraphElement.showLoadingAnimation(); @@ -338,16 +338,14 @@ define([ // the API wont return more than 200KMs ! - remember last bar block with complete KM information let lastCompleteDiffHourData = 0; - // loop kills and count kills by hour for (let i = 0; i < kbData.length; i++) { let killmailData = kbData[i]; - - let killDate = getDateObjectByTimeString(killmailData.killTime); + let killDate = Util.convertDateToUTC(new Date(killmailData.killmail_time)); // get time diff let timeDiffMin = Math.round(( serverDate - killDate ) / 1000 / 60); - let timeDiffHour = Math.round(timeDiffMin / 60); + let timeDiffHour = Math.floor(timeDiffMin / 60); // update chart data if (chartData[timeDiffHour]) { @@ -420,22 +418,11 @@ define([ }); }; - /** - * transform timestring - * @param timeString - * @returns {Date} - */ - let getDateObjectByTimeString = function(timeString){ - let match = timeString.match(/^(\d+)-(\d+)-(\d+) (\d+)\:(\d+)\:(\d+)$/); - let date = new Date(match[1], match[2] - 1, match[3], match[4], match[5], match[6]); - - return date; - }; - /** * get module element + * @param parentElement * @param systemData - * @returns {*|HTMLElement} + * @returns {*|jQuery|HTMLElement} */ let getModule = function(parentElement, systemData){ diff --git a/js/app/util.js b/js/app/util.js index 58f4045f1..350df0267 100644 --- a/js/app/util.js +++ b/js/app/util.js @@ -4,8 +4,8 @@ define([ 'jquery', 'app/init', - 'config/system_effect', - 'config/signature_type', + 'conf/system_effect', + 'conf/signature_type', 'bootbox', 'localForage', 'velocity', @@ -264,7 +264,8 @@ define([ errors[i].field.length > 0 ){ let formField = formElement.find('[name="' + errors[i].field + '"]'); - formField.parents('.form-group').removeClass('has-success').addClass('has-error'); + let formGroup = formField.parents('.form-group').removeClass('has-success').addClass('has-error'); + let formHelp = formGroup.find('.help-block').text(errors[i].message); } }else if(errors[i].type === 'warning'){ @@ -559,6 +560,7 @@ define([ let data = { id: config.headCharacterSwitchId, + browserTabId: getBrowserTabId(), routes: Init.routes, userData: userData, otherCharacters: $.grep( userData.characters, function( character ) { @@ -729,17 +731,24 @@ define([ let defaultOptions = { dismissible: true, + messageId: 'pf-alert-' + Math.random().toString(36).substring(7), messageTypeClass: messageTypeClass, - messageTextClass: messageTextClass + messageTextClass: messageTextClass, + insertElement: 'replace' }; defaultOptions = $.extend(defaultOptions, config); - let content = Mustache.render(template, defaultOptions); - containerElement.html(content); + switch(defaultOptions.insertElement){ + case 'replace': containerElement.html(content); break; + case 'prepend': containerElement.prepend(content); break; + case 'append': containerElement.append(content); break; + default: console.error('insertElement: %s is not specified!', defaultOptions.insertElement); + } - containerElement.children().first().velocity('stop').velocity('fadeIn'); + //containerElement.children().first().velocity('stop').velocity('fadeIn'); + $('#' + defaultOptions.messageId).velocity('stop').velocity('fadeIn'); }); }; @@ -1072,6 +1081,20 @@ define([ return Init.currentUserData; }; + /** + * get a unique ID for each tab + * -> store ID in session storage + */ + let getBrowserTabId = () => { + let key = 'tabId'; + let tabId = sessionStorage.getItem(key); + if(tabId === null){ + tabId = Math.random().toString(36).substr(2, 5); + sessionStorage.setItem(key, tabId); + } + return tabId; + }; + /** * set default jQuery AJAX configuration */ @@ -1081,6 +1104,9 @@ define([ // Add custom application headers on "same origin" requests only! // -> Otherwise a "preflight" request is made, which will "probably" fail if(settings.crossDomain === false){ + // Add browser tab information + xhr.setRequestHeader('Pf-Tab-Id', getBrowserTabId()) ; + // add current character data to ANY XHR request (HTTP HEADER) // -> This helps to identify multiple characters on multiple browser tabs let userData = getCurrentUserData(); @@ -2058,34 +2084,59 @@ define([ }; /** - * Create Date as UTC + * clear session Storage + * -> otherwise a tab refresh does not clear sessionStorage! + */ + let clearSessionStorage = () => { + if(sessionStorage){ + sessionStorage.clear(); + } + }; + + /** + * Create Date() as UTC * @param date * @returns {Date} */ - let createDateAsUTC = function(date) { + let createDateAsUTC = function(date){ return new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate(), date.getHours(), date.getMinutes(), date.getSeconds())); }; /** - * Convert Date to UTC (!important function!) + * Convert Date() to UTC (!important function!) * @param date * @returns {Date} */ - let convertDateToUTC = function(date) { + let convertDateToUTC = function(date){ return new Date(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), date.getUTCHours(), date.getUTCMinutes(), date.getUTCSeconds()); }; /** - * Convert Date to Time String + * Convert Date() to Time String * @param date + * @param showSeconds * @returns {string} */ - let convertDateToString = function(date){ + let convertDateToString = function(date, showSeconds){ let dateString = ('0'+ (date.getMonth() + 1 )).slice(-2) + '/' + ('0'+date.getDate()).slice(-2) + '/' + date.getFullYear(); let timeString = ('0' + date.getHours()).slice(-2) + ':' + ('0'+date.getMinutes()).slice(-2); + timeString += (showSeconds) ? ':' + ('0'+date.getSeconds()).slice(-2) : ''; return dateString + ' ' + timeString; }; + /** + * get deep json object value if exists + * -> e.g. key = 'first.last.third' string + * @param obj + * @param key + * @returns {*} + */ + let getObjVal = (obj, key) => { + return key.split('.').reduce((o, x) => { + return (typeof o === 'undefined' || o === null) ? o : o[x]; + }, obj); + }; + /** * get document path * -> www.pathfinder.com/pathfinder/ -> /pathfinder @@ -2197,11 +2248,15 @@ define([ getNearBySystemData: getNearBySystemData, getNearByCharacterData: getNearByCharacterData, setDestination: setDestination, + convertDateToUTC: convertDateToUTC, convertDateToString: convertDateToString, getOpenDialogs: getOpenDialogs, openIngameWindow: openIngameWindow, formatPrice: formatPrice, getLocalStorage: getLocalStorage, + clearSessionStorage: clearSessionStorage, + getBrowserTabId: getBrowserTabId, + getObjVal: getObjVal, getDocumentPath: getDocumentPath, redirect: redirect, logout: logout diff --git a/js/lib/bootbox.min.js b/js/lib/bootbox.min.js index 0dc0cbd5f..cb8edd0ad 100644 --- a/js/lib/bootbox.min.js +++ b/js/lib/bootbox.min.js @@ -3,4 +3,4 @@ * * http://bootboxjs.com/license.txt */ -!function(a,b){"use strict";"function"==typeof define&&define.amd?define(["jquery"],b):"object"==typeof exports?module.exports=b(require("jquery")):a.bootbox=b(a.jQuery)}(this,function a(b,c){"use strict";function d(a){var b=q[o.locale];return b?b[a]:q.en[a]}function e(a,c,d){a.stopPropagation(),a.preventDefault();var e=b.isFunction(d)&&d.call(c,a)===!1;e||c.modal("hide")}function f(a){var b,c=0;for(b in a)c++;return c}function g(a,c){var d=0;b.each(a,function(a,b){c(a,b,d++)})}function h(a){var c,d;if("object"!=typeof a)throw new Error("Please supply an object of options");if(!a.message)throw new Error("Please specify a message");return a=b.extend({},o,a),a.buttons||(a.buttons={}),c=a.buttons,d=f(c),g(c,function(a,e,f){if(b.isFunction(e)&&(e=c[a]={callback:e}),"object"!==b.type(e))throw new Error("button with key "+a+" must be an object");e.label||(e.label=a),e.className||(e.className=2>=d&&f===d-1?"btn-primary":"btn-default")}),a}function i(a,b){var c=a.length,d={};if(1>c||c>2)throw new Error("Invalid argument length");return 2===c||"string"==typeof a[0]?(d[b[0]]=a[0],d[b[1]]=a[1]):d=a[0],d}function j(a,c,d){return b.extend(!0,{},a,i(c,d))}function k(a,b,c,d){var e={className:"bootbox-"+a,buttons:l.apply(null,b)};return m(j(e,d,c),b)}function l(){for(var a={},b=0,c=arguments.length;c>b;b++){var e=arguments[b],f=e.toLowerCase(),g=e.toUpperCase();a[f]={label:d(g)}}return a}function m(a,b){var d={};return g(b,function(a,b){d[b]=!0}),g(a.buttons,function(a){if(d[a]===c)throw new Error("button key "+a+" is not allowed (options are "+b.join("\n")+")")}),a}var n={dialog:"",header:"",footer:"",closeButton:"",form:"
",inputs:{text:"",textarea:"",email:"",select:"",checkbox:"
",date:"",time:"",number:"",password:""}},o={locale:"en",backdrop:"static",animate:!0,className:null,closeButton:!0,show:!0,container:"body"},p={};p.alert=function(){var a;if(a=k("alert",["ok"],["message","callback"],arguments),a.callback&&!b.isFunction(a.callback))throw new Error("alert requires callback property to be a function when provided");return a.buttons.ok.callback=a.onEscape=function(){return b.isFunction(a.callback)?a.callback.call(this):!0},p.dialog(a)},p.confirm=function(){var a;if(a=k("confirm",["cancel","confirm"],["message","callback"],arguments),a.buttons.cancel.callback=a.onEscape=function(){return a.callback.call(this,!1)},a.buttons.confirm.callback=function(){return a.callback.call(this,!0)},!b.isFunction(a.callback))throw new Error("confirm requires a callback");return p.dialog(a)},p.prompt=function(){var a,d,e,f,h,i,k;if(f=b(n.form),d={className:"bootbox-prompt",buttons:l("cancel","confirm"),value:"",inputType:"text"},a=m(j(d,arguments,["title","callback"]),["cancel","confirm"]),i=a.show===c?!0:a.show,a.message=f,a.buttons.cancel.callback=a.onEscape=function(){return a.callback.call(this,null)},a.buttons.confirm.callback=function(){var c;switch(a.inputType){case"text":case"textarea":case"email":case"select":case"date":case"time":case"number":case"password":c=h.val();break;case"checkbox":var d=h.find("input:checked");c=[],g(d,function(a,d){c.push(b(d).val())})}return a.callback.call(this,c)},a.show=!1,!a.title)throw new Error("prompt requires a title");if(!b.isFunction(a.callback))throw new Error("prompt requires a callback");if(!n.inputs[a.inputType])throw new Error("invalid prompt type");switch(h=b(n.inputs[a.inputType]),a.inputType){case"text":case"textarea":case"email":case"date":case"time":case"number":case"password":h.val(a.value);break;case"select":var o={};if(k=a.inputOptions||[],!b.isArray(k))throw new Error("Please pass an array of input options");if(!k.length)throw new Error("prompt with select requires options");g(k,function(a,d){var e=h;if(d.value===c||d.text===c)throw new Error("given options in wrong format");d.group&&(o[d.group]||(o[d.group]=b("").attr("label",d.group)),e=o[d.group]),e.append("")}),g(o,function(a,b){h.append(b)}),h.val(a.value);break;case"checkbox":var q=b.isArray(a.value)?a.value:[a.value];if(k=a.inputOptions||[],!k.length)throw new Error("prompt with checkbox requires options");if(!k[0].value||!k[0].text)throw new Error("given options in wrong format");h=b("
"),g(k,function(c,d){var e=b(n.inputs[a.inputType]);e.find("input").attr("value",d.value),e.find("label").append(d.text),g(q,function(a,b){b===d.value&&e.find("input").prop("checked",!0)}),h.append(e)})}return a.placeholder&&h.attr("placeholder",a.placeholder),a.pattern&&h.attr("pattern",a.pattern),a.maxlength&&h.attr("maxlength",a.maxlength),f.append(h),f.on("submit",function(a){a.preventDefault(),a.stopPropagation(),e.find(".btn-primary").click()}),e=p.dialog(a),e.off("shown.bs.modal"),e.on("shown.bs.modal",function(){h.focus()}),i===!0&&e.modal("show"),e},p.dialog=function(a){a=h(a);var d=b(n.dialog),f=d.find(".modal-dialog"),i=d.find(".modal-body"),j=a.buttons,k="",l={onEscape:a.onEscape};if(b.fn.modal===c)throw new Error("$.fn.modal is not defined; please double check you have included the Bootstrap JavaScript library. See http://getbootstrap.com/javascript/ for more details.");if(g(j,function(a,b){k+="",l[a]=b.callback}),i.find(".bootbox-body").html(a.message),a.animate===!0&&d.addClass("fade"),a.className&&d.addClass(a.className),"large"===a.size?f.addClass("modal-lg"):"small"===a.size&&f.addClass("modal-sm"),a.title&&i.before(n.header),a.closeButton){var m=b(n.closeButton);a.title?d.find(".modal-header").prepend(m):m.css("margin-top","-10px").prependTo(i)}return a.title&&d.find(".modal-title").html(a.title),k.length&&(i.after(n.footer),d.find(".modal-footer").html(k)),d.on("hidden.bs.modal",function(a){a.target===this&&d.remove()}),d.on("shown.bs.modal",function(){d.find(".btn-primary:first").focus()}),"static"!==a.backdrop&&d.on("click.dismiss.bs.modal",function(a){d.children(".modal-backdrop").length&&(a.currentTarget=d.children(".modal-backdrop").get(0)),a.target===a.currentTarget&&d.trigger("escape.close.bb")}),d.on("escape.close.bb",function(a){l.onEscape&&e(a,d,l.onEscape)}),d.on("click",".modal-footer button",function(a){var c=b(this).data("bb-handler");e(a,d,l[c])}),d.on("click",".bootbox-close-button",function(a){e(a,d,l.onEscape)}),d.on("keyup",function(a){27===a.which&&d.trigger("escape.close.bb")}),b(a.container).append(d),d.modal({backdrop:a.backdrop?"static":!1,keyboard:!1,show:!1}),a.show&&d.modal("show"),d},p.setDefaults=function(){var a={};2===arguments.length?a[arguments[0]]=arguments[1]:a=arguments[0],b.extend(o,a)},p.hideAll=function(){return b(".bootbox").modal("hide"),p};var q={bg_BG:{OK:"Ок",CANCEL:"Отказ",CONFIRM:"Потвърждавам"},br:{OK:"OK",CANCEL:"Cancelar",CONFIRM:"Sim"},cs:{OK:"OK",CANCEL:"Zrušit",CONFIRM:"Potvrdit"},da:{OK:"OK",CANCEL:"Annuller",CONFIRM:"Accepter"},de:{OK:"OK",CANCEL:"Abbrechen",CONFIRM:"Akzeptieren"},el:{OK:"Εντάξει",CANCEL:"Ακύρωση",CONFIRM:"Επιβεβαίωση"},en:{OK:"OK",CANCEL:"Cancel",CONFIRM:"OK"},es:{OK:"OK",CANCEL:"Cancelar",CONFIRM:"Aceptar"},et:{OK:"OK",CANCEL:"Katkesta",CONFIRM:"OK"},fa:{OK:"قبول",CANCEL:"لغو",CONFIRM:"تایید"},fi:{OK:"OK",CANCEL:"Peruuta",CONFIRM:"OK"},fr:{OK:"OK",CANCEL:"Annuler",CONFIRM:"D'accord"},he:{OK:"אישור",CANCEL:"ביטול",CONFIRM:"אישור"},hu:{OK:"OK",CANCEL:"Mégsem",CONFIRM:"Megerősít"},hr:{OK:"OK",CANCEL:"Odustani",CONFIRM:"Potvrdi"},id:{OK:"OK",CANCEL:"Batal",CONFIRM:"OK"},it:{OK:"OK",CANCEL:"Annulla",CONFIRM:"Conferma"},ja:{OK:"OK",CANCEL:"キャンセル",CONFIRM:"確認"},lt:{OK:"Gerai",CANCEL:"Atšaukti",CONFIRM:"Patvirtinti"},lv:{OK:"Labi",CANCEL:"Atcelt",CONFIRM:"Apstiprināt"},nl:{OK:"OK",CANCEL:"Annuleren",CONFIRM:"Accepteren"},no:{OK:"OK",CANCEL:"Avbryt",CONFIRM:"OK"},pl:{OK:"OK",CANCEL:"Anuluj",CONFIRM:"Potwierdź"},pt:{OK:"OK",CANCEL:"Cancelar",CONFIRM:"Confirmar"},ru:{OK:"OK",CANCEL:"Отмена",CONFIRM:"Применить"},sq:{OK:"OK",CANCEL:"Anulo",CONFIRM:"Prano"},sv:{OK:"OK",CANCEL:"Avbryt",CONFIRM:"OK"},th:{OK:"ตกลง",CANCEL:"ยกเลิก",CONFIRM:"ยืนยัน"},tr:{OK:"Tamam",CANCEL:"İptal",CONFIRM:"Onayla"},zh_CN:{OK:"OK",CANCEL:"取消",CONFIRM:"确认"},zh_TW:{OK:"OK",CANCEL:"取消",CONFIRM:"確認"}};return p.addLocale=function(a,c){return b.each(["OK","CANCEL","CONFIRM"],function(a,b){if(!c[b])throw new Error("Please supply a translation for '"+b+"'")}),q[a]={OK:c.OK,CANCEL:c.CANCEL,CONFIRM:c.CONFIRM},p},p.removeLocale=function(a){return delete q[a],p},p.setLocale=function(a){return p.setDefaults("locale",a)},p.init=function(c){return a(c||b)},p}); \ No newline at end of file +!function(a,b){"use strict";"function"==typeof define&&define.amd?define(["jquery"],b):"object"==typeof exports?module.exports=b(require("jquery")):a.bootbox=b(a.jQuery)}(this,function a(b,c){"use strict";function d(a){var b=q[o.locale];return b?b[a]:q.en[a]}function e(a,c,d){a.stopPropagation(),a.preventDefault();var e=b.isFunction(d)&&d.call(c,a)===!1;e||c.modal("hide")}function f(a){var b,c=0;for(b in a)c++;return c}function g(a,c){var d=0;b.each(a,function(a,b){c(a,b,d++)})}function h(a){var c,d;if("object"!=typeof a)throw new Error("Please supply an object of options");if(!a.message)throw new Error("Please specify a message");return a=b.extend({},o,a),a.buttons||(a.buttons={}),c=a.buttons,d=f(c),g(c,function(a,e,f){if(b.isFunction(e)&&(e=c[a]={callback:e}),"object"!==b.type(e))throw new Error("button with key "+a+" must be an object");e.label||(e.label=a),e.className||(e.className=2>=d&&f===d-1?"btn-primary":"btn-default")}),a}function i(a,b){var c=a.length,d={};if(1>c||c>2)throw new Error("Invalid argument length");return 2===c||"string"==typeof a[0]?(d[b[0]]=a[0],d[b[1]]=a[1]):d=a[0],d}function j(a,c,d){return b.extend(!0,{},a,i(c,d))}function k(a,b,c,d){var e={className:"bootbox-"+a,buttons:l.apply(null,b)};return m(j(e,d,c),b)}function l(){for(var a={},b=0,c=arguments.length;c>b;b++){var e=arguments[b],f=e.toLowerCase(),g=e.toUpperCase();a[f]={label:d(g)}}return a}function m(a,b){var d={};return g(b,function(a,b){d[b]=!0}),g(a.buttons,function(a){if(d[a]===c)throw new Error("button key "+a+" is not allowed (options are "+b.join("\n")+")")}),a}var n={dialog:"",header:"",footer:"",closeButton:"",form:"
",inputs:{text:"",textarea:"",email:"",select:"",checkbox:"
",date:"",time:"",number:"",password:""}},o={locale:"en",backdrop:"static",animate:!0,className:null,closeButton:!0,show:!0,container:"body"},p={};p.alert=function(){var a;if(a=k("alert",["ok"],["message","callback"],arguments),a.callback&&!b.isFunction(a.callback))throw new Error("alert requires callback property to be a function when provided");return a.buttons.ok.callback=a.onEscape=function(){return b.isFunction(a.callback)?a.callback.call(this):!0},p.dialog(a)},p.confirm=function(){var a;if(a=k("confirm",["cancel","confirm"],["message","callback"],arguments),a.buttons.cancel.callback=a.onEscape=function(){return a.callback.call(this,!1)},a.buttons.confirm.callback=function(){return a.callback.call(this,!0)},!b.isFunction(a.callback))throw new Error("confirm requires a callback");return p.dialog(a)},p.prompt=function(){var a,d,e,f,h,i,k;if(f=b(n.form),d={className:"bootbox-prompt",buttons:l("cancel","confirm"),value:"",inputType:"text"},a=m(j(d,arguments,["title","callback"]),["cancel","confirm"]),i=a.show===c?!0:a.show,a.message=f,a.buttons.cancel.callback=a.onEscape=function(){return a.callback.call(this,null)},a.buttons.confirm.callback=function(){var c;switch(a.inputType){case"text":case"textarea":case"email":case"select":case"date":case"time":case"number":case"password":c=h.val();break;case"checkbox":var d=h.find("input:checked");c=[],g(d,function(a,d){c.push(b(d).val())})}return a.callback.call(this,c)},a.show=!1,!a.title)throw new Error("prompt requires a title");if(!b.isFunction(a.callback))throw new Error("prompt requires a callback");if(!n.inputs[a.inputType])throw new Error("invalid prompt type");switch(h=b(n.inputs[a.inputType]),a.inputType){case"text":case"textarea":case"email":case"date":case"time":case"number":case"password":h.val(a.value);break;case"select":var o={};if(k=a.inputOptions||[],!b.isArray(k))throw new Error("Please pass an array of input options");if(!k.length)throw new Error("prompt with select requires options");g(k,function(a,d){var e=h;if(d.value===c||d.text===c)throw new Error("given options in wrong format");d.group&&(o[d.group]||(o[d.group]=b("").attr("label",d.group)),e=o[d.group]),e.append("")}),g(o,function(a,b){h.append(b)}),h.val(a.value);break;case"checkbox":var q=b.isArray(a.value)?a.value:[a.value];if(k=a.inputOptions||[],!k.length)throw new Error("prompt with checkbox requires options");if(!k[0].value||!k[0].text)throw new Error("given options in wrong format");h=b("
"),g(k,function(c,d){var e=b(n.inputs[a.inputType]);e.find("input").attr("value",d.value),e.find("label").append(d.text),g(q,function(a,b){b===d.value&&e.find("input").prop("checked",!0)}),h.append(e)})}return a.placeholder&&h.attr("placeholder",a.placeholder),a.pattern&&h.attr("pattern",a.pattern),a.maxlength&&h.attr("maxlength",a.maxlength),f.append(h),f.on("submit",function(a){a.preventDefault(),a.stopPropagation(),e.find(".btn-primary").click()}),e=p.dialog(a),e.off("shown.bs.modal"),e.on("shown.bs.modal",function(){h.focus()}),i===!0&&e.modal("show"),e},p.dialog=function(a){a=h(a);var d=b(n.dialog),f=d.find(".modal-dialog"),i=d.find(".modal-body"),j=a.buttons,k="",l={onEscape:a.onEscape};if(b.fn.modal===c)throw new Error("$.fn.modal is not defined; please double check you have included the Bootstrap JavaScript library. See http://getbootstrap.com/javascript/ for more details.");if(g(j,function(a,b){k+="",l[a]=b.callback}),i.find(".bootbox-body").html(a.message),a.animate===!0&&d.addClass("fade"),a.className&&d.addClass(a.className),"large"===a.size?f.addClass("modal-lg"):"small"===a.size&&f.addClass("modal-sm"),a.title&&i.before(n.header),a.closeButton){var m=b(n.closeButton);a.title?d.find(".modal-header").prepend(m):m.prependTo(i)}return a.title&&d.find(".modal-title").html(a.title),k.length&&(i.after(n.footer),d.find(".modal-footer").html(k)),d.on("hidden.bs.modal",function(a){a.target===this&&d.remove()}),d.on("shown.bs.modal",function(){d.find(".btn-primary:first").focus()}),"static"!==a.backdrop&&d.on("click.dismiss.bs.modal",function(a){d.children(".modal-backdrop").length&&(a.currentTarget=d.children(".modal-backdrop").get(0)),a.target===a.currentTarget&&d.trigger("escape.close.bb")}),d.on("escape.close.bb",function(a){l.onEscape&&e(a,d,l.onEscape)}),d.on("click",".modal-footer button",function(a){var c=b(this).data("bb-handler");e(a,d,l[c])}),d.on("click",".bootbox-close-button",function(a){e(a,d,l.onEscape)}),d.on("keyup",function(a){27===a.which&&d.trigger("escape.close.bb")}),b(a.container).append(d),d.modal({backdrop:a.backdrop?"static":!1,keyboard:!1,show:!1}),a.show&&d.modal("show"),d},p.setDefaults=function(){var a={};2===arguments.length?a[arguments[0]]=arguments[1]:a=arguments[0],b.extend(o,a)},p.hideAll=function(){return b(".bootbox").modal("hide"),p};var q={bg_BG:{OK:"Ок",CANCEL:"Отказ",CONFIRM:"Потвърждавам"},br:{OK:"OK",CANCEL:"Cancelar",CONFIRM:"Sim"},cs:{OK:"OK",CANCEL:"Zrušit",CONFIRM:"Potvrdit"},da:{OK:"OK",CANCEL:"Annuller",CONFIRM:"Accepter"},de:{OK:"OK",CANCEL:"Abbrechen",CONFIRM:"Akzeptieren"},el:{OK:"Εντάξει",CANCEL:"Ακύρωση",CONFIRM:"Επιβεβαίωση"},en:{OK:"OK",CANCEL:"Cancel",CONFIRM:"OK"},es:{OK:"OK",CANCEL:"Cancelar",CONFIRM:"Aceptar"},et:{OK:"OK",CANCEL:"Katkesta",CONFIRM:"OK"},fa:{OK:"قبول",CANCEL:"لغو",CONFIRM:"تایید"},fi:{OK:"OK",CANCEL:"Peruuta",CONFIRM:"OK"},fr:{OK:"OK",CANCEL:"Annuler",CONFIRM:"D'accord"},he:{OK:"אישור",CANCEL:"ביטול",CONFIRM:"אישור"},hu:{OK:"OK",CANCEL:"Mégsem",CONFIRM:"Megerősít"},hr:{OK:"OK",CANCEL:"Odustani",CONFIRM:"Potvrdi"},id:{OK:"OK",CANCEL:"Batal",CONFIRM:"OK"},it:{OK:"OK",CANCEL:"Annulla",CONFIRM:"Conferma"},ja:{OK:"OK",CANCEL:"キャンセル",CONFIRM:"確認"},lt:{OK:"Gerai",CANCEL:"Atšaukti",CONFIRM:"Patvirtinti"},lv:{OK:"Labi",CANCEL:"Atcelt",CONFIRM:"Apstiprināt"},nl:{OK:"OK",CANCEL:"Annuleren",CONFIRM:"Accepteren"},no:{OK:"OK",CANCEL:"Avbryt",CONFIRM:"OK"},pl:{OK:"OK",CANCEL:"Anuluj",CONFIRM:"Potwierdź"},pt:{OK:"OK",CANCEL:"Cancelar",CONFIRM:"Confirmar"},ru:{OK:"OK",CANCEL:"Отмена",CONFIRM:"Применить"},sq:{OK:"OK",CANCEL:"Anulo",CONFIRM:"Prano"},sv:{OK:"OK",CANCEL:"Avbryt",CONFIRM:"OK"},th:{OK:"ตกลง",CANCEL:"ยกเลิก",CONFIRM:"ยืนยัน"},tr:{OK:"Tamam",CANCEL:"İptal",CONFIRM:"Onayla"},zh_CN:{OK:"OK",CANCEL:"取消",CONFIRM:"确认"},zh_TW:{OK:"OK",CANCEL:"取消",CONFIRM:"確認"}};return p.addLocale=function(a,c){return b.each(["OK","CANCEL","CONFIRM"],function(a,b){if(!c[b])throw new Error("Please supply a translation for '"+b+"'")}),q[a]={OK:c.OK,CANCEL:c.CANCEL,CONFIRM:c.CONFIRM},p},p.removeLocale=function(a){return delete q[a],p},p.setLocale=function(a){return p.setDefaults("locale",a)},p.init=function(c){return a(c||b)},p}); \ No newline at end of file diff --git a/js/lib/jquery.dragToSelect.js b/js/lib/jquery.dragToSelect.js index e37756fbf..98937ab41 100644 --- a/js/lib/jquery.dragToSelect.js +++ b/js/lib/jquery.dragToSelect.js @@ -93,13 +93,26 @@ jQuery.fn.dragToSelect = function (conf) { return this; } - var parentOffset = parent.offset(); - var parentDim = { - left: parentOffset.left, - top: parentOffset.top, - width: parent.width(), - height: parent.height() - }; + var parentDim = { + left: 0, + top: 0, + width: 10, + height: 10 + }; + + // set parent dimensions + // -> should be updated in case of left/right menu is open + var setParentDimensions = (parent) => { + var parentOffset = parent.offset(); + parentDim = { + left: parentOffset.left, + top: parentOffset.top, + width: parent.width(), + height: parent.height() + }; + } + + setParentDimensions(parent); // Current origin of select box var selectBoxOrigin = { @@ -343,6 +356,7 @@ jQuery.fn.dragToSelect = function (conf) { // Do the right stuff then return this -------------------------------------------------------- selectBox.mousemove(function(e){ + setParentDimensions(parent); lastMousePosition.x = e.pageX; lastMousePosition.y = e.pageY; e.preventDefault(); @@ -353,7 +367,6 @@ jQuery.fn.dragToSelect = function (conf) { e.which === 1 && // left mouse down e.target === realParent[0] // prevent while dragging a system :) ) { - // Make sure user isn't clicking scrollbar (or disallow clicks far to the right actually) if ((e.pageX + 20) > jQuery(document.body).width()) { return; @@ -366,6 +379,7 @@ jQuery.fn.dragToSelect = function (conf) { e.preventDefault(); }).mousemove(function(e){ + setParentDimensions(parent); lastMousePosition.x = e.pageX; lastMousePosition.y = e.pageY; e.preventDefault(); diff --git a/js/lib/validator.min.js b/js/lib/validator.min.js index 9a49ff3d7..71916ad10 100644 --- a/js/lib/validator.min.js +++ b/js/lib/validator.min.js @@ -1,9 +1,9 @@ /*! - * Validator v0.10.1 for Bootstrap 3, by @1000hz - * Copyright 2016 Cina Saffary + * Validator v0.11.9 for Bootstrap 3, by @1000hz + * Copyright 2017 Cina Saffary * Licensed under http://opensource.org/licenses/MIT * * https://github.com/1000hz/bootstrap-validator */ -+function(a){"use strict";function b(b){return b.is('[type="checkbox"]')?b.prop("checked"):b.is('[type="radio"]')?!!a('[name="'+b.attr("name")+'"]:checked').length:a.trim(b.val())}function c(b){return this.each(function(){var c=a(this),e=a.extend({},d.DEFAULTS,c.data(),"object"==typeof b&&b),f=c.data("bs.validator");(f||"destroy"!=b)&&(f||c.data("bs.validator",f=new d(this,e)),"string"==typeof b&&f[b]())})}var d=function(c,e){this.options=e,this.$element=a(c),this.$inputs=this.$element.find(d.INPUT_SELECTOR),this.$btn=a('button[type="submit"], input[type="submit"]').filter('[form="'+this.$element.attr("id")+'"]').add(this.$element.find('input[type="submit"], button[type="submit"]')),e.errors=a.extend({},d.DEFAULTS.errors,e.errors);for(var f in e.custom)if(!e.errors[f])throw new Error("Missing default error message for custom validator: "+f);a.extend(d.VALIDATORS,e.custom),this.$element.attr("novalidate",!0),this.toggleSubmit(),this.$element.on("input.bs.validator change.bs.validator focusout.bs.validator",d.INPUT_SELECTOR,a.proxy(this.onInput,this)),this.$element.on("submit.bs.validator",a.proxy(this.onSubmit,this)),this.$element.find("[data-match]").each(function(){var c=a(this),d=c.data("match");a(d).on("input.bs.validator",function(){b(c)&&c.trigger("input.bs.validator")})})};d.INPUT_SELECTOR=':input:not([type="submit"], button):enabled:visible',d.FOCUS_OFFSET=20,d.DEFAULTS={delay:500,html:!1,disable:!0,focus:!0,custom:{},errors:{match:"Does not match",minlength:"Not long enough"},feedback:{success:"glyphicon-ok",error:"glyphicon-remove"}},d.VALIDATORS={"native":function(a){var b=a[0];return b.checkValidity?b.checkValidity():!0},match:function(b){var c=b.data("match");return!b.val()||b.val()===a(c).val()},minlength:function(a){var b=a.data("minlength");return!a.val()||a.val().length>=b}},d.prototype.onInput=function(b){var c=this,d=a(b.target),e="focusout"!==b.type;this.validateInput(d,e).done(function(){c.toggleSubmit()})},d.prototype.validateInput=function(c,d){var e=b(c),f=c.data("bs.validator.previous"),g=c.data("bs.validator.errors");if(f===e)return a.Deferred().resolve();c.data("bs.validator.previous",e),c.is('[type="radio"]')&&(c=this.$element.find('input[name="'+c.attr("name")+'"]'));var h=a.Event("validate.bs.validator",{relatedTarget:c[0]});if(this.$element.trigger(h),!h.isDefaultPrevented()){var i=this;return this.runValidators(c).done(function(b){c.data("bs.validator.errors",b),b.length?d?i.defer(c,i.showErrors):i.showErrors(c):i.clearErrors(c),g&&b.toString()===g.toString()||(h=b.length?a.Event("invalid.bs.validator",{relatedTarget:c[0],detail:b}):a.Event("valid.bs.validator",{relatedTarget:c[0],detail:g}),i.$element.trigger(h)),i.toggleSubmit(),i.$element.trigger(a.Event("validated.bs.validator",{relatedTarget:c[0]}))})}},d.prototype.runValidators=function(c){function e(a){return c.data(a+"-error")||c.data("error")||"native"==a&&c[0].validationMessage||h.errors[a]}var f=[],g=a.Deferred(),h=this.options;return c.data("bs.validator.deferred")&&c.data("bs.validator.deferred").reject(),c.data("bs.validator.deferred",g),a.each(d.VALIDATORS,a.proxy(function(a,d){if((b(c)||c.attr("required"))&&(c.data(a)||"native"==a)&&!d.call(this,c)){var g=e(a);!~f.indexOf(g)&&f.push(g)}},this)),!f.length&&b(c)&&c.data("remote")?this.defer(c,function(){var d={};d[c.attr("name")]=b(c),a.get(c.data("remote"),d).fail(function(a,b,c){f.push(e("remote")||c)}).always(function(){g.resolve(f)})}):g.resolve(f),g.promise()},d.prototype.validate=function(){var b=this;return a.when(this.$inputs.map(function(){return b.validateInput(a(this),!1)})).then(function(){b.toggleSubmit(),b.$btn.hasClass("disabled")&&b.focusError()}),this},d.prototype.focusError=function(){if(this.options.focus){var b=a(".has-error:first :input");a(document.body).animate({scrollTop:b.offset().top-d.FOCUS_OFFSET},250),b.focus()}},d.prototype.showErrors=function(b){var c=this.options.html?"html":"text",d=b.data("bs.validator.errors"),e=b.closest(".form-group"),f=e.find(".help-block.with-errors"),g=e.find(".form-control-feedback");d.length&&(d=a("
","
"],col:[2,"","
"],tr:[2,"","
"],td:[3,"","
"],_default:[0,"",""]};Ve.optgroup=Ve.option,Ve.tbody=Ve.tfoot=Ve.colgroup=Ve.caption=Ve.thead,Ve.th=Ve.td;var Xe=/<|&#?\w+;/;!function(){var e=ee.createDocumentFragment().appendChild(ee.createElement("div")),t=ee.createElement("input");t.setAttribute("type","radio"),t.setAttribute("checked","checked"),t.setAttribute("name","t"),e.appendChild(t),de.checkClone=e.cloneNode(!0).cloneNode(!0).lastChild.checked,e.innerHTML="",de.noCloneChecked=!!e.cloneNode(!0).lastChild.defaultValue}();var Ye=ee.documentElement,Ke=/^key/,Ge=/^(?:mouse|pointer|contextmenu|drag|drop)|click/,Qe=/^([^.]*)(?:\.(.+)|)/;pe.event={global:{},add:function(e,t,n,r,o){var a,i,s,l,c,u,d,f,p,h,m,g=Re.get(e);if(g)for(n.handler&&(a=n,n=a.handler,o=a.selector),o&&pe.find.matchesSelector(Ye,o),n.guid||(n.guid=pe.guid++),(l=g.events)||(l=g.events={}),(i=g.handle)||(i=g.handle=function(t){return void 0!==pe&&pe.event.triggered!==t.type?pe.event.dispatch.apply(e,arguments):void 0}),c=(t=(t||"").match(ke)||[""]).length;c--;)s=Qe.exec(t[c])||[],p=m=s[1],h=(s[2]||"").split(".").sort(),p&&(d=pe.event.special[p]||{},p=(o?d.delegateType:d.bindType)||p,d=pe.event.special[p]||{},u=pe.extend({type:p,origType:m,data:r,handler:n,guid:n.guid,selector:o,needsContext:o&&pe.expr.match.needsContext.test(o),namespace:h.join(".")},a),(f=l[p])||(f=l[p]=[],f.delegateCount=0,d.setup&&!1!==d.setup.call(e,r,h,i)||e.addEventListener&&e.addEventListener(p,i)),d.add&&(d.add.call(e,u),u.handler.guid||(u.handler.guid=n.guid)),o?f.splice(f.delegateCount++,0,u):f.push(u),pe.event.global[p]=!0)},remove:function(e,t,n,r,o){var a,i,s,l,c,u,d,f,p,h,m,g=Re.hasData(e)&&Re.get(e);if(g&&(l=g.events)){for(c=(t=(t||"").match(ke)||[""]).length;c--;)if(s=Qe.exec(t[c])||[],p=m=s[1],h=(s[2]||"").split(".").sort(),p){for(d=pe.event.special[p]||{},f=l[p=(r?d.delegateType:d.bindType)||p]||[],s=s[2]&&new RegExp("(^|\\.)"+h.join("\\.(?:.*\\.|)")+"(\\.|$)"),i=a=f.length;a--;)u=f[a],!o&&m!==u.origType||n&&n.guid!==u.guid||s&&!s.test(u.namespace)||r&&r!==u.selector&&("**"!==r||!u.selector)||(f.splice(a,1),u.selector&&f.delegateCount--,d.remove&&d.remove.call(e,u));i&&!f.length&&(d.teardown&&!1!==d.teardown.call(e,h,g.handle)||pe.removeEvent(e,p,g.handle),delete l[p])}else for(p in l)pe.event.remove(e,p+t[c],n,r,!0);pe.isEmptyObject(l)&&Re.remove(e,"handle events")}},dispatch:function(e){var t,n,r,o,a,i,s=pe.event.fix(e),l=new Array(arguments.length),c=(Re.get(this,"events")||{})[s.type]||[],u=pe.event.special[s.type]||{};for(l[0]=s,t=1;t=1))for(;c!==this;c=c.parentNode||this)if(1===c.nodeType&&("click"!==e.type||!0!==c.disabled)){for(a=[],i={},n=0;n-1:pe.find(o,this,null,[c]).length),i[o]&&a.push(r);a.length&&s.push({elem:c,handlers:a})}return c=this,l\x20\t\r\n\f]*)[^>]*)\/>/gi,Je=/\s*$/g;pe.extend({htmlPrefilter:function(e){return e.replace(Ze,"<$1>")},clone:function(e,t,n){var r,o,a,i,s=e.cloneNode(!0),l=pe.contains(e.ownerDocument,e);if(!(de.noCloneChecked||1!==e.nodeType&&11!==e.nodeType||pe.isXMLDoc(e)))for(i=v(s),a=v(e),r=0,o=a.length;r0&&y(i,!l&&v(e,"script")),s},cleanData:function(e){for(var t,n,r,o=pe.event.special,a=0;void 0!==(n=e[a]);a++)if(Ee(n)){if(t=n[Re.expando]){if(t.events)for(r in t.events)o[r]?pe.event.remove(n,r):pe.removeEvent(n,r,t.handle);n[Re.expando]=void 0}n[Pe.expando]&&(n[Pe.expando]=void 0)}}}),pe.fn.extend({detach:function(e){return O(this,e,!0)},remove:function(e){return O(this,e)},text:function(e){return Fe(this,function(e){return void 0===e?pe.text(this):this.empty().each(function(){1!==this.nodeType&&11!==this.nodeType&&9!==this.nodeType||(this.textContent=e)})},null,e,arguments.length)},append:function(){return A(this,arguments,function(e){1!==this.nodeType&&11!==this.nodeType&&9!==this.nodeType||T(this,e).appendChild(e)})},prepend:function(){return A(this,arguments,function(e){if(1===this.nodeType||11===this.nodeType||9===this.nodeType){var t=T(this,e);t.insertBefore(e,t.firstChild)}})},before:function(){return A(this,arguments,function(e){this.parentNode&&this.parentNode.insertBefore(e,this)})},after:function(){return A(this,arguments,function(e){this.parentNode&&this.parentNode.insertBefore(e,this.nextSibling)})},empty:function(){for(var e,t=0;null!=(e=this[t]);t++)1===e.nodeType&&(pe.cleanData(v(e,!1)),e.textContent="");return this},clone:function(e,t){return e=null!=e&&e,t=null==t?e:t,this.map(function(){return pe.clone(this,e,t)})},html:function(e){return Fe(this,function(e){var t=this[0]||{},n=0,r=this.length;if(void 0===e&&1===t.nodeType)return t.innerHTML;if("string"==typeof e&&!Je.test(e)&&!Ve[(qe.exec(e)||["",""])[1].toLowerCase()]){e=pe.htmlPrefilter(e);try{for(;n1)}}),pe.Tween=j,j.prototype={constructor:j,init:function(e,t,n,r,o,a){this.elem=e,this.prop=n,this.easing=o||pe.easing._default,this.options=t,this.start=this.now=this.cur(),this.end=r,this.unit=a||(pe.cssNumber[n]?"":"px")},cur:function(){var e=j.propHooks[this.prop];return e&&e.get?e.get(this):j.propHooks._default.get(this)},run:function(e){var t,n=j.propHooks[this.prop];return this.options.duration?this.pos=t=pe.easing[this.easing](e,this.options.duration*e,0,1,this.options.duration):this.pos=t=e,this.now=(this.end-this.start)*t+this.start,this.options.step&&this.options.step.call(this.elem,this.now,this),n&&n.set?n.set(this):j.propHooks._default.set(this),this}},j.prototype.init.prototype=j.prototype,j.propHooks={_default:{get:function(e){var t;return 1!==e.elem.nodeType||null!=e.elem[e.prop]&&null==e.elem.style[e.prop]?e.elem[e.prop]:(t=pe.css(e.elem,e.prop,""))&&"auto"!==t?t:0},set:function(e){pe.fx.step[e.prop]?pe.fx.step[e.prop](e):1!==e.elem.nodeType||null==e.elem.style[pe.cssProps[e.prop]]&&!pe.cssHooks[e.prop]?e.elem[e.prop]=e.now:pe.style(e.elem,e.prop,e.now+e.unit)}}},j.propHooks.scrollTop=j.propHooks.scrollLeft={set:function(e){e.elem.nodeType&&e.elem.parentNode&&(e.elem[e.prop]=e.now)}},pe.easing={linear:function(e){return e},swing:function(e){return.5-Math.cos(e*Math.PI)/2},_default:"swing"},pe.fx=j.prototype.init,pe.fx.step={};var dt,ft,pt=/^(?:toggle|show|hide)$/,ht=/queueHooks$/;pe.Animation=pe.extend(W,{tweeners:{"*":[function(e,t){var n=this.createTween(e,t);return h(n.elem,e,Be.exec(t),n),n}]},tweener:function(e,t){pe.isFunction(e)?(t=e,e=["*"]):e=e.match(ke);for(var n,r=0,o=e.length;r1)},removeAttr:function(e){return this.each(function(){pe.removeAttr(this,e)})}}),pe.extend({attr:function(e,t,n){var r,o,a=e.nodeType;if(3!==a&&8!==a&&2!==a)return void 0===e.getAttribute?pe.prop(e,t,n):(1===a&&pe.isXMLDoc(e)||(o=pe.attrHooks[t.toLowerCase()]||(pe.expr.match.bool.test(t)?mt:void 0)),void 0!==n?null===n?void pe.removeAttr(e,t):o&&"set"in o&&void 0!==(r=o.set(e,n,t))?r:(e.setAttribute(t,n+""),n):o&&"get"in o&&null!==(r=o.get(e,t))?r:null==(r=pe.find.attr(e,t))?void 0:r)},attrHooks:{type:{set:function(e,t){if(!de.radioValue&&"radio"===t&&pe.nodeName(e,"input")){var n=e.value;return e.setAttribute("type",t),n&&(e.value=n),t}}}},removeAttr:function(e,t){var n,r=0,o=t&&t.match(ke);if(o&&1===e.nodeType)for(;n=o[r++];)e.removeAttribute(n)}}),mt={set:function(e,t,n){return!1===t?pe.removeAttr(e,n):e.setAttribute(n,n),n}},pe.each(pe.expr.match.bool.source.match(/\w+/g),function(e,t){var n=gt[t]||pe.find.attr;gt[t]=function(e,t,r){var o,a,i=t.toLowerCase();return r||(a=gt[i],gt[i]=o,o=null!=n(e,t,r)?i:null,gt[i]=a),o}});var vt=/^(?:input|select|textarea|button)$/i,yt=/^(?:a|area)$/i;pe.fn.extend({prop:function(e,t){return Fe(this,pe.prop,e,t,arguments.length>1)},removeProp:function(e){return this.each(function(){delete this[pe.propFix[e]||e]})}}),pe.extend({prop:function(e,t,n){var r,o,a=e.nodeType;if(3!==a&&8!==a&&2!==a)return 1===a&&pe.isXMLDoc(e)||(t=pe.propFix[t]||t,o=pe.propHooks[t]),void 0!==n?o&&"set"in o&&void 0!==(r=o.set(e,n,t))?r:e[t]=n:o&&"get"in o&&null!==(r=o.get(e,t))?r:e[t]},propHooks:{tabIndex:{get:function(e){var t=pe.find.attr(e,"tabindex");return t?parseInt(t,10):vt.test(e.nodeName)||yt.test(e.nodeName)&&e.href?0:-1}}},propFix:{for:"htmlFor",class:"className"}}),de.optSelected||(pe.propHooks.selected={get:function(e){var t=e.parentNode;return t&&t.parentNode&&t.parentNode.selectedIndex,null},set:function(e){var t=e.parentNode;t&&(t.selectedIndex,t.parentNode&&t.parentNode.selectedIndex)}}),pe.each(["tabIndex","readOnly","maxLength","cellSpacing","cellPadding","rowSpan","colSpan","useMap","frameBorder","contentEditable"],function(){pe.propFix[this.toLowerCase()]=this}),pe.fn.extend({addClass:function(e){var t,n,r,o,a,i,s,l=0;if(pe.isFunction(e))return this.each(function(t){pe(this).addClass(e.call(this,t,z(this)))});if("string"==typeof e&&e)for(t=e.match(ke)||[];n=this[l++];)if(o=z(n),r=1===n.nodeType&&" "+q(o)+" "){for(i=0;a=t[i++];)r.indexOf(" "+a+" ")<0&&(r+=a+" ");o!==(s=q(r))&&n.setAttribute("class",s)}return this},removeClass:function(e){var t,n,r,o,a,i,s,l=0;if(pe.isFunction(e))return this.each(function(t){pe(this).removeClass(e.call(this,t,z(this)))});if(!arguments.length)return this.attr("class","");if("string"==typeof e&&e)for(t=e.match(ke)||[];n=this[l++];)if(o=z(n),r=1===n.nodeType&&" "+q(o)+" "){for(i=0;a=t[i++];)for(;r.indexOf(" "+a+" ")>-1;)r=r.replace(" "+a+" "," ");o!==(s=q(r))&&n.setAttribute("class",s)}return this},toggleClass:function(e,t){var n=typeof e;return"boolean"==typeof t&&"string"===n?t?this.addClass(e):this.removeClass(e):pe.isFunction(e)?this.each(function(n){pe(this).toggleClass(e.call(this,n,z(this),t),t)}):this.each(function(){var t,r,o,a;if("string"===n)for(r=0,o=pe(this),a=e.match(ke)||[];t=a[r++];)o.hasClass(t)?o.removeClass(t):o.addClass(t);else void 0!==e&&"boolean"!==n||((t=z(this))&&Re.set(this,"__className__",t),this.setAttribute&&this.setAttribute("class",t||!1===e?"":Re.get(this,"__className__")||""))})},hasClass:function(e){var t,n,r=0;for(t=" "+e+" ";n=this[r++];)if(1===n.nodeType&&(" "+q(z(n))+" ").indexOf(t)>-1)return!0;return!1}});var bt=/\r/g;pe.fn.extend({val:function(e){var t,n,r,o=this[0];return arguments.length?(r=pe.isFunction(e),this.each(function(n){var o;1===this.nodeType&&(null==(o=r?e.call(this,n,pe(this).val()):e)?o="":"number"==typeof o?o+="":pe.isArray(o)&&(o=pe.map(o,function(e){return null==e?"":e+""})),(t=pe.valHooks[this.type]||pe.valHooks[this.nodeName.toLowerCase()])&&"set"in t&&void 0!==t.set(this,o,"value")||(this.value=o))})):o?(t=pe.valHooks[o.type]||pe.valHooks[o.nodeName.toLowerCase()])&&"get"in t&&void 0!==(n=t.get(o,"value"))?n:"string"==typeof(n=o.value)?n.replace(bt,""):null==n?"":n:void 0}}),pe.extend({valHooks:{option:{get:function(e){var t=pe.find.attr(e,"value");return null!=t?t:q(pe.text(e))}},select:{get:function(e){var t,n,r,o=e.options,a=e.selectedIndex,i="select-one"===e.type,s=i?null:[],l=i?a+1:o.length;for(r=a<0?l:i?a:0;r-1)&&(n=!0);return n||(e.selectedIndex=-1),a}}}}),pe.each(["radio","checkbox"],function(){pe.valHooks[this]={set:function(e,t){if(pe.isArray(t))return e.checked=pe.inArray(pe(e).val(),t)>-1}},de.checkOn||(pe.valHooks[this].get=function(e){return null===e.getAttribute("value")?"on":e.value})});var xt=/^(?:focusinfocus|focusoutblur)$/;pe.extend(pe.event,{trigger:function(t,n,r,o){var a,i,s,l,c,u,d,f=[r||ee],p=le.call(t,"type")?t.type:t,h=le.call(t,"namespace")?t.namespace.split("."):[];if(i=s=r=r||ee,3!==r.nodeType&&8!==r.nodeType&&!xt.test(p+pe.event.triggered)&&(p.indexOf(".")>-1&&(h=p.split("."),p=h.shift(),h.sort()),c=p.indexOf(":")<0&&"on"+p,t=t[pe.expando]?t:new pe.Event(p,"object"==typeof t&&t),t.isTrigger=o?2:3,t.namespace=h.join("."),t.rnamespace=t.namespace?new RegExp("(^|\\.)"+h.join("\\.(?:.*\\.|)")+"(\\.|$)"):null,t.result=void 0,t.target||(t.target=r),n=null==n?[t]:pe.makeArray(n,[t]),d=pe.event.special[p]||{},o||!d.trigger||!1!==d.trigger.apply(r,n))){if(!o&&!d.noBubble&&!pe.isWindow(r)){for(l=d.delegateType||p,xt.test(l+p)||(i=i.parentNode);i;i=i.parentNode)f.push(i),s=i;s===(r.ownerDocument||ee)&&f.push(s.defaultView||s.parentWindow||e)}for(a=0;(i=f[a++])&&!t.isPropagationStopped();)t.type=a>1?l:d.bindType||p,(u=(Re.get(i,"events")||{})[t.type]&&Re.get(i,"handle"))&&u.apply(i,n),(u=c&&i[c])&&u.apply&&Ee(i)&&(t.result=u.apply(i,n),!1===t.result&&t.preventDefault());return t.type=p,o||t.isDefaultPrevented()||d._default&&!1!==d._default.apply(f.pop(),n)||!Ee(r)||c&&pe.isFunction(r[p])&&!pe.isWindow(r)&&((s=r[c])&&(r[c]=null),pe.event.triggered=p,r[p](),pe.event.triggered=void 0,s&&(r[c]=s)),t.result}},simulate:function(e,t,n){var r=pe.extend(new pe.Event,n,{type:e,isSimulated:!0});pe.event.trigger(r,null,t)}}),pe.fn.extend({trigger:function(e,t){return this.each(function(){pe.event.trigger(e,t,this)})},triggerHandler:function(e,t){var n=this[0];if(n)return pe.event.trigger(e,t,n,!0)}}),pe.each("blur focus focusin focusout resize scroll click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup contextmenu".split(" "),function(e,t){pe.fn[t]=function(e,n){return arguments.length>0?this.on(t,null,e,n):this.trigger(t)}}),pe.fn.extend({hover:function(e,t){return this.mouseenter(e).mouseleave(t||e)}}),de.focusin="onfocusin"in e,de.focusin||pe.each({focus:"focusin",blur:"focusout"},function(e,t){var n=function(e){pe.event.simulate(t,e.target,pe.event.fix(e))};pe.event.special[t]={setup:function(){var r=this.ownerDocument||this,o=Re.access(r,t);o||r.addEventListener(e,n,!0),Re.access(r,t,(o||0)+1)},teardown:function(){var r=this.ownerDocument||this,o=Re.access(r,t)-1;o?Re.access(r,t,o):(r.removeEventListener(e,n,!0),Re.remove(r,t))}}});var wt=e.location,Ct=pe.now(),St=/\?/;pe.parseXML=function(t){var n;if(!t||"string"!=typeof t)return null;try{n=(new e.DOMParser).parseFromString(t,"text/xml")}catch(e){n=void 0}return n&&!n.getElementsByTagName("parsererror").length||pe.error("Invalid XML: "+t),n};var Tt=/\[\]$/,Dt=/\r?\n/g,_t=/^(?:submit|button|image|reset|file)$/i,It=/^(?:input|select|textarea|keygen)/i;pe.param=function(e,t){var n,r=[],o=function(e,t){var n=pe.isFunction(t)?t():t;r[r.length]=encodeURIComponent(e)+"="+encodeURIComponent(null==n?"":n)};if(pe.isArray(e)||e.jquery&&!pe.isPlainObject(e))pe.each(e,function(){o(this.name,this.value)});else for(n in e)V(n,e[n],t,o);return r.join("&")},pe.fn.extend({serialize:function(){return pe.param(this.serializeArray())},serializeArray:function(){return this.map(function(){var e=pe.prop(this,"elements");return e?pe.makeArray(e):this}).filter(function(){var e=this.type;return this.name&&!pe(this).is(":disabled")&&It.test(this.nodeName)&&!_t.test(e)&&(this.checked||!We.test(e))}).map(function(e,t){var n=pe(this).val();return null==n?null:pe.isArray(n)?pe.map(n,function(e){return{name:t.name,value:e.replace(Dt,"\r\n")}}):{name:t.name,value:n.replace(Dt,"\r\n")}}).get()}});var kt=/%20/g,At=/#.*$/,Ot=/([?&])_=[^&]*/,Ft=/^(.*?):[ \t]*([^\r\n]*)$/gm,Et=/^(?:about|app|app-storage|.+-extension|file|res|widget):$/,Rt=/^(?:GET|HEAD)$/,Pt=/^\/\//,Nt={},Lt={},jt="*/".concat("*"),Bt=ee.createElement("a");Bt.href=wt.href,pe.extend({active:0,lastModified:{},etag:{},ajaxSettings:{url:wt.href,type:"GET",isLocal:Et.test(wt.protocol),global:!0,processData:!0,async:!0,contentType:"application/x-www-form-urlencoded; charset=UTF-8",accepts:{"*":jt,text:"text/plain",html:"text/html",xml:"application/xml, text/xml",json:"application/json, text/javascript"},contents:{xml:/\bxml\b/,html:/\bhtml/,json:/\bjson\b/},responseFields:{xml:"responseXML",text:"responseText",json:"responseJSON"},converters:{"* text":String,"text html":!0,"text json":JSON.parse,"text xml":pe.parseXML},flatOptions:{url:!0,context:!0}},ajaxSetup:function(e,t){return t?K(K(e,pe.ajaxSettings),t):K(pe.ajaxSettings,e)},ajaxPrefilter:X(Nt),ajaxTransport:X(Lt),ajax:function(t,n){function r(t,n,r,s){var c,f,p,x,w,C=n;u||(u=!0,l&&e.clearTimeout(l),o=void 0,i=s||"",S.readyState=t>0?4:0,c=t>=200&&t<300||304===t,r&&(x=G(h,S,r)),x=Q(h,x,S,c),c?(h.ifModified&&((w=S.getResponseHeader("Last-Modified"))&&(pe.lastModified[a]=w),(w=S.getResponseHeader("etag"))&&(pe.etag[a]=w)),204===t||"HEAD"===h.type?C="nocontent":304===t?C="notmodified":(C=x.state,f=x.data,p=x.error,c=!p)):(p=C,!t&&C||(C="error",t<0&&(t=0))),S.status=t,S.statusText=(n||C)+"",c?v.resolveWith(m,[f,C,S]):v.rejectWith(m,[S,C,p]),S.statusCode(b),b=void 0,d&&g.trigger(c?"ajaxSuccess":"ajaxError",[S,h,c?f:p]),y.fireWith(m,[S,C]),d&&(g.trigger("ajaxComplete",[S,h]),--pe.active||pe.event.trigger("ajaxStop")))}"object"==typeof t&&(n=t,t=void 0),n=n||{};var o,a,i,s,l,c,u,d,f,p,h=pe.ajaxSetup({},n),m=h.context||h,g=h.context&&(m.nodeType||m.jquery)?pe(m):pe.event,v=pe.Deferred(),y=pe.Callbacks("once memory"),b=h.statusCode||{},x={},w={},C="canceled",S={readyState:0,getResponseHeader:function(e){var t;if(u){if(!s)for(s={};t=Ft.exec(i);)s[t[1].toLowerCase()]=t[2];t=s[e.toLowerCase()]}return null==t?null:t},getAllResponseHeaders:function(){return u?i:null},setRequestHeader:function(e,t){return null==u&&(e=w[e.toLowerCase()]=w[e.toLowerCase()]||e,x[e]=t),this},overrideMimeType:function(e){return null==u&&(h.mimeType=e),this},statusCode:function(e){var t;if(e)if(u)S.always(e[S.status]);else for(t in e)b[t]=[b[t],e[t]];return this},abort:function(e){var t=e||C;return o&&o.abort(t),r(0,t),this}};if(v.promise(S),h.url=((t||h.url||wt.href)+"").replace(Pt,wt.protocol+"//"),h.type=n.method||n.type||h.method||h.type,h.dataTypes=(h.dataType||"*").toLowerCase().match(ke)||[""],null==h.crossDomain){c=ee.createElement("a");try{c.href=h.url,c.href=c.href,h.crossDomain=Bt.protocol+"//"+Bt.host!=c.protocol+"//"+c.host}catch(e){h.crossDomain=!0}}if(h.data&&h.processData&&"string"!=typeof h.data&&(h.data=pe.param(h.data,h.traditional)),Y(Nt,h,n,S),u)return S;(d=pe.event&&h.global)&&0==pe.active++&&pe.event.trigger("ajaxStart"),h.type=h.type.toUpperCase(),h.hasContent=!Rt.test(h.type),a=h.url.replace(At,""),h.hasContent?h.data&&h.processData&&0===(h.contentType||"").indexOf("application/x-www-form-urlencoded")&&(h.data=h.data.replace(kt,"+")):(p=h.url.slice(a.length),h.data&&(a+=(St.test(a)?"&":"?")+h.data,delete h.data),!1===h.cache&&(a=a.replace(Ot,"$1"),p=(St.test(a)?"&":"?")+"_="+Ct+++p),h.url=a+p),h.ifModified&&(pe.lastModified[a]&&S.setRequestHeader("If-Modified-Since",pe.lastModified[a]),pe.etag[a]&&S.setRequestHeader("If-None-Match",pe.etag[a])),(h.data&&h.hasContent&&!1!==h.contentType||n.contentType)&&S.setRequestHeader("Content-Type",h.contentType),S.setRequestHeader("Accept",h.dataTypes[0]&&h.accepts[h.dataTypes[0]]?h.accepts[h.dataTypes[0]]+("*"!==h.dataTypes[0]?", "+jt+"; q=0.01":""):h.accepts["*"]);for(f in h.headers)S.setRequestHeader(f,h.headers[f]);if(h.beforeSend&&(!1===h.beforeSend.call(m,S,h)||u))return S.abort();if(C="abort",y.add(h.complete),S.done(h.success),S.fail(h.error),o=Y(Lt,h,n,S)){if(S.readyState=1,d&&g.trigger("ajaxSend",[S,h]),u)return S;h.async&&h.timeout>0&&(l=e.setTimeout(function(){S.abort("timeout")},h.timeout));try{u=!1,o.send(x,r)}catch(e){if(u)throw e;r(-1,e)}}else r(-1,"No Transport");return S},getJSON:function(e,t,n){return pe.get(e,t,n,"json")},getScript:function(e,t){return pe.get(e,void 0,t,"script")}}),pe.each(["get","post"],function(e,t){pe[t]=function(e,n,r,o){return pe.isFunction(n)&&(o=o||r,r=n,n=void 0),pe.ajax(pe.extend({url:e,type:t,dataType:o,data:n,success:r},pe.isPlainObject(e)&&e))}}),pe._evalUrl=function(e){return pe.ajax({url:e,type:"GET",dataType:"script",cache:!0,async:!1,global:!1,throws:!0})},pe.fn.extend({wrapAll:function(e){var t;return this[0]&&(pe.isFunction(e)&&(e=e.call(this[0])),t=pe(e,this[0].ownerDocument).eq(0).clone(!0),this[0].parentNode&&t.insertBefore(this[0]),t.map(function(){for(var e=this;e.firstElementChild;)e=e.firstElementChild;return e}).append(this)),this},wrapInner:function(e){return pe.isFunction(e)?this.each(function(t){pe(this).wrapInner(e.call(this,t))}):this.each(function(){var t=pe(this),n=t.contents();n.length?n.wrapAll(e):t.append(e)})},wrap:function(e){var t=pe.isFunction(e);return this.each(function(n){pe(this).wrapAll(t?e.call(this,n):e)})},unwrap:function(e){return this.parent(e).not("body").each(function(){pe(this).replaceWith(this.childNodes)}),this}}),pe.expr.pseudos.hidden=function(e){return!pe.expr.pseudos.visible(e)},pe.expr.pseudos.visible=function(e){return!!(e.offsetWidth||e.offsetHeight||e.getClientRects().length)},pe.ajaxSettings.xhr=function(){try{return new e.XMLHttpRequest}catch(e){}};var Mt={0:200,1223:204},Ht=pe.ajaxSettings.xhr();de.cors=!!Ht&&"withCredentials"in Ht,de.ajax=Ht=!!Ht,pe.ajaxTransport(function(t){var n,r;if(de.cors||Ht&&!t.crossDomain)return{send:function(o,a){var i,s=t.xhr();if(s.open(t.type,t.url,t.async,t.username,t.password),t.xhrFields)for(i in t.xhrFields)s[i]=t.xhrFields[i];t.mimeType&&s.overrideMimeType&&s.overrideMimeType(t.mimeType),t.crossDomain||o["X-Requested-With"]||(o["X-Requested-With"]="XMLHttpRequest");for(i in o)s.setRequestHeader(i,o[i]);n=function(e){return function(){n&&(n=r=s.onload=s.onerror=s.onabort=s.onreadystatechange=null,"abort"===e?s.abort():"error"===e?"number"!=typeof s.status?a(0,"error"):a(s.status,s.statusText):a(Mt[s.status]||s.status,s.statusText,"text"!==(s.responseType||"text")||"string"!=typeof s.responseText?{binary:s.response}:{text:s.responseText},s.getAllResponseHeaders()))}},s.onload=n(),r=s.onerror=n("error"),void 0!==s.onabort?s.onabort=r:s.onreadystatechange=function(){4===s.readyState&&e.setTimeout(function(){n&&r()})},n=n("abort");try{s.send(t.hasContent&&t.data||null)}catch(e){if(n)throw e}},abort:function(){n&&n()}}}),pe.ajaxPrefilter(function(e){e.crossDomain&&(e.contents.script=!1)}),pe.ajaxSetup({accepts:{script:"text/javascript, application/javascript, application/ecmascript, application/x-ecmascript"},contents:{script:/\b(?:java|ecma)script\b/},converters:{"text script":function(e){return pe.globalEval(e),e}}}),pe.ajaxPrefilter("script",function(e){void 0===e.cache&&(e.cache=!1),e.crossDomain&&(e.type="GET")}),pe.ajaxTransport("script",function(e){if(e.crossDomain){var t,n;return{send:function(r,o){t=pe("